diff --git a/.github/instructions/vs-code-designer.instructions.md b/.github/instructions/vs-code-designer.instructions.md index fa5d92e53ea..44c8a2de0f3 100644 --- a/.github/instructions/vs-code-designer.instructions.md +++ b/.github/instructions/vs-code-designer.instructions.md @@ -143,6 +143,7 @@ Each test runs in its own fresh VS Code session to avoid workspace-switch conten | 4.5 | designerViewExtended.test.ts | Parallel branches + run-after (ADO #10109401) | | 4.6 | keyboardNavigation.test.ts | Ctrl+Up/Down + Ctrl+Alt+P / Ctrl+Shift+P hotkey contract | | 4.7 | dataMapper.test.ts, demo, smoke, standalone | Data Mapper + generic tests | +| 4.11 | bundleCdnHealth.test.ts | CDN integrity headers probe (`Content-Length` / `Content-MD5` on the Workflows extension bundle). Pure Mocha — no VS Code session. | ### Shared Helper Modules @@ -193,9 +194,10 @@ $env:E2E_MODE = "newtestsonly" # Phases 4.3-4.6 only (requires prior $env:E2E_MODE = "conversiononly" # Phases 4.8a-e only (requires prior 4.1 manifest) $env:E2E_MODE = "conversioncreateonly" # Phase 4.8b only (builds own legacy fixture) $env:E2E_MODE = "nonlogicappstartup" # Phase 4.0 only +$env:E2E_MODE = "bundleintegrityonly" # Phase 4.11 — pure-Mocha CDN integrity probe (no VS Code) # CI matrix shard modes (each runs on its own GitHub Actions runner): -$env:E2E_MODE = "independentonly" # 4.0 + 4.8b — no Phase 4.1 dep +$env:E2E_MODE = "independentonly" # 4.0 + 4.8b + 4.11 — no Phase 4.1 dep $env:E2E_MODE = "createplusdesigner" # 4.1 → 4.2, 4.7 $env:E2E_MODE = "createplusnewtests" # 4.1 → 4.3, 4.4, 4.5, 4.6 $env:E2E_MODE = "createplusconversion" # 4.1 → 4.8a, 4.8c, 4.8d, 4.8e diff --git a/.github/workflows/vscode-e2e.yml b/.github/workflows/vscode-e2e.yml index 1a1575a28f4..638e7e7f424 100644 --- a/.github/workflows/vscode-e2e.yml +++ b/.github/workflows/vscode-e2e.yml @@ -205,7 +205,6 @@ jobs: key: la-runtime-deps-${{ runner.os }}-v1 restore-keys: | la-runtime-deps-${{ runner.os }}- - save-always: true - name: Run fixtures scenario (p41a-fixtures) run: | @@ -214,6 +213,7 @@ jobs: node apps/vs-code-designer/src/test/ui/run-e2e.js env: LA_E2E_SCENARIO: p41a-fixtures + LA_E2E_STRICT_DEPENDENCY_VALIDATION: '1' NODE_OPTIONS: --max-old-space-size=4096 # Node's os.tmpdir() honors TMPDIR/TMP/TEMP on Linux; setting TEMP # pins workspace + manifest creation under $RUNNER_TEMP/la-e2e-test @@ -221,6 +221,39 @@ jobs: TEMP: ${{ runner.temp }} TMPDIR: ${{ runner.temp }} + - name: Verify Logic Apps extension bundle sidecar + run: | + BUNDLE_ROOT="${HOME}/.azure-functions-core-tools/Functions/ExtensionBundles/Microsoft.Azure.Functions.ExtensionBundle.Workflows" + node - "$BUNDLE_ROOT" <<'NODE' + const fs = require('fs'); + const path = require('path'); + const root = process.argv[2]; + if (!fs.existsSync(root)) { + throw new Error(`Bundle root missing: ${root}`); + } + const versions = fs.readdirSync(root) + .filter((name) => /^\d+\.\d+\.\d+/.test(name) && fs.statSync(path.join(root, name)).isDirectory()) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + if (versions.length === 0) { + throw new Error(`No bundle version folders under ${root}`); + } + const bundleDir = path.join(root, versions[0]); + const sidecarPath = path.join(bundleDir, '.bundle-source-md5'); + if (!fs.existsSync(sidecarPath)) { + throw new Error(`Bundle sidecar missing: ${sidecarPath}`); + } + const sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); + if (!sidecar || typeof sidecar.sourceMd5 !== 'string' || typeof sidecar.contentHash !== 'string') { + throw new Error(`Bundle sidecar missing sourceMd5/contentHash: ${sidecarPath}`); + } + const entries = fs.readdirSync(bundleDir).filter((name) => name !== '.bundle-source-md5'); + if (entries.length === 0) { + throw new Error(`Bundle directory has no extracted content: ${bundleDir}`); + } + console.log(`Bundle verified: ${bundleDir}`); + console.log(`Sidecar: ${sidecarPath}`); + NODE + - name: Verify fixtures manifest exists run: | MANIFEST="${RUNNER_TEMP}/la-e2e-test/created-workspaces.json" @@ -236,6 +269,12 @@ jobs: run: | tar -czf workspace-fixtures.tar.gz -C "${RUNNER_TEMP}" la-e2e-test + - name: Tar Logic Apps extension bundle + run: | + tar -czf logic-apps-extension-bundle.tar.gz \ + -C "${HOME}/.azure-functions-core-tools/Functions/ExtensionBundles" \ + Microsoft.Azure.Functions.ExtensionBundle.Workflows + - name: Upload workspace fixtures artifact uses: actions/upload-artifact@v4 with: @@ -244,6 +283,14 @@ jobs: retention-days: 1 if-no-files-found: error + - name: Upload Logic Apps extension bundle artifact + uses: actions/upload-artifact@v4 + with: + name: logic-apps-extension-bundle-${{ github.sha }} + path: logic-apps-extension-bundle.tar.gz + retention-days: 1 + if-no-files-found: error + - name: Upload fixture screenshots on failure uses: actions/upload-artifact@v4 if: failure() @@ -263,31 +310,71 @@ jobs: strategy: fail-fast: false matrix: - scenario: - # Independent / no-workspace (don't need fixtures artifact) - - p40-nonlogicapp - - p48b-conversioncreate + include: + # Independent / no-workspace (don't need fixtures or bundle artifacts) + - scenario: p40-nonlogicapp + use_workspace_fixtures: false + use_bundle_artifact: false + - scenario: p48b-conversioncreate + use_workspace_fixtures: false + use_bundle_artifact: false # Full 12-shape behavior validation — self-creates its own workspaces, - # so it doesn't consume the fixtures artifact either. - - p41b-createworkspace-behavior + # so it doesn't consume the fixtures artifact, but it still consumes + # the setup-fixtures bundle artifact and runs bundle preflight. + - scenario: p41b-createworkspace-behavior + use_workspace_fixtures: false + use_bundle_artifact: true # Designer lifecycle (consume fixtures from setup-fixtures) - - p42-standard - - p42-customcode - - p42-rulesengine + - scenario: p42-standard + use_workspace_fixtures: true + use_bundle_artifact: true + - scenario: p42-customcode + use_workspace_fixtures: true + use_bundle_artifact: true + - scenario: p42-rulesengine + use_workspace_fixtures: true + use_bundle_artifact: true # Runtime-touching consumer tests (consume fixtures) - - p43-inlinejavascript - - p43-customcode - - p43-rulesengine - - p44-statelessvariables - - p45-designerviewextended - - p46-keyboardnav + - scenario: p43-inlinejavascript + use_workspace_fixtures: true + use_bundle_artifact: true + - scenario: p43-customcode + use_workspace_fixtures: true + use_bundle_artifact: true + - scenario: p43-rulesengine + use_workspace_fixtures: true + use_bundle_artifact: true + - scenario: p44-statelessvariables + use_workspace_fixtures: true + use_bundle_artifact: true + - scenario: p45-designerviewextended + use_workspace_fixtures: true + use_bundle_artifact: true + - scenario: p46-keyboardnav + use_workspace_fixtures: true + use_bundle_artifact: true # Multi-designer + dataMapper suite (manifest-multi) - - p47-suite + - scenario: p47-suite + use_workspace_fixtures: true + use_bundle_artifact: true # Conversion suite - - p48a-conversionno - - p48c-multipledesigners - - p48d-conversionyes - - p48e-conversionsubfolder + - scenario: p48a-conversionno + use_workspace_fixtures: true + use_bundle_artifact: true + - scenario: p48c-multipledesigners + use_workspace_fixtures: true + use_bundle_artifact: true + - scenario: p48d-conversionyes + use_workspace_fixtures: true + use_bundle_artifact: true + - scenario: p48e-conversionsubfolder + use_workspace_fixtures: true + use_bundle_artifact: true + # Bundle on-disk repair / integrity gate (Phase 14 code path). + # Consumes the standard/Stateful fixture from setup-fixtures. + - scenario: p412-bundlerepair + use_workspace_fixtures: true + use_bundle_artifact: true steps: - name: Checkout @@ -342,19 +429,16 @@ jobs: - name: Extract build artifacts run: tar -xzf extension-build.tar.gz - # Scenarios that self-create their workspaces skip the fixtures download: - # - p40-nonlogicapp (plain folder, no workspace) - # - p48b-conversioncreate (builds its own legacy fixture) - # - p41b-createworkspace-behavior (full 12-shape wizard run) + # Scenarios that self-create their workspaces set use_workspace_fixtures=false. - name: Download workspace fixtures artifact - if: matrix.scenario != 'p40-nonlogicapp' && matrix.scenario != 'p48b-conversioncreate' && matrix.scenario != 'p41b-createworkspace-behavior' + if: matrix.use_workspace_fixtures == true uses: actions/download-artifact@v4 with: name: workspace-fixtures-${{ github.sha }} path: . - name: Extract workspace fixtures into $RUNNER_TEMP - if: matrix.scenario != 'p40-nonlogicapp' && matrix.scenario != 'p48b-conversioncreate' && matrix.scenario != 'p41b-createworkspace-behavior' + if: matrix.use_workspace_fixtures == true run: | mkdir -p "${RUNNER_TEMP}" tar -xzf workspace-fixtures.tar.gz -C "${RUNNER_TEMP}" @@ -365,6 +449,20 @@ jobs: cat "${RUNNER_TEMP}/la-e2e-test/created-workspaces.json" fi + - name: Download Logic Apps extension bundle artifact + if: matrix.use_bundle_artifact == true + uses: actions/download-artifact@v4 + with: + name: logic-apps-extension-bundle-${{ github.sha }} + path: . + + - name: Extract Logic Apps extension bundle + if: matrix.use_bundle_artifact == true + run: | + mkdir -p "${HOME}/.azure-functions-core-tools/Functions/ExtensionBundles" + tar -xzf logic-apps-extension-bundle.tar.gz -C "${HOME}/.azure-functions-core-tools/Functions/ExtensionBundles" + find "${HOME}/.azure-functions-core-tools/Functions/ExtensionBundles/Microsoft.Azure.Functions.ExtensionBundle.Workflows" -maxdepth 2 -type f -name '.bundle-source-md5' -print + - name: Install system dependencies for virtual display run: | sudo apt-get update @@ -399,6 +497,7 @@ jobs: env: # Per-scenario matrix: each shard runs exactly one scenarios[] entry. LA_E2E_SCENARIO: ${{ matrix.scenario }} + LA_E2E_BUNDLE_PREFLIGHT: ${{ matrix.use_bundle_artifact && '1' || '0' }} NODE_OPTIONS: --max-old-space-size=4096 # Pin os.tmpdir() so the bootstrapper finds the manifest the # setup-fixtures job uploaded (same RUNNER_TEMP layout). diff --git a/.squad/knowledge/INDEX.md b/.squad/knowledge/INDEX.md index f4bdd8cde8c..1394225f7d8 100644 --- a/.squad/knowledge/INDEX.md +++ b/.squad/knowledge/INDEX.md @@ -6,10 +6,11 @@ Read this first on **every** task. Open any file whose triggers match the task d | If the task involves… | Read these | |---|---| +| **Opening or updating ANY pull request body** | **`.github/pull_request_template.md` (lowercase filename — exists at repo root in `.github/`, do NOT improvise sections, do NOT skip)** **+** [review-patterns.md](review-patterns.md) ("PR body must conform…") | | `git`, worktrees, branches, repo setup, syncing agents | [session-learnings.md](session-learnings.md) **+ run `git config --get-regexp '^alias\.'`** (custom aliases: `git new `, `git agents`, `git sync-agents`, `git unhide-agents`, `git ap`) **+** [ci-patterns.md](ci-patterns.md) (worktree off-main vs off-feature, stacked PRs from forks) | | Pull request lifecycle, pushing, CI checks, failing workflows, retries | [ci-patterns.md](ci-patterns.md) | | PR comments, reviewer feedback, addressing review threads | [review-patterns.md](review-patterns.md) | -| Opening or updating a PR body, `AI PR Validation` bot failures, `needs-pr-update` label | [review-patterns.md](review-patterns.md) ("PR body must conform to .github/pull_request_template.md…") **+** `.github/pull_request_template.md` | +| `AI PR Validation` bot failures, `needs-pr-update` label | [review-patterns.md](review-patterns.md) ("PR body must conform to .github/pull_request_template.md…") **+** `.github/pull_request_template.md` | | Squad routing, agent prompts, playbooks, charters | [agent-improvements.md](agent-improvements.md) | | VS Code extension E2E (ExTester), `run-e2e.js`, designer/run/debug tests | [vscode-e2e-testing.md](vscode-e2e-testing.md) **+** `apps/vs-code-designer/src/test/ui/SKILL.md` | | Functions runtime readiness, `:7071` probes, `listCallbackUrl`, `waitForRuntimeReady`, "runtime not ready" flakes | [runtime-readiness-probes.md](runtime-readiness-probes.md) | @@ -21,9 +22,10 @@ Read this first on **every** task. Open any file whose triggers match the task d ## Hard Rules -1. **Never run a raw `git` command before checking aliases.** Repo-specific aliases (e.g., `git new` for worktrees) encode conventions that raw git won't enforce. -2. **Never skip this index because a task "looks trivial."** Trivial-looking tasks (worktree creation, single test runs, one-line scripts) are where repo conventions get missed. -3. **When a knowledge file disagrees with a generic best practice, the knowledge file wins** — it reflects repo-specific evidence. +1. **Before opening or editing any PR body**, read `.github/pull_request_template.md` (lowercase filename) at the repo root and use its exact section structure. Do NOT improvise. The fork and the `Azure/LogicAppsUX` upstream both ship the same template. +2. **Never run a raw `git` command before checking aliases.** Repo-specific aliases (e.g., `git new` for worktrees) encode conventions that raw git won't enforce. +3. **Never skip this index because a task "looks trivial."** Trivial-looking tasks (worktree creation, single test runs, one-line scripts) are where repo conventions get missed. +4. **When a knowledge file disagrees with a generic best practice, the knowledge file wins** — it reflects repo-specific evidence. ## Maintenance diff --git a/.squad/knowledge/review-patterns.md b/.squad/knowledge/review-patterns.md index f27d3bf33af..56c875b4088 100644 --- a/.squad/knowledge/review-patterns.md +++ b/.squad/knowledge/review-patterns.md @@ -26,12 +26,13 @@ Curated patterns for PR comments, reviewer feedback, and final summaries. Add en - Why it matters: PR #9164 hit this on initial open — the `release-scribe`-style PR body shipped without ticking Commit Type, Risk Level, or Test Plan E2E boxes and without a `## Contributors` section, so AI PR Validation flagged 3 sections ❌ + 2 ⚠️. Rewriting the body to match `.github/pull_request_template.md` and applying `risk:medium` cleared all 8 sections to ✅. The bot re-runs on `pull_request_target.types: [edited, labeled, unlabeled]` so a single `gh pr edit --body-file ... --add-label risk: --remove-label needs-pr-update` triggers a fresh validation pass. - Pattern (release-scribe and pr-orchestrator): 1. Read `.github/pull_request_template.md` before authoring any PR body — do NOT improvise sections. - 2. Tick exactly one `Commit Type` and exactly one `Risk Level` checkbox. The PR title prefix (`fix:`, `feat:`, `perf:`, `test:`, `ci:`, `docs:`, `chore:`) must match the single ticked commit type. - 3. Apply the matching repo label (`risk:low` / `risk:medium` / `risk:high`) in the same `gh pr edit` call. - 4. Include a literal `## Contributors` section listing `@user` mentions — a trailing `Co-authored-by:` git trailer alone does NOT satisfy this requirement. - 5. Tick the Test Plan boxes that actually apply. If the diff adds a `*.spec.ts(x)` file under a test folder, "Unit tests added/updated" must be ticked. Cite CI run IDs and per-shard wall-times when CI evidence exists. - 6. Remove `needs-pr-update` in the same edit operation. - 7. Wait ~5-7 minutes for the bot to re-validate and verify all sections show ✅ before declaring the body work complete. + 2. **Preserve every section, every comment hint, and every checkbox from the template verbatim.** Tick only the ones that apply; leave the rest as `- [ ]`. Never delete unticked checkbox rows or section comments — reviewers expect the full grid. + 3. Tick exactly one `Commit Type` and exactly one `Risk Level` checkbox. The PR title prefix (`fix:`, `feat:`, `perf:`, `test:`, `ci:`, `docs:`, `chore:`) must match the single ticked commit type. + 4. Apply the matching repo label (`risk:low` / `risk:medium` / `risk:high`) in the same `gh pr edit` call. + 5. Include a literal `## Contributors` section listing `@user` mentions — a trailing `Co-authored-by:` git trailer alone does NOT satisfy this requirement. + 6. Tick the Test Plan boxes that actually apply. If the diff adds a `*.spec.ts(x)` file under a test folder, "Unit tests added/updated" must be ticked. Cite CI run IDs and per-shard wall-times when CI evidence exists. + 7. Remove `needs-pr-update` in the same edit operation. + 8. Wait ~5-7 minutes for the bot to re-validate and verify all sections show ✅ before declaring the body work complete. - Source: PR #9164 AI PR Validation bot iterations; `.github/workflows/pr-ai-validation.yml`; `.github/pull_request_template.md`. - Applies to: `release-scribe`, `pr-orchestrator`, `chief-engineer`, `pr-comment-triage`. - Status: verified. diff --git a/apps/vs-code-designer/CLAUDE.md b/apps/vs-code-designer/CLAUDE.md index fa5d92e53ea..44c8a2de0f3 100644 --- a/apps/vs-code-designer/CLAUDE.md +++ b/apps/vs-code-designer/CLAUDE.md @@ -143,6 +143,7 @@ Each test runs in its own fresh VS Code session to avoid workspace-switch conten | 4.5 | designerViewExtended.test.ts | Parallel branches + run-after (ADO #10109401) | | 4.6 | keyboardNavigation.test.ts | Ctrl+Up/Down + Ctrl+Alt+P / Ctrl+Shift+P hotkey contract | | 4.7 | dataMapper.test.ts, demo, smoke, standalone | Data Mapper + generic tests | +| 4.11 | bundleCdnHealth.test.ts | CDN integrity headers probe (`Content-Length` / `Content-MD5` on the Workflows extension bundle). Pure Mocha — no VS Code session. | ### Shared Helper Modules @@ -193,9 +194,10 @@ $env:E2E_MODE = "newtestsonly" # Phases 4.3-4.6 only (requires prior $env:E2E_MODE = "conversiononly" # Phases 4.8a-e only (requires prior 4.1 manifest) $env:E2E_MODE = "conversioncreateonly" # Phase 4.8b only (builds own legacy fixture) $env:E2E_MODE = "nonlogicappstartup" # Phase 4.0 only +$env:E2E_MODE = "bundleintegrityonly" # Phase 4.11 — pure-Mocha CDN integrity probe (no VS Code) # CI matrix shard modes (each runs on its own GitHub Actions runner): -$env:E2E_MODE = "independentonly" # 4.0 + 4.8b — no Phase 4.1 dep +$env:E2E_MODE = "independentonly" # 4.0 + 4.8b + 4.11 — no Phase 4.1 dep $env:E2E_MODE = "createplusdesigner" # 4.1 → 4.2, 4.7 $env:E2E_MODE = "createplusnewtests" # 4.1 → 4.3, 4.4, 4.5, 4.6 $env:E2E_MODE = "createplusconversion" # 4.1 → 4.8a, 4.8c, 4.8d, 4.8e diff --git a/apps/vs-code-designer/src/test/ui/SKILL.md b/apps/vs-code-designer/src/test/ui/SKILL.md index e04e990cba2..7c8b2f66d34 100644 --- a/apps/vs-code-designer/src/test/ui/SKILL.md +++ b/apps/vs-code-designer/src/test/ui/SKILL.md @@ -64,6 +64,7 @@ Run `npx biome check --write ` and `npx tsup --config tsup.e2e.test.confi | File | Purpose | |------|---------| | `src/test/ui/nonLogicAppStartup.test.ts` | Plain-folder startup regression test. Phase 4.0 | +| `src/test/ui/bundleCdnHealth.test.ts` | CDN integrity probe (`Content-Length` + `Content-MD5` on Microsoft.Azure.Functions.ExtensionBundle.Workflows). Pure Mocha — runs without VS Code. Phase 4.11 / `E2E_MODE=bundleintegrityonly`. | | `src/test/ui/createWorkspace.test.ts` | Create Workspace wizard tests (~4359 lines). Phase 4.1 | | `src/test/ui/designerActions.test.ts` | Designer full lifecycle tests (~2647 lines). Phase 4.2 | | `src/test/ui/designerOpen.test.ts` | Designer open tests (~1100 lines). Deprecated — Phase 4.2 now uses `designerActions.test.ts` only | @@ -124,6 +125,7 @@ pnpm run test:ui # Runs node src/test/ui/run-e2e.js | (unset) | Runs Phase 4.0 (non-Logic-App startup), Phase 4.1 (createWorkspace), then later designer/conversion phases | | `nonlogicappstartup` | Runs only Phase 4.0 with minimal settings and no runtime dependency paths | | `designeronly` | Skips Phase 4.1, runs Phase 4.2 using workspaces from a previous Phase 4.1 run | +| `bundleintegrityonly` | Runs Phase 4.11 (`bundleCdnHealth.test.ts`) — pure-Mocha probe of `cdn.functions.azure.com` integrity headers. No VS Code session, no compiled extension required (only `npx tsup --config tsup.e2e.test.config.ts`). Bundled into the `independentonly` shard for CI. | **IMPORTANT**: `E2E_MODE=designeronly` requires that Phase 4.1 has been run previously in the same session and workspaces still exist on disk. If the previous run's `after()` hook cleaned up workspaces, Phase 4.2 tests will fail with "Missing workspace directories" errors. diff --git a/apps/vs-code-designer/src/test/ui/bundleCdnHealth.test.ts b/apps/vs-code-designer/src/test/ui/bundleCdnHealth.test.ts new file mode 100644 index 00000000000..0b1edb6e742 --- /dev/null +++ b/apps/vs-code-designer/src/test/ui/bundleCdnHealth.test.ts @@ -0,0 +1,204 @@ +/// + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * End-to-end CDN health probe for Microsoft.Azure.Functions.ExtensionBundle.Workflows. + * + * Runs as a plain Mocha suite (no ExTester / VS Code) so it stays fast and + * doesn't require an X server. The test verifies two things against the + * live `cdn.functions.azure.com` endpoint: + * + * 1. The CDN still emits the headers we depend on for client-side + * integrity verification (Content-Length + Content-MD5). If the CDN + * stops sending these — or changes their format — every Phase-1 + * download verification turns into a no-op without anyone noticing. + * This test fails loudly so the regression is caught at PR time. + * + * 2. A small JSON file (`index.json`, a few KB) can be downloaded + * end-to-end through `downloadFileWithVerification` from + * `app/utils/integrity.ts`. We use this rather than the full bundle + * zip (~250 MB) to keep the test fast. + * + * Triggered by E2E_MODE=bundleintegrityonly via run-e2e.js. Also wired into + * the `independentonly` shard. + */ + +import * as assert from 'assert'; +import axios from 'axios'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { downloadFileWithVerification, fetchExpectedMd5, DEFAULT_DOWNLOAD_MAX_ATTEMPTS } from '../../app/utils/integrity'; + +const PUBLIC_BUNDLE_BASE_URL = 'https://cdn.functions.azure.com/public'; +const EXTENSION_BUNDLE_ID = 'Microsoft.Azure.Functions.ExtensionBundle.Workflows'; +const INDEX_URL = `${PUBLIC_BUNDLE_BASE_URL}/ExtensionBundles/${EXTENSION_BUNDLE_ID}/index.json`; + +const TEST_TIMEOUT = 60_000; +// Accept a base64 MD5 like "K2WG08BmGeB5pCBJZ/uHwg==". 24 chars including a +// trailing "==" pad is the canonical shape for a 16-byte MD5. +const BASE64_MD5_PATTERN = /^[A-Za-z0-9+/]{22}==$/; + +interface BundleIndexFeed extends Array {} + +async function fetchBundleIndex(): Promise { + const response = await axios.get(INDEX_URL, { timeout: 30_000 }); + return response.data; +} + +function pickLatestVersion(feed: BundleIndexFeed): string { + if (!Array.isArray(feed) || feed.length === 0) { + throw new Error('Bundle index feed contained no versions'); + } + // Sort numerically by major.minor.patch so we pick the actual newest version + // rather than relying on lexicographic order (which would put 1.9.0 > 1.10.0). + const sorted = [...feed].sort((a, b) => { + const pa = a.split('.').map((n) => Number.parseInt(n, 10)); + const pb = b.split('.').map((n) => Number.parseInt(n, 10)); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const da = pa[i] ?? 0; + const db = pb[i] ?? 0; + if (da !== db) { + return da - db; + } + } + return 0; + }); + return sorted[sorted.length - 1]; +} + +function buildBundleZipUrl(version: string): string { + return `${PUBLIC_BUNDLE_BASE_URL}/ExtensionBundles/${EXTENSION_BUNDLE_ID}/${version}/${EXTENSION_BUNDLE_ID}.${version}_any-any.zip`; +} + +describe('Bundle CDN integrity headers (live)', function () { + this.timeout(TEST_TIMEOUT); + + let latestVersion: string; + let zipUrl: string; + let scratchDir: string; + + before(async () => { + const feed = await fetchBundleIndex(); + latestVersion = pickLatestVersion(feed); + zipUrl = buildBundleZipUrl(latestVersion); + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'la-e2e-bundle-cdn-')); + // eslint-disable-next-line no-console + console.log(` [bundleCdnHealth] probing ${zipUrl}`); + }); + + after(() => { + if (scratchDir && fs.existsSync(scratchDir)) { + fs.rmSync(scratchDir, { recursive: true, force: true }); + } + }); + + it('HEAD on the latest bundle zip returns Content-Length + Content-MD5', async () => { + const response = await axios.head(zipUrl, { timeout: 30_000 }); + const headers = response.headers as Record; + + const contentLength = headers['content-length']; + assert.ok(contentLength, 'CDN response missing Content-Length header'); + const lengthValue = Number(contentLength); + assert.ok(Number.isFinite(lengthValue) && lengthValue > 0, `Content-Length not a positive integer: ${contentLength}`); + + const contentMd5 = headers['content-md5']; + assert.ok(contentMd5, 'CDN response missing Content-MD5 header — Phase 1 hash verification cannot work without it'); + assert.ok(BASE64_MD5_PATTERN.test(contentMd5), `Content-MD5 is not a valid 16-byte base64 MD5: ${contentMd5}`); + }); + + it('fetchExpectedMd5 returns a valid base64 MD5 for the bundle zip', async () => { + const md5 = await fetchExpectedMd5(zipUrl); + assert.ok(md5, 'fetchExpectedMd5 returned undefined for live bundle zip'); + assert.ok(BASE64_MD5_PATTERN.test(md5 as string), `fetchExpectedMd5 returned non-base64 value: ${md5}`); + }); + + it('downloadFileWithVerification successfully downloads index.json with verification', async () => { + const destPath = path.join(scratchDir, 'index.json'); + const result = await downloadFileWithVerification(INDEX_URL, destPath, { + maxAttempts: DEFAULT_DOWNLOAD_MAX_ATTEMPTS, + }); + + assert.ok(fs.existsSync(destPath), 'index.json was not written to disk'); + const stat = fs.statSync(destPath); + assert.ok(stat.size > 0, 'index.json on disk has zero bytes'); + + // Result captures actual bytes + MD5; only verify expected against actual + // when the response actually carried those headers (some CDN endpoints + // omit Content-MD5 on small JSON, in which case this is a no-op). + if (result.expectedSize !== undefined) { + assert.strictEqual(result.actualSize, result.expectedSize, 'actualSize !== expectedSize'); + } + if (result.expectedMd5 !== undefined) { + assert.strictEqual(result.actualMd5, result.expectedMd5, 'actualMd5 !== expectedMd5'); + } + }); + + it('downloadFileWithVerification round-trips dot.net/v1/dotnet-install.ps1 (gzip regression guard)', async () => { + // Regression: this URL is served gzipped by the dot.net CDN, which used + // to trip our Content-Length check (Content-Length describes the + // compressed bytes but axios decompresses the stream). The fix forces + // Accept-Encoding: identity *and* tolerates Content-Encoding if the + // server ignores the hint. If this test fails with DownloadIntegrityError + // again the CDN or axios behavior drifted — see integrity.ts. + const destPath = path.join(scratchDir, 'dotnet-install.ps1'); + const result = await downloadFileWithVerification('https://dot.net/v1/dotnet-install.ps1', destPath, { + maxAttempts: DEFAULT_DOWNLOAD_MAX_ATTEMPTS, + }); + + assert.ok(fs.existsSync(destPath), 'dotnet-install.ps1 was not written to disk'); + const stat = fs.statSync(destPath); + assert.ok(stat.size > 1024, `dotnet-install.ps1 on disk is suspiciously small: ${stat.size} bytes`); + assert.ok(result.actualSize === stat.size, `actualSize (${result.actualSize}) !== stat.size (${stat.size})`); + + const body = fs.readFileSync(destPath, 'utf8'); + assert.ok( + body.includes('param') || body.includes('Param') || body.includes('<#'), + 'dotnet-install.ps1 does not look like a PowerShell script — likely truncated or HTML error page' + ); + }); +}); + +describe('Bundle CDN integrity headers (experimental settings smoke)', function () { + this.timeout(TEST_TIMEOUT); + + it('honors FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI env var override (HEAD against the override URL)', async () => { + // The extension's `getExtensionBundleBaseUrl()` reads: + // 1) local.settings.json + // 2) process.env.FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI + // 3) experimental VS Code setting + // 4) public default + // The helper itself imports `vscode`, so we can't run it from this + // pure-Mocha test. Instead we replicate the contract that `binaries.ts` + // and `bundleFeed.ts` rely on: that the env var takes precedence over + // the public default. + const before = process.env.FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI; + try { + process.env.FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI = PUBLIC_BUNDLE_BASE_URL; + // If a dev points the env var at the public CDN, we should still get + // a healthy index.json — confirms the URL shape. + const response = await axios.get( + `${process.env.FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI}/ExtensionBundles/${EXTENSION_BUNDLE_ID}/index.json`, + { + timeout: 30_000, + } + ); + assert.strictEqual(response.status, 200); + } finally { + if (before === undefined) { + delete process.env.FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI; + } else { + process.env.FUNCTIONS_EXTENSIONBUNDLE_SOURCE_URI = before; + } + } + }); +}); + +// NOTE: computeBundleContentHash deterministic + mutation-sensitivity coverage lives in the +// vitest unit suite (`src/app/utils/__test__/bundleContentHash.test.ts`) because importing +// `bundleFeed` from this plain-Mocha E2E entry pulls in `vscode` transitively. The unit +// tests use real fs against a tmpdir, so the live behavior is fully exercised there. diff --git a/apps/vs-code-designer/src/test/ui/bundleRepair.test.ts b/apps/vs-code-designer/src/test/ui/bundleRepair.test.ts new file mode 100644 index 00000000000..cac3052cde3 --- /dev/null +++ b/apps/vs-code-designer/src/test/ui/bundleRepair.test.ts @@ -0,0 +1,418 @@ +/// + +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * End-to-end repair test for the on-disk Logic Apps extension bundle integrity gate + * (Phase 14 — `ensureExtensionBundleHealthy`). + * + * Scenario the user reported originally: a user (or AV / sync tool) removes + * files from inside an already-installed bundle directory, e.g. the PowerShell + * subfolder under `bin\\runtimes\\…`. Before Phase 14 the extension trusted + * the `.bundle-source-md5` sidecar as a session cache and silently spawned + * func.exe against a corrupt bundle. After Phase 14 it must: + * + * 1. Recompute the on-disk content hash and detect the drift. + * 2. Show a user-visible "incomplete or its checksum no longer matches" + * notification toast + a "Re-downloading … files on disk were modified + * or corrupted" progress notification. + * 3. Re-download + re-extract the bundle. + * 4. Rewrite the `.bundle-source-md5` sidecar so the new content hash + * matches what's on disk. + * 5. Block downstream (validateAndInstallBinaries / startDesignTimeApi) + * until the repair is complete. + * + * This test reproduces the scenario without `Developer: Reload Window` + * (which the existing test harness avoids — see designerHelpers.ts:2334 + * comment), by directly invoking the `Azure Logic Apps: Validate and install + * dependency binaries` command, which is the same code path that runs at + * activation. + * + * Triggered by E2E_MODE=bundlerepaironly via run-e2e.js. Requires a + * workspace manifest from a prior Phase 4.1 (createWorkspace) run. + */ + +import * as assert from 'assert'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { VSBrowser, Workbench, type WebDriver } from 'vscode-extension-tester'; +import { loadWorkspaceManifest } from './workspaceManifest'; +import { sleep } from './helpers'; + +const TEST_TIMEOUT = 600_000; + +const EXTENSION_BUNDLE_DIR = path.join( + os.homedir(), + '.azure-functions-core-tools', + 'Functions', + 'ExtensionBundles', + 'Microsoft.Azure.Functions.ExtensionBundle.Workflows' +); + +const SIDECAR_FILE = '.bundle-source-md5'; + +/** How long we wait for the initial activation to fully install the bundle. */ +const INITIAL_INSTALL_TIMEOUT_MS = 5 * 60_000; + +/** How long we wait for the repair after the validate-and-install command. */ +const REPAIR_TIMEOUT_MS = 5 * 60_000; + +interface BundleState { + bundleVersion: string; + bundleDir: string; + sidecarPath: string; +} + +/** Picks the highest semver-looking subdirectory under EXTENSION_BUNDLE_DIR. */ +function findLatestInstalledBundle(): BundleState | undefined { + if (!fs.existsSync(EXTENSION_BUNDLE_DIR)) { + return undefined; + } + const versionFolders = fs + .readdirSync(EXTENSION_BUNDLE_DIR) + .filter((name) => { + try { + return fs.statSync(path.join(EXTENSION_BUNDLE_DIR, name)).isDirectory() && /^\d+\.\d+\.\d+/.test(name); + } catch { + return false; + } + }) + .sort((a, b) => { + const pa = a.split('.').map((x) => Number.parseInt(x, 10)); + const pb = b.split('.').map((x) => Number.parseInt(x, 10)); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pb[i] ?? 0) - (pa[i] ?? 0); + if (diff !== 0) { + return diff; + } + } + return 0; + }); + const bundleVersion = versionFolders[0]; + if (!bundleVersion) { + return undefined; + } + const bundleDir = path.join(EXTENSION_BUNDLE_DIR, bundleVersion); + return { bundleVersion, bundleDir, sidecarPath: path.join(bundleDir, SIDECAR_FILE) }; +} + +/** + * Polls until the bundle directory exists AND the sidecar is written — + * the post-condition of a complete install. + */ +async function waitForBundleInstalled(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastSeen: BundleState | undefined; + while (Date.now() < deadline) { + const state = findLatestInstalledBundle(); + if (state && fs.existsSync(state.sidecarPath)) { + return state; + } + lastSeen = state; + await sleep(2000); + } + throw new Error( + `Timed out after ${timeoutMs}ms waiting for bundle install. Last state: ${ + lastSeen ? `version=${lastSeen.bundleVersion}, sidecar=${fs.existsSync(lastSeen.sidecarPath)}` : 'no bundle dir found' + }. EXTENSION_BUNDLE_DIR=${EXTENSION_BUNDLE_DIR}` + ); +} + +/** Recursively walks bundleDir and finds the first matching file by predicate. */ +function findFileMatching(root: string, predicate: (rel: string) => boolean, limit = 5): string[] { + const found: string[] = []; + const stack: string[] = [root]; + while (stack.length > 0 && found.length < limit) { + const cur = stack.pop()!; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(cur, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + const full = path.join(cur, e.name); + const rel = path.relative(root, full); + if (e.isDirectory()) { + stack.push(full); + } else if (predicate(rel)) { + found.push(full); + if (found.length >= limit) { + break; + } + } + } + } + return found; +} + +/** Computes SHA256 of all files under root (sorted) — same algorithm as `computeBundleContentHash`. */ +function computeBundleContentHashSync(root: string): string { + const hash = crypto.createHash('sha256'); + const stack: string[] = [root]; + const files: { rel: string; abs: string }[] = []; + while (stack.length > 0) { + const cur = stack.pop()!; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(cur, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + const full = path.join(cur, e.name); + const rel = path.relative(root, full).split(path.sep).join('/'); + if (rel === SIDECAR_FILE) { + continue; + } + if (e.isDirectory()) { + stack.push(full); + } else if (e.isFile()) { + files.push({ rel, abs: full }); + } + } + } + files.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0)); + for (const f of files) { + const stat = fs.statSync(f.abs); + hash.update(f.rel); + hash.update('\0'); + hash.update(String(stat.size)); + hash.update('\0'); + const buf = fs.readFileSync(f.abs); + hash.update(buf); + hash.update('\0'); + } + return hash.digest('base64'); +} + +interface SidecarShape { + version?: number; + sourceMd5?: string; + contentHash?: string; +} + +function readSidecar(sidecarPath: string): SidecarShape { + return JSON.parse(fs.readFileSync(sidecarPath, 'utf8')) as SidecarShape; +} + +/** + * Tampers with the installed bundle by: + * 1. Deleting the .bundle-source-md5 sidecar (so a downstream "did it + * rewrite the sidecar" check is unambiguous). + * 2. Deleting 2-5 .dll files from the bin folder (the actual content + * drift the gate must detect via hash recomputation). + * Returns the absolute paths of the deleted .dll files so the test can + * later confirm they were restored. + */ +function tamperBundle(state: BundleState): string[] { + // Remove the sidecar so the new state is unambiguous after repair. + if (fs.existsSync(state.sidecarPath)) { + fs.unlinkSync(state.sidecarPath); + } + + // Walk bundle and find 2-5 .dll files to delete. We look under `bin/` if + // present, otherwise anywhere — bundles ship as a flat tree on disk after + // extraction. + const binDir = path.join(state.bundleDir, 'bin'); + const root = fs.existsSync(binDir) ? binDir : state.bundleDir; + const dlls = findFileMatching(root, (rel) => rel.toLowerCase().endsWith('.dll'), 5); + + // Skip the first 2 (likely entry-point/runtime dlls that may be locked by + // a still-running func.exe from autoStartDesignTime) and delete the + // rest. If we have fewer than 3, just take what's there. + const toDelete = dlls.length >= 3 ? dlls.slice(2) : dlls.slice(-2); + const deleted: string[] = []; + for (const full of toDelete) { + try { + fs.unlinkSync(full); + deleted.push(full); + } catch (err) { + // File may be locked by func.exe — skip and try the next one. + console.log(`[bundleRepair] Could not delete ${path.relative(state.bundleDir, full)}: ${(err as Error).message}`); + } + } + return deleted; +} + +/** + * Polls until ALL of: + * - The sidecar file is back on disk. + * - Every previously-deleted .dll path exists again. + * - The on-disk content hash matches the sidecar's contentHash field. + */ +async function waitForBundleRepaired(state: BundleState, deletedDlls: string[], timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastReason = 'no check yet'; + while (Date.now() < deadline) { + if (!fs.existsSync(state.sidecarPath)) { + lastReason = 'sidecar missing'; + await sleep(2000); + continue; + } + const missing = deletedDlls.filter((p) => !fs.existsSync(p)); + if (missing.length > 0) { + lastReason = `${missing.length}/${deletedDlls.length} deleted .dll(s) still missing`; + await sleep(2000); + continue; + } + let sidecar: SidecarShape; + try { + sidecar = readSidecar(state.sidecarPath); + } catch (err) { + lastReason = `sidecar unreadable: ${(err as Error).message}`; + await sleep(2000); + continue; + } + if (!sidecar.contentHash) { + lastReason = 'sidecar present but has no contentHash field'; + await sleep(2000); + continue; + } + const actualHash = computeBundleContentHashSync(state.bundleDir); + if (actualHash !== sidecar.contentHash) { + lastReason = `content hash drift (expected ${sidecar.contentHash}, got ${actualHash})`; + await sleep(2000); + continue; + } + return; // success + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for bundle repair. Last reason: ${lastReason}`); +} + +/** + * Best-effort scrape of any visible VS Code notification text so the test + * can include user-visible UX evidence in the assertion. Returns aggregated + * notification text across all notification surfaces. + */ +async function getVisibleNotificationText(driver: WebDriver): Promise { + try { + return ( + (await driver.executeScript(` + const selectors = [ + '.notification-toast', + '.notifications-toasts', + '.notification-list-item', + '.notifications-list-container', + '.monaco-progress-container', + '.progress-message', + ]; + return Array.from(document.querySelectorAll(selectors.join(','))) + .map((el) => el.textContent || '') + .join('\\n'); + `)) || '' + ); + } catch { + return ''; + } +} + +/** + * Polls for either of the user-visible bundle repair notifications. Returns + * the first matched fragment, or '' on timeout. Non-blocking — failure here + * does NOT fail the test (disk-level repair is the primary assertion). + */ +async function waitForRepairNotification(driver: WebDriver, timeoutMs: number): Promise { + const fragments = [ + 'incomplete or its checksum', + 'files on disk were modified', + 'Re-downloading Logic Apps extension bundle', + 'on-disk integrity check failed', + ]; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const text = await getVisibleNotificationText(driver); + const match = fragments.find((f) => text.toLowerCase().includes(f.toLowerCase())); + if (match) { + return match; + } + await sleep(1000); + } + return ''; +} + +describe('Bundle on-disk integrity gate — repair after tamper (E2E)', () => { + let driver: WebDriver; + let workbench: Workbench; + + before(function () { + this.timeout(60_000); + driver = VSBrowser.instance.driver; + workbench = new Workbench(); + }); + + it('detects tampered bundle and repairs it before any downstream consumer runs', async function () { + this.timeout(TEST_TIMEOUT); + + // Sanity: this test requires a workspace from a prior Phase 4.1 run. + const manifest = loadWorkspaceManifest(); + const standard = manifest.find((e) => e.appType === 'standard' && e.wfType === 'Stateful'); + if (!standard) { + this.skip(); + return; + } + + // ── Step 1: wait for the initial bundle install to complete ──────────── + console.log('[bundleRepair] Step 1: waiting for initial bundle install…'); + const installedState = await waitForBundleInstalled(INITIAL_INSTALL_TIMEOUT_MS); + console.log(`[bundleRepair] Bundle installed: ${installedState.bundleVersion} at ${installedState.bundleDir}`); + + // Sanity: sidecar should parse and have a contentHash before we tamper. + const preSidecar = readSidecar(installedState.sidecarPath); + assert.ok(preSidecar.contentHash, 'pre-tamper sidecar should include contentHash'); + + // ── Step 2: tamper ───────────────────────────────────────────────────── + console.log('[bundleRepair] Step 2: tampering with bundle (delete sidecar + a few .dlls)…'); + const deletedDlls = tamperBundle(installedState); + assert.ok(deletedDlls.length > 0, 'tamper should delete at least one .dll file'); + assert.ok(!fs.existsSync(installedState.sidecarPath), 'sidecar should be gone after tamper'); + console.log( + `[bundleRepair] Deleted ${deletedDlls.length} .dll(s) + sidecar. Sample: ${path.relative(installedState.bundleDir, deletedDlls[0])}` + ); + + // ── Step 3: trigger the integrity gate ───────────────────────────────── + // This is the same `validateAndInstallBinaries` path that runs at + // activation. Threading the command through the palette avoids + // `workbench.action.reloadWindow` (which the harness intentionally + // avoids — see designerHelpers.ts:2334). + console.log('[bundleRepair] Step 3: invoking Validate and install dependency binaries command…'); + await workbench.executeCommand('Azure Logic Apps: Validate and install dependency binaries'); + + // ── Step 4 (best-effort): scrape visible notifications ───────────────── + // Don't fail the test on this — it's UX evidence, but timing is racy and + // notifications auto-dismiss. The disk-level check below is the + // authoritative pass/fail signal. + const notificationMatch = await waitForRepairNotification(driver, 30_000); + if (notificationMatch) { + console.log(`[bundleRepair] ✓ Observed user-visible repair notification fragment: "${notificationMatch}"`); + } else { + console.log( + '[bundleRepair] ⚠ No repair notification observed within 30s (UX evidence missing — repair may still succeed at disk level).' + ); + } + + // ── Step 5: wait for the repair to land on disk ──────────────────────── + console.log('[bundleRepair] Step 5: waiting for on-disk repair (sidecar back + deleted .dlls back + content hash matches)…'); + await waitForBundleRepaired(installedState, deletedDlls, REPAIR_TIMEOUT_MS); + + // ── Step 6: final assertions ─────────────────────────────────────────── + const postSidecar = readSidecar(installedState.sidecarPath); + assert.ok(postSidecar.contentHash, 'post-repair sidecar should include contentHash'); + const postActualHash = computeBundleContentHashSync(installedState.bundleDir); + assert.strictEqual( + postActualHash, + postSidecar.contentHash, + `post-repair on-disk content hash should match the freshly-written sidecar contentHash field (got ${postActualHash}, sidecar says ${postSidecar.contentHash})` + ); + for (const dll of deletedDlls) { + assert.ok( + fs.existsSync(dll), + `deleted .dll should have been restored by the repair: ${path.relative(installedState.bundleDir, dll)}` + ); + } + console.log(`[bundleRepair] ✓ Bundle repaired end-to-end. ${deletedDlls.length} .dll(s) restored. Sidecar contentHash matches disk.`); + }); +}); diff --git a/apps/vs-code-designer/src/test/ui/createWorkspace.fixtures.test.ts b/apps/vs-code-designer/src/test/ui/createWorkspace.fixtures.test.ts index e52609a1cf1..38fb4634aaf 100644 --- a/apps/vs-code-designer/src/test/ui/createWorkspace.fixtures.test.ts +++ b/apps/vs-code-designer/src/test/ui/createWorkspace.fixtures.test.ts @@ -19,6 +19,8 @@ // full behavior coverage runs independently in createWorkspace.behavior.test.ts. import * as fs from 'fs'; +import * as crypto from 'crypto'; +import * as os from 'os'; import * as path from 'path'; import { By, EditorView, type WebDriver, Workbench } from 'vscode-extension-tester'; import { @@ -49,6 +51,254 @@ import { } from './createWorkspaceShared'; const TEST_TIMEOUT = 180_000; +const EXTENSION_BUNDLE_DIR = path.join( + os.homedir(), + '.azure-functions-core-tools', + 'Functions', + 'ExtensionBundles', + 'Microsoft.Azure.Functions.ExtensionBundle.Workflows' +); +const BUNDLE_SIDECAR_FILE = '.bundle-source-md5'; +const VALIDATE_DEPENDENCIES_COMMAND = 'Azure Logic Apps: Validate and install dependency binaries'; +const VS_CODE_LOGS_DIR = path.join(os.tmpdir(), 'test-resources', 'settings', 'logs'); + +function compareSemverDesc(a: string, b: string): number { + const pa = a.split('.').map((part) => Number.parseInt(part, 10)); + const pb = b.split('.').map((part) => Number.parseInt(part, 10)); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pb[i] || 0) - (pa[i] || 0); + if (diff !== 0) { + return diff; + } + } + return 0; +} + +function findLatestWorkflowsBundle(): { version: string; bundleDir: string; sidecarPath: string } | undefined { + if (!fs.existsSync(EXTENSION_BUNDLE_DIR)) { + return undefined; + } + + const version = fs + .readdirSync(EXTENSION_BUNDLE_DIR) + .filter((entry) => { + const fullPath = path.join(EXTENSION_BUNDLE_DIR, entry); + return /^\d+\.\d+\.\d+/.test(entry) && fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory(); + }) + .sort(compareSemverDesc)[0]; + + if (!version) { + return undefined; + } + + const bundleDir = path.join(EXTENSION_BUNDLE_DIR, version); + return { version, bundleDir, sidecarPath: path.join(bundleDir, BUNDLE_SIDECAR_FILE) }; +} + +function listBundleFiles(bundleDir: string): { relPath: string; fullPath: string }[] { + const files: { relPath: string; fullPath: string }[] = []; + const stack = [bundleDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + const relPath = path.relative(bundleDir, fullPath).split(path.sep).join('/'); + if (relPath === BUNDLE_SIDECAR_FILE) { + continue; + } + + if (entry.isFile()) { + files.push({ relPath, fullPath }); + } + if (entry.isDirectory()) { + stack.push(fullPath); + } + } + } + + files.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0)); + return files; +} + +function computeBundleContentHash(files: { relPath: string; fullPath: string }[]): string { + const hash = crypto.createHash('sha256'); + const nul = Buffer.from([0]); + for (const file of files) { + const stat = fs.statSync(file.fullPath); + hash.update(Buffer.from(file.relPath, 'utf8')); + hash.update(nul); + hash.update(Buffer.from(String(stat.size), 'utf8')); + hash.update(nul); + hash.update(fs.readFileSync(file.fullPath)); + hash.update(nul); + } + + return hash.digest('base64'); +} + +function assertWorkflowsBundleSidecarReady(): void { + const bundle = findLatestWorkflowsBundle(); + if (!bundle) { + throw new Error(`[fixtures:setup] Logic Apps extension bundle is missing at ${EXTENSION_BUNDLE_DIR}`); + } + if (!fs.existsSync(bundle.sidecarPath)) { + throw new Error(`[fixtures:setup] Logic Apps extension bundle sidecar is missing: ${bundle.sidecarPath}`); + } + + const sidecar = JSON.parse(fs.readFileSync(bundle.sidecarPath, 'utf8')); + if (!sidecar || typeof sidecar.sourceMd5 !== 'string' || typeof sidecar.contentHash !== 'string') { + throw new Error(`[fixtures:setup] Logic Apps extension bundle sidecar has invalid shape: ${bundle.sidecarPath}`); + } + + const files = listBundleFiles(bundle.bundleDir); + if (files.length === 0) { + throw new Error(`[fixtures:setup] Logic Apps extension bundle has no extracted content: ${bundle.bundleDir}`); + } + + const contentHash = computeBundleContentHash(files); + if (sidecar.contentHash !== contentHash) { + throw new Error( + `[fixtures:setup] Logic Apps extension bundle content hash mismatch: ${bundle.bundleDir} sidecar=${sidecar.contentHash} actual=${contentHash}` + ); + } +} + +function dumpBundleDirectoryState(label: string): void { + console.log(`[fixtures:setup] ${label}: bundle root=${EXTENSION_BUNDLE_DIR}`); + if (!fs.existsSync(EXTENSION_BUNDLE_DIR)) { + console.log(`[fixtures:setup] ${label}: bundle root is missing`); + return; + } + + const versions = fs + .readdirSync(EXTENSION_BUNDLE_DIR) + .filter((entry) => { + const fullPath = path.join(EXTENSION_BUNDLE_DIR, entry); + return fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory(); + }) + .sort(compareSemverDesc); + console.log(`[fixtures:setup] ${label}: versions=[${versions.join(', ')}]`); + + for (const version of versions.slice(0, 5)) { + const bundleDir = path.join(EXTENSION_BUNDLE_DIR, version); + const sidecarPath = path.join(bundleDir, BUNDLE_SIDECAR_FILE); + const entries = fs.readdirSync(bundleDir).slice(0, 30); + console.log(`[fixtures:setup] ${label}: version=${version}, sidecar=${fs.existsSync(sidecarPath)}, entries=[${entries.join(', ')}]`); + } +} + +function listFilesRecursive(root: string, limit = 200): string[] { + const files: string[] = []; + const stack = [root]; + while (stack.length > 0 && files.length < limit) { + const current = stack.pop(); + if (!current) { + continue; + } + + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isFile()) { + files.push(fullPath); + } + if (entry.isDirectory()) { + stack.push(fullPath); + } + if (files.length >= limit) { + break; + } + } + } + return files; +} + +function dumpRelevantVSCodeLogs(label: string): void { + if (!fs.existsSync(VS_CODE_LOGS_DIR)) { + console.log(`[fixtures:setup] ${label}: VS Code logs dir is missing: ${VS_CODE_LOGS_DIR}`); + return; + } + + const candidates = listFilesRecursive(VS_CODE_LOGS_DIR) + .filter((file) => /\.(log|txt)$/i.test(file)) + .map((file) => ({ file, mtimeMs: fs.statSync(file).mtimeMs })) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .slice(0, 30); + + console.log(`[fixtures:setup] ${label}: scanning ${candidates.length} recent VS Code log files under ${VS_CODE_LOGS_DIR}`); + let dumped = 0; + for (const { file } of candidates) { + const raw = fs.readFileSync(file, 'utf8'); + if (!/Logic Apps|extension bundle|Runtime Dependencies|dependencies validation|validateAndInstallBinaries/i.test(raw)) { + continue; + } + const relativePath = path.relative(VS_CODE_LOGS_DIR, file); + console.log(`[fixtures:setup] ${label}: log tail from ${relativePath}\n${raw.slice(-6000)}`); + dumped++; + } + + if (dumped === 0) { + console.log(`[fixtures:setup] ${label}: no recent VS Code logs contained Logic Apps dependency markers`); + } +} + +async function captureSetupDiagnostics(label: string, driver: WebDriver): Promise { + dumpBundleDirectoryState(label); + dumpRelevantVSCodeLogs(label); + try { + await driver.switchTo().defaultContent(); + await captureScreenshot(driver, `fixtures-setup-${label.replace(/[^a-z0-9_-]+/gi, '-')}`); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.log(`[fixtures:setup] ${label}: screenshot capture failed: ${message}`); + } +} + +async function waitForWorkflowsBundleSidecarReady(timeoutMs = 300_000): Promise { + const startedAt = Date.now(); + let lastError: Error | undefined; + + while (Date.now() - startedAt < timeoutMs) { + try { + assertWorkflowsBundleSidecarReady(); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + await sleep(5000); + } + } + + throw lastError ?? new Error('[fixtures:setup] Timed out waiting for Logic Apps extension bundle sidecar'); +} + +async function validateDependenciesThroughProductCommand(workbench: Workbench, driver: WebDriver): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + console.log(`[fixtures:setup] Invoking product dependency validation command (attempt ${attempt}/3)...`); + await workbench.executeCommand(VALIDATE_DEPENDENCIES_COMMAND); + console.log('[fixtures:setup] Product dependency validation command completed'); + return; + } catch (error: unknown) { + lastError = error; + const message = error instanceof Error ? error.message : String(error); + console.log(`[fixtures:setup] Product dependency validation command failed on attempt ${attempt}/3: ${message}`); + try { + await driver.switchTo().defaultContent(); + await dismissNotifications(driver); + } catch { + /* ignore cleanup failures between retries */ + } + await sleep(2000); + } + } + + const message = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`[fixtures:setup] Product dependency validation command failed after retries: ${message}`); +} /** * Minimal manifest-shape smoke assertion for a freshly created workspace. @@ -130,13 +380,27 @@ describe('Create Workspace Fixtures', function () { const tempDir = createTempDir(); before(async function () { - this.timeout(120_000); + this.timeout(600_000); workbench = new Workbench(); driver = workbench.getDriver(); console.log('[fixtures:setup] Waiting for extension to be ready...'); await waitForExtensionReady(workbench); console.log('[fixtures:setup] Extension is ready'); + if (process.env.LA_E2E_STRICT_DEPENDENCY_VALIDATION === '1') { + try { + console.log('[fixtures:setup] Strict dependency validation enabled; invoking product validation before fixtures...'); + dumpBundleDirectoryState('before-product-validation'); + await validateDependenciesThroughProductCommand(workbench, driver); + dumpBundleDirectoryState('after-product-validation-command-returned'); + await waitForWorkflowsBundleSidecarReady(); + console.log('[fixtures:setup] Dependency/bundle validation produced a bundle sidecar before fixture creation'); + } catch (error: unknown) { + await captureSetupDiagnostics('strict-validation-failed', driver); + throw error; + } + } + // Clear any stale manifest from a previous run so this run starts fresh. try { if (fs.existsSync(WORKSPACE_MANIFEST_PATH)) { diff --git a/apps/vs-code-designer/src/test/ui/createWorkspaceShared.ts b/apps/vs-code-designer/src/test/ui/createWorkspaceShared.ts index f559083a8da..8b93f08ad9d 100644 --- a/apps/vs-code-designer/src/test/ui/createWorkspaceShared.ts +++ b/apps/vs-code-designer/src/test/ui/createWorkspaceShared.ts @@ -222,6 +222,18 @@ export function createTempDir(): string { return tmpBase; } +async function typeQuickInputQuery(driver: WebDriver, query: string): Promise { + const inputEl = await driver.wait( + until.elementLocated(By.css('.quick-input-widget:not(.hidden) .quick-input-box input')), + 30_000, + 'QuickInput input element not located' + ); + await driver.wait(until.elementIsVisible(inputEl), 30_000, 'QuickInput input not visible'); + await driver.wait(until.elementIsEnabled(inputEl), 5_000, 'QuickInput input not enabled'); + await inputEl.sendKeys(Key.chord(Key.CONTROL, 'a')); + await inputEl.sendKeys(query); +} + /** * Open the command palette, type a search query, and select a specific pick. * @@ -259,23 +271,11 @@ export async function selectCreateWorkspaceCommand(workbench: Workbench): Promis input = await workbench.openCommandPrompt(); await sleep(500); - // Ensure the QuickPick element is actually visible and enabled - // before driving it. Up to 30s — generous because slow runners take a - // while to mount the widget after the keybind fires. - const inputEl = await driver.wait( - until.elementLocated(By.css('.quick-input-widget:not(.hidden) .quick-input-box input')), - 30_000, - 'QuickInput input element not located' - ); - await driver.wait(until.elementIsVisible(inputEl), 30_000, 'QuickInput input not visible'); - await driver.wait(until.elementIsEnabled(inputEl), 5_000, 'QuickInput input not enabled'); - // CRITICAL: Use '> ' prefix to stay in command mode (file search otherwise). // We bypass ExTester InputBox.setText() which calls clear() and throws // ElementNotInteractableError when the element is transiently busy. // Raw sendKeys with select-all is reliable. - await inputEl.sendKeys(Key.chord(Key.CONTROL, 'a')); - await inputEl.sendKeys('> logic app workspace'); + await typeQuickInputQuery(driver, '> logic app workspace'); await sleep(2_000); // Wait for picks to populate break; // success } catch (e: any) { @@ -337,9 +337,14 @@ export async function selectCreateWorkspaceCommand(workbench: Workbench): Promis } if (!bestPick) { - // Try a different search term + // Try a different search term against a fresh command palette. Reusing a + // no-pick widget can race VS Code clearing the palette and leave the input + // hidden in CI. console.log('[selectCreateWorkspaceCommand] No match, trying "> Create new logic"'); - await input.setText('> Create new logic'); + await safeCancelQuickInput(input, 'selectCreateWorkspaceCommand:fallback'); + input = await workbench.openCommandPrompt(); + await sleep(500); + await typeQuickInputQuery(driver, '> Create new logic'); await sleep(2000); picks = await input.getQuickPicks(); diff --git a/apps/vs-code-designer/src/test/ui/run-e2e.js b/apps/vs-code-designer/src/test/ui/run-e2e.js index 9c462288d51..561821cf3f9 100644 --- a/apps/vs-code-designer/src/test/ui/run-e2e.js +++ b/apps/vs-code-designer/src/test/ui/run-e2e.js @@ -16,7 +16,9 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); -const { exec } = require('child_process'); +const crypto = require('crypto'); +const { exec, execSync } = require('child_process'); +const { ExTester } = require('vscode-extension-tester'); const projectDir = path.resolve(__dirname, '..', '..', '..'); const distDir = path.join(projectDir, 'dist'); @@ -27,8 +29,20 @@ const distDir = path.join(projectDir, 'dist'); * change between VS Code versions. Pinning ensures the same version is used * locally and in CI. Update this when ExTester releases support for newer versions. */ -const VSCODE_VERSION = '1.108.0'; +const DEFAULT_VSCODE_VERSION = '1.108.0'; +const VSCODE_VERSION_SOURCE = process.env.E2E_VSCODE_VERSION ? 'E2E_VSCODE_VERSION' : process.env.CODE_VERSION ? 'CODE_VERSION' : 'default'; +if (process.env.E2E_VSCODE_VERSION && process.env.CODE_VERSION && process.env.E2E_VSCODE_VERSION !== process.env.CODE_VERSION) { + throw new Error( + `E2E_VSCODE_VERSION (${process.env.E2E_VSCODE_VERSION}) and CODE_VERSION (${process.env.CODE_VERSION}) disagree. ` + + 'Set only one VS Code version override for run-e2e.js.' + ); +} +const VSCODE_VERSION = process.env.E2E_VSCODE_VERSION || process.env.CODE_VERSION || DEFAULT_VSCODE_VERSION; +process.env.CODE_VERSION = VSCODE_VERSION; const DOWNLOAD_RETRY_ATTEMPTS = 3; +const EXTENSION_BUNDLE_ID = 'Microsoft.Azure.Functions.ExtensionBundle.Workflows'; +const EXTENSION_BUNDLE_ROOT = path.join(os.homedir(), '.azure-functions-core-tools', 'Functions', 'ExtensionBundles', EXTENSION_BUNDLE_ID); +const BUNDLE_SIDECAR_FILE = '.bundle-source-md5'; // Store test-extensions in test-resources/ (alongside VS Code download) rather // than dist/ — tsup's `clean: true` wipes dist/ on every build:extension, which @@ -36,6 +50,158 @@ const DOWNLOAD_RETRY_ATTEMPTS = 3; const extDir = path.join(os.tmpdir(), 'test-resources', 'test-extensions'); const testGlob = path.resolve(projectDir, 'out', 'test', '*.js').replace(/\\/g, '/'); +function compareSemverDesc(a, b) { + const pa = a.split('.').map((part) => Number.parseInt(part, 10)); + const pb = b.split('.').map((part) => Number.parseInt(part, 10)); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pb[i] || 0) - (pa[i] || 0); + if (diff !== 0) { + return diff; + } + } + return 0; +} + +function findLatestExtensionBundle() { + if (!fs.existsSync(EXTENSION_BUNDLE_ROOT)) { + return undefined; + } + const versions = fs + .readdirSync(EXTENSION_BUNDLE_ROOT) + .filter((name) => { + const fullPath = path.join(EXTENSION_BUNDLE_ROOT, name); + return /^\d+\.\d+\.\d+/.test(name) && fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory(); + }) + .sort(compareSemverDesc); + const version = versions[0]; + if (!version) { + return undefined; + } + const bundleDir = path.join(EXTENSION_BUNDLE_ROOT, version); + return { + version, + bundleDir, + sidecarPath: path.join(bundleDir, BUNDLE_SIDECAR_FILE), + }; +} + +function listBundleFiles(bundleDir) { + const files = []; + const stack = [bundleDir]; + while (stack.length > 0) { + const current = stack.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + const relPath = path.relative(bundleDir, fullPath).split(path.sep).join('/'); + if (relPath === BUNDLE_SIDECAR_FILE) { + continue; + } + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile()) { + files.push({ relPath, fullPath }); + } + } + } + files.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0)); + return files; +} + +function computeBundleContentHash(bundleDir, files) { + const hash = crypto.createHash('sha256'); + const nul = Buffer.from([0]); + for (const file of files) { + const stat = fs.statSync(file.fullPath); + hash.update(Buffer.from(file.relPath, 'utf8')); + hash.update(nul); + hash.update(Buffer.from(String(stat.size), 'utf8')); + hash.update(nul); + hash.update(fs.readFileSync(file.fullPath)); + hash.update(nul); + } + return hash.digest('base64'); +} + +function verifyLogicAppsExtensionBundle(label) { + const state = findLatestExtensionBundle(); + if (!state) { + throw new Error(`[${label}] Logic Apps extension bundle root is missing or has no version folders: ${EXTENSION_BUNDLE_ROOT}`); + } + if (!fs.existsSync(state.sidecarPath)) { + throw new Error(`[${label}] Logic Apps extension bundle sidecar is missing: ${state.sidecarPath}`); + } + + let sidecar; + try { + sidecar = JSON.parse(fs.readFileSync(state.sidecarPath, 'utf8')); + } catch (error) { + throw new Error(`[${label}] Logic Apps extension bundle sidecar is not valid JSON: ${error.message}`); + } + if (!sidecar || typeof sidecar.sourceMd5 !== 'string' || typeof sidecar.contentHash !== 'string') { + throw new Error(`[${label}] Logic Apps extension bundle sidecar is missing sourceMd5/contentHash: ${state.sidecarPath}`); + } + + const files = listBundleFiles(state.bundleDir); + if (files.length === 0) { + throw new Error(`[${label}] Logic Apps extension bundle directory is empty: ${state.bundleDir}`); + } + const contentHash = computeBundleContentHash(state.bundleDir, files); + if (contentHash !== sidecar.contentHash) { + throw new Error( + `[${label}] Logic Apps extension bundle content hash mismatch for ${state.bundleDir}: expected ${sidecar.contentHash}, got ${contentHash}` + ); + } + console.log( + `[${label}] Logic Apps extension bundle healthy: version=${state.version}, files=${files.length}, sidecar=${state.sidecarPath}` + ); + return state; +} + +function pruneUnhealthyLogicAppsExtensionBundles(label) { + if (!fs.existsSync(EXTENSION_BUNDLE_ROOT)) { + return; + } + + const versions = fs + .readdirSync(EXTENSION_BUNDLE_ROOT) + .filter((name) => { + const fullPath = path.join(EXTENSION_BUNDLE_ROOT, name); + return /^\d+\.\d+\.\d+/.test(name) && fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory(); + }) + .sort(compareSemverDesc); + + for (const version of versions) { + const bundleDir = path.join(EXTENSION_BUNDLE_ROOT, version); + try { + const sidecarPath = path.join(bundleDir, BUNDLE_SIDECAR_FILE); + if (!fs.existsSync(sidecarPath)) { + console.log(`[${label}] Removing bundle ${version}: missing sidecar ${sidecarPath}`); + fs.rmSync(bundleDir, { recursive: true, force: true }); + continue; + } + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); + const files = listBundleFiles(bundleDir); + const contentHash = files.length > 0 ? computeBundleContentHash(bundleDir, files) : undefined; + if (!sidecar || typeof sidecar.contentHash !== 'string' || files.length === 0 || contentHash !== sidecar.contentHash) { + console.log(`[${label}] Removing bundle ${version}: invalid sidecar/content hash`); + fs.rmSync(bundleDir, { recursive: true, force: true }); + } + } catch (error) { + console.log(`[${label}] Removing bundle ${version}: health check failed (${error.message})`); + fs.rmSync(bundleDir, { recursive: true, force: true }); + } + } +} + +function createExTester() { + return new ExTester( + undefined, // storageFolder — use default (os.tmpdir()/test-resources) + undefined, // releaseType — Stable + extDir // extensionsDir — isolated dir, passed as --extensions-dir + ); +} + /** * Recursively copy a directory, skipping test-extensions itself to avoid infinite recursion. */ @@ -93,9 +259,80 @@ function installExtensionWithCli(cliBase, dep, label = dep) { }); } -async function downloadExTesterAssets(extest) { - await withDownloadRetry(`download VS Code ${VSCODE_VERSION}`, () => extest.downloadCode(VSCODE_VERSION)); - await withDownloadRetry(`download ChromeDriver ${VSCODE_VERSION}`, () => extest.downloadChromeDriver(VSCODE_VERSION)); +function findNestedWindowsCliPath(codeFolder) { + if (process.platform !== 'win32') { + return undefined; + } + + try { + for (const entry of fs.readdirSync(codeFolder, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const candidate = path.join(codeFolder, entry.name, 'resources', 'app', 'out', 'cli.js'); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + /* diagnostic only */ + } + + return undefined; +} + +function preflightVSCodeCli(extest) { + const codeFolder = extest.code.getCodeFolder(); + const cliPath = extest.code.getCliPath(); + const executablePath = extest.code.executablePath; + const nestedCliPath = findNestedWindowsCliPath(codeFolder); + + if (!fs.existsSync(executablePath)) { + throw new Error(`VS Code executable not found at ${executablePath}. Clear ${codeFolder} and rerun the E2E launcher.`); + } + + if (!fs.existsSync(cliPath)) { + const nestedHint = nestedCliPath ? ` Found nested Windows CLI at ${nestedCliPath}, but ExTester resolved ${cliPath}.` : ''; + throw new Error( + `VS Code CLI not found at ${cliPath}.${nestedHint} ` + + `Clear ${codeFolder} and rerun, or update vscode-extension-tester if the VS Code archive layout changed.` + ); + } + + let output; + try { + output = execSync(`${extest.code.getCliInitCommand()} -v`, { + encoding: 'utf8', + env: extest.code.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + const nestedHint = nestedCliPath ? ` Nested Windows CLI candidate: ${nestedCliPath}.` : ''; + throw new Error( + `VS Code CLI preflight failed for ${cliPath}.${nestedHint} ` + + `Clear ${codeFolder} and rerun, or update vscode-extension-tester if the VS Code archive layout changed.\n${error.message}` + ); + } + + const actualVersion = output.split(/\r?\n/)[0]?.trim(); + if (/^\d+\.\d+\.\d+/.test(VSCODE_VERSION) && actualVersion !== VSCODE_VERSION) { + throw new Error( + `VS Code CLI preflight resolved version ${actualVersion || ''}, expected ${VSCODE_VERSION}. Clear ${codeFolder} and rerun.` + ); + } + + console.log(` ✓ VS Code CLI preflight: ${actualVersion} (${cliPath})`); +} + +async function downloadExTesterAssets() { + await withDownloadRetry(`download VS Code ${VSCODE_VERSION}`, async () => { + const downloadTester = createExTester(); + await downloadTester.downloadCode(VSCODE_VERSION); + preflightVSCodeCli(createExTester()); + }); + + await withDownloadRetry(`download ChromeDriver ${VSCODE_VERSION}`, () => createExTester().downloadChromeDriver(VSCODE_VERSION)); } /** @@ -261,6 +498,33 @@ async function parallelLimit(taskFns, limit) { } async function main() { + // Fast path: bundleintegrityonly is a pure-Node Mocha probe that needs + // neither the built extension (dist/package.json) nor ExTester / VS Code. + // Short-circuit before any of those checks so the phase can run on a + // fresh checkout (or any shard) without first running the extension build. + const earlyMode = (process.env.E2E_MODE || 'full').toLowerCase(); + if (earlyMode === 'bundleintegrityonly') { + const outTestDir = path.resolve(__dirname, '..', '..', '..', 'out', 'test'); + const file = path.join(outTestDir, 'bundleCdnHealth.test.js').replace(/\\/g, '/'); + if (!fs.existsSync(file)) { + console.error(`\n✖ Compiled test not found: ${file}\n Run: npx tsup --config tsup.e2e.test.config.ts`); + process.exit(2); + } + try { + delete require.cache[require.resolve(file)]; + } catch { + /* ignore */ + } + const Mocha = require('mocha'); + const mocha = new Mocha({ color: true, timeout: 60_000, reporter: 'spec' }); + mocha.addFile(file); + const exit = await new Promise((resolve) => { + mocha.run((failures) => resolve(failures > 0 ? 1 : 0)); + }); + console.log(`\n=== bundleintegrityonly exit code: ${exit} ===`); + process.exit(exit); + } + const { ExTester } = require('vscode-extension-tester'); // ------------------------------------------------------------------ @@ -313,22 +577,21 @@ async function main() { console.log(`dist/ source: ${distDir}`); console.log(`Extensions dir: ${extDir}`); - // Create ExTester WITHOUT coverage — we don't need --extensionDevelopmentPath - // because we're copying our extension directly into --extensions-dir - const extest = new ExTester( - undefined, // storageFolder — use default (os.tmpdir()/test-resources) - undefined, // releaseType — Stable - extDir // extensionsDir — isolated dir, passed as --extensions-dir - ); - // Step 1: Download VS Code + ChromeDriver (skips if already cached) console.log('\n=== Step 1: Download VS Code + ChromeDriver ==='); - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); - // Step 2: Install extension dependencies from the marketplace (PARALLEL) + // Create ExTester WITHOUT coverage — we don't need --extensionDevelopmentPath + // because we're copying our extension directly into --extensions-dir. + // This must happen after download/preflight so it cannot retain stale CLI + // state from a VS Code archive layout that changed during download. + const extest = createExTester(); + + // Step 2: Install extension dependencies from the marketplace. // Skip deps already present in test-extensions/. For uncached deps, - // run VS Code CLI --install-extension in parallel instead of sequentially - // to cut install time from ~60-90s to ~30-40s (limited by the largest dep). + // run VS Code CLI --install-extension sequentially. Parallel CLI installs + // race on shared extension/cache directories and can leave VS Code with + // partially extracted dependencies even when a follow-up retry succeeds. const getExtensionEntries = (extensionId) => { if (!fs.existsSync(extDir)) { return []; @@ -390,13 +653,7 @@ async function main() { // but we can run it with async exec instead of execSync. const cliBase = extest.code.getCliInitCommand(); - // Concurrency limit of 3 to avoid EPERM/ENOENT race conditions. - // Multiple CLI processes that install the same transitive dependency - // (e.g., both csharp and csdevkit pull in dotnet-runtime) corrupt - // the shared CachedExtensionVSIXs directory when run simultaneously. - // With 3 slots, smaller deps finish first and free a slot before - // the larger dotnet deps start, reducing overlap. - const CONCURRENCY = 3; + const CONCURRENCY = 1; console.log(` Installing ${depsToInstall.length} deps (concurrency=${CONCURRENCY})...`); const taskFns = depsToInstall.map((dep) => () => { @@ -727,6 +984,11 @@ async function main() { } try { + const rootBinDir = path.join(nodeJsRoot, 'bin'); + if (fs.existsSync(path.join(rootBinDir, 'node'))) { + return rootBinDir; + } + const nodeSubfolder = fs .readdirSync(nodeJsRoot, { withFileTypes: true }) .find((entry) => entry.isDirectory() && entry.name.includes('node'))?.name; @@ -746,6 +1008,8 @@ async function main() { const nodeJsDir = resolveNodeBinDir(depsRoot); const funcToolsDir = path.join(depsRoot, 'FuncCoreTools'); const funcExecutable = process.platform === 'win32' ? 'func.exe' : 'func'; + const nodeExecutable = process.platform === 'win32' ? 'node.exe' : 'node'; + const dotnetExecutable = process.platform === 'win32' ? 'dotnet.exe' : 'dotnet'; const funcBinaryCandidates = [ path.join(funcToolsDir, funcExecutable), path.join(funcToolsDir, 'in-proc8', funcExecutable), @@ -758,8 +1022,8 @@ async function main() { dotnetSdkDir: path.join(depsRoot, 'DotNetSDK'), nodeJsDir, funcBinary, - dotnetBinary: path.join(depsRoot, 'DotNetSDK', 'dotnet'), - nodeBinary: path.join(nodeJsDir, 'node'), + dotnetBinary: path.join(depsRoot, 'DotNetSDK', dotnetExecutable), + nodeBinary: path.join(nodeJsDir, nodeExecutable), }; }; @@ -769,13 +1033,44 @@ async function main() { }; const shouldValidateRuntimeDependencies = () => !runtimeDependenciesReady(); + const pruneInvalidRuntimeDependencyRoots = (label) => { + const { depsRoot, funcToolsDir, dotnetSdkDir, nodeJsDir, funcBinary, dotnetBinary, nodeBinary } = getRuntimeDependencyPaths(); + const dependencyRoots = [ + { label: 'NodeJs', root: path.join(depsRoot, 'NodeJs'), probes: [nodeBinary] }, + { label: 'FuncCoreTools', root: funcToolsDir, probes: [funcBinary] }, + { label: 'DotNetSDK', root: dotnetSdkDir, probes: [dotnetBinary] }, + ]; + + for (const dependency of dependencyRoots) { + if (!fs.existsSync(dependency.root)) { + continue; + } + + const hasProbe = dependency.probes.some((probe) => fs.existsSync(probe)); + if (!hasProbe) { + const children = fs.readdirSync(dependency.root).slice(0, 10); + console.log( + `[${label}] Removing incomplete ${dependency.label} dependency root: ${dependency.root} children=[${children.join(', ')}] probes=[${dependency.probes.join(', ')}]` + ); + fs.rmSync(dependency.root, { recursive: true, force: true }); + } + } + }; + // Create a VS Code settings file. Called before each phase group so we can // enable dependency validation for Phase 4.1 (first run) and disable it // for all subsequent phases (saves 30-60s per session startup). const settingsFile = path.join(projectDir, 'out', 'test', 'vscode-settings.json'); fs.mkdirSync(path.dirname(settingsFile), { recursive: true }); - const writeTestSettings = ({ validateDependencies = false, autoStartDesignTime = true, includeRuntimeDependencyPaths = true } = {}) => { + const writeTestSettings = ({ + validateDependencies = false, + autoStartDesignTime = true, + includeRuntimeDependencyPaths = true, + useExperimentalBundle = false, + experimentalBundleSourceUri = '', + experimentalBundleVersion = '', + } = {}) => { const settings = { 'extensions.autoUpdate': false, 'extensions.autoCheckUpdates': false, @@ -800,6 +1095,7 @@ async function main() { // Dependency validation: Phase 4.1 needs this ON (first run downloads/validates // binaries). All subsequent phases set it OFF since paths are already resolved. 'azureLogicAppsStandard.autoRuntimeDependenciesValidationAndInstallation': validateDependencies, + 'azureLogicAppsStandard.e2eStrictDependencyValidation': process.env.LA_E2E_STRICT_DEPENDENCY_VALIDATION === '1', // Design-time auto-start: ON for tests that need the runtime (designer, run), // OFF for tests that only check UI/conversion to save startup time. 'azureLogicAppsStandard.autoStartDesignTime': autoStartDesignTime, @@ -813,6 +1109,13 @@ async function main() { // the generated task chain has started instead of waiting the default // 60 s. Other phases never reach pickProcess so this is harmless. 'azureLogicAppsStandard.pickProcessTimeout': 15, + // Experimental-bundle opt-ins. Off by default for every phase so the + // standard CDN flow continues to be tested. The bundleintegrityonly + // phase or any future phase that wants to test a private bundle can + // flip these via writeTestSettings options. + 'azureLogicAppsStandard.useExperimentalExtensionBundle': useExperimentalBundle, + 'azureLogicAppsStandard.experimentalExtensionBundleSourceUri': experimentalBundleSourceUri, + 'azureLogicAppsStandard.experimentalExtensionBundleVersion': experimentalBundleVersion, }; if (includeRuntimeDependencyPaths) { const { depsRoot, funcBinary, dotnetBinary, nodeBinary } = getRuntimeDependencyPaths(); @@ -885,6 +1188,7 @@ async function main() { const phase1aFiles = [testFile('createWorkspace.fixtures.test.js')]; const phase2Files = [testFile('designerActions.test.js')]; + const connectionPromptFallbackFiles = [testFile('connectionPromptFallback.test.js')]; // Each new test gets its own phase (fresh VS Code session) to avoid // workspace-switch contention with the previous test's debug processes. @@ -908,6 +1212,19 @@ async function main() { const phase10ModernFiles = [testFile('codefulDebugTasksModern.test.js')]; const phase10LegacyFiles = [testFile('codefulDebugTasksLegacy.test.js')]; + // Phase 4.11 — Bundle CDN integrity probe. Pure Mocha (no ExTester / VS Code). + // Verifies that cdn.functions.azure.com still emits Content-Length + + // Content-MD5 headers we depend on for download integrity verification, and + // does a small live download through the verifier helper. + const phaseBundleIntegrityFiles = [testFile('bundleCdnHealth.test.js')]; + + // Phase 4.12 — Bundle on-disk repair E2E. Real ExTester / VS Code session. + // Reproduces the user-reported regression (deleting files from inside an + // installed bundle is detected on next validate-and-install, repaired + // synchronously, and the sidecar is rewritten so the cached hash matches + // disk). See bundleRepair.test.ts for the full scenario. + const phaseBundleRepairFiles = [testFile('bundleRepair.test.js')]; + // ------------------------------------------------------------------ // Per-scenario inventory (Phase A scaffold). // @@ -964,7 +1281,7 @@ async function main() { id: 'p41b-createworkspace-behavior', testFile: phase1Files, workspaceSpec: 'self-creates', - settings: { validateDependencies: true, autoStartDesignTime: true }, + settings: { validateDependencies: false, autoStartDesignTime: false }, monolithic: true, }, @@ -990,6 +1307,13 @@ async function main() { settings: { validateDependencies: false, autoStartDesignTime: false }, env: { LA_E2E_SHAPE: 'rulesEngine', LA_E2E_SKIP_VALIDATION_WAIT: '1' }, }, + { + id: 'p42-connectionprompt', + testFile: connectionPromptFallbackFiles[0], + workspaceSpec: { appType: 'standard', wfType: 'Stateful' }, + settings: { validateDependencies: false, autoStartDesignTime: false }, + env: { LA_E2E_SKIP_VALIDATION_WAIT: '1' }, + }, // Phases 4.3-4.6 — runtime-touching consumer tests { @@ -1033,7 +1357,7 @@ async function main() { { id: 'p46-keyboardnav', testFile: phase6Files[0], - workspaceSpec: { appType: 'standard', wfType: 'Stateful', use: 'p41a-fixtures' }, + workspaceSpec: { appType: 'standard', wfType: 'Stateful' }, settings: { validateDependencies: false, autoStartDesignTime: true }, }, @@ -1073,10 +1397,25 @@ async function main() { workspaceSpec: { appType: 'standard', wfType: 'Stateful', use: 'appDir' }, settings: { validateDependencies: true, autoStartDesignTime: false }, }, + + // Phase 4.12 — On-disk bundle repair / integrity gate (Phase 14 code path). + // Reuses a Standard/Stateful workspace from the fixtures manifest, lets the + // initial bundle install, deletes a couple .dll files from inside the + // installed bundle, invokes the validate-and-install-binaries command, and + // asserts the bundle is repaired on disk (sidecar back, .dlls back, + // recomputed content hash matches sidecar). autoStartDesignTime is OFF so + // func.exe does not lock the .dlls we tamper with. + { + id: 'p412-bundlerepair', + testFile: phaseBundleRepairFiles[0], + workspaceSpec: { appType: 'standard', wfType: 'Stateful' }, + settings: { validateDependencies: true, autoStartDesignTime: false }, + }, ]; const e2eMode = (process.env.E2E_MODE || 'full').toLowerCase(); console.log(`\nE2E mode: ${e2eMode}`); + console.log(`VS Code version: ${VSCODE_VERSION} (${VSCODE_VERSION_SOURCE})`); // Note: shard reliability is gated by helpers in runHelpers.ts (waitForRuntimeReady, // clickRunTrigger, assertRunTriggerable) and helpers.ts (selectCreateWorkspaceCommand, // switchToWebviewFrame, openFolderInSession, waitForWorkbenchReady). All CI-dependent @@ -1264,12 +1603,40 @@ async function main() { /* ignore */ } } - const phaseTester = new ExTester(undefined, undefined, extDir); + const phaseTester = createExTester(); const code = await phaseTester.runTests(files, phaseRunOptions); console.log(` ${phaseName} exit code: ${code}`); return code; }; + // Runs Mocha against compiled test files directly, without spawning a VS Code + // session. Used for the bundle CDN integrity probe, which is a pure Node + // test (HTTP HEAD + small download) and does not need ExTester. + const runMochaPhase = async (phaseName, files) => { + console.log(`\n=== ${phaseName} (Mocha-only, no VS Code) ===`); + console.log(` Files: ${files.join(', ')}`); + for (const file of files) { + try { + delete require.cache[require.resolve(file)]; + } catch { + /* ignore */ + } + } + // Lazy-require Mocha so other phases don't pay the cost. + const Mocha = require('mocha'); + const mocha = new Mocha({ color: true, timeout: 60_000, reporter: 'spec' }); + for (const file of files) { + mocha.addFile(file); + } + return await new Promise((resolve) => { + mocha.run((failures) => { + const code = failures > 0 ? 1 : 0; + console.log(` ${phaseName} exit code: ${code}`); + resolve(code); + }); + }); + }; + const configureCodefulRecorderEnvironment = () => { const eventsFile = path.join(os.tmpdir(), 'la-e2e-test', 'codeful-events.jsonl'); const triggerDir = path.join(os.tmpdir(), 'la-e2e-test', 'triggers'); @@ -1647,6 +2014,11 @@ namespace ${namespaceName} } await prepareFreshSession(id); + if (id === 'p41a-fixtures' && process.env.LA_E2E_STRICT_DEPENDENCY_VALIDATION === '1') { + pruneInvalidRuntimeDependencyRoots(`prelaunch:${id}`); + pruneUnhealthyLogicAppsExtensionBundles(`prelaunch:${id}`); + } + const { resources, legacyDir } = selectWorkspaceForSpec(workspaceSpec, id); if (legacyDir) { process.env.LA_E2E_LEGACY_PROJECT_DIR = legacyDir; @@ -1655,7 +2027,13 @@ namespace ${namespaceName} if (!monolithic && fileList.length !== 1) { console.warn(` [${id}] Non-monolithic scenario received ${fileList.length} files; running all of them`); } + if (process.env.LA_E2E_BUNDLE_PREFLIGHT === '1') { + verifyLogicAppsExtensionBundle(`preflight:${id}`); + } const exit = await runPhase(`Scenario ${id}`, fileList, { resources }); + if (exit === 0 && id === 'p41a-fixtures') { + verifyLogicAppsExtensionBundle('p41a-fixtures'); + } // Restore env overrides so subsequent scenarios aren't contaminated. for (const { key, prev } of envOverridesApplied) { if (prev === undefined) { @@ -1688,19 +2066,19 @@ namespace ${namespaceName} process.exit(2); } console.log(`\nRunning single scenario (LA_E2E_SCENARIO): ${singleScenarioId}`); - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const singleExit = await runScenarioPhases([scenarioEntry]); process.exit(singleExit); } if (e2eMode === 'scenarios') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const scenariosExit = await runScenarioPhases(scenarios); process.exit(scenariosExit); } if (e2eMode === 'scenarios-pilot') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); // Pilot exactly one scenario: inlineJavascript. Decision gate per the // per-scenario re-architecture plan — if this passes where the current // createplusnewtests shard fails Phase 4.3, the new pattern is validated. @@ -1725,15 +2103,28 @@ namespace ${namespaceName} } if (e2eMode === 'codefuldebugonly') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const phase10Exit = await runCodefulDebugPhases('phase10-only'); process.exit(phase10Exit); } + // bundleintegrityonly is handled by the early short-circuit at the top + // of main() (search for `earlyMode === 'bundleintegrityonly'`). + + if (e2eMode === 'bundlerepaironly') { + const bundleRepairScenario = scenarios.find((s) => s.id === 'p412-bundlerepair'); + if (!bundleRepairScenario) { + throw new Error('bundlerepaironly: p412-bundlerepair scenario not found in scenarios[] table'); + } + await downloadExTesterAssets(); + const phase12Exit = await runScenarioPhases([bundleRepairScenario]); + process.exit(phase12Exit); + } + if (e2eMode === 'nonlogicappstartup') { // Startup regression test: intentionally omit runtime dependency paths to // exercise extension activation in a plain, non-Logic-App folder. - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); writeTestSettings({ validateDependencies: false, autoStartDesignTime: false, includeRuntimeDependencyPaths: false }); await prepareFreshSession('nonlogicappstartup-only'); @@ -1743,7 +2134,7 @@ namespace ${namespaceName} if (e2eMode === 'designeronly') { // Ensure VS Code and ChromeDriver are downloaded - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); writeTestSettings({ validateDependencies: shouldValidateRuntimeDependencies(), autoStartDesignTime: true }); await prepareFreshSession('phase2-only'); @@ -1756,7 +2147,7 @@ namespace ${namespaceName} if (e2eMode === 'newtestsonly') { // Run only the new tests (phases 4.3–4.6) each in their own session - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); writeTestSettings({ validateDependencies: shouldValidateRuntimeDependencies(), autoStartDesignTime: true }); const wsResources = getPhase2Resources(); const exits = []; @@ -1783,7 +2174,7 @@ namespace ${namespaceName} if (e2eMode === 'conversiononly') { // Run only the workspace conversion tests (phases 4.8a–4.8d) - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); // ALL conversion tests need validateDependencies ON so the extension // fully activates and detects legacy projects / shows conversion dialog. writeTestSettings({ validateDependencies: true, autoStartDesignTime: false }); @@ -1873,7 +2264,7 @@ namespace ${namespaceName} if (e2eMode === 'conversioncreateonly') { // Run only Phase 4.8b: Open legacy project folder (no .code-workspace), // click Yes, then verify one Create click starts and completes workspace creation. - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); writeTestSettings({ validateDependencies: true, autoStartDesignTime: false }); const legacyDir = createLegacyProjectFixture('conversioncreateonly'); @@ -1885,7 +2276,7 @@ namespace ${namespaceName} } if (e2eMode === 'createonly') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); await prepareFreshSession('phase1-only'); const phase1Exit = await runPhase('Phase 4.1: createWorkspace session', phase1Files); process.exit(phase1Exit); @@ -1909,7 +2300,7 @@ namespace ${namespaceName} // ---------------------------------------------------------------------- if (e2eMode === 'independentonly') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const exits = []; // Phase 4.0: nonLogicAppStartup — plain folder, no Logic App context. @@ -1926,13 +2317,17 @@ namespace ${namespaceName} process.env.LA_E2E_LEGACY_PROJECT_DIR = legacyDir; exits.push(await runPhase('Phase 4.8b: conversionCreate', phase8bFiles, { resources: [legacyDir] })); + // Phase 4.11: bundleCdnHealth — pure Mocha, no VS Code. Confirms the + // CDN still emits Content-Length + Content-MD5 headers we rely on. + exits.push(await runMochaPhase('Phase 4.11: bundleCdnHealth', phaseBundleIntegrityFiles)); + const finalExit = Math.max(...exits); - console.log(`\n=== Independent shard results: 4.0=${exits[0]}, 4.8b=${exits[1]} → exit ${finalExit} ===`); + console.log(`\n=== Independent shard results: 4.0=${exits[0]}, 4.8b=${exits[1]}, 4.11=${exits[2]} → exit ${finalExit} ===`); process.exit(finalExit); } if (e2eMode === 'createplusdesigner') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const exits = []; // Phase 4.1: createWorkspace — needed to produce the manifest consumed @@ -1949,6 +2344,13 @@ namespace ${namespaceName} const phase2Resources = getPhase2Resources(); exits.push(await runPhase('Phase 4.2: designerActions', phase2Files, { resources: phase2Resources })); + // Phase 4.2b: connector prompt cancellation fallback — deliberately + // removes Azure connector sentinel settings and verifies designer load. + writeTestSettings({ validateDependencies: false, autoStartDesignTime: false }); + await new Promise((r) => setTimeout(r, 3000)); + await prepareFreshSession('phase2b-shard'); + exits.push(await runPhase('Phase 4.2b: connectionPromptFallback', connectionPromptFallbackFiles, { resources: phase2Resources })); + // Phase 4.7: demo/smoke/standalone/dataMapper. dataMapper depends on // Phase 4.1's manifest; the others are quick (~14s total for the three). // Co-locating with `designer` keeps them all on a Phase-4.1-aware runner @@ -1959,12 +2361,14 @@ namespace ${namespaceName} exits.push(await runPhase('Phase 4.7: remaining suites', phase7Files)); const finalExit = Math.max(...exits); - console.log(`\n=== Designer shard results: 4.1=${exits[0]}, 4.2=${exits[1]}, 4.7=${exits[2]} → exit ${finalExit} ===`); + console.log( + `\n=== Designer shard results: 4.1=${exits[0]}, 4.2=${exits[1]}, 4.2b=${exits[2]}, 4.7=${exits[3]} → exit ${finalExit} ===` + ); process.exit(finalExit); } if (e2eMode === 'createplusnewtests') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const exits = []; writeTestSettings({ validateDependencies: true, autoStartDesignTime: true }); @@ -1998,7 +2402,7 @@ namespace ${namespaceName} } if (e2eMode === 'createplusconversion') { - await downloadExTesterAssets(extest); + await downloadExTesterAssets(); const exits = []; writeTestSettings({ validateDependencies: true, autoStartDesignTime: true }); diff --git a/docs/ai-setup/packages/vs-code-designer.md b/docs/ai-setup/packages/vs-code-designer.md index 164070f80d5..9fdc6e47e7a 100644 --- a/docs/ai-setup/packages/vs-code-designer.md +++ b/docs/ai-setup/packages/vs-code-designer.md @@ -142,6 +142,7 @@ Each test runs in its own fresh VS Code session to avoid workspace-switch conten | 4.5 | designerViewExtended.test.ts | Parallel branches + run-after (ADO #10109401) | | 4.6 | keyboardNavigation.test.ts | Ctrl+Up/Down + Ctrl+Alt+P / Ctrl+Shift+P hotkey contract | | 4.7 | dataMapper.test.ts, demo, smoke, standalone | Data Mapper + generic tests | +| 4.11 | bundleCdnHealth.test.ts | CDN integrity headers probe (`Content-Length` / `Content-MD5` on the Workflows extension bundle). Pure Mocha — no VS Code session. | ### Shared Helper Modules @@ -192,9 +193,10 @@ $env:E2E_MODE = "newtestsonly" # Phases 4.3-4.6 only (requires prior $env:E2E_MODE = "conversiononly" # Phases 4.8a-e only (requires prior 4.1 manifest) $env:E2E_MODE = "conversioncreateonly" # Phase 4.8b only (builds own legacy fixture) $env:E2E_MODE = "nonlogicappstartup" # Phase 4.0 only +$env:E2E_MODE = "bundleintegrityonly" # Phase 4.11 — pure-Mocha CDN integrity probe (no VS Code) # CI matrix shard modes (each runs on its own GitHub Actions runner): -$env:E2E_MODE = "independentonly" # 4.0 + 4.8b — no Phase 4.1 dep +$env:E2E_MODE = "independentonly" # 4.0 + 4.8b + 4.11 — no Phase 4.1 dep $env:E2E_MODE = "createplusdesigner" # 4.1 → 4.2, 4.7 $env:E2E_MODE = "createplusnewtests" # 4.1 → 4.3, 4.4, 4.5, 4.6 $env:E2E_MODE = "createplusconversion" # 4.1 → 4.8a, 4.8c, 4.8d, 4.8e