From db5f655cf0531483dc7e60c39562c533a998352a Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:19:46 -0700 Subject: [PATCH 1/5] Add VS Code CLI Create Workspace parity Add latest-stable @vscode/test-cli coverage for activation, command registration, Create Workspace parity labels, generated workspace lifecycle smoke, CI wiring, and documentation while preserving ExTester coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/vscode-e2e.yml | 100 +- apps/vs-code-designer/.vscode-test.mjs | 172 +- apps/vs-code-designer/package.json | 17 +- .../scripts/open-e2e-cli-vscode.js | 178 + apps/vs-code-designer/scripts/run-e2e-cli.js | 169 + apps/vs-code-designer/src/main.ts | 32 +- apps/vs-code-designer/src/test/e2e/README.md | 165 +- .../src/test/e2e/cdpClient.ts | 472 +++ .../src/test/e2e/commands.test.ts | 69 +- .../src/test/e2e/createWorkspace.test.ts | 3041 +++++++++++++++++ .../src/test/e2e/dialogGuard.ts | 83 + .../src/test/e2e/extension.test.ts | 121 +- .../src/test/e2e/screenshot.ts | 70 + .../src/test/e2e/visibleDelay.ts | 7 + .../src/test/e2e/workspaceLifecycle.test.ts | 2657 ++++++++++++++ package.json | 19 +- 16 files changed, 7259 insertions(+), 113 deletions(-) create mode 100644 apps/vs-code-designer/scripts/open-e2e-cli-vscode.js create mode 100644 apps/vs-code-designer/scripts/run-e2e-cli.js create mode 100644 apps/vs-code-designer/src/test/e2e/cdpClient.ts create mode 100644 apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts create mode 100644 apps/vs-code-designer/src/test/e2e/dialogGuard.ts create mode 100644 apps/vs-code-designer/src/test/e2e/screenshot.ts create mode 100644 apps/vs-code-designer/src/test/e2e/visibleDelay.ts create mode 100644 apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts diff --git a/.github/workflows/vscode-e2e.yml b/.github/workflows/vscode-e2e.yml index 09078d19028..b9b84ee2fe5 100644 --- a/.github/workflows/vscode-e2e.yml +++ b/.github/workflows/vscode-e2e.yml @@ -138,10 +138,14 @@ jobs: - name: Build extension run: pnpm turbo run build:extension --cache-dir=.turbo - - name: Compile E2E tests + - name: Compile ExTester E2E tests working-directory: apps/vs-code-designer run: npx tsup --config tsup.e2e.test.config.ts + - name: Compile @vscode/test-cli E2E tests + working-directory: apps/vs-code-designer + run: pnpm run test:e2e-cli:compile + - name: Tar build artifacts # Tar preserves symlinks/permissions and dramatically speeds upload # vs uploading thousands of small files in node_modules-adjacent dirs. @@ -678,6 +682,96 @@ jobs: if-no-files-found: ignore retention-days: 30 + # --------------------------------------------------------------------------- + # @vscode/test-cli Create Workspace parity visibility. + # + # This is additive to the ExTester matrix above: ExTester remains the owner for + # full designer/wizard DOM coverage and the VS Code 1.108.0 compatibility pin + # below remains unchanged. These matrix legs exercise the official + # @vscode/test-cli baseline against focused Create Workspace labels so PR CI + # can see latest-stable VS Code extension-host parity without serializing the + # whole suite into one hard-to-diagnose run. + # + # Intentionally excluded from PR CI for now: workspaceLifecycle. That target + # creates workspaces, reopens generated app folders, opens designer webviews, + # builds .NET projects, starts Azurite/runtime, runs triggers, and validates + # Overview results. Keep it as an explicit/manual script until its runtime cost + # and flake profile justify becoming a PR gate. + vscode-e2e-cli-create-workspace: + name: vscode-e2e-cli-create-workspace (${{ matrix.label }}) + needs: setup-extension-build + timeout-minutes: 25 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - label: createWorkspaceBehavior + - label: createWorkspaceCoreMatrix + - label: createWorkspacePreviewMatrix + - label: createWorkspaceCodeful + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 20.x + package-manager-cache: false + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + run_install: | + - recursive: true + args: [--frozen-lockfile, --strict-peer-dependencies] + + - name: Download extension build artifact + uses: actions/download-artifact@v7 + with: + name: extension-build-${{ github.sha }} + path: . + + - name: Extract build artifacts + run: tar -xzf extension-build.tar.gz + + - name: Install system dependencies for virtual display + run: | + sudo apt-get update + sudo apt-get install -y xvfb libgbm-dev libgtk-3-0 libnss3 libasound2t64 libxss1 libatk-bridge2.0-0 libatk1.0-0 + + - name: Run @vscode/test-cli Create Workspace label (${{ matrix.label }}) + working-directory: apps/vs-code-designer + run: | + export PATH="$(dirname $(which node)):/usr/local/bin:/usr/bin:/bin:$PATH" + echo "PATH=$PATH" + xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \ + pnpm exec node scripts/run-e2e-cli.js --label "${{ matrix.label }}" + env: + NODE_OPTIONS: --max-old-space-size=4096 + TEMP: ${{ runner.temp }} + TMPDIR: ${{ runner.temp }} + + - name: Upload CLI screenshots (always) + uses: actions/upload-artifact@v6 + if: always() + with: + name: vscode-e2e-cli-screenshots-${{ matrix.label }} + path: apps/vs-code-designer/.vscode-test/screenshots/cli/ + if-no-files-found: ignore + retention-days: 30 + # --------------------------------------------------------------------------- # Stage C: codeful debug (F5) as a first-class CI shard on BOTH OSes. # @@ -1855,7 +1949,7 @@ jobs: # ("vscode-e2e-summary") regardless of how many scenarios we add later. vscode-e2e-summary: name: vscode-e2e-summary - needs: [setup-extension-build, setup-fixtures, vscode-e2e, vscode-e2e-windows, vscode-e2e-codeful-ubuntu, vscode-e2e-codeful-windows, vscode-e2e-azurite, vscode-e2e-azurite-windows, vscode-e2e-funcselfheal-windows, vscode-e2e-compat, setup-runtime-deps-windows] + needs: [setup-extension-build, setup-fixtures, vscode-e2e, vscode-e2e-windows, vscode-e2e-cli-create-workspace, vscode-e2e-codeful-ubuntu, vscode-e2e-codeful-windows, vscode-e2e-azurite, vscode-e2e-azurite-windows, vscode-e2e-funcselfheal-windows, vscode-e2e-compat, setup-runtime-deps-windows] if: always() runs-on: ubuntu-latest steps: @@ -1870,6 +1964,7 @@ jobs: echo "setup-fixtures: ${{ needs.setup-fixtures.result }}" echo "vscode-e2e (matrix): ${{ needs.vscode-e2e.result }}" echo "vscode-e2e-windows: ${{ needs.vscode-e2e-windows.result }}" + echo "vscode-e2e-cli-create-workspace: ${{ needs.vscode-e2e-cli-create-workspace.result }}" echo "vscode-e2e-codeful-ubuntu: ${{ needs.vscode-e2e-codeful-ubuntu.result }}" echo "vscode-e2e-codeful-windows: ${{ needs.vscode-e2e-codeful-windows.result }}" echo "vscode-e2e-azurite: ${{ needs.vscode-e2e-azurite.result }}" @@ -1881,6 +1976,7 @@ jobs: [ "${{ needs.setup-fixtures.result }}" != "success" ] || \ [ "${{ needs.vscode-e2e.result }}" != "success" ] || \ [ "${{ needs.vscode-e2e-windows.result }}" != "success" ] || \ + [ "${{ needs.vscode-e2e-cli-create-workspace.result }}" != "success" ] || \ [ "${{ needs.vscode-e2e-codeful-ubuntu.result }}" != "success" ] || \ [ "${{ needs.vscode-e2e-codeful-windows.result }}" != "success" ] || \ [ "${{ needs.vscode-e2e-azurite.result }}" != "success" ] || \ diff --git a/apps/vs-code-designer/.vscode-test.mjs b/apps/vs-code-designer/.vscode-test.mjs index 8a920d29ba1..0f90b2298b7 100644 --- a/apps/vs-code-designer/.vscode-test.mjs +++ b/apps/vs-code-designer/.vscode-test.mjs @@ -1,41 +1,163 @@ import { defineConfig } from '@vscode/test-cli'; +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import { tmpdir } from 'os'; import * as path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const checkoutHash = createHash('sha1').update(__dirname).digest('hex').slice(0, 8); +const remoteDebuggingPort = + process.env.LA_E2E_CLI_REMOTE_DEBUGGING_PORT ?? String(9200 + (Number.parseInt(checkoutHash.slice(0, 4), 16) % 500)); +const userDataSuffix = process.env.LA_E2E_CLI_USER_DATA_SUFFIX; +const userDataDir = + process.platform === 'win32' + ? path.join(__dirname, '.vscode-test', userDataSuffix ? `user-data-${userDataSuffix}` : 'user-data') + : path.join(tmpdir(), `la-vscode-test-${checkoutHash}${userDataSuffix ? `-${userDataSuffix}` : ''}`); +const extensionDevelopmentPath = path.join(__dirname, 'dist'); +const startupResource = process.env.LA_E2E_CLI_STARTUP_RESOURCE; +const includeWorkspaceLifecycle = process.env.LA_E2E_CLI_INCLUDE_WORKSPACE_LIFECYCLE === '1' || process.argv.includes('workspaceLifecycle'); +const dependencyRoot = path.join(process.env.USERPROFILE ?? process.env.HOME ?? '', '.azurelogicapps', 'dependencies'); -export default defineConfig([ +prepareUserSettings(userDataDir); + +const baseConfig = { + version: 'stable', + extensionDevelopmentPath, + ...(startupResource ? { workspaceFolder: startupResource } : {}), + env: { + VSCODE_RUNNING_TESTS: '1', + DEBUGTELEMETRY: '1', + LA_E2E_CLI_VISIBLE_DELAY_MS: process.env.LA_E2E_CLI_VISIBLE_DELAY_MS ?? '0', + LA_E2E_CLI_REMOTE_DEBUGGING_PORT: remoteDebuggingPort, + LA_E2E_CLI_USER_DATA_DIR: userDataDir, + }, + launchArgs: [ + '--user-data-dir', + userDataDir, + '--disable-gpu', + '--disable-updates', + '--disable-restore-windows', + '--disable-workspace-trust', + '--skip-welcome', + '--skip-release-notes', + '--locale=en-US', + `--remote-debugging-port=${remoteDebuggingPort}`, + '--remote-debugging-address=127.0.0.1', + ], +}; + +const configs = [ { label: 'unitTests', - files: 'out/test/e2e/**/*.test.js', - version: 'stable', - workspaceFolder: path.join(__dirname, 'e2e', 'test-workspace'), + ...baseConfig, + files: ['out/test/e2e/extension.test.js', 'out/test/e2e/commands.test.js'], mocha: { ui: 'tdd', - timeout: 60000, + timeout: 120000, }, - launchArgs: [ - '--disable-extensions', // Disable other extensions to speed up tests - '--user-data-dir', path.join(__dirname, '.vscode-test', 'user-data'), - '--extensions-dir', path.join(__dirname, '.vscode-test', 'extensions'), - '--disable-gpu', // Helps with stability in CI - '--disable-updates', // Prevent update checks - ], }, { - label: 'integrationTests', - files: 'out/test/e2e/integration/**/*.test.js', - version: 'stable', - workspaceFolder: path.join(__dirname, 'e2e', 'test-workspace'), + label: 'createWorkspace', + ...createWorkspaceConfig('default', 600000), + }, + { + label: 'createWorkspaceBehavior', + ...createWorkspaceConfig('behavior', 240000), + }, + { + label: 'createWorkspaceCoreMatrix', + ...createWorkspaceConfig('core-matrix', 900000), + }, + { + label: 'createWorkspacePreviewMatrix', + ...createWorkspaceConfig('preview-matrix', 900000), + }, + { + label: 'createWorkspaceCodeful', + ...createWorkspaceConfig('codeful', 600000), + }, + { + label: 'createWorkspaceFixturesManifest', + ...createWorkspaceConfig('fixtures-manifest', 700000, { + LA_E2E_CLI_CREATE_WORKSPACE_FIXTURE_MANIFEST: path.join(tmpdir(), 'la-e2e-test', 'created-workspaces.json'), + }), + }, +]; + +if (includeWorkspaceLifecycle) { + configs.push({ + label: 'workspaceLifecycle', + ...baseConfig, + files: ['out/test/e2e/workspaceLifecycle.test.js'], mocha: { ui: 'tdd', - timeout: 120000, + timeout: 1200000, + }, + }); +} + +export default defineConfig(configs); + +function createWorkspaceConfig(group, timeout, extraEnv = {}) { + return { + ...baseConfig, + env: { + ...baseConfig.env, + LA_E2E_CLI_CREATE_WORKSPACE_GROUP: group, + ...extraEnv, }, - launchArgs: [ - '--user-data-dir', path.join(__dirname, '.vscode-test', 'user-data'), - '--extensions-dir', path.join(__dirname, '.vscode-test', 'extensions'), - '--disable-gpu', - '--disable-updates', - ], - }, -]); + files: ['out/test/e2e/createWorkspace.test.js'], + mocha: { + ui: 'tdd', + timeout, + }, + }; +} + +function prepareUserSettings(userDataPath) { + const userSettingsPath = path.join(userDataPath, 'User', 'settings.json'); + fs.mkdirSync(path.dirname(userSettingsPath), { recursive: true }); + fs.writeFileSync( + userSettingsPath, + `${JSON.stringify( + { + 'azureLogicAppsStandard.autoRuntimeDependenciesValidationAndInstallation': false, + 'azureLogicAppsStandard.autoRuntimeDependenciesPath': dependencyRoot, + 'azureLogicAppsStandard.funcCoreToolsBinaryPath': path.join( + dependencyRoot, + 'FuncCoreTools', + process.platform === 'win32' ? 'func.exe' : 'func' + ), + 'azureLogicAppsStandard.dotnetBinaryPath': findExecutable('dotnet') ?? 'dotnet', + 'azureLogicAppsStandard.nodeJsBinaryPath': findExecutable('node') ?? 'node', + 'azureLogicAppsStandard.autoStartDesignTime': false, + 'azureLogicAppsStandard.autoStartAzurite': true, + 'azureLogicAppsStandard.azuriteLocationSetting': path.join(userDataPath, 'azurite'), + 'azureLogicAppsStandard.silentAuth': true, + 'azurite.location': path.join(userDataPath, 'azurite'), + 'azureLogicAppsStandard.parameterizeConnectionsInProjectLoad': false, + 'azureLogicAppsStandard.enableManagedIdentityAuth': false, + 'telemetry.telemetryLevel': 'off', + 'update.mode': 'none', + }, + null, + 2 + )}\n` + ); +} + +function findExecutable(command) { + const pathEnv = process.env.PATH ?? ''; + const extensions = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']; + for (const directory of pathEnv.split(path.delimiter)) { + for (const extension of extensions) { + const candidate = path.join(directory, `${command}${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + + return undefined; +} diff --git a/apps/vs-code-designer/package.json b/apps/vs-code-designer/package.json index f5739494f3b..ece6a58490e 100644 --- a/apps/vs-code-designer/package.json +++ b/apps/vs-code-designer/package.json @@ -68,9 +68,22 @@ "vscode:designer:pack:step2": "cd ./dist && vsce package", "lint": "eslint . --report-unused-disable-directives --max-warnings 0", "test:extension-unit": "vitest run --retry=3", - "test:e2e-cli": "vscode-test", + "test:e2e-cli": "node scripts/run-e2e-cli.js", + "test:e2e-cli:smoke": "pnpm run test:e2e-cli --label unitTests", + "test:e2e-cli:create-workspace": "pnpm run test:e2e-cli --label createWorkspace", + "test:e2e-cli:create-workspace:behavior": "pnpm run test:e2e-cli --label createWorkspaceBehavior", + "test:e2e-cli:create-workspace:core-matrix": "pnpm run test:e2e-cli --label createWorkspaceCoreMatrix", + "test:e2e-cli:create-workspace:preview-matrix": "pnpm run test:e2e-cli --label createWorkspacePreviewMatrix", + "test:e2e-cli:create-workspace:codeful": "pnpm run test:e2e-cli --label createWorkspaceCodeful", + "test:e2e-cli:create-workspace:fixtures": "pnpm run test:e2e-cli --label createWorkspaceFixturesManifest", + "test:e2e-cli:create-workspace:full": "pnpm run test:e2e-cli:create-workspace:behavior && pnpm run test:e2e-cli:create-workspace:core-matrix && pnpm run test:e2e-cli:create-workspace:preview-matrix && pnpm run test:e2e-cli:create-workspace:codeful", + "test:e2e-cli:workspace-lifecycle": "pnpm run test:e2e-cli:build && node scripts/run-e2e-cli.js --workspace-lifecycle", + "test:e2e-cli:show-create-workspace": "pnpm run test:e2e-cli --label createWorkspace --visible-delay-ms 60000", + "test:e2e-cli:build": "pnpm run build:e2e-cli:extension && pnpm --dir ../vs-code-react run build:extension && pnpm run test:e2e-cli:compile", + "test:e2e-cli:open": "pnpm run build:e2e-cli:extension && pnpm --dir ../vs-code-react run build:extension && node scripts/open-e2e-cli-vscode.js", + "build:e2e-cli:extension": "tsup src/main.ts --no-config --format cjs --target es2020 --sourcemap --clean --external vscode --keep-names --out-dir dist && pnpm run copyFiles && node scripts/install-dist-dependencies.js --ignore-scripts", "test:e2e-cli:compile": "tsc -p ./tsconfig.e2e.json", - "pretest:e2e-cli": "pnpm run test:e2e-cli:compile", + "pretest:e2e-cli": "pnpm run test:e2e-cli:build", "vscode:designer:e2e:ui": "cd ../.. && pnpm run build:extension && cd apps/vs-code-designer && pnpm run build:ui && node out/test/run-e2e.js", "vscode:designer:e2e:headless": "cd ../.. && pnpm run build:extension && cd apps/vs-code-designer && pnpm run build:ui && node out/test/run-e2e.js" } diff --git a/apps/vs-code-designer/scripts/open-e2e-cli-vscode.js b/apps/vs-code-designer/scripts/open-e2e-cli-vscode.js new file mode 100644 index 00000000000..8fd549b1f30 --- /dev/null +++ b/apps/vs-code-designer/scripts/open-e2e-cli-vscode.js @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* global __dirname, console, process, require */ +const { spawn, spawnSync } = require('child_process'); +const { createHash } = require('crypto'); +const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs'); +const { tmpdir } = require('os'); +const { join } = require('path'); +const { downloadAndUnzipVSCode, resolveCliPathFromVSCodeExecutablePath } = require('@vscode/test-electron'); + +const extensionRoot = join(__dirname, '..'); +const checkoutHash = createHash('sha1').update(extensionRoot).digest('hex').slice(0, 8); +const distPath = join(extensionRoot, 'dist'); +const visibleStateRoot = join(extensionRoot, '.vscode-test', 'visible'); +const visibleRunRoot = join(visibleStateRoot, `run-${process.pid}-${Date.now()}`); +const userDataDir = + process.platform === 'win32' ? join(visibleRunRoot, 'user-data') : join(tmpdir(), `la-vscode-visible-${checkoutHash}-${process.pid}`); +const extensionsDir = join(visibleStateRoot, 'extensions'); +const activationNotesPath = join(visibleRunRoot, 'activation-check.md'); + +async function main() { + if (!existsSync(distPath)) { + console.error(`Extension dist folder does not exist: ${distPath}`); + process.exit(1); + } + + const extensionPackageJson = readExtensionPackageJson(); + + prepareUserSettings(userDataDir); + writeActivationNotes(extensionPackageJson); + + console.log('Downloading or reusing latest stable VS Code for the visible activation check...'); + const vscodeExecutablePath = await downloadAndUnzipVSCode('stable'); + const cliPath = resolveCliPathFromVSCodeExecutablePath(vscodeExecutablePath); + const version = runCodeCli(cliPath, ['--version'], { allowFailure: false }).stdout.split(/\r?\n/)[0]?.trim(); + + installExtensionDependencies(cliPath, extensionPackageJson); + logInstalledExtensions(cliPath); + + const launchArgs = [ + `--user-data-dir=${userDataDir}`, + `--extensions-dir=${extensionsDir}`, + `--extensionDevelopmentPath=${distPath}`, + '--disable-workspace-trust', + '--disable-updates', + '--skip-welcome', + '--skip-release-notes', + '--locale=en-US', + '--new-window', + activationNotesPath, + ]; + + const child = spawn(cliPath, launchArgs, { + detached: true, + env: { + ...process.env, + VSCODE_RUNNING_TESTS: '1', + DEBUGTELEMETRY: '1', + }, + shell: process.platform === 'win32', + stdio: 'ignore', + }); + child.unref(); + + console.log(`Launched VS Code ${version || 'stable'} from: ${vscodeExecutablePath}`); + console.log(`User data dir: ${userDataDir}`); + console.log(`Extension development path: ${distPath}`); + console.log('Workspace: none (empty VS Code window)'); + console.log('In the VS Code window, run "Developer: Show Running Extensions" and look for "Azure Logic Apps (Standard)".'); + console.log('Run "Extensions: Show Installed Extensions" to confirm the dependency extensions are installed in the test profile.'); +} + +function installExtensionDependencies(cliPath, packageJson) { + const extensionDependencies = packageJson.extensionDependencies ?? []; + + if (!extensionDependencies.length) { + return; + } + + mkdirSync(extensionsDir, { recursive: true }); + for (const extensionId of extensionDependencies) { + console.log(`Installing extension dependency into visible test profile: ${extensionId}`); + runCodeCli(cliPath, [`--extensions-dir=${extensionsDir}`, '--install-extension', extensionId, '--force'], { allowFailure: false }); + } +} + +function logInstalledExtensions(cliPath) { + console.log('Installed extensions in visible test profile:'); + runCodeCli(cliPath, [`--extensions-dir=${extensionsDir}`, '--list-extensions', '--show-versions'], { allowFailure: false }); +} + +function prepareUserSettings(userDataPath) { + const userSettingsPath = join(userDataPath, 'User', 'settings.json'); + mkdirSync(join(userDataPath, 'User'), { recursive: true }); + writeFileSync( + userSettingsPath, + `${JSON.stringify( + { + 'azureLogicAppsStandard.autoRuntimeDependenciesValidationAndInstallation': false, + 'azureLogicAppsStandard.autoStartDesignTime': false, + 'azureLogicAppsStandard.parameterizeConnectionsInProjectLoad': false, + 'azureLogicAppsStandard.enableManagedIdentityAuth': false, + 'telemetry.telemetryLevel': 'off', + 'update.mode': 'none', + }, + null, + 2 + )}\n` + ); +} + +function writeActivationNotes(packageJson) { + mkdirSync(visibleStateRoot, { recursive: true }); + const extensionDependencies = packageJson.extensionDependencies ?? []; + writeFileSync( + activationNotesPath, + [ + '# Logic Apps @vscode/test-cli activation check', + '', + 'This window was launched by `pnpm run test:e2e-cli:open` against latest stable VS Code.', + '', + 'To confirm activation visually:', + '', + '1. Open the Command Palette.', + '2. Run `Developer: Show Running Extensions`.', + '3. Confirm `Azure Logic Apps (Standard)` appears as an activated extension.', + `4. Confirm its extension path is \`${distPath}\` so you know the locally built development extension loaded.`, + '5. Search the Command Palette for `Azure Logic Apps` commands, such as `Create new project...`.', + '6. Run `Azure Logic Apps: Create new logic app workspace...` to open the Create Workspace experience manually.', + '', + 'This window intentionally starts without a folder or `.code-workspace` loaded. The Create Workspace flow should be the first project/workspace entry point.', + '', + 'To confirm extension dependencies are installed:', + '', + '1. Open the Command Palette.', + '2. Run `Extensions: Show Installed Extensions`.', + '3. Confirm these manifest dependencies are present:', + '', + ...extensionDependencies.map((extensionId) => `- \`${extensionId}\``), + '', + 'The window uses the same isolated test environment variables as the automated smoke:', + '', + '- `VSCODE_RUNNING_TESTS=1`', + '- `DEBUGTELEMETRY=1`', + ].join('\n') + ); +} + +function readExtensionPackageJson() { + const packageJsonPath = join(distPath, 'package.json'); + return JSON.parse(readFileSync(packageJsonPath, 'utf8')); +} + +function runCodeCli(cliPath, args, options) { + const result = spawnSync(cliPath, args, { + encoding: 'utf8', + shell: process.platform === 'win32', + }); + + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + if (!options.allowFailure && result.status !== 0) { + process.exit(result.status ?? 1); + } + + return result; +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/vs-code-designer/scripts/run-e2e-cli.js b/apps/vs-code-designer/scripts/run-e2e-cli.js new file mode 100644 index 00000000000..2b524fe7876 --- /dev/null +++ b/apps/vs-code-designer/scripts/run-e2e-cli.js @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/* global __dirname, console, process, require */ +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const forbiddenOutputPatterns = [ + { + name: 'VS Code DialogService refusal', + pattern: /DialogService:.*refused to show dialog/i, + }, + { + name: 'Unexpected VS Code dialog attempt', + pattern: /Unexpected VS Code dialog attempted/i, + }, +]; + +const { args, visibleDelayMs, workspaceLifecycle } = parseArgs(process.argv.slice(2)); + +if (workspaceLifecycle) { + runWorkspaceLifecycle(visibleDelayMs).catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} else if (args.length === 0) { + runDefaultBaseline(visibleDelayMs).catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} else { + runVscodeTest(args, { visibleDelayMs }) + .then((code) => process.exit(code)) + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} + +async function runWorkspaceLifecycle(visibleDelayMs) { + const lifecycleDir = path.resolve(__dirname, '..', '.vscode-test', 'workspace-lifecycle'); + fs.mkdirSync(lifecycleDir, { recursive: true }); + const manifest = []; + + for (const label of ['standard', 'custom-code', 'rules-engine']) { + const manifestPath = path.join(lifecycleDir, `manifest-${label}-${Date.now()}.json`); + await runVscodeTest(['--label', 'workspaceLifecycle'], { + visibleDelayMs, + extraEnv: { + LA_E2E_CLI_INCLUDE_WORKSPACE_LIFECYCLE: '1', + LA_E2E_CLI_USER_DATA_SUFFIX: `workspace-lifecycle-create-${sanitizeEnvSegment(label)}-${Date.now()}`, + LA_E2E_CLI_WORKSPACE_LIFECYCLE_MODE: 'create', + LA_E2E_CLI_WORKSPACE_LIFECYCLE_CREATE_LABEL: label, + LA_E2E_CLI_WORKSPACE_LIFECYCLE_MANIFEST: manifestPath, + }, + }); + manifest.push(...JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))); + } + + if (!Array.isArray(manifest) || manifest.length === 0) { + throw new Error('Workspace lifecycle setup did not write workspace entries'); + } + + for (const entry of manifest) { + await runVscodeTest(['--label', 'workspaceLifecycle'], { + visibleDelayMs, + extraEnv: { + LA_E2E_CLI_INCLUDE_WORKSPACE_LIFECYCLE: '1', + LA_E2E_CLI_USER_DATA_SUFFIX: `workspace-lifecycle-${sanitizeEnvSegment(entry.label)}-${Date.now()}`, + LA_E2E_CLI_MINIMAL_ACTIVATION: '1', + LA_E2E_CLI_SKIP_ACTIVATION_WORKSPACE_ENSURE: '1', + LA_E2E_CLI_WORKSPACE_LIFECYCLE_MODE: 'run', + LA_E2E_CLI_WORKSPACE_LIFECYCLE_CASE: JSON.stringify(entry), + LA_E2E_CLI_STARTUP_RESOURCE: entry.appDir, + }, + }); + } + + if (process.env.LA_E2E_CLI_PRESERVE_WORKSPACES !== '1') { + for (const entry of manifest) { + try { + fs.rmSync(entry.workspaceDir, { recursive: true, force: true }); + } catch (error) { + console.warn(`[workspace-lifecycle] Unable to remove temp workspace ${entry.workspaceDir}: ${String(error)}`); + } + } + } +} + +async function runDefaultBaseline(visibleDelayMs) { + for (const label of ['unitTests', 'createWorkspace']) { + await runVscodeTest(['--label', label], { visibleDelayMs }); + } + + return 0; +} + +function sanitizeEnvSegment(value) { + return String(value).replace(/[^a-z0-9_-]+/gi, '-'); +} + +function runVscodeTest(args, options = {}) { + const userDataSuffix = process.env.LA_E2E_CLI_USER_DATA_SUFFIX ?? `run-${Date.now()}-${process.pid}`; + const child = spawn('vscode-test', args, { + shell: process.platform === 'win32', + env: { + ...process.env, + LA_E2E_CLI_USER_DATA_SUFFIX: userDataSuffix, + ...(options.visibleDelayMs ? { LA_E2E_CLI_VISIBLE_DELAY_MS: options.visibleDelayMs } : {}), + ...(options.extraEnv ?? {}), + }, + }); + + let output = ''; + + child.stdout.on('data', (data) => { + const text = data.toString(); + output += text; + process.stdout.write(text); + }); + + child.stderr.on('data', (data) => { + const text = data.toString(); + output += text; + process.stderr.write(text); + }); + + return new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => { + const matchedPattern = forbiddenOutputPatterns.find(({ pattern }) => pattern.test(output)); + if (matchedPattern) { + reject(new Error(`\n[activation-smoke] Failed because VS Code output contained: ${matchedPattern.name}`)); + return; + } + if (code && code !== 0) { + reject(new Error(`Exit code: ${code}`)); + return; + } + + resolve(0); + }); + }); +} + +function parseArgs(rawArgs) { + const args = []; + let visibleDelayMs; + let workspaceLifecycle = false; + + for (let index = 0; index < rawArgs.length; index++) { + const arg = rawArgs[index]; + if (arg === '--visible-delay-ms') { + visibleDelayMs = rawArgs[index + 1]; + index++; + continue; + } + if (arg === '--workspace-lifecycle') { + workspaceLifecycle = true; + continue; + } + + args.push(arg); + } + + return { args, visibleDelayMs, workspaceLifecycle }; +} diff --git a/apps/vs-code-designer/src/main.ts b/apps/vs-code-designer/src/main.ts index eaca64e88ec..5e1b821ef18 100644 --- a/apps/vs-code-designer/src/main.ts +++ b/apps/vs-code-designer/src/main.ts @@ -4,11 +4,7 @@ import { registerCommands } from './app/commands/registerCommands'; import { getResourceGroupsApi } from './app/resourcesExtension/getExtensionApi'; import type { AzureAccountTreeItemWithProjects } from './app/tree/AzureAccountTreeItemWithProjects'; import { downloadExtensionBundle } from './app/utils/bundleFeed'; -import { - scheduleStartAllDesignTimeApis, - stopAllDesignTimeApis, - startDesignTimeApi, -} from './app/utils/codeless/startDesignTimeApi'; +import { scheduleStartAllDesignTimeApis, stopAllDesignTimeApis, startDesignTimeApi } from './app/utils/codeless/startDesignTimeApi'; import { UriHandler } from './app/utils/codeless/urihandler'; import { getExtensionVersion, initializeCustomExtensionContext, updateLogicAppsContext } from './app/utils/extension'; import { registerFuncHostTaskEvents } from './app/utils/funcCoreTools/funcHostTask'; @@ -51,7 +47,12 @@ import { enableLocalManagedIdentityAuth } from './app/utils/managedIdentity'; import { localize } from './localize'; import { isDevContainerWorkspace } from './app/utils/devContainerUtils'; import { parameterizeAllConnections } from './app/commands/parameterizeConnections'; -import { getWorkspaceSetting, isManagedIdentityAuthEnabled, shouldParameterizeConnections, updateGlobalSetting } from './app/utils/vsCodeConfig/settings'; +import { + getWorkspaceSetting, + isManagedIdentityAuthEnabled, + shouldParameterizeConnections, + updateGlobalSetting, +} from './app/utils/vsCodeConfig/settings'; import { isAutoStartDesignTimeNotificationSuppressed, isManagedIdentityAuthNotificationSuppressed, @@ -98,8 +99,17 @@ export async function activate(context: vscode.ExtensionContext) { activateContext.telemetry.properties.lastStep = 'registerCommands'; registerCommands(); + if (process.env.LA_E2E_CLI_MINIMAL_ACTIVATION === '1') { + activateContext.telemetry.properties.minimalActivationForE2eCli = 'true'; + context.subscriptions.push(ext.outputChannel); + return; + } - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + if ( + vscode.workspace.workspaceFolders && + vscode.workspace.workspaceFolders.length > 0 && + process.env.LA_E2E_CLI_SKIP_ACTIVATION_WORKSPACE_ENSURE !== '1' + ) { activateContext.telemetry.properties.lastStep = 'ensureWorkspace'; await callWithTelemetryAndErrorHandling('activate.ensureWorkspace', async (actionContext: IActionContext) => { actionContext.telemetry.properties.isActivationEvent = 'true'; @@ -259,7 +269,9 @@ async function promptShouldEnableLocalManagedIdentityAuth(): Promise { if (selection === enableButton) { return true; - } else if (selection === dontShowAgain) { + } + + if (selection === dontShowAgain) { await suppressManagedIdentityAuthNotification(); return false; } @@ -362,7 +374,9 @@ async function promptShouldAutoStartDesignTime(projectPaths: string[]): Promise< if (result === confirm) { await updateGlobalSetting(autoStartDesignTimeSetting, true); return true; - } else if (result === dontWarnAgain) { + } + + if (result === dontWarnAgain) { await suppressAutoStartDesignTimeNotification(); } diff --git a/apps/vs-code-designer/src/test/e2e/README.md b/apps/vs-code-designer/src/test/e2e/README.md index f8f12fcc5c1..abb482eaa7e 100644 --- a/apps/vs-code-designer/src/test/e2e/README.md +++ b/apps/vs-code-designer/src/test/e2e/README.md @@ -1,10 +1,10 @@ # VS Code Extension E2E Tests (CLI-based) -This directory contains end-to-end tests for the Logic Apps VS Code extension using the official `@vscode/test-cli` framework. +This directory contains extension-host smoke tests for the Logic Apps VS Code extension using the official `@vscode/test-cli` framework. ## Overview -These tests follow the pattern from [helloworld-test-cli-sample](https://github.com/microsoft/vscode-extension-samples/tree/main/helloworld-test-cli-sample) and run directly inside VS Code's extension host environment. +These tests follow the pattern from [helloworld-test-cli-sample](https://github.com/microsoft/vscode-extension-samples/tree/main/helloworld-test-cli-sample) and run directly inside VS Code's extension host environment on latest stable VS Code. They intentionally start from an empty VS Code window with no folder or `.code-workspace` loaded, then cover activation, command registration, Create Workspace, and a focused generated-workspace designer/runtime lifecycle. Keep ExTester webview DOM scenarios in `src/test/ui/` for deeper designer and wizard UI coverage. ## Test Structure @@ -12,27 +12,147 @@ These tests follow the pattern from [helloworld-test-cli-sample](https://github. src/test/e2e/ ├── extension.test.ts # Basic extension activation tests ├── commands.test.ts # Command registration and execution tests +├── createWorkspace.test.ts # Latest-VS Code Create Workspace webview behavior, matrix, and artifact checks +├── workspaceLifecycle.test.ts # Generated workspace designer open + runtime execution smoke ├── runTest.ts # Test runner entry point -└── integration/ - ├── workflow.test.ts # Workflow file integration tests - └── designer.test.ts # Designer panel tests +└── integration/ # Legacy prototypes; not wired into the default CLI baseline ``` ## Running Tests -### Run all e2e tests -```bash +Run these commands from the repository root unless a section says otherwise. + +### Open latest stable VS Code and see extension activation +```powershell +pnpm run test:e2e-cli:open +``` + +Use this when you want to visually confirm the extension is loading and activating in a latest stable VS Code instance. The command builds the extension into `apps/vs-code-designer/dist`, downloads or reuses latest stable VS Code, installs extension dependencies into an isolated test profile, opens an empty VS Code window in a fresh profile for each run, and leaves the VS Code window running. + +In the opened VS Code window, confirm no folder or workspace is loaded, then run **Developer: Show Running Extensions** and confirm **Azure Logic Apps (Standard)** is active and loaded from `apps/vs-code-designer/dist`. Run **Extensions: Show Installed Extensions** to confirm the manifest dependencies are installed in the isolated profile. You can also search the Command Palette for **Azure Logic Apps** commands, such as **Create new project...**. The window uses the same test environment variables as the automated smoke: `VSCODE_RUNNING_TESTS=1` and `DEBUGTELEMETRY=1`. + +### Run the activation and command-registration smoke +```powershell pnpm run test:e2e-cli ``` +This is the default quick check for this suite. It builds the VS Code extension into `apps/vs-code-designer/dist`, builds the VS Code React webview bundle into `dist/vs-code-react`, compiles the CLI test files, launches latest stable VS Code without a startup folder/workspace, verifies the Logic Apps extension is loaded from the development `dist` folder, verifies its manifest dependencies are visible to VS Code, activates the extension, verifies core Logic Apps commands are registered, and opens the Create Workspace webview from the empty window. + +The smoke prints explicit `[activation-smoke]` lines with the VS Code version, extension development path, dependency extension IDs and versions, and activation completion. It keeps the VS Code test window visible briefly before closing so the launch is observable during local runs. When the VS Code test window is visible, the smoke also writes the same lines to the **Logic Apps @vscode/test-cli Smoke** output channel. + +The smoke also scans VS Code output for setup warnings that indicate an invalid extension-host baseline. `DialogService: refused to show dialog` and any guarded `showInformationMessage` / `showWarningMessage` / `showErrorMessage` call fail the run instead of being ignored. + +Screenshots are saved under `apps/vs-code-designer/.vscode-test/screenshots/cli/` on Windows. + +The activation/command-only smoke alias skips the Create Workspace webview check: + +```powershell +pnpm run test:e2e-cli:smoke +``` + +### Run Create Workspace checks +```powershell +pnpm run test:e2e-cli:create-workspace +``` + +This launches latest stable VS Code without a startup folder/workspace, asserts the window is still empty before the command runs, executes `azureLogicAppsStandard.createWorkspace`, verifies VS Code opens a `mainThreadWebview-CreateWorkspace` tab titled **Create workspace**, and drives the real rendered webview through Chrome DevTools Protocol. The default target mirrors the high-value ExTester Create Workspace validation and core artifact categories for: + +- workspace parent folder path validation and Standard required-field progression gating; +- workspace, logic app, and workflow name format validation, including reserved workflow names; +- Standard workflow type selection, review-step echoing, and final Next-button enablement; +- custom-code folder, namespace, function name, and .NET version gating; +- rules-engine folder, namespace, and function name gating; +- initial-render/content assertions, including available workflow type options; +- actual workspace creation for core Standard, custom-code, and rules-engine projects. + +It captures screenshots for representative initial, valid-form, review, scrolled form, and created-workspace states. After clicking **Create workspace**, it verifies durable disk artifacts such as the `.code-workspace` file, generated logic app folder, workflow JSON, function project files, rules-engine artifacts, and the stable essentials in generated `.vscode/settings.json`, `extensions.json`, `tasks.json`, and `launch.json`. These checks intentionally assert durable contract-level details (extension recommendations, Logic Apps local-project settings, debug configuration type/name/request, and task labels/dependency shape) rather than byte-for-byte layout. + +Focused Create Workspace slices are available when you need broader parity without running the whole suite: + +```powershell +pnpm run test:e2e-cli:create-workspace:behavior +pnpm run test:e2e-cli:create-workspace:core-matrix +pnpm run test:e2e-cli:create-workspace:preview-matrix +pnpm run test:e2e-cli:create-workspace:codeful +pnpm run test:e2e-cli:create-workspace:fixtures +pnpm run test:e2e-cli:create-workspace:full +``` + +The behavior target verifies initial render/content, Standard required-field progression, workflow type review/back preservation, and app-type cleanup. The core matrix covers Standard, custom-code, and rules-engine creation for Stateful/Stateless variants. The preview matrix covers Autonomous agents and Conversational agents across Standard/custom-code/rules-engine artifact generation, including deterministic workflow `kind`, Standard agent action/trigger shape, and custom-code/rules-engine starter function action names. Custom-code and rules-engine preview selections currently reuse their function/rules starter workflow templates; the stable preview distinction there is the generated workflow `kind`. The codeful target covers the current/modern codeful template plus the legacy-control `.csproj` target shape used by ExTester Phase 4.10: both cases create through the same `Logic app (codeful)` product radio, then the legacy-control case patches only the generated `.csproj` target hooks to `AfterTargets="Publish"` because latest stable VS Code exposes no separate legacy-control picker. Both cases verify `.csproj`, workflow `.cs`, `Program.cs`, `host.json`, `local.settings.json`, and stable codeful `.vscode` settings/tasks/launch essentials while asserting no codeless `workflow.json` is generated. ExTester remains the owner for legacy/modern codeful runtime-task semantics. + +The fixtures target is a focused Create Workspace parity mode for the `@vscode/test-cli` harness. It creates the four downstream fixture shapes through the real wizard — Standard Stateful, Standard Stateless, CustomCode Stateful, and RulesEngine Stateful — and writes a downstream-compatible manifest to `%TEMP%\la-e2e-test\created-workspaces.json` (or `os.tmpdir()/la-e2e-test/created-workspaces.json` on non-Windows). The manifest shape intentionally matches `src/test/ui/workspaceManifest.ts` so consumers can read `wsDir`, `wsFilePath`, `appDir`, `wfDir`, `appType`, and `wfType` the same way they read ExTester fixtures. The mode preserves the generated workspace directories because the manifest points at absolute paths. + +This CLI fixture mode does not replace or remove ExTester coverage. `run-e2e.js` downstream ExTester phases continue to treat `p41a-fixtures` as their canonical fixture owner; use this CLI target when you specifically need latest-stable `@vscode/test-cli` Create Workspace parity or a local manifest produced by the CLI harness. + +The full Create Workspace script intentionally runs the focused labels in separate VS Code hosts instead of one very long webview session, which keeps dropdown/popover state isolated and matches the lifecycle suite's fresh-process pattern. + +### CI coverage + +`.github/workflows/vscode-e2e.yml` runs the focused Create Workspace labels in a dedicated `vscode-e2e-cli-create-workspace` matrix: + +- `createWorkspaceBehavior` +- `createWorkspaceCoreMatrix` +- `createWorkspacePreviewMatrix` +- `createWorkspaceCodeful` + +`createWorkspaceFixturesManifest` is intentionally a focused/manual fixture producer for now; ExTester `p41a-fixtures` remains the fixture-backed downstream owner in the `run-e2e.js` matrix. + +This job is additive to the existing ExTester matrix; it does not replace `src/test/ui/` coverage. The generated workspace designer/runtime lifecycle remains manual for now because it exercises designer open, .NET build, Azurite/runtime startup, trigger execution, and Overview verification, making it substantially higher-cost and higher-flake than the focused Create Workspace parity checks. + +### Run generated workspace designer/runtime lifecycle +```powershell +pnpm run test:e2e-cli:workspace-lifecycle +``` + +This launches latest stable VS Code from an empty window, creates Standard, custom-code, and rules-engine workspaces through the real Create Workspace webview, opens the generated folders in fresh test hosts, opens the local designer, and captures screenshots at the important creation/designer/debug/overview stages. The Standard lifecycle adds the built-in Request trigger and Response action through the designer UI, saves the workflow, starts the generated Logic Apps debug configuration, opens Overview, clicks **Run trigger**, and verifies the latest run reaches `Succeeded`. + +The custom-code and rules-engine lifecycles use the workflows generated by Create Workspace. Before debug, the test explicitly builds the sibling generated .NET function project with `dotnet build` so the runtime has `lib\custom\\function.json` metadata even though the test host starts from only the Logic App folder. It then opens designer, saves, starts debug, opens Overview, waits for the Run trigger and callback URL to become ready, clicks **Run trigger**, and verifies the latest run reaches `Succeeded`. + +This smoke proves the latest-VS Code extension host can load generated projects, hydrate designer webviews, start the product-managed Azurite/runtime path, and execute saved workflows. It deliberately keeps the broader webview DOM authoring flows in ExTester. + +To keep that Create Workspace window visible longer while debugging locally: + +```powershell +pnpm run test:e2e-cli:show-create-workspace +``` + +That runs the same Create Workspace smoke and keeps VS Code open for 60 seconds before the test host exits. + ### Run tests with a specific label -```bash +```powershell pnpm run test:e2e-cli --label unitTests -pnpm run test:e2e-cli --label integrationTests +pnpm run test:e2e-cli --label createWorkspace +pnpm run test:e2e-cli --label createWorkspaceBehavior +pnpm run test:e2e-cli --label createWorkspaceCoreMatrix +pnpm run test:e2e-cli --label createWorkspacePreviewMatrix +pnpm run test:e2e-cli --label createWorkspaceCodeful +pnpm run test:e2e-cli --label createWorkspaceFixturesManifest +pnpm run test:e2e-cli --label workspaceLifecycle ``` ### Compile tests only (without running) -```bash +```powershell +pnpm run test:e2e-cli:compile +``` + +### Package-local commands + +If your terminal is already in `apps/vs-code-designer`, run the same scripts without the root forwarding: + +```powershell +pnpm run test:e2e-cli:smoke +pnpm run test:e2e-cli:create-workspace +pnpm run test:e2e-cli:create-workspace:behavior +pnpm run test:e2e-cli:create-workspace:core-matrix +pnpm run test:e2e-cli:create-workspace:preview-matrix +pnpm run test:e2e-cli:create-workspace:codeful +pnpm run test:e2e-cli:create-workspace:fixtures +pnpm run test:e2e-cli:create-workspace:full +pnpm run test:e2e-cli:workspace-lifecycle +pnpm run test:e2e-cli:show-create-workspace +pnpm run test:e2e-cli:open +pnpm run test:e2e-cli --label unitTests +pnpm run test:e2e-cli --label createWorkspace pnpm run test:e2e-cli:compile ``` @@ -40,8 +160,18 @@ pnpm run test:e2e-cli:compile The test configuration is in [.vscode-test.mjs](../../.vscode-test.mjs): -- **unitTests**: Basic extension and command tests with shorter timeout -- **integrationTests**: Full integration tests with longer timeout +- **unitTests**: extension activation and command-registration smoke tests +- **createWorkspace**: default Create Workspace validation plus core creation smoke +- **createWorkspaceBehavior**: initial render/content, review/back, and app-type cleanup checks +- **createWorkspaceCoreMatrix**: Standard/custom-code/rules-engine Stateful and Stateless artifact creation +- **createWorkspacePreviewMatrix**: Autonomous agents and Conversational agents artifact creation across app types +- **createWorkspaceCodeful**: current/modern and legacy-control codeful artifact creation +- **createWorkspaceFixturesManifest**: creates Standard Stateful, Standard Stateless, CustomCode Stateful, and RulesEngine Stateful fixtures and writes the ExTester-compatible `created-workspaces.json` manifest +- **workspaceLifecycle**: generated-workspace designer open and runtime execution smoke for Standard, custom-code, and rules-engine projects + +The config builds from `dist/`, sets `VSCODE_RUNNING_TESTS=1` and `DEBUGTELEMETRY=1`, lets `@vscode/test-cli` install extension dependencies into its managed test profile, does not pass a startup workspace folder, and uses an isolated user-data directory. On non-Windows agents it uses a short temp user-data path to avoid Unix socket path-length issues. + +The legacy files under `src/test/e2e/integration/` are not part of this baseline. Some of them open designer webviews or exercise workspace-conversion UI without the ExTester harness, which can produce errors such as missing `dist/vs-code-react/index.html` or refused dialogs in extension-host tests. ## Test Development @@ -68,12 +198,9 @@ suite('My Test Suite', () => { }); ``` -## Test Workspace +## Startup Workspace -Tests run in the `e2e/test-workspace` directory which contains: -- Sample workflow files for testing -- VS Code settings and extension recommendations -- Any fixtures needed for tests +These CLI tests intentionally start with no folder and no `.code-workspace` loaded. Do not add `e2e/test-workspace` or another prebuilt project as the default startup resource for this baseline; the first project/workspace entry point under test is the Create Workspace command. ## Differences from vscode-extension-tester @@ -82,8 +209,10 @@ The existing `src/test/ui/` tests use `vscode-extension-tester` which: - Good for visual/UI testing - Slower but more comprehensive UI interaction -These new CLI-based tests: +These CLI-based tests: - Run directly in VS Code's extension host - Faster execution - Better for API-level testing - Easier to debug +- Provide focused Chrome DevTools Protocol coverage for Create Workspace, designer, and Overview smoke paths +- Do not replace ExTester; keep ExTester for full designer and wizard DOM flows diff --git a/apps/vs-code-designer/src/test/e2e/cdpClient.ts b/apps/vs-code-designer/src/test/e2e/cdpClient.ts new file mode 100644 index 00000000000..7922df0e720 --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/cdpClient.ts @@ -0,0 +1,472 @@ +import * as assert from 'assert'; +import { randomBytes } from 'crypto'; +import * as net from 'net'; + +interface CdpTarget { + id?: string; + type: string; + title?: string; + url?: string; + webSocketDebuggerUrl?: string; +} + +interface CdpResponse { + id?: number; + method?: string; + params?: any; + result?: any; + error?: { message?: string }; +} + +interface CdpExecutionContext { + id: number; + origin?: string; + name?: string; +} + +export class CdpConnection { + private nextId = 1; + private buffer = Buffer.alloc(0); + private readonly pending = new Map void; reject: (error: Error) => void }>(); + private readonly contextListeners: Array<(context: CdpExecutionContext) => void> = []; + + private constructor(private readonly socket: net.Socket) { + socket.on('data', (chunk) => this.onData(chunk)); + socket.on('error', (error) => this.rejectAll(error)); + socket.on('close', () => this.rejectAll(new Error('CDP WebSocket closed'))); + } + + static async connect(webSocketUrl: string): Promise { + const url = new URL(webSocketUrl); + const port = Number(url.port || '80'); + const key = randomBytes(16).toString('base64'); + const socket = net.connect(port, url.hostname); + + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('error', reject); + }); + + socket.write( + [ + `GET ${url.pathname}${url.search} HTTP/1.1`, + `Host: ${url.host}`, + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Key: ${key}`, + 'Sec-WebSocket-Version: 13', + '', + '', + ].join('\r\n') + ); + + await waitForHandshake(socket); + return new CdpConnection(socket); + } + + async send(method: string, params?: Record): Promise { + const id = this.nextId++; + const message = JSON.stringify({ id, method, params }); + + const promise = new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + }); + + this.socket.write(encodeClientFrame(message)); + return promise; + } + + async evaluate(contextId: number | undefined, expression: string): Promise { + const response = await this.send('Runtime.evaluate', { + ...(contextId ? { contextId } : {}), + expression, + awaitPromise: true, + returnByValue: true, + }); + + if (response.result?.exceptionDetails) { + throw new Error(`CDP evaluation failed: ${JSON.stringify(response.result.exceptionDetails)}`); + } + + return response.result?.result?.value as T; + } + + onExecutionContextCreated(listener: (context: CdpExecutionContext) => void): void { + this.contextListeners.push(listener); + } + + dispose(): void { + this.socket.end(); + } + + private onData(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]); + + while (this.buffer.length >= 2) { + const frame = tryDecodeServerFrame(this.buffer); + if (!frame) { + return; + } + + this.buffer = this.buffer.subarray(frame.consumed); + + if (frame.opcode === 8) { + this.socket.end(); + return; + } + + if (frame.opcode !== 1) { + continue; + } + + const message = JSON.parse(frame.payload.toString('utf8')) as CdpResponse; + if (typeof message.id === 'number') { + const pending = this.pending.get(message.id); + if (pending) { + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message ?? JSON.stringify(message.error))); + } else { + pending.resolve(message); + } + } + } else if (message.method === 'Runtime.executionContextCreated') { + for (const listener of this.contextListeners) { + listener(message.params.context); + } + } + } + } + + private rejectAll(error: Error): void { + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + } +} + +export async function connectToVsCodeCdp( + options: { targetName?: string; urlIncludes?: string; titleIncludes?: string } = {} +): Promise { + const port = process.env.LA_E2E_CLI_REMOTE_DEBUGGING_PORT; + assert.ok(port, 'LA_E2E_CLI_REMOTE_DEBUGGING_PORT must be set for webview DOM smoke tests'); + + const targetName = options.targetName ?? 'Logic Apps webview'; + const urlIncludes = options.urlIncludes ?? 'extensionId=ms-azuretools.vscode-azurelogicapps'; + const titleIncludes = options.titleIncludes; + const deadline = Date.now() + 15000; + let targets: CdpTarget[] = []; + + while (Date.now() < deadline) { + targets = (await fetchJson(`http://127.0.0.1:${port}/json/list`)) as CdpTarget[]; + const webviewTarget = [...targets] + .reverse() + .find( + (target) => + target.type === 'iframe' && + target.webSocketDebuggerUrl && + target.url?.startsWith('vscode-webview://') && + target.url.includes(urlIncludes) && + (!titleIncludes || target.title?.includes(titleIncludes)) + ); + + if (webviewTarget?.webSocketDebuggerUrl) { + if (webviewTarget.id) { + await fetch(`http://127.0.0.1:${port}/json/activate/${webviewTarget.id}`).catch(() => undefined); + } + + return CdpConnection.connect(webviewTarget.webSocketDebuggerUrl); + } + + await delay(250); + } + + assert.fail(`Unable to find ${targetName} CDP target. Targets: ${JSON.stringify(targets)}`); +} + +export async function connectToVsCodeWorkbenchCdp(): Promise { + const port = process.env.LA_E2E_CLI_REMOTE_DEBUGGING_PORT; + assert.ok(port, 'LA_E2E_CLI_REMOTE_DEBUGGING_PORT must be set for workbench DOM smoke tests'); + + const deadline = Date.now() + 15000; + let targets: CdpTarget[] = []; + + while (Date.now() < deadline) { + targets = (await fetchJson(`http://127.0.0.1:${port}/json/list`)) as CdpTarget[]; + const workbenchTarget = targets.find( + (target) => + target.type === 'page' && + target.webSocketDebuggerUrl && + target.url?.includes('/workbench/workbench.html') && + target.title?.includes('[Extension Development Host]') + ); + + if (workbenchTarget?.webSocketDebuggerUrl) { + if (workbenchTarget.id) { + await fetch(`http://127.0.0.1:${port}/json/activate/${workbenchTarget.id}`).catch(() => undefined); + } + return CdpConnection.connect(workbenchTarget.webSocketDebuggerUrl); + } + + await delay(250); + } + + assert.fail(`Unable to find VS Code workbench CDP target. Targets: ${JSON.stringify(targets)}`); +} + +export async function waitForCreateWorkspaceFrameContext(cdp: CdpConnection, timeoutMs = 15000): Promise { + return waitForWebviewFrameContext(cdp, { + allTextIncludes: ['Create logic app workspace', 'Workspace parent folder path', 'Workspace name'], + description: 'Create Workspace webview DOM context', + timeoutMs, + }); +} + +export async function waitForWebviewFrameContext( + cdp: CdpConnection, + options: { allTextIncludes: string[]; description: string; timeoutMs?: number } +): Promise { + const contexts = new Map(); + const lastTexts = new Map(); + const lastDiagnostics = new Map(); + cdp.onExecutionContextCreated((context) => contexts.set(context.id, context)); + + await cdp.send('Runtime.enable'); + + const deadline = Date.now() + (options.timeoutMs ?? 15000); + while (Date.now() < deadline) { + for (const context of contexts.values()) { + const diagnostics = await cdp + .evaluate<{ + ownText: string; + text: string; + readyState: string; + location: string; + html: string; + scripts: string[]; + links: string[]; + frames: Array<{ id: string; src: string; location: string; readyState: string; text: string; html: string }>; + }>( + context.id, + `(() => { + const collectText = (root) => { + let text = ''; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + let node = walker.currentNode; + while (node) { + if (node instanceof HTMLScriptElement || node instanceof HTMLStyleElement) { + node = walker.nextSibling() || walker.nextNode(); + continue; + } + if (node.parentElement instanceof HTMLScriptElement || node.parentElement instanceof HTMLStyleElement) { + node = walker.nextNode(); + continue; + } + if (node.nodeType === Node.TEXT_NODE) { + text += node.textContent || ''; + } + if (node.shadowRoot) { + text += collectText(node.shadowRoot); + } + if (node instanceof HTMLIFrameElement && node.contentDocument) { + text += collectText(node.contentDocument); + } + node = walker.nextNode(); + } + return text; + }; + const frameDocuments = Array.from(document.querySelectorAll('iframe')) + .map((frame) => frame.contentDocument) + .filter(Boolean); + const frames = Array.from(document.querySelectorAll('iframe')).map((frame) => ({ + id: frame.id || '', + src: frame.src || '', + location: frame.contentDocument?.location.href || '', + readyState: frame.contentDocument?.readyState || '', + text: frame.contentDocument ? collectText(frame.contentDocument).slice(0, 1000) : '', + html: frame.contentDocument?.documentElement?.outerHTML?.slice(0, 1000) || '', + })); + const frameText = frameDocuments.map((frameDocument) => collectText(frameDocument)).join('\\n'); + const ownText = document.body?.innerText || collectText(document) || ''; + return { + ownText: ownText.trim(), + text: [ownText, frameText].join('\\n').trim(), + readyState: document.readyState, + location: document.location.href, + html: [ + document.documentElement?.outerHTML?.slice(0, 2000) || '', + ...frameDocuments.map((frameDocument) => frameDocument.documentElement?.outerHTML?.slice(0, 2000) || ''), + ].join('\\n---FRAME---\\n'), + scripts: Array.from(document.scripts).map((script) => script.src || script.textContent?.slice(0, 120) || ''), + links: Array.from(document.querySelectorAll('link')).map((link) => link.href || ''), + frames, + }; + })()` + ) + .catch((error) => ({ + text: '', + ownText: '', + readyState: 'unknown', + location: 'unknown', + html: String(error), + scripts: [], + links: [], + })); + const text = diagnostics.text; + const ownText = diagnostics.ownText; + lastTexts.set(context.id, ownText || text); + lastDiagnostics.set(context.id, diagnostics); + + if (options.allTextIncludes.every((expected) => ownText.includes(expected))) { + return context.id; + } + } + + await delay(250); + } + + assert.fail( + `Timed out waiting for ${options.description}. Contexts: ${JSON.stringify([...contexts.values()])}. Last text: ${JSON.stringify( + [...lastTexts.entries()].map(([contextId, text]) => ({ + contextId, + text: text.slice(0, 1000), + diagnostics: lastDiagnostics.get(contextId), + })) + )}` + ); +} + +export async function captureCdpScreenshot(cdp: CdpConnection, name: string): Promise { + const fs = await import('fs'); + const path = await import('path'); + const screenshotRoot = + process.env.LA_E2E_CLI_SCREENSHOT_DIR ?? path.resolve(__dirname, '..', '..', '..', '.vscode-test', 'screenshots', 'cli'); + + fs.mkdirSync(screenshotRoot, { recursive: true }); + const screenshotPath = path.join(screenshotRoot, `${sanitizeFileSegment(name)}.png`); + const response = await cdp.send('Page.captureScreenshot', { format: 'png', fromSurface: true }); + const data = response.result?.data; + if (typeof data !== 'string') { + return undefined; + } + + fs.writeFileSync(screenshotPath, data, 'base64'); + console.log(`[screenshot] Saved: ${screenshotPath}`); + return screenshotPath; +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +async function waitForHandshake(socket: net.Socket): Promise { + let buffer = Buffer.alloc(0); + + await new Promise((resolve, reject) => { + const onData = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) { + return; + } + + const header = buffer.subarray(0, headerEnd).toString('utf8'); + socket.off('data', onData); + socket.off('error', reject); + + if (!header.startsWith('HTTP/1.1 101')) { + reject(new Error(`CDP WebSocket upgrade failed: ${header}`)); + return; + } + + resolve(); + }; + + socket.on('data', onData); + socket.once('error', reject); + }); +} + +function encodeClientFrame(text: string): Buffer { + const payload = Buffer.from(text, 'utf8'); + const length = payload.length; + const lengthBytes = length < 126 ? 0 : length <= 0xffff ? 2 : 8; + const header = Buffer.alloc(2 + lengthBytes + 4); + header[0] = 0x81; + + if (lengthBytes === 0) { + header[1] = 0x80 | length; + } else if (lengthBytes === 2) { + header[1] = 0x80 | 126; + header.writeUInt16BE(length, 2); + } else { + header[1] = 0x80 | 127; + header.writeBigUInt64BE(BigInt(length), 2); + } + + const maskOffset = 2 + lengthBytes; + const mask = randomBytes(4); + mask.copy(header, maskOffset); + + const maskedPayload = Buffer.alloc(payload.length); + for (let index = 0; index < payload.length; index++) { + maskedPayload[index] = payload[index] ^ mask[index % 4]; + } + + return Buffer.concat([header, maskedPayload]); +} + +function tryDecodeServerFrame(buffer: Buffer): { opcode: number; payload: Buffer; consumed: number } | undefined { + const firstByte = buffer[0]; + const secondByte = buffer[1]; + let length = secondByte & 0x7f; + let offset = 2; + + if (length === 126) { + if (buffer.length < offset + 2) { + return undefined; + } + length = buffer.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + if (buffer.length < offset + 8) { + return undefined; + } + length = Number(buffer.readBigUInt64BE(offset)); + offset += 8; + } + + const masked = (secondByte & 0x80) !== 0; + const maskOffset = offset; + if (masked) { + offset += 4; + } + + if (buffer.length < offset + length) { + return undefined; + } + + const payload = Buffer.from(buffer.subarray(offset, offset + length)); + if (masked) { + const mask = buffer.subarray(maskOffset, maskOffset + 4); + for (let index = 0; index < payload.length; index++) { + payload[index] ^= mask[index % 4]; + } + } + + return { opcode: firstByte & 0x0f, payload, consumed: offset + length }; +} + +function sanitizeFileSegment(value: string): string { + return value.replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'screenshot'; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/vs-code-designer/src/test/e2e/commands.test.ts b/apps/vs-code-designer/src/test/e2e/commands.test.ts index 53fb06f71e1..db93e03b2a7 100644 --- a/apps/vs-code-designer/src/test/e2e/commands.test.ts +++ b/apps/vs-code-designer/src/test/e2e/commands.test.ts @@ -1,48 +1,57 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; +import { assertNoDialogAttempts, installDialogGuard } from './dialogGuard'; +import { waitForVisibleDelay } from './visibleDelay'; + +const logicAppsExtensionId = 'ms-azuretools.vscode-azurelogicapps'; +const expectedCommands = [ + 'azureLogicAppsStandard.openDesigner', + 'azureLogicAppsStandard.createWorkspace', + 'azureLogicAppsStandard.createProject', + 'azureLogicAppsStandard.createWorkflow', + 'azureLogicAppsStandard.openOverview', + 'azureLogicAppsStandard.addCustomCode', + 'azureLogicAppsStandard.dataMap.createDataMap', + 'azureLogicAppsStandard.runProjectConsistencyCheck', + 'azureLogicAppsStandard.reportIssue', +]; + +installDialogGuard(); suite('Logic Apps Commands Tests', () => { - vscode.window.showInformationMessage('Starting Command Tests'); - - test('Should list all registered commands', async () => { - const commands = await vscode.commands.getCommands(true); - assert.ok(commands.length > 0, 'Should have registered commands'); - console.log(`Total commands registered: ${commands.length}`); - - // Filter Logic Apps related commands - const logicAppsCommands = commands.filter( - (cmd) => cmd.includes('logicApps') || cmd.includes('azureLogicApps') || cmd.includes('logic-apps') - ); - - console.log(`Logic Apps commands found: ${logicAppsCommands.length}`); - if (logicAppsCommands.length > 0) { - console.log('Logic Apps commands:', logicAppsCommands.slice(0, 10)); - } + suiteSetup(async () => { + const extension = vscode.extensions.getExtension(logicAppsExtensionId); + assert.ok(extension, `Expected ${logicAppsExtensionId} to be loaded from the extension development path`); + await extension.activate(); }); - test('Should be able to execute showInformationMessage', async () => { - // This is a basic VS Code API test to ensure the API is working - const result = vscode.window.showInformationMessage('Test message from e2e test'); - assert.ok(result !== undefined, 'showInformationMessage should return a thenable'); + suiteTeardown(async () => { + await waitForVisibleDelay('command registration smoke'); }); - test('Should be able to create output channel', () => { - const outputChannel = vscode.window.createOutputChannel('Logic Apps E2E Test'); - assert.ok(outputChannel, 'Should be able to create output channel'); + test('Should register expected Logic Apps commands', async () => { + const commands = await vscode.commands.getCommands(true); + const missingCommands = expectedCommands.filter((command) => !commands.includes(command)); + + assert.deepStrictEqual(missingCommands, [], `Missing expected Logic Apps commands: ${missingCommands.join(', ')}`); + }); - outputChannel.appendLine('E2E Test Output Channel Created'); - outputChannel.show(true); + test('Should expose a focused command namespace', async () => { + const commands = await vscode.commands.getCommands(true); + const logicAppsCommands = commands.filter((command) => command.startsWith('azureLogicAppsStandard.')); - // Clean up - outputChannel.dispose(); + assert.ok(logicAppsCommands.length >= expectedCommands.length, 'Should register the Logic Apps command namespace'); }); test('Should be able to access configuration', () => { const config = vscode.workspace.getConfiguration('azureLogicAppsStandard'); assert.ok(config, 'Configuration should be accessible'); - // Try to get a configuration value (might be undefined if not set) - const projectRuntime = config.get('projectRuntime'); - console.log(`Project runtime config: ${projectRuntime ?? 'not set'}`); + assert.strictEqual(config.get('autoRuntimeDependenciesValidationAndInstallation'), false); + assert.strictEqual(config.get('autoStartDesignTime'), false); + }); + + test('Should not attempt startup dialogs while registering commands', async () => { + await assertNoDialogAttempts('Logic Apps command registration smoke'); }); }); diff --git a/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts b/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts new file mode 100644 index 00000000000..f4733e7bdd1 --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts @@ -0,0 +1,3041 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { connectToVsCodeCdp, waitForCreateWorkspaceFrameContext } from './cdpClient'; +import { assertNoDialogAttempts, installDialogGuard } from './dialogGuard'; +import { captureCliScreenshot } from './screenshot'; +import { waitForVisibleDelay } from './visibleDelay'; + +const logicAppsExtensionId = 'ms-azuretools.vscode-azurelogicapps'; +const createWorkspaceCommand = 'azureLogicAppsStandard.createWorkspace'; +const createWorkspaceViewType = 'CreateWorkspace'; +const createWorkspaceTabViewType = `mainThreadWebview-${createWorkspaceViewType}`; +const createWorkspaceTitle = 'Create workspace'; +const nameValidationMessage = 'must start with a letter and can only contain letters, digits'; +const emptyValidationMessage = 'cannot be empty'; +const reservedNameValidationMessage = 'reserved and cannot be used'; +const sameAsLogicAppValidationMessage = 'cannot be the same as the logic app name'; +const namespaceValidationMessage = 'valid C# namespace'; +const functionsExtensionId = 'ms-azuretools.vscode-azurefunctions'; +const dotnetExtensionId = 'ms-dotnettools.csharp'; +const csDevKitExtensionId = 'ms-dotnettools.csdevkit'; +const logicAppsProjectLanguageSetting = 'azureLogicAppsStandard.projectLanguage'; +const logicAppsProjectRuntimeSetting = 'azureLogicAppsStandard.projectRuntime'; +const logicAppsDeploySubpathSetting = 'azureLogicAppsStandard.deploySubpath'; +const logicAppsPickProcessCommand = 'azureLogicAppsStandard.pickFuncProcess'; +const logicAppsGetDebugSymbolDllCommand = 'azureLogicAppsStandard.getDebugSymbolDll'; +const funcCoreToolsBinaryPathSetting = '${config:azureLogicAppsStandard.funcCoreToolsBinaryPath}'; +const dotnetBinaryPathSetting = '${config:azureLogicAppsStandard.dotnetBinaryPath}'; +const funcHostStartTaskLabel = 'func: host start'; +const funcWatchProblemMatcher = '$func-watch'; + +type CdpEvaluator = { + evaluate(contextId: number, expression: string): Promise; + send(method: string, params?: Record): Promise; +}; +type FieldLabels = string | string[]; +type WorkspaceAppType = 'standard' | 'customCode' | 'rulesEngine' | 'codeful'; +type CodefulControlVariant = 'modern-control' | 'legacy-control'; +type WorkflowType = 'Stateful' | 'Stateless' | 'Autonomous agents (Preview)' | 'Conversational agents (Preview)'; +type CreateWorkspaceGroup = 'default' | 'behavior' | 'core-matrix' | 'preview-matrix' | 'codeful' | 'fixtures-manifest' | 'full'; + +interface FieldValidationCase { + name: string; + labels: FieldLabels; + invalidValue: string; + expectedMessage: string; + validValue: string; +} + +interface WorkspaceCreationCase { + label: string; + appType: WorkspaceAppType; + radioLabel: string; + wsName: string; + appName: string; + wfName: string; + workflowType: WorkflowType; + functionFolderName?: string; + functionNamespace?: string; + functionName?: string; + codefulControlVariant?: CodefulControlVariant; +} + +/** + * Must stay downstream-compatible with src/test/ui/workspaceManifest.ts. + * ExTester p41a-fixtures remains the canonical producer for run-e2e.js phases; + * this CLI shape exists for focused @vscode/test-cli fixture generation. + */ +interface WorkspaceManifestEntry { + label: string; + parentDir: string; + wsName: string; + appName: string; + wfName: string; + appType: WorkspaceAppType; + wfType: WorkflowType; + ccFolderName?: string; + fnName?: string; + fnNamespace?: string; + wsDir: string; + wsFilePath: string; + appDir: string; + wfDir: string; + createdAt: string; +} + +type WorkflowAction = { + type?: unknown; + kind?: unknown; + inputs?: { + functionName?: unknown; + parameters?: Record; + statusCode?: unknown; + body?: unknown; + modelConfigurations?: Record; + }; + limit?: unknown; + runAfter?: Record; + tools?: unknown; +}; + +type WorkflowTrigger = { + type?: unknown; + kind?: unknown; + inputs?: unknown; +}; + +type WorkflowJson = { + kind?: string; + definition?: { + actions?: Record; + contentVersion?: unknown; + outputs?: Record; + triggers?: Record; + }; +}; + +type WorkspaceJson = { + folders?: Array<{ name?: string; path?: string }>; +}; + +type ExtensionsJson = { + recommendations?: unknown; +}; + +type LaunchConfiguration = { + [key: string]: unknown; + name?: unknown; + type?: unknown; + request?: unknown; + processId?: unknown; + funcRuntime?: unknown; + customCodeRuntime?: unknown; + isCodeless?: unknown; +}; + +type LaunchJson = { + version?: unknown; + configurations?: unknown; +}; + +type TaskJson = { + [key: string]: unknown; + label?: unknown; + type?: unknown; + command?: unknown; + args?: unknown; + isBackground?: unknown; + problemMatcher?: unknown; + dependsOn?: unknown; + group?: unknown; + options?: unknown; + windows?: unknown; + linux?: unknown; + osx?: unknown; +}; + +type TasksJson = { + version?: unknown; + tasks?: unknown; + inputs?: unknown; +}; + +interface Point { + x: number; + y: number; +} + +installDialogGuard(); + +suite('Create Workspace Experience Tests', () => { + const createWorkspaceGroup = getCreateWorkspaceGroup(); + const tempWorkspaceParentPath = createWorkspaceParentPath(createWorkspaceGroup); + const createWorkspaceCaseFilter = process.env.LA_E2E_CLI_CREATE_WORKSPACE_CASE; + + suiteSetup(async () => { + const extension = vscode.extensions.getExtension(logicAppsExtensionId); + assert.ok(extension, `Expected ${logicAppsExtensionId} to be loaded from the extension development path`); + await extension.activate(); + + if (createWorkspaceGroup === 'fixtures-manifest') { + clearFixtureManifest(); + } + }); + + suiteTeardown(async () => { + await waitForVisibleDelay('Create Workspace smoke'); + await closeWebviewTabs(createWorkspaceViewType); + }); + + suiteTeardown(() => { + if (createWorkspaceGroup === 'fixtures-manifest' || process.env.LA_E2E_CLI_PRESERVE_WORKSPACES === '1') { + console.log(`[create-workspace-smoke] Preserving fixture workspace parent ${tempWorkspaceParentPath}`); + return; + } + + try { + fs.rmSync(tempWorkspaceParentPath, { recursive: true, force: true }); + } catch (error) { + console.warn(`[create-workspace-smoke] Unable to remove temp workspace parent ${tempWorkspaceParentPath}: ${String(error)}`); + } + }); + + if (shouldRunCreateWorkspaceGroup(createWorkspaceGroup, ['default', 'behavior', 'full'])) { + test('Should open the Create Workspace webview and validate fields before project creation', async () => { + assertEmptyWorkspace('before executing Create Workspace'); + + const tabsBefore = getWebviewTabs(createWorkspaceViewType).length; + + await vscode.commands.executeCommand(createWorkspaceCommand); + + const tab = await waitForWebviewTab(createWorkspaceViewType, tabsBefore); + assert.strictEqual(getTabViewType(tab), createWorkspaceTabViewType); + assert.strictEqual(tab.label, createWorkspaceTitle); + + const cdp = await connectToVsCodeCdp({ targetName: 'Create Workspace webview' }); + try { + const createWorkspaceContextId = await waitForCreateWorkspaceFrameContext(cdp); + await assertInitialCreateWorkspaceContent(cdp, createWorkspaceContextId); + await captureCliScreenshot('create-workspace-initial-form'); + await runStandardRequiredFieldProgression(cdp, createWorkspaceContextId, tempWorkspaceParentPath); + await runStandardFieldValidationCases(cdp, createWorkspaceContextId, tempWorkspaceParentPath); + await captureCliScreenshot('create-workspace-standard-fields-valid'); + + await runCustomCodeFieldValidationCases(cdp, createWorkspaceContextId, tempWorkspaceParentPath); + await captureCliScreenshot('create-workspace-custom-code-fields-valid'); + + await runRulesEngineFieldValidationCases(cdp, createWorkspaceContextId, tempWorkspaceParentPath); + await captureCliScreenshot('create-workspace-rules-engine-fields-valid'); + } finally { + cdp.dispose(); + } + + await assertNoDialogAttempts('Create Workspace command execution'); + }); + } + + if (shouldRunCreateWorkspaceGroup(createWorkspaceGroup, ['behavior', 'full'])) { + test('Should verify review back navigation and app type cleanup', async function () { + this.timeout(240000); + + for (const creationCase of filterCreationCases(getReviewBackCases(), createWorkspaceCaseFilter)) { + const { cdp, contextId } = await openCreateWorkspaceContext(); + try { + await fillWorkspaceCreationFields(cdp, contextId, creationCase, tempWorkspaceParentPath); + await assertNextButtonEnabled(cdp, contextId, `${creationCase.label} review/back fields`); + await goToReviewAndBack(cdp, contextId, creationCase); + await assertWorkspaceCreationFields(cdp, contextId, creationCase, tempWorkspaceParentPath); + await captureWorkspaceCreationFormScreenshots(cdp, contextId, creationCase.label, 'review-back'); + } finally { + cdp.dispose(); + } + } + + await verifyWorkflowTypeDescriptionAndReview(tempWorkspaceParentPath); + await verifyAppTypeCleanup(tempWorkspaceParentPath); + await assertNoDialogAttempts('Create Workspace review/back flows'); + }); + } + + if (shouldRunCreateWorkspaceGroup(createWorkspaceGroup, ['default', 'core-matrix', 'full'])) { + test('Should create core Standard, custom code, and rules engine workspaces from the webview', async function () { + this.timeout(600000); + + for (const creationCase of filterCreationCases(getCoreCreationCases(), createWorkspaceCaseFilter)) { + await createWorkspaceThroughWebview(creationCase, tempWorkspaceParentPath); + verifyCreatedWorkspace(tempWorkspaceParentPath, creationCase); + await captureCliScreenshot(`create-workspace-${creationCase.label}-created`); + } + + await assertNoDialogAttempts('Create Workspace core project creation flows'); + }); + } + + if (shouldRunCreateWorkspaceGroup(createWorkspaceGroup, ['fixtures-manifest'])) { + test('Should create downstream-compatible workspace fixtures manifest from the webview', async function () { + this.timeout(700000); + + const manifestEntries: WorkspaceManifestEntry[] = []; + for (const creationCase of filterCreationCases(getFixtureManifestCreationCases(), createWorkspaceCaseFilter)) { + await createWorkspaceThroughWebview(creationCase, tempWorkspaceParentPath); + verifyCreatedWorkspace(tempWorkspaceParentPath, creationCase); + + const manifestEntry = buildWorkspaceManifestEntry(tempWorkspaceParentPath, creationCase); + assertWorkspaceManifestEntry(manifestEntry); + manifestEntries.push(manifestEntry); + writeFixtureManifest(manifestEntries); + await captureCliScreenshot(`create-workspace-fixtures-${creationCase.label}-created`); + } + + assertFixtureManifestComplete(manifestEntries, createWorkspaceCaseFilter); + await assertNoDialogAttempts('Create Workspace fixture manifest flows'); + }); + } + + if (shouldRunCreateWorkspaceGroup(createWorkspaceGroup, ['preview-matrix', 'full'])) { + test('Should create preview workflow type workspaces from the webview', async function () { + this.timeout(600000); + + for (const creationCase of filterCreationCases(getPreviewCreationCases(), createWorkspaceCaseFilter)) { + await createWorkspaceThroughWebview(creationCase, tempWorkspaceParentPath); + verifyCreatedWorkspace(tempWorkspaceParentPath, creationCase); + await captureCliScreenshot(`create-workspace-${creationCase.label}-created`); + } + + await assertNoDialogAttempts('Create Workspace preview project creation flows'); + }); + } + + if (shouldRunCreateWorkspaceGroup(createWorkspaceGroup, ['codeful', 'full'])) { + test('Should create modern and legacy-control codeful workspaces from the webview', async function () { + this.timeout(480000); + + for (const creationCase of filterCreationCases(getCodefulCreationCases(), createWorkspaceCaseFilter)) { + await createWorkspaceThroughWebview(creationCase, tempWorkspaceParentPath); + applyCodefulControlVariant(tempWorkspaceParentPath, creationCase); + verifyCreatedWorkspace(tempWorkspaceParentPath, creationCase); + await captureCliScreenshot(`create-workspace-${creationCase.label}-created`); + } + + await assertNoDialogAttempts('Create Workspace codeful project creation flows'); + }); + } +}); + +function getCreateWorkspaceGroup(): CreateWorkspaceGroup { + const group = process.env.LA_E2E_CLI_CREATE_WORKSPACE_GROUP; + if ( + group === 'behavior' || + group === 'core-matrix' || + group === 'preview-matrix' || + group === 'codeful' || + group === 'fixtures-manifest' || + group === 'full' + ) { + return group; + } + + return 'default'; +} + +function createWorkspaceParentPath(group: CreateWorkspaceGroup): string { + if (group !== 'fixtures-manifest') { + return fs.mkdtempSync(path.join(os.tmpdir(), 'la-e2e-cli-create-workspace-')); + } + + const parentPath = path.dirname(getFixtureManifestPath()); + fs.mkdirSync(parentPath, { recursive: true }); + return parentPath; +} + +function shouldRunCreateWorkspaceGroup(current: CreateWorkspaceGroup, groups: CreateWorkspaceGroup[]): boolean { + return groups.includes(current); +} + +function filterCreationCases(cases: WorkspaceCreationCase[], caseFilter: string | undefined): WorkspaceCreationCase[] { + if (!caseFilter) { + return cases; + } + + const labels = caseFilter + .split(',') + .map((label) => label.trim()) + .filter(Boolean); + return cases.filter((creationCase) => labels.includes(creationCase.label)); +} + +function getReviewBackCases(): WorkspaceCreationCase[] { + return [ + createWorkspaceCase('review-standard', 'standard', 'Logic app (Standard)', 'Stateful', 'clirvstd'), + createWorkspaceCase('review-custom-code', 'customCode', 'Logic app with custom code', 'Stateful', 'clirvcc'), + createWorkspaceCase('review-rules-engine', 'rulesEngine', 'Logic app with rules engine', 'Stateful', 'clirvre'), + ]; +} + +function getCoreCreationCases(): WorkspaceCreationCase[] { + return [ + createWorkspaceCase('standard-stateful', 'standard', 'Logic app (Standard)', 'Stateful', 'clistdsf'), + createWorkspaceCase('standard-stateless', 'standard', 'Logic app (Standard)', 'Stateless', 'clistdsl'), + createWorkspaceCase('custom-code-stateful', 'customCode', 'Logic app with custom code', 'Stateful', 'cliccsf'), + createWorkspaceCase('custom-code-stateless', 'customCode', 'Logic app with custom code', 'Stateless', 'cliccsl'), + createWorkspaceCase('rules-engine-stateful', 'rulesEngine', 'Logic app with rules engine', 'Stateful', 'cliresf'), + createWorkspaceCase('rules-engine-stateless', 'rulesEngine', 'Logic app with rules engine', 'Stateless', 'cliresl'), + ]; +} + +function getFixtureManifestCreationCases(): WorkspaceCreationCase[] { + return [ + createWorkspaceCase('standard-stateful', 'standard', 'Logic app (Standard)', 'Stateful', 'clifixstdsf'), + createWorkspaceCase('standard-stateless', 'standard', 'Logic app (Standard)', 'Stateless', 'clifixstdsl'), + createWorkspaceCase('custom-code-stateful', 'customCode', 'Logic app with custom code', 'Stateful', 'clifixccsf'), + createWorkspaceCase('rules-engine-stateful', 'rulesEngine', 'Logic app with rules engine', 'Stateful', 'clifixresf'), + ]; +} + +function getPreviewCreationCases(): WorkspaceCreationCase[] { + return [ + createWorkspaceCase('standard-autonomous-agent', 'standard', 'Logic app (Standard)', 'Autonomous agents (Preview)', 'clistdaa'), + createWorkspaceCase('standard-conversational-agent', 'standard', 'Logic app (Standard)', 'Conversational agents (Preview)', 'clistdca'), + createWorkspaceCase( + 'custom-code-autonomous-agent', + 'customCode', + 'Logic app with custom code', + 'Autonomous agents (Preview)', + 'cliccaa' + ), + createWorkspaceCase( + 'custom-code-conversational-agent', + 'customCode', + 'Logic app with custom code', + 'Conversational agents (Preview)', + 'cliccca' + ), + createWorkspaceCase( + 'rules-engine-autonomous-agent', + 'rulesEngine', + 'Logic app with rules engine', + 'Autonomous agents (Preview)', + 'clireaa' + ), + createWorkspaceCase( + 'rules-engine-conversational-agent', + 'rulesEngine', + 'Logic app with rules engine', + 'Conversational agents (Preview)', + 'clireca' + ), + ]; +} + +function getCodefulCreationCases(): WorkspaceCreationCase[] { + // The latest-stable @vscode/test-cli host exposes the same product picker as + // ExTester: one "Logic app (codeful)" radio option. ExTester selects the + // legacy-control variant by creating a second codeful workspace through that + // radio and patching only the generated .csproj target hooks afterward, so the + // CLI suite mirrors that parity shape here without changing product code. + const modern = createWorkspaceCase('codeful-modern-control', 'codeful', 'Logic app (codeful)', 'Stateful', 'clicodemodern'); + modern.codefulControlVariant = 'modern-control'; + + const legacy = createWorkspaceCase('codeful-legacy-control', 'codeful', 'Logic app (codeful)', 'Stateful', 'clicodelegacy'); + legacy.codefulControlVariant = 'legacy-control'; + + return [modern, legacy]; +} + +function createWorkspaceCase( + label: string, + appType: WorkspaceAppType, + radioLabel: string, + workflowType: WorkflowType, + prefix: string +): WorkspaceCreationCase { + const baseName = uniqueName(prefix); + const creationCase: WorkspaceCreationCase = { + label, + appType, + radioLabel, + wsName: `${baseName}ws`, + appName: `${baseName}app`, + wfName: `${baseName}wf`, + workflowType, + }; + + if (appType === 'customCode' || appType === 'rulesEngine') { + creationCase.functionFolderName = `${baseName}funcfolder`; + creationCase.functionNamespace = appType === 'rulesEngine' ? 'RulesEngineNamespace' : 'MyCompany.Functions'; + creationCase.functionName = `${baseName}fn`; + } + + return creationCase; +} + +function assertEmptyWorkspace(context: string): void { + assert.ok( + !vscode.workspace.workspaceFile || vscode.workspace.workspaceFile.scheme === 'untitled', + `No saved .code-workspace file should be loaded ${context}. Actual: ${vscode.workspace.workspaceFile?.toString()}` + ); + assert.deepStrictEqual(vscode.workspace.workspaceFolders ?? [], [], `No folders should be loaded ${context}`); +} + +async function runStandardFieldValidationCases(cdp: CdpEvaluator, contextId: number, validPath: string): Promise { + await runInvalidThenValidCase(cdp, contextId, { + name: 'workspace parent folder path rejects non-existent paths', + labels: 'Workspace parent folder path', + invalidValue: 'Z:\\nonexistent\\fake\\path\\that\\does\\not\\exist', + expectedMessage: 'not exist', + validValue: validPath, + }); + + await runEmptyThenValidCase(cdp, contextId, 'workspace parent folder path is required', 'Workspace parent folder path', validPath); + + await runNameFieldCases(cdp, contextId, 'workspace name', 'Workspace name', 'validws', [ + ['starts with number', '123invalid', nameValidationMessage], + ['contains spaces', 'my workspace', nameValidationMessage], + ['contains special characters', 'ws@#$name', nameValidationMessage], + ['starts with hyphen', '-leadinghyphen', nameValidationMessage], + ['starts with underscore', '_leadingunderscore', nameValidationMessage], + ['ends with hyphen', 'trailinghyphen-', nameValidationMessage], + ['contains dots', 'my.workspace', nameValidationMessage], + ['ends with underscore', 'myws_', nameValidationMessage], + ['is empty', '', emptyValidationMessage], + ]); + + await runNameFieldCases(cdp, contextId, 'logic app name', 'Logic app name', 'validapp', [ + ['starts with number', '999app', nameValidationMessage], + ['is empty', '', emptyValidationMessage], + ['contains special characters', 'app@name', nameValidationMessage], + ['contains spaces', 'my app', nameValidationMessage], + ['starts with underscore', '_myapp', nameValidationMessage], + ['starts with hyphen', '-myapp', nameValidationMessage], + ['ends with hyphen', 'myapp-', nameValidationMessage], + ]); + + await runNameFieldCases(cdp, contextId, 'workflow name', 'Workflow name', 'validwf', [ + ['starts with number', '123workflow', nameValidationMessage], + ['is empty', '', emptyValidationMessage], + ['contains special characters', 'wf@name', nameValidationMessage], + ['contains spaces', 'my workflow', nameValidationMessage], + ['starts with underscore', '_workflow', nameValidationMessage], + ['starts with hyphen', '-workflow', nameValidationMessage], + ['ends with hyphen', 'workflow-', nameValidationMessage], + ['is reserved Artifacts', 'Artifacts', reservedNameValidationMessage], + ['is reserved lib', 'lib', reservedNameValidationMessage], + ['is reserved artifacts lowercase', 'artifacts', reservedNameValidationMessage], + ['is reserved ARTIFACTS uppercase', 'ARTIFACTS', reservedNameValidationMessage], + ['is reserved workflow-designtime', 'workflow-designtime', reservedNameValidationMessage], + ['is reserved custom', 'custom', reservedNameValidationMessage], + ]); + + await enterFieldValue(cdp, contextId, 'Workflow name', 'la-trigger-github'); + await waitForFieldValidationMessageToClear(cdp, contextId, 'Workflow name', nameValidationMessage); + await selectRadioOption(cdp, contextId, 'Logic app (Standard)'); + await selectDropdownOption(cdp, contextId, 'Workflow type', 'Stateful'); + await waitForAsyncValidationToSettle(cdp, contextId); + await assertNextButtonEnabled(cdp, contextId, 'standard fields are valid'); +} + +async function runStandardRequiredFieldProgression(cdp: CdpEvaluator, contextId: number, validPath: string): Promise { + await selectRadioOption(cdp, contextId, 'Logic app (Standard)'); + await waitForFieldVisible(cdp, contextId, 'Workspace parent folder path'); + await waitForFieldVisible(cdp, contextId, 'Workspace name'); + await waitForFieldVisible(cdp, contextId, 'Logic app name'); + await waitForFieldVisible(cdp, contextId, 'Workflow name'); + + await enterFieldValue(cdp, contextId, 'Workspace parent folder path', ''); + await enterFieldValue(cdp, contextId, 'Workspace name', ''); + await enterFieldValue(cdp, contextId, 'Logic app name', ''); + await enterFieldValue(cdp, contextId, 'Workflow name', ''); + await assertNextButtonDisabled(cdp, contextId, 'standard progression: all required fields empty'); + + await enterFieldValue(cdp, contextId, 'Workspace parent folder path', validPath); + await waitForAsyncValidationToSettle(cdp, contextId); + await assertNextButtonDisabled(cdp, contextId, 'standard progression: path only'); + + await enterFieldValue(cdp, contextId, 'Workspace name', uniqueName('stdprogws')); + await waitForAsyncValidationToSettle(cdp, contextId); + await assertNextButtonDisabled(cdp, contextId, 'standard progression: path and workspace'); + + await enterFieldValue(cdp, contextId, 'Logic app name', uniqueName('stdprogapp')); + await waitForAsyncValidationToSettle(cdp, contextId); + await assertNextButtonDisabled(cdp, contextId, 'standard progression: path, workspace, and app'); + + await enterFieldValue(cdp, contextId, 'Workflow name', '!!!invalid'); + await waitForFieldValidationMessage(cdp, contextId, 'Workflow name', nameValidationMessage); + await assertNextButtonDisabled(cdp, contextId, 'standard progression: invalid workflow name'); + + await enterFieldValue(cdp, contextId, 'Workflow name', uniqueName('stdprogwf')); + await waitForFieldValidationMessageToClear(cdp, contextId, 'Workflow name', nameValidationMessage); + await selectDropdownOption(cdp, contextId, 'Workflow type', 'Stateful'); + await waitForAsyncValidationToSettle(cdp, contextId); + await assertNextButtonEnabled(cdp, contextId, 'standard progression: all required fields valid'); +} + +async function runCustomCodeFieldValidationCases(cdp: CdpEvaluator, contextId: number, validPath: string): Promise { + await seedStandardFields(cdp, contextId, validPath); + await selectRadioOption(cdp, contextId, 'Logic app with custom code'); + await waitForFieldVisible(cdp, contextId, ['Custom code folder name', 'custom code folder', 'Code folder name', 'Folder name']); + await selectDropdownOption(cdp, contextId, '.NET Version', '.NET 8'); + + const customCodeFolderLabels = ['Custom code folder name', 'custom code folder', 'Code folder name', 'Folder name']; + await runNameFieldCases(cdp, contextId, 'custom code folder name', customCodeFolderLabels, 'validfolder', [ + ['starts with number', '123folder', nameValidationMessage], + ['matches logic app name', 'validapp', sameAsLogicAppValidationMessage], + ['is empty', '', emptyValidationMessage], + ['contains special characters', 'folder@name', nameValidationMessage], + ['contains spaces', 'my folder', nameValidationMessage], + ['starts with underscore', '_folder', nameValidationMessage], + ['ends with hyphen', 'folder-', nameValidationMessage], + ]); + + await runNameFieldCases( + cdp, + contextId, + 'custom code function namespace', + ['Function namespace', 'Namespace', 'namespace'], + 'ValidNamespace', + [ + ['starts with number', '123.Bad.Namespace', namespaceValidationMessage], + ['contains hyphen', 'Invalid-Namespace', namespaceValidationMessage], + ['is empty', '', emptyValidationMessage], + ] + ); + await enterFieldValue(cdp, contextId, ['Function namespace', 'Namespace', 'namespace'], 'MyCompany.Functions'); + await waitForFieldValidationMessageToClear(cdp, contextId, ['Function namespace', 'Namespace', 'namespace'], namespaceValidationMessage); + + await runNameFieldCases(cdp, contextId, 'custom code function name', 'Function name', 'validfn', [ + ['starts with number', '999func', nameValidationMessage], + ['is empty', '', emptyValidationMessage], + ['contains hyphen', 'my-func', nameValidationMessage], + ['contains special characters', 'func@name', nameValidationMessage], + ['contains spaces', 'my func', nameValidationMessage], + ['contains dots', 'my.func', nameValidationMessage], + ['starts with underscore', '_func', nameValidationMessage], + ]); + + await runThreeRequiredFieldGatingCases(cdp, contextId, 'custom code fields', { + first: { labels: customCodeFolderLabels, validValue: 'validfolder' }, + second: { labels: ['Function namespace', 'Namespace', 'namespace'], validValue: 'ValidNamespace' }, + third: { labels: 'Function name', validValue: 'validfn' }, + }); +} + +async function runRulesEngineFieldValidationCases(cdp: CdpEvaluator, contextId: number, validPath: string): Promise { + await seedStandardFields(cdp, contextId, validPath); + await selectRadioOption(cdp, contextId, 'Logic app with rules engine'); + await waitForFieldVisible(cdp, contextId, ['Rules engine folder name', 'rules engine folder', 'Folder name']); + + const rulesEngineFolderLabels = ['Rules engine folder name', 'rules engine folder', 'Folder name']; + await runNameFieldCases(cdp, contextId, 'rules engine folder name', rulesEngineFolderLabels, 'validrefolder', [ + ['starts with number', '123folder', nameValidationMessage], + ['matches logic app name', 'validapp', sameAsLogicAppValidationMessage], + ['is empty', '', emptyValidationMessage], + ['contains special characters', 'folder@name', nameValidationMessage], + ['contains spaces', 'my folder', nameValidationMessage], + ['starts with underscore', '_folder', nameValidationMessage], + ['ends with hyphen', 'folder-', nameValidationMessage], + ]); + + await runNameFieldCases( + cdp, + contextId, + 'rules engine function namespace', + ['Function namespace', 'Namespace', 'namespace'], + 'ValidNamespace', + [ + ['contains hyphen', 'Invalid-Namespace', namespaceValidationMessage], + ['starts with number', '123.Bad.Namespace', namespaceValidationMessage], + ['is empty', '', emptyValidationMessage], + ] + ); + + await runNameFieldCases(cdp, contextId, 'rules engine function name', 'Function name', 'validfn', [ + ['starts with number', '999func', nameValidationMessage], + ['is empty', '', emptyValidationMessage], + ['contains hyphen', 'my-func', nameValidationMessage], + ['contains special characters', 'func@name', nameValidationMessage], + ['contains spaces', 'my func', nameValidationMessage], + ['contains dots', 'my.func', nameValidationMessage], + ['starts with underscore', '_func', nameValidationMessage], + ]); + + await runThreeRequiredFieldGatingCases(cdp, contextId, 'rules engine fields', { + first: { labels: rulesEngineFolderLabels, validValue: 'validrefolder' }, + second: { labels: ['Function namespace', 'Namespace', 'namespace'], validValue: 'ValidNamespace' }, + third: { labels: 'Function name', validValue: 'validfn' }, + }); +} + +async function seedStandardFields(cdp: CdpEvaluator, contextId: number, validPath: string): Promise { + await enterFieldValue(cdp, contextId, 'Workspace parent folder path', validPath); + await waitForAsyncValidationToSettle(cdp, contextId); + await enterFieldValue(cdp, contextId, 'Workspace name', 'validws'); + await waitForAsyncValidationToSettle(cdp, contextId); + await enterFieldValue(cdp, contextId, 'Logic app name', 'validapp'); + await selectRadioOption(cdp, contextId, 'Logic app (Standard)'); + await enterFieldValue(cdp, contextId, 'Workflow name', 'validwf'); + await selectDropdownOption(cdp, contextId, 'Workflow type', 'Stateful'); +} + +async function createWorkspaceThroughWebview(creationCase: WorkspaceCreationCase, parentPath: string): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= 2; attempt++) { + const { cdp, contextId } = await openCreateWorkspaceContext(); + let submitted = false; + try { + await fillWorkspaceCreationFields(cdp, contextId, creationCase, parentPath); + await assertNextButtonEnabled(cdp, contextId, `${creationCase.label} creation fields`); + await clickWizardButton(cdp, contextId, 'Next'); + await waitForReviewStep(cdp, contextId, creationCase); + await captureCliScreenshot(`create-workspace-${creationCase.label}-review`); + submitted = true; + await clickWizardButton(cdp, contextId, 'Create workspace'); + await waitForWorkspaceFile(parentPath, creationCase.wsName); + await waitForWorkspaceArtifacts(parentPath, creationCase); + return; + } catch (error) { + if (submitted || attempt === 2) { + throw error; + } + + lastError = error; + console.warn(`[create-workspace-smoke] Retrying ${creationCase.label} creation after pre-submit webview failure: ${String(error)}`); + } finally { + cdp.dispose(); + } + } + + throw lastError; +} + +async function openCreateWorkspaceContext(): Promise<{ cdp: CdpEvaluator & { dispose(): void }; contextId: number }> { + await closeWebviewTabs(createWorkspaceViewType); + const tabsBefore = getWebviewTabs(createWorkspaceViewType).length; + + await vscode.commands.executeCommand(createWorkspaceCommand); + + const tab = await waitForWebviewTab(createWorkspaceViewType, tabsBefore); + assert.strictEqual(getTabViewType(tab), createWorkspaceTabViewType); + assert.strictEqual(tab.label, createWorkspaceTitle); + + const cdp = await connectToVsCodeCdp(); + const contextId = await waitForCreateWorkspaceFrameContext(cdp); + return { cdp, contextId }; +} + +async function fillWorkspaceCreationFields( + cdp: CdpEvaluator, + contextId: number, + creationCase: WorkspaceCreationCase, + parentPath: string +): Promise { + await waitForFieldVisible(cdp, contextId, 'Workspace parent folder path'); + await enterFieldValue(cdp, contextId, 'Workspace parent folder path', parentPath); + await waitForAsyncValidationToSettle(cdp, contextId); + await waitForFieldVisible(cdp, contextId, 'Workspace name'); + await enterFieldValue(cdp, contextId, 'Workspace name', creationCase.wsName); + await waitForAsyncValidationToSettle(cdp, contextId); + await waitForFieldVisible(cdp, contextId, 'Logic app name'); + await enterFieldValue(cdp, contextId, 'Logic app name', creationCase.appName); + await waitForFieldVisible(cdp, contextId, 'Workflow name'); + await enterFieldValue(cdp, contextId, 'Workflow name', creationCase.wfName); + await selectDropdownOption(cdp, contextId, 'Workflow type', creationCase.workflowType); + await selectRadioOption(cdp, contextId, creationCase.radioLabel); + + if (creationCase.appType === 'customCode') { + await waitForFieldVisible(cdp, contextId, ['Custom code folder name', 'custom code folder', 'Code folder name', 'Folder name']); + await selectDropdownOption(cdp, contextId, '.NET Version', '.NET 8'); + await enterFieldValue( + cdp, + contextId, + ['Custom code folder name', 'custom code folder', 'Code folder name', 'Folder name'], + requiredValue(creationCase.functionFolderName) + ); + await enterFieldValue(cdp, contextId, ['Function namespace', 'Namespace', 'namespace'], requiredValue(creationCase.functionNamespace)); + await enterFieldValue(cdp, contextId, 'Function name', requiredValue(creationCase.functionName)); + } else if (creationCase.appType === 'rulesEngine') { + await waitForFieldVisible(cdp, contextId, ['Rules engine folder name', 'rules engine folder', 'Folder name']); + await enterFieldValue( + cdp, + contextId, + ['Rules engine folder name', 'rules engine folder', 'Folder name'], + requiredValue(creationCase.functionFolderName) + ); + await enterFieldValue(cdp, contextId, ['Function namespace', 'Namespace', 'namespace'], requiredValue(creationCase.functionNamespace)); + await enterFieldValue(cdp, contextId, 'Function name', requiredValue(creationCase.functionName)); + } + + if (creationCase.appType !== 'codeful' && !(await isDropdownValueSelected(cdp, contextId, 'Workflow type', creationCase.workflowType))) { + await selectDropdownOption(cdp, contextId, 'Workflow type', creationCase.workflowType); + } + + await waitForAsyncValidationToSettle(cdp, contextId); + await assertWorkspaceCreationFields(cdp, contextId, creationCase, parentPath); + await captureWorkspaceCreationFormScreenshots(cdp, contextId, creationCase.label, 'fields-verified'); +} + +async function assertWorkspaceCreationFields( + cdp: CdpEvaluator, + contextId: number, + creationCase: WorkspaceCreationCase, + parentPath: string +): Promise { + const expectedFields: Array<{ labels: FieldLabels; value: string }> = [ + { labels: 'Workspace parent folder path', value: parentPath }, + { labels: 'Workspace name', value: creationCase.wsName }, + { labels: 'Logic app name', value: creationCase.appName }, + { labels: 'Workflow name', value: creationCase.wfName }, + ]; + + if (creationCase.appType === 'customCode') { + expectedFields.push( + { + labels: ['Custom code folder name', 'custom code folder', 'Code folder name', 'Folder name'], + value: requiredValue(creationCase.functionFolderName), + }, + { labels: ['Function namespace', 'Namespace', 'namespace'], value: requiredValue(creationCase.functionNamespace) }, + { labels: 'Function name', value: requiredValue(creationCase.functionName) } + ); + } else if (creationCase.appType === 'rulesEngine') { + expectedFields.push( + { + labels: ['Rules engine folder name', 'rules engine folder', 'Folder name'], + value: requiredValue(creationCase.functionFolderName), + }, + { labels: ['Function namespace', 'Namespace', 'namespace'], value: requiredValue(creationCase.functionNamespace) }, + { labels: 'Function name', value: requiredValue(creationCase.functionName) } + ); + } + + for (const field of expectedFields) { + const state = await getFieldState(cdp, contextId, field.labels); + assert.strictEqual( + state.value, + field.value, + `Expected ${creationCase.label} field ${getLabels(field.labels).join('/')} to equal ${field.value}. State=${JSON.stringify(state)}` + ); + } + + if (creationCase.appType !== 'codeful') { + assert.ok( + await isDropdownValueSelected(cdp, contextId, 'Workflow type', creationCase.workflowType), + `Expected ${creationCase.label} Workflow type dropdown to be ${creationCase.workflowType}` + ); + } + assert.ok( + await isRadioOptionChecked(cdp, contextId, creationCase.radioLabel), + `Expected ${creationCase.label} app type radio to be checked` + ); + + if (creationCase.appType === 'customCode') { + assert.ok(await isDropdownValueSelected(cdp, contextId, '.NET Version', '.NET 8'), 'Expected custom-code .NET Version to be .NET 8'); + } +} + +async function captureWorkspaceCreationFormScreenshots(cdp: CdpEvaluator, contextId: number, label: string, stage: string): Promise { + for (const position of ['top', 'middle', 'bottom']) { + await scrollCreateWorkspaceForm(cdp, contextId, position); + await captureCliScreenshot(`create-workspace-${label}-${stage}-${position}`); + } +} + +async function scrollCreateWorkspaceForm(cdp: CdpEvaluator, contextId: number, position: string): Promise { + await cdp.evaluate( + contextId, + `(() => { + const position = ${JSON.stringify(position)}; + const scrollableElements = Array.from(document.querySelectorAll('*')) + .filter((element) => element instanceof HTMLElement && element.scrollHeight > element.clientHeight + 20); + const scrollable = scrollableElements + .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || document.scrollingElement; + if (!scrollable) { + return; + } + const maxScrollTop = scrollable.scrollHeight - scrollable.clientHeight; + const top = position === 'top' ? 0 : position === 'middle' ? Math.floor(maxScrollTop / 2) : maxScrollTop; + scrollable.scrollTo({ top, behavior: 'instant' }); + })()` + ); + await new Promise((resolve) => setTimeout(resolve, 250)); +} + +async function clickWizardButton(cdp: CdpEvaluator, contextId: number, buttonText: string): Promise { + const clickResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; point?: Point }>( + contextId, + `(() => { + const expected = ${JSON.stringify(buttonText)}; + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const button = Array.from(document.querySelectorAll('button')) + .filter(isVisible) + .find((candidate) => (candidate.textContent || '').includes(expected)); + if (!(button instanceof HTMLButtonElement)) { + return { ok: false, reason: 'Button not found', text: document.body?.innerText || '' }; + } + if (button.disabled || button.getAttribute('aria-disabled') === 'true') { + return { ok: false, reason: 'Button is disabled', text: document.body?.innerText || '' }; + } + button.scrollIntoView({ block: 'center', inline: 'center' }); + button.focus(); + const rect = button.getBoundingClientRect(); + return { ok: true, text: document.body?.innerText || '', point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } }; + })()` + ); + + assert.strictEqual(clickResult.ok, true, clickResult.reason ?? `Failed to click "${buttonText}" button. Text: ${clickResult.text ?? ''}`); + assert.ok(clickResult.point, `Failed to locate "${buttonText}" button click point.`); + await clickPoint(cdp, clickResult.point); +} + +async function assertInitialCreateWorkspaceContent(cdp: CdpEvaluator, contextId: number): Promise { + const pageText = await getPageText(cdp, contextId); + const expectedText = [ + 'Create logic app workspace', + 'Workspace parent folder path', + 'Workspace name', + 'Logic app name', + 'Logic app (Standard)', + 'Logic app (codeful)', + 'Logic app with custom code', + 'Logic app with rules engine', + 'Workflow name', + 'Workflow type', + 'Browse', + ]; + + for (const text of expectedText) { + assert.ok(containsIgnoreCase(pageText, text), `Initial Create Workspace page should include "${text}". Text: ${pageText}`); + } + + assert.ok(!containsIgnoreCase(pageText, 'Package path'), `Create Workspace flow should not show package path fields. Text: ${pageText}`); + await assertNextButtonDisabled(cdp, contextId, 'initial form'); + await assertWizardButtonDisabledOrAbsent(cdp, contextId, 'Back', 'initial form'); + await assertDropdownHasOptions(cdp, contextId, 'Workflow type', [ + 'Stateful', + 'Stateless', + 'Autonomous agents (Preview)', + 'Conversational agents (Preview)', + ]); +} + +async function goToReviewAndBack(cdp: CdpEvaluator, contextId: number, creationCase: WorkspaceCreationCase): Promise { + await clickWizardButton(cdp, contextId, 'Next'); + await waitForReviewStep(cdp, contextId, creationCase); + await captureCliScreenshot(`create-workspace-${creationCase.label}-review-before-back`); + await clickWizardButton(cdp, contextId, 'Back'); + await waitForFormStep(cdp, contextId, creationCase); +} + +async function waitForFormStep(cdp: CdpEvaluator, contextId: number, creationCase: WorkspaceCreationCase): Promise { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const fieldState = await getFieldState(cdp, contextId, 'Workspace name').catch(() => undefined); + if (fieldState?.value === creationCase.wsName) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const pageText = await getPageText(cdp, contextId); + assert.fail(`Timed out waiting to return to ${creationCase.label} form step. Text: ${pageText}`); +} + +async function verifyAppTypeCleanup(parentPath: string): Promise { + const { cdp, contextId } = await openCreateWorkspaceContext(); + try { + const cleanupCase = createWorkspaceCase('cleanup-custom-code', 'customCode', 'Logic app with custom code', 'Stateful', 'clicleanup'); + await fillWorkspaceCreationFields(cdp, contextId, cleanupCase, parentPath); + await selectRadioOption(cdp, contextId, 'Logic app (Standard)'); + await waitForFieldHidden(cdp, contextId, ['Custom code folder name', 'custom code folder', 'Code folder name', 'Folder name']); + await waitForFieldHidden(cdp, contextId, ['Function namespace', 'Namespace', 'namespace']); + await waitForFieldHidden(cdp, contextId, 'Function name'); + await assertNextButtonEnabled(cdp, contextId, 'standard fields after custom-code cleanup'); + + await selectRadioOption(cdp, contextId, 'Logic app with rules engine'); + await waitForFieldVisible(cdp, contextId, ['Rules engine folder name', 'rules engine folder', 'Folder name']); + await selectRadioOption(cdp, contextId, 'Logic app (Standard)'); + await waitForFieldHidden(cdp, contextId, ['Rules engine folder name', 'rules engine folder', 'Folder name']); + await assertNextButtonEnabled(cdp, contextId, 'standard fields after rules-engine cleanup'); + await captureCliScreenshot('create-workspace-app-type-cleanup'); + } finally { + cdp.dispose(); + } +} + +async function verifyWorkflowTypeDescriptionAndReview(parentPath: string): Promise { + const workflowTypeCases: Array<{ label: string; workflowType: WorkflowType; prefix: string; selectedTextFragment: string }> = [ + { label: 'workflow-type-stateless', workflowType: 'Stateless', prefix: 'cliwfsl', selectedTextFragment: 'Stateless' }, + { + label: 'workflow-type-autonomous-agent', + workflowType: 'Autonomous agents (Preview)', + prefix: 'cliwfaa', + selectedTextFragment: 'Autonomous', + }, + { + label: 'workflow-type-conversational-agent', + workflowType: 'Conversational agents (Preview)', + prefix: 'cliwfca', + selectedTextFragment: 'Conversational', + }, + ]; + + for (const workflowTypeCase of workflowTypeCases) { + const { cdp, contextId } = await openCreateWorkspaceContext(); + try { + const creationCase = createWorkspaceCase( + workflowTypeCase.label, + 'standard', + 'Logic app (Standard)', + workflowTypeCase.workflowType, + workflowTypeCase.prefix + ); + await fillWorkspaceCreationFields(cdp, contextId, creationCase, parentPath); + await assertSelectedWorkflowTypeDescriptionVisible( + cdp, + contextId, + workflowTypeCase.workflowType, + workflowTypeCase.selectedTextFragment + ); + await clickWizardButton(cdp, contextId, 'Next'); + await waitForReviewStep(cdp, contextId, creationCase); + await assertReviewContainsWorkflowType(cdp, contextId, creationCase.workflowType); + await captureCliScreenshot(`create-workspace-${workflowTypeCase.label}-review`); + } finally { + cdp.dispose(); + } + } +} + +async function waitForReviewStep(cdp: CdpEvaluator, contextId: number, creationCase: WorkspaceCreationCase): Promise { + const expectedValues = [ + creationCase.wsName, + creationCase.appName, + creationCase.wfName, + getReviewWorkflowTypeText(creationCase.workflowType), + creationCase.functionFolderName, + creationCase.functionNamespace, + creationCase.functionName, + ].filter((value): value is string => !!value); + const deadline = Date.now() + 15000; + + while (Date.now() < deadline) { + const pageText = await getPageText(cdp, contextId); + const onReviewStep = containsIgnoreCase(pageText, 'Review') && containsIgnoreCase(pageText, 'Create workspace'); + const valuesPresent = expectedValues.every((value) => pageText.includes(value)); + if (onReviewStep && valuesPresent) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const pageText = await getPageText(cdp, contextId); + assert.fail(`Timed out waiting for ${creationCase.label} review step. Text: ${pageText}`); +} + +async function assertSelectedWorkflowTypeDescriptionVisible( + cdp: CdpEvaluator, + contextId: number, + workflowType: WorkflowType, + selectedTextFragment: string +): Promise { + // ExTester's stable parity assertion is that the selected workflow type text is visible + // on the setup form before moving to review; do not depend on layout-specific copy nodes. + assert.ok( + await isDropdownValueSelected(cdp, contextId, 'Workflow type', workflowType), + `Expected Workflow type dropdown to show "${workflowType}"` + ); + + const pageText = await getPageText(cdp, contextId); + assert.ok( + containsIgnoreCase(pageText, selectedTextFragment), + `Expected selected workflow type text containing "${selectedTextFragment}" to be visible before review. Text: ${pageText}` + ); +} + +async function assertReviewContainsWorkflowType(cdp: CdpEvaluator, contextId: number, workflowType: WorkflowType): Promise { + const pageText = await getPageText(cdp, contextId); + const reviewWorkflowTypeText = getReviewWorkflowTypeText(workflowType); + assert.ok( + containsIgnoreCase(pageText, reviewWorkflowTypeText), + `Expected review step to include workflow type "${reviewWorkflowTypeText}" for "${workflowType}". Text: ${pageText}` + ); +} + +function getReviewWorkflowTypeText(workflowType: WorkflowType): string { + if (workflowType === 'Autonomous agents (Preview)') { + return 'Autonomous'; + } + + if (workflowType === 'Conversational agents (Preview)') { + return 'Conversational'; + } + + return workflowType; +} + +async function waitForWorkspaceFile(parentPath: string, wsName: string): Promise { + const workspaceFilePath = path.join(parentPath, wsName, `${wsName}.code-workspace`); + await waitForPathExists(workspaceFilePath, 45000); +} + +async function waitForWorkspaceArtifacts(parentPath: string, creationCase: WorkspaceCreationCase): Promise { + const workspaceDir = path.join(parentPath, creationCase.wsName); + const appDir = path.join(workspaceDir, creationCase.appName); + await waitForPathExists(path.join(appDir, 'host.json'), 45000); + await waitForPathExists(path.join(appDir, 'local.settings.json'), 45000); + await waitForVsCodeArtifacts(appDir); + + if (creationCase.appType === 'codeful') { + await waitForPathExists(path.join(appDir, `${creationCase.wfName}.cs`), 45000); + await waitForPathExists(path.join(appDir, `${creationCase.appName}.csproj`), 45000); + await waitForPathExists(path.join(appDir, 'Program.cs'), 45000); + return; + } + + await waitForPathExists(path.join(appDir, creationCase.wfName, 'workflow.json'), 45000); + + if (creationCase.appType === 'customCode' || creationCase.appType === 'rulesEngine') { + const functionFolderName = requiredValue(creationCase.functionFolderName); + const functionName = requiredValue(creationCase.functionName); + const functionDir = path.join(workspaceDir, functionFolderName); + await waitForPathExists(path.join(functionDir, `${functionName}.cs`), 45000); + await waitForPathExists(path.join(functionDir, `${functionName}.csproj`), 45000); + await waitForFunctionVsCodeArtifacts(functionDir); + } + + if (creationCase.appType === 'rulesEngine') { + await waitForPathExists(path.join(appDir, 'Artifacts', 'Rules', 'SampleRuleSet.xml'), 45000); + await waitForPathExists(path.join(appDir, 'Artifacts', 'Schemas', 'SchemaUser.xsd'), 45000); + } +} + +async function waitForPathExists(filePath: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (fs.existsSync(filePath)) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + const parentPath = path.dirname(filePath); + const parentContents = fs.existsSync(parentPath) ? fs.readdirSync(parentPath) : ['(parent missing)']; + assert.fail(`Timed out waiting for generated path ${filePath}. Parent contents: ${JSON.stringify(parentContents)}`); +} + +function verifyCreatedWorkspace(parentPath: string, creationCase: WorkspaceCreationCase): void { + const workspaceDir = path.join(parentPath, creationCase.wsName); + const workspaceFilePath = path.join(workspaceDir, `${creationCase.wsName}.code-workspace`); + const appDir = path.join(workspaceDir, creationCase.appName); + const workflowJsonPath = path.join(appDir, creationCase.wfName, 'workflow.json'); + + assert.ok(fs.existsSync(workspaceDir), `Workspace directory should exist: ${workspaceDir}`); + assert.ok(fs.existsSync(workspaceFilePath), `.code-workspace file should exist: ${workspaceFilePath}`); + assert.ok(fs.existsSync(appDir), `Logic app directory should exist: ${appDir}`); + assert.ok(fs.existsSync(path.join(appDir, 'host.json')), `host.json should exist under ${appDir}`); + assert.ok(fs.existsSync(path.join(appDir, 'local.settings.json')), `local.settings.json should exist under ${appDir}`); + verifyLogicAppVsCodeArtifacts(appDir, creationCase); + + const workspaceContent = readJsonFile(workspaceFilePath); + const folderNames = (workspaceContent.folders ?? []).map((folder) => folder.name); + assert.ok(folderNames.includes(creationCase.appName), `.code-workspace should include logic app folder ${creationCase.appName}`); + assertWorkspaceFolderPath(workspaceFilePath, workspaceContent, creationCase.appName, appDir, creationCase); + + if (creationCase.appType === 'codeful') { + verifyCodefulProject(appDir, creationCase); + assert.ok(!fs.existsSync(workflowJsonPath), `Codeful workspace should not generate codeless workflow.json: ${workflowJsonPath}`); + return; + } + + assert.ok(fs.existsSync(workflowJsonPath), `workflow.json should exist: ${workflowJsonPath}`); + const workflowJson = JSON.parse(fs.readFileSync(workflowJsonPath, 'utf-8')) as WorkflowJson; + assert.strictEqual( + workflowJson.kind, + getExpectedWorkflowKind(creationCase.workflowType), + `${creationCase.label} workflow kind should match ${creationCase.workflowType}` + ); + + verifyWorkflowDefinitionShape(workflowJson, creationCase); + + if (creationCase.appType === 'customCode') { + verifyFunctionProject(workspaceDir, creationCase, workspaceContent); + } else if (creationCase.appType === 'rulesEngine') { + verifyFunctionProject(workspaceDir, creationCase, workspaceContent); + assert.ok( + fs.existsSync(path.join(appDir, 'Artifacts', 'Rules', 'SampleRuleSet.xml')), + 'Rules engine workspace should include Artifacts\\Rules\\SampleRuleSet.xml' + ); + assert.ok( + fs.existsSync(path.join(appDir, 'Artifacts', 'Schemas', 'SchemaUser.xsd')), + 'Rules engine workspace should include Artifacts\\Schemas\\SchemaUser.xsd' + ); + } +} + +function assertWorkspaceFolderPath( + workspaceFilePath: string, + workspaceContent: WorkspaceJson, + folderName: string, + expectedPath: string, + creationCase: WorkspaceCreationCase +): void { + const folder = (workspaceContent.folders ?? []).find((candidate) => candidate.name === folderName); + assert.ok(folder, `${creationCase.label} .code-workspace should include folder ${folderName}`); + assert.strictEqual(typeof folder.path, 'string', `${creationCase.label} .code-workspace folder ${folderName} should include a path`); + + const folderPath = folder.path; + assert.ok(folderPath, `${creationCase.label} .code-workspace folder ${folderName} should include a non-empty path`); + const actualPath = path.resolve(path.dirname(workspaceFilePath), folderPath); + assert.strictEqual( + actualPath.toLowerCase(), + expectedPath.toLowerCase(), + `${creationCase.label} .code-workspace folder ${folderName} should resolve to ${expectedPath}` + ); +} + +function getFixtureManifestPath(): string { + return process.env.LA_E2E_CLI_CREATE_WORKSPACE_FIXTURE_MANIFEST ?? path.join(os.tmpdir(), 'la-e2e-test', 'created-workspaces.json'); +} + +function clearFixtureManifest(): void { + const manifestPath = getFixtureManifestPath(); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + if (fs.existsSync(manifestPath)) { + fs.unlinkSync(manifestPath); + console.log(`[create-workspace-fixtures] Cleared stale manifest at ${manifestPath}`); + } +} + +function buildWorkspaceManifestEntry(parentPath: string, creationCase: WorkspaceCreationCase): WorkspaceManifestEntry { + const wsDir = path.join(parentPath, creationCase.wsName); + const appDir = path.join(wsDir, creationCase.appName); + const entry: WorkspaceManifestEntry = { + label: getFixtureManifestLabel(creationCase), + parentDir: parentPath, + wsName: creationCase.wsName, + appName: creationCase.appName, + wfName: creationCase.wfName, + appType: creationCase.appType, + wfType: creationCase.workflowType, + wsDir, + wsFilePath: path.join(wsDir, `${creationCase.wsName}.code-workspace`), + appDir, + wfDir: path.join(appDir, creationCase.wfName), + createdAt: new Date().toISOString(), + }; + + if (creationCase.functionFolderName) { + entry.ccFolderName = creationCase.functionFolderName; + } + if (creationCase.functionName) { + entry.fnName = creationCase.functionName; + } + if (creationCase.functionNamespace) { + entry.fnNamespace = creationCase.functionNamespace; + } + + return entry; +} + +function getFixtureManifestLabel(creationCase: WorkspaceCreationCase): string { + if (creationCase.appType === 'standard') { + return `Standard + ${creationCase.workflowType}`; + } + if (creationCase.appType === 'customCode') { + return `CustomCode + ${creationCase.workflowType}`; + } + if (creationCase.appType === 'rulesEngine') { + return `RulesEngine + ${creationCase.workflowType}`; + } + + return `Codeful + ${creationCase.workflowType}`; +} + +function writeFixtureManifest(entries: WorkspaceManifestEntry[]): void { + const manifestPath = getFixtureManifestPath(); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, `${JSON.stringify(entries, null, 2)}\n`, 'utf-8'); + console.log(`[create-workspace-fixtures] Wrote ${entries.length} manifest entries to ${manifestPath}`); +} + +async function waitForVsCodeArtifacts(projectDir: string): Promise { + const vscodeDir = path.join(projectDir, '.vscode'); + await waitForPathExists(path.join(vscodeDir, 'settings.json'), 45000); + await waitForPathExists(path.join(vscodeDir, 'extensions.json'), 45000); + await waitForPathExists(path.join(vscodeDir, 'tasks.json'), 45000); + await waitForPathExists(path.join(vscodeDir, 'launch.json'), 45000); +} + +async function waitForFunctionVsCodeArtifacts(functionDir: string): Promise { + const vscodeDir = path.join(functionDir, '.vscode'); + await waitForPathExists(path.join(vscodeDir, 'settings.json'), 45000); + await waitForPathExists(path.join(vscodeDir, 'extensions.json'), 45000); + await waitForPathExists(path.join(vscodeDir, 'tasks.json'), 45000); +} + +function verifyLogicAppVsCodeArtifacts(appDir: string, creationCase: WorkspaceCreationCase): void { + const vscodeDir = path.join(appDir, '.vscode'); + for (const fileName of ['settings.json', 'extensions.json', 'tasks.json', 'launch.json']) { + assert.ok(fs.existsSync(path.join(vscodeDir, fileName)), `${creationCase.label} should generate .vscode/${fileName}`); + } + + const settings = readJsonFile>(path.join(vscodeDir, 'settings.json')); + const extensions = readJsonFile(path.join(vscodeDir, 'extensions.json')); + const tasks = readJsonFile(path.join(vscodeDir, 'tasks.json')); + const launch = readJsonFile(path.join(vscodeDir, 'launch.json')); + + verifyLogicAppSettings(settings, creationCase); + verifyLogicAppExtensionRecommendations(extensions, creationCase); + verifyLogicAppTasks(tasks, creationCase); + verifyLogicAppLaunch(launch, creationCase); +} + +function verifyLogicAppSettings(settings: Record, creationCase: WorkspaceCreationCase): void { + const expectedLanguage = creationCase.appType === 'codeful' ? 'C#' : 'JavaScript'; + assert.strictEqual( + settings[logicAppsProjectLanguageSetting], + expectedLanguage, + `${creationCase.label} settings should set Logic Apps project language` + ); + assert.strictEqual(settings[logicAppsProjectRuntimeSetting], '~4', `${creationCase.label} settings should set Functions runtime ~4`); + assert.strictEqual( + settings['debug.internalConsoleOptions'], + 'neverOpen', + `${creationCase.label} settings should suppress debug console auto-open` + ); + assert.strictEqual( + settings['azureFunctions.suppressProject'], + true, + `${creationCase.label} settings should suppress Azure Functions project prompts` + ); + + if (creationCase.appType === 'standard' || creationCase.appType === 'codeful') { + assert.strictEqual(settings[logicAppsDeploySubpathSetting], '.', `${creationCase.label} settings should deploy from the project root`); + } + + if (creationCase.appType === 'codeful') { + assert.strictEqual( + settings['omnisharp.enableMsBuildLoadProjectsOnDemand'], + false, + `${creationCase.label} settings should disable OmniSharp on-demand project loading` + ); + assert.strictEqual( + settings['omnisharp.disableMSBuildDiagnosticWarning'], + true, + `${creationCase.label} settings should suppress OmniSharp MSBuild diagnostics` + ); + } +} + +function verifyLogicAppExtensionRecommendations(extensions: ExtensionsJson, creationCase: WorkspaceCreationCase): void { + assertRecommendations( + extensions, + [logicAppsExtensionId, dotnetExtensionId, functionsExtensionId, csDevKitExtensionId], + `${creationCase.label} .vscode/extensions.json` + ); +} + +function verifyLogicAppLaunch(launch: LaunchJson, creationCase: WorkspaceCreationCase): void { + assert.strictEqual(launch.version, '0.2.0', `${creationCase.label} launch.json should use VS Code launch schema 0.2.0`); + const configurations = assertRecordArray(launch.configurations, `${creationCase.label} launch.json configurations`); + assert.strictEqual(configurations.length, 1, `${creationCase.label} launch.json should contain one generated debug configuration`); + const configuration = configurations[0]; + assert.ok(configuration, `${creationCase.label} launch.json should include a debug configuration`); + + if (creationCase.appType === 'standard') { + assert.strictEqual( + configuration.name, + `Run/Debug logic app ${creationCase.appName}`, + `${creationCase.label} launch config should target the generated Logic App` + ); + assert.strictEqual(configuration.type, 'coreclr', `${creationCase.label} launch config should attach to the Functions host`); + assert.strictEqual(configuration.request, 'attach', `${creationCase.label} launch config should use attach request`); + assert.strictEqual( + configuration.processId, + `\${command:${logicAppsPickProcessCommand}}`, + `${creationCase.label} launch config should use the Logic Apps process picker` + ); + return; + } + + if (creationCase.appType === 'codeful') { + assert.strictEqual( + configuration.name, + `Run/Debug logic app ${creationCase.appName}`, + `${creationCase.label} launch config should target the generated codeful Logic App` + ); + assert.strictEqual(configuration.type, 'logicapp', `${creationCase.label} launch config should use the Logic Apps debug adapter`); + assert.strictEqual(configuration.request, 'launch', `${creationCase.label} launch config should use launch request`); + assert.strictEqual(configuration.funcRuntime, 'coreclr', `${creationCase.label} launch config should use coreclr Functions runtime`); + assert.strictEqual(configuration.isCodeless, false, `${creationCase.label} launch config should identify codeful projects`); + assert.strictEqual( + configuration.customCodeRuntime, + undefined, + `${creationCase.label} codeful launch config should not include a customCodeRuntime` + ); + return; + } + + assert.strictEqual( + configuration.name, + `Run/Debug logic app with local function ${creationCase.appName}`, + `${creationCase.label} launch config should target the generated Logic App with local function` + ); + assert.strictEqual(configuration.type, 'logicapp', `${creationCase.label} launch config should use the Logic Apps debug adapter`); + assert.strictEqual(configuration.request, 'launch', `${creationCase.label} launch config should use launch request`); + assert.strictEqual(configuration.funcRuntime, 'coreclr', `${creationCase.label} launch config should use coreclr Functions runtime`); + assert.strictEqual(configuration.isCodeless, true, `${creationCase.label} launch config should identify codeless projects`); + assert.strictEqual( + typeof configuration.customCodeRuntime, + 'string', + `${creationCase.label} launch config should include the local function runtime` + ); +} + +function verifyLogicAppTasks(tasksJson: TasksJson, creationCase: WorkspaceCreationCase): void { + assert.strictEqual(tasksJson.version, '2.0.0', `${creationCase.label} tasks.json should use VS Code tasks schema 2.0.0`); + const tasks = assertRecordArray(tasksJson.tasks, `${creationCase.label} tasks.json tasks`); + + if (creationCase.appType === 'codeful') { + assertTaskLabels(tasks, ['clean', 'build', 'clean release', 'publish', funcHostStartTaskLabel], creationCase); + assert.ok( + !tasks.some((task) => task.label === 'generateDebugSymbols'), + `${creationCase.label} codeful tasks should not include bundle debug symbol generation` + ); + assert.strictEqual(tasksJson.inputs, undefined, `${creationCase.label} codeful tasks should not include bundle debug symbol inputs`); + verifyDotnetBuildTaskChain(tasks, creationCase); + verifyFuncHostStartTask(requiredTask(tasks, funcHostStartTaskLabel, creationCase), creationCase, { expectedDependsOn: 'build' }); + return; + } + + assertTaskLabels(tasks, ['generateDebugSymbols', funcHostStartTaskLabel], creationCase); + verifyDebugSymbolsTask(requiredTask(tasks, 'generateDebugSymbols', creationCase), creationCase); + verifyDebugSymbolsInput(tasksJson.inputs, creationCase); + verifyFuncHostStartTask(requiredTask(tasks, funcHostStartTaskLabel, creationCase), creationCase, { expectedDependsOn: undefined }); +} + +function assertTaskLabels(tasks: TaskJson[], expectedLabels: string[], creationCase: WorkspaceCreationCase): void { + const actualLabels = tasks.map((task) => task.label); + assert.deepStrictEqual( + [...actualLabels].sort(), + [...expectedLabels].sort(), + `${creationCase.label} should generate the stable task label set` + ); +} + +function verifyDebugSymbolsTask(task: TaskJson, creationCase: WorkspaceCreationCase): void { + assert.strictEqual(task.type, 'process', `${creationCase.label} generateDebugSymbols should be a process task`); + assert.strictEqual(task.command, dotnetBinaryPathSetting, `${creationCase.label} generateDebugSymbols should use configured dotnet`); + assert.deepStrictEqual( + task.args, + ['${input:getDebugSymbolDll}'], + `${creationCase.label} generateDebugSymbols should resolve the DLL through input` + ); + assert.strictEqual(task.problemMatcher, '$msCompile', `${creationCase.label} generateDebugSymbols should use the C# problem matcher`); +} + +function verifyDebugSymbolsInput(inputs: unknown, creationCase: WorkspaceCreationCase): void { + const inputList = assertRecordArray(inputs, `${creationCase.label} tasks.json inputs`); + assert.deepStrictEqual( + inputList, + [{ id: 'getDebugSymbolDll', type: 'command', command: logicAppsGetDebugSymbolDllCommand }], + `${creationCase.label} tasks.json should resolve debug-symbol DLLs through the Logic Apps command input` + ); +} + +function verifyDotnetBuildTaskChain(tasks: TaskJson[], creationCase: WorkspaceCreationCase): void { + const clean = requiredTask(tasks, 'clean', creationCase); + assert.strictEqual(clean.type, 'process', `${creationCase.label} clean task should be a process task`); + assert.strictEqual(clean.command, dotnetBinaryPathSetting, `${creationCase.label} clean task should use configured dotnet`); + assert.deepStrictEqual(clean.args, ['clean', '/property:GenerateFullPaths=true', '/consoleloggerparameters:NoSummary']); + assert.strictEqual(clean.problemMatcher, '$msCompile', `${creationCase.label} clean task should use the C# problem matcher`); + + const build = requiredTask(tasks, 'build', creationCase); + assert.strictEqual(build.type, 'process', `${creationCase.label} build task should be a process task`); + assert.strictEqual(build.command, dotnetBinaryPathSetting, `${creationCase.label} build task should use configured dotnet`); + assert.deepStrictEqual(build.args, ['build', '/property:GenerateFullPaths=true', '/consoleloggerparameters:NoSummary']); + assert.strictEqual(build.dependsOn, 'clean', `${creationCase.label} build task should depend on clean`); + assert.deepStrictEqual(build.group, { kind: 'build', isDefault: true }, `${creationCase.label} build task should be the default build`); + assert.strictEqual(build.problemMatcher, '$msCompile', `${creationCase.label} build task should use the C# problem matcher`); + + const cleanRelease = requiredTask(tasks, 'clean release', creationCase); + assert.strictEqual(cleanRelease.type, 'process', `${creationCase.label} clean release task should be a process task`); + assert.strictEqual( + cleanRelease.command, + dotnetBinaryPathSetting, + `${creationCase.label} clean release task should use configured dotnet` + ); + assert.deepStrictEqual(cleanRelease.args, [ + 'clean', + '--configuration', + 'Release', + '/property:GenerateFullPaths=true', + '/consoleloggerparameters:NoSummary', + ]); + assert.strictEqual( + cleanRelease.problemMatcher, + '$msCompile', + `${creationCase.label} clean release task should use the C# problem matcher` + ); + + const publish = requiredTask(tasks, 'publish', creationCase); + assert.strictEqual(publish.type, 'process', `${creationCase.label} publish task should be a process task`); + assert.strictEqual(publish.command, dotnetBinaryPathSetting, `${creationCase.label} publish task should use configured dotnet`); + assert.deepStrictEqual(publish.args, [ + 'publish', + '--configuration', + 'Release', + '/property:GenerateFullPaths=true', + '/consoleloggerparameters:NoSummary', + ]); + assert.strictEqual(publish.dependsOn, 'clean release', `${creationCase.label} publish task should depend on clean release`); + assert.strictEqual(publish.problemMatcher, '$msCompile', `${creationCase.label} publish task should use the C# problem matcher`); +} + +function verifyFuncHostStartTask( + task: TaskJson, + creationCase: WorkspaceCreationCase, + options: { expectedDependsOn: string | undefined } +): void { + assert.strictEqual(task.problemMatcher, funcWatchProblemMatcher, `${creationCase.label} func host task should use $func-watch`); + assert.strictEqual(task.isBackground, true, `${creationCase.label} func host task should be backgrounded`); + assert.strictEqual( + task.dependsOn, + options.expectedDependsOn, + `${creationCase.label} func host task dependency should match project shape` + ); + + if (creationCase.appType === 'codeful') { + assert.strictEqual(task.group, undefined, `${creationCase.label} func host task should not be the default build task`); + } else { + assert.deepStrictEqual( + task.group, + { kind: 'build', isDefault: true }, + `${creationCase.label} func host task should be the default build task` + ); + } + + if (task.type === 'shell') { + assert.strictEqual( + task.command, + funcCoreToolsBinaryPathSetting, + `${creationCase.label} func host shell task should use configured func path` + ); + assert.deepStrictEqual(task.args, ['host', 'start'], `${creationCase.label} func host shell task should pass stable host start args`); + assertPlatformFuncTaskEnv(task, creationCase); + return; + } + + assert.strictEqual( + task.type, + 'func', + `${creationCase.label} func host task should be shell when managed binaries exist or func otherwise` + ); + assert.strictEqual(task.command, 'host start', `${creationCase.label} func host fallback task should use the stable host start command`); + assert.strictEqual(task.args, undefined, `${creationCase.label} func host fallback task should not duplicate host start args`); +} + +function assertPlatformFuncTaskEnv(task: TaskJson, creationCase: WorkspaceCreationCase): void { + const platformBlocks = [ + ['windows', '\\NodeJs;', '\\DotNetSDK;', '${env:PATH}'], + ['linux', '/NodeJs:', '/DotNetSDK:', '${env:PATH}'], + ['osx', '/NodeJs:', '/DotNetSDK:', '${env:PATH}'], + ] as const; + + for (const [platform, nodeSegment, dotnetSegment, inheritedPath] of platformBlocks) { + const platformBlock = assertRecord(task[platform], `${creationCase.label} func host ${platform} override`); + const options = assertRecord(platformBlock.options, `${creationCase.label} func host ${platform} options`); + const env = assertRecord(options.env, `${creationCase.label} func host ${platform} env`); + const pathValue = env.PATH; + assert.ok(typeof pathValue === 'string', `${creationCase.label} func host ${platform} PATH should be a string`); + assert.ok(pathValue.includes(nodeSegment), `${creationCase.label} func host ${platform} PATH should include managed NodeJs`); + assert.ok(pathValue.includes(dotnetSegment), `${creationCase.label} func host ${platform} PATH should include managed DotNetSDK`); + assert.ok(pathValue.includes(inheritedPath), `${creationCase.label} func host ${platform} PATH should preserve inherited PATH`); + } +} + +function assertWorkspaceManifestEntry(entry: WorkspaceManifestEntry): void { + assert.ok(fs.existsSync(entry.wsDir), `Manifest wsDir should exist: ${entry.wsDir}`); + assert.ok(fs.existsSync(entry.wsFilePath), `Manifest wsFilePath should exist: ${entry.wsFilePath}`); + assert.ok(fs.existsSync(entry.appDir), `Manifest appDir should exist: ${entry.appDir}`); + assert.ok(fs.existsSync(entry.wfDir), `Manifest wfDir should exist: ${entry.wfDir}`); + + const workflowJsonPath = path.join(entry.wfDir, 'workflow.json'); + const workflowJson = JSON.parse(fs.readFileSync(workflowJsonPath, 'utf-8')) as { kind?: string }; + assert.strictEqual(workflowJson.kind, getExpectedWorkflowKind(entry.wfType), `${entry.label} manifest workflow kind should match`); +} + +function assertFixtureManifestComplete(entries: WorkspaceManifestEntry[], caseFilter: string | undefined): void { + const manifestPath = getFixtureManifestPath(); + assert.ok(fs.existsSync(manifestPath), `Fixture manifest should exist: ${manifestPath}`); + + const writtenEntries = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as WorkspaceManifestEntry[]; + assert.strictEqual(writtenEntries.length, entries.length, 'Fixture manifest should contain the entries created in this run'); + if (caseFilter) { + assert.ok(writtenEntries.length > 0, `Filtered fixture manifest run should create at least one entry. Filter=${caseFilter}`); + return; + } + + for (const expected of [ + ['standard', 'Stateful'], + ['standard', 'Stateless'], + ['customCode', 'Stateful'], + ['rulesEngine', 'Stateful'], + ] as const) { + assert.ok( + writtenEntries.some((entry) => entry.appType === expected[0] && entry.wfType === expected[1]), + `Fixture manifest should include ${expected[0]} ${expected[1]}` + ); + } +} + +function verifyWorkflowDefinitionShape(workflowJson: WorkflowJson, creationCase: WorkspaceCreationCase): void { + assert.ok(workflowJson.definition, `${creationCase.label} workflow.json should include a definition`); + assert.strictEqual(workflowJson.definition.contentVersion, '1.0.0.0', `${creationCase.label} contentVersion should be deterministic`); + assert.deepStrictEqual( + workflowJson.definition.outputs ?? {}, + {}, + `${creationCase.label} generated workflow should start with no outputs` + ); + + const actions = workflowJson.definition.actions ?? {}; + const triggers = workflowJson.definition.triggers ?? {}; + + if (creationCase.appType === 'standard') { + verifyStandardWorkflowDefinition(actions, triggers, creationCase); + return; + } + + if (creationCase.appType === 'customCode') { + verifyCustomCodeWorkflowDefinition(actions, triggers, creationCase); + return; + } + + if (creationCase.appType === 'rulesEngine') { + verifyRulesEngineWorkflowDefinition(actions, triggers, creationCase); + } +} + +function verifyStandardWorkflowDefinition( + actions: Record, + triggers: Record, + creationCase: WorkspaceCreationCase +): void { + if (creationCase.workflowType === 'Autonomous agents (Preview)') { + const agentAction = requiredAction(actions, 'Default_Agent', creationCase); + assert.strictEqual(agentAction.type, 'Agent', `${creationCase.label} Default_Agent action should be an Agent action`); + assert.deepStrictEqual(agentAction.runAfter ?? {}, {}, `${creationCase.label} autonomous agent should not run after a chat trigger`); + assert.ok(isRecord(agentAction.inputs?.parameters), `${creationCase.label} Default_Agent should include parameter inputs`); + assert.strictEqual( + agentAction.inputs?.parameters?.agentModelType, + 'AzureOpenAI', + `${creationCase.label} Default_Agent should keep the expected default model type` + ); + assert.ok(isRecord(agentAction.inputs?.modelConfigurations), `${creationCase.label} Default_Agent should include model configurations`); + assert.deepStrictEqual(triggers, {}, `${creationCase.label} autonomous agent workflow should not include generated triggers`); + assert.deepStrictEqual( + Object.keys(actions).sort(), + ['Default_Agent'], + `${creationCase.label} autonomous agent should only include Default_Agent` + ); + return; + } + + if (creationCase.workflowType === 'Conversational agents (Preview)') { + const chatTrigger = requiredTrigger(triggers, 'When_a_new_chat_session_starts', creationCase); + assert.strictEqual(chatTrigger.type, 'Request', `${creationCase.label} chat trigger should be a Request trigger`); + assert.strictEqual(chatTrigger.kind, 'Agent', `${creationCase.label} chat trigger should use Agent kind`); + + const agentAction = requiredAction(actions, 'Default_Agent', creationCase); + assert.strictEqual(agentAction.type, 'Agent', `${creationCase.label} Default_Agent action should be an Agent action`); + assert.deepStrictEqual( + agentAction.runAfter?.When_a_new_chat_session_starts, + ['Succeeded'], + `${creationCase.label} Default_Agent should run after the chat-session trigger` + ); + assert.deepStrictEqual( + Object.keys(triggers).sort(), + ['When_a_new_chat_session_starts'], + `${creationCase.label} conversational agent should only include the chat-session trigger` + ); + assert.deepStrictEqual( + Object.keys(actions).sort(), + ['Default_Agent'], + `${creationCase.label} conversational agent should only include Default_Agent` + ); + return; + } + + assert.deepStrictEqual(actions, {}, `${creationCase.label} Standard ${creationCase.workflowType} workflow should start with no actions`); + assert.deepStrictEqual( + triggers, + {}, + `${creationCase.label} Standard ${creationCase.workflowType} workflow should start with no triggers` + ); +} + +function verifyCustomCodeWorkflowDefinition( + actions: Record, + triggers: Record, + creationCase: WorkspaceCreationCase +): void { + // Preview selections for custom-code currently reuse the custom-code starter workflow. + // The stable preview distinction for this app type is the top-level workflow kind, + // while the action/trigger contract remains the local-function template below. + const actionName = 'Call_a_local_function_in_this_logic_app'; + const invokeAction = requiredAction(actions, actionName, creationCase); + assert.strictEqual(invokeAction.type, 'InvokeFunction', `${creationCase.label} should invoke the generated local function`); + assert.strictEqual( + invokeAction.inputs?.functionName, + requiredValue(creationCase.functionName), + `${creationCase.label} InvokeFunction action should target the generated function` + ); + assert.deepStrictEqual( + invokeAction.inputs?.parameters, + { temperatureScale: 'Celsius', zipCode: 85396 }, + `${creationCase.label} InvokeFunction parameters should match the custom-code starter template` + ); + + verifyHttpRequestTrigger(triggers, creationCase); + verifyResponseAfterAction(actions, actionName, creationCase); + assert.deepStrictEqual( + Object.keys(actions).sort(), + [actionName, 'Response'].sort(), + `${creationCase.label} custom-code workflow should include only InvokeFunction and Response actions` + ); +} + +function verifyRulesEngineWorkflowDefinition( + actions: Record, + triggers: Record, + creationCase: WorkspaceCreationCase +): void { + // Preview selections for rules-engine currently reuse the rules starter workflow. + // The stable preview distinction for this app type is the top-level workflow kind, + // while the action/trigger contract remains the local-rules-function template below. + const actionName = 'Call_a_local_rules_function_in_this_logic_app'; + const invokeAction = requiredAction(actions, actionName, creationCase); + assert.strictEqual(invokeAction.type, 'InvokeFunction', `${creationCase.label} should invoke the generated local rules function`); + assert.strictEqual( + invokeAction.inputs?.functionName, + requiredValue(creationCase.functionName), + `${creationCase.label} rules InvokeFunction action should target the generated function` + ); + assert.strictEqual( + invokeAction.inputs?.parameters?.ruleSetName, + 'SampleRuleSet', + `${creationCase.label} rules action should target the sample rule set` + ); + assert.strictEqual( + invokeAction.inputs?.parameters?.documentType, + 'SchemaUser', + `${creationCase.label} rules action should target the sample schema` + ); + assert.strictEqual( + typeof invokeAction.inputs?.parameters?.inputXml, + 'string', + `${creationCase.label} rules action should include XML input` + ); + assert.ok( + Object.hasOwn(invokeAction.inputs?.parameters ?? {}, 'purchaseAmount'), + `${creationCase.label} rules action should include purchaseAmount` + ); + assert.ok(Object.hasOwn(invokeAction.inputs?.parameters ?? {}, 'zipCode'), `${creationCase.label} rules action should include zipCode`); + + verifyHttpRequestTrigger(triggers, creationCase); + verifyResponseAfterAction(actions, actionName, creationCase); + assert.deepStrictEqual( + Object.keys(actions).sort(), + [actionName, 'Response'].sort(), + `${creationCase.label} rules-engine workflow should include only InvokeFunction and Response actions` + ); +} + +function verifyHttpRequestTrigger(triggers: Record, creationCase: WorkspaceCreationCase): void { + const trigger = requiredTrigger(triggers, 'When_a_HTTP_request_is_received', creationCase); + assert.strictEqual(trigger.type, 'Request', `${creationCase.label} starter workflow should include an HTTP Request trigger`); + assert.strictEqual(trigger.kind, 'Http', `${creationCase.label} starter workflow should include an HTTP trigger kind`); + assert.deepStrictEqual( + Object.keys(triggers).sort(), + ['When_a_HTTP_request_is_received'], + `${creationCase.label} starter workflow should only include the generated HTTP trigger` + ); +} + +function verifyResponseAfterAction(actions: Record, actionName: string, creationCase: WorkspaceCreationCase): void { + const responseAction = requiredAction(actions, 'Response', creationCase); + assert.strictEqual(responseAction.type, 'Response', `${creationCase.label} should include a Response action`); + assert.strictEqual(responseAction.kind, 'http', `${creationCase.label} Response action should use http kind`); + assert.strictEqual(responseAction.inputs?.statusCode, 200, `${creationCase.label} Response action should return HTTP 200`); + assert.strictEqual( + responseAction.inputs?.body, + `@body('${actionName}')`, + `${creationCase.label} Response action should return the InvokeFunction body` + ); + assert.deepStrictEqual( + responseAction.runAfter?.[actionName], + ['Succeeded'], + `${creationCase.label} Response action should run after ${actionName}` + ); +} + +function requiredAction(actions: Record, actionName: string, creationCase: WorkspaceCreationCase): WorkflowAction { + assert.ok( + actions[actionName], + `${creationCase.label} workflow should include ${actionName}. Actions: ${JSON.stringify(Object.keys(actions))}` + ); + return actions[actionName]; +} + +function requiredTrigger( + triggers: Record, + triggerName: string, + creationCase: WorkspaceCreationCase +): WorkflowTrigger { + assert.ok( + triggers[triggerName], + `${creationCase.label} workflow should include ${triggerName}. Triggers: ${JSON.stringify(Object.keys(triggers))}` + ); + return triggers[triggerName]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function applyCodefulControlVariant(parentPath: string, creationCase: WorkspaceCreationCase): void { + if (creationCase.appType !== 'codeful' || creationCase.codefulControlVariant !== 'legacy-control') { + return; + } + + const appDir = path.join(parentPath, creationCase.wsName, creationCase.appName); + const csprojPath = getCodefulCsprojPath(appDir); + let csprojContent = fs.readFileSync(csprojPath, 'utf-8'); + + for (const targetName of ['CopyToCodefulFolder', 'ReplaceLanguageNetCore']) { + const targetMatch = csprojContent.match(new RegExp(`]*Name=["']${targetName}["'][^>]*>`)); + assert.ok(targetMatch, `Legacy-control codeful case should find ${targetName} target in ${csprojPath}`); + + const targetTag = targetMatch[0]; + const updatedTargetTag = targetTag.replace(/(AfterTargets=["'])Build;Publish(["'])/, '$1Publish$2'); + assert.notStrictEqual( + updatedTargetTag, + targetTag, + `Legacy-control codeful case should patch ${targetName} AfterTargets in ${csprojPath}` + ); + csprojContent = csprojContent.replace(targetTag, updatedTargetTag); + } + + fs.writeFileSync(csprojPath, csprojContent, 'utf-8'); +} + +function verifyCodefulProject(appDir: string, creationCase: WorkspaceCreationCase): void { + for (const fileName of [ + `${creationCase.wfName}.cs`, + `${creationCase.appName}.csproj`, + 'Program.cs', + 'host.json', + 'local.settings.json', + ]) { + const filePath = path.join(appDir, fileName); + assert.ok(fs.existsSync(filePath), `Codeful workspace should include ${fileName}: ${filePath}`); + } + + const localSettingsPath = path.join(appDir, 'local.settings.json'); + const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')) as { Values?: Record }; + assert.strictEqual( + localSettings.Values?.WORKFLOW_CODEFUL_ENABLED, + 'true', + `Codeful workspace should set WORKFLOW_CODEFUL_ENABLED in ${localSettingsPath}` + ); + + const csprojPath = getCodefulCsprojPath(appDir); + const csprojContent = fs.readFileSync(csprojPath, 'utf-8'); + assert.ok(csprojContent.includes('net8'), `Codeful .csproj should target net8: ${csprojPath}`); + assert.ok(csprojContent.includes('Microsoft.Azure.Workflows.Sdk'), `Codeful .csproj should reference Workflows SDK: ${csprojPath}`); + + const expectedControlVariant = creationCase.codefulControlVariant ?? 'modern-control'; + for (const targetName of ['CopyToCodefulFolder', 'ReplaceLanguageNetCore']) { + const afterTargets = getCsprojTargetAfterTargets(csprojContent, targetName); + if (expectedControlVariant === 'legacy-control') { + assert.strictEqual(afterTargets, 'Publish', `${creationCase.label} should keep ${targetName} as a legacy Publish-only target`); + } else { + const targetTokens = getAfterTargetsTokens(afterTargets); + assert.ok( + targetTokens.includes('Build') && targetTokens.includes('Publish'), + `${creationCase.label} should keep ${targetName} on the modern Build;Publish target. Actual: ${afterTargets ?? '(missing)'}` + ); + } + } +} + +function getCodefulCsprojPath(appDir: string): string { + const csprojFiles = fs.readdirSync(appDir).filter((name) => name.endsWith('.csproj')); + assert.strictEqual(csprojFiles.length, 1, `Expected exactly one codeful .csproj in ${appDir}, found ${csprojFiles.join(', ')}`); + const csprojFile = csprojFiles[0]; + assert.ok(csprojFile, `Expected a codeful .csproj in ${appDir}`); + return path.join(appDir, csprojFile); +} + +function getCsprojTargetAfterTargets(csprojContent: string, targetName: string): string | null { + const targetMatch = csprojContent.match(new RegExp(`]*Name=["']${targetName}["'][^>]*>`)); + if (!targetMatch) { + return null; + } + + const afterTargetsMatch = targetMatch[0].match(/\bAfterTargets=["']([^"']+)["']/); + return afterTargetsMatch?.[1] ?? ''; +} + +function getAfterTargetsTokens(afterTargets: string | null): string[] { + return (afterTargets ?? '') + .split(';') + .map((token) => token.trim()) + .filter(Boolean); +} + +function getExpectedWorkflowKind(workflowType: WorkflowType): string { + switch (workflowType) { + case 'Stateless': + return 'Stateless'; + case 'Conversational agents (Preview)': + return 'Agent'; + case 'Stateful': + case 'Autonomous agents (Preview)': + return 'Stateful'; + default: { + const exhaustive: never = workflowType; + return exhaustive; + } + } +} + +function verifyFunctionProject(workspaceDir: string, creationCase: WorkspaceCreationCase, workspaceContent: WorkspaceJson): void { + const functionFolderName = requiredValue(creationCase.functionFolderName); + const functionName = requiredValue(creationCase.functionName); + const functionNamespace = requiredValue(creationCase.functionNamespace); + const functionDir = path.join(workspaceDir, functionFolderName); + const functionCsPath = path.join(functionDir, `${functionName}.cs`); + const functionProjectPath = path.join(functionDir, `${functionName}.csproj`); + + assertWorkspaceFolderPath( + path.join(workspaceDir, `${creationCase.wsName}.code-workspace`), + workspaceContent, + functionFolderName, + functionDir, + creationCase + ); + assert.ok(fs.existsSync(functionDir), `Function project directory should exist: ${functionDir}`); + assert.ok(fs.existsSync(functionCsPath), `Function .cs file should exist: ${functionCsPath}`); + assert.ok(fs.existsSync(functionProjectPath), `Function .csproj file should exist: ${functionProjectPath}`); + verifyFunctionVsCodeArtifacts(functionDir, creationCase); + + const functionSource = fs.readFileSync(functionCsPath, 'utf-8'); + assert.ok(functionSource.includes(functionNamespace), `Function source should include namespace ${functionNamespace}`); + assert.ok(functionSource.includes(functionName), `Function source should include function name ${functionName}`); + + if (creationCase.appType === 'rulesEngine') { + assert.ok( + fs.existsSync(path.join(functionDir, 'ContosoPurchase.cs')), + 'Rules engine function folder should include ContosoPurchase.cs' + ); + } +} + +function verifyFunctionVsCodeArtifacts(functionDir: string, creationCase: WorkspaceCreationCase): void { + const vscodeDir = path.join(functionDir, '.vscode'); + for (const fileName of ['settings.json', 'extensions.json', 'tasks.json']) { + assert.ok(fs.existsSync(path.join(vscodeDir, fileName)), `${creationCase.label} function app should generate .vscode/${fileName}`); + } + + const settings = readJsonFile>(path.join(vscodeDir, 'settings.json')); + assert.strictEqual(settings['azureFunctions.projectLanguage'], 'C#', `${creationCase.label} function app should set C# project language`); + assert.strictEqual(settings['azureFunctions.projectRuntime'], '~4', `${creationCase.label} function app should set Functions runtime ~4`); + assert.strictEqual( + settings['debug.internalConsoleOptions'], + 'neverOpen', + `${creationCase.label} function app should suppress debug console auto-open` + ); + assert.strictEqual( + settings['azureFunctions.preDeployTask'], + 'publish (functions)', + `${creationCase.label} function app should set predeploy task` + ); + assert.strictEqual(settings['azureFunctions.templateFilter'], 'Core', `${creationCase.label} function app should use Core templates`); + assert.strictEqual( + settings['azureFunctions.showTargetFrameworkWarning'], + false, + `${creationCase.label} function app should suppress target framework warning` + ); + const deploySubpath = settings['azureFunctions.deploySubpath']; + assert.strictEqual(typeof deploySubpath, 'string', `${creationCase.label} function app should set deploySubpath`); + const deploySubpathValue = String(deploySubpath); + assert.ok(deploySubpathValue.startsWith('bin/Release/'), `${creationCase.label} function app deploySubpath should use Release output`); + assert.ok(deploySubpathValue.endsWith('/publish'), `${creationCase.label} function app deploySubpath should point to publish output`); + + const extensions = readJsonFile(path.join(vscodeDir, 'extensions.json')); + assertRecommendations( + extensions, + [functionsExtensionId, dotnetExtensionId], + `${creationCase.label} function app .vscode/extensions.json` + ); + + const tasks = readJsonFile(path.join(vscodeDir, 'tasks.json')); + assert.strictEqual(tasks.version, '2.0.0', `${creationCase.label} function app tasks.json should use VS Code tasks schema 2.0.0`); + const taskList = assertRecordArray(tasks.tasks, `${creationCase.label} function app tasks.json tasks`); + assertTaskLabels(taskList, ['build'], creationCase); + const buildTask = requiredTask(taskList, 'build', creationCase); + assert.strictEqual(buildTask.type, 'process', `${creationCase.label} function app build should be a process task`); + assert.strictEqual(buildTask.command, dotnetBinaryPathSetting, `${creationCase.label} function app build should use configured dotnet`); + assert.deepStrictEqual( + buildTask.args, + ['build', '${workspaceFolder}'], + `${creationCase.label} function app build should target the workspace folder` + ); + assert.deepStrictEqual( + buildTask.group, + { kind: 'build', isDefault: true }, + `${creationCase.label} function app build should be the default build` + ); +} + +function readJsonFile(filePath: string): T { + assert.ok(fs.existsSync(filePath), `Expected JSON file to exist: ${filePath}`); + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T; +} + +function assertRecommendations(extensions: ExtensionsJson, expectedRecommendations: string[], context: string): void { + const recommendations = assertStringArray(extensions.recommendations, `${context} recommendations`); + for (const expected of expectedRecommendations) { + assert.ok(recommendations.includes(expected), `${context} should recommend ${expected}. Actual: ${JSON.stringify(recommendations)}`); + } +} + +function assertStringArray(value: unknown, context: string): string[] { + assert.ok(Array.isArray(value), `${context} should be an array`); + for (const item of value) { + assert.strictEqual(typeof item, 'string', `${context} should only contain strings`); + } + return value as string[]; +} + +function assertRecord(value: unknown, context: string): Record { + assert.ok(isRecord(value), `${context} should be an object`); + return value; +} + +function assertRecordArray>(value: unknown, context: string): T[] { + assert.ok(Array.isArray(value), `${context} should be an array`); + for (const item of value) { + assert.ok(isRecord(item), `${context} should only contain objects`); + } + return value as T[]; +} + +function requiredTask(tasks: TaskJson[], label: string, creationCase: WorkspaceCreationCase): TaskJson { + const task = tasks.find((candidate) => candidate.label === label); + assert.ok( + task, + `${creationCase.label} tasks.json should include task ${label}. Tasks: ${JSON.stringify(tasks.map((candidate) => candidate.label))}` + ); + return task; +} + +function requiredValue(value: string | undefined): string { + assert.ok(value, 'Expected required workspace creation value to be defined'); + return value; +} + +async function runNameFieldCases( + cdp: CdpEvaluator, + contextId: number, + fieldName: string, + labels: FieldLabels, + validPrefix: string, + cases: [string, string, string][] +): Promise { + for (const [caseName, invalidValue, expectedMessage] of cases) { + const validValue = uniqueName(validPrefix); + if (invalidValue) { + await runInvalidThenValidCase(cdp, contextId, { + name: `${fieldName} ${caseName}`, + labels, + invalidValue, + expectedMessage, + validValue, + }); + } else { + await runEmptyThenValidCase(cdp, contextId, `${fieldName} ${caseName}`, labels, validValue); + } + } +} + +async function runInvalidThenValidCase(cdp: CdpEvaluator, contextId: number, testCase: FieldValidationCase): Promise { + await enterFieldValue(cdp, contextId, testCase.labels, testCase.invalidValue); + await waitForFieldValidationMessage(cdp, contextId, testCase.labels, testCase.expectedMessage); + await enterFieldValue(cdp, contextId, testCase.labels, testCase.validValue); + await waitForFieldValidationMessageToClear(cdp, contextId, testCase.labels, testCase.expectedMessage); +} + +async function runEmptyThenValidCase( + cdp: CdpEvaluator, + contextId: number, + name: string, + labels: FieldLabels, + validValue: string +): Promise { + await enterFieldValue(cdp, contextId, labels, validValue); + await enterFieldValue(cdp, contextId, labels, ''); + await waitForFieldValidationMessage(cdp, contextId, labels, emptyValidationMessage); + await assertNextButtonDisabled(cdp, contextId, name); + await enterFieldValue(cdp, contextId, labels, validValue); + await waitForFieldValidationMessageToClear(cdp, contextId, labels, emptyValidationMessage); +} + +async function runThreeRequiredFieldGatingCases( + cdp: CdpEvaluator, + contextId: number, + name: string, + fields: { + first: { labels: FieldLabels; validValue: string }; + second: { labels: FieldLabels; validValue: string }; + third: { labels: FieldLabels; validValue: string }; + } +): Promise { + await enterFieldValue(cdp, contextId, fields.first.labels, ''); + await enterFieldValue(cdp, contextId, fields.second.labels, ''); + await enterFieldValue(cdp, contextId, fields.third.labels, ''); + await waitForFieldValidationMessage(cdp, contextId, fields.first.labels, emptyValidationMessage); + await assertNextButtonDisabled(cdp, contextId, `${name}: all empty`); + + await enterFieldValue(cdp, contextId, fields.first.labels, uniqueName(fields.first.validValue)); + await assertNextButtonDisabled(cdp, contextId, `${name}: first valid only`); + + await enterFieldValue(cdp, contextId, fields.first.labels, ''); + await enterFieldValue(cdp, contextId, fields.second.labels, fields.second.validValue); + await assertNextButtonDisabled(cdp, contextId, `${name}: second valid only`); + + await enterFieldValue(cdp, contextId, fields.second.labels, ''); + await enterFieldValue(cdp, contextId, fields.third.labels, uniqueName(fields.third.validValue)); + await assertNextButtonDisabled(cdp, contextId, `${name}: third valid only`); + + await enterFieldValue(cdp, contextId, fields.first.labels, uniqueName(fields.first.validValue)); + await enterFieldValue(cdp, contextId, fields.second.labels, fields.second.validValue); + await enterFieldValue(cdp, contextId, fields.third.labels, ''); + await assertNextButtonDisabled(cdp, contextId, `${name}: first and second valid`); + + await enterFieldValue(cdp, contextId, fields.second.labels, ''); + await enterFieldValue(cdp, contextId, fields.third.labels, uniqueName(fields.third.validValue)); + await assertNextButtonDisabled(cdp, contextId, `${name}: first and third valid`); + + await enterFieldValue(cdp, contextId, fields.first.labels, ''); + await enterFieldValue(cdp, contextId, fields.second.labels, fields.second.validValue); + await assertNextButtonDisabled(cdp, contextId, `${name}: second and third valid`); + + await enterFieldValue(cdp, contextId, fields.first.labels, uniqueName(fields.first.validValue)); + await enterFieldValue(cdp, contextId, fields.second.labels, fields.second.validValue); + await enterFieldValue(cdp, contextId, fields.third.labels, uniqueName(fields.third.validValue)); + await assertNextButtonEnabled(cdp, contextId, `${name}: all valid`); +} + +async function enterFieldValue(cdp: CdpEvaluator, contextId: number, labels: FieldLabels, value: string): Promise { + const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; value?: string }>( + contextId, + withField( + labels, + `input.focus(); + input.select(); + return { ok: true, value: input.value };` + ) + ); + + assert.strictEqual( + focusResult.ok, + true, + focusResult.reason ?? `Failed to focus ${getLabels(labels).join('/')} field. Text: ${focusResult.text ?? ''}` + ); + + try { + await replaceFocusedInputText(cdp, value); + } catch { + await cdp.evaluate( + contextId, + withField( + labels, + `setInputValue(input, ${JSON.stringify(value)}); + return { ok: true, value: input.value };` + ) + ); + } + + const result = await waitForFieldValue(cdp, contextId, labels, value); + assert.strictEqual( + result.value, + value, + `Expected field "${getLabels(labels).join('/')}" to equal "${value}". State: ${JSON.stringify(result)}` + ); +} + +async function waitForFieldVisible(cdp: CdpEvaluator, contextId: number, labels: FieldLabels): Promise { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const result = await getFieldState(cdp, contextId, labels).catch(() => undefined); + if (result?.ok) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const text = await getPageText(cdp, contextId); + assert.fail(`Timed out waiting for field "${getLabels(labels).join('/')}" to be visible. Webview text: ${text}`); +} + +async function waitForFieldHidden(cdp: CdpEvaluator, contextId: number, labels: FieldLabels): Promise { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const result = await getFieldState(cdp, contextId, labels).catch(() => undefined); + if (!result?.ok) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const result = await getFieldState(cdp, contextId, labels).catch((error) => ({ text: String(error) })); + assert.fail(`Expected field "${getLabels(labels).join('/')}" to be hidden. State: ${JSON.stringify(result)}`); +} + +async function waitForFieldValidationMessage( + cdp: CdpEvaluator, + contextId: number, + labels: FieldLabels, + expectedMessage: string +): Promise { + const deadline = Date.now() + (expectedMessage === 'not exist' ? 45000 : 10000); + while (Date.now() < deadline) { + const result = await getFieldState(cdp, contextId, labels); + const fieldText = `${result.fieldText ?? ''}\n${result.validationText ?? ''}`; + if (containsIgnoreCase(fieldText, expectedMessage)) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const finalState = await getFieldState(cdp, contextId, labels).catch((error) => ({ text: String(error) })); + assert.fail( + `Timed out waiting for validation message "${expectedMessage}" on field "${getLabels(labels).join('/')}". State: ${JSON.stringify(finalState)}` + ); +} + +async function waitForFieldValidationMessageToClear( + cdp: CdpEvaluator, + contextId: number, + labels: FieldLabels, + message: string +): Promise { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const result = await getFieldState(cdp, contextId, labels); + const fieldText = `${result.fieldText ?? ''}\n${result.validationText ?? ''}`; + if (!containsIgnoreCase(fieldText, message)) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const result = await getFieldState(cdp, contextId, labels).catch((error) => ({ text: String(error) })); + assert.fail( + `Timed out waiting for validation message "${message}" to clear on field "${getLabels(labels).join('/')}". State: ${JSON.stringify(result)}` + ); +} + +async function waitForAsyncValidationToSettle(cdp: CdpEvaluator, contextId: number): Promise { + const pendingMessages = ['Validating path', 'Checking workspace availability']; + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + const pageText = await getPageText(cdp, contextId); + if (!pendingMessages.some((message) => containsIgnoreCase(pageText, message))) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const pageText = await getPageText(cdp, contextId); + assert.fail(`Timed out waiting for async Create Workspace validation to settle. Webview text: ${pageText}`); +} + +async function assertNextButtonDisabled(cdp: CdpEvaluator, contextId: number, context: string): Promise { + const deadline = Date.now() + 8000; + while (Date.now() < deadline) { + const result = await getNextButtonState(cdp, contextId); + if (result.found && result.disabled) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const result = await getNextButtonState(cdp, contextId); + assert.fail(`Expected Next button to be disabled for ${context}. State: ${JSON.stringify(result)}`); +} + +async function assertNextButtonEnabled(cdp: CdpEvaluator, contextId: number, context: string): Promise { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + const result = await getNextButtonState(cdp, contextId); + if (result.found && !result.disabled) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const result = await getNextButtonState(cdp, contextId); + assert.fail(`Expected Next button to be enabled for ${context}. State: ${JSON.stringify(result)}`); +} + +async function assertWizardButtonDisabledOrAbsent( + cdp: CdpEvaluator, + contextId: number, + buttonText: string, + context: string +): Promise { + const result = await getWizardButtonState(cdp, contextId, buttonText); + assert.ok( + !result.found || result.disabled, + `Expected ${buttonText} button to be disabled or absent for ${context}. State: ${JSON.stringify(result)}` + ); +} + +async function assertDropdownHasOptions(cdp: CdpEvaluator, contextId: number, labelText: string, expectedOptions: string[]): Promise { + const focusResult = await getDropdownClickPoint(cdp, contextId, labelText); + assert.strictEqual(focusResult.ok, true, focusResult.reason ?? `Failed to find "${labelText}" dropdown. Text: ${focusResult.text ?? ''}`); + assert.ok(focusResult.point, `Failed to locate "${labelText}" dropdown click point.`); + + await clickPoint(cdp, focusResult.point); + if (!(await hasDropdownOptions(cdp, contextId))) { + await dispatchDropdownClickFallback(cdp, contextId, labelText); + } + if (!(await hasDropdownOptions(cdp, contextId))) { + await pressKey(cdp, 'Enter', undefined, 13); + } + if (!(await hasDropdownOptions(cdp, contextId))) { + await pressKey(cdp, 'Space', ' ', 32); + } + await waitForDropdownOptions(cdp, contextId); + const options = await getVisibleDropdownOptions(cdp, contextId); + for (const expectedOption of expectedOptions) { + assert.ok( + options.some((option) => option === expectedOption), + `Expected "${labelText}" dropdown to include "${expectedOption}". Options: ${JSON.stringify(options)}` + ); + } + await pressKey(cdp, 'Escape', 'Escape', 27); +} + +async function selectRadioOption(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; point?: Point }>( + contextId, + `(() => { + const expected = ${JSON.stringify(labelText)}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 180 && text.includes(expected); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + if (!label) { + return { ok: false, reason: 'Radio label not found', text: document.body?.innerText || '' }; + } + + const radioRoot = label.closest('[role="radio"], .fui-Radio') || label; + const input = radioRoot.querySelector('input[type="radio"]'); + if (!(input instanceof HTMLInputElement)) { + return { ok: false, reason: 'Radio input not found', text: radioRoot.outerHTML }; + } + + const clickable = radioRoot instanceof HTMLElement ? radioRoot : input; + clickable.scrollIntoView({ block: 'center', inline: 'center' }); + input.focus(); + const rect = clickable.getBoundingClientRect(); + return { + ok: true, + text: radioRoot.outerHTML, + point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, + }; + })()` + ); + + assert.strictEqual( + focusResult.ok, + true, + focusResult.reason ?? `Failed to focus radio option "${labelText}". Text: ${focusResult.text ?? ''}` + ); + assert.ok(focusResult.point, `Failed to locate radio option "${labelText}" click point.`); + await clickPoint(cdp, focusResult.point); + if (!(await isRadioOptionChecked(cdp, contextId, labelText))) { + await dispatchRadioClickFallback(cdp, contextId, labelText); + } + if (!(await isRadioOptionChecked(cdp, contextId, labelText))) { + await pressKey(cdp, 'Space', ' ', 32); + } + await waitForRadioOptionChecked(cdp, contextId, labelText); +} + +async function dispatchRadioClickFallback(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + await cdp.evaluate( + contextId, + `(() => { + const expected = ${JSON.stringify(labelText)}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 180 && text.includes(expected); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; + const input = radioRoot?.querySelector('input[type="radio"]'); + if (!(input instanceof HTMLInputElement)) { + return; + } + + input.focus(); + input.click(); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + })()` + ); +} + +async function selectDropdownOption(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { + if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { + return; + } + + const focusResult = await getDropdownClickPoint(cdp, contextId, labelText); + + assert.strictEqual( + focusResult.ok, + true, + focusResult.reason ?? `Failed to focus "${labelText}" dropdown. Text: ${focusResult.text ?? ''}` + ); + assert.ok(focusResult.point, `Failed to locate "${labelText}" dropdown click point.`); + await clickPoint(cdp, focusResult.point); + await new Promise((resolve) => setTimeout(resolve, 500)); + if (!(await hasDropdownOptions(cdp, contextId))) { + await dispatchDropdownClickFallback(cdp, contextId, labelText); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!(await hasDropdownOptions(cdp, contextId))) { + await pressKey(cdp, 'Enter', undefined, 13); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!(await hasDropdownOptions(cdp, contextId))) { + await pressKey(cdp, 'Space', ' ', 32); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!(await hasDropdownOptions(cdp, contextId))) { + await pressKey(cdp, 'ArrowDown', 'ArrowDown', 40); + await new Promise((resolve) => setTimeout(resolve, 250)); + await pressKey(cdp, 'Enter', undefined, 13); + await new Promise((resolve) => setTimeout(resolve, 500)); + if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { + return; + } + } + + const optionResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; options?: string[]; optionIndex?: number }>( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const options = Array.from(document.querySelectorAll('[role="option"]')).filter(isVisible); + const option = options.find((candidate) => normalize(candidate.textContent) === ${JSON.stringify(optionText)}); + if (!(option instanceof HTMLElement)) { + return { + ok: false, + reason: 'Dropdown option not found', + options: options.map((candidate) => normalize(candidate.textContent)), + text: document.body?.innerText || '', + }; + } + + return { ok: true, optionIndex: options.indexOf(option) }; + })()` + ); + + assert.strictEqual( + optionResult.ok, + true, + `Failed to select "${optionText}" from "${labelText}". Reason: ${optionResult.reason ?? 'unknown'}. Options: ${JSON.stringify( + optionResult.options + )}. Text: ${optionResult.text ?? ''}` + ); + for (let index = 0; index < (optionResult.optionIndex ?? 0); index++) { + await pressKey(cdp, 'ArrowDown', 'ArrowDown', 40); + } + await pressKey(cdp, 'Enter', undefined, 13); + await waitForDropdownValue(cdp, contextId, labelText, optionText); +} + +async function getDropdownClickPoint( + cdp: CdpEvaluator, + contextId: number, + labelText: string +): Promise<{ ok: boolean; reason?: string; text?: string; point?: Point }> { + return cdp.evaluate<{ ok: boolean; reason?: string; text?: string; point?: Point }>( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent).toLowerCase(); + return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + if (!label) { + return { ok: false, reason: 'Dropdown label not found', text: document.body?.innerText || '' }; + } + const dropdownId = label?.getAttribute('for'); + const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; + const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); + if (!(dropdown instanceof HTMLButtonElement)) { + return { ok: false, reason: 'Dropdown button not found', text: document.body?.innerText || '' }; + } + + dropdown.scrollIntoView({ block: 'center', inline: 'center' }); + dropdown.focus(); + const rect = dropdown.getBoundingClientRect(); + return { + ok: true, + text: document.body?.innerText || '', + point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, + }; + })()` + ); +} + +async function dispatchDropdownClickFallback(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + await cdp.evaluate( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent).toLowerCase(); + return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const dropdownId = label?.getAttribute('for'); + const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; + const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); + if (!(dropdown instanceof HTMLButtonElement)) { + return; + } + + dropdown.focus(); + dropdown.click(); + })()` + ); +} + +async function waitForDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (await hasDropdownOptions(cdp, contextId)) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const pageText = await getPageText(cdp, contextId); + assert.fail(`Timed out waiting for dropdown options. Text: ${pageText}`); +} + +async function getVisibleDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + return Array.from(document.querySelectorAll('[role="option"]')).filter(isVisible).map((option) => normalize(option.textContent)); + })()` + ); +} + +async function hasDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate( + contextId, + `(() => { + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + return Array.from(document.querySelectorAll('[role="option"]')).some(isVisible); + })()` + ); +} + +async function waitForRadioOptionChecked(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const result = await cdp.evaluate<{ checked: boolean; text?: string }>( + contextId, + `(() => { + const expected = ${JSON.stringify(labelText)}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 180 && text.includes(expected); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; + const input = radioRoot?.querySelector('input[type="radio"]'); + return { checked: input instanceof HTMLInputElement ? input.checked : false, text: radioRoot?.outerHTML || document.body?.innerText || '' }; + })()` + ); + if (result.checked) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const result = await getNextButtonState(cdp, contextId); + assert.fail(`Expected radio option "${labelText}" to be checked. State: ${JSON.stringify(result)}`); +} + +async function isRadioOptionChecked(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + const result = await cdp.evaluate<{ checked: boolean }>( + contextId, + `(() => { + const expected = ${JSON.stringify(labelText)}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 180 && text.includes(expected); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; + const input = radioRoot?.querySelector('input[type="radio"]'); + return { checked: input instanceof HTMLInputElement ? input.checked : false }; + })()` + ); + return result.checked; +} + +async function waitForDropdownValue(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const result = await getNextButtonState(cdp, contextId); + assert.fail(`Expected dropdown "${labelText}" to select "${optionText}". State: ${JSON.stringify(result)}`); +} + +async function isDropdownValueSelected(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { + const result = await cdp.evaluate<{ selected: boolean }>( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter((candidate) => { + const text = normalize(candidate.textContent).toLowerCase(); + return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const dropdownId = label?.getAttribute('for'); + const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; + const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); + const text = dropdown?.textContent || ''; + return { selected: normalize(text).includes(${JSON.stringify(optionText)}) }; + })()` + ); + return result.selected; +} + +async function pressKey(cdp: CdpEvaluator, code: string, key?: string, windowsVirtualKeyCode?: number): Promise { + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: key ?? code, + code, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: key ?? code, + code, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + }); +} + +async function clickPoint(cdp: CdpEvaluator, point: Point): Promise { + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: point.x, + y: point.y, + button: 'none', + }); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button: 'left', + buttons: 1, + clickCount: 1, + }); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button: 'left', + buttons: 0, + clickCount: 1, + }); +} + +async function getFieldState( + cdp: CdpEvaluator, + contextId: number, + labels: FieldLabels +): Promise<{ + ok: boolean; + reason?: string; + value?: string; + fieldText?: string; + validationText?: string; + pageText?: string; + ariaInvalid?: string | null; + describedBy?: string | null; +}> { + return cdp.evaluate( + contextId, + withField( + labels, + `return { + ok: true, + value: input.value, + fieldText: field?.innerText || '', + validationText: getValidationText(input, field), + pageText: document.body?.innerText || '', + ariaInvalid: input.getAttribute('aria-invalid'), + describedBy: input.getAttribute('aria-describedby'), + };` + ) + ); +} + +async function waitForFieldValue( + cdp: CdpEvaluator, + contextId: number, + labels: FieldLabels, + expectedValue: string +): Promise<{ ok: boolean; value?: string; fieldText?: string; pageText?: string }> { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const result = await getFieldState(cdp, contextId, labels); + if (result.value === expectedValue) { + return result; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + return getFieldState(cdp, contextId, labels); +} + +async function replaceFocusedInputText(cdp: CdpEvaluator, value: string): Promise { + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'Control', + code: 'ControlLeft', + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + modifiers: 2, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'a', + code: 'KeyA', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + modifiers: 2, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'a', + code: 'KeyA', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + modifiers: 2, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'Control', + code: 'ControlLeft', + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'Backspace', + code: 'Backspace', + windowsVirtualKeyCode: 8, + nativeVirtualKeyCode: 8, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'Backspace', + code: 'Backspace', + windowsVirtualKeyCode: 8, + nativeVirtualKeyCode: 8, + }); + + if (value) { + await cdp.send('Input.insertText', { text: value }); + } +} + +async function getNextButtonState( + cdp: CdpEvaluator, + contextId: number +): Promise<{ found: boolean; disabled?: boolean; text?: string; pageText?: string; fieldValues?: unknown[] }> { + return getWizardButtonState(cdp, contextId, 'Next'); +} + +async function getWizardButtonState( + cdp: CdpEvaluator, + contextId: number, + buttonText: string +): Promise<{ found: boolean; disabled?: boolean; text?: string; pageText?: string; fieldValues?: unknown[] }> { + return cdp.evaluate( + contextId, + `(() => { + const expectedButtonText = ${JSON.stringify(buttonText)}; + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const buttons = Array.from(document.querySelectorAll('button')).filter(isVisible); + const button = buttons.find((candidate) => (candidate.textContent || '').includes(expectedButtonText)); + const invalidFields = Array.from(document.querySelectorAll('input[aria-invalid="true"]')).map((input) => { + const label = input.id ? document.querySelector('label[for="' + CSS.escape(input.id) + '"]') : null; + const field = input.closest('[class*="fui-Field"]') || input.parentElement; + return { + label: label?.textContent || '', + value: input instanceof HTMLInputElement ? input.value : '', + text: field?.innerText || '', + }; + }); + const fieldValues = Array.from(document.querySelectorAll('input')).filter(isVisible).map((input) => { + const label = input.id ? document.querySelector('label[for="' + CSS.escape(input.id) + '"]') : null; + const field = input.closest('[class*="fui-Field"]') || input.parentElement; + return { + label: label?.textContent || '', + type: input instanceof HTMLInputElement ? input.type : '', + value: input instanceof HTMLInputElement ? input.value : '', + checked: input instanceof HTMLInputElement ? input.checked : undefined, + text: field?.innerText || '', + }; + }); + const pageText = document.body?.innerText || ''; + if (!button) { + return { found: false, text: pageText, pageText, invalidFields, fieldValues }; + } + + const disabled = button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true'; + return { found: true, disabled, text: button.textContent || '', pageText, invalidFields, fieldValues }; + })()` + ); +} + +async function getPageText(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate(contextId, 'document.body?.innerText || ""').catch((error) => String(error)); +} + +function withField(labels: FieldLabels, action: string): string { + return `(() => { + const labelsToFind = ${JSON.stringify(getLabels(labels))}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const visibleInputs = Array.from(document.querySelectorAll('input')).filter(isVisible); + const inputByAttribute = visibleInputs + .filter((candidate) => { + const searchableText = [ + candidate.getAttribute('aria-label'), + candidate.getAttribute('placeholder'), + candidate.getAttribute('name'), + candidate.id, + ].map(normalize).join(' ').toLowerCase(); + return labelsToFind.some((expected) => searchableText.includes(expected.toLowerCase())); + }) + .sort((a, b) => normalize(a.getAttribute('placeholder') || a.getAttribute('aria-label') || a.id).length - normalize(b.getAttribute('placeholder') || b.getAttribute('aria-label') || b.id).length)[0]; + const visibleTextElements = Array.from(document.querySelectorAll('label, span, div, p')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 160; + }); + const exactLabel = visibleTextElements + .filter((candidate) => labelsToFind.some((expected) => normalize(candidate.textContent).toLowerCase() === expected.toLowerCase())) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const partialLabel = visibleTextElements + .filter((candidate) => + labelsToFind.some((expected) => normalize(candidate.textContent).toLowerCase().includes(expected.toLowerCase())) + ) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const label = exactLabel || partialLabel; + if (!label && !inputByAttribute) { + return { ok: false, reason: 'Field label not found', text: document.body?.innerText || '' }; + } + + const inputId = label?.getAttribute('for'); + const fieldRoot = label?.closest('[class*="fui-Field"]') || label?.parentElement?.parentElement || label?.parentElement; + const labelRect = label?.getBoundingClientRect(); + const nearestInput = labelRect + ? visibleInputs + .map((candidate) => ({ candidate, rect: candidate.getBoundingClientRect() })) + .filter(({ rect }) => rect.bottom >= labelRect.top - 5) + .sort((a, b) => { + const scoreA = a.rect.top >= labelRect.bottom - 5 ? a.rect.top - labelRect.bottom : 0; + const scoreB = b.rect.top >= labelRect.bottom - 5 ? b.rect.top - labelRect.bottom : 0; + return scoreA - scoreB || a.rect.top - b.rect.top; + })[0]?.candidate + : undefined; + const fieldInputs = fieldRoot ? Array.from(fieldRoot.querySelectorAll('input')).filter(isVisible) : []; + const fieldInput = + fieldInputs.length === 1 + ? fieldInputs[0] + : labelRect + ? fieldInputs + .map((candidate) => ({ candidate, rect: candidate.getBoundingClientRect() })) + .filter(({ rect }) => rect.bottom >= labelRect.top - 5) + .sort((a, b) => { + const scoreA = a.rect.top >= labelRect.bottom - 5 ? a.rect.top - labelRect.bottom : 0; + const scoreB = b.rect.top >= labelRect.bottom - 5 ? b.rect.top - labelRect.bottom : 0; + return scoreA - scoreB || a.rect.top - b.rect.top; + })[0]?.candidate + : undefined; + const input = inputByAttribute || (inputId ? document.getElementById(inputId) : null) || fieldInput || nearestInput; + if (!(input instanceof HTMLInputElement)) { + return { ok: false, reason: 'Field input not found', text: document.body?.innerText || '', labelHtml: label?.outerHTML }; + } + + const field = input.closest('[class*="fui-Field"]') || input.parentElement; + const getValidationText = (inputElement, fieldElement) => { + const describedBy = inputElement.getAttribute('aria-describedby'); + const describedText = describedBy + ? describedBy + .split(/\\s+/) + .map((id) => document.getElementById(id)?.innerText || '') + .filter(Boolean) + .join('\\n') + : ''; + return [describedText, fieldElement?.innerText || ''].filter(Boolean).join('\\n'); + }; + const setInputValue = (inputElement, value) => { + inputElement.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(inputElement, value); + inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: value ? 'insertText' : 'deleteContentBackward', data: value })); + inputElement.dispatchEvent(new Event('change', { bubbles: true })); + inputElement.blur(); + }; + + ${action} + })()`; +} + +function getLabels(labels: FieldLabels): string[] { + return Array.isArray(labels) ? labels : [labels]; +} + +function containsIgnoreCase(value: string, expected: string): boolean { + return value.toLowerCase().includes(expected.toLowerCase()); +} + +function uniqueName(prefix: string): string { + return `${prefix}${Date.now().toString(36).slice(-5)}`; +} + +async function waitForWebviewTab(viewType: string, previousCount: number): Promise { + const timeoutMs = 10000; + const pollMs = 250; + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const tabs = getWebviewTabs(viewType); + if (tabs.length > previousCount) { + return tabs[tabs.length - 1]; + } + + if (tabs.length > 0 && previousCount === 0) { + return tabs[tabs.length - 1]; + } + + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + + assert.fail(`Timed out waiting for ${viewType} webview tab to open. Open tabs: ${describeOpenTabs()}`); +} + +function getWebviewTabs(viewType: string): vscode.Tab[] { + return vscode.window.tabGroups.all.flatMap((group) => + group.tabs.filter((tab) => { + return getTabViewType(tab) === `mainThreadWebview-${viewType}`; + }) + ); +} + +function getTabViewType(tab: vscode.Tab): string | undefined { + const input = tab.input as { viewType?: unknown }; + return typeof input.viewType === 'string' ? input.viewType : undefined; +} + +async function closeWebviewTabs(viewType: string): Promise { + const tabs = getWebviewTabs(viewType); + if (tabs.length > 0) { + await vscode.window.tabGroups.close(tabs); + } +} + +function describeOpenTabs(): string { + return JSON.stringify( + vscode.window.tabGroups.all.flatMap((group) => + group.tabs.map((tab) => ({ + label: tab.label, + isActive: tab.isActive, + inputType: tab.input?.constructor?.name, + viewType: getTabViewType(tab), + })) + ) + ); +} diff --git a/apps/vs-code-designer/src/test/e2e/dialogGuard.ts b/apps/vs-code-designer/src/test/e2e/dialogGuard.ts new file mode 100644 index 00000000000..21b240d33e6 --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/dialogGuard.ts @@ -0,0 +1,83 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +type DialogMethodName = 'showErrorMessage' | 'showInformationMessage' | 'showWarningMessage'; +type DialogMethod = (message: string, ...items: unknown[]) => Thenable; + +interface DialogAttempt { + method: DialogMethodName; + message: string; + items: string[]; +} + +interface DialogGuardState { + installed: boolean; + attempts: DialogAttempt[]; +} + +const dialogMethods: DialogMethodName[] = ['showErrorMessage', 'showInformationMessage', 'showWarningMessage']; +const globalStateKey = '__logicAppsE2eDialogGuard'; + +export function installDialogGuard(): void { + const state = getDialogGuardState(); + if (state.installed) { + return; + } + + const windowWithDialogs = vscode.window as unknown as Record; + for (const method of dialogMethods) { + windowWithDialogs[method] = (message: string, ...items: unknown[]): Thenable => { + state.attempts.push({ + method, + message, + items: items.map(formatDialogItem), + }); + console.error(`[activation-smoke] Unexpected VS Code dialog attempted through ${method}: ${message}`); + return Promise.resolve(undefined); + }; + } + + state.installed = true; +} + +export async function assertNoDialogAttempts(context: string): Promise { + await new Promise((resolve) => setTimeout(resolve, 500)); + + const attempts = getDialogGuardState().attempts; + if (attempts.length === 0) { + return; + } + + assert.fail( + [ + `Unexpected VS Code dialog attempt(s) during ${context}.`, + 'The @vscode/test-cli baseline should suppress activation prompts and surface setup warnings as test failures.', + ...attempts.map( + (attempt, index) => + `${index + 1}. ${attempt.method}: ${attempt.message}${attempt.items.length ? ` [${attempt.items.join(', ')}]` : ''}` + ), + ].join('\n') + ); +} + +function getDialogGuardState(): DialogGuardState { + const globalWithState = globalThis as unknown as Record; + globalWithState[globalStateKey] ??= { + installed: false, + attempts: [], + }; + + return globalWithState[globalStateKey]; +} + +function formatDialogItem(item: unknown): string { + if (typeof item === 'string') { + return item; + } + + if (typeof item === 'object' && item !== null && 'title' in item) { + return String((item as { title: unknown }).title); + } + + return String(item); +} diff --git a/apps/vs-code-designer/src/test/e2e/extension.test.ts b/apps/vs-code-designer/src/test/e2e/extension.test.ts index c05862822cb..d0c52516bd3 100644 --- a/apps/vs-code-designer/src/test/e2e/extension.test.ts +++ b/apps/vs-code-designer/src/test/e2e/extension.test.ts @@ -1,39 +1,116 @@ import * as assert from 'assert'; +import * as path from 'path'; import * as vscode from 'vscode'; +import { assertNoDialogAttempts, installDialogGuard } from './dialogGuard'; +import { captureCliScreenshot } from './screenshot'; + +const logicAppsExtensionId = 'ms-azuretools.vscode-azurelogicapps'; +const activationChannelName = 'Logic Apps @vscode/test-cli Smoke'; +const expectedExtensionDevelopmentPath = path.resolve(__dirname, '..', '..', '..', 'dist'); + +installDialogGuard(); suite('Extension Activation Tests', () => { - vscode.window.showInformationMessage('Starting Extension Activation Tests'); + let extension: vscode.Extension | undefined; + let activationChannel: vscode.OutputChannel | undefined; + + suiteSetup(() => { + extension = vscode.extensions.getExtension(logicAppsExtensionId); + activationChannel = vscode.window.createOutputChannel(activationChannelName); + }); test('VS Code is running', () => { assert.ok(vscode.version, 'VS Code version should be defined'); - console.log(`VS Code version: ${vscode.version}`); + console.log(`[activation-smoke] VS Code version: ${vscode.version}`); + }); + + test('Test runner environment is configured', () => { + assert.strictEqual(process.env.VSCODE_RUNNING_TESTS, '1'); + assert.strictEqual(process.env.DEBUGTELEMETRY, '1'); }); - test('Extension is present', async () => { - // The extension should be available in the extensions list - const extension = vscode.extensions.getExtension('ms-azuretools.vscode-azurelogicapps'); + test('Logic Apps extension is present with package metadata', () => { + assert.ok(extension, `Expected ${logicAppsExtensionId} to be loaded from the extension development path`); + assert.strictEqual(extension.packageJSON.name, 'vscode-azurelogicapps'); + assert.strictEqual(extension.packageJSON.publisher, 'ms-azuretools'); + assert.strictEqual(extension.packageJSON.engines.vscode, '^1.104.0'); + }); + + test('Logic Apps extension is loaded from the development dist folder', () => { + assert.ok(extension, `Expected ${logicAppsExtensionId} to be loaded from the extension development path`); + + assert.strictEqual( + normalizeFsPath(extension.extensionUri.fsPath), + normalizeFsPath(expectedExtensionDevelopmentPath), + `Expected ${logicAppsExtensionId} to load from ${expectedExtensionDevelopmentPath}` + ); + assert.strictEqual(extension.packageJSON.main, 'main.js'); + logActivationEvidence(`Loaded ${logicAppsExtensionId} from ${extension.extensionUri.fsPath}`); + }); + + test('Logic Apps extension dependencies are installed and visible to VS Code', () => { + assert.ok(extension, `Expected ${logicAppsExtensionId} to be loaded from the extension development path`); + + const extensionDependencies = getExtensionDependencies(extension); + assert.ok(extensionDependencies.length, `${logicAppsExtensionId} should declare extensionDependencies`); - // In test environment, the extension might be loaded differently - // Check if we can at least query extensions - const allExtensions = vscode.extensions.all; - assert.ok(allExtensions.length > 0, 'Should have at least one extension loaded'); - console.log(`Total extensions loaded: ${allExtensions.length}`); + const missingDependencies = extensionDependencies.filter((extensionId) => !vscode.extensions.getExtension(extensionId)); + assert.deepStrictEqual(missingDependencies, [], `Missing extension dependencies: ${missingDependencies.join(', ')}`); - // Log if our extension is found - if (extension) { - console.log('Logic Apps extension found!'); - } else { - console.log('Logic Apps extension not found in list - this may be expected in test environment'); + for (const extensionId of extensionDependencies) { + const dependency = vscode.extensions.getExtension(extensionId); + assert.ok(dependency, `Expected dependency ${extensionId} to be installed`); + logActivationEvidence( + `Dependency available: ${extensionId}@${dependency.packageJSON.version ?? 'unknown'} from ${dependency.extensionUri.fsPath}` + ); } }); - test('Workspace is available', () => { - // Check if workspace folders are available - const workspaceFolders = vscode.workspace.workspaceFolders; - console.log(`Workspace folders: ${workspaceFolders?.length ?? 0}`); + test('Logic Apps extension activates successfully', async () => { + assert.ok(extension, `Expected ${logicAppsExtensionId} to be loaded from the extension development path`); - // In some test configurations, workspace might be empty - // This is not necessarily an error - assert.ok(true, 'Workspace access should be available'); + logActivationEvidence(`Activating ${logicAppsExtensionId}`); + logActivationEvidence(`Extension path: ${extension.extensionUri.fsPath}`); + logActivationEvidence(`VS Code version: ${vscode.version}`); + + const activationStartedAt = Date.now(); + await extension.activate(); + const activationDurationMs = Date.now() - activationStartedAt; + + assert.strictEqual(extension.isActive, true, 'Extension should be active after activate() resolves'); + + logActivationEvidence(`Activated ${logicAppsExtensionId} in ${activationDurationMs}ms`); }); + + test('Logic Apps extension activation does not attempt startup dialogs', async () => { + await assertNoDialogAttempts('Logic Apps extension activation'); + }); + + test('VS Code starts without a folder or saved workspace loaded', async () => { + assert.ok( + !vscode.workspace.workspaceFile || vscode.workspace.workspaceFile.scheme === 'untitled', + `No saved .code-workspace file should be loaded at startup. Actual: ${vscode.workspace.workspaceFile?.toString()}` + ); + assert.deepStrictEqual(vscode.workspace.workspaceFolders ?? [], [], 'No folders should be loaded at startup'); + await captureCliScreenshot('empty-window-startup'); + }); + + function logActivationEvidence(message: string): void { + const line = `[activation-smoke] ${message}`; + console.log(line); + activationChannel?.appendLine(line); + activationChannel?.show(true); + } + + function getExtensionDependencies(logicAppsExtension: vscode.Extension): string[] { + const extensionDependencies = logicAppsExtension.packageJSON.extensionDependencies; + + assert.ok(Array.isArray(extensionDependencies), `${logicAppsExtensionId} should declare extensionDependencies`); + return extensionDependencies; + } + + function normalizeFsPath(fsPath: string): string { + const normalizedPath = path.normalize(fsPath); + return process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath; + } }); diff --git a/apps/vs-code-designer/src/test/e2e/screenshot.ts b/apps/vs-code-designer/src/test/e2e/screenshot.ts new file mode 100644 index 00000000000..955d79fda0b --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/screenshot.ts @@ -0,0 +1,70 @@ +import { execFile } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); +const screenshotRoot = + process.env.LA_E2E_CLI_SCREENSHOT_DIR ?? path.resolve(__dirname, '..', '..', '..', '.vscode-test', 'screenshots', 'cli'); + +export async function captureCliScreenshot(name: string): Promise { + fs.mkdirSync(screenshotRoot, { recursive: true }); + + const screenshotPath = path.join(screenshotRoot, `${sanitizeFileSegment(name)}.png`); + if (process.platform !== 'win32') { + console.log(`[screenshot] Skipping CLI screenshot on unsupported platform: ${process.platform}`); + return undefined; + } + + await captureWindowsScreenshot(screenshotPath); + console.log(`[screenshot] Saved: ${screenshotPath}`); + return screenshotPath; +} + +export async function captureCdpScreenshot( + cdp: { send(method: string, params?: Record): Promise }, + name: string +): Promise { + fs.mkdirSync(screenshotRoot, { recursive: true }); + + const screenshotPath = path.join(screenshotRoot, `${sanitizeFileSegment(name)}.png`); + const response = (await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true })) as { + result?: { data?: string }; + }; + const data = response.result?.data; + if (!data) { + console.log(`[screenshot] CDP screenshot unavailable: ${screenshotPath}`); + return undefined; + } + + fs.writeFileSync(screenshotPath, Buffer.from(data, 'base64')); + console.log(`[screenshot] Saved: ${screenshotPath}`); + return screenshotPath; +} + +function sanitizeFileSegment(value: string): string { + return value.replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'screenshot'; +} + +async function captureWindowsScreenshot(screenshotPath: string): Promise { + const escapedScreenshotPath = screenshotPath.replace(/'/g, "''"); + const script = ` +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen +$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height +$graphics = [System.Drawing.Graphics]::FromImage($bitmap) +try { + $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size) + $bitmap.Save('${escapedScreenshotPath}', [System.Drawing.Imaging.ImageFormat]::Png) +} finally { + $graphics.Dispose() + $bitmap.Dispose() +} +`; + const encodedCommand = Buffer.from(script, 'utf16le').toString('base64'); + + await execFileAsync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Sta', '-EncodedCommand', encodedCommand], { + timeout: 15000, + }); +} diff --git a/apps/vs-code-designer/src/test/e2e/visibleDelay.ts b/apps/vs-code-designer/src/test/e2e/visibleDelay.ts new file mode 100644 index 00000000000..22303da332a --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/visibleDelay.ts @@ -0,0 +1,7 @@ +export async function waitForVisibleDelay(context: string): Promise { + const visibleDelayMs = Number(process.env.LA_E2E_CLI_VISIBLE_DELAY_MS ?? '0'); + if (visibleDelayMs > 0) { + console.log(`[activation-smoke] Keeping VS Code visible for ${visibleDelayMs}ms before closing (${context})`); + await new Promise((resolve) => setTimeout(resolve, visibleDelayMs)); + } +} diff --git a/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts b/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts new file mode 100644 index 00000000000..6fabe2d3568 --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts @@ -0,0 +1,2657 @@ +import * as assert from 'assert'; +import { execFileSync, execSync } from 'child_process'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { + connectToVsCodeCdp, + connectToVsCodeWorkbenchCdp, + waitForCreateWorkspaceFrameContext, + waitForWebviewFrameContext, +} from './cdpClient'; +import { assertNoDialogAttempts, installDialogGuard } from './dialogGuard'; +import { captureCdpScreenshot } from './screenshot'; +import { waitForVisibleDelay } from './visibleDelay'; + +const logicAppsExtensionId = 'ms-azuretools.vscode-azurelogicapps'; +const createWorkspaceCommand = 'azureLogicAppsStandard.createWorkspace'; +const openDesignerCommand = 'azureLogicAppsStandard.openDesigner'; +const openOverviewCommand = 'azureLogicAppsStandard.openOverview'; +const createWorkspaceViewType = 'CreateWorkspace'; +const createWorkspaceTabViewType = `mainThreadWebview-${createWorkspaceViewType}`; +const createWorkspaceTitle = 'Create workspace'; +const designerViewType = 'designerLocal'; +const designerTabViewType = `mainThreadWebview-${designerViewType}`; +const overviewViewType = 'workflowOverview'; +const overviewTabViewType = `mainThreadWebview-${overviewViewType}`; +const managementBaseUrl = 'http://localhost:7071/runtime/webhooks/workflow/api/management'; +const apiVersion = '2019-10-01-edge-preview'; +const requestTriggerTitle = 'When a HTTP request is received'; +const responseActionTitle = 'Response'; +const azuritePorts = [10000, 10001, 10002]; + +type CdpEvaluator = { + evaluate(contextId: number | undefined, expression: string): Promise; + send(method: string, params?: Record): Promise; +}; +type FieldLabels = string | string[]; +type WorkspaceAppType = 'standard' | 'customCode' | 'rulesEngine'; + +interface WorkspaceCreationCase { + label: string; + appType: WorkspaceAppType; + radioLabel: string; + wsName: string; + appName: string; + wfName: string; + functionFolderName?: string; + functionNamespace?: string; + functionName?: string; +} + +interface CreatedWorkspace { + label: string; + appType: WorkspaceAppType; + wsName: string; + appName: string; + wfName: string; + functionFolderName?: string; + functionNamespace?: string; + functionName?: string; + workspaceDir: string; + workspaceFilePath: string; + appDir: string; + workflowJsonPath: string; + folderPaths: string[]; +} + +interface HttpResult { + status: number; + body: string; +} + +interface SavedWorkflowOperations { + requestTriggerName: string; + responseActionName: string; +} + +installDialogGuard(); + +suite('Generated Workspace Designer Lifecycle Tests', () => { + const tempWorkspaceParentPath = fs.mkdtempSync(path.join(os.tmpdir(), 'la-e2e-cli-workspace-lifecycle-')); + const lifecycleMode = process.env.LA_E2E_CLI_WORKSPACE_LIFECYCLE_MODE ?? 'create'; + + suiteSetup(async () => { + const extension = vscode.extensions.getExtension(logicAppsExtensionId); + assert.ok(extension, `Expected ${logicAppsExtensionId} to be loaded from the extension development path`); + await extension.activate(); + }); + + suiteTeardown(async () => { + await waitForVisibleDelay('Generated workspace designer lifecycle'); + await closeWebviewTabs(createWorkspaceViewType); + await closeWebviewTabs(designerViewType); + await stopDebuggingAndTasks(); + }); + + suiteTeardown(() => { + if (lifecycleMode === 'create') { + return; + } + + try { + fs.rmSync(tempWorkspaceParentPath, { recursive: true, force: true }); + } catch (error) { + console.warn(`[workspace-lifecycle] Unable to remove temp workspace parent ${tempWorkspaceParentPath}: ${String(error)}`); + } + }); + + test('Should open generated designers and run saved workflows for Standard, custom code, and rules engine projects', async function () { + this.timeout(1_200_000); + + if (lifecycleMode === 'create') { + const createdWorkspaces: CreatedWorkspace[] = []; + const createLabel = process.env.LA_E2E_CLI_WORKSPACE_LIFECYCLE_CREATE_LABEL; + for (const creationCase of getWorkspaceCreationCases().filter((candidate) => !createLabel || candidate.label === createLabel)) { + createdWorkspaces.push(await createWorkspaceThroughWebview(creationCase, tempWorkspaceParentPath)); + } + + const manifestPath = process.env.LA_E2E_CLI_WORKSPACE_LIFECYCLE_MANIFEST; + assert.ok(manifestPath, 'LA_E2E_CLI_WORKSPACE_LIFECYCLE_MANIFEST must be set in create mode'); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, `${JSON.stringify(createdWorkspaces, null, 2)}\n`); + return; + } + + assert.strictEqual(lifecycleMode, 'run', `Unsupported workspace lifecycle mode: ${lifecycleMode}`); + const createdWorkspace = getWorkspaceLifecycleCaseFromEnv(); + console.log(`[workspace-lifecycle] Running ${createdWorkspace.label} workspace from ${createdWorkspace.workspaceFilePath}`); + ensureLocalSettingsForDesigner(createdWorkspace.appDir); + + console.log(`[workspace-lifecycle] Waiting for ${createdWorkspace.label} Logic App folder`); + await waitForGeneratedLogicAppFolder(createdWorkspace); + buildCustomCodeProjectIfNeeded(createdWorkspace); + if (createdWorkspace.appType === 'standard') { + console.log(`[workspace-lifecycle] Opening ${createdWorkspace.label} designer`); + await openDesignerAndCreateWorkflow(createdWorkspace); + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-designer-open`); + } else { + console.log(`[workspace-lifecycle] ${createdWorkspace.label}: skipping designer open; using generated workflow`); + assertGeneratedWorkflowReadyForRuntime(createdWorkspace); + await waitForCustomCodeRuntimeArtifactsIfNeeded(createdWorkspace); + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-generated-workflow-ready`); + } + + await startDebuggingGeneratedWorkspace(createdWorkspace); + await runWorkflowThroughOverviewAndAssertSucceeded(createdWorkspace); + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-run-succeeded`); + + await assertNoDialogAttempts('Generated workspace designer lifecycle'); + }); +}); + +function getWorkspaceCreationCases(): WorkspaceCreationCase[] { + return [ + { + label: 'standard', + appType: 'standard', + radioLabel: 'Logic app (Standard)', + wsName: uniqueName('clilifestdws'), + appName: uniqueName('clilifestdapp'), + wfName: uniqueName('clilifestdwf'), + }, + { + label: 'custom-code', + appType: 'customCode', + radioLabel: 'Logic app with custom code', + wsName: uniqueName('clilifeccws'), + appName: uniqueName('clilifeccapp'), + wfName: uniqueName('clilifeccwf'), + functionFolderName: uniqueName('clilifeccfolder'), + functionNamespace: 'MyCompany.Functions', + functionName: uniqueName('clilifeccfn'), + }, + { + label: 'rules-engine', + appType: 'rulesEngine', + radioLabel: 'Logic app with rules engine', + wsName: uniqueName('cliliferews'), + appName: uniqueName('clilifereapp'), + wfName: uniqueName('cliliferewf'), + functionFolderName: uniqueName('cliliferefolder'), + functionNamespace: 'RulesEngineNamespace', + functionName: uniqueName('cliliferefn'), + }, + ]; +} + +function getWorkspaceLifecycleCaseFromEnv(): CreatedWorkspace { + const rawCase = process.env.LA_E2E_CLI_WORKSPACE_LIFECYCLE_CASE; + assert.ok(rawCase, 'LA_E2E_CLI_WORKSPACE_LIFECYCLE_CASE must be set in run mode'); + return JSON.parse(rawCase) as CreatedWorkspace; +} + +async function createWorkspaceThroughWebview(creationCase: WorkspaceCreationCase, parentPath: string): Promise { + const { cdp, contextId } = await openCreateWorkspaceContext(); + try { + await fillWorkspaceCreationFields(cdp, contextId, creationCase, parentPath); + await dismissWorkbenchNotifications(); + await assertWorkspaceCreationFields(cdp, contextId, creationCase, parentPath); + await captureWorkspaceCreationFormScreenshots(cdp, contextId, creationCase.label, 'fields-verified'); + await assertNextButtonEnabled(cdp, contextId, `${creationCase.label} creation fields`); + await clickWizardButton(cdp, contextId, 'Next'); + await waitForReviewStep(cdp, contextId, creationCase); + await captureLifecycleScreenshot(`workspace-lifecycle-${creationCase.label}-review`); + await clickWizardButton(cdp, contextId, 'Create workspace'); + await waitForCreatedWorkspaceMaterialization(parentPath, creationCase); + return verifyCreatedWorkspace(parentPath, creationCase); + } finally { + cdp.dispose(); + await closeWebviewTabs(createWorkspaceViewType); + } +} + +async function openCreateWorkspaceContext(): Promise<{ cdp: CdpEvaluator & { dispose(): void }; contextId: number }> { + await closeWebviewTabs(createWorkspaceViewType); + const tabsBefore = getWebviewTabs(createWorkspaceViewType).length; + + await vscode.commands.executeCommand(createWorkspaceCommand); + + const tab = await waitForWebviewTab(createWorkspaceViewType, tabsBefore); + assert.strictEqual(getTabViewType(tab), createWorkspaceTabViewType); + assert.strictEqual(tab.label, createWorkspaceTitle); + + const cdp = await connectToVsCodeCdp({ targetName: 'Create Workspace webview' }); + const contextId = await waitForCreateWorkspaceFrameContext(cdp, 60000); + await captureLifecycleScreenshot('workspace-lifecycle-create-workspace-form-ready'); + return { cdp, contextId }; +} + +async function fillWorkspaceCreationFields( + cdp: CdpEvaluator, + contextId: number, + creationCase: WorkspaceCreationCase, + parentPath: string +): Promise { + await enterFieldValue(cdp, contextId, 'Workspace parent folder path', parentPath); + await waitForAsyncValidationToSettle(cdp, contextId); + await captureLifecycleScreenshot(`workspace-lifecycle-${creationCase.label}-parent-folder-entered`); + await enterFieldValue(cdp, contextId, 'Workspace name', creationCase.wsName); + await waitForAsyncValidationToSettle(cdp, contextId); + await captureLifecycleScreenshot(`workspace-lifecycle-${creationCase.label}-workspace-name-entered`); + await enterFieldValue(cdp, contextId, 'Logic app name', creationCase.appName); + await enterFieldValue(cdp, contextId, 'Workflow name', creationCase.wfName); + await captureLifecycleScreenshot(`workspace-lifecycle-${creationCase.label}-required-fields-entered`); + await selectDropdownOption(cdp, contextId, 'Workflow type', 'Stateful'); + await selectRadioOption(cdp, contextId, creationCase.radioLabel); + await captureLifecycleScreenshot(`workspace-lifecycle-${creationCase.label}-type-selected`); + + if (creationCase.appType === 'customCode') { + await waitForFieldVisible(cdp, contextId, ['Custom code folder name', 'custom code folder', 'Code folder name', 'Folder name']); + await selectDropdownOption(cdp, contextId, '.NET Version', '.NET 8'); + await enterFieldValue( + cdp, + contextId, + ['Custom code folder name', 'custom code folder', 'Code folder name', 'Folder name'], + requiredValue(creationCase.functionFolderName) + ); + await enterFieldValue(cdp, contextId, ['Function namespace', 'Namespace', 'namespace'], requiredValue(creationCase.functionNamespace)); + await enterFieldValue(cdp, contextId, 'Function name', requiredValue(creationCase.functionName)); + } else if (creationCase.appType === 'rulesEngine') { + await waitForFieldVisible(cdp, contextId, ['Rules engine folder name', 'rules engine folder', 'Folder name']); + await enterFieldValue( + cdp, + contextId, + ['Rules engine folder name', 'rules engine folder', 'Folder name'], + requiredValue(creationCase.functionFolderName) + ); + await enterFieldValue(cdp, contextId, ['Function namespace', 'Namespace', 'namespace'], requiredValue(creationCase.functionNamespace)); + await enterFieldValue(cdp, contextId, 'Function name', requiredValue(creationCase.functionName)); + } + + await waitForAsyncValidationToSettle(cdp, contextId); +} + +async function assertWorkspaceCreationFields( + cdp: CdpEvaluator, + contextId: number, + creationCase: WorkspaceCreationCase, + parentPath: string +): Promise { + const expectedFields: Array<{ labels: FieldLabels; value: string }> = [ + { labels: 'Workspace parent folder path', value: parentPath }, + { labels: 'Workspace name', value: creationCase.wsName }, + { labels: 'Logic app name', value: creationCase.appName }, + { labels: 'Workflow name', value: creationCase.wfName }, + ]; + + if (creationCase.appType === 'customCode') { + expectedFields.push( + { + labels: ['Custom code folder name', 'custom code folder', 'Code folder name', 'Folder name'], + value: requiredValue(creationCase.functionFolderName), + }, + { labels: ['Function namespace', 'Namespace', 'namespace'], value: requiredValue(creationCase.functionNamespace) }, + { labels: 'Function name', value: requiredValue(creationCase.functionName) } + ); + } else if (creationCase.appType === 'rulesEngine') { + expectedFields.push( + { + labels: ['Rules engine folder name', 'rules engine folder', 'Folder name'], + value: requiredValue(creationCase.functionFolderName), + }, + { labels: ['Function namespace', 'Namespace', 'namespace'], value: requiredValue(creationCase.functionNamespace) }, + { labels: 'Function name', value: requiredValue(creationCase.functionName) } + ); + } + + for (const field of expectedFields) { + const state = await getFieldState(cdp, contextId, field.labels); + assert.strictEqual( + state.value, + field.value, + `Expected ${creationCase.label} field ${getLabels(field.labels).join('/')} to equal ${field.value}. State=${JSON.stringify(state)}` + ); + } + + assert.ok( + await isDropdownValueSelected(cdp, contextId, 'Workflow type', 'Stateful'), + `Expected ${creationCase.label} Workflow type dropdown to be Stateful` + ); + assert.ok( + await isRadioOptionChecked(cdp, contextId, creationCase.radioLabel), + `Expected ${creationCase.label} app type radio to be checked` + ); + + if (creationCase.appType === 'customCode') { + assert.ok(await isDropdownValueSelected(cdp, contextId, '.NET Version', '.NET 8'), 'Expected custom-code .NET Version to be .NET 8'); + } +} + +async function captureWorkspaceCreationFormScreenshots(cdp: CdpEvaluator, contextId: number, label: string, stage: string): Promise { + for (const position of ['top', 'middle', 'bottom']) { + await scrollCreateWorkspaceForm(cdp, contextId, position); + await captureLifecycleScreenshot(`workspace-lifecycle-${label}-${stage}-${position}`); + } +} + +async function scrollCreateWorkspaceForm(cdp: CdpEvaluator, contextId: number, position: string): Promise { + await cdp.evaluate( + contextId, + `(() => { + const position = ${JSON.stringify(position)}; + const scrollableElements = Array.from(document.querySelectorAll('*')) + .filter((element) => element instanceof HTMLElement && element.scrollHeight > element.clientHeight + 20); + const scrollable = scrollableElements + .sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || document.scrollingElement; + if (!scrollable) { + return; + } + const maxScrollTop = scrollable.scrollHeight - scrollable.clientHeight; + const top = position === 'top' ? 0 : position === 'middle' ? Math.floor(maxScrollTop / 2) : maxScrollTop; + scrollable.scrollTo({ top, behavior: 'instant' }); + })()` + ); + await new Promise((resolve) => setTimeout(resolve, 250)); +} + +function ensureLocalSettingsForDesigner(appDir: string): void { + const settingsPath = path.join(appDir, 'local.settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + settings.Values = settings.Values ?? {}; + settings.Values.WORKFLOWS_SUBSCRIPTION_ID = ''; + fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`); +} + +async function waitForGeneratedLogicAppFolder(createdWorkspace: CreatedWorkspace): Promise { + await waitUntil( + () => { + const openFolders = vscode.workspace.workspaceFolders ?? []; + return openFolders.some((folder) => normalizeFsPath(folder.uri.fsPath) === normalizeFsPath(createdWorkspace.appDir)); + }, + 45000, + `startup workspace folders to include ${createdWorkspace.appDir}. Current folders: ${JSON.stringify( + (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath) + )}` + ); +} + +async function openDesignerAndCreateWorkflow(createdWorkspace: CreatedWorkspace): Promise { + await closeAllTabs(); + const workflowDocument = await vscode.workspace.openTextDocument(vscode.Uri.file(createdWorkspace.workflowJsonPath)); + await vscode.window.showTextDocument(workflowDocument, { preview: false }); + const tabsBefore = getWebviewTabs(designerViewType).length; + + const openDesignerPromise = vscode.commands + .executeCommand(openDesignerCommand) + .then(undefined, (error) => console.warn(`[workspace-lifecycle] openDesigner command rejected: ${String(error)}`)); + assert.ok(openDesignerPromise, 'Expected open designer command to start'); + + await handleDesignerQuickPickPrompts(15000); + + const tab = await waitForWebviewTab(designerViewType, tabsBefore, 360000); + assert.strictEqual(getTabViewType(tab), designerTabViewType); + assert.ok( + tab.label.includes(createdWorkspace.wfName), + `Expected designer tab label to include workflow name "${createdWorkspace.wfName}". Open tabs: ${describeOpenTabs()}` + ); + + await handleDesignerQuickPickPrompts(15000); + + const cdp = await connectToVsCodeCdp({ targetName: `${createdWorkspace.label} designer webview` }); + try { + const contextId = await waitForWebviewFrameContext(cdp, { + allTextIncludes: ['Save'], + description: `${createdWorkspace.label} designer webview DOM context`, + timeoutMs: 180000, + }); + await waitForDesignerText( + cdp, + contextId, + ['Add a trigger', requestTriggerTitle, responseActionTitle], + 180000, + `${createdWorkspace.label} designer canvas content` + ); + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-designer-ready`); + + const initialCanvasText = await getDesignerText(cdp, contextId); + if (initialCanvasText.includes('Add a trigger')) { + await addRequestTriggerThroughDesigner(cdp, contextId, createdWorkspace.label); + await addResponseActionThroughDesigner(cdp, contextId, createdWorkspace.label); + } else { + console.log(`[workspace-lifecycle] ${createdWorkspace.label}: designer opened with generated workflow content`); + if (!initialCanvasText.includes(responseActionTitle)) { + await addResponseActionThroughDesigner(cdp, contextId, createdWorkspace.label); + } + } + await saveWorkflowThroughDesigner(cdp, contextId, createdWorkspace.label); + + const canvasText = await getDesignerText(cdp, contextId); + assert.ok( + canvasText.includes(responseActionTitle), + `${createdWorkspace.label} designer should render the Response action added through the UI. Text: ${canvasText.slice(0, 1000)}` + ); + await waitForSavedWorkflowContainsDesignerChanges(createdWorkspace); + } catch (error) { + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-designer-failure`); + throw error; + } finally { + cdp.dispose(); + } +} + +async function addRequestTriggerThroughDesigner(cdp: CdpEvaluator, contextId: number, label: string): Promise { + console.log(`[workspace-lifecycle] ${label}: clicking Add a trigger`); + await clickDesignerElement( + cdp, + contextId, + ['[data-testid="card-Add a trigger"]', '[data-automation-id="card-Add_a_trigger"]', '[aria-label="Add a trigger"]'], + 'Add a trigger' + ); + await waitForDiscoveryPanelThroughDesigner(cdp, contextId, 60000, `${label} trigger discovery panel`); + await captureLifecycleScreenshot(`workspace-lifecycle-${label}-trigger-panel-open`); + + console.log(`[workspace-lifecycle] ${label}: searching for Request trigger`); + await searchInDiscoveryPanelThroughDesigner(cdp, contextId, 'Request'); + await captureLifecycleScreenshot(`workspace-lifecycle-${label}-request-search-entered`); + await waitForSearchResultsThroughDesigner(cdp, contextId, 60000, `${label} Request search results`); + + await selectOperationThroughDesigner(cdp, contextId, 'Request', [ + 'when a http request is received', + 'when an http request is received', + 'http request', + ]); + await waitForDesignerText(cdp, contextId, [requestTriggerTitle, 'Request'], 90000, `${label} Request trigger on canvas`); + await captureLifecycleScreenshot(`workspace-lifecycle-${label}-request-trigger-added`); +} + +async function addResponseActionThroughDesigner(cdp: CdpEvaluator, contextId: number, label: string): Promise { + console.log(`[workspace-lifecycle] ${label}: clicking Add an action`); + let actionPanelOpened = false; + for (let attempt = 1; attempt <= 3; attempt++) { + await clickDesignerElement( + cdp, + contextId, + [ + '[data-automation-id^="msla-plus-button-"]', + '[id^="msla-edge-button-"]', + '[data-testid="card-Add an action"]', + '[data-automation-id="card-Add_an_action"]', + '[aria-label="Add an action"]', + ], + 'Add an action', + { requireTextMatch: false, useLastMatch: true } + ); + + if (await waitForOptionalDiscoveryPanelThroughDesigner(cdp, contextId, 2500)) { + actionPanelOpened = true; + break; + } + + const clickedMenuItem = await tryClickDesignerElement( + cdp, + contextId, + ['[data-automation-id^="msla-add-button-"]', '[role="menuitem"]'], + 'Add an action' + ); + if (clickedMenuItem) { + if (await waitForOptionalDiscoveryPanelThroughDesigner(cdp, contextId, 2500)) { + actionPanelOpened = true; + break; + } + } + + console.log(`[workspace-lifecycle] ${label}: Add Action panel did not open on attempt ${attempt}`); + } + assert.ok(actionPanelOpened, `${label} Add Action panel should open`); + await waitForDiscoveryPanelThroughDesigner(cdp, contextId, 60000, `${label} action discovery panel`); + await captureLifecycleScreenshot(`workspace-lifecycle-${label}-action-panel-open`); + + console.log(`[workspace-lifecycle] ${label}: searching for Response action`); + await searchInDiscoveryPanelThroughDesigner(cdp, contextId, responseActionTitle); + await captureLifecycleScreenshot(`workspace-lifecycle-${label}-response-search-entered`); + await waitForSearchResultsThroughDesigner(cdp, contextId, 60000, `${label} Response search results`); + + await selectOperationThroughDesigner(cdp, contextId, responseActionTitle, ['response']); + await waitForDesignerText(cdp, contextId, [responseActionTitle], 90000, `${label} Response action on canvas`); + await captureLifecycleScreenshot(`workspace-lifecycle-${label}-response-action-added`); +} + +async function saveWorkflowThroughDesigner(cdp: CdpEvaluator, contextId: number, label: string): Promise { + console.log(`[workspace-lifecycle] ${label}: saving workflow through designer command bar`); + await clickDesignerElement(cdp, contextId, ['button[aria-label="Save"]'], 'Save'); + await waitUntil( + async () => + cdp.evaluate( + contextId, + `(() => { + const button = document.querySelector('button[aria-label="Save"]'); + const text = (button?.textContent || '').toLowerCase(); + const label = (button?.getAttribute('aria-label') || '').toLowerCase(); + return !text.includes('saving') && !label.includes('saving'); + })()` + ), + 60000, + `${label} designer save to complete` + ); +} + +async function clickDesignerElement( + cdp: CdpEvaluator, + contextId: number, + selectors: string[], + textToFind: string, + options: { requireTextMatch?: boolean; useLastMatch?: boolean } = {} +): Promise { + const result = await cdp.evaluate<{ + ok: boolean; + reason?: string; + text?: string; + point?: { x: number; y: number }; + candidates?: string[]; + }>( + contextId, + `(() => { + const selectors = ${JSON.stringify(selectors)}; + const textToFind = ${JSON.stringify(textToFind.toLowerCase())}; + const requireTextMatch = ${JSON.stringify(options.requireTextMatch !== false)}; + const useLastMatch = ${JSON.stringify(options.useLastMatch === true)}; + const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const matchesText = (element) => { + const text = normalize(element.textContent).toLowerCase(); + const ariaLabel = normalize(element.getAttribute('aria-label')).toLowerCase(); + const title = normalize(element.getAttribute('title')).toLowerCase(); + return !requireTextMatch || !textToFind || text.includes(textToFind) || ariaLabel.includes(textToFind) || title.includes(textToFind); + }; + const candidates = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))) + .filter(isVisible) + .filter(matchesText); + const debugCandidates = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))) + .filter(isVisible) + .slice(0, 10) + .map((element) => { + const aid = normalize(element.getAttribute('data-automation-id')); + const aria = normalize(element.getAttribute('aria-label')); + const text = normalize(element.textContent).slice(0, 120); + return aid + ' | ' + aria + ' | ' + text; + }); + const element = useLastMatch ? candidates.at(-1) : candidates[0]; + if (!element) { + return { ok: false, reason: 'Element not found', candidates: debugCandidates, text: document.body?.innerText || '' }; + } + + element.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = element.getBoundingClientRect(); + return { + ok: true, + point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, + text: normalize(element.textContent || element.getAttribute('aria-label') || ''), + }; + })()` + ); + + assert.ok( + result.ok && result.point, + `Expected clickable designer element "${textToFind}". Reason=${result.reason} candidates=${JSON.stringify(result.candidates)} text=${String( + result.text + ).slice(0, 1000)}` + ); + + console.log(`[workspace-lifecycle] Clicking designer element "${textToFind}" (${result.text ?? ''})`); + await clickPoint(cdp, result.point); +} + +async function tryClickDesignerElement( + cdp: CdpEvaluator, + contextId: number, + selectors: string[], + textToFind: string, + options: { requireTextMatch?: boolean; useLastMatch?: boolean } = {} +): Promise { + try { + await clickDesignerElement(cdp, contextId, selectors, textToFind, options); + return true; + } catch (error) { + console.log(`[workspace-lifecycle] Optional designer element "${textToFind}" was not clickable: ${String(error)}`); + return false; + } +} + +async function hasDiscoveryPanelThroughDesigner(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate( + contextId, + `(() => { + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + return [ + '.msla-panel-root-Discovery', + '[class*="panel-root"]', + '[data-automation-id="msla-search-box"]', + '.msla-search-box', + 'input[placeholder*="Search"]', + ].some((selector) => Array.from(document.querySelectorAll(selector)).some(isVisible)); + })()` + ); +} + +async function waitForDiscoveryPanelThroughDesigner( + cdp: CdpEvaluator, + contextId: number, + timeoutMs: number, + description: string +): Promise { + await waitUntil(() => hasDiscoveryPanelThroughDesigner(cdp, contextId), timeoutMs, description); +} + +async function waitForOptionalDiscoveryPanelThroughDesigner(cdp: CdpEvaluator, contextId: number, timeoutMs: number): Promise { + try { + await waitForDiscoveryPanelThroughDesigner(cdp, contextId, timeoutMs, 'optional designer discovery panel'); + return true; + } catch { + return false; + } +} + +async function searchInDiscoveryPanelThroughDesigner(cdp: CdpEvaluator, contextId: number, searchTerm: string): Promise { + const result = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; value?: string }>( + contextId, + `(() => { + const searchTerm = ${JSON.stringify(searchTerm)}; + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const selectors = [ + '[data-automation-id="msla-search-box"] input', + '[data-automation-id="msla-search-box"]', + '.msla-search-box input', + '.msla-search-box', + 'input[placeholder*="Search"]', + 'input[type="text"]', + ]; + for (const selector of selectors) { + const element = Array.from(document.querySelectorAll(selector)).find(isVisible); + if (!element) { + continue; + } + + const input = element instanceof HTMLInputElement ? element : element.querySelector('input'); + if (!(input instanceof HTMLInputElement)) { + continue; + } + + input.scrollIntoView({ block: 'center', inline: 'center' }); + input.focus(); + input.select(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(input, searchTerm); + input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: searchTerm })); + input.dispatchEvent(new Event('change', { bubbles: true })); + return { + ok: true, + text: input.placeholder || input.getAttribute('aria-label') || '', + value: input.value, + }; + } + + return { ok: false, reason: 'Search input not found', text: document.body?.innerText || '' }; + })()` + ); + + assert.ok( + result.ok && result.value === searchTerm, + `Expected designer search input for "${searchTerm}". Reason=${result.reason} value=${result.value} text=${result.text?.slice(0, 1000)}` + ); +} + +async function waitForSearchResultsThroughDesigner( + cdp: CdpEvaluator, + contextId: number, + timeoutMs: number, + description: string +): Promise { + await waitUntil( + () => + cdp.evaluate( + contextId, + `(() => { + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const selectors = [ + '[data-automation-id^="msla-op-search-result-"]', + '[data-testid^="msla-op-search-result-"]', + '.msla-op-search-card-container', + '.msla-op-search-card', + '.msla-recommendation-panel-card', + '[role="option"]', + ]; + return selectors.some((selector) => Array.from(document.querySelectorAll(selector)).some(isVisible)); + })()` + ), + timeoutMs, + description + ); +} + +async function selectOperationThroughDesigner( + cdp: CdpEvaluator, + contextId: number, + operationName: string, + variants: string[] +): Promise { + const result = await cdp.evaluate<{ + ok: boolean; + reason?: string; + point?: { x: number; y: number }; + text?: string; + candidates?: string[]; + }>( + contextId, + `(() => { + const variants = ${JSON.stringify([operationName, ...variants].map((variant) => variant.toLowerCase()))}; + const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const selectors = [ + '[data-automation-id^="msla-op-search-result-"]', + '[data-testid^="msla-op-search-result-"]', + '.msla-op-search-card-container', + '.msla-op-search-card', + '.msla-recommendation-panel-card', + '[role="option"]', + '[class*="connector"] [role="button"]', + '[class*="connector"] button', + ]; + const cards = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))).filter(isVisible); + const candidates = cards.slice(0, 12).map((element) => { + const aid = normalize(element.getAttribute('data-automation-id')); + const aria = normalize(element.getAttribute('aria-label')); + const text = normalize(element.textContent).slice(0, 160); + return aid + ' | ' + aria + ' | ' + text; + }); + + for (const card of cards) { + const title = normalize(card.querySelector('.msla-op-search-card-title')?.textContent); + const text = normalize(title || card.textContent).toLowerCase(); + const aria = normalize(card.getAttribute('aria-label')).toLowerCase(); + const aid = normalize(card.getAttribute('data-automation-id')).toLowerCase(); + const combined = text + ' ' + aria + ' ' + aid; + if (combined === 'all' || combined.startsWith('all ')) { + continue; + } + + if (variants.some((variant) => combined.includes(variant))) { + card.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = card.getBoundingClientRect(); + return { + ok: true, + point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, + text: normalize(title || card.textContent || card.getAttribute('aria-label') || ''), + candidates, + }; + } + } + + return { ok: false, reason: 'Operation card not found', candidates, text: document.body?.innerText || '' }; + })()` + ); + + assert.ok( + result.ok && result.point, + `Expected operation card "${operationName}". Reason=${result.reason} candidates=${JSON.stringify(result.candidates)} text=${String( + result.text + ).slice(0, 1000)}` + ); + + console.log(`[workspace-lifecycle] Selecting operation "${operationName}" (${result.text ?? ''})`); + await clickPoint(cdp, result.point); +} + +async function waitForDesignerText( + cdp: CdpEvaluator, + contextId: number, + expectedText: string[], + timeoutMs: number, + description: string +): Promise { + await waitUntil( + () => + cdp.evaluate( + contextId, + `(() => { + const collectText = (root) => { + let text = ''; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + let node = walker.currentNode; + while (node) { + if (node instanceof HTMLScriptElement || node instanceof HTMLStyleElement) { + node = walker.nextSibling() || walker.nextNode(); + continue; + } + if (node.parentElement instanceof HTMLScriptElement || node.parentElement instanceof HTMLStyleElement) { + node = walker.nextNode(); + continue; + } + if (node.nodeType === Node.TEXT_NODE) { + text += node.textContent || ''; + } + if (node.shadowRoot) { + text += collectText(node.shadowRoot); + } + if (node instanceof HTMLIFrameElement && node.contentDocument) { + text += collectText(node.contentDocument); + } + node = walker.nextNode(); + } + return text; + }; + const text = collectText(document).toLowerCase(); + return ${JSON.stringify(expectedText.map((text) => text.toLowerCase()))}.some((expected) => text.includes(expected)); + })()` + ), + timeoutMs, + description + ); +} + +async function getDesignerText(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate( + contextId, + `(() => { + const collectText = (root) => { + let text = ''; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + let node = walker.currentNode; + while (node) { + if (node instanceof HTMLScriptElement || node instanceof HTMLStyleElement) { + node = walker.nextSibling() || walker.nextNode(); + continue; + } + if (node.parentElement instanceof HTMLScriptElement || node.parentElement instanceof HTMLStyleElement) { + node = walker.nextNode(); + continue; + } + if (node.nodeType === Node.TEXT_NODE) { + text += node.textContent || ''; + } + if (node.shadowRoot) { + text += collectText(node.shadowRoot); + } + if (node instanceof HTMLIFrameElement && node.contentDocument) { + text += collectText(node.contentDocument); + } + node = walker.nextNode(); + } + return text; + }; + return collectText(document); + })()` + ); +} + +async function waitForSavedWorkflowContainsDesignerChanges(createdWorkspace: CreatedWorkspace): Promise { + let operations: SavedWorkflowOperations | undefined; + await waitUntil( + () => { + operations = tryGetSavedWorkflowOperations(createdWorkspace); + return operations !== undefined; + }, + 60000, + `${createdWorkspace.label} workflow.json to contain UI-created Request trigger and Response action` + ); + + assert.ok(operations, `Expected saved workflow operations for ${createdWorkspace.label}`); + console.log( + `[workspace-lifecycle] ${createdWorkspace.label}: saved Request trigger "${operations.requestTriggerName}" and Response action "${operations.responseActionName}"` + ); + return operations; +} + +function getSavedWorkflowOperations(createdWorkspace: CreatedWorkspace): SavedWorkflowOperations { + const operations = tryGetSavedWorkflowOperations(createdWorkspace); + assert.ok(operations, `Expected ${createdWorkspace.workflowJsonPath} to contain a saved Request trigger and Response action`); + return operations; +} + +function tryGetSavedWorkflowOperations(createdWorkspace: CreatedWorkspace): SavedWorkflowOperations | undefined { + const workflowJson = JSON.parse(fs.readFileSync(createdWorkspace.workflowJsonPath, 'utf-8')); + const triggers = workflowJson?.definition?.triggers ?? {}; + const actions = workflowJson?.definition?.actions ?? {}; + const requestTriggerEntry = Object.entries(triggers).find(([, trigger]) => { + const triggerRecord = trigger as Record; + return String(triggerRecord.type ?? '').toLowerCase() === 'request' || String(triggerRecord.kind ?? '').toLowerCase() === 'http'; + }); + const responseActionEntry = Object.entries(actions).find(([, action]) => { + const actionRecord = action as Record; + return String(actionRecord.type ?? '').toLowerCase() === 'response'; + }); + + if (!requestTriggerEntry || !responseActionEntry) { + return undefined; + } + + return { + requestTriggerName: requestTriggerEntry[0], + responseActionName: responseActionEntry[0], + }; +} + +function getSavedWorkflowRunEvidence(createdWorkspace: CreatedWorkspace): { + requestTriggerName: string; + expectedActionNames: string[]; +} { + const workflowJson = JSON.parse(fs.readFileSync(createdWorkspace.workflowJsonPath, 'utf-8')); + const triggers = workflowJson?.definition?.triggers ?? {}; + const actions = workflowJson?.definition?.actions ?? {}; + const requestTriggerEntry = Object.entries(triggers).find(([, trigger]) => { + const triggerRecord = trigger as Record; + return String(triggerRecord.type ?? '').toLowerCase() === 'request' || String(triggerRecord.kind ?? '').toLowerCase() === 'http'; + }); + assert.ok(requestTriggerEntry, `Expected ${createdWorkspace.workflowJsonPath} to contain a Request trigger`); + + const actionEntries = Object.entries(actions); + assert.ok(actionEntries.length > 0, `Expected ${createdWorkspace.workflowJsonPath} to contain at least one action`); + + if (createdWorkspace.appType === 'standard') { + const responseActionEntry = actionEntries.find(([, action]) => { + const actionRecord = action as Record; + return String(actionRecord.type ?? '').toLowerCase() === 'response'; + }); + assert.ok(responseActionEntry, `Expected ${createdWorkspace.workflowJsonPath} to contain a Response action`); + return { + requestTriggerName: requestTriggerEntry[0], + expectedActionNames: [responseActionEntry[0]], + }; + } + + const invokeFunctionActionNames = actionEntries + .filter(([, action]) => String((action as Record).type ?? '').toLowerCase() === 'invokefunction') + .map(([actionName]) => actionName); + assert.ok( + invokeFunctionActionNames.length > 0, + `Expected ${createdWorkspace.workflowJsonPath} to contain an InvokeFunction action. Actions=${JSON.stringify(actionEntries.map(([name]) => name))}` + ); + + return { + requestTriggerName: requestTriggerEntry[0], + expectedActionNames: invokeFunctionActionNames, + }; +} + +function assertGeneratedWorkflowReadyForRuntime(createdWorkspace: CreatedWorkspace): void { + const evidence = getSavedWorkflowRunEvidence(createdWorkspace); + assert.ok(evidence.requestTriggerName, `Expected ${createdWorkspace.label} generated workflow to have a request trigger`); + assert.ok(evidence.expectedActionNames.length > 0, `Expected ${createdWorkspace.label} generated workflow to have runnable actions`); + console.log( + `[workspace-lifecycle] ${createdWorkspace.label}: generated workflow trigger="${evidence.requestTriggerName}", actions=${JSON.stringify( + evidence.expectedActionNames + )}` + ); +} + +function buildCustomCodeProjectIfNeeded(createdWorkspace: CreatedWorkspace): void { + if (createdWorkspace.appType === 'standard') { + return; + } + + const customCodeProjectPaths = getCustomCodeProjectPaths(createdWorkspace); + assert.ok( + customCodeProjectPaths.length > 0, + `Expected ${createdWorkspace.label} workspace to include a custom-code project folder. Folders: ${createdWorkspace.folderPaths.join(', ')}` + ); + + const dotnetPath = vscode.workspace.getConfiguration('azureLogicAppsStandard').get('dotnetBinaryPath') ?? 'dotnet'; + for (const projectPath of customCodeProjectPaths) { + const csprojPath = fs.readdirSync(projectPath).find((entry) => entry.endsWith('.csproj')); + assert.ok(csprojPath, `Expected a .csproj file under ${projectPath}`); + + console.log(`[workspace-lifecycle] ${createdWorkspace.label}: building custom-code project ${path.join(projectPath, csprojPath)}`); + try { + const output = execFileSync(dotnetPath, ['build', path.join(projectPath, csprojPath), '--nologo'], { + cwd: projectPath, + encoding: 'utf-8', + timeout: 180000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + console.log(`[workspace-lifecycle] ${createdWorkspace.label}: custom-code build output\n${output}`); + } catch (error) { + const execError = error as { stdout?: string; stderr?: string; message?: string }; + assert.fail( + `${createdWorkspace.label} custom-code build failed. stdout=${execError.stdout ?? ''} stderr=${ + execError.stderr ?? '' + } message=${execError.message ?? String(error)}` + ); + } + } +} + +async function waitForCustomCodeRuntimeArtifactsIfNeeded(createdWorkspace: CreatedWorkspace): Promise { + if (createdWorkspace.appType === 'standard') { + return; + } + + const invokeFunctionNames = getSavedWorkflowInvokeFunctionNames(createdWorkspace); + assert.ok( + invokeFunctionNames.length > 0, + `Expected ${createdWorkspace.label} workflow to contain an InvokeFunction action before debug. Workflow: ${fs + .readFileSync(createdWorkspace.workflowJsonPath, 'utf-8') + .slice(0, 2000)}` + ); + + await waitUntil( + () => + invokeFunctionNames.every((functionName) => + fs.existsSync(path.join(createdWorkspace.appDir, 'lib', 'custom', functionName, 'function.json')) + ), + 180000, + `${createdWorkspace.label} custom-code function metadata under lib\\custom. Expected functions=${invokeFunctionNames.join( + ', ' + )}. Files=${JSON.stringify(getCustomCodeDiagnosticFiles(createdWorkspace.appDir))}` + ); +} + +function getSavedWorkflowInvokeFunctionNames(createdWorkspace: CreatedWorkspace): string[] { + const workflowJson = JSON.parse(fs.readFileSync(createdWorkspace.workflowJsonPath, 'utf-8')); + const actions = workflowJson?.definition?.actions ?? {}; + return Object.values(actions) + .filter((action) => String((action as Record).type ?? '').toLowerCase() === 'invokefunction') + .map((action) => String((((action as Record).inputs as Record | undefined) ?? {}).functionName ?? '')) + .filter((functionName) => functionName.length > 0); +} + +function getCustomCodeProjectPaths(createdWorkspace: CreatedWorkspace): string[] { + return createdWorkspace.folderPaths.filter((folderPath) => folderPath !== createdWorkspace.appDir && hasCsproj(folderPath)); +} + +function hasCsproj(folderPath: string): boolean { + return fs.existsSync(folderPath) && fs.readdirSync(folderPath).some((entry) => entry.endsWith('.csproj')); +} + +function getCustomCodeDiagnosticFiles(appDir: string): string[] { + const customCodePath = path.join(appDir, 'lib', 'custom'); + if (!fs.existsSync(customCodePath)) { + return []; + } + + return walkFiles(customCodePath).map((filePath) => path.relative(appDir, filePath)); +} + +async function startDebuggingGeneratedWorkspace(createdWorkspace: CreatedWorkspace): Promise { + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(createdWorkspace.appDir)); + assert.ok(folder, `Expected ${createdWorkspace.appDir} to be open as a workspace folder`); + + const launchPath = path.join(createdWorkspace.appDir, '.vscode', 'launch.json'); + const launchJson = JSON.parse(fs.readFileSync(launchPath, 'utf-8')) as { + configurations?: Record[]; + }; + const generatedConfig = launchJson.configurations?.[0]; + assert.ok(generatedConfig, `Expected ${launchPath} to contain a debug configuration`); + assert.ok(generatedConfig.name, `Expected ${launchPath} debug configuration to have a name`); + + await stopDebuggingAndTasks(); + await killPortsBound([7071, ...azuritePorts]); + console.log(`[workspace-lifecycle] Starting debug for ${createdWorkspace.workflowJsonPath} with ${String(generatedConfig.name)}`); + await logAzuriteDiagnostics('before debug autostart', createdWorkspace.appDir); + try { + let startDebuggingOutcome: { started?: boolean; error?: unknown } | undefined; + const monitorStartDebugging = async () => { + try { + const started = await vscode.debug.startDebugging(folder, generatedConfig as vscode.DebugConfiguration); + startDebuggingOutcome = { started }; + } catch (error) { + startDebuggingOutcome = { error }; + } + }; + const startDebuggingMonitor = monitorStartDebugging(); + + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-debug-starting`); + await handleWorkbenchPrompts([ + { matchText: 'Enable connectors in Azure', optionText: 'Skip for now' }, + { matchText: 'Configure Azurite to autostart on project debug?', optionText: 'Enable AutoStart' }, + { matchText: 'Failed to verify "AzureWebJobsStorage" connection', optionText: 'Debug anyway' }, + ]); + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-debug-prompts-handled`); + await waitForDebugStartup(createdWorkspace, () => startDebuggingOutcome, 300000); + await Promise.race([startDebuggingMonitor, Promise.resolve(undefined)]); + } catch (error) { + await logAzuriteDiagnostics('debug autostart failure', createdWorkspace.appDir); + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-debug-failure`); + throw error; + } + await logAzuriteDiagnostics('after debug autostart', createdWorkspace.appDir); +} + +async function waitForDebugStartup( + createdWorkspace: CreatedWorkspace, + getStartDebuggingOutcome: () => { started?: boolean; error?: unknown } | undefined, + timeoutMs: number +): Promise { + await waitUntil( + async () => { + const outcome = getStartDebuggingOutcome(); + if (outcome?.error) { + throw outcome.error; + } + if (outcome?.started === false) { + throw new Error(`VS Code reported startDebugging=false for ${createdWorkspace.appDir}`); + } + + const debugOrTaskStarted = + outcome?.started === true || + !!vscode.debug.activeDebugSession || + vscode.tasks.taskExecutions.some((execution) => execution.task.name.toLowerCase().includes('func: host start')); + + return debugOrTaskStarted && (await isHostRunning()); + }, + timeoutMs, + `debug launch plus Functions host Running state for ${createdWorkspace.appDir}` + ); +} + +async function runWorkflowThroughOverviewAndAssertSucceeded(createdWorkspace: CreatedWorkspace): Promise { + const workflowName = createdWorkspace.wfName; + const runEvidence = getSavedWorkflowRunEvidence(createdWorkspace); + await waitForWorkflowReadyForOverviewRun(workflowName, runEvidence.requestTriggerName); + const previousRunName = await getLatestRunName(workflowName); + + await openOverviewAndClickRunTrigger(createdWorkspace, previousRunName); + + const run = await waitForLatestRunStatus(workflowName, 'Succeeded', 180000, previousRunName); + const actionStatuses = await getLatestRunActionStatuses(workflowName, run.name); + assert.ok(actionStatuses.length > 0, `Expected action status evidence for workflow ${workflowName}, run ${run.name}`); + + const failedActions = actionStatuses.filter((action) => action.status !== 'Succeeded'); + assert.deepStrictEqual(failedActions, [], `Expected all workflow actions to succeed. Actions: ${JSON.stringify(actionStatuses)}`); + for (const expectedActionName of runEvidence.expectedActionNames) { + assert.ok( + actionStatuses.some((action) => action.name === expectedActionName), + `Expected action ${expectedActionName} to appear in run history. Actions: ${JSON.stringify(actionStatuses)}` + ); + } +} + +async function waitForWorkflowReadyForOverviewRun(workflowName: string, triggerName: string): Promise { + await waitForHostRunning(300000); + await waitForWorkflowHealthy(workflowName, 240000); + const callbackUrl = await waitForCallbackUrl(workflowName, triggerName, 240000); + assert.ok(callbackUrl.includes('/triggers/'), `Expected callback URL for ${workflowName}/${triggerName}. Actual: ${callbackUrl}`); +} + +async function openOverviewAndClickRunTrigger(createdWorkspace: CreatedWorkspace, previousRunName: string | undefined): Promise { + console.log(`[workspace-lifecycle] Opening ${createdWorkspace.label} Overview`); + await closeWebviewTabs(designerViewType); + await closeWebviewTabs(overviewViewType); + await closeAllTabs(); + + const tabsBefore = getWebviewTabs(overviewViewType).length; + await vscode.commands.executeCommand(openOverviewCommand, vscode.Uri.file(createdWorkspace.workflowJsonPath)); + const tab = await waitForWebviewTab(overviewViewType, tabsBefore, 60000); + assert.strictEqual(getTabViewType(tab), overviewTabViewType); + + const cdp = await connectToVsCodeCdp({ targetName: `${createdWorkspace.label} overview webview` }); + try { + const contextId = await waitForWebviewFrameContext(cdp, { + allTextIncludes: ['Run trigger', 'Refresh'], + description: `${createdWorkspace.label} overview webview DOM context`, + timeoutMs: 120000, + }); + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-overview-open`); + await clickOverviewRunTrigger(cdp, contextId, createdWorkspace); + await waitForNewRunStarted(createdWorkspace.wfName, previousRunName, 60000); + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-overview-run-clicked`); + await waitForOverviewRunStatus(cdp, contextId, createdWorkspace.label, 'Succeeded', 180000); + await captureLifecycleScreenshot(`workspace-lifecycle-${createdWorkspace.label}-overview-run-succeeded`); + } finally { + cdp.dispose(); + } +} + +async function clickOverviewRunTrigger(cdp: CdpEvaluator, contextId: number, createdWorkspace: CreatedWorkspace): Promise { + let lastState = ''; + let refreshedAfterReadyProbe = false; + await waitUntil( + async () => { + const result = await getOverviewButtonState(cdp, contextId, 'Run trigger'); + const state = JSON.stringify(result); + if (state !== lastState) { + lastState = state; + console.log(`[workspace-lifecycle] ${createdWorkspace.label}: Overview Run trigger state ${state}`); + } + + if (result.found && !result.disabled && result.hasCallbackUrl && !result.isLoading) { + return true; + } + + if (!refreshedAfterReadyProbe && !result.isLoading) { + await clickOverviewButton(cdp, contextId, 'Refresh').catch(() => undefined); + refreshedAfterReadyProbe = true; + } + + return false; + }, + 180000, + `${createdWorkspace.label} Overview Run trigger button to become enabled with a callback URL. Last state: ${lastState}` + ); + await clickOverviewButton(cdp, contextId, 'Run trigger'); + console.log(`[workspace-lifecycle] ${createdWorkspace.label}: clicked Overview Run trigger`); +} + +async function waitForOverviewRunStatus( + cdp: CdpEvaluator, + contextId: number, + label: string, + targetStatus: string, + timeoutMs: number +): Promise { + let lastStatus = ''; + let refreshAfterRunObserved = false; + await waitUntil( + async () => { + const status = await getOverviewLatestRunStatus(cdp, contextId); + if (status && status !== lastStatus) { + lastStatus = status; + console.log(`[workspace-lifecycle] ${label}: Overview latest run status "${status}"`); + } + + if (status === targetStatus) { + return true; + } + if (status === 'Failed' || status === 'Cancelled') { + throw new Error(`${label} Overview latest run ended with status "${status}"`); + } + + if (status && !refreshAfterRunObserved) { + await clickOverviewButton(cdp, contextId, 'Refresh').catch(() => undefined); + refreshAfterRunObserved = true; + } + + return false; + }, + timeoutMs, + `${label} Overview latest run to reach ${targetStatus}; last status="${lastStatus}"` + ); +} + +async function getOverviewButtonState( + cdp: CdpEvaluator, + contextId: number, + ariaLabel: string +): Promise<{ found: boolean; disabled?: boolean; hasCallbackUrl?: boolean; isLoading?: boolean; text?: string }> { + return cdp.evaluate( + contextId, + `(() => { + const ariaLabel = ${JSON.stringify(ariaLabel)}; + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const bodyText = document.body?.innerText || ''; + const button = Array.from(document.querySelectorAll('button')).find((candidate) => + isVisible(candidate) && + ((candidate.getAttribute('aria-label') || '').includes(ariaLabel) || (candidate.textContent || '').includes(ariaLabel)) + ); + if (!button) { + return { found: false, hasCallbackUrl: bodyText.includes('/triggers/') || bodyText.includes('Callback URL:'), isLoading: bodyText.includes('Loading'), text: bodyText }; + } + + return { + found: true, + disabled: button.disabled === true || button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true', + hasCallbackUrl: bodyText.includes('/triggers/') || bodyText.includes('Callback URL:'), + isLoading: bodyText.includes('Loading'), + text: button.textContent || button.getAttribute('aria-label') || '', + }; + })()` + ); +} + +async function clickOverviewButton( + cdp: CdpEvaluator, + contextId: number, + ariaLabel: string, + options: { force?: boolean } = {} +): Promise { + const result = await cdp.evaluate<{ ok: boolean; reason?: string; point?: { x: number; y: number }; text?: string }>( + contextId, + `(() => { + const ariaLabel = ${JSON.stringify(ariaLabel)}; + const force = ${JSON.stringify(options.force === true)}; + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const button = Array.from(document.querySelectorAll('button')).find((candidate) => + isVisible(candidate) && + ((candidate.getAttribute('aria-label') || '').includes(ariaLabel) || (candidate.textContent || '').includes(ariaLabel)) + ); + if (!button) { + return { ok: false, reason: 'Button not found', text: document.body?.innerText || '' }; + } + if (!force && (button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true')) { + return { ok: false, reason: 'Button disabled', text: button.textContent || button.getAttribute('aria-label') || '' }; + } + + button.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = button.getBoundingClientRect(); + return { + ok: true, + point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, + text: button.textContent || button.getAttribute('aria-label') || '', + }; + })()` + ); + + assert.ok( + result.ok && result.point, + `Expected Overview button "${ariaLabel}" to be clickable. Reason=${result.reason} text=${result.text}` + ); + await clickPoint(cdp, result.point); +} + +async function getOverviewLatestRunStatus(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate( + contextId, + `(() => { + const statuses = ['Succeeded', 'Running', 'Failed', 'Cancelled', 'Waiting']; + const rows = Array.from(document.querySelectorAll('[role="row"], .ms-DetailsRow, tr')); + for (const row of rows) { + const text = row.textContent || ''; + if (text.includes('Status') && text.includes('Identifier')) { + continue; + } + + const status = statuses.find((candidate) => text.includes(candidate)); + if (status) { + return status; + } + } + + const bodyText = document.body?.innerText || ''; + return statuses.find((candidate) => bodyText.includes(candidate)) || ''; + })()` + ); +} + +async function waitForHostRunning(timeoutMs: number): Promise { + await waitUntil(() => isHostRunning(), timeoutMs, 'Functions host to report state=Running'); +} + +async function isHostRunning(): Promise { + const status = await httpRequest({ url: 'http://localhost:7071/admin/host/status', method: 'GET' }, 5000).catch(() => undefined); + if (status?.status !== 200) { + return false; + } + + try { + const body = JSON.parse(status.body); + return String(body?.state ?? '').toLowerCase() === 'running'; + } catch { + return false; + } +} + +async function waitForWorkflowHealthy(workflowName: string, timeoutMs: number): Promise { + await waitUntil( + async () => { + const workflows = await httpRequest({ url: `${managementBaseUrl}/workflows?api-version=${apiVersion}`, method: 'GET' }, 5000).catch( + () => undefined + ); + if (workflows?.status !== 200) { + return false; + } + + const workflow = parseListResponse(workflows.body).find((item) => item?.name === workflowName); + const healthState = workflow?.properties?.health?.state ?? workflow?.health?.state; + return String(healthState ?? '').toLowerCase() === 'healthy'; + }, + timeoutMs, + `workflow ${workflowName} to be Healthy` + ); +} + +async function waitForCallbackUrl(workflowName: string, triggerName: string, timeoutMs: number): Promise { + let lastBody = ''; + await waitUntil( + async () => { + const response = await httpRequest( + { + url: `${managementBaseUrl}/workflows/${encodeURIComponent(workflowName)}/triggers/${encodeURIComponent( + triggerName + )}/listCallbackUrl?api-version=${apiVersion}`, + method: 'POST', + }, + 5000 + ).catch(() => undefined); + lastBody = response?.body ?? ''; + if (response?.status !== 200) { + return false; + } + + try { + const parsed = JSON.parse(response.body); + return typeof parsed?.value === 'string' && parsed.value.length > 0; + } catch { + return false; + } + }, + timeoutMs, + `callback URL for workflow ${workflowName}. Last body: ${lastBody.slice(0, 500)}` + ); + + const response = await httpRequest({ + url: `${managementBaseUrl}/workflows/${encodeURIComponent(workflowName)}/triggers/${encodeURIComponent( + triggerName + )}/listCallbackUrl?api-version=${apiVersion}`, + method: 'POST', + }); + const parsed = JSON.parse(response.body); + return parsed.value; +} + +async function getLatestRunName(workflowName: string): Promise { + const runs = await httpRequest( + { url: `${managementBaseUrl}/workflows/${encodeURIComponent(workflowName)}/runs?api-version=${apiVersion}`, method: 'GET' }, + 5000 + ).catch(() => undefined); + if (runs?.status !== 200) { + return undefined; + } + + const latestRun = parseListResponse(runs.body)[0]; + return typeof latestRun?.name === 'string' ? latestRun.name : undefined; +} + +async function waitForNewRunStarted(workflowName: string, previousRunName: string | undefined, timeoutMs: number): Promise { + let latestRunName = ''; + await waitUntil( + async () => { + latestRunName = (await getLatestRunName(workflowName)) ?? ''; + return !!latestRunName && latestRunName !== previousRunName; + }, + timeoutMs, + `new run for workflow ${workflowName}; previous run=${previousRunName ?? '(none)'}, latest run=${latestRunName || '(none)'}` + ); + + return latestRunName; +} + +async function waitForLatestRunStatus( + workflowName: string, + targetStatus: string, + timeoutMs: number, + excludedRunName?: string +): Promise<{ name: string; status: string }> { + let lastStatus = ''; + let lastBody = ''; + await waitUntil( + async () => { + const runs = await httpRequest( + { url: `${managementBaseUrl}/workflows/${encodeURIComponent(workflowName)}/runs?api-version=${apiVersion}`, method: 'GET' }, + 5000 + ).catch(() => undefined); + lastBody = runs?.body ?? ''; + if (runs?.status !== 200) { + return false; + } + + const latestRun = parseListResponse(runs.body)[0]; + if (!latestRun || latestRun.name === excludedRunName) { + return false; + } + const status = latestRun?.properties?.status ?? latestRun?.status; + if (typeof status === 'string') { + lastStatus = status; + } + if (status === 'Failed' || status === 'Cancelled') { + throw new Error(`Workflow ${workflowName} run ended with ${status}. Body: ${runs.body.slice(0, 1000)}`); + } + return status === targetStatus && typeof latestRun?.name === 'string'; + }, + timeoutMs, + `workflow ${workflowName} latest run to reach ${targetStatus}. Last status: ${lastStatus}. Last body: ${lastBody.slice(0, 500)}` + ); + + const runs = await httpRequest({ + url: `${managementBaseUrl}/workflows/${encodeURIComponent(workflowName)}/runs?api-version=${apiVersion}`, + method: 'GET', + }); + const latestRun = parseListResponse(runs.body)[0]; + assert.notStrictEqual( + latestRun.name, + excludedRunName, + `Expected latest run for ${workflowName} to be new after Overview Run trigger click` + ); + return { + name: latestRun.name, + status: latestRun.properties?.status ?? latestRun.status, + }; +} + +async function getLatestRunActionStatuses(workflowName: string, runName: string): Promise> { + const actions = await httpRequest({ + url: `${managementBaseUrl}/workflows/${encodeURIComponent(workflowName)}/runs/${encodeURIComponent(runName)}/actions?api-version=${apiVersion}`, + method: 'GET', + }); + assert.strictEqual(actions.status, 200, `Expected actions endpoint to return 200. Body: ${actions.body.slice(0, 1000)}`); + + return parseListResponse(actions.body).map((action) => ({ + name: action?.name, + status: action?.properties?.status ?? action?.status, + })); +} + +function parseListResponse(body: string): any[] { + const parsed = JSON.parse(body); + return Array.isArray(parsed?.value) ? parsed.value : Array.isArray(parsed) ? parsed : []; +} + +function httpRequest(options: { url: string; method: string; body?: string }, timeoutMs = 15000): Promise { + return new Promise((resolve) => { + const url = new URL(options.url); + const request = http.request( + { + hostname: url.hostname, + port: url.port, + path: `${url.pathname}${url.search}`, + method: options.method, + timeout: timeoutMs, + headers: options.body + ? { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(options.body), + } + : undefined, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => { + resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }); + }); + } + ); + + request.on('error', (error) => resolve({ status: 0, body: String(error) })); + request.on('timeout', () => { + request.destroy(); + resolve({ status: 0, body: `Timed out after ${timeoutMs}ms` }); + }); + if (options.body) { + request.write(options.body); + } + request.end(); + }); +} + +async function stopDebuggingAndTasks(): Promise { + if (vscode.debug.activeDebugSession) { + await vscode.debug.stopDebugging(vscode.debug.activeDebugSession); + await waitUntil(() => !vscode.debug.activeDebugSession, 10000, 'active debug session to stop'); + } + for (const execution of vscode.tasks.taskExecutions) { + execution.terminate(); + } + await waitUntil(() => vscode.tasks.taskExecutions.length === 0, 10000, 'VS Code task executions to terminate'); +} + +async function logAzuriteDiagnostics(stage: string, appDir: string): Promise { + const logicAppsConfig = vscode.workspace.getConfiguration('azureLogicAppsStandard', vscode.Uri.file(appDir)); + const azuriteConfig = vscode.workspace.getConfiguration('azurite', vscode.Uri.file(appDir)); + const azuriteExtension = vscode.extensions.getExtension('Azurite.azurite') ?? vscode.extensions.getExtension('azurite.azurite'); + const portResults = await Promise.all( + [ + { name: 'blob', url: 'http://127.0.0.1:10000/devstoreaccount1?comp=list' }, + { name: 'queue', url: 'http://127.0.0.1:10001/devstoreaccount1?comp=list' }, + { name: 'table', url: 'http://127.0.0.1:10002/Tables' }, + ].map(async (probe) => ({ + name: probe.name, + ...(await httpRequest({ url: probe.url, method: 'GET' }, 2000)), + })) + ); + + console.log( + `[workspace-lifecycle][azurite-diagnostics][${stage}] settings=${JSON.stringify({ + autoStartAzurite: logicAppsConfig.get('autoStartAzurite'), + logicAppsAzuriteLocation: logicAppsConfig.get('azuriteLocationSetting'), + azuriteLocation: azuriteConfig.get('location'), + })}` + ); + console.log( + `[workspace-lifecycle][azurite-diagnostics][${stage}] extension=${JSON.stringify({ + id: azuriteExtension?.id, + version: azuriteExtension?.packageJSON?.version, + isActive: azuriteExtension?.isActive, + extensionPath: azuriteExtension?.extensionPath, + })}` + ); + console.log( + `[workspace-lifecycle][azurite-diagnostics][${stage}] tasks=${JSON.stringify( + vscode.tasks.taskExecutions.map((execution) => execution.task.name) + )}` + ); + console.log(`[workspace-lifecycle][azurite-diagnostics][${stage}] ports=${JSON.stringify(portResults)}`); + + const statusBarText = await getWorkbenchText().catch((error) => `Unable to read workbench text: ${String(error)}`); + const azuriteStatusLines = statusBarText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.toLowerCase().includes('azurite')); + console.log(`[workspace-lifecycle][azurite-diagnostics][${stage}] statusBar=${JSON.stringify(azuriteStatusLines.slice(-12))}`); + + for (const log of findRelevantVsCodeLogFiles().slice(-8)) { + console.log(`[workspace-lifecycle][azurite-diagnostics][${stage}] logTail ${log}:\n${tailFile(log, 2500)}`); + } +} + +async function getWorkbenchText(): Promise { + const cdp = await connectToVsCodeWorkbenchCdp(); + try { + return await cdp.evaluate(undefined, 'document.body?.innerText || ""'); + } finally { + cdp.dispose(); + } +} + +function findRelevantVsCodeLogFiles(): string[] { + const userDataDir = process.env.LA_E2E_CLI_USER_DATA_DIR; + if (!userDataDir || !fs.existsSync(userDataDir)) { + return []; + } + + const logsDir = path.join(userDataDir, 'logs'); + if (!fs.existsSync(logsDir)) { + return []; + } + + return walkFiles(logsDir) + .filter((filePath) => { + const lowerPath = filePath.toLowerCase(); + return lowerPath.includes('azurite') || lowerPath.includes('azure logic apps') || lowerPath.includes('output_logging'); + }) + .sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs); +} + +function walkFiles(directory: string): string[] { + const result: string[] = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + result.push(...walkFiles(entryPath)); + } else { + result.push(entryPath); + } + } + return result; +} + +function tailFile(filePath: string, maxChars: number): string { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return content.slice(-maxChars); + } catch (error) { + return `Unable to read ${filePath}: ${String(error)}`; + } +} + +async function killPortsBound(ports: number[]): Promise { + for (const port of ports) { + await killPortBound(port); + } +} + +async function killPortBound(port: number): Promise { + try { + if (process.platform === 'win32') { + let pidsRaw = ''; + try { + pidsRaw = execSync( + `powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess | Sort-Object -Unique"`, + { stdio: 'pipe', timeout: 5000 } + ).toString(); + } catch { + pidsRaw = ''; + } + + const pids = pidsRaw + .split(/\s+/) + .map((pid) => pid.trim()) + .filter((pid) => /^\d+$/.test(pid)); + for (const pid of pids) { + execSync(`powershell -NoProfile -Command "Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue"`, { + stdio: 'pipe', + timeout: 5000, + }); + console.log(`[workspace-lifecycle] Killed PID ${pid} listening on :${port}`); + } + return; + } + + let pidsRaw = ''; + try { + pidsRaw = execSync(`lsof -ti:${port}`, { stdio: 'pipe', timeout: 5000, shell: '/bin/sh' }).toString(); + } catch { + pidsRaw = ''; + } + const pids = pidsRaw + .split(/\s+/) + .map((pid) => pid.trim()) + .filter((pid) => /^\d+$/.test(pid)); + for (const pid of pids) { + execSync(`kill -9 ${pid}`, { stdio: 'pipe', timeout: 5000, shell: '/bin/sh' }); + console.log(`[workspace-lifecycle] Killed PID ${pid} listening on :${port}`); + } + } catch (error) { + console.log(`[workspace-lifecycle] Non-fatal port cleanup failure for :${port}: ${String(error)}`); + } +} + +async function clickWizardButton(cdp: CdpEvaluator, contextId: number, buttonText: string): Promise { + const clickResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; point?: { x: number; y: number } }>( + contextId, + `(() => { + const expected = ${JSON.stringify(buttonText)}; + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const button = Array.from(document.querySelectorAll('button')) + .filter(isVisible) + .find((candidate) => (candidate.textContent || '').includes(expected)); + if (!(button instanceof HTMLButtonElement)) { + return { ok: false, reason: 'Button not found', text: document.body?.innerText || '' }; + } + if (button.disabled || button.getAttribute('aria-disabled') === 'true') { + return { ok: false, reason: 'Button is disabled', text: document.body?.innerText || '' }; + } + button.scrollIntoView({ block: 'center', inline: 'center' }); + button.focus(); + const rect = button.getBoundingClientRect(); + return { ok: true, text: document.body?.innerText || '', point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } }; + })()` + ); + + assert.strictEqual(clickResult.ok, true, clickResult.reason ?? `Failed to click "${buttonText}" button. Text: ${clickResult.text ?? ''}`); + assert.ok(clickResult.point, `Failed to locate "${buttonText}" button click point.`); + await clickPoint(cdp, clickResult.point); +} + +async function waitForReviewStep(cdp: CdpEvaluator, contextId: number, creationCase: WorkspaceCreationCase): Promise { + const expectedValues = [ + creationCase.wsName, + creationCase.appName, + creationCase.wfName, + creationCase.functionFolderName, + creationCase.functionNamespace, + creationCase.functionName, + ].filter((value): value is string => !!value); + const deadline = Date.now() + 15000; + + while (Date.now() < deadline) { + const pageText = await getPageText(cdp, contextId); + const onReviewStep = containsIgnoreCase(pageText, 'Review') && containsIgnoreCase(pageText, 'Create workspace'); + const valuesPresent = expectedValues.every((value) => pageText.includes(value)); + if (onReviewStep && valuesPresent) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const pageText = await getPageText(cdp, contextId); + assert.fail(`Timed out waiting for ${creationCase.label} review step. Text: ${pageText}`); +} + +async function waitForCreatedWorkspaceMaterialization(parentPath: string, creationCase: WorkspaceCreationCase): Promise { + const workspaceDir = path.join(parentPath, creationCase.wsName); + const workspaceFilePath = path.join(workspaceDir, `${creationCase.wsName}.code-workspace`); + const appDir = path.join(workspaceDir, creationCase.appName); + const workflowJsonPath = path.join(appDir, creationCase.wfName, 'workflow.json'); + const functionFolderName = creationCase.functionFolderName; + + await waitUntil( + () => { + if (!fs.existsSync(workspaceFilePath) || !fs.existsSync(appDir) || !fs.existsSync(workflowJsonPath)) { + return false; + } + + if (creationCase.appType === 'standard') { + return true; + } + + if (!functionFolderName) { + return false; + } + + const functionDir = path.join(workspaceDir, functionFolderName); + return fs.existsSync(functionDir) && hasCsproj(functionDir); + }, + 60000, + `generated workspace files for ${creationCase.label} under ${workspaceDir}` + ); +} + +function verifyCreatedWorkspace(parentPath: string, creationCase: WorkspaceCreationCase): CreatedWorkspace { + const workspaceDir = path.join(parentPath, creationCase.wsName); + const workspaceFilePath = path.join(workspaceDir, `${creationCase.wsName}.code-workspace`); + const appDir = path.join(workspaceDir, creationCase.appName); + const workflowJsonPath = path.join(appDir, creationCase.wfName, 'workflow.json'); + + assert.ok(fs.existsSync(workspaceDir), `Workspace directory should exist: ${workspaceDir}`); + assert.ok(fs.existsSync(workspaceFilePath), `.code-workspace file should exist: ${workspaceFilePath}`); + assert.ok(fs.existsSync(appDir), `Logic app directory should exist: ${appDir}`); + assert.ok(fs.existsSync(workflowJsonPath), `workflow.json should exist: ${workflowJsonPath}`); + + const workspaceContent = JSON.parse(fs.readFileSync(workspaceFilePath, 'utf-8')) as { folders?: Array<{ name?: string; path?: string }> }; + const folderPaths = (workspaceContent.folders ?? []).map((folder) => path.resolve(workspaceDir, folder.path ?? folder.name ?? '')); + assert.ok( + folderPaths.some((folderPath) => normalizeFsPath(folderPath) === normalizeFsPath(appDir)), + 'Generated workspace should include the logic app folder' + ); + + if (creationCase.appType !== 'standard') { + const functionFolderName = requiredValue(creationCase.functionFolderName); + const functionDir = path.join(workspaceDir, functionFolderName); + assert.ok( + folderPaths.some((folderPath) => path.basename(folderPath) === functionFolderName), + `Generated ${creationCase.label} workspace should include function folder ${functionFolderName}` + ); + assert.ok(fs.existsSync(functionDir), `Generated ${creationCase.label} function folder should exist: ${functionDir}`); + assert.ok(hasCsproj(functionDir), `Generated ${creationCase.label} function folder should include a .csproj: ${functionDir}`); + } + + return { + label: creationCase.label, + appType: creationCase.appType, + wsName: creationCase.wsName, + appName: creationCase.appName, + wfName: creationCase.wfName, + functionFolderName: creationCase.functionFolderName, + functionNamespace: creationCase.functionNamespace, + functionName: creationCase.functionName, + workspaceDir, + workspaceFilePath, + appDir, + workflowJsonPath, + folderPaths, + }; +} + +function requiredValue(value: string | undefined): string { + assert.ok(value, 'Expected required workspace creation value to be defined'); + return value; +} + +async function enterFieldValue(cdp: CdpEvaluator, contextId: number, labels: FieldLabels, value: string): Promise { + await waitForFieldVisible(cdp, contextId, labels); + const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string }>( + contextId, + withField( + labels, + `input.focus(); + input.select(); + return { ok: true };` + ) + ); + + assert.strictEqual( + focusResult.ok, + true, + `Failed to focus ${getLabels(labels).join('/')} field. ${focusResult.reason ?? ''} Text: ${focusResult.text ?? ''}` + ); + + try { + await replaceFocusedInputText(cdp, value); + } catch { + await cdp.evaluate( + contextId, + withField( + labels, + `setInputValue(input, ${JSON.stringify(value)}); + return { ok: true };` + ) + ); + } + + await waitUntil( + async () => (await getFieldState(cdp, contextId, labels)).value === value, + 5000, + `${getLabels(labels).join('/')} to equal ${value}` + ); +} + +async function waitForFieldVisible(cdp: CdpEvaluator, contextId: number, labels: FieldLabels): Promise { + await waitUntil( + async () => { + const result = await getFieldState(cdp, contextId, labels).catch(() => undefined); + return !!result?.ok; + }, + 10000, + `field "${getLabels(labels).join('/')}" to be visible` + ); +} + +async function waitForAsyncValidationToSettle(cdp: CdpEvaluator, contextId: number): Promise { + const pendingMessages = ['Validating path', 'Checking workspace availability']; + await waitUntil( + async () => { + const pageText = await getPageText(cdp, contextId); + return !pendingMessages.some((message) => containsIgnoreCase(pageText, message)); + }, + 15000, + 'Create Workspace async validation to settle' + ); +} + +async function assertNextButtonEnabled(cdp: CdpEvaluator, contextId: number, context: string): Promise { + let lastState: { found: boolean; disabled?: boolean; text?: string } | undefined; + let lastError: unknown; + for (let attempt = 0; attempt < 60; attempt++) { + try { + lastState = await getNextButtonState(cdp, contextId); + if (lastState.found && !lastState.disabled) { + return; + } + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + assert.fail( + `Timed out waiting for Next button to be enabled for ${context}. Last state: ${JSON.stringify(lastState)}${ + lastError ? `. Last error: ${String(lastError)}` : '' + }` + ); +} + +async function selectRadioOption(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string }>( + contextId, + `(() => { + const expected = ${JSON.stringify(labelText)}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 180 && text.includes(expected); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + if (!label) { + return { ok: false, reason: 'Radio label not found', text: document.body?.innerText || '' }; + } + + const radioRoot = label.closest('[role="radio"], .fui-Radio') || label; + const input = radioRoot.querySelector('input[type="radio"]'); + if (!(input instanceof HTMLInputElement)) { + return { ok: false, reason: 'Radio input not found', text: radioRoot.outerHTML }; + } + + input.focus(); + return { ok: document.activeElement === input, reason: document.activeElement === input ? undefined : 'Radio input did not receive focus', text: radioRoot.outerHTML }; + })()` + ); + + assert.strictEqual( + focusResult.ok, + true, + focusResult.reason ?? `Failed to focus radio option "${labelText}". Text: ${focusResult.text ?? ''}` + ); + await pressKey(cdp, 'Space', ' ', 32); + await waitUntil(() => isRadioOptionChecked(cdp, contextId, labelText), 5000, `radio option "${labelText}" to be checked`); +} + +async function selectDropdownOption(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { + if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { + return; + } + + const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; point?: { x: number; y: number } }>( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent).toLowerCase(); + return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + if (!label) { + return { ok: false, reason: 'Dropdown label not found', text: document.body?.innerText || '' }; + } + + const dropdownId = label.getAttribute('for'); + const field = label.closest('[class*="fui-Field"]') || label.parentElement; + const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); + if (!(dropdown instanceof HTMLButtonElement)) { + return { ok: false, reason: 'Dropdown button not found', text: document.body?.innerText || '' }; + } + + dropdown.scrollIntoView({ block: 'center', inline: 'center' }); + dropdown.focus(); + const rect = dropdown.getBoundingClientRect(); + return { ok: true, point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } }; + })()` + ); + + assert.strictEqual( + focusResult.ok, + true, + focusResult.reason ?? `Failed to focus "${labelText}" dropdown. Text: ${focusResult.text ?? ''}` + ); + assert.ok(focusResult.point, `Failed to locate "${labelText}" dropdown click point.`); + await clickPoint(cdp, focusResult.point); + if (!(await waitForDropdownOptions(cdp, contextId, 1000))) { + await pressKey(cdp, 'Enter', undefined, 13); + } + if (!(await waitForDropdownOptions(cdp, contextId, 1000))) { + await pressKey(cdp, 'Space', ' ', 32); + } + if (!(await waitForDropdownOptions(cdp, contextId, 1000))) { + await pressKey(cdp, 'ArrowDown', 'ArrowDown', 40); + await pressKey(cdp, 'Enter', undefined, 13); + if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { + return; + } + } + + const optionResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; options?: string[]; optionIndex?: number }>( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const options = Array.from(document.querySelectorAll('[role="option"]')).filter(isVisible); + const option = options.find((candidate) => normalize(candidate.textContent) === ${JSON.stringify(optionText)}); + if (!(option instanceof HTMLElement)) { + return { + ok: false, + reason: 'Dropdown option not found', + options: options.map((candidate) => normalize(candidate.textContent)), + text: document.body?.innerText || '', + }; + } + + return { ok: true, optionIndex: options.indexOf(option) }; + })()` + ); + + assert.strictEqual( + optionResult.ok, + true, + `Failed to select "${optionText}" from "${labelText}". Reason: ${optionResult.reason ?? 'unknown'}. Options: ${JSON.stringify( + optionResult.options + )}. Text: ${optionResult.text ?? ''}` + ); + for (let index = 0; index < (optionResult.optionIndex ?? 0); index++) { + await pressKey(cdp, 'ArrowDown', 'ArrowDown', 40); + } + await pressKey(cdp, 'Enter', undefined, 13); + await waitUntil( + () => isDropdownValueSelected(cdp, contextId, labelText, optionText), + 5000, + `"${labelText}" dropdown to select "${optionText}"` + ); +} + +async function handleDesignerQuickPickPrompts(timeoutMs = 20000): Promise { + await handleWorkbenchPrompts( + [ + { matchText: 'Enable connectors in Azure', optionText: 'Skip for now' }, + { matchText: 'Connection Keys', optionText: 'Connection Keys' }, + ], + timeoutMs + ); +} + +async function handleWorkbenchPrompts(prompts: Array<{ matchText: string; optionText: string }>, timeoutMs = 20000): Promise { + const cdp = await connectToVsCodeWorkbenchCdp(); + try { + const deadline = Date.now() + timeoutMs; + const noPromptDeadline = Date.now() + 1500; + let handledPrompt = false; + while (Date.now() < deadline) { + const result = await cdp.evaluate<{ + visible: boolean; + text: string; + targetText?: string; + point?: { x: number; y: number }; + }>( + undefined, + `(() => { + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const prompts = ${JSON.stringify(prompts)}; + const promptContainers = Array.from(document.querySelectorAll( + '.quick-input-widget, .monaco-dialog-box, [role="dialog"], .notification-toast, .notification-list-item' + )).filter(isVisible); + + for (const container of promptContainers) { + if (!(container instanceof HTMLElement)) { + continue; + } + + const inputText = Array.from(container.querySelectorAll('input')) + .map((input) => (input.value || '') + ' ' + (input.getAttribute('placeholder') || '')) + .join(' '); + const containerText = ((container.innerText || container.textContent || '') + ' ' + inputText).replace(/\\s+/g, ' ').trim(); + + const rows = Array.from(container.querySelectorAll('.monaco-list-row, [role="option"]')).filter(isVisible); + const rowData = rows.map((row) => ({ + element: row, + text: (row.textContent || '').replace(/\\s+/g, ' ').trim(), + })); + const prompt = prompts.find((candidate) => { + const lowerContainerText = containerText.toLowerCase(); + const lowerMatchText = candidate.matchText.toLowerCase(); + const lowerOptionText = candidate.optionText.toLowerCase(); + return lowerContainerText.includes(lowerMatchText) || rowData.some((entry) => entry.text.toLowerCase().includes(lowerOptionText)); + }); + if (!prompt) { + continue; + } + + const buttons = Array.from(container.querySelectorAll('a.monaco-button, button, .monaco-text-button')).filter(isVisible); + const buttonData = buttons.map((button) => ({ + element: button, + text: (button.textContent || '').replace(/\\s+/g, ' ').trim(), + })); + const lowerOptionText = prompt.optionText.toLowerCase(); + const targetButton = + buttonData.find((entry) => entry.text.toLowerCase() === lowerOptionText) || + buttonData.find((entry) => entry.text.toLowerCase().includes(lowerOptionText) && entry.text.length < containerText.length); + if (targetButton) { + targetButton.element.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = targetButton.element.getBoundingClientRect(); + return { + visible: true, + text: containerText, + targetText: targetButton.text, + point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, + }; + } + + const targetRow = rowData.find((entry) => entry.text.toLowerCase().includes(prompt.optionText.toLowerCase())); + if (targetRow) { + targetRow.element.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = targetRow.element.getBoundingClientRect(); + return { + visible: true, + text: containerText, + targetText: targetRow.text, + point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, + }; + } + + return { visible: true, text: containerText }; + } + + return { visible: false, text: document.body?.innerText || '' }; + })()` + ); + + if (result.point) { + console.log(`[workspace-lifecycle] Selecting workbench prompt option "${result.targetText}"`); + await clickPoint(cdp, result.point); + handledPrompt = true; + await waitForWorkbenchPromptOptionToDismiss(cdp, result.targetText ?? '', 5000).catch(() => undefined); + continue; + } + + if (!result.visible && (handledPrompt || Date.now() > noPromptDeadline)) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } finally { + cdp.dispose(); + } +} + +async function dismissWorkbenchNotifications(): Promise { + const cdp = await connectToVsCodeWorkbenchCdp(); + try { + for (let attempt = 0; attempt < 4; attempt++) { + const point = await cdp.evaluate<{ x: number; y: number } | undefined>( + undefined, + `(() => { + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const notifications = Array.from(document.querySelectorAll('.notification-toast, .notification-list-item')).filter(isVisible); + for (const notification of notifications) { + const buttons = Array.from(notification.querySelectorAll('button, .monaco-button, .monaco-text-button')).filter(isVisible); + const button = buttons.find((candidate) => { + const text = (candidate.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + return text === 'don\\'t show again' || text === 'close'; + }); + const target = button || notification.querySelector('.codicon-close, [aria-label*="Close"], [title*="Close"]'); + if (target instanceof HTMLElement && isVisible(target)) { + target.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = target.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } + } + return undefined; + })()` + ); + + if (!point) { + return; + } + + await clickPoint(cdp, point); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } finally { + cdp.dispose(); + } +} + +async function waitForWorkbenchPromptOptionToDismiss(cdp: CdpEvaluator, optionText: string, timeoutMs: number): Promise { + if (!optionText) { + return; + } + + await waitUntil( + () => + cdp.evaluate( + undefined, + `(() => { + const optionText = ${JSON.stringify(optionText.toLowerCase())}; + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const promptContainers = Array.from(document.querySelectorAll( + '.quick-input-widget, .monaco-dialog-box, [role="dialog"], .notification-toast, .notification-list-item' + )).filter(isVisible); + return !promptContainers.some((container) => (container.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase().includes(optionText)); + })()` + ), + timeoutMs, + `workbench prompt option "${optionText}" to dismiss` + ); +} + +async function captureLifecycleScreenshot(name: string): Promise { + const cdp = await connectToVsCodeWorkbenchCdp(); + try { + await captureCdpScreenshot(cdp, name); + } finally { + cdp.dispose(); + } +} + +async function hasDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate( + contextId, + `(() => { + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + return Array.from(document.querySelectorAll('[role="option"]')).some(isVisible); + })()` + ); +} + +async function waitForDropdownOptions(cdp: CdpEvaluator, contextId: number, timeoutMs: number): Promise { + try { + await waitUntil(() => hasDropdownOptions(cdp, contextId), timeoutMs, 'dropdown options to become visible'); + return true; + } catch { + return false; + } +} + +async function isRadioOptionChecked(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + const result = await cdp.evaluate<{ checked: boolean }>( + contextId, + `(() => { + const expected = ${JSON.stringify(labelText)}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 180 && text.includes(expected); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; + const input = radioRoot?.querySelector('input[type="radio"]'); + return { checked: input instanceof HTMLInputElement ? input.checked : false }; + })()` + ); + return result.checked; +} + +async function isDropdownValueSelected(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { + const result = await cdp.evaluate<{ selected: boolean }>( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter((candidate) => { + const text = normalize(candidate.textContent).toLowerCase(); + return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const dropdownId = label?.getAttribute('for'); + const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; + const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); + const text = dropdown?.textContent || ''; + return { selected: normalize(text).includes(${JSON.stringify(optionText)}) }; + })()` + ); + return result.selected; +} + +async function pressKey(cdp: CdpEvaluator, code: string, key?: string, windowsVirtualKeyCode?: number): Promise { + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: key ?? code, + code, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: key ?? code, + code, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + }); +} + +async function clickPoint(cdp: CdpEvaluator, point: { x: number; y: number }): Promise { + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: point.x, + y: point.y, + button: 'none', + }); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button: 'left', + buttons: 1, + clickCount: 1, + }); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button: 'left', + buttons: 0, + clickCount: 1, + }); +} + +async function replaceFocusedInputText(cdp: CdpEvaluator, value: string): Promise { + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'Control', + code: 'ControlLeft', + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + modifiers: 2, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'a', + code: 'KeyA', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + modifiers: 2, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'a', + code: 'KeyA', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + modifiers: 2, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'Control', + code: 'ControlLeft', + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'Backspace', + code: 'Backspace', + windowsVirtualKeyCode: 8, + nativeVirtualKeyCode: 8, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'Backspace', + code: 'Backspace', + windowsVirtualKeyCode: 8, + nativeVirtualKeyCode: 8, + }); + + if (value) { + await cdp.send('Input.insertText', { text: value }); + } +} + +async function getFieldState( + cdp: CdpEvaluator, + contextId: number, + labels: FieldLabels +): Promise<{ ok: boolean; reason?: string; value?: string; text?: string }> { + return cdp.evaluate( + contextId, + withField( + labels, + `return { + ok: true, + value: input.value, + text: document.body?.innerText || '', + };` + ) + ); +} + +async function getNextButtonState(cdp: CdpEvaluator, contextId: number): Promise<{ found: boolean; disabled?: boolean; text?: string }> { + return cdp.evaluate( + contextId, + `(() => { + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const buttons = Array.from(document.querySelectorAll('button')).filter(isVisible); + const button = buttons.find((candidate) => (candidate.textContent || '').includes('Next')); + const pageText = document.body?.innerText || ''; + if (!button) { + return { found: false, text: pageText }; + } + + const disabled = button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true'; + return { found: true, disabled, text: pageText }; + })()` + ); +} + +async function getPageText(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate(contextId, 'document.body?.innerText || ""').catch((error) => String(error)); +} + +function withField(labels: FieldLabels, action: string): string { + return `(() => { + const labelsToFind = ${JSON.stringify(getLabels(labels))}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const visibleInputs = Array.from(document.querySelectorAll('input')).filter(isVisible); + const inputByAttribute = visibleInputs + .filter((candidate) => { + const searchableText = [ + candidate.getAttribute('aria-label'), + candidate.getAttribute('placeholder'), + candidate.getAttribute('name'), + candidate.id, + ].map(normalize).join(' ').toLowerCase(); + return labelsToFind.some((expected) => searchableText.includes(expected.toLowerCase())); + }) + .sort((a, b) => normalize(a.getAttribute('placeholder') || a.getAttribute('aria-label') || a.id).length - normalize(b.getAttribute('placeholder') || b.getAttribute('aria-label') || b.id).length)[0]; + const visibleTextElements = Array.from(document.querySelectorAll('label, span, div, p')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 160; + }); + const exactLabel = visibleTextElements + .filter((candidate) => labelsToFind.some((expected) => normalize(candidate.textContent).toLowerCase() === expected.toLowerCase())) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const partialLabel = visibleTextElements + .filter((candidate) => + labelsToFind.some((expected) => normalize(candidate.textContent).toLowerCase().includes(expected.toLowerCase())) + ) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const label = exactLabel || partialLabel; + if (!label && !inputByAttribute) { + return { ok: false, reason: 'Field label not found', text: document.body?.innerText || '' }; + } + + const inputId = label?.getAttribute('for'); + const field = label?.closest('[class*="fui-Field"]') || label?.parentElement?.parentElement || label?.parentElement; + const labelRect = label?.getBoundingClientRect(); + const nearestInput = labelRect + ? visibleInputs + .map((candidate) => ({ candidate, rect: candidate.getBoundingClientRect() })) + .filter(({ rect }) => rect.bottom >= labelRect.top - 5) + .sort((a, b) => { + const scoreA = a.rect.top >= labelRect.bottom - 5 ? a.rect.top - labelRect.bottom : 0; + const scoreB = b.rect.top >= labelRect.bottom - 5 ? b.rect.top - labelRect.bottom : 0; + return scoreA - scoreB || a.rect.top - b.rect.top; + })[0]?.candidate + : undefined; + const fieldInputs = field ? Array.from(field.querySelectorAll('input')).filter(isVisible) : []; + const fieldInput = + fieldInputs.length === 1 + ? fieldInputs[0] + : labelRect + ? fieldInputs + .map((candidate) => ({ candidate, rect: candidate.getBoundingClientRect() })) + .filter(({ rect }) => rect.bottom >= labelRect.top - 5) + .sort((a, b) => { + const scoreA = a.rect.top >= labelRect.bottom - 5 ? a.rect.top - labelRect.bottom : 0; + const scoreB = b.rect.top >= labelRect.bottom - 5 ? b.rect.top - labelRect.bottom : 0; + return scoreA - scoreB || a.rect.top - b.rect.top; + })[0]?.candidate + : undefined; + const input = inputByAttribute || (inputId ? document.getElementById(inputId) : null) || fieldInput || nearestInput; + if (!(input instanceof HTMLInputElement)) { + return { ok: false, reason: 'Field input not found', text: document.body?.innerText || '', labelHtml: label?.outerHTML }; + } + + const setInputValue = (inputElement, value) => { + inputElement.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(inputElement, value); + inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: value ? 'insertText' : 'deleteContentBackward', data: value })); + inputElement.dispatchEvent(new Event('change', { bubbles: true })); + inputElement.blur(); + }; + + ${action} + })()`; +} + +function getLabels(labels: FieldLabels): string[] { + return Array.isArray(labels) ? labels : [labels]; +} + +function getWebviewTabs(viewType: string): vscode.Tab[] { + return vscode.window.tabGroups.all.flatMap((group) => + group.tabs.filter((tab) => { + return getTabViewType(tab) === `mainThreadWebview-${viewType}`; + }) + ); +} + +function getTabViewType(tab: vscode.Tab): string | undefined { + const input = tab.input as { viewType?: unknown }; + return typeof input.viewType === 'string' ? input.viewType : undefined; +} + +async function waitForWebviewTab(viewType: string, previousCount: number, timeoutMs = 10000): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const tabs = getWebviewTabs(viewType); + if (tabs.length > previousCount) { + return tabs[tabs.length - 1]; + } + + if (tabs.length > 0 && previousCount === 0) { + return tabs[tabs.length - 1]; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + assert.fail(`Timed out waiting for ${viewType} webview tab to open. Open tabs: ${describeOpenTabs()}`); +} + +async function closeWebviewTabs(viewType: string): Promise { + const tabs = getWebviewTabs(viewType); + if (tabs.length > 0) { + await vscode.window.tabGroups.close(tabs); + } +} + +async function closeAllTabs(): Promise { + const tabs = vscode.window.tabGroups.all.flatMap((group) => group.tabs); + if (tabs.length > 0) { + await vscode.window.tabGroups.close(tabs); + } +} + +function describeOpenTabs(): string { + return JSON.stringify( + vscode.window.tabGroups.all.flatMap((group) => + group.tabs.map((tab) => ({ + label: tab.label, + isActive: tab.isActive, + inputType: tab.input?.constructor?.name, + viewType: getTabViewType(tab), + })) + ) + ); +} + +async function waitUntil(predicate: () => boolean | Promise, timeoutMs: number, description: string): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + if (await predicate()) { + return; + } + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + assert.fail(`Timed out waiting for ${description}${lastError ? `. Last error: ${String(lastError)}` : ''}`); +} + +async function withTimeout(promise: Thenable, timeoutMs: number, description: string): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${description}`)), timeoutMs); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +function containsIgnoreCase(value: string, expected: string): boolean { + return value.toLowerCase().includes(expected.toLowerCase()); +} + +function uniqueName(prefix: string): string { + return `${prefix}${Date.now().toString(36).slice(-5)}`; +} + +function normalizeFsPath(fsPath: string): string { + const normalizedPath = path.normalize(fsPath); + return process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath; +} diff --git a/package.json b/package.json index 541a16e3e92..b7630fefc80 100644 --- a/package.json +++ b/package.json @@ -69,11 +69,7 @@ }, "license": "MIT", "lint-staged": { - "*.{js,ts,tsx}": [ - "npm run extract", - "eslint --cache --fix", - "biome check --write" - ] + "*.{js,ts,tsx}": ["npm run extract", "eslint --cache --fix", "biome check --write"] }, "private": true, "scripts": { @@ -100,6 +96,19 @@ "test:e2e:designer:ui": "playwright test --config=playwright.designer.config.ts --ui", "test:e2e:chatClient": "playwright test --config=playwright.chatClient.config.ts", "test:e2e:chatClient:ui": "playwright test --config=playwright.chatClient.config.ts --ui", + "test:e2e-cli": "pnpm --dir apps/vs-code-designer run test:e2e-cli", + "test:e2e-cli:smoke": "pnpm --dir apps/vs-code-designer run test:e2e-cli:smoke", + "test:e2e-cli:create-workspace": "pnpm --dir apps/vs-code-designer run test:e2e-cli:create-workspace", + "test:e2e-cli:create-workspace:behavior": "pnpm --dir apps/vs-code-designer run test:e2e-cli:create-workspace:behavior", + "test:e2e-cli:create-workspace:core-matrix": "pnpm --dir apps/vs-code-designer run test:e2e-cli:create-workspace:core-matrix", + "test:e2e-cli:create-workspace:preview-matrix": "pnpm --dir apps/vs-code-designer run test:e2e-cli:create-workspace:preview-matrix", + "test:e2e-cli:create-workspace:codeful": "pnpm --dir apps/vs-code-designer run test:e2e-cli:create-workspace:codeful", + "test:e2e-cli:create-workspace:fixtures": "pnpm --dir apps/vs-code-designer run test:e2e-cli:create-workspace:fixtures", + "test:e2e-cli:create-workspace:full": "pnpm --dir apps/vs-code-designer run test:e2e-cli:create-workspace:full", + "test:e2e-cli:workspace-lifecycle": "pnpm --dir apps/vs-code-designer run test:e2e-cli:workspace-lifecycle", + "test:e2e-cli:show-create-workspace": "pnpm --dir apps/vs-code-designer run test:e2e-cli:show-create-workspace", + "test:e2e-cli:compile": "pnpm --dir apps/vs-code-designer run test:e2e-cli:compile", + "test:e2e-cli:open": "pnpm --dir apps/vs-code-designer run test:e2e-cli:open", "test:extension-unit": "turbo run test:extension-unit", "testgen": "playwright codegen https://localhost:4200", "vscode:designer:pack": "turbo run vscode:designer:pack", From bb0476ebc7ba273122183c086468a4d25923cf30 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:28:38 -0700 Subject: [PATCH 2/5] Simplify VS Code CLI E2E helpers Extract shared CLI helper utilities and add an ExTester-to-test-cli Create Workspace parity map for traceability. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/vs-code-designer/src/test/e2e/README.md | 2 + .../src/test/e2e/cdpClient.ts | 23 ----- .../src/test/e2e/createWorkspace.test.ts | 83 +------------------ .../src/test/e2e/createWorkspaceParityMap.md | 73 ++++++++++++++++ .../src/test/e2e/createWorkspaceTypes.ts | 21 +++++ .../src/test/e2e/extension.test.ts | 6 +- .../src/test/e2e/testUtils.ts | 14 ++++ .../src/test/e2e/webviewTabs.ts | 61 ++++++++++++++ .../src/test/e2e/workspaceLifecycle.test.ts | 76 +---------------- 9 files changed, 178 insertions(+), 181 deletions(-) create mode 100644 apps/vs-code-designer/src/test/e2e/createWorkspaceParityMap.md create mode 100644 apps/vs-code-designer/src/test/e2e/createWorkspaceTypes.ts create mode 100644 apps/vs-code-designer/src/test/e2e/testUtils.ts create mode 100644 apps/vs-code-designer/src/test/e2e/webviewTabs.ts diff --git a/apps/vs-code-designer/src/test/e2e/README.md b/apps/vs-code-designer/src/test/e2e/README.md index abb482eaa7e..7a7add9f44c 100644 --- a/apps/vs-code-designer/src/test/e2e/README.md +++ b/apps/vs-code-designer/src/test/e2e/README.md @@ -173,6 +173,8 @@ The config builds from `dist/`, sets `VSCODE_RUNNING_TESTS=1` and `DEBUGTELEMETR The legacy files under `src/test/e2e/integration/` are not part of this baseline. Some of them open designer webviews or exercise workspace-conversion UI without the ExTester harness, which can produce errors such as missing `dist/vs-code-react/index.html` or refused dialogs in extension-host tests. +For a detailed traceability view from the ExTester Create Workspace behavior and fixture suites to these CLI labels, see [createWorkspaceParityMap.md](./createWorkspaceParityMap.md). + ## Test Development ### Using VS Code Extension Test Runner diff --git a/apps/vs-code-designer/src/test/e2e/cdpClient.ts b/apps/vs-code-designer/src/test/e2e/cdpClient.ts index 7922df0e720..af55e3eca91 100644 --- a/apps/vs-code-designer/src/test/e2e/cdpClient.ts +++ b/apps/vs-code-designer/src/test/e2e/cdpClient.ts @@ -337,25 +337,6 @@ export async function waitForWebviewFrameContext( ); } -export async function captureCdpScreenshot(cdp: CdpConnection, name: string): Promise { - const fs = await import('fs'); - const path = await import('path'); - const screenshotRoot = - process.env.LA_E2E_CLI_SCREENSHOT_DIR ?? path.resolve(__dirname, '..', '..', '..', '.vscode-test', 'screenshots', 'cli'); - - fs.mkdirSync(screenshotRoot, { recursive: true }); - const screenshotPath = path.join(screenshotRoot, `${sanitizeFileSegment(name)}.png`); - const response = await cdp.send('Page.captureScreenshot', { format: 'png', fromSurface: true }); - const data = response.result?.data; - if (typeof data !== 'string') { - return undefined; - } - - fs.writeFileSync(screenshotPath, data, 'base64'); - console.log(`[screenshot] Saved: ${screenshotPath}`); - return screenshotPath; -} - async function fetchJson(url: string): Promise { const response = await fetch(url); if (!response.ok) { @@ -463,10 +444,6 @@ function tryDecodeServerFrame(buffer: Buffer): { opcode: number; payload: Buffer return { opcode: firstByte & 0x0f, payload, consumed: offset + length }; } -function sanitizeFileSegment(value: string): string { - return value.replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'screenshot'; -} - function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts b/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts index f4733e7bdd1..7b8254e4005 100644 --- a/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts +++ b/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts @@ -4,9 +4,12 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { connectToVsCodeCdp, waitForCreateWorkspaceFrameContext } from './cdpClient'; +import type { FieldLabels, WorkspaceAppType, WorkspaceCreationCase, WorkflowType } from './createWorkspaceTypes'; import { assertNoDialogAttempts, installDialogGuard } from './dialogGuard'; import { captureCliScreenshot } from './screenshot'; +import { containsIgnoreCase, uniqueName } from './testUtils'; import { waitForVisibleDelay } from './visibleDelay'; +import { closeWebviewTabs, getTabViewType, getWebviewTabs, waitForWebviewTab } from './webviewTabs'; const logicAppsExtensionId = 'ms-azuretools.vscode-azurelogicapps'; const createWorkspaceCommand = 'azureLogicAppsStandard.createWorkspace'; @@ -35,10 +38,6 @@ type CdpEvaluator = { evaluate(contextId: number, expression: string): Promise; send(method: string, params?: Record): Promise; }; -type FieldLabels = string | string[]; -type WorkspaceAppType = 'standard' | 'customCode' | 'rulesEngine' | 'codeful'; -type CodefulControlVariant = 'modern-control' | 'legacy-control'; -type WorkflowType = 'Stateful' | 'Stateless' | 'Autonomous agents (Preview)' | 'Conversational agents (Preview)'; type CreateWorkspaceGroup = 'default' | 'behavior' | 'core-matrix' | 'preview-matrix' | 'codeful' | 'fixtures-manifest' | 'full'; interface FieldValidationCase { @@ -49,20 +48,6 @@ interface FieldValidationCase { validValue: string; } -interface WorkspaceCreationCase { - label: string; - appType: WorkspaceAppType; - radioLabel: string; - wsName: string; - appName: string; - wfName: string; - workflowType: WorkflowType; - functionFolderName?: string; - functionNamespace?: string; - functionName?: string; - codefulControlVariant?: CodefulControlVariant; -} - /** * Must stay downstream-compatible with src/test/ui/workspaceManifest.ts. * ExTester p41a-fixtures remains the canonical producer for run-e2e.js phases; @@ -2977,65 +2962,3 @@ function withField(labels: FieldLabels, action: string): string { function getLabels(labels: FieldLabels): string[] { return Array.isArray(labels) ? labels : [labels]; } - -function containsIgnoreCase(value: string, expected: string): boolean { - return value.toLowerCase().includes(expected.toLowerCase()); -} - -function uniqueName(prefix: string): string { - return `${prefix}${Date.now().toString(36).slice(-5)}`; -} - -async function waitForWebviewTab(viewType: string, previousCount: number): Promise { - const timeoutMs = 10000; - const pollMs = 250; - const startedAt = Date.now(); - - while (Date.now() - startedAt < timeoutMs) { - const tabs = getWebviewTabs(viewType); - if (tabs.length > previousCount) { - return tabs[tabs.length - 1]; - } - - if (tabs.length > 0 && previousCount === 0) { - return tabs[tabs.length - 1]; - } - - await new Promise((resolve) => setTimeout(resolve, pollMs)); - } - - assert.fail(`Timed out waiting for ${viewType} webview tab to open. Open tabs: ${describeOpenTabs()}`); -} - -function getWebviewTabs(viewType: string): vscode.Tab[] { - return vscode.window.tabGroups.all.flatMap((group) => - group.tabs.filter((tab) => { - return getTabViewType(tab) === `mainThreadWebview-${viewType}`; - }) - ); -} - -function getTabViewType(tab: vscode.Tab): string | undefined { - const input = tab.input as { viewType?: unknown }; - return typeof input.viewType === 'string' ? input.viewType : undefined; -} - -async function closeWebviewTabs(viewType: string): Promise { - const tabs = getWebviewTabs(viewType); - if (tabs.length > 0) { - await vscode.window.tabGroups.close(tabs); - } -} - -function describeOpenTabs(): string { - return JSON.stringify( - vscode.window.tabGroups.all.flatMap((group) => - group.tabs.map((tab) => ({ - label: tab.label, - isActive: tab.isActive, - inputType: tab.input?.constructor?.name, - viewType: getTabViewType(tab), - })) - ) - ); -} diff --git a/apps/vs-code-designer/src/test/e2e/createWorkspaceParityMap.md b/apps/vs-code-designer/src/test/e2e/createWorkspaceParityMap.md new file mode 100644 index 00000000000..92c7e4c018a --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/createWorkspaceParityMap.md @@ -0,0 +1,73 @@ +# Create Workspace ExTester to @vscode/test-cli parity map + +This map tracks how the latest-stable `@vscode/test-cli` Create Workspace coverage relates to the existing ExTester Create Workspace suites. The CLI suite is additive: it does not replace ExTester, and ExTester remains the canonical owner for Selenium DOM coverage and downstream `run-e2e.js` fixture phases. + +## CLI labels + +| CLI label | Script | Purpose | +|---|---|---| +| `unitTests` | `pnpm run test:e2e-cli:smoke` | Activation, dependency hydration, command registration, empty-window startup. | +| `createWorkspace` | `pnpm run test:e2e-cli:create-workspace` | Default focused validation plus core Standard/custom-code/rules-engine creation smoke. | +| `createWorkspaceBehavior` | `pnpm run test:e2e-cli:create-workspace:behavior` | Initial render/content, field validation, review/back, workflow-type review checks, app-type cleanup. | +| `createWorkspaceCoreMatrix` | `pnpm run test:e2e-cli:create-workspace:core-matrix` | Standard/custom-code/rules-engine Stateful and Stateless generated artifact checks. | +| `createWorkspacePreviewMatrix` | `pnpm run test:e2e-cli:create-workspace:preview-matrix` | Autonomous agents and Conversational agents generated artifact checks across Standard/custom-code/rules-engine. | +| `createWorkspaceCodeful` | `pnpm run test:e2e-cli:create-workspace:codeful` | Current codeful project creation plus legacy-control `.csproj` shape simulation. | +| `createWorkspaceFixturesManifest` | `pnpm run test:e2e-cli:create-workspace:fixtures` | CLI-generated manifest compatible with `src/test/ui/workspaceManifest.ts`; focused/manual, not the ExTester fixture owner. | +| `workspaceLifecycle` | `pnpm run test:e2e-cli:workspace-lifecycle` | Generated Standard/custom-code/rules-engine Stateful debug/run smoke. | + +## ExTester behavior suite correlation + +Source suite: `src/test/ui/createWorkspace.behavior.test.ts`. + +| ExTester coverage area | Representative ExTester tests | CLI correlation | Notes | +|---|---|---|---| +| Initial form shell/content | `should verify all form elements on initial render` | `createWorkspaceBehavior` / `assertInitialCreateWorkspaceContent` | CLI asserts the correct Create Workspace webview, required labels, app-type choices, workflow-type options, disabled Next, and absence of package-flow fields. | +| Parent path validation | `should show validation error for non-existent path`, `should show validation error when path is cleared` | `createWorkspaceBehavior` / `runStandardFieldValidationCases` | CLI validates error text and recovery with a valid temp folder. | +| Workspace name validation | starts with number, special characters, leading/trailing separators, empty, dots, trailing underscore | `createWorkspaceBehavior` / `runStandardFieldValidationCases` | CLI groups these as field-scoped invalid-then-valid cases and required-field progression. | +| Logic app name validation | invalid value, empty value, special characters, leading/trailing separators | `createWorkspaceBehavior` / `runStandardFieldValidationCases` | CLI asserts the target field error and recovery rather than relying only on Next disabled. | +| Workflow name validation | invalid value, empty value, special characters, leading/trailing separators | `createWorkspaceBehavior` / `runStandardFieldValidationCases` | CLI validates the corresponding workflow-name field state and valid recovery. | +| Reserved workflow names | `Artifacts`, `lib`, case-insensitive reserved name, `workflow-designtime`, recovery when valid | `createWorkspaceBehavior` / `runStandardFieldValidationCases` | CLI follows ExTester behavior by asserting the field-scoped reserved-name error and recovery, not a stricter Next-disabled contract. | +| Standard required-field progression | `should keep Next button disabled until all required fields are valid` | `createWorkspaceBehavior` / `runStandardRequiredFieldProgression` | CLI asserts partial-fill gating for Standard required fields. | +| Custom-code field visibility | `should show custom code fields when selecting custom code radio` | `createWorkspaceBehavior` / `runCustomCodeFieldValidationCases` | CLI selects the custom-code app type and asserts required custom-code fields are present before validating them. | +| Custom-code folder validation | invalid folder, same as logic app, empty, special characters, leading/trailing separators | `createWorkspaceBehavior` / `runCustomCodeFieldValidationCases` | CLI validates field-scoped messages and recovery. | +| Custom-code namespace validation | invalid namespace, dotted valid namespace, empty namespace | `createWorkspaceBehavior` / `runCustomCodeFieldValidationCases` | CLI validates namespace-specific message and valid dotted namespace acceptance. | +| Custom-code function name validation | invalid, empty, hyphenated, special characters, leading underscore | `createWorkspaceBehavior` / `runCustomCodeFieldValidationCases` | CLI validates function-name-specific errors and recovery. | +| Custom-code partial-fill gating | `should keep Next disabled for all partial-fill combinations of custom code fields` | `createWorkspaceBehavior` / `runCustomCodeFieldValidationCases` | CLI keeps a partial-fill matrix for custom-code-specific required fields. | +| Rules-engine field visibility | `should show rules engine fields when selecting rules engine radio` | `createWorkspaceBehavior` / `runRulesEngineFieldValidationCases` | CLI selects the rules-engine app type and asserts required rules-engine fields are present before validating them. | +| Rules-engine folder validation | invalid folder, same as logic app, empty, special characters, leading/trailing separators | `createWorkspaceBehavior` / `runRulesEngineFieldValidationCases` | CLI validates field-scoped messages and recovery. | +| Rules-engine namespace validation | invalid namespace, starts with digit, empty namespace | `createWorkspaceBehavior` / `runRulesEngineFieldValidationCases` | CLI validates namespace-specific messages and recovery. | +| Rules-engine function name validation | invalid, empty, hyphenated, special characters, leading underscore | `createWorkspaceBehavior` / `runRulesEngineFieldValidationCases` | CLI validates function-name-specific messages and recovery. | +| Rules-engine partial-fill gating | `should keep Next disabled for all partial-fill combinations of rules engine fields` | `createWorkspaceBehavior` / `runRulesEngineFieldValidationCases` | CLI keeps a partial-fill matrix for rules-engine-specific required fields. | +| Standard review/back | Stateful review/back | `createWorkspaceBehavior` / `goToReviewAndBack` | CLI asserts review content, then Back preserves field values and selections. | +| Workflow-type review/back | Stateless, Autonomous agents, Conversational agents review checks | `createWorkspaceBehavior` / `verifyWorkflowTypeDescriptionAndReview` | CLI verifies selection text/description and review-step echo for each workflow type. | +| Custom-code review/back | custom-code valid values and review | `createWorkspaceBehavior` / `goToReviewAndBack` | CLI asserts custom-code values, .NET selection, review content, and Back preservation. | +| Rules-engine review/back | rules-engine valid values and review | `createWorkspaceBehavior` / `goToReviewAndBack` | CLI asserts rules-engine values, review content, and Back preservation. | +| App-type cleanup | switch custom-code/rules-engine back to Standard | `createWorkspaceBehavior` / `verifyAppTypeCleanup` | CLI asserts app-specific fields are removed and Standard selection remains valid. | +| Codeful webview creation | modern and legacy-control codeful workspace creation | `createWorkspaceCodeful` | CLI creates through the current codeful radio. The legacy-control case patches only `.csproj` target hooks to mirror ExTester Phase 4.10's legacy simulation. | +| Standard workspace creation | Standard Stateful/Stateless and preview workflow creation | `createWorkspaceCoreMatrix`, `createWorkspacePreviewMatrix` | CLI verifies generated folders, `.code-workspace`, workflow JSON, and `.vscode` essentials. | +| Custom-code workspace creation | CustomCode Stateful/Stateless and preview workflow creation | `createWorkspaceCoreMatrix`, `createWorkspacePreviewMatrix` | CLI verifies Logic App and sibling function project artifacts, workflow shape, and `.vscode` essentials. | +| Rules-engine workspace creation | RulesEngine Stateful/Stateless and preview workflow creation | `createWorkspaceCoreMatrix`, `createWorkspacePreviewMatrix` | CLI verifies Logic App and sibling rules function project artifacts, workflow shape, and `.vscode` essentials. | + +## ExTester fixture suite correlation + +Source suite: `src/test/ui/createWorkspace.fixtures.test.ts`. + +| ExTester fixture case | CLI correlation | Notes | +|---|---|---| +| Standard + Stateful workspace manifest entry | `createWorkspaceFixturesManifest` | CLI creates through the real wizard and writes the same manifest shape. | +| Standard + Stateless workspace manifest entry | `createWorkspaceFixturesManifest` | CLI creates through the real wizard and writes the same manifest shape. | +| CustomCode + Stateful workspace manifest entry | `createWorkspaceFixturesManifest` | CLI creates through the real wizard and writes the same manifest shape. | +| RulesEngine + Stateful workspace manifest entry | `createWorkspaceFixturesManifest` | CLI creates through the real wizard and writes the same manifest shape. | + +ExTester `p41a-fixtures` remains the canonical producer for downstream `run-e2e.js` phases. The CLI fixture label is for latest-stable parity and local focused verification. + +## Runtime/debug correlation + +| User-facing requirement | CLI coverage | ExTester coverage that remains relevant | +|---|---|---| +| Standard generated workspace can open designer, add Request/Response, debug, run trigger, and succeed | `workspaceLifecycle` Standard case | ExTester Phase 4.2 remains the deeper Selenium designer lifecycle owner. | +| Custom-code generated workspace can debug and run successfully | `workspaceLifecycle` custom-code case | ExTester still owns broader webview DOM/debug coverage and task-event semantics. | +| Rules-engine generated workspace can debug and run successfully | `workspaceLifecycle` rules-engine case | ExTester still owns broader webview DOM/debug coverage. | +| NuGet conversion can debug and run successfully | Not in CLI Create Workspace baseline | ExTester conversion/NuGet scenarios remain owner. | +| Codeful modern-vs-legacy debug task behavior | Not in CLI Create Workspace baseline | ExTester Phase 4.10 remains owner. | + diff --git a/apps/vs-code-designer/src/test/e2e/createWorkspaceTypes.ts b/apps/vs-code-designer/src/test/e2e/createWorkspaceTypes.ts new file mode 100644 index 00000000000..26bea94c6d9 --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/createWorkspaceTypes.ts @@ -0,0 +1,21 @@ +export type FieldLabels = string | string[]; + +export type WorkspaceAppType = 'standard' | 'customCode' | 'rulesEngine' | 'codeful'; + +export type CodefulControlVariant = 'modern-control' | 'legacy-control'; + +export type WorkflowType = 'Stateful' | 'Stateless' | 'Autonomous agents (Preview)' | 'Conversational agents (Preview)'; + +export interface WorkspaceCreationCase { + label: string; + appType: WorkspaceAppType; + radioLabel: string; + wsName: string; + appName: string; + wfName: string; + workflowType: WorkflowType; + functionFolderName?: string; + functionNamespace?: string; + functionName?: string; + codefulControlVariant?: CodefulControlVariant; +} diff --git a/apps/vs-code-designer/src/test/e2e/extension.test.ts b/apps/vs-code-designer/src/test/e2e/extension.test.ts index d0c52516bd3..2bcabe0b2d9 100644 --- a/apps/vs-code-designer/src/test/e2e/extension.test.ts +++ b/apps/vs-code-designer/src/test/e2e/extension.test.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { assertNoDialogAttempts, installDialogGuard } from './dialogGuard'; import { captureCliScreenshot } from './screenshot'; +import { normalizeFsPath } from './testUtils'; const logicAppsExtensionId = 'ms-azuretools.vscode-azurelogicapps'; const activationChannelName = 'Logic Apps @vscode/test-cli Smoke'; @@ -108,9 +109,4 @@ suite('Extension Activation Tests', () => { assert.ok(Array.isArray(extensionDependencies), `${logicAppsExtensionId} should declare extensionDependencies`); return extensionDependencies; } - - function normalizeFsPath(fsPath: string): string { - const normalizedPath = path.normalize(fsPath); - return process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath; - } }); diff --git a/apps/vs-code-designer/src/test/e2e/testUtils.ts b/apps/vs-code-designer/src/test/e2e/testUtils.ts new file mode 100644 index 00000000000..6653440e8e3 --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/testUtils.ts @@ -0,0 +1,14 @@ +import * as path from 'path'; + +export function containsIgnoreCase(value: string, expected: string): boolean { + return value.toLowerCase().includes(expected.toLowerCase()); +} + +export function uniqueName(prefix: string): string { + return `${prefix}${Date.now().toString(36).slice(-5)}`; +} + +export function normalizeFsPath(fsPath: string): string { + const normalizedPath = path.normalize(fsPath); + return process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath; +} diff --git a/apps/vs-code-designer/src/test/e2e/webviewTabs.ts b/apps/vs-code-designer/src/test/e2e/webviewTabs.ts new file mode 100644 index 00000000000..e0f3cc6dba2 --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/webviewTabs.ts @@ -0,0 +1,61 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +export function getWebviewTabs(viewType: string): vscode.Tab[] { + return vscode.window.tabGroups.all.flatMap((group) => + group.tabs.filter((tab) => { + return getTabViewType(tab) === `mainThreadWebview-${viewType}`; + }) + ); +} + +export function getTabViewType(tab: vscode.Tab): string | undefined { + const input = tab.input as { viewType?: unknown }; + return typeof input.viewType === 'string' ? input.viewType : undefined; +} + +export async function waitForWebviewTab(viewType: string, previousCount: number, timeoutMs = 10000): Promise { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const tabs = getWebviewTabs(viewType); + if (tabs.length > previousCount) { + return tabs[tabs.length - 1]; + } + + if (tabs.length > 0 && previousCount === 0) { + return tabs[tabs.length - 1]; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + assert.fail(`Timed out waiting for ${viewType} webview tab to open. Open tabs: ${describeOpenTabs()}`); +} + +export async function closeWebviewTabs(viewType: string): Promise { + const tabs = getWebviewTabs(viewType); + if (tabs.length > 0) { + await vscode.window.tabGroups.close(tabs); + } +} + +export async function closeAllTabs(): Promise { + const tabs = vscode.window.tabGroups.all.flatMap((group) => group.tabs); + if (tabs.length > 0) { + await vscode.window.tabGroups.close(tabs); + } +} + +export function describeOpenTabs(): string { + return JSON.stringify( + vscode.window.tabGroups.all.flatMap((group) => + group.tabs.map((tab) => ({ + label: tab.label, + isActive: tab.isActive, + inputType: tab.input?.constructor?.name, + viewType: getTabViewType(tab), + })) + ) + ); +} diff --git a/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts b/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts index 6fabe2d3568..242fe6257a2 100644 --- a/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts +++ b/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts @@ -11,9 +11,12 @@ import { waitForCreateWorkspaceFrameContext, waitForWebviewFrameContext, } from './cdpClient'; +import type { FieldLabels } from './createWorkspaceTypes'; import { assertNoDialogAttempts, installDialogGuard } from './dialogGuard'; import { captureCdpScreenshot } from './screenshot'; +import { containsIgnoreCase, normalizeFsPath, uniqueName } from './testUtils'; import { waitForVisibleDelay } from './visibleDelay'; +import { closeAllTabs, closeWebviewTabs, describeOpenTabs, getTabViewType, getWebviewTabs, waitForWebviewTab } from './webviewTabs'; const logicAppsExtensionId = 'ms-azuretools.vscode-azurelogicapps'; const createWorkspaceCommand = 'azureLogicAppsStandard.createWorkspace'; @@ -36,7 +39,6 @@ type CdpEvaluator = { evaluate(contextId: number | undefined, expression: string): Promise; send(method: string, params?: Record): Promise; }; -type FieldLabels = string | string[]; type WorkspaceAppType = 'standard' | 'customCode' | 'rulesEngine'; interface WorkspaceCreationCase { @@ -2550,65 +2552,6 @@ function getLabels(labels: FieldLabels): string[] { return Array.isArray(labels) ? labels : [labels]; } -function getWebviewTabs(viewType: string): vscode.Tab[] { - return vscode.window.tabGroups.all.flatMap((group) => - group.tabs.filter((tab) => { - return getTabViewType(tab) === `mainThreadWebview-${viewType}`; - }) - ); -} - -function getTabViewType(tab: vscode.Tab): string | undefined { - const input = tab.input as { viewType?: unknown }; - return typeof input.viewType === 'string' ? input.viewType : undefined; -} - -async function waitForWebviewTab(viewType: string, previousCount: number, timeoutMs = 10000): Promise { - const startedAt = Date.now(); - - while (Date.now() - startedAt < timeoutMs) { - const tabs = getWebviewTabs(viewType); - if (tabs.length > previousCount) { - return tabs[tabs.length - 1]; - } - - if (tabs.length > 0 && previousCount === 0) { - return tabs[tabs.length - 1]; - } - - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - assert.fail(`Timed out waiting for ${viewType} webview tab to open. Open tabs: ${describeOpenTabs()}`); -} - -async function closeWebviewTabs(viewType: string): Promise { - const tabs = getWebviewTabs(viewType); - if (tabs.length > 0) { - await vscode.window.tabGroups.close(tabs); - } -} - -async function closeAllTabs(): Promise { - const tabs = vscode.window.tabGroups.all.flatMap((group) => group.tabs); - if (tabs.length > 0) { - await vscode.window.tabGroups.close(tabs); - } -} - -function describeOpenTabs(): string { - return JSON.stringify( - vscode.window.tabGroups.all.flatMap((group) => - group.tabs.map((tab) => ({ - label: tab.label, - isActive: tab.isActive, - inputType: tab.input?.constructor?.name, - viewType: getTabViewType(tab), - })) - ) - ); -} - async function waitUntil(predicate: () => boolean | Promise, timeoutMs: number, description: string): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown; @@ -2642,16 +2585,3 @@ async function withTimeout(promise: Thenable, timeoutMs: number, descripti } } } - -function containsIgnoreCase(value: string, expected: string): boolean { - return value.toLowerCase().includes(expected.toLowerCase()); -} - -function uniqueName(prefix: string): string { - return `${prefix}${Date.now().toString(36).slice(-5)}`; -} - -function normalizeFsPath(fsPath: string): string { - const normalizedPath = path.normalize(fsPath); - return process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath; -} From 23fe693f1d903a59e2db1c3fc15526938be15d91 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:33:21 -0700 Subject: [PATCH 3/5] Map Create Workspace CLI parity Add traceability from ExTester Create Workspace cases to @vscode/test-cli labels and extract shared Create Workspace case metadata. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/test/e2e/createWorkspace.test.ts | 126 ++---------------- .../src/test/e2e/createWorkspaceCases.ts | 114 ++++++++++++++++ 2 files changed, 123 insertions(+), 117 deletions(-) create mode 100644 apps/vs-code-designer/src/test/e2e/createWorkspaceCases.ts diff --git a/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts b/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts index 7b8254e4005..819018cfd5a 100644 --- a/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts +++ b/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts @@ -4,6 +4,15 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { connectToVsCodeCdp, waitForCreateWorkspaceFrameContext } from './cdpClient'; +import { + createWorkspaceCase, + filterCreationCases, + getCodefulCreationCases, + getCoreCreationCases, + getFixtureManifestCreationCases, + getPreviewCreationCases, + getReviewBackCases, +} from './createWorkspaceCases'; import type { FieldLabels, WorkspaceAppType, WorkspaceCreationCase, WorkflowType } from './createWorkspaceTypes'; import { assertNoDialogAttempts, installDialogGuard } from './dialogGuard'; import { captureCliScreenshot } from './screenshot'; @@ -340,123 +349,6 @@ function shouldRunCreateWorkspaceGroup(current: CreateWorkspaceGroup, groups: Cr return groups.includes(current); } -function filterCreationCases(cases: WorkspaceCreationCase[], caseFilter: string | undefined): WorkspaceCreationCase[] { - if (!caseFilter) { - return cases; - } - - const labels = caseFilter - .split(',') - .map((label) => label.trim()) - .filter(Boolean); - return cases.filter((creationCase) => labels.includes(creationCase.label)); -} - -function getReviewBackCases(): WorkspaceCreationCase[] { - return [ - createWorkspaceCase('review-standard', 'standard', 'Logic app (Standard)', 'Stateful', 'clirvstd'), - createWorkspaceCase('review-custom-code', 'customCode', 'Logic app with custom code', 'Stateful', 'clirvcc'), - createWorkspaceCase('review-rules-engine', 'rulesEngine', 'Logic app with rules engine', 'Stateful', 'clirvre'), - ]; -} - -function getCoreCreationCases(): WorkspaceCreationCase[] { - return [ - createWorkspaceCase('standard-stateful', 'standard', 'Logic app (Standard)', 'Stateful', 'clistdsf'), - createWorkspaceCase('standard-stateless', 'standard', 'Logic app (Standard)', 'Stateless', 'clistdsl'), - createWorkspaceCase('custom-code-stateful', 'customCode', 'Logic app with custom code', 'Stateful', 'cliccsf'), - createWorkspaceCase('custom-code-stateless', 'customCode', 'Logic app with custom code', 'Stateless', 'cliccsl'), - createWorkspaceCase('rules-engine-stateful', 'rulesEngine', 'Logic app with rules engine', 'Stateful', 'cliresf'), - createWorkspaceCase('rules-engine-stateless', 'rulesEngine', 'Logic app with rules engine', 'Stateless', 'cliresl'), - ]; -} - -function getFixtureManifestCreationCases(): WorkspaceCreationCase[] { - return [ - createWorkspaceCase('standard-stateful', 'standard', 'Logic app (Standard)', 'Stateful', 'clifixstdsf'), - createWorkspaceCase('standard-stateless', 'standard', 'Logic app (Standard)', 'Stateless', 'clifixstdsl'), - createWorkspaceCase('custom-code-stateful', 'customCode', 'Logic app with custom code', 'Stateful', 'clifixccsf'), - createWorkspaceCase('rules-engine-stateful', 'rulesEngine', 'Logic app with rules engine', 'Stateful', 'clifixresf'), - ]; -} - -function getPreviewCreationCases(): WorkspaceCreationCase[] { - return [ - createWorkspaceCase('standard-autonomous-agent', 'standard', 'Logic app (Standard)', 'Autonomous agents (Preview)', 'clistdaa'), - createWorkspaceCase('standard-conversational-agent', 'standard', 'Logic app (Standard)', 'Conversational agents (Preview)', 'clistdca'), - createWorkspaceCase( - 'custom-code-autonomous-agent', - 'customCode', - 'Logic app with custom code', - 'Autonomous agents (Preview)', - 'cliccaa' - ), - createWorkspaceCase( - 'custom-code-conversational-agent', - 'customCode', - 'Logic app with custom code', - 'Conversational agents (Preview)', - 'cliccca' - ), - createWorkspaceCase( - 'rules-engine-autonomous-agent', - 'rulesEngine', - 'Logic app with rules engine', - 'Autonomous agents (Preview)', - 'clireaa' - ), - createWorkspaceCase( - 'rules-engine-conversational-agent', - 'rulesEngine', - 'Logic app with rules engine', - 'Conversational agents (Preview)', - 'clireca' - ), - ]; -} - -function getCodefulCreationCases(): WorkspaceCreationCase[] { - // The latest-stable @vscode/test-cli host exposes the same product picker as - // ExTester: one "Logic app (codeful)" radio option. ExTester selects the - // legacy-control variant by creating a second codeful workspace through that - // radio and patching only the generated .csproj target hooks afterward, so the - // CLI suite mirrors that parity shape here without changing product code. - const modern = createWorkspaceCase('codeful-modern-control', 'codeful', 'Logic app (codeful)', 'Stateful', 'clicodemodern'); - modern.codefulControlVariant = 'modern-control'; - - const legacy = createWorkspaceCase('codeful-legacy-control', 'codeful', 'Logic app (codeful)', 'Stateful', 'clicodelegacy'); - legacy.codefulControlVariant = 'legacy-control'; - - return [modern, legacy]; -} - -function createWorkspaceCase( - label: string, - appType: WorkspaceAppType, - radioLabel: string, - workflowType: WorkflowType, - prefix: string -): WorkspaceCreationCase { - const baseName = uniqueName(prefix); - const creationCase: WorkspaceCreationCase = { - label, - appType, - radioLabel, - wsName: `${baseName}ws`, - appName: `${baseName}app`, - wfName: `${baseName}wf`, - workflowType, - }; - - if (appType === 'customCode' || appType === 'rulesEngine') { - creationCase.functionFolderName = `${baseName}funcfolder`; - creationCase.functionNamespace = appType === 'rulesEngine' ? 'RulesEngineNamespace' : 'MyCompany.Functions'; - creationCase.functionName = `${baseName}fn`; - } - - return creationCase; -} - function assertEmptyWorkspace(context: string): void { assert.ok( !vscode.workspace.workspaceFile || vscode.workspace.workspaceFile.scheme === 'untitled', diff --git a/apps/vs-code-designer/src/test/e2e/createWorkspaceCases.ts b/apps/vs-code-designer/src/test/e2e/createWorkspaceCases.ts new file mode 100644 index 00000000000..e8222d6159d --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/createWorkspaceCases.ts @@ -0,0 +1,114 @@ +import type { WorkspaceAppType, WorkspaceCreationCase, WorkflowType } from './createWorkspaceTypes'; +import { uniqueName } from './testUtils'; + +export function filterCreationCases(cases: WorkspaceCreationCase[], caseFilter: string | undefined): WorkspaceCreationCase[] { + if (!caseFilter) { + return cases; + } + + const labels = caseFilter + .split(',') + .map((label) => label.trim()) + .filter(Boolean); + return cases.filter((creationCase) => labels.includes(creationCase.label)); +} + +export function getReviewBackCases(): WorkspaceCreationCase[] { + return [ + createWorkspaceCase('review-standard', 'standard', 'Logic app (Standard)', 'Stateful', 'clirvstd'), + createWorkspaceCase('review-custom-code', 'customCode', 'Logic app with custom code', 'Stateful', 'clirvcc'), + createWorkspaceCase('review-rules-engine', 'rulesEngine', 'Logic app with rules engine', 'Stateful', 'clirvre'), + ]; +} + +export function getCoreCreationCases(): WorkspaceCreationCase[] { + return [ + createWorkspaceCase('standard-stateful', 'standard', 'Logic app (Standard)', 'Stateful', 'clistdsf'), + createWorkspaceCase('standard-stateless', 'standard', 'Logic app (Standard)', 'Stateless', 'clistdsl'), + createWorkspaceCase('custom-code-stateful', 'customCode', 'Logic app with custom code', 'Stateful', 'cliccsf'), + createWorkspaceCase('custom-code-stateless', 'customCode', 'Logic app with custom code', 'Stateless', 'cliccsl'), + createWorkspaceCase('rules-engine-stateful', 'rulesEngine', 'Logic app with rules engine', 'Stateful', 'cliresf'), + createWorkspaceCase('rules-engine-stateless', 'rulesEngine', 'Logic app with rules engine', 'Stateless', 'cliresl'), + ]; +} + +export function getFixtureManifestCreationCases(): WorkspaceCreationCase[] { + return [ + createWorkspaceCase('standard-stateful', 'standard', 'Logic app (Standard)', 'Stateful', 'clifixstdsf'), + createWorkspaceCase('standard-stateless', 'standard', 'Logic app (Standard)', 'Stateless', 'clifixstdsl'), + createWorkspaceCase('custom-code-stateful', 'customCode', 'Logic app with custom code', 'Stateful', 'clifixccsf'), + createWorkspaceCase('rules-engine-stateful', 'rulesEngine', 'Logic app with rules engine', 'Stateful', 'clifixresf'), + ]; +} + +export function getPreviewCreationCases(): WorkspaceCreationCase[] { + return [ + createWorkspaceCase('standard-autonomous-agent', 'standard', 'Logic app (Standard)', 'Autonomous agents (Preview)', 'clistdaa'), + createWorkspaceCase('standard-conversational-agent', 'standard', 'Logic app (Standard)', 'Conversational agents (Preview)', 'clistdca'), + createWorkspaceCase( + 'custom-code-autonomous-agent', + 'customCode', + 'Logic app with custom code', + 'Autonomous agents (Preview)', + 'cliccaa' + ), + createWorkspaceCase( + 'custom-code-conversational-agent', + 'customCode', + 'Logic app with custom code', + 'Conversational agents (Preview)', + 'cliccca' + ), + createWorkspaceCase( + 'rules-engine-autonomous-agent', + 'rulesEngine', + 'Logic app with rules engine', + 'Autonomous agents (Preview)', + 'clireaa' + ), + createWorkspaceCase( + 'rules-engine-conversational-agent', + 'rulesEngine', + 'Logic app with rules engine', + 'Conversational agents (Preview)', + 'clireca' + ), + ]; +} + +export function getCodefulCreationCases(): WorkspaceCreationCase[] { + const modern = createWorkspaceCase('codeful-modern-control', 'codeful', 'Logic app (codeful)', 'Stateful', 'clicodemodern'); + modern.codefulControlVariant = 'modern-control'; + + const legacy = createWorkspaceCase('codeful-legacy-control', 'codeful', 'Logic app (codeful)', 'Stateful', 'clicodelegacy'); + legacy.codefulControlVariant = 'legacy-control'; + + return [modern, legacy]; +} + +export function createWorkspaceCase( + label: string, + appType: WorkspaceAppType, + radioLabel: string, + workflowType: WorkflowType, + prefix: string +): WorkspaceCreationCase { + const baseName = uniqueName(prefix); + const creationCase: WorkspaceCreationCase = { + label, + appType, + radioLabel, + wsName: `${baseName}ws`, + appName: `${baseName}app`, + wfName: `${baseName}wf`, + workflowType, + }; + + if (appType === 'customCode' || appType === 'rulesEngine') { + creationCase.functionFolderName = `${baseName}funcfolder`; + creationCase.functionNamespace = appType === 'rulesEngine' ? 'RulesEngineNamespace' : 'MyCompany.Functions'; + creationCase.functionName = `${baseName}fn`; + } + + return creationCase; +} From ed03887ace3956e2ed738c4441775c67d098bbba Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:11:11 -0700 Subject: [PATCH 4/5] Refactor VS Code CLI E2E helpers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/test/e2e/cdpFormHelpers.ts | 861 +++++++++++++++++ .../src/test/e2e/createWorkspace.test.ts | 887 +----------------- .../src/test/e2e/workspaceArtifacts.ts | 28 + .../src/test/e2e/workspaceLifecycle.test.ts | 526 +---------- 4 files changed, 928 insertions(+), 1374 deletions(-) create mode 100644 apps/vs-code-designer/src/test/e2e/cdpFormHelpers.ts create mode 100644 apps/vs-code-designer/src/test/e2e/workspaceArtifacts.ts diff --git a/apps/vs-code-designer/src/test/e2e/cdpFormHelpers.ts b/apps/vs-code-designer/src/test/e2e/cdpFormHelpers.ts new file mode 100644 index 00000000000..828b1a6f8ee --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/cdpFormHelpers.ts @@ -0,0 +1,861 @@ +import * as assert from 'assert'; +import type { FieldLabels } from './createWorkspaceTypes'; +import { containsIgnoreCase } from './testUtils'; + +export type CdpEvaluator = { + evaluate(contextId: number | undefined, expression: string): Promise; + send(method: string, params?: Record): Promise; +}; + +export type Point = { + x: number; + y: number; +}; + +type FieldState = { + ok: boolean; + reason?: string; + value?: string; + fieldText?: string; + validationText?: string; + pageText?: string; + ariaInvalid?: string | null; + describedBy?: string | null; +}; + +type WizardButtonState = { + found: boolean; + disabled?: boolean; + text?: string; + pageText?: string; + fieldValues?: unknown[]; +}; + +export async function enterFieldValue(cdp: CdpEvaluator, contextId: number, labels: FieldLabels, value: string): Promise { + await waitForFieldVisible(cdp, contextId, labels); + const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; value?: string }>( + contextId, + withField( + labels, + `input.focus(); + input.select(); + return { ok: true, value: input.value };` + ) + ); + + assert.strictEqual( + focusResult.ok, + true, + focusResult.reason ?? `Failed to focus ${getLabels(labels).join('/')} field. Text: ${focusResult.text ?? ''}` + ); + + try { + await replaceFocusedInputText(cdp, value); + } catch { + await cdp.evaluate( + contextId, + withField( + labels, + `setInputValue(input, ${JSON.stringify(value)}); + return { ok: true, value: input.value };` + ) + ); + } + + const result = await waitForFieldValue(cdp, contextId, labels, value); + assert.strictEqual( + result.value, + value, + `Expected field "${getLabels(labels).join('/')}" to equal "${value}". State: ${JSON.stringify(result)}` + ); +} + +export async function waitForFieldVisible(cdp: CdpEvaluator, contextId: number, labels: FieldLabels): Promise { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const result = await getFieldState(cdp, contextId, labels).catch(() => undefined); + if (result?.ok) { + return; + } + + await delay(250); + } + + const text = await getPageText(cdp, contextId); + assert.fail(`Timed out waiting for field "${getLabels(labels).join('/')}" to be visible. Webview text: ${text}`); +} + +export async function waitForFieldHidden(cdp: CdpEvaluator, contextId: number, labels: FieldLabels): Promise { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const result = await getFieldState(cdp, contextId, labels).catch(() => undefined); + if (!result?.ok) { + return; + } + + await delay(250); + } + + const result = await getFieldState(cdp, contextId, labels).catch((error) => ({ text: String(error) })); + assert.fail(`Expected field "${getLabels(labels).join('/')}" to be hidden. State: ${JSON.stringify(result)}`); +} + +export async function waitForFieldValidationMessage( + cdp: CdpEvaluator, + contextId: number, + labels: FieldLabels, + expectedMessage: string +): Promise { + const deadline = Date.now() + (expectedMessage === 'not exist' ? 45000 : 10000); + while (Date.now() < deadline) { + const result = await getFieldState(cdp, contextId, labels); + const fieldText = `${result.fieldText ?? ''}\n${result.validationText ?? ''}`; + if (containsIgnoreCase(fieldText, expectedMessage)) { + return; + } + + await delay(250); + } + + const finalState = await getFieldState(cdp, contextId, labels).catch((error) => ({ text: String(error) })); + assert.fail( + `Timed out waiting for validation message "${expectedMessage}" on field "${getLabels(labels).join('/')}". State: ${JSON.stringify(finalState)}` + ); +} + +export async function waitForFieldValidationMessageToClear( + cdp: CdpEvaluator, + contextId: number, + labels: FieldLabels, + message: string +): Promise { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const result = await getFieldState(cdp, contextId, labels); + const fieldText = `${result.fieldText ?? ''}\n${result.validationText ?? ''}`; + if (!containsIgnoreCase(fieldText, message)) { + return; + } + + await delay(250); + } + + const result = await getFieldState(cdp, contextId, labels).catch((error) => ({ text: String(error) })); + assert.fail( + `Timed out waiting for validation message "${message}" to clear on field "${getLabels(labels).join('/')}". State: ${JSON.stringify(result)}` + ); +} + +export async function waitForAsyncValidationToSettle(cdp: CdpEvaluator, contextId: number): Promise { + const pendingMessages = ['Validating path', 'Checking workspace availability']; + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + const pageText = await getPageText(cdp, contextId); + if (!pendingMessages.some((message) => containsIgnoreCase(pageText, message))) { + return; + } + + await delay(250); + } + + const pageText = await getPageText(cdp, contextId); + assert.fail(`Timed out waiting for async Create Workspace validation to settle. Webview text: ${pageText}`); +} + +export async function assertNextButtonDisabled(cdp: CdpEvaluator, contextId: number, context: string): Promise { + const deadline = Date.now() + 8000; + while (Date.now() < deadline) { + const result = await getNextButtonState(cdp, contextId); + if (result.found && result.disabled) { + return; + } + + await delay(250); + } + + const result = await getNextButtonState(cdp, contextId); + assert.fail(`Expected Next button to be disabled for ${context}. State: ${JSON.stringify(result)}`); +} + +export async function assertNextButtonEnabled(cdp: CdpEvaluator, contextId: number, context: string): Promise { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + const result = await getNextButtonState(cdp, contextId); + if (result.found && !result.disabled) { + return; + } + + await delay(250); + } + + const result = await getNextButtonState(cdp, contextId); + assert.fail(`Expected Next button to be enabled for ${context}. State: ${JSON.stringify(result)}`); +} + +export async function assertWizardButtonDisabledOrAbsent( + cdp: CdpEvaluator, + contextId: number, + buttonText: string, + context: string +): Promise { + const result = await getWizardButtonState(cdp, contextId, buttonText); + assert.ok( + !result.found || result.disabled, + `Expected ${buttonText} button to be disabled or absent for ${context}. State: ${JSON.stringify(result)}` + ); +} + +export async function assertDropdownHasOptions( + cdp: CdpEvaluator, + contextId: number, + labelText: string, + expectedOptions: string[] +): Promise { + const focusResult = await getDropdownClickPoint(cdp, contextId, labelText); + assert.strictEqual(focusResult.ok, true, focusResult.reason ?? `Failed to find "${labelText}" dropdown. Text: ${focusResult.text ?? ''}`); + assert.ok(focusResult.point, `Failed to locate "${labelText}" dropdown click point.`); + + await clickPoint(cdp, focusResult.point); + if (!(await hasDropdownOptions(cdp, contextId))) { + await dispatchDropdownClickFallback(cdp, contextId, labelText); + } + if (!(await hasDropdownOptions(cdp, contextId))) { + await pressKey(cdp, 'Enter', undefined, 13); + } + if (!(await hasDropdownOptions(cdp, contextId))) { + await pressKey(cdp, 'Space', ' ', 32); + } + await waitForDropdownOptions(cdp, contextId); + const options = await getVisibleDropdownOptions(cdp, contextId); + for (const expectedOption of expectedOptions) { + assert.ok( + options.some((option) => option === expectedOption), + `Expected "${labelText}" dropdown to include "${expectedOption}". Options: ${JSON.stringify(options)}` + ); + } + await pressKey(cdp, 'Escape', 'Escape', 27); +} + +export async function selectRadioOption(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; point?: Point }>( + contextId, + `(() => { + const expected = ${JSON.stringify(labelText)}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 180 && text.includes(expected); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + if (!label) { + return { ok: false, reason: 'Radio label not found', text: document.body?.innerText || '' }; + } + + const radioRoot = label.closest('[role="radio"], .fui-Radio') || label; + const input = radioRoot.querySelector('input[type="radio"]'); + if (!(input instanceof HTMLInputElement)) { + return { ok: false, reason: 'Radio input not found', text: radioRoot.outerHTML }; + } + + const clickable = radioRoot instanceof HTMLElement ? radioRoot : input; + clickable.scrollIntoView({ block: 'center', inline: 'center' }); + input.focus(); + const rect = clickable.getBoundingClientRect(); + return { + ok: true, + text: radioRoot.outerHTML, + point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, + }; + })()` + ); + + assert.strictEqual( + focusResult.ok, + true, + focusResult.reason ?? `Failed to focus radio option "${labelText}". Text: ${focusResult.text ?? ''}` + ); + assert.ok(focusResult.point, `Failed to locate radio option "${labelText}" click point.`); + await clickPoint(cdp, focusResult.point); + if (!(await isRadioOptionChecked(cdp, contextId, labelText))) { + await dispatchRadioClickFallback(cdp, contextId, labelText); + } + if (!(await isRadioOptionChecked(cdp, contextId, labelText))) { + await pressKey(cdp, 'Space', ' ', 32); + } + await waitForRadioOptionChecked(cdp, contextId, labelText); +} + +export async function selectDropdownOption(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { + if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { + return; + } + + const focusResult = await getDropdownClickPoint(cdp, contextId, labelText); + + assert.strictEqual( + focusResult.ok, + true, + focusResult.reason ?? `Failed to focus "${labelText}" dropdown. Text: ${focusResult.text ?? ''}` + ); + assert.ok(focusResult.point, `Failed to locate "${labelText}" dropdown click point.`); + await clickPoint(cdp, focusResult.point); + await delay(500); + if (!(await hasDropdownOptions(cdp, contextId))) { + await dispatchDropdownClickFallback(cdp, contextId, labelText); + await delay(500); + } + if (!(await hasDropdownOptions(cdp, contextId))) { + await pressKey(cdp, 'Enter', undefined, 13); + await delay(500); + } + if (!(await hasDropdownOptions(cdp, contextId))) { + await pressKey(cdp, 'Space', ' ', 32); + await delay(500); + } + if (!(await hasDropdownOptions(cdp, contextId))) { + await pressKey(cdp, 'ArrowDown', 'ArrowDown', 40); + await delay(250); + await pressKey(cdp, 'Enter', undefined, 13); + await delay(500); + if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { + return; + } + } + + const optionResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; options?: string[]; optionIndex?: number }>( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const options = Array.from(document.querySelectorAll('[role="option"]')).filter(isVisible); + const option = options.find((candidate) => normalize(candidate.textContent) === ${JSON.stringify(optionText)}); + if (!(option instanceof HTMLElement)) { + return { + ok: false, + reason: 'Dropdown option not found', + options: options.map((candidate) => normalize(candidate.textContent)), + text: document.body?.innerText || '', + }; + } + + return { ok: true, optionIndex: options.indexOf(option) }; + })()` + ); + + assert.strictEqual( + optionResult.ok, + true, + `Failed to select "${optionText}" from "${labelText}". Reason: ${optionResult.reason ?? 'unknown'}. Options: ${JSON.stringify( + optionResult.options + )}. Text: ${optionResult.text ?? ''}` + ); + for (let index = 0; index < (optionResult.optionIndex ?? 0); index++) { + await pressKey(cdp, 'ArrowDown', 'ArrowDown', 40); + } + await pressKey(cdp, 'Enter', undefined, 13); + await waitForDropdownValue(cdp, contextId, labelText, optionText); +} + +export async function hasDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate( + contextId, + `(() => { + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + return Array.from(document.querySelectorAll('[role="option"]')).some(isVisible); + })()` + ); +} + +export async function isRadioOptionChecked(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + const result = await cdp.evaluate<{ checked: boolean }>( + contextId, + `(() => { + const expected = ${JSON.stringify(labelText)}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 180 && text.includes(expected); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; + const input = radioRoot?.querySelector('input[type="radio"]'); + return { checked: input instanceof HTMLInputElement ? input.checked : false }; + })()` + ); + return result.checked; +} + +export async function isDropdownValueSelected( + cdp: CdpEvaluator, + contextId: number, + labelText: string, + optionText: string +): Promise { + const result = await cdp.evaluate<{ selected: boolean }>( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter((candidate) => { + const text = normalize(candidate.textContent).toLowerCase(); + return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const dropdownId = label?.getAttribute('for'); + const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; + const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); + const text = dropdown?.textContent || ''; + return { selected: normalize(text).includes(${JSON.stringify(optionText)}) }; + })()` + ); + return result.selected; +} + +export async function pressKey(cdp: CdpEvaluator, code: string, key?: string, windowsVirtualKeyCode?: number): Promise { + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: key ?? code, + code, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: key ?? code, + code, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + }); +} + +export async function clickPoint(cdp: CdpEvaluator, point: Point): Promise { + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: point.x, + y: point.y, + button: 'none', + }); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button: 'left', + buttons: 1, + clickCount: 1, + }); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button: 'left', + buttons: 0, + clickCount: 1, + }); +} + +export async function getFieldState(cdp: CdpEvaluator, contextId: number, labels: FieldLabels): Promise { + return cdp.evaluate( + contextId, + withField( + labels, + `return { + ok: true, + value: input.value, + fieldText: field?.innerText || '', + validationText: getValidationText(input, field), + pageText: document.body?.innerText || '', + ariaInvalid: input.getAttribute('aria-invalid'), + describedBy: input.getAttribute('aria-describedby'), + };` + ) + ); +} + +export async function getNextButtonState(cdp: CdpEvaluator, contextId: number): Promise { + return getWizardButtonState(cdp, contextId, 'Next'); +} + +export async function getWizardButtonState(cdp: CdpEvaluator, contextId: number, buttonText: string): Promise { + return cdp.evaluate( + contextId, + `(() => { + const expectedButtonText = ${JSON.stringify(buttonText)}; + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const buttons = Array.from(document.querySelectorAll('button')).filter(isVisible); + const button = buttons.find((candidate) => (candidate.textContent || '').includes(expectedButtonText)); + const invalidFields = Array.from(document.querySelectorAll('input[aria-invalid="true"]')).map((input) => { + const label = input.id ? document.querySelector('label[for="' + CSS.escape(input.id) + '"]') : null; + const field = input.closest('[class*="fui-Field"]') || input.parentElement; + return { + label: label?.textContent || '', + value: input instanceof HTMLInputElement ? input.value : '', + text: field?.innerText || '', + }; + }); + const fieldValues = Array.from(document.querySelectorAll('input')).filter(isVisible).map((input) => { + const label = input.id ? document.querySelector('label[for="' + CSS.escape(input.id) + '"]') : null; + const field = input.closest('[class*="fui-Field"]') || input.parentElement; + return { + label: label?.textContent || '', + type: input instanceof HTMLInputElement ? input.type : '', + value: input instanceof HTMLInputElement ? input.value : '', + checked: input instanceof HTMLInputElement ? input.checked : undefined, + text: field?.innerText || '', + }; + }); + const pageText = document.body?.innerText || ''; + if (!button) { + return { found: false, text: pageText, pageText, invalidFields, fieldValues }; + } + + const disabled = button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true'; + return { found: true, disabled, text: button.textContent || '', pageText, invalidFields, fieldValues }; + })()` + ); +} + +export async function getPageText(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate(contextId, 'document.body?.innerText || ""').catch((error) => String(error)); +} + +export function getLabels(labels: FieldLabels): string[] { + return Array.isArray(labels) ? labels : [labels]; +} + +async function getVisibleDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { + return cdp.evaluate( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + return Array.from(document.querySelectorAll('[role="option"]')).filter(isVisible).map((option) => normalize(option.textContent)); + })()` + ); +} + +async function waitForDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (await hasDropdownOptions(cdp, contextId)) { + return; + } + + await delay(100); + } + + const pageText = await getPageText(cdp, contextId); + assert.fail(`Timed out waiting for dropdown options. Text: ${pageText}`); +} + +async function waitForRadioOptionChecked(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const result = await cdp.evaluate<{ checked: boolean; text?: string }>( + contextId, + `(() => { + const expected = ${JSON.stringify(labelText)}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 180 && text.includes(expected); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; + const input = radioRoot?.querySelector('input[type="radio"]'); + return { checked: input instanceof HTMLInputElement ? input.checked : false, text: radioRoot?.outerHTML || document.body?.innerText || '' }; + })()` + ); + if (result.checked) { + return; + } + + await delay(100); + } + + const result = await getNextButtonState(cdp, contextId); + assert.fail(`Expected radio option "${labelText}" to be checked. State: ${JSON.stringify(result)}`); +} + +async function waitForDropdownValue(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { + return; + } + + await delay(100); + } + + const result = await getNextButtonState(cdp, contextId); + assert.fail(`Expected dropdown "${labelText}" to select "${optionText}". State: ${JSON.stringify(result)}`); +} + +async function waitForFieldValue( + cdp: CdpEvaluator, + contextId: number, + labels: FieldLabels, + expectedValue: string +): Promise<{ ok: boolean; value?: string; fieldText?: string; pageText?: string }> { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const result = await getFieldState(cdp, contextId, labels); + if (result.value === expectedValue) { + return result; + } + + await delay(100); + } + + return getFieldState(cdp, contextId, labels); +} + +async function replaceFocusedInputText(cdp: CdpEvaluator, value: string): Promise { + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'Control', + code: 'ControlLeft', + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + modifiers: 2, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'a', + code: 'KeyA', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + modifiers: 2, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'a', + code: 'KeyA', + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + modifiers: 2, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'Control', + code: 'ControlLeft', + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'Backspace', + code: 'Backspace', + windowsVirtualKeyCode: 8, + nativeVirtualKeyCode: 8, + }); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'Backspace', + code: 'Backspace', + windowsVirtualKeyCode: 8, + nativeVirtualKeyCode: 8, + }); + + if (value) { + await cdp.send('Input.insertText', { text: value }); + } +} + +async function dispatchRadioClickFallback(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + await cdp.evaluate( + contextId, + `(() => { + const expected = ${JSON.stringify(labelText)}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 180 && text.includes(expected); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; + const input = radioRoot?.querySelector('input[type="radio"]'); + if (!(input instanceof HTMLInputElement)) { + return; + } + + input.focus(); + input.click(); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + })()` + ); +} + +async function getDropdownClickPoint( + cdp: CdpEvaluator, + contextId: number, + labelText: string +): Promise<{ ok: boolean; reason?: string; text?: string; point?: Point }> { + return cdp.evaluate<{ ok: boolean; reason?: string; text?: string; point?: Point }>( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent).toLowerCase(); + return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + if (!label) { + return { ok: false, reason: 'Dropdown label not found', text: document.body?.innerText || '' }; + } + const dropdownId = label?.getAttribute('for'); + const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; + const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); + if (!(dropdown instanceof HTMLButtonElement)) { + return { ok: false, reason: 'Dropdown button not found', text: document.body?.innerText || '' }; + } + + dropdown.scrollIntoView({ block: 'center', inline: 'center' }); + dropdown.focus(); + const rect = dropdown.getBoundingClientRect(); + return { + ok: true, + text: document.body?.innerText || '', + point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, + }; + })()` + ); +} + +async function dispatchDropdownClickFallback(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { + await cdp.evaluate( + contextId, + `(() => { + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const label = Array.from(document.querySelectorAll('label, span, div')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent).toLowerCase(); + return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); + }) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const dropdownId = label?.getAttribute('for'); + const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; + const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); + if (!(dropdown instanceof HTMLButtonElement)) { + return; + } + + dropdown.focus(); + dropdown.click(); + })()` + ); +} + +function withField(labels: FieldLabels, action: string): string { + return `(() => { + const labelsToFind = ${JSON.stringify(getLabels(labels))}; + const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); + const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); + const visibleInputs = Array.from(document.querySelectorAll('input')).filter(isVisible); + const inputByAttribute = visibleInputs + .filter((candidate) => { + const searchableText = [ + candidate.getAttribute('aria-label'), + candidate.getAttribute('placeholder'), + candidate.getAttribute('name'), + candidate.id, + ].map(normalize).join(' ').toLowerCase(); + return labelsToFind.some((expected) => searchableText.includes(expected.toLowerCase())); + }) + .sort((a, b) => normalize(a.getAttribute('placeholder') || a.getAttribute('aria-label') || a.id).length - normalize(b.getAttribute('placeholder') || b.getAttribute('aria-label') || b.id).length)[0]; + const visibleTextElements = Array.from(document.querySelectorAll('label, span, div, p')) + .filter(isVisible) + .filter((candidate) => { + const text = normalize(candidate.textContent); + return text.length > 0 && text.length < 160; + }); + const exactLabel = visibleTextElements + .filter((candidate) => labelsToFind.some((expected) => normalize(candidate.textContent).toLowerCase() === expected.toLowerCase())) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const partialLabel = visibleTextElements + .filter((candidate) => + labelsToFind.some((expected) => normalize(candidate.textContent).toLowerCase().includes(expected.toLowerCase())) + ) + .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; + const label = exactLabel || partialLabel; + if (!label && !inputByAttribute) { + return { ok: false, reason: 'Field label not found', text: document.body?.innerText || '' }; + } + + const inputId = label?.getAttribute('for'); + const fieldRoot = label?.closest('[class*="fui-Field"]') || label?.parentElement?.parentElement || label?.parentElement; + const labelRect = label?.getBoundingClientRect(); + const nearestInput = labelRect + ? visibleInputs + .map((candidate) => ({ candidate, rect: candidate.getBoundingClientRect() })) + .filter(({ rect }) => rect.bottom >= labelRect.top - 5) + .sort((a, b) => { + const scoreA = a.rect.top >= labelRect.bottom - 5 ? a.rect.top - labelRect.bottom : 0; + const scoreB = b.rect.top >= labelRect.bottom - 5 ? b.rect.top - labelRect.bottom : 0; + return scoreA - scoreB || a.rect.top - b.rect.top; + })[0]?.candidate + : undefined; + const fieldInputs = fieldRoot ? Array.from(fieldRoot.querySelectorAll('input')).filter(isVisible) : []; + const fieldInput = + fieldInputs.length === 1 + ? fieldInputs[0] + : labelRect + ? fieldInputs + .map((candidate) => ({ candidate, rect: candidate.getBoundingClientRect() })) + .filter(({ rect }) => rect.bottom >= labelRect.top - 5) + .sort((a, b) => { + const scoreA = a.rect.top >= labelRect.bottom - 5 ? a.rect.top - labelRect.bottom : 0; + const scoreB = b.rect.top >= labelRect.bottom - 5 ? b.rect.top - labelRect.bottom : 0; + return scoreA - scoreB || a.rect.top - b.rect.top; + })[0]?.candidate + : undefined; + const input = inputByAttribute || (inputId ? document.getElementById(inputId) : null) || fieldInput || nearestInput; + if (!(input instanceof HTMLInputElement)) { + return { ok: false, reason: 'Field input not found', text: document.body?.innerText || '', labelHtml: label?.outerHTML }; + } + + const field = input.closest('[class*="fui-Field"]') || input.parentElement; + const getValidationText = (inputElement, fieldElement) => { + const describedBy = inputElement.getAttribute('aria-describedby'); + const describedText = describedBy + ? describedBy + .split(/\\s+/) + .map((id) => document.getElementById(id)?.innerText || '') + .filter(Boolean) + .join('\\n') + : ''; + return [describedText, fieldElement?.innerText || ''].filter(Boolean).join('\\n'); + }; + const setInputValue = (inputElement, value) => { + inputElement.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(inputElement, value); + inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: value ? 'insertText' : 'deleteContentBackward', data: value })); + inputElement.dispatchEvent(new Event('change', { bubbles: true })); + inputElement.blur(); + }; + + ${action} + })()`; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts b/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts index 819018cfd5a..300d2c133f4 100644 --- a/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts +++ b/apps/vs-code-designer/src/test/e2e/createWorkspace.test.ts @@ -4,6 +4,28 @@ import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { connectToVsCodeCdp, waitForCreateWorkspaceFrameContext } from './cdpClient'; +import { + assertDropdownHasOptions, + assertNextButtonDisabled, + assertNextButtonEnabled, + assertWizardButtonDisabledOrAbsent, + clickPoint, + type CdpEvaluator, + enterFieldValue, + getFieldState, + getLabels, + getPageText, + isDropdownValueSelected, + isRadioOptionChecked, + type Point, + selectDropdownOption, + selectRadioOption, + waitForAsyncValidationToSettle, + waitForFieldHidden, + waitForFieldValidationMessage, + waitForFieldValidationMessageToClear, + waitForFieldVisible, +} from './cdpFormHelpers'; import { createWorkspaceCase, filterCreationCases, @@ -19,6 +41,7 @@ import { captureCliScreenshot } from './screenshot'; import { containsIgnoreCase, uniqueName } from './testUtils'; import { waitForVisibleDelay } from './visibleDelay'; import { closeWebviewTabs, getTabViewType, getWebviewTabs, waitForWebviewTab } from './webviewTabs'; +import { requiredValue, waitForPathExists } from './workspaceArtifacts'; const logicAppsExtensionId = 'ms-azuretools.vscode-azurelogicapps'; const createWorkspaceCommand = 'azureLogicAppsStandard.createWorkspace'; @@ -43,10 +66,6 @@ const dotnetBinaryPathSetting = '${config:azureLogicAppsStandard.dotnetBinaryPat const funcHostStartTaskLabel = 'func: host start'; const funcWatchProblemMatcher = '$func-watch'; -type CdpEvaluator = { - evaluate(contextId: number, expression: string): Promise; - send(method: string, params?: Record): Promise; -}; type CreateWorkspaceGroup = 'default' | 'behavior' | 'core-matrix' | 'preview-matrix' | 'codeful' | 'fixtures-manifest' | 'full'; interface FieldValidationCase { @@ -157,11 +176,6 @@ type TasksJson = { inputs?: unknown; }; -interface Point { - x: number; - y: number; -} - installDialogGuard(); suite('Create Workspace Experience Tests', () => { @@ -990,22 +1004,6 @@ async function waitForWorkspaceArtifacts(parentPath: string, creationCase: Works } } -async function waitForPathExists(filePath: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (fs.existsSync(filePath)) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - const parentPath = path.dirname(filePath); - const parentContents = fs.existsSync(parentPath) ? fs.readdirSync(parentPath) : ['(parent missing)']; - assert.fail(`Timed out waiting for generated path ${filePath}. Parent contents: ${JSON.stringify(parentContents)}`); -} - function verifyCreatedWorkspace(parentPath: string, creationCase: WorkspaceCreationCase): void { const workspaceDir = path.join(parentPath, creationCase.wsName); const workspaceFilePath = path.join(workspaceDir, `${creationCase.wsName}.code-workspace`); @@ -1924,11 +1922,6 @@ function requiredTask(tasks: TaskJson[], label: string, creationCase: WorkspaceC return task; } -function requiredValue(value: string | undefined): string { - assert.ok(value, 'Expected required workspace creation value to be defined'); - return value; -} - async function runNameFieldCases( cdp: CdpEvaluator, contextId: number, @@ -2020,837 +2013,3 @@ async function runThreeRequiredFieldGatingCases( await enterFieldValue(cdp, contextId, fields.third.labels, uniqueName(fields.third.validValue)); await assertNextButtonEnabled(cdp, contextId, `${name}: all valid`); } - -async function enterFieldValue(cdp: CdpEvaluator, contextId: number, labels: FieldLabels, value: string): Promise { - const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; value?: string }>( - contextId, - withField( - labels, - `input.focus(); - input.select(); - return { ok: true, value: input.value };` - ) - ); - - assert.strictEqual( - focusResult.ok, - true, - focusResult.reason ?? `Failed to focus ${getLabels(labels).join('/')} field. Text: ${focusResult.text ?? ''}` - ); - - try { - await replaceFocusedInputText(cdp, value); - } catch { - await cdp.evaluate( - contextId, - withField( - labels, - `setInputValue(input, ${JSON.stringify(value)}); - return { ok: true, value: input.value };` - ) - ); - } - - const result = await waitForFieldValue(cdp, contextId, labels, value); - assert.strictEqual( - result.value, - value, - `Expected field "${getLabels(labels).join('/')}" to equal "${value}". State: ${JSON.stringify(result)}` - ); -} - -async function waitForFieldVisible(cdp: CdpEvaluator, contextId: number, labels: FieldLabels): Promise { - const deadline = Date.now() + 10000; - while (Date.now() < deadline) { - const result = await getFieldState(cdp, contextId, labels).catch(() => undefined); - if (result?.ok) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - const text = await getPageText(cdp, contextId); - assert.fail(`Timed out waiting for field "${getLabels(labels).join('/')}" to be visible. Webview text: ${text}`); -} - -async function waitForFieldHidden(cdp: CdpEvaluator, contextId: number, labels: FieldLabels): Promise { - const deadline = Date.now() + 10000; - while (Date.now() < deadline) { - const result = await getFieldState(cdp, contextId, labels).catch(() => undefined); - if (!result?.ok) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - const result = await getFieldState(cdp, contextId, labels).catch((error) => ({ text: String(error) })); - assert.fail(`Expected field "${getLabels(labels).join('/')}" to be hidden. State: ${JSON.stringify(result)}`); -} - -async function waitForFieldValidationMessage( - cdp: CdpEvaluator, - contextId: number, - labels: FieldLabels, - expectedMessage: string -): Promise { - const deadline = Date.now() + (expectedMessage === 'not exist' ? 45000 : 10000); - while (Date.now() < deadline) { - const result = await getFieldState(cdp, contextId, labels); - const fieldText = `${result.fieldText ?? ''}\n${result.validationText ?? ''}`; - if (containsIgnoreCase(fieldText, expectedMessage)) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - const finalState = await getFieldState(cdp, contextId, labels).catch((error) => ({ text: String(error) })); - assert.fail( - `Timed out waiting for validation message "${expectedMessage}" on field "${getLabels(labels).join('/')}". State: ${JSON.stringify(finalState)}` - ); -} - -async function waitForFieldValidationMessageToClear( - cdp: CdpEvaluator, - contextId: number, - labels: FieldLabels, - message: string -): Promise { - const deadline = Date.now() + 10000; - while (Date.now() < deadline) { - const result = await getFieldState(cdp, contextId, labels); - const fieldText = `${result.fieldText ?? ''}\n${result.validationText ?? ''}`; - if (!containsIgnoreCase(fieldText, message)) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - const result = await getFieldState(cdp, contextId, labels).catch((error) => ({ text: String(error) })); - assert.fail( - `Timed out waiting for validation message "${message}" to clear on field "${getLabels(labels).join('/')}". State: ${JSON.stringify(result)}` - ); -} - -async function waitForAsyncValidationToSettle(cdp: CdpEvaluator, contextId: number): Promise { - const pendingMessages = ['Validating path', 'Checking workspace availability']; - const deadline = Date.now() + 15000; - while (Date.now() < deadline) { - const pageText = await getPageText(cdp, contextId); - if (!pendingMessages.some((message) => containsIgnoreCase(pageText, message))) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - const pageText = await getPageText(cdp, contextId); - assert.fail(`Timed out waiting for async Create Workspace validation to settle. Webview text: ${pageText}`); -} - -async function assertNextButtonDisabled(cdp: CdpEvaluator, contextId: number, context: string): Promise { - const deadline = Date.now() + 8000; - while (Date.now() < deadline) { - const result = await getNextButtonState(cdp, contextId); - if (result.found && result.disabled) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - const result = await getNextButtonState(cdp, contextId); - assert.fail(`Expected Next button to be disabled for ${context}. State: ${JSON.stringify(result)}`); -} - -async function assertNextButtonEnabled(cdp: CdpEvaluator, contextId: number, context: string): Promise { - const deadline = Date.now() + 30000; - while (Date.now() < deadline) { - const result = await getNextButtonState(cdp, contextId); - if (result.found && !result.disabled) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - const result = await getNextButtonState(cdp, contextId); - assert.fail(`Expected Next button to be enabled for ${context}. State: ${JSON.stringify(result)}`); -} - -async function assertWizardButtonDisabledOrAbsent( - cdp: CdpEvaluator, - contextId: number, - buttonText: string, - context: string -): Promise { - const result = await getWizardButtonState(cdp, contextId, buttonText); - assert.ok( - !result.found || result.disabled, - `Expected ${buttonText} button to be disabled or absent for ${context}. State: ${JSON.stringify(result)}` - ); -} - -async function assertDropdownHasOptions(cdp: CdpEvaluator, contextId: number, labelText: string, expectedOptions: string[]): Promise { - const focusResult = await getDropdownClickPoint(cdp, contextId, labelText); - assert.strictEqual(focusResult.ok, true, focusResult.reason ?? `Failed to find "${labelText}" dropdown. Text: ${focusResult.text ?? ''}`); - assert.ok(focusResult.point, `Failed to locate "${labelText}" dropdown click point.`); - - await clickPoint(cdp, focusResult.point); - if (!(await hasDropdownOptions(cdp, contextId))) { - await dispatchDropdownClickFallback(cdp, contextId, labelText); - } - if (!(await hasDropdownOptions(cdp, contextId))) { - await pressKey(cdp, 'Enter', undefined, 13); - } - if (!(await hasDropdownOptions(cdp, contextId))) { - await pressKey(cdp, 'Space', ' ', 32); - } - await waitForDropdownOptions(cdp, contextId); - const options = await getVisibleDropdownOptions(cdp, contextId); - for (const expectedOption of expectedOptions) { - assert.ok( - options.some((option) => option === expectedOption), - `Expected "${labelText}" dropdown to include "${expectedOption}". Options: ${JSON.stringify(options)}` - ); - } - await pressKey(cdp, 'Escape', 'Escape', 27); -} - -async function selectRadioOption(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { - const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; point?: Point }>( - contextId, - `(() => { - const expected = ${JSON.stringify(labelText)}; - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent); - return text.length > 0 && text.length < 180 && text.includes(expected); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - if (!label) { - return { ok: false, reason: 'Radio label not found', text: document.body?.innerText || '' }; - } - - const radioRoot = label.closest('[role="radio"], .fui-Radio') || label; - const input = radioRoot.querySelector('input[type="radio"]'); - if (!(input instanceof HTMLInputElement)) { - return { ok: false, reason: 'Radio input not found', text: radioRoot.outerHTML }; - } - - const clickable = radioRoot instanceof HTMLElement ? radioRoot : input; - clickable.scrollIntoView({ block: 'center', inline: 'center' }); - input.focus(); - const rect = clickable.getBoundingClientRect(); - return { - ok: true, - text: radioRoot.outerHTML, - point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, - }; - })()` - ); - - assert.strictEqual( - focusResult.ok, - true, - focusResult.reason ?? `Failed to focus radio option "${labelText}". Text: ${focusResult.text ?? ''}` - ); - assert.ok(focusResult.point, `Failed to locate radio option "${labelText}" click point.`); - await clickPoint(cdp, focusResult.point); - if (!(await isRadioOptionChecked(cdp, contextId, labelText))) { - await dispatchRadioClickFallback(cdp, contextId, labelText); - } - if (!(await isRadioOptionChecked(cdp, contextId, labelText))) { - await pressKey(cdp, 'Space', ' ', 32); - } - await waitForRadioOptionChecked(cdp, contextId, labelText); -} - -async function dispatchRadioClickFallback(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { - await cdp.evaluate( - contextId, - `(() => { - const expected = ${JSON.stringify(labelText)}; - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent); - return text.length > 0 && text.length < 180 && text.includes(expected); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; - const input = radioRoot?.querySelector('input[type="radio"]'); - if (!(input instanceof HTMLInputElement)) { - return; - } - - input.focus(); - input.click(); - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - })()` - ); -} - -async function selectDropdownOption(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { - if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { - return; - } - - const focusResult = await getDropdownClickPoint(cdp, contextId, labelText); - - assert.strictEqual( - focusResult.ok, - true, - focusResult.reason ?? `Failed to focus "${labelText}" dropdown. Text: ${focusResult.text ?? ''}` - ); - assert.ok(focusResult.point, `Failed to locate "${labelText}" dropdown click point.`); - await clickPoint(cdp, focusResult.point); - await new Promise((resolve) => setTimeout(resolve, 500)); - if (!(await hasDropdownOptions(cdp, contextId))) { - await dispatchDropdownClickFallback(cdp, contextId, labelText); - await new Promise((resolve) => setTimeout(resolve, 500)); - } - if (!(await hasDropdownOptions(cdp, contextId))) { - await pressKey(cdp, 'Enter', undefined, 13); - await new Promise((resolve) => setTimeout(resolve, 500)); - } - if (!(await hasDropdownOptions(cdp, contextId))) { - await pressKey(cdp, 'Space', ' ', 32); - await new Promise((resolve) => setTimeout(resolve, 500)); - } - if (!(await hasDropdownOptions(cdp, contextId))) { - await pressKey(cdp, 'ArrowDown', 'ArrowDown', 40); - await new Promise((resolve) => setTimeout(resolve, 250)); - await pressKey(cdp, 'Enter', undefined, 13); - await new Promise((resolve) => setTimeout(resolve, 500)); - if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { - return; - } - } - - const optionResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; options?: string[]; optionIndex?: number }>( - contextId, - `(() => { - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const options = Array.from(document.querySelectorAll('[role="option"]')).filter(isVisible); - const option = options.find((candidate) => normalize(candidate.textContent) === ${JSON.stringify(optionText)}); - if (!(option instanceof HTMLElement)) { - return { - ok: false, - reason: 'Dropdown option not found', - options: options.map((candidate) => normalize(candidate.textContent)), - text: document.body?.innerText || '', - }; - } - - return { ok: true, optionIndex: options.indexOf(option) }; - })()` - ); - - assert.strictEqual( - optionResult.ok, - true, - `Failed to select "${optionText}" from "${labelText}". Reason: ${optionResult.reason ?? 'unknown'}. Options: ${JSON.stringify( - optionResult.options - )}. Text: ${optionResult.text ?? ''}` - ); - for (let index = 0; index < (optionResult.optionIndex ?? 0); index++) { - await pressKey(cdp, 'ArrowDown', 'ArrowDown', 40); - } - await pressKey(cdp, 'Enter', undefined, 13); - await waitForDropdownValue(cdp, contextId, labelText, optionText); -} - -async function getDropdownClickPoint( - cdp: CdpEvaluator, - contextId: number, - labelText: string -): Promise<{ ok: boolean; reason?: string; text?: string; point?: Point }> { - return cdp.evaluate<{ ok: boolean; reason?: string; text?: string; point?: Point }>( - contextId, - `(() => { - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent).toLowerCase(); - return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - if (!label) { - return { ok: false, reason: 'Dropdown label not found', text: document.body?.innerText || '' }; - } - const dropdownId = label?.getAttribute('for'); - const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; - const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); - if (!(dropdown instanceof HTMLButtonElement)) { - return { ok: false, reason: 'Dropdown button not found', text: document.body?.innerText || '' }; - } - - dropdown.scrollIntoView({ block: 'center', inline: 'center' }); - dropdown.focus(); - const rect = dropdown.getBoundingClientRect(); - return { - ok: true, - text: document.body?.innerText || '', - point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }, - }; - })()` - ); -} - -async function dispatchDropdownClickFallback(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { - await cdp.evaluate( - contextId, - `(() => { - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent).toLowerCase(); - return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const dropdownId = label?.getAttribute('for'); - const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; - const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); - if (!(dropdown instanceof HTMLButtonElement)) { - return; - } - - dropdown.focus(); - dropdown.click(); - })()` - ); -} - -async function waitForDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - if (await hasDropdownOptions(cdp, contextId)) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - const pageText = await getPageText(cdp, contextId); - assert.fail(`Timed out waiting for dropdown options. Text: ${pageText}`); -} - -async function getVisibleDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { - return cdp.evaluate( - contextId, - `(() => { - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - return Array.from(document.querySelectorAll('[role="option"]')).filter(isVisible).map((option) => normalize(option.textContent)); - })()` - ); -} - -async function hasDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { - return cdp.evaluate( - contextId, - `(() => { - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - return Array.from(document.querySelectorAll('[role="option"]')).some(isVisible); - })()` - ); -} - -async function waitForRadioOptionChecked(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - const result = await cdp.evaluate<{ checked: boolean; text?: string }>( - contextId, - `(() => { - const expected = ${JSON.stringify(labelText)}; - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent); - return text.length > 0 && text.length < 180 && text.includes(expected); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; - const input = radioRoot?.querySelector('input[type="radio"]'); - return { checked: input instanceof HTMLInputElement ? input.checked : false, text: radioRoot?.outerHTML || document.body?.innerText || '' }; - })()` - ); - if (result.checked) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - const result = await getNextButtonState(cdp, contextId); - assert.fail(`Expected radio option "${labelText}" to be checked. State: ${JSON.stringify(result)}`); -} - -async function isRadioOptionChecked(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { - const result = await cdp.evaluate<{ checked: boolean }>( - contextId, - `(() => { - const expected = ${JSON.stringify(labelText)}; - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent); - return text.length > 0 && text.length < 180 && text.includes(expected); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; - const input = radioRoot?.querySelector('input[type="radio"]'); - return { checked: input instanceof HTMLInputElement ? input.checked : false }; - })()` - ); - return result.checked; -} - -async function waitForDropdownValue(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { - return; - } - - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - const result = await getNextButtonState(cdp, contextId); - assert.fail(`Expected dropdown "${labelText}" to select "${optionText}". State: ${JSON.stringify(result)}`); -} - -async function isDropdownValueSelected(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { - const result = await cdp.evaluate<{ selected: boolean }>( - contextId, - `(() => { - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter((candidate) => { - const text = normalize(candidate.textContent).toLowerCase(); - return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const dropdownId = label?.getAttribute('for'); - const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; - const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); - const text = dropdown?.textContent || ''; - return { selected: normalize(text).includes(${JSON.stringify(optionText)}) }; - })()` - ); - return result.selected; -} - -async function pressKey(cdp: CdpEvaluator, code: string, key?: string, windowsVirtualKeyCode?: number): Promise { - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyDown', - key: key ?? code, - code, - windowsVirtualKeyCode, - nativeVirtualKeyCode: windowsVirtualKeyCode, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyUp', - key: key ?? code, - code, - windowsVirtualKeyCode, - nativeVirtualKeyCode: windowsVirtualKeyCode, - }); -} - -async function clickPoint(cdp: CdpEvaluator, point: Point): Promise { - await cdp.send('Input.dispatchMouseEvent', { - type: 'mouseMoved', - x: point.x, - y: point.y, - button: 'none', - }); - await cdp.send('Input.dispatchMouseEvent', { - type: 'mousePressed', - x: point.x, - y: point.y, - button: 'left', - buttons: 1, - clickCount: 1, - }); - await cdp.send('Input.dispatchMouseEvent', { - type: 'mouseReleased', - x: point.x, - y: point.y, - button: 'left', - buttons: 0, - clickCount: 1, - }); -} - -async function getFieldState( - cdp: CdpEvaluator, - contextId: number, - labels: FieldLabels -): Promise<{ - ok: boolean; - reason?: string; - value?: string; - fieldText?: string; - validationText?: string; - pageText?: string; - ariaInvalid?: string | null; - describedBy?: string | null; -}> { - return cdp.evaluate( - contextId, - withField( - labels, - `return { - ok: true, - value: input.value, - fieldText: field?.innerText || '', - validationText: getValidationText(input, field), - pageText: document.body?.innerText || '', - ariaInvalid: input.getAttribute('aria-invalid'), - describedBy: input.getAttribute('aria-describedby'), - };` - ) - ); -} - -async function waitForFieldValue( - cdp: CdpEvaluator, - contextId: number, - labels: FieldLabels, - expectedValue: string -): Promise<{ ok: boolean; value?: string; fieldText?: string; pageText?: string }> { - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - const result = await getFieldState(cdp, contextId, labels); - if (result.value === expectedValue) { - return result; - } - - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - return getFieldState(cdp, contextId, labels); -} - -async function replaceFocusedInputText(cdp: CdpEvaluator, value: string): Promise { - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyDown', - key: 'Control', - code: 'ControlLeft', - windowsVirtualKeyCode: 17, - nativeVirtualKeyCode: 17, - modifiers: 2, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyDown', - key: 'a', - code: 'KeyA', - windowsVirtualKeyCode: 65, - nativeVirtualKeyCode: 65, - modifiers: 2, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyUp', - key: 'a', - code: 'KeyA', - windowsVirtualKeyCode: 65, - nativeVirtualKeyCode: 65, - modifiers: 2, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyUp', - key: 'Control', - code: 'ControlLeft', - windowsVirtualKeyCode: 17, - nativeVirtualKeyCode: 17, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyDown', - key: 'Backspace', - code: 'Backspace', - windowsVirtualKeyCode: 8, - nativeVirtualKeyCode: 8, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyUp', - key: 'Backspace', - code: 'Backspace', - windowsVirtualKeyCode: 8, - nativeVirtualKeyCode: 8, - }); - - if (value) { - await cdp.send('Input.insertText', { text: value }); - } -} - -async function getNextButtonState( - cdp: CdpEvaluator, - contextId: number -): Promise<{ found: boolean; disabled?: boolean; text?: string; pageText?: string; fieldValues?: unknown[] }> { - return getWizardButtonState(cdp, contextId, 'Next'); -} - -async function getWizardButtonState( - cdp: CdpEvaluator, - contextId: number, - buttonText: string -): Promise<{ found: boolean; disabled?: boolean; text?: string; pageText?: string; fieldValues?: unknown[] }> { - return cdp.evaluate( - contextId, - `(() => { - const expectedButtonText = ${JSON.stringify(buttonText)}; - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const buttons = Array.from(document.querySelectorAll('button')).filter(isVisible); - const button = buttons.find((candidate) => (candidate.textContent || '').includes(expectedButtonText)); - const invalidFields = Array.from(document.querySelectorAll('input[aria-invalid="true"]')).map((input) => { - const label = input.id ? document.querySelector('label[for="' + CSS.escape(input.id) + '"]') : null; - const field = input.closest('[class*="fui-Field"]') || input.parentElement; - return { - label: label?.textContent || '', - value: input instanceof HTMLInputElement ? input.value : '', - text: field?.innerText || '', - }; - }); - const fieldValues = Array.from(document.querySelectorAll('input')).filter(isVisible).map((input) => { - const label = input.id ? document.querySelector('label[for="' + CSS.escape(input.id) + '"]') : null; - const field = input.closest('[class*="fui-Field"]') || input.parentElement; - return { - label: label?.textContent || '', - type: input instanceof HTMLInputElement ? input.type : '', - value: input instanceof HTMLInputElement ? input.value : '', - checked: input instanceof HTMLInputElement ? input.checked : undefined, - text: field?.innerText || '', - }; - }); - const pageText = document.body?.innerText || ''; - if (!button) { - return { found: false, text: pageText, pageText, invalidFields, fieldValues }; - } - - const disabled = button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true'; - return { found: true, disabled, text: button.textContent || '', pageText, invalidFields, fieldValues }; - })()` - ); -} - -async function getPageText(cdp: CdpEvaluator, contextId: number): Promise { - return cdp.evaluate(contextId, 'document.body?.innerText || ""').catch((error) => String(error)); -} - -function withField(labels: FieldLabels, action: string): string { - return `(() => { - const labelsToFind = ${JSON.stringify(getLabels(labels))}; - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const visibleInputs = Array.from(document.querySelectorAll('input')).filter(isVisible); - const inputByAttribute = visibleInputs - .filter((candidate) => { - const searchableText = [ - candidate.getAttribute('aria-label'), - candidate.getAttribute('placeholder'), - candidate.getAttribute('name'), - candidate.id, - ].map(normalize).join(' ').toLowerCase(); - return labelsToFind.some((expected) => searchableText.includes(expected.toLowerCase())); - }) - .sort((a, b) => normalize(a.getAttribute('placeholder') || a.getAttribute('aria-label') || a.id).length - normalize(b.getAttribute('placeholder') || b.getAttribute('aria-label') || b.id).length)[0]; - const visibleTextElements = Array.from(document.querySelectorAll('label, span, div, p')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent); - return text.length > 0 && text.length < 160; - }); - const exactLabel = visibleTextElements - .filter((candidate) => labelsToFind.some((expected) => normalize(candidate.textContent).toLowerCase() === expected.toLowerCase())) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const partialLabel = visibleTextElements - .filter((candidate) => - labelsToFind.some((expected) => normalize(candidate.textContent).toLowerCase().includes(expected.toLowerCase())) - ) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const label = exactLabel || partialLabel; - if (!label && !inputByAttribute) { - return { ok: false, reason: 'Field label not found', text: document.body?.innerText || '' }; - } - - const inputId = label?.getAttribute('for'); - const fieldRoot = label?.closest('[class*="fui-Field"]') || label?.parentElement?.parentElement || label?.parentElement; - const labelRect = label?.getBoundingClientRect(); - const nearestInput = labelRect - ? visibleInputs - .map((candidate) => ({ candidate, rect: candidate.getBoundingClientRect() })) - .filter(({ rect }) => rect.bottom >= labelRect.top - 5) - .sort((a, b) => { - const scoreA = a.rect.top >= labelRect.bottom - 5 ? a.rect.top - labelRect.bottom : 0; - const scoreB = b.rect.top >= labelRect.bottom - 5 ? b.rect.top - labelRect.bottom : 0; - return scoreA - scoreB || a.rect.top - b.rect.top; - })[0]?.candidate - : undefined; - const fieldInputs = fieldRoot ? Array.from(fieldRoot.querySelectorAll('input')).filter(isVisible) : []; - const fieldInput = - fieldInputs.length === 1 - ? fieldInputs[0] - : labelRect - ? fieldInputs - .map((candidate) => ({ candidate, rect: candidate.getBoundingClientRect() })) - .filter(({ rect }) => rect.bottom >= labelRect.top - 5) - .sort((a, b) => { - const scoreA = a.rect.top >= labelRect.bottom - 5 ? a.rect.top - labelRect.bottom : 0; - const scoreB = b.rect.top >= labelRect.bottom - 5 ? b.rect.top - labelRect.bottom : 0; - return scoreA - scoreB || a.rect.top - b.rect.top; - })[0]?.candidate - : undefined; - const input = inputByAttribute || (inputId ? document.getElementById(inputId) : null) || fieldInput || nearestInput; - if (!(input instanceof HTMLInputElement)) { - return { ok: false, reason: 'Field input not found', text: document.body?.innerText || '', labelHtml: label?.outerHTML }; - } - - const field = input.closest('[class*="fui-Field"]') || input.parentElement; - const getValidationText = (inputElement, fieldElement) => { - const describedBy = inputElement.getAttribute('aria-describedby'); - const describedText = describedBy - ? describedBy - .split(/\\s+/) - .map((id) => document.getElementById(id)?.innerText || '') - .filter(Boolean) - .join('\\n') - : ''; - return [describedText, fieldElement?.innerText || ''].filter(Boolean).join('\\n'); - }; - const setInputValue = (inputElement, value) => { - inputElement.focus(); - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; - setter?.call(inputElement, value); - inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: value ? 'insertText' : 'deleteContentBackward', data: value })); - inputElement.dispatchEvent(new Event('change', { bubbles: true })); - inputElement.blur(); - }; - - ${action} - })()`; -} - -function getLabels(labels: FieldLabels): string[] { - return Array.isArray(labels) ? labels : [labels]; -} diff --git a/apps/vs-code-designer/src/test/e2e/workspaceArtifacts.ts b/apps/vs-code-designer/src/test/e2e/workspaceArtifacts.ts new file mode 100644 index 00000000000..12c46715ce9 --- /dev/null +++ b/apps/vs-code-designer/src/test/e2e/workspaceArtifacts.ts @@ -0,0 +1,28 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +export function hasCsproj(folderPath: string): boolean { + return fs.existsSync(folderPath) && fs.readdirSync(folderPath).some((entry) => entry.endsWith('.csproj')); +} + +export function requiredValue(value: string | undefined): string { + assert.ok(value, 'Expected required workspace creation value to be defined'); + return value; +} + +export async function waitForPathExists(filePath: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (fs.existsSync(filePath)) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + const parentPath = path.dirname(filePath); + const parentContents = fs.existsSync(parentPath) ? fs.readdirSync(parentPath) : ['(parent missing)']; + assert.fail(`Timed out waiting for generated path ${filePath}. Parent contents: ${JSON.stringify(parentContents)}`); +} diff --git a/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts b/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts index 242fe6257a2..e646fc7952e 100644 --- a/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts +++ b/apps/vs-code-designer/src/test/e2e/workspaceLifecycle.test.ts @@ -11,12 +11,28 @@ import { waitForCreateWorkspaceFrameContext, waitForWebviewFrameContext, } from './cdpClient'; +import { + assertNextButtonEnabled, + clickPoint, + type CdpEvaluator, + enterFieldValue, + getFieldState, + getLabels, + getPageText, + isDropdownValueSelected, + isRadioOptionChecked, + selectDropdownOption, + selectRadioOption, + waitForAsyncValidationToSettle, + waitForFieldVisible, +} from './cdpFormHelpers'; import type { FieldLabels } from './createWorkspaceTypes'; import { assertNoDialogAttempts, installDialogGuard } from './dialogGuard'; import { captureCdpScreenshot } from './screenshot'; import { containsIgnoreCase, normalizeFsPath, uniqueName } from './testUtils'; import { waitForVisibleDelay } from './visibleDelay'; import { closeAllTabs, closeWebviewTabs, describeOpenTabs, getTabViewType, getWebviewTabs, waitForWebviewTab } from './webviewTabs'; +import { hasCsproj, requiredValue } from './workspaceArtifacts'; const logicAppsExtensionId = 'ms-azuretools.vscode-azurelogicapps'; const createWorkspaceCommand = 'azureLogicAppsStandard.createWorkspace'; @@ -35,10 +51,6 @@ const requestTriggerTitle = 'When a HTTP request is received'; const responseActionTitle = 'Response'; const azuritePorts = [10000, 10001, 10002]; -type CdpEvaluator = { - evaluate(contextId: number | undefined, expression: string): Promise; - send(method: string, params?: Record): Promise; -}; type WorkspaceAppType = 'standard' | 'customCode' | 'rulesEngine'; interface WorkspaceCreationCase { @@ -1062,10 +1074,6 @@ function getCustomCodeProjectPaths(createdWorkspace: CreatedWorkspace): string[] return createdWorkspace.folderPaths.filter((folderPath) => folderPath !== createdWorkspace.appDir && hasCsproj(folderPath)); } -function hasCsproj(folderPath: string): boolean { - return fs.existsSync(folderPath) && fs.readdirSync(folderPath).some((entry) => entry.endsWith('.csproj')); -} - function getCustomCodeDiagnosticFiles(appDir: string): string[] { const customCodePath = path.join(appDir, 'lib', 'custom'); if (!fs.existsSync(customCodePath)) { @@ -1866,227 +1874,6 @@ function verifyCreatedWorkspace(parentPath: string, creationCase: WorkspaceCreat }; } -function requiredValue(value: string | undefined): string { - assert.ok(value, 'Expected required workspace creation value to be defined'); - return value; -} - -async function enterFieldValue(cdp: CdpEvaluator, contextId: number, labels: FieldLabels, value: string): Promise { - await waitForFieldVisible(cdp, contextId, labels); - const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string }>( - contextId, - withField( - labels, - `input.focus(); - input.select(); - return { ok: true };` - ) - ); - - assert.strictEqual( - focusResult.ok, - true, - `Failed to focus ${getLabels(labels).join('/')} field. ${focusResult.reason ?? ''} Text: ${focusResult.text ?? ''}` - ); - - try { - await replaceFocusedInputText(cdp, value); - } catch { - await cdp.evaluate( - contextId, - withField( - labels, - `setInputValue(input, ${JSON.stringify(value)}); - return { ok: true };` - ) - ); - } - - await waitUntil( - async () => (await getFieldState(cdp, contextId, labels)).value === value, - 5000, - `${getLabels(labels).join('/')} to equal ${value}` - ); -} - -async function waitForFieldVisible(cdp: CdpEvaluator, contextId: number, labels: FieldLabels): Promise { - await waitUntil( - async () => { - const result = await getFieldState(cdp, contextId, labels).catch(() => undefined); - return !!result?.ok; - }, - 10000, - `field "${getLabels(labels).join('/')}" to be visible` - ); -} - -async function waitForAsyncValidationToSettle(cdp: CdpEvaluator, contextId: number): Promise { - const pendingMessages = ['Validating path', 'Checking workspace availability']; - await waitUntil( - async () => { - const pageText = await getPageText(cdp, contextId); - return !pendingMessages.some((message) => containsIgnoreCase(pageText, message)); - }, - 15000, - 'Create Workspace async validation to settle' - ); -} - -async function assertNextButtonEnabled(cdp: CdpEvaluator, contextId: number, context: string): Promise { - let lastState: { found: boolean; disabled?: boolean; text?: string } | undefined; - let lastError: unknown; - for (let attempt = 0; attempt < 60; attempt++) { - try { - lastState = await getNextButtonState(cdp, contextId); - if (lastState.found && !lastState.disabled) { - return; - } - } catch (error) { - lastError = error; - } - - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - assert.fail( - `Timed out waiting for Next button to be enabled for ${context}. Last state: ${JSON.stringify(lastState)}${ - lastError ? `. Last error: ${String(lastError)}` : '' - }` - ); -} - -async function selectRadioOption(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { - const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string }>( - contextId, - `(() => { - const expected = ${JSON.stringify(labelText)}; - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent); - return text.length > 0 && text.length < 180 && text.includes(expected); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - if (!label) { - return { ok: false, reason: 'Radio label not found', text: document.body?.innerText || '' }; - } - - const radioRoot = label.closest('[role="radio"], .fui-Radio') || label; - const input = radioRoot.querySelector('input[type="radio"]'); - if (!(input instanceof HTMLInputElement)) { - return { ok: false, reason: 'Radio input not found', text: radioRoot.outerHTML }; - } - - input.focus(); - return { ok: document.activeElement === input, reason: document.activeElement === input ? undefined : 'Radio input did not receive focus', text: radioRoot.outerHTML }; - })()` - ); - - assert.strictEqual( - focusResult.ok, - true, - focusResult.reason ?? `Failed to focus radio option "${labelText}". Text: ${focusResult.text ?? ''}` - ); - await pressKey(cdp, 'Space', ' ', 32); - await waitUntil(() => isRadioOptionChecked(cdp, contextId, labelText), 5000, `radio option "${labelText}" to be checked`); -} - -async function selectDropdownOption(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { - if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { - return; - } - - const focusResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; point?: { x: number; y: number } }>( - contextId, - `(() => { - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent).toLowerCase(); - return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - if (!label) { - return { ok: false, reason: 'Dropdown label not found', text: document.body?.innerText || '' }; - } - - const dropdownId = label.getAttribute('for'); - const field = label.closest('[class*="fui-Field"]') || label.parentElement; - const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); - if (!(dropdown instanceof HTMLButtonElement)) { - return { ok: false, reason: 'Dropdown button not found', text: document.body?.innerText || '' }; - } - - dropdown.scrollIntoView({ block: 'center', inline: 'center' }); - dropdown.focus(); - const rect = dropdown.getBoundingClientRect(); - return { ok: true, point: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } }; - })()` - ); - - assert.strictEqual( - focusResult.ok, - true, - focusResult.reason ?? `Failed to focus "${labelText}" dropdown. Text: ${focusResult.text ?? ''}` - ); - assert.ok(focusResult.point, `Failed to locate "${labelText}" dropdown click point.`); - await clickPoint(cdp, focusResult.point); - if (!(await waitForDropdownOptions(cdp, contextId, 1000))) { - await pressKey(cdp, 'Enter', undefined, 13); - } - if (!(await waitForDropdownOptions(cdp, contextId, 1000))) { - await pressKey(cdp, 'Space', ' ', 32); - } - if (!(await waitForDropdownOptions(cdp, contextId, 1000))) { - await pressKey(cdp, 'ArrowDown', 'ArrowDown', 40); - await pressKey(cdp, 'Enter', undefined, 13); - if (await isDropdownValueSelected(cdp, contextId, labelText, optionText)) { - return; - } - } - - const optionResult = await cdp.evaluate<{ ok: boolean; reason?: string; text?: string; options?: string[]; optionIndex?: number }>( - contextId, - `(() => { - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const options = Array.from(document.querySelectorAll('[role="option"]')).filter(isVisible); - const option = options.find((candidate) => normalize(candidate.textContent) === ${JSON.stringify(optionText)}); - if (!(option instanceof HTMLElement)) { - return { - ok: false, - reason: 'Dropdown option not found', - options: options.map((candidate) => normalize(candidate.textContent)), - text: document.body?.innerText || '', - }; - } - - return { ok: true, optionIndex: options.indexOf(option) }; - })()` - ); - - assert.strictEqual( - optionResult.ok, - true, - `Failed to select "${optionText}" from "${labelText}". Reason: ${optionResult.reason ?? 'unknown'}. Options: ${JSON.stringify( - optionResult.options - )}. Text: ${optionResult.text ?? ''}` - ); - for (let index = 0; index < (optionResult.optionIndex ?? 0); index++) { - await pressKey(cdp, 'ArrowDown', 'ArrowDown', 40); - } - await pressKey(cdp, 'Enter', undefined, 13); - await waitUntil( - () => isDropdownValueSelected(cdp, contextId, labelText, optionText), - 5000, - `"${labelText}" dropdown to select "${optionText}"` - ); -} - async function handleDesignerQuickPickPrompts(timeoutMs = 20000): Promise { await handleWorkbenchPrompts( [ @@ -2271,287 +2058,6 @@ async function captureLifecycleScreenshot(name: string): Promise { } } -async function hasDropdownOptions(cdp: CdpEvaluator, contextId: number): Promise { - return cdp.evaluate( - contextId, - `(() => { - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - return Array.from(document.querySelectorAll('[role="option"]')).some(isVisible); - })()` - ); -} - -async function waitForDropdownOptions(cdp: CdpEvaluator, contextId: number, timeoutMs: number): Promise { - try { - await waitUntil(() => hasDropdownOptions(cdp, contextId), timeoutMs, 'dropdown options to become visible'); - return true; - } catch { - return false; - } -} - -async function isRadioOptionChecked(cdp: CdpEvaluator, contextId: number, labelText: string): Promise { - const result = await cdp.evaluate<{ checked: boolean }>( - contextId, - `(() => { - const expected = ${JSON.stringify(labelText)}; - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent); - return text.length > 0 && text.length < 180 && text.includes(expected); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const radioRoot = label?.closest('[role="radio"], .fui-Radio') || label; - const input = radioRoot?.querySelector('input[type="radio"]'); - return { checked: input instanceof HTMLInputElement ? input.checked : false }; - })()` - ); - return result.checked; -} - -async function isDropdownValueSelected(cdp: CdpEvaluator, contextId: number, labelText: string, optionText: string): Promise { - const result = await cdp.evaluate<{ selected: boolean }>( - contextId, - `(() => { - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const label = Array.from(document.querySelectorAll('label, span, div')) - .filter((candidate) => { - const text = normalize(candidate.textContent).toLowerCase(); - return text.length > 0 && text.length < 160 && text.includes(${JSON.stringify(labelText.toLowerCase())}); - }) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const dropdownId = label?.getAttribute('for'); - const field = label?.closest('[class*="fui-Field"]') || label?.parentElement; - const dropdown = (dropdownId ? document.getElementById(dropdownId) : null) || field?.querySelector('button[role="combobox"]'); - const text = dropdown?.textContent || ''; - return { selected: normalize(text).includes(${JSON.stringify(optionText)}) }; - })()` - ); - return result.selected; -} - -async function pressKey(cdp: CdpEvaluator, code: string, key?: string, windowsVirtualKeyCode?: number): Promise { - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyDown', - key: key ?? code, - code, - windowsVirtualKeyCode, - nativeVirtualKeyCode: windowsVirtualKeyCode, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyUp', - key: key ?? code, - code, - windowsVirtualKeyCode, - nativeVirtualKeyCode: windowsVirtualKeyCode, - }); -} - -async function clickPoint(cdp: CdpEvaluator, point: { x: number; y: number }): Promise { - await cdp.send('Input.dispatchMouseEvent', { - type: 'mouseMoved', - x: point.x, - y: point.y, - button: 'none', - }); - await cdp.send('Input.dispatchMouseEvent', { - type: 'mousePressed', - x: point.x, - y: point.y, - button: 'left', - buttons: 1, - clickCount: 1, - }); - await cdp.send('Input.dispatchMouseEvent', { - type: 'mouseReleased', - x: point.x, - y: point.y, - button: 'left', - buttons: 0, - clickCount: 1, - }); -} - -async function replaceFocusedInputText(cdp: CdpEvaluator, value: string): Promise { - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyDown', - key: 'Control', - code: 'ControlLeft', - windowsVirtualKeyCode: 17, - nativeVirtualKeyCode: 17, - modifiers: 2, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyDown', - key: 'a', - code: 'KeyA', - windowsVirtualKeyCode: 65, - nativeVirtualKeyCode: 65, - modifiers: 2, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyUp', - key: 'a', - code: 'KeyA', - windowsVirtualKeyCode: 65, - nativeVirtualKeyCode: 65, - modifiers: 2, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyUp', - key: 'Control', - code: 'ControlLeft', - windowsVirtualKeyCode: 17, - nativeVirtualKeyCode: 17, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyDown', - key: 'Backspace', - code: 'Backspace', - windowsVirtualKeyCode: 8, - nativeVirtualKeyCode: 8, - }); - await cdp.send('Input.dispatchKeyEvent', { - type: 'keyUp', - key: 'Backspace', - code: 'Backspace', - windowsVirtualKeyCode: 8, - nativeVirtualKeyCode: 8, - }); - - if (value) { - await cdp.send('Input.insertText', { text: value }); - } -} - -async function getFieldState( - cdp: CdpEvaluator, - contextId: number, - labels: FieldLabels -): Promise<{ ok: boolean; reason?: string; value?: string; text?: string }> { - return cdp.evaluate( - contextId, - withField( - labels, - `return { - ok: true, - value: input.value, - text: document.body?.innerText || '', - };` - ) - ); -} - -async function getNextButtonState(cdp: CdpEvaluator, contextId: number): Promise<{ found: boolean; disabled?: boolean; text?: string }> { - return cdp.evaluate( - contextId, - `(() => { - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const buttons = Array.from(document.querySelectorAll('button')).filter(isVisible); - const button = buttons.find((candidate) => (candidate.textContent || '').includes('Next')); - const pageText = document.body?.innerText || ''; - if (!button) { - return { found: false, text: pageText }; - } - - const disabled = button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true'; - return { found: true, disabled, text: pageText }; - })()` - ); -} - -async function getPageText(cdp: CdpEvaluator, contextId: number): Promise { - return cdp.evaluate(contextId, 'document.body?.innerText || ""').catch((error) => String(error)); -} - -function withField(labels: FieldLabels, action: string): string { - return `(() => { - const labelsToFind = ${JSON.stringify(getLabels(labels))}; - const normalize = (value) => (value || '').replace(/\\*/g, '').replace(/\\s+/g, ' ').trim(); - const isVisible = (element) => !!(element && (element.offsetWidth || element.offsetHeight || element.getClientRects().length)); - const visibleInputs = Array.from(document.querySelectorAll('input')).filter(isVisible); - const inputByAttribute = visibleInputs - .filter((candidate) => { - const searchableText = [ - candidate.getAttribute('aria-label'), - candidate.getAttribute('placeholder'), - candidate.getAttribute('name'), - candidate.id, - ].map(normalize).join(' ').toLowerCase(); - return labelsToFind.some((expected) => searchableText.includes(expected.toLowerCase())); - }) - .sort((a, b) => normalize(a.getAttribute('placeholder') || a.getAttribute('aria-label') || a.id).length - normalize(b.getAttribute('placeholder') || b.getAttribute('aria-label') || b.id).length)[0]; - const visibleTextElements = Array.from(document.querySelectorAll('label, span, div, p')) - .filter(isVisible) - .filter((candidate) => { - const text = normalize(candidate.textContent); - return text.length > 0 && text.length < 160; - }); - const exactLabel = visibleTextElements - .filter((candidate) => labelsToFind.some((expected) => normalize(candidate.textContent).toLowerCase() === expected.toLowerCase())) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const partialLabel = visibleTextElements - .filter((candidate) => - labelsToFind.some((expected) => normalize(candidate.textContent).toLowerCase().includes(expected.toLowerCase())) - ) - .sort((a, b) => normalize(a.textContent).length - normalize(b.textContent).length)[0]; - const label = exactLabel || partialLabel; - if (!label && !inputByAttribute) { - return { ok: false, reason: 'Field label not found', text: document.body?.innerText || '' }; - } - - const inputId = label?.getAttribute('for'); - const field = label?.closest('[class*="fui-Field"]') || label?.parentElement?.parentElement || label?.parentElement; - const labelRect = label?.getBoundingClientRect(); - const nearestInput = labelRect - ? visibleInputs - .map((candidate) => ({ candidate, rect: candidate.getBoundingClientRect() })) - .filter(({ rect }) => rect.bottom >= labelRect.top - 5) - .sort((a, b) => { - const scoreA = a.rect.top >= labelRect.bottom - 5 ? a.rect.top - labelRect.bottom : 0; - const scoreB = b.rect.top >= labelRect.bottom - 5 ? b.rect.top - labelRect.bottom : 0; - return scoreA - scoreB || a.rect.top - b.rect.top; - })[0]?.candidate - : undefined; - const fieldInputs = field ? Array.from(field.querySelectorAll('input')).filter(isVisible) : []; - const fieldInput = - fieldInputs.length === 1 - ? fieldInputs[0] - : labelRect - ? fieldInputs - .map((candidate) => ({ candidate, rect: candidate.getBoundingClientRect() })) - .filter(({ rect }) => rect.bottom >= labelRect.top - 5) - .sort((a, b) => { - const scoreA = a.rect.top >= labelRect.bottom - 5 ? a.rect.top - labelRect.bottom : 0; - const scoreB = b.rect.top >= labelRect.bottom - 5 ? b.rect.top - labelRect.bottom : 0; - return scoreA - scoreB || a.rect.top - b.rect.top; - })[0]?.candidate - : undefined; - const input = inputByAttribute || (inputId ? document.getElementById(inputId) : null) || fieldInput || nearestInput; - if (!(input instanceof HTMLInputElement)) { - return { ok: false, reason: 'Field input not found', text: document.body?.innerText || '', labelHtml: label?.outerHTML }; - } - - const setInputValue = (inputElement, value) => { - inputElement.focus(); - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; - setter?.call(inputElement, value); - inputElement.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: value ? 'insertText' : 'deleteContentBackward', data: value })); - inputElement.dispatchEvent(new Event('change', { bubbles: true })); - inputElement.blur(); - }; - - ${action} - })()`; -} - -function getLabels(labels: FieldLabels): string[] { - return Array.isArray(labels) ? labels : [labels]; -} - async function waitUntil(predicate: () => boolean | Promise, timeoutMs: number, description: string): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown; From 097593ffa5af7a44d84074dd8d4f2c6d25e646d8 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:22:39 -0700 Subject: [PATCH 5/5] test(vscode): add test-cli lifecycle baseline Add latest-stable @vscode/test-cli coverage for VS Code activation, workspace creation, generated workspace lifecycles, Azure auth warm-up, MSN Weather designer authoring, and codeful debug task parity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/vscode-e2e.yml | 81 +- .squad/knowledge/vscode-e2e-testing.md | 25 + apps/vs-code-designer/.vscode-test.mjs | 86 +- apps/vs-code-designer/package.json | 9 +- .../scripts/open-e2e-cli-vscode.js | 93 +- apps/vs-code-designer/scripts/run-e2e-cli.js | 582 ++++- .../scripts/summarize-e2e-cli-results.js | 289 +++ .../__test__/getAuthorizationToken.test.ts | 31 +- .../utils/codeless/getAuthorizationToken.ts | 25 +- apps/vs-code-designer/src/test/e2e/README.md | 215 +- .../src/test/e2e/azureAuthWarmup.test.ts | 22 + .../src/test/e2e/cdpClient.ts | 56 + .../src/test/e2e/cdpFormHelpers.ts | 19 +- .../src/test/e2e/createWorkspace.test.ts | 87 +- .../src/test/e2e/createWorkspaceParityMap.md | 9 +- .../src/test/e2e/dialogGuard.ts | 114 +- .../src/test/e2e/screenshot.ts | 8 +- .../src/test/e2e/workspaceArtifacts.ts | 351 +++ .../src/test/e2e/workspaceLifecycle.test.ts | 2292 +++++++++++++++-- package.json | 5 + 20 files changed, 4091 insertions(+), 308 deletions(-) create mode 100644 apps/vs-code-designer/scripts/summarize-e2e-cli-results.js create mode 100644 apps/vs-code-designer/src/test/e2e/azureAuthWarmup.test.ts diff --git a/.github/workflows/vscode-e2e.yml b/.github/workflows/vscode-e2e.yml index b9b84ee2fe5..60b385cb5d5 100644 --- a/.github/workflows/vscode-e2e.yml +++ b/.github/workflows/vscode-e2e.yml @@ -752,17 +752,53 @@ jobs: sudo apt-get install -y xvfb libgbm-dev libgtk-3-0 libnss3 libasound2t64 libxss1 libatk-bridge2.0-0 libatk1.0-0 - name: Run @vscode/test-cli Create Workspace label (${{ matrix.label }}) + id: run_cli_create_workspace_label working-directory: apps/vs-code-designer run: | export PATH="$(dirname $(which node)):/usr/local/bin:/usr/bin:/bin:$PATH" echo "PATH=$PATH" + mkdir -p .vscode-test/results + set +e xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \ - pnpm exec node scripts/run-e2e-cli.js --label "${{ matrix.label }}" + pnpm exec node scripts/run-e2e-cli.js --label "${{ matrix.label }}" 2>&1 | tee ".vscode-test/results/${{ matrix.label }}.log" + status=${PIPESTATUS[0]} + set -e + outcome="success" + if [ "$status" -ne 0 ]; then + outcome="failure" + fi + node scripts/summarize-e2e-cli-results.js \ + --label "${{ matrix.label }}" \ + --log ".vscode-test/results/${{ matrix.label }}.log" \ + --out-dir ".vscode-test/results" \ + --outcome "$outcome" + exit "$status" env: NODE_OPTIONS: --max-old-space-size=4096 TEMP: ${{ runner.temp }} TMPDIR: ${{ runner.temp }} + - name: Summarize @vscode/test-cli Create Workspace result + if: always() + working-directory: apps/vs-code-designer + run: | + node scripts/summarize-e2e-cli-results.js \ + --append-summary \ + --json ".vscode-test/results/${{ matrix.label }}.json" \ + --github-summary "$GITHUB_STEP_SUMMARY" + + - name: Upload CLI structured results (always) + uses: actions/upload-artifact@v6 + if: always() + with: + name: vscode-e2e-cli-test-results-${{ matrix.label }} + path: | + apps/vs-code-designer/.vscode-test/results/${{ matrix.label }}.json + apps/vs-code-designer/.vscode-test/results/${{ matrix.label }}.junit.xml + apps/vs-code-designer/.vscode-test/results/${{ matrix.label }}.summary.md + if-no-files-found: ignore + retention-days: 30 + - name: Upload CLI screenshots (always) uses: actions/upload-artifact@v6 if: always() @@ -772,6 +808,49 @@ jobs: if-no-files-found: ignore retention-days: 30 + - name: Upload CLI test log (always) + uses: actions/upload-artifact@v6 + if: always() + with: + name: vscode-e2e-cli-log-${{ matrix.label }} + path: apps/vs-code-designer/.vscode-test/results/${{ matrix.label }}.log + if-no-files-found: ignore + retention-days: 30 + + vscode-e2e-cli-create-workspace-report: + name: vscode-e2e-cli-create-workspace-report + needs: vscode-e2e-cli-create-workspace + if: always() + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download structured CLI results + uses: actions/download-artifact@v7 + continue-on-error: true + with: + pattern: vscode-e2e-cli-test-results-* + path: apps/vs-code-designer/.vscode-test/results/aggregate-input + merge-multiple: true + + - name: Summarize aggregate @vscode/test-cli Create Workspace results + run: | + node apps/vs-code-designer/scripts/summarize-e2e-cli-results.js \ + --aggregate \ + --results-dir apps/vs-code-designer/.vscode-test/results/aggregate-input \ + --out-dir apps/vs-code-designer/.vscode-test/results/aggregate \ + --github-summary "$GITHUB_STEP_SUMMARY" + + - name: Upload aggregate CLI result dashboard + uses: actions/upload-artifact@v6 + if: always() + with: + name: vscode-e2e-cli-test-results-summary + path: apps/vs-code-designer/.vscode-test/results/aggregate/ + if-no-files-found: ignore + retention-days: 30 + # --------------------------------------------------------------------------- # Stage C: codeful debug (F5) as a first-class CI shard on BOTH OSes. # diff --git a/.squad/knowledge/vscode-e2e-testing.md b/.squad/knowledge/vscode-e2e-testing.md index 9c3f08b3ed3..34ca064312b 100644 --- a/.squad/knowledge/vscode-e2e-testing.md +++ b/.squad/knowledge/vscode-e2e-testing.md @@ -23,6 +23,20 @@ Curated durable learnings for VS Code ExTester UI E2E tests. Add entries through ## Current Learnings +### `@vscode/test-cli` Create Workspace CI publishes structured results + +- Learning: `.github/workflows/vscode-e2e.yml` runs the latest-stable `@vscode/test-cli` Create Workspace labels in the `vscode-e2e-cli-create-workspace` matrix and each label must publish structured results, not only logs. +- Why it matters: Without JUnit/JSON summaries, screenshots, and aggregate pass-rate output, failures are discovered by reading long VS Code host logs and users cannot quickly see which label failed. +- Pattern: + 1. Run each label through `apps/vs-code-designer/scripts/run-e2e-cli.js`. + 2. Tee the raw log to `.vscode-test/results/