diff --git a/.github/actions/select-tests/action.yml b/.github/actions/select-tests/action.yml index 2dd69bc290a..0aade3856d6 100644 --- a/.github/actions/select-tests/action.yml +++ b/.github/actions/select-tests/action.yml @@ -12,7 +12,7 @@ inputs: setupDotNet: required: false default: 'true' - description: 'Whether to bootstrap the .NET SDK (./dotnet.sh --version). Set false when the caller already installed the SDK in the same job. Selection only needs the SDK -- not a full repo restore -- because it builds an MSBuild ProjectGraph, which auto-resolves SDKs.' + description: 'Whether to bootstrap the .NET SDK and the runtime required by SelectTests. Set false when the caller already installed the toolchain in the same job. Selection does not require a full repo restore because it builds an MSBuild ProjectGraph, which auto-resolves SDKs.' enforce: required: false default: 'false' @@ -83,14 +83,23 @@ runs: with: persist-credentials: false - # Minimal SDK bootstrap -- not a full ./restore.sh. dotnet.sh -> InitializeDotNetCli installs only - # the SDK into repo-local .dotnet (no runtime toolsets, no project restore). SelectTests then builds - # its MSBuild ProjectGraph over the solution on this SDK (MSBuild SDKs auto-resolve) -- selection - # does not need the repo restored. - - name: Install SDK (minimal) + # Minimal toolchain bootstrap -- not a full ./restore.sh. The repo SDK is .NET 11 while + # SelectTests intentionally targets .NET 10 for its Microsoft.Build ProjectGraph dependency, + # so install that one pinned runtime without restoring the rest of the repo toolsets. + - name: Install toolchain (minimal) if: ${{ inputs.setupDotNet == 'true' }} shell: bash - run: ./dotnet.sh --version + run: | + set -euo pipefail + ./dotnet.sh --version + runtime_version="$(sed -n 's:.*\(.*\).*:\1:p' eng/Versions.props)" + if [ -z "$runtime_version" ]; then + echo "::error::Could not read DotNetRuntimeNet10VersionForTesting from eng/Versions.props." >&2 + exit 1 + fi + ./eng/common/dotnet-install.sh \ + -runtime dotnet \ + -version "$runtime_version" - name: Select relevant tests id: select diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 46b087737fc..0157ba505b2 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -65,15 +65,10 @@ "version": "v7.0.1", "sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" }, - "github/gh-aw-actions/setup-cli@v0.85.4": { - "repo": "github/gh-aw-actions/setup-cli", - "version": "v0.85.4", - "sha": "2709137ea6c5b0e19aa621454dc643ea8dc526b1" - }, - "github/gh-aw-actions/setup@v0.85.4": { + "github/gh-aw-actions/setup@v0.86.2": { "repo": "github/gh-aw-actions/setup", - "version": "v0.85.4", - "sha": "2709137ea6c5b0e19aa621454dc643ea8dc526b1" + "version": "v0.86.2", + "sha": "6aab9e5b5c91c615506061f09bedd81a23babe3c" } }, "containers": { diff --git a/.github/workflows/analyze-ci-failure.lock.yml b/.github/workflows/analyze-ci-failure.lock.yml index 0c65b3b7d12..b3bcb79e0fa 100644 --- a/.github/workflows/analyze-ci-failure.lock.yml +++ b/.github/workflows/analyze-ci-failure.lock.yml @@ -1,6 +1,6 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c44db4d6b9d3eb5fcff0743f5be77375e66dec9b1c263c8d33deea9dcb257240","body_hash":"e9f3545ccbe75e728bdac1140123f0ebea921e69ff4b3a44a28fa977bf00ba74","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"34e114876b0b11c390a56381ad16ebd13914f8d5","version":"v4"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/download-artifact","sha":"d3f86a106a0bac45b974a628896c90dbdf5c8093","version":"v4"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"actions/upload-artifact","sha":"ea165f8d65b6e75b540449e92b4886f43607fa02","version":"v4"},{"repo":"github/gh-aw-actions/setup","sha":"2709137ea6c5b0e19aa621454dc643ea8dc526b1","version":"v0.85.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} -# This file was automatically generated by gh-aw (v0.85.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c44db4d6b9d3eb5fcff0743f5be77375e66dec9b1c263c8d33deea9dcb257240","body_hash":"e9f3545ccbe75e728bdac1140123f0ebea921e69ff4b3a44a28fa977bf00ba74","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"34e114876b0b11c390a56381ad16ebd13914f8d5","version":"v4"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/download-artifact","sha":"d3f86a106a0bac45b974a628896c90dbdf5c8093","version":"v4"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"actions/upload-artifact","sha":"ea165f8d65b6e75b540449e92b4886f43607fa02","version":"v4"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} +# This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ # / _ \ | | (_) @@ -34,7 +34,6 @@ # - ENABLE_RERUN: (main workflow) # # Secrets used: -# - COPILOT_GITHUB_TOKEN # - GH_AW_GITHUB_MCP_SERVER_TOKEN # - GH_AW_GITHUB_TOKEN # - GITHUB_TOKEN @@ -51,15 +50,15 @@ # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 # - actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 -# - github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 +# - github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 # - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 # - ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 -# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 +# - ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f # - ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 -# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 +# - ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e name: "Analyze CI Failure" on: @@ -126,7 +125,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -136,7 +135,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Analyze CI Failure" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/analyze-ci-failure.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate agentic run info @@ -145,9 +144,9 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.78" - GH_AW_INFO_AGENT_VERSION: "1.0.78" - GH_AW_INFO_CLI_VERSION: "v0.85.4" + GH_AW_INFO_VERSION: "1.0.79" + GH_AW_INFO_AGENT_VERSION: "1.0.79" + GH_AW_INFO_CLI_VERSION: "v0.86.2" GH_AW_INFO_WORKFLOW_NAME: "Analyze CI Failure" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -157,6 +156,7 @@ jobs: GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_AGENT_RUNTIME: "" GH_AW_COMPILED_STRICT: "true" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -213,7 +213,6 @@ jobs: id: check-oauth-tokens run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - name: Checkout .github and .agents folders @@ -233,8 +232,8 @@ jobs: env: GH_AW_AGENT_FOLDERS: ".agents .github" GH_AW_AGENT_FILES: "AGENTS.md" - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" - name: Check workflow lock file id: check-lock-file uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -250,7 +249,7 @@ jobs: - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.85.4" + GH_AW_COMPILED_VERSION: "v0.86.2" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -261,9 +260,12 @@ jobs: if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" - name: Create prompt with built-in context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ACTIONS_DIR: ${{ runner.temp }}/gh-aw/actions + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}]}" GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -272,63 +274,22 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - # poutine:ignore untrusted_checkout_exec - run: | - bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" - { - cat << 'GH_AW_PROMPT_56ae825d04a19e36_EOF' - - GH_AW_PROMPT_56ae825d04a19e36_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_56ae825d04a19e36_EOF' - - Tools: missing_tool, missing_data, noop, publish_data, rerun_failed_jobs - - GH_AW_PROMPT_56ae825d04a19e36_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_56ae825d04a19e36_EOF' - - The following GitHub context information is available for this workflow: - {{#if github.actor}} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if github.repository}} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if github.workspace}} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} - - **issue-number**: #__GH_AW_EXPR_802A9F6A__ - {{/if}} - {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} - - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ - {{/if}} - {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} - - **pull-request-number**: #__GH_AW_EXPR_463A214A__ - {{/if}} - {{#if github.event.comment.id || github.aw.context.comment_id}} - - **comment-id**: __GH_AW_EXPR_FF1D34CE__ - {{/if}} - {{#if github.run_id}} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_56ae825d04a19e36_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_56ae825d04a19e36_EOF' - - {{#runtime-import .github/workflows/analyze-ci-failure.md}} - GH_AW_PROMPT_56ae825d04a19e36_EOF - } > "$GH_AW_PROMPT" + GH_AW_PROMPT_CONTENT_0000: "\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: missing_tool, missing_data, noop, publish_data, rerun_failed_jobs\n" + GH_AW_PROMPT_CONTENT_0002: "\n" + GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_802A9F6A__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_1A3A194A__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_463A214A__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_FF1D34CE__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n\n\n" + GH_AW_PROMPT_CONTENT_0004: "\n" + GH_AW_PROMPT_CONTENT_0005: "{{#runtime-import .github/workflows/analyze-ci-failure.md}}\n" + with: + script: | + const { setupGlobals } = require(process.env.GH_AW_ACTIONS_DIR + '/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(process.env.GH_AW_ACTIONS_DIR + '/create_prompt.cjs'); + await main(core); - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_ENGINE_ID: "copilot" with: script: | @@ -339,7 +300,7 @@ jobs: - name: Substitute placeholders uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -375,14 +336,18 @@ jobs: }); - name: Validate prompt placeholders env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Stage prompt files for artifact upload + run: | + mkdir -p /tmp/gh-aw/aw-prompts + cp -a "${RUNNER_TEMP}/gh-aw/aw-prompts/." /tmp/gh-aw/aw-prompts/ - name: Upload activation artifact if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -452,7 +417,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -461,7 +426,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Analyze CI Failure" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/analyze-ci-failure.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths @@ -512,11 +477,13 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 --rootless - name: Determine automatic lockdown mode for GitHub MCP Server @@ -545,7 +512,7 @@ jobs: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" @@ -721,22 +688,23 @@ jobs: export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export MCP_GATEWAY_ALLOWED_MOUNT_ROOTS="${GITHUB_WORKSPACE}:rw,${RUNNER_TEMP}/gh-aw:ro,${RUNNER_TEMP}/gh-aw/safeoutputs:rw,/opt:ro,/tmp:rw,/usr/bin/gh:ro" export DEBUG="*" export GH_AW_ENGINE="copilot" MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.8' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e MCP_GATEWAY_ALLOWED_MOUNT_ROOTS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.9' mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_bd6748a39fe70b8b_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_e26f23cc8d0ae67e_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "container": "ghcr.io/github/github-mcp-server:v1.9.0", "env": { "GITHUB_FEATURES": "fields_param", "GITHUB_HOST": "${GITHUB_SERVER_URL}", @@ -793,7 +761,7 @@ jobs: "startupTimeout": 120 } } - GH_AW_MCP_CONFIG_bd6748a39fe70b8b_EOF + GH_AW_MCP_CONFIG_e26f23cc8d0ae67e_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -827,6 +795,18 @@ jobs: printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -851,8 +831,8 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE @@ -866,7 +846,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -1457,7 +1437,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1466,7 +1446,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Analyze CI Failure" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/analyze-ci-failure.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1705,7 +1685,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1714,7 +1694,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Analyze CI Failure" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/analyze-ci-failure.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1766,25 +1746,7 @@ jobs: - name: Prepare threat detection files if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - rm -f /tmp/gh-aw/agent_usage.json - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then - echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context." - fi - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - for f in /tmp/gh-aw/aw-*.bundle; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + bash "${RUNNER_TEMP}/gh-aw/actions/prepare_threat_detection_files.sh" - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1804,18 +1766,20 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - rm -f /tmp/gh-aw/threat-detection/step-summary.md - touch /tmp/gh-aw/threat-detection/step-summary.md + rm -f /tmp/gh-aw/step-summary.md + touch /tmp/gh-aw/step-summary.md - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Execute GitHub Copilot CLI @@ -1831,6 +1795,18 @@ jobs: mkdir -p "$HOME/.copilot" printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -1857,9 +1833,10 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: + GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode @@ -1871,14 +1848,13 @@ jobs: GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] @@ -1887,6 +1863,23 @@ jobs: RUNNER_TEMP: ${{ runner.temp }} S2STOKENS: true TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Echo detection step summary + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + run: | + if [ -s /tmp/gh-aw/step-summary.md ]; then + cat /tmp/gh-aw/step-summary.md + fi + - name: Render detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/render_detection_log.cjs'); + await main(); - name: Parse threat detection token usage for step summary id: parse_detection_token_usage if: always() @@ -1953,14 +1946,14 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} env: GH_AW_SETUP_WORKFLOW_NAME: "Analyze CI Failure" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/analyze-ci-failure.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Check team membership for workflow @@ -2478,15 +2471,20 @@ jobs: code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_items_applied: ${{ steps.process_safe_outputs.outputs.items_applied }} + process_safe_outputs_items_cancelled: ${{ steps.process_safe_outputs.outputs.items_cancelled }} + process_safe_outputs_items_deferred: ${{ steps.process_safe_outputs.outputs.items_deferred }} process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }} + process_safe_outputs_items_skipped: ${{ steps.process_safe_outputs.outputs.items_skipped }} process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }} + process_safe_outputs_items_warnings: ${{ steps.process_safe_outputs.outputs.items_warnings }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -2495,7 +2493,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Analyze CI Failure" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/analyze-ci-failure.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact diff --git a/.github/workflows/build-cli-native-archives.yml b/.github/workflows/build-cli-native-archives.yml index dc313770b40..f6bdbaa3e1a 100644 --- a/.github/workflows/build-cli-native-archives.yml +++ b/.github/workflows/build-cli-native-archives.yml @@ -16,12 +16,14 @@ on: Defaults to all platforms (linux-x64, linux-arm64, win-x64, win-arm64, osx-arm64). required: false type: string + # Use the VS 2026 ARM preview image for its newer native linker. The VS 2022 ARM image + # fails to link Aspire.Dashboard.obj with LNK1322. default: >- [ {"os": "ubuntu-latest", "runner": "8-core-ubuntu-latest", "rids": "linux-x64"}, {"os": "ubuntu-latest", "runner": "ubuntu-24.04-arm", "rids": "linux-arm64"}, {"os": "windows-latest", "runner": "windows-latest", "rids": "win-x64"}, - {"os": "windows-latest", "runner": "windows-11-arm", "rids": "win-arm64"}, + {"os": "windows-latest", "runner": "windows-11-vs2026-arm", "rids": "win-arm64"}, {"os": "macos-latest", "runner": "macos-latest", "rids": "osx-arm64"} ] @@ -99,6 +101,16 @@ jobs: /bl:${{ github.workspace }}/artifacts/log/${{ inputs.configuration }}/BundlePayload.binlog ${{ inputs.versionOverrideArg }} + - name: Smoke test Native AOT Dashboard + # osx-x64 is cross-compiled on an Apple Silicon runner and cannot be executed there + # without introducing an undeclared Rosetta dependency. + if: ${{ matrix.targets.rids != 'osx-x64' }} + shell: pwsh + run: | + $extension = if ('${{ runner.os }}' -eq 'Windows') { '.exe' } else { '' } + $dashboardPath = (Join-Path '${{ github.workspace }}' 'artifacts/bin/Aspire.Dashboard/${{ matrix.targets.rids }}/${{ inputs.configuration }}/net11.0/${{ matrix.targets.rids }}/publish/Aspire.Dashboard') + $extension + ./eng/scripts/test-native-dashboard.ps1 -DashboardPath $dashboardPath + - name: Build CLI packages run: > ${{ runner.os == 'Windows' && './build.cmd' || './build.sh' }} diff --git a/.github/workflows/daily-repo-status.lock.yml b/.github/workflows/daily-repo-status.lock.yml index bc992a9ca48..5ebcec53570 100644 --- a/.github/workflows/daily-repo-status.lock.yml +++ b/.github/workflows/daily-repo-status.lock.yml @@ -1,6 +1,6 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"441fe3ce33beb847f81893da0670b20aa6d9f12cbf046a62a860e475a3293533","body_hash":"936610a6392f840bf4485eb4a73d30c1247401e93bd19a693e47a50cc24a7496","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"2709137ea6c5b0e19aa621454dc643ea8dc526b1","version":"v0.85.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} -# This file was automatically generated by gh-aw (v0.85.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"441fe3ce33beb847f81893da0670b20aa6d9f12cbf046a62a860e475a3293533","body_hash":"936610a6392f840bf4485eb4a73d30c1247401e93bd19a693e47a50cc24a7496","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} +# This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ # / _ \ | | (_) @@ -29,7 +29,6 @@ # a 7-day burndown chart using cached daily snapshots. # # Secrets used: -# - COPILOT_GITHUB_TOKEN # - GH_AW_GITHUB_MCP_SERVER_TOKEN # - GH_AW_GITHUB_TOKEN # - GITHUB_TOKEN @@ -43,15 +42,15 @@ # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 +# - github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 # - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 # - ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 -# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 +# - ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f # - ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 -# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 +# - ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e name: "13.3 Release Burndown Report" on: @@ -95,14 +94,14 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} env: GH_AW_SETUP_WORKFLOW_NAME: "13.3 Release Burndown Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-repo-status.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate agentic run info @@ -111,9 +110,9 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.78" - GH_AW_INFO_AGENT_VERSION: "1.0.78" - GH_AW_INFO_CLI_VERSION: "v0.85.4" + GH_AW_INFO_VERSION: "1.0.79" + GH_AW_INFO_AGENT_VERSION: "1.0.79" + GH_AW_INFO_CLI_VERSION: "v0.86.2" GH_AW_INFO_WORKFLOW_NAME: "13.3 Release Burndown Report" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -123,6 +122,7 @@ jobs: GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_AGENT_RUNTIME: "" GH_AW_COMPILED_STRICT: "true" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -135,7 +135,6 @@ jobs: id: check-oauth-tokens run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - name: Checkout .github and .agents folders @@ -155,8 +154,8 @@ jobs: env: GH_AW_AGENT_FOLDERS: ".agents .github" GH_AW_AGENT_FILES: "AGENTS.md" - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" - name: Check workflow lock file id: check-lock-file uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -172,7 +171,7 @@ jobs: - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.85.4" + GH_AW_COMPILED_VERSION: "v0.86.2" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -183,9 +182,12 @@ jobs: if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" - name: Create prompt with built-in context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ACTIONS_DIR: ${{ runner.temp }}/gh-aw/actions + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"cache_memory_prompt.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}]}" GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -194,64 +196,22 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - # poutine:ignore untrusted_checkout_exec - run: | - bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" - { - cat << 'GH_AW_PROMPT_67a087ba6d97491a_EOF' - - GH_AW_PROMPT_67a087ba6d97491a_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_67a087ba6d97491a_EOF' - - Tools: create_issue, missing_tool, missing_data, noop - - GH_AW_PROMPT_67a087ba6d97491a_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_67a087ba6d97491a_EOF' - - The following GitHub context information is available for this workflow: - {{#if github.actor}} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if github.repository}} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if github.workspace}} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} - - **issue-number**: #__GH_AW_EXPR_802A9F6A__ - {{/if}} - {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} - - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ - {{/if}} - {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} - - **pull-request-number**: #__GH_AW_EXPR_463A214A__ - {{/if}} - {{#if github.event.comment.id || github.aw.context.comment_id}} - - **comment-id**: __GH_AW_EXPR_FF1D34CE__ - {{/if}} - {{#if github.run_id}} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_67a087ba6d97491a_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_67a087ba6d97491a_EOF' - - {{#runtime-import .github/workflows/daily-repo-status.md}} - GH_AW_PROMPT_67a087ba6d97491a_EOF - } > "$GH_AW_PROMPT" + GH_AW_PROMPT_CONTENT_0000: "\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: create_issue, missing_tool, missing_data, noop\n" + GH_AW_PROMPT_CONTENT_0002: "\n" + GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_802A9F6A__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_1A3A194A__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_463A214A__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_FF1D34CE__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n\n\n" + GH_AW_PROMPT_CONTENT_0004: "\n" + GH_AW_PROMPT_CONTENT_0005: "{{#runtime-import .github/workflows/daily-repo-status.md}}\n" + with: + script: | + const { setupGlobals } = require(process.env.GH_AW_ACTIONS_DIR + '/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(process.env.GH_AW_ACTIONS_DIR + '/create_prompt.cjs'); + await main(core); - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_ENGINE_ID: "copilot" with: script: | @@ -262,7 +222,7 @@ jobs: - name: Substitute placeholders uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_ALLOWED_EXTENSIONS: '' GH_AW_CACHE_DESCRIPTION: '' GH_AW_CACHE_DIR: '/tmp/gh-aw/cache-memory/' @@ -302,14 +262,18 @@ jobs: }); - name: Validate prompt placeholders env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Stage prompt files for artifact upload + run: | + mkdir -p /tmp/gh-aw/aw-prompts + cp -a "${RUNNER_TEMP}/gh-aw/aw-prompts/." /tmp/gh-aw/aw-prompts/ - name: Upload activation artifact if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -377,7 +341,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -386,7 +350,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "13.3 Release Burndown Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-repo-status.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths @@ -448,11 +412,13 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 --rootless - name: Determine automatic lockdown mode for GitHub MCP Server @@ -481,7 +447,7 @@ jobs: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" @@ -646,22 +612,23 @@ jobs: export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export MCP_GATEWAY_ALLOWED_MOUNT_ROOTS="${GITHUB_WORKSPACE}:rw,${RUNNER_TEMP}/gh-aw:ro,${RUNNER_TEMP}/gh-aw/safeoutputs:rw,/opt:ro,/tmp:rw,/usr/bin/gh:ro" export DEBUG="*" export GH_AW_ENGINE="copilot" MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.8' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e MCP_GATEWAY_ALLOWED_MOUNT_ROOTS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.9' mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_b9a84583d08a9e5d_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_8ce5941148a2fcea_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "container": "ghcr.io/github/github-mcp-server:v1.9.0", "env": { "GITHUB_FEATURES": "fields_param", "GITHUB_HOST": "${GITHUB_SERVER_URL}", @@ -718,7 +685,7 @@ jobs: "startupTimeout": 120 } } - GH_AW_MCP_CONFIG_b9a84583d08a9e5d_EOF + GH_AW_MCP_CONFIG_8ce5941148a2fcea_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -752,6 +719,18 @@ jobs: printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -776,8 +755,8 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE @@ -791,7 +770,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -997,7 +976,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1006,7 +985,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "13.3 Release Burndown Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-repo-status.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1206,7 +1185,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1215,7 +1194,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "13.3 Release Burndown Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-repo-status.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1267,25 +1246,7 @@ jobs: - name: Prepare threat detection files if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - rm -f /tmp/gh-aw/agent_usage.json - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then - echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context." - fi - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - for f in /tmp/gh-aw/aw-*.bundle; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + bash "${RUNNER_TEMP}/gh-aw/actions/prepare_threat_detection_files.sh" - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1305,18 +1266,20 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - rm -f /tmp/gh-aw/threat-detection/step-summary.md - touch /tmp/gh-aw/threat-detection/step-summary.md + rm -f /tmp/gh-aw/step-summary.md + touch /tmp/gh-aw/step-summary.md - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Execute GitHub Copilot CLI @@ -1332,6 +1295,18 @@ jobs: mkdir -p "$HOME/.copilot" printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -1358,9 +1333,10 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: + GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode @@ -1372,14 +1348,13 @@ jobs: GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] @@ -1388,6 +1363,23 @@ jobs: RUNNER_TEMP: ${{ runner.temp }} S2STOKENS: true TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Echo detection step summary + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + run: | + if [ -s /tmp/gh-aw/step-summary.md ]; then + cat /tmp/gh-aw/step-summary.md + fi + - name: Render detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/render_detection_log.cjs'); + await main(); - name: Parse threat detection token usage for step summary id: parse_detection_token_usage if: always() @@ -1473,15 +1465,20 @@ jobs: create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} + process_safe_outputs_items_applied: ${{ steps.process_safe_outputs.outputs.items_applied }} + process_safe_outputs_items_cancelled: ${{ steps.process_safe_outputs.outputs.items_cancelled }} + process_safe_outputs_items_deferred: ${{ steps.process_safe_outputs.outputs.items_deferred }} process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }} + process_safe_outputs_items_skipped: ${{ steps.process_safe_outputs.outputs.items_skipped }} process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }} + process_safe_outputs_items_warnings: ${{ steps.process_safe_outputs.outputs.items_warnings }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1490,7 +1487,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "13.3 Release Burndown Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-repo-status.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1558,7 +1555,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1567,7 +1564,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "13.3 Release Burndown Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-repo-status.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download cache-memory artifact (default) diff --git a/.github/workflows/extension-changelog.lock.yml b/.github/workflows/extension-changelog.lock.yml index 13120b573a3..a776fcfef71 100644 --- a/.github/workflows/extension-changelog.lock.yml +++ b/.github/workflows/extension-changelog.lock.yml @@ -1,6 +1,6 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"00b41866a84336e24032b2ba011fbfb10196acc2a595a4413b872377cea24492","body_hash":"d1d30916ad32257baf6fd20c2ca97dcef138c4b94fb18a9b98f0538a4a6a41e6","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78"}} -# gh-aw-manifest: {"version":1,"secrets":["ASPIRE_BOT_APP_ID","ASPIRE_BOT_PRIVATE_KEY","COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/create-github-app-token","sha":"bcd2ba49218906704ab6c1aa796996da409d3eb1","version":"v3.2.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"2709137ea6c5b0e19aa621454dc643ea8dc526b1","version":"v0.85.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}],"has_pull_request":true} -# This file was automatically generated by gh-aw (v0.85.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"30af2e1cbb577dc33f216092e83b27456f3a110ba3e668ca328826f13238a18e","body_hash":"2154bfa969f45e7df0b83b79adea2a74f0fcdf6fff84c848e012b8c32551d65d","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-manifest: {"version":1,"secrets":["ASPIRE_BOT_APP_ID","ASPIRE_BOT_PRIVATE_KEY","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/create-github-app-token","sha":"bcd2ba49218906704ab6c1aa796996da409d3eb1","version":"v3.2.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}],"has_pull_request":true} +# This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ # / _ \ | | (_) @@ -38,7 +38,6 @@ # Secrets used: # - ASPIRE_BOT_APP_ID # - ASPIRE_BOT_PRIVATE_KEY -# - COPILOT_GITHUB_TOKEN # - GH_AW_CI_TRIGGER_TOKEN # - GH_AW_GITHUB_MCP_SERVER_TOKEN # - GH_AW_GITHUB_TOKEN @@ -52,15 +51,15 @@ # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 +# - github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 # - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 # - ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 -# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 +# - ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f # - ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 -# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 +# - ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e name: "Generate the VS Code extension changelog for a release PR" on: @@ -109,7 +108,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -118,7 +117,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Generate the VS Code extension changelog for a release PR" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/extension-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate agentic run info @@ -127,9 +126,9 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.78" - GH_AW_INFO_AGENT_VERSION: "1.0.78" - GH_AW_INFO_CLI_VERSION: "v0.85.4" + GH_AW_INFO_VERSION: "1.0.79" + GH_AW_INFO_AGENT_VERSION: "1.0.79" + GH_AW_INFO_CLI_VERSION: "v0.86.2" GH_AW_INFO_WORKFLOW_NAME: "Generate the VS Code extension changelog for a release PR" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -139,6 +138,7 @@ jobs: GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_AGENT_RUNTIME: "" GH_AW_COMPILED_STRICT: "true" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -151,7 +151,6 @@ jobs: id: check-oauth-tokens run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - name: Checkout .github and .agents folders @@ -171,12 +170,12 @@ jobs: env: GH_AW_AGENT_FOLDERS: ".agents .github" GH_AW_AGENT_FILES: "AGENTS.md" - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.85.4" + GH_AW_COMPILED_VERSION: "v0.86.2" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -199,9 +198,12 @@ jobs: if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" - name: Create prompt with built-in context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ACTIONS_DIR: ${{ runner.temp }}/gh-aw/actions + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"file\":\"safe_outputs_push_to_pr_branch.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}]}" GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -212,66 +214,22 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - # poutine:ignore untrusted_checkout_exec - run: | - bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" - { - cat << 'GH_AW_PROMPT_b61177b961ebf0ea_EOF' - - GH_AW_PROMPT_b61177b961ebf0ea_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_b61177b961ebf0ea_EOF' - - Tools: push_to_pull_request_branch, missing_tool, missing_data, noop - GH_AW_PROMPT_b61177b961ebf0ea_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_push_to_pr_branch.md" - cat << 'GH_AW_PROMPT_b61177b961ebf0ea_EOF' - - GH_AW_PROMPT_b61177b961ebf0ea_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_b61177b961ebf0ea_EOF' - - The following GitHub context information is available for this workflow: - {{#if github.actor}} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if github.repository}} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if github.workspace}} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} - - **issue-number**: #__GH_AW_EXPR_802A9F6A__ - {{/if}} - {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} - - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ - {{/if}} - {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} - - **pull-request-number**: #__GH_AW_EXPR_463A214A__ - {{/if}} - {{#if github.event.comment.id || github.aw.context.comment_id}} - - **comment-id**: __GH_AW_EXPR_FF1D34CE__ - {{/if}} - {{#if github.run_id}} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_b61177b961ebf0ea_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_b61177b961ebf0ea_EOF' - - {{#runtime-import .github/workflows/extension-changelog.md}} - GH_AW_PROMPT_b61177b961ebf0ea_EOF - } > "$GH_AW_PROMPT" + GH_AW_PROMPT_CONTENT_0000: "\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: push_to_pull_request_branch, missing_tool, missing_data, noop\n" + GH_AW_PROMPT_CONTENT_0002: "\n" + GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_802A9F6A__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_1A3A194A__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_463A214A__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_FF1D34CE__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n\n\n" + GH_AW_PROMPT_CONTENT_0004: "\n" + GH_AW_PROMPT_CONTENT_0005: "{{#runtime-import .github/workflows/extension-changelog.md}}\n" + with: + script: | + const { setupGlobals } = require(process.env.GH_AW_ACTIONS_DIR + '/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(process.env.GH_AW_ACTIONS_DIR + '/create_prompt.cjs'); + await main(core); - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_ENGINE_ID: "copilot" GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} GH_AW_GITHUB_EVENT_NAME: ${{ github.event_name }} @@ -284,7 +242,7 @@ jobs: - name: Substitute placeholders uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -324,14 +282,18 @@ jobs: }); - name: Validate prompt placeholders env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Stage prompt files for artifact upload + run: | + mkdir -p /tmp/gh-aw/aw-prompts + cp -a "${RUNNER_TEMP}/gh-aw/aw-prompts/." /tmp/gh-aw/aw-prompts/ - name: Upload activation artifact if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -392,7 +354,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -401,7 +363,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Generate the VS Code extension changelog for a release PR" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/extension-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths @@ -447,11 +409,13 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 --rootless - name: Generate GitHub App token @@ -500,10 +464,10 @@ jobs: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - name: Preload authoritative marker range for local changelog enumeration - run: "set -euo pipefail\n\nCHANGELOG=extension/CHANGELOG.md\nif [ ! -f \"${CHANGELOG}\" ]; then\n echo \"No ${CHANGELOG} file in this checkout; skipping authoritative range preload.\"\n exit 0\nfi\n\nmapfile -t MARKERS < <(grep '$'\nif [[ ! \"${MARKER_LINE}\" =~ ${MARKER_REGEX} ]]; then\n echo \"::error::Could not parse authoritative marker: ${MARKER_LINE}\"\n exit 1\nfi\n\nFROM_SHA=\"${BASH_REMATCH[1]}\"\nTO_SHA=\"${BASH_REMATCH[2]}\"\nCURRENT_BRANCH=\"$(git branch --show-current)\"\nif [ -z \"${CURRENT_BRANCH}\" ]; then\n echo \"::error::Could not determine the checked-out PR branch for authoritative range preload.\"\n exit 1\nfi\n\nrange_is_available() {\n git cat-file -e \"${FROM_SHA}^{commit}\" 2>/dev/null \\\n && git cat-file -e \"${TO_SHA}^{commit}\" 2>/dev/null \\\n && git merge-base --is-ancestor \"${FROM_SHA}\" \"${TO_SHA}\" 2>/dev/null \\\n && git log --format='%H%x09%s' --no-merges \"${FROM_SHA}..${TO_SHA}\" -- extension/ >/dev/null 2>&1\n}\n\nif range_is_available; then\n echo \"Authoritative marker range ${FROM_SHA}..${TO_SHA} is already locally enumerable.\"\n exit 0\nfi\n\nDEEPEN_BY=128\nATTEMPT=0\nwhile [ \"$(git rev-parse --is-shallow-repository)\" = \"true\" ]; do\n ATTEMPT=$((ATTEMPT + 1))\n if [ \"${ATTEMPT}\" -le 6 ]; then\n echo \"Deepening ${CURRENT_BRANCH} by ${DEEPEN_BY} commits to preload ${FROM_SHA}..${TO_SHA}.\"\n git fetch --no-tags --deepen=\"${DEEPEN_BY}\" origin \"${CURRENT_BRANCH}\"\n DEEPEN_BY=$((DEEPEN_BY * 2))\n else\n echo \"Unshallowing ${CURRENT_BRANCH} to preload ${FROM_SHA}..${TO_SHA}.\"\n git fetch --no-tags --unshallow origin \"${CURRENT_BRANCH}\"\n fi\n\n if range_is_available; then\n echo \"Preloaded authoritative marker range ${FROM_SHA}..${TO_SHA} for local changelog enumeration.\"\n exit 0\n fi\ndone\n\nif range_is_available; then\n echo \"Preloaded authoritative marker range ${FROM_SHA}..${TO_SHA} for local changelog enumeration.\"\n exit 0\nfi\n\necho \"::error::Failed to preload authoritative marker range ${FROM_SHA}..${TO_SHA} for local changelog enumeration.\"\nexit 1" + run: "set -euo pipefail\n\nCHANGELOG=extension/CHANGELOG.md\nif [ ! -f \"${CHANGELOG}\" ]; then\n echo \"No ${CHANGELOG} file in this checkout; skipping authoritative range preload.\"\n exit 0\nfi\n\nmapfile -t MARKERS < <(grep '$'\nif [[ ! \"${MARKER_LINE}\" =~ ${MARKER_REGEX} ]]; then\n echo \"::error::Could not parse authoritative marker: ${MARKER_LINE}\"\n exit 1\nfi\n\nFROM_SHA=\"${BASH_REMATCH[1]}\"\nTO_SHA=\"${BASH_REMATCH[2]}\"\nCANDIDATES_FILE=\"${RUNNER_TEMP}/gh-aw/extension-changelog-candidates.tsv\"\nmkdir -p \"${RUNNER_TEMP}/gh-aw\"\nrm -f \"${CANDIDATES_FILE}\"\nCURRENT_BRANCH=\"$(git branch --show-current)\"\nif [ -z \"${CURRENT_BRANCH}\" ]; then\n echo \"::error::Could not determine the checked-out PR branch for authoritative range preload.\"\n exit 1\nfi\n\nmaterialize_candidate_set() {\n if ! git cat-file -e \"${FROM_SHA}^{commit}\" 2>/dev/null \\\n || ! git cat-file -e \"${TO_SHA}^{commit}\" 2>/dev/null \\\n || ! git merge-base --is-ancestor \"${FROM_SHA}\" \"${TO_SHA}\" 2>/dev/null; then\n return 1\n fi\n\n if ! git log --format='%H%x09%s' --no-merges \\\n \"${FROM_SHA}..${TO_SHA}\" -- extension/ > \"${CANDIDATES_FILE}\"; then\n rm -f \"${CANDIDATES_FILE}\"\n return 1\n fi\n\n local candidate_count\n candidate_count=\"$(wc -l < \"${CANDIDATES_FILE}\")\"\n echo \"Materialized ${candidate_count} authoritative extension changelog candidates in ${CANDIDATES_FILE}.\"\n}\n\nif materialize_candidate_set; then\n echo \"Authoritative marker range ${FROM_SHA}..${TO_SHA} is already locally enumerable.\"\n exit 0\nfi\n\nDEEPEN_BY=128\nATTEMPT=0\nwhile [ \"$(git rev-parse --is-shallow-repository)\" = \"true\" ]; do\n ATTEMPT=$((ATTEMPT + 1))\n if [ \"${ATTEMPT}\" -le 6 ]; then\n echo \"Deepening ${CURRENT_BRANCH} by ${DEEPEN_BY} commits to preload ${FROM_SHA}..${TO_SHA}.\"\n git fetch --no-tags --deepen=\"${DEEPEN_BY}\" origin \"${CURRENT_BRANCH}\"\n DEEPEN_BY=$((DEEPEN_BY * 2))\n else\n echo \"Unshallowing ${CURRENT_BRANCH} to preload ${FROM_SHA}..${TO_SHA}.\"\n git fetch --no-tags --unshallow origin \"${CURRENT_BRANCH}\"\n fi\n\n if materialize_candidate_set; then\n echo \"Preloaded authoritative marker range ${FROM_SHA}..${TO_SHA} for local changelog enumeration.\"\n exit 0\n fi\ndone\n\nif materialize_candidate_set; then\n echo \"Preloaded authoritative marker range ${FROM_SHA}..${TO_SHA} for local changelog enumeration.\"\n exit 0\nfi\n\necho \"::error::Failed to preload authoritative marker range ${FROM_SHA}..${TO_SHA} for local changelog enumeration.\"\nexit 1" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" @@ -597,6 +561,10 @@ jobs: }, "pull_request_number": { "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 } } }, @@ -648,22 +616,23 @@ jobs: export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export MCP_GATEWAY_ALLOWED_MOUNT_ROOTS="${GITHUB_WORKSPACE}:rw,${RUNNER_TEMP}/gh-aw:ro,${RUNNER_TEMP}/gh-aw/safeoutputs:rw,/opt:ro,/tmp:rw,/usr/bin/gh:ro" export DEBUG="*" export GH_AW_ENGINE="copilot" MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.8' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e MCP_GATEWAY_ALLOWED_MOUNT_ROOTS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.9' mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_c3984b671fa9f53f_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_5614418005a60134_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "container": "ghcr.io/github/github-mcp-server:v1.9.0", "env": { "GITHUB_FEATURES": "fields_param", "GITHUB_HOST": "${GITHUB_SERVER_URL}", @@ -725,7 +694,7 @@ jobs: "startupTimeout": 120 } } - GH_AW_MCP_CONFIG_c3984b671fa9f53f_EOF + GH_AW_MCP_CONFIG_5614418005a60134_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -750,6 +719,32 @@ jobs: - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): + # --allow-tool github + # --allow-tool safeoutputs + # --allow-tool shell(cat) + # --allow-tool shell(date) + # --allow-tool shell(echo) + # --allow-tool shell(git add:*) + # --allow-tool shell(git branch:*) + # --allow-tool shell(git checkout:*) + # --allow-tool shell(git commit:*) + # --allow-tool shell(git merge:*) + # --allow-tool shell(git rm:*) + # --allow-tool shell(git status) + # --allow-tool shell(git switch:*) + # --allow-tool shell(github:*) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(ls) + # --allow-tool shell(printf) + # --allow-tool shell(pwd) + # --allow-tool shell(safeoutputs:*) + # --allow-tool shell(sort) + # --allow-tool shell(tail) + # --allow-tool shell(uniq) + # --allow-tool shell(wc) + # --allow-tool shell(yq) + # --allow-tool write timeout-minutes: 20 run: | set -o pipefail @@ -759,6 +754,18 @@ jobs: printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -783,8 +790,8 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(git add:*)'\'' --allow-tool '\''shell(git branch:*)'\'' --allow-tool '\''shell(git checkout:*)'\'' --allow-tool '\''shell(git commit:*)'\'' --allow-tool '\''shell(git merge:*)'\'' --allow-tool '\''shell(git rm:*)'\'' --allow-tool '\''shell(git status)'\'' --allow-tool '\''shell(git switch:*)'\'' --allow-tool '\''shell(github:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(printf)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE @@ -798,7 +805,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -991,7 +998,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1000,7 +1007,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Generate the VS Code extension changelog for a release PR" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/extension-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate GitHub App token @@ -1213,7 +1220,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1222,7 +1229,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Generate the VS Code extension changelog for a release PR" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/extension-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1274,25 +1281,7 @@ jobs: - name: Prepare threat detection files if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - rm -f /tmp/gh-aw/agent_usage.json - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then - echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context." - fi - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - for f in /tmp/gh-aw/aw-*.bundle; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + bash "${RUNNER_TEMP}/gh-aw/actions/prepare_threat_detection_files.sh" - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1312,18 +1301,20 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - rm -f /tmp/gh-aw/threat-detection/step-summary.md - touch /tmp/gh-aw/threat-detection/step-summary.md + rm -f /tmp/gh-aw/step-summary.md + touch /tmp/gh-aw/step-summary.md - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Execute GitHub Copilot CLI @@ -1339,6 +1330,18 @@ jobs: mkdir -p "$HOME/.copilot" printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -1365,9 +1368,10 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: + GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode @@ -1379,14 +1383,13 @@ jobs: GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] @@ -1395,6 +1398,23 @@ jobs: RUNNER_TEMP: ${{ runner.temp }} S2STOKENS: true TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Echo detection step summary + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + run: | + if [ -s /tmp/gh-aw/step-summary.md ]; then + cat /tmp/gh-aw/step-summary.md + fi + - name: Render detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/render_detection_log.cjs'); + await main(); - name: Parse threat detection token usage for step summary id: parse_detection_token_usage if: always() @@ -1466,14 +1486,14 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} env: GH_AW_SETUP_WORKFLOW_NAME: "Generate the VS Code extension changelog for a release PR" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/extension-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Check team membership for workflow @@ -1524,8 +1544,13 @@ jobs: code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_items_applied: ${{ steps.process_safe_outputs.outputs.items_applied }} + process_safe_outputs_items_cancelled: ${{ steps.process_safe_outputs.outputs.items_cancelled }} + process_safe_outputs_items_deferred: ${{ steps.process_safe_outputs.outputs.items_deferred }} process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }} + process_safe_outputs_items_skipped: ${{ steps.process_safe_outputs.outputs.items_skipped }} process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }} + process_safe_outputs_items_warnings: ${{ steps.process_safe_outputs.outputs.items_warnings }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} @@ -1534,7 +1559,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1543,7 +1568,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Generate the VS Code extension changelog for a release PR" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/extension-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact diff --git a/.github/workflows/extension-changelog.md b/.github/workflows/extension-changelog.md index aeaef29043e..b6c3cecac30 100644 --- a/.github/workflows/extension-changelog.md +++ b/.github/workflows/extension-changelog.md @@ -118,6 +118,10 @@ safe-outputs: repositories: ["aspire"] tools: + # Shell access is explicit because the unfiltered GitHub integrity setting + # requires an intentional allowlist. The agent reads the candidate set + # materialized by the trusted pre-agent step and inspects the changelog. + bash: ["cat", "grep", "head", "tail", "wc"] github: # `repos` exposes the commit-comparison and file-content APIs used to gather # the extension change set. `pull_requests` and `search` enrich commits with @@ -171,20 +175,34 @@ pre-agent-steps: FROM_SHA="${BASH_REMATCH[1]}" TO_SHA="${BASH_REMATCH[2]}" + CANDIDATES_FILE="${RUNNER_TEMP}/gh-aw/extension-changelog-candidates.tsv" + mkdir -p "${RUNNER_TEMP}/gh-aw" + rm -f "${CANDIDATES_FILE}" CURRENT_BRANCH="$(git branch --show-current)" if [ -z "${CURRENT_BRANCH}" ]; then echo "::error::Could not determine the checked-out PR branch for authoritative range preload." exit 1 fi - range_is_available() { - git cat-file -e "${FROM_SHA}^{commit}" 2>/dev/null \ - && git cat-file -e "${TO_SHA}^{commit}" 2>/dev/null \ - && git merge-base --is-ancestor "${FROM_SHA}" "${TO_SHA}" 2>/dev/null \ - && git log --format='%H%x09%s' --no-merges "${FROM_SHA}..${TO_SHA}" -- extension/ >/dev/null 2>&1 + materialize_candidate_set() { + if ! git cat-file -e "${FROM_SHA}^{commit}" 2>/dev/null \ + || ! git cat-file -e "${TO_SHA}^{commit}" 2>/dev/null \ + || ! git merge-base --is-ancestor "${FROM_SHA}" "${TO_SHA}" 2>/dev/null; then + return 1 + fi + + if ! git log --format='%H%x09%s' --no-merges \ + "${FROM_SHA}..${TO_SHA}" -- extension/ > "${CANDIDATES_FILE}"; then + rm -f "${CANDIDATES_FILE}" + return 1 + fi + + local candidate_count + candidate_count="$(wc -l < "${CANDIDATES_FILE}")" + echo "Materialized ${candidate_count} authoritative extension changelog candidates in ${CANDIDATES_FILE}." } - if range_is_available; then + if materialize_candidate_set; then echo "Authoritative marker range ${FROM_SHA}..${TO_SHA} is already locally enumerable." exit 0 fi @@ -202,13 +220,13 @@ pre-agent-steps: git fetch --no-tags --unshallow origin "${CURRENT_BRANCH}" fi - if range_is_available; then + if materialize_candidate_set; then echo "Preloaded authoritative marker range ${FROM_SHA}..${TO_SHA} for local changelog enumeration." exit 0 fi done - if range_is_available; then + if materialize_candidate_set; then echo "Preloaded authoritative marker range ${FROM_SHA}..${TO_SHA} for local changelog enumeration." exit 0 fi @@ -314,18 +332,19 @@ The PR body, description, and deterministic fallback notes are presentation-only and MUST NOT be used to discover the change set. The validated marker range is the only authoritative source. -A deterministic pre-agent step already preloaded the authoritative marker range -and history into this checkout. Do not perform any network fetch in the agent -step. If the local range is missing or cannot be enumerated, treat that as a -workflow error and fail with a diagnostic instead of trying to repair it here. +A deterministic pre-agent step already validated and materialized the +authoritative marker range as +`${RUNNER_TEMP}/gh-aw/extension-changelog-candidates.tsv`. Do not run `git` or +perform any network fetch in the agent step. If the candidate file cannot be +read, treat that as a workflow error and fail with a diagnostic instead of +trying to repair it here. -Use local git as the authoritative source for the exact candidate set: +Treat that file as the authoritative source for the exact candidate set: -1. Run `git log --format='%H%x09%s' --no-merges .. -- extension/` - locally and treat the full output of that command as the exact candidate set. -2. Count the candidates produced by that command and keep that count in mind - while you work. -3. Consider and classify **every** candidate from that full local set before you +1. Read every line in the file. Each line contains the 40-character commit SHA, + a tab, and its first-line subject. +2. Count every line and keep that exact candidate count in mind while you work. +3. Consider and classify **every** candidate from that full set before you write notes. You may group related user-facing commits into one final note and exclude internal-only commits, but you MUST NOT stop after a fixed number of commits and MUST NOT use only the newest page, partial list, PR body, or @@ -404,7 +423,7 @@ editing `extension/CHANGELOG.md`, emit a single push request as your final output so gh-aw commits the change to the triggering PR's head branch with a clear commit message (for example `Generate extension changelog for v`). Do not include any other file in -the change. Then write a short success line to the run summary. The summary must be auditable: report the exact candidate count from Step 4, how many candidates were included in the final notes and how many were excluded, and ensure the included/excluded totals (or equivalent auditable classification totals) account for every candidate from the local git range: +the change. Then write a short success line to the run summary. The summary must be auditable: report the exact candidate count from Step 4, how many candidates were included in the final notes and how many were excluded, and ensure the included/excluded totals (or equivalent auditable classification totals) account for every candidate from the pre-agent candidate file: > Replaced the placeholder `extension/CHANGELOG.md` entry for **v``** > on PR #`${{ github.event.pull_request.number }}`. Range: ``..``. @@ -431,9 +450,9 @@ no-op when no request is emitted. - More than one marker present (Step 2). - Malformed `from`/`to` SHAs, or SHAs that don't resolve in `microsoft/aspire` (Step 3). -- The local git range still cannot be enumerated after the pre-agent history - preload, or required enrichment searches fail outright so you cannot produce - notes. +- The authoritative candidate file is missing or unreadable after pre-agent + materialization, or required enrichment searches fail outright so you cannot + produce notes. For these, exit non-zero so the run shows a red X, and write the failing API, the marker contents, and the error to the run summary so a maintainer can diff --git a/.github/workflows/extension-e2e-tests.yml b/.github/workflows/extension-e2e-tests.yml index 5558d3d4ada..01bbfa89645 100644 --- a/.github/workflows/extension-e2e-tests.yml +++ b/.github/workflows/extension-e2e-tests.yml @@ -75,7 +75,9 @@ jobs: name: VS Code extension E2E (${{ matrix.name }}, ${{ matrix.shardName }}) needs: verify_extester_feed runs-on: ${{ matrix.runner }} - timeout-minutes: 75 + # The Java AppHost shard imports a multi-project Gradle build and then runs the whole + # application under a debugger, so it needs more than the shared budget. + timeout-minutes: ${{ matrix.timeoutMinutes || 75 }} env: NPM_REGISTRY: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 @@ -132,6 +134,22 @@ jobs: archivePattern: aspire-cli-win-x64*.zip cliBinary: aspire.exe useXvfb: false + - name: Linux + shardName: dynamic-debug-configuration + spec: out/test-e2e/test-e2e/dynamicDebugConfiguration.e2e.test.js + runner: ubuntu-latest + rid: linux-x64 + archivePattern: aspire-cli-linux-x64*.tar.gz + cliBinary: aspire + useXvfb: true + - name: Windows + shardName: dynamic-debug-configuration + spec: out/test-e2e/test-e2e/dynamicDebugConfiguration.e2e.test.js + runner: windows-latest + rid: win-x64 + archivePattern: aspire-cli-win-x64*.zip + cliBinary: aspire.exe + useXvfb: false - name: Linux shardName: apphost-tree advisoryIssue: https://github.com/microsoft/aspire/issues/19282 @@ -248,6 +266,68 @@ jobs: archivePattern: aspire-cli-win-x64*.zip cliBinary: aspire.exe useXvfb: false + - name: Linux + shardName: java-apphost + spec: out/test-e2e/test-e2e/javaAppHost.e2e.test.js + runner: ubuntu-latest + rid: linux-x64 + archivePattern: aspire-cli-linux-x64*.tar.gz + cliBinary: aspire + useXvfb: true + installJava: true + timeoutMinutes: 110 + - name: Linux + shardName: java-apphost-debug-startup-timeout + spec: out/test-e2e/test-e2e/javaAppHostDebugStartupTimeout.e2e.test.js + runner: ubuntu-latest + rid: linux-x64 + archivePattern: aspire-cli-linux-x64*.tar.gz + cliBinary: aspire + useXvfb: true + installJava: true + timeoutMinutes: 110 + unsetCliStartTimeout: true + - name: Linux + shardName: java-codelens + spec: out/test-e2e/test-e2e/javaCodeLens.e2e.test.js + runner: ubuntu-latest + rid: linux-x64 + archivePattern: aspire-cli-linux-x64*.tar.gz + cliBinary: aspire + useXvfb: true + installJava: true + - name: Linux + shardName: workspace-target-proof + spec: out/test-e2e/test-e2e/workspaceTargetProof.e2e.test.js + runner: ubuntu-latest + rid: linux-x64 + archivePattern: aspire-cli-linux-x64*.tar.gz + cliBinary: aspire + useXvfb: true + - name: Linux + shardName: cli-path-resolution + spec: out/test-e2e/test-e2e/cliPathResolution.e2e.test.js + runner: ubuntu-latest + rid: linux-x64 + archivePattern: aspire-cli-linux-x64*.tar.gz + cliBinary: aspire + useXvfb: true + - name: Linux + shardName: cli-path-rejection + spec: out/test-e2e/test-e2e/cliPathRejectionNotification.e2e.test.js + runner: ubuntu-latest + rid: linux-x64 + archivePattern: aspire-cli-linux-x64*.tar.gz + cliBinary: aspire + useXvfb: true + - name: Windows + shardName: cli-path-rejection + spec: out/test-e2e/test-e2e/cliPathRejectionNotification.e2e.test.js + runner: windows-latest + rid: win-x64 + archivePattern: aspire-cli-win-x64*.zip + cliBinary: aspire.exe + useXvfb: false - name: Linux shardName: edge-cases spec: out/test-e2e/test-e2e/edgeCases.e2e.test.js @@ -434,6 +514,137 @@ jobs: echo 'FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT=1' } >> "$GITHUB_ENV" + - name: Set up the JDK for the Java E2E specs + if: ${{ matrix.installJava }} + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + # The CLI compiles a scaffolded Java AppHost with `javac --release 25`, because the AppHost + # uses an instance main method in an implicitly declared class (JEP 512, finalized in 25). + # Hosted runner images ship 17 and 21, so neither the AppHost nor the playground's Gradle + # toolchain resolves without this. + java-version: '25' + + - name: Cache Java dependencies for the Java E2E specs + if: ${{ matrix.installJava }} + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + # The Java specs launch real Spring Boot resources, so a cold runner otherwise downloads the + # whole Spring dependency tree inside the test's readiness budget. Cache the user-level Maven + # and Gradle stores rather than a per-project directory: the specs run out of a generated + # workspace copied from the playground, so only the shared caches survive the copy. + # + # `~/.m2/wrapper` holds the Maven distribution itself, which `mvnw` downloads from + # repo.maven.apache.org into `${MAVEN_USER_HOME}/wrapper/dists` because these projects pin + # `distributionType=only-script`. Without it cached, every run re-fetched Maven from Maven + # Central, so a single outage there failed the shard - the `~/.gradle/wrapper` entry below + # already gave Gradle the equivalent protection. + path: | + ~/.m2/repository + ~/.m2/wrapper + ~/.gradle/caches + ~/.gradle/wrapper + # `maven-wrapper.properties` participates in the key for the same reason + # `gradle-wrapper.properties` does: it carries the pinned `distributionUrl`, so the entry has + # to rotate when the Maven version changes. Otherwise the key would still hit exactly after a + # version bump, actions/cache would skip the save, and the newly downloaded distribution would + # never make it into the cache. + key: java-e2e-${{ runner.os }}-${{ hashFiles('playground/JavaSpringBoot/**/pom.xml', 'playground/JavaSpringBoot/**/build.gradle*', 'playground/JavaSpringBoot/**/gradle-wrapper.properties', 'playground/JavaSpringBoot/**/maven-wrapper.properties') }} + restore-keys: | + java-e2e-${{ runner.os }}- + + - name: Warm the Java dependency caches + if: ${{ matrix.installJava }} + shell: bash + run: | + set -euo pipefail + + # Resolving dependencies here rather than inside the specs keeps a cold cache from being + # charged against the in-test wait for a resource endpoint, which is what made these shards + # time out on cache misses. The AppHost project is deliberately excluded: it compiles against + # the Aspire Java SDK that the CLI generates later, during the E2E run itself. + # + # A resolution failure must not fail the job - it only means the specs pay the download cost + # they paid before this step existed. + # + # Retry before giving up, though. Both wrappers fetch their own distribution on a cold cache, + # and a failure there is not merely a slower run: it leaves no Maven on the runner at all, so + # the build inside the spec exits 1, the resource never starts, and the shard fails 15 minutes + # later as "Timed out after 900000ms waiting for an HTTP endpoint for resource 'catalog'" + # rather than as the download error it actually is. + warm() { + local description=$1 + local directory=$2 + shift 2 + + local attempt + for attempt in 1 2 3; do + if ( cd "$directory" && "$@" ); then + return 0 + fi + + if [ "$attempt" -lt 3 ]; then + echo "warning: pre-resolving $description failed (attempt $attempt/3), retrying" + sleep $((attempt * 15)) + fi + done + + echo "warning: could not pre-resolve $description" + } + + cd playground/JavaSpringBoot + for maven_project in catalog worker; do + # `dependency:go-offline` resolves the dependency tree but not every plugin a real build + # runs, so compiling here is what actually leaves `~/.m2` - the directory this job caches - + # holding the compiler, resources and Spring Boot plugins. The build output itself is + # deliberately not reused: the specs copy the playground without `target/` so each run + # builds clean, which is the same reason the Gradle project below is compiled rather than + # only resolved. + warm "Maven dependencies for $maven_project" "$maven_project" \ + ./mvnw --batch-mode --no-transfer-progress -q dependency:go-offline compile + done + warm "Gradle dependencies for orders" orders \ + ./gradlew --no-daemon --quiet compileJava + + - name: Install Java E2E prerequisites + if: ${{ matrix.installJava }} + shell: bash + run: | + set -euo pipefail + + # The E2E VS Code instance runs offline, and the extension only advertises the `java` + # capability - which is what makes the CLI hand a Java AppHost back to the extension to + # launch and debug, instead of spawning `java` itself - when redhat.java and + # vscjava.vscode-java-debug are both installed. vscode-java-dependency is a hard activation + # dependency of the debug extension. Versions and digests are pinned so a marketplace + # release cannot change what these shards are testing. + # + # redhat.java also publishes platform-specific builds that embed a JRE; the universal build + # is a third of the size and runs on the JDK installed above. + java_language_vsix="$RUNNER_TEMP/redhat-java-1.55.0.vsix" + curl --fail --location --compressed --retry 3 --retry-all-errors \ + --output "$java_language_vsix" \ + 'https://marketplace.visualstudio.com/_apis/public/gallery/publishers/redhat/vsextensions/java/1.55.0/vspackage' + echo '011639c3ee347b9591895bfc77d8cf28f836c053d0a49ae4a83efe9dc473a603 '"$java_language_vsix" | sha256sum --check - + + java_debug_vsix="$RUNNER_TEMP/vscjava-vscode-java-debug-0.59.0.vsix" + curl --fail --location --compressed --retry 3 --retry-all-errors \ + --output "$java_debug_vsix" \ + 'https://marketplace.visualstudio.com/_apis/public/gallery/publishers/vscjava/vsextensions/vscode-java-debug/0.59.0/vspackage' + echo 'e5973fcd763a984ea4d6e57644ccf8f5fe6caa80c0e589f29403f00c9ace3920 '"$java_debug_vsix" | sha256sum --check - + + java_dependency_vsix="$RUNNER_TEMP/vscjava-vscode-java-dependency-0.27.6.vsix" + curl --fail --location --compressed --retry 3 --retry-all-errors \ + --output "$java_dependency_vsix" \ + 'https://marketplace.visualstudio.com/_apis/public/gallery/publishers/vscjava/vsextensions/vscode-java-dependency/0.27.6/vspackage' + echo 'e4e7c8b90953e62c8ebcedaaafb88b3c42ddec3fcc9e37f268ac3203427d9fa0 '"$java_dependency_vsix" | sha256sum --check - + + { + echo "ASPIRE_EXTENSION_E2E_JAVA_VSIX=$java_language_vsix:$java_debug_vsix:$java_dependency_vsix" + } >> "$GITHUB_ENV" + + java -version + - name: Prepare Aspire CLI and package hive id: prepare_cli run: | @@ -465,6 +676,33 @@ jobs: } & $cli.FullName setup + # pwsh does not stop on a native command's exit code, so an unchecked failure here left the + # shard running without the orchestration component and failing minutes later with a debug + # startup timeout instead of the real reason - "The Aspire orchestration component is not + # installed at ~/.nuget/packages/microsoft.developercontrolplane.//tools/dcp". + if ($LASTEXITCODE -ne 0) { throw "aspire setup failed with exit code $LASTEXITCODE." } + + # A zero exit code only means setup ran, not that it produced an orchestration component. The + # shards resolve DCP from this bundle rather than from ~/.nuget/packages, because a + # guest-language AppHost never restores the Microsoft.DeveloperControlPlane. package. + # Assert it is really here so a missing DCP fails on this line, naming the component, instead + # of surfacing minutes later as an unexplained debug startup timeout. + # + # `aspire setup` extracts to SetupCommand.GetDefaultInstallPath(processPath), which is the + # *parent of the parent* of the CLI binary - not the directory the archive was unpacked into. + # With the CLI at artifacts/bundle//aspire that install path is artifacts/bundle, so DCP + # lands beside $bundleDir rather than inside it. Derive the same path the CLI derives instead + # of assuming a layout, otherwise this assertion fails on every shard even when setup worked. + $cliDir = Split-Path -Path $cli.FullName -Parent + $installPath = Split-Path -Path $cliDir -Parent + $dcpExecutable = if ('${{ runner.os }}' -eq 'Windows') { 'dcp.exe' } else { 'dcp' } + $dcp = Get-ChildItem -Path $installPath -Recurse -File -Filter $dcpExecutable -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $dcp) { + Get-ChildItem -Path $installPath -Recurse -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName + throw "'aspire setup' succeeded but no $dcpExecutable exists under $installPath, so every AppHost in this shard would fail to orchestrate." + } + Write-Host "Orchestration component: $($dcp.FullName)" + "ASPIRE_DCP_PATH=$($dcp.Directory.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append $shippingDir = Join-Path '${{ github.workspace }}' 'artifacts/nugets/Debug/Shipping' if (-not (Test-Path $shippingDir)) { @@ -509,12 +747,54 @@ jobs: - name: Install E2E recorder if: ${{ matrix.useXvfb }} run: | + # ffmpeg only powers the diagnostic screen recordings, and run-e2e.js already skips + # recording with a warning when the binary is missing. So this step must never be able to + # fail or stall a shard: it is strictly a nice-to-have for debugging flakes after the fact. + # + # It used to call apt-get unguarded, which talks to the Azure Ubuntu mirrors. That is a + # network dependency with no timeout, and a degraded mirror has hung this step past the + # job timeout - taking down shards (including ones with no Java or recording relevance) + # that were otherwise healthy. Bound every call and give up quietly instead. if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { ffmpeg -version | Select-Object -First 1 - } else { - sudo apt-get update - sudo apt-get install -y --no-install-recommends ffmpeg } + else { + $installed = $false + $attempts = 2 + + for ($attempt = 1; $attempt -le $attempts; $attempt++) { + # `timeout` (coreutils) bounds a stalled child and exits 124, which is what converts an + # unresponsive mirror into a retryable failure rather than an open-ended wait. + # `--kill-after` matters: plain `timeout` only sends SIGTERM, and apt/dpkg block or + # defer signals around critical sections, so without the escalation to SIGKILL a + # wedged child could still outlive its own timeout. + sudo timeout --kill-after=30 180 apt-get update + + # Only the install decides success: `apt-get update` can time out while the install + # still succeeds from the indices already on the runner image. + sudo timeout --kill-after=30 180 apt-get install -y --no-install-recommends ffmpeg + if ($LASTEXITCODE -eq 0) { + $installed = $true + break + } + + if ($attempt -lt $attempts) { + Write-Host "::warning::Attempt $attempt to install ffmpeg failed; retrying." + Start-Sleep -Seconds 15 + } + } + + if (-not $installed) { + Write-Host '::warning::ffmpeg could not be installed; E2E screen recordings are disabled for this shard.' + } + } + + # GitHub's pwsh wrapper appends `if ((Test-Path variable:/LASTEXITCODE)) { exit $LASTEXITCODE }`, + # so a failed apt-get would otherwise fail this step through the leftover exit code and take + # the shard down over a missing diagnostic recorder. Exit explicitly to hold the + # "this step must never fail the job" contract. + # https://docs.github.com/actions/reference/workflows-and-actions/workflow-syntax#exit-codes-and-error-action-preference + exit 0 - name: Run extension E2E tests working-directory: extension diff --git a/.github/workflows/extension-release.yml b/.github/workflows/extension-release.yml index cff47687c18..1587995244c 100644 --- a/.github/workflows/extension-release.yml +++ b/.github/workflows/extension-release.yml @@ -456,7 +456,6 @@ jobs: echo " | \`SkipNpmPointerPublish\` | \`true\` |" echo " | \`SkipChannelPromotion\` | \`true\` |" echo " | \`SkipWinGetPublish\` | \`true\` |" - echo " | \`SkipHomebrewValidation\` | \`true\` |" echo " | \`SkipGitHubTasks\` | \`true\` |" echo " | \`SkipReleaseAssets\` | \`true\` |" echo " | \`SkipNixPackageUpdate\` | \`true\` |" diff --git a/.github/workflows/homebrew-validate-release.yml b/.github/workflows/homebrew-validate-release.yml new file mode 100644 index 00000000000..0cedb0c4c64 --- /dev/null +++ b/.github/workflows/homebrew-validate-release.yml @@ -0,0 +1,143 @@ +# Validate the Homebrew cask against a freshly-published Aspire release. +# +# Runs the full upstream-equivalent cask validation (`brew audit --cask +# --online --signing` + a real `brew install`/`brew uninstall` cycle) +# against the cask file generated from the just-published release's +# aspire-cli-osx-* archives. This is the same validation that used to +# run as HomebrewValidateJob inside eng/pipelines/release-publish-nuget.yml. +# It was moved out because the AzDO release pipeline now creates the +# GitHub release as a draft (to be compatible with GitHub's immutable +# releases — see docs/release-process.md), and draft assets are not +# served from the public github.com/.../releases/download/v/... +# URL that `brew audit --online` and `brew install` need. The validation +# therefore has to wait until the release manager publishes the draft. +# +# Trigger semantics: we subscribe to `release: [published]` rather than +# `[released]`. Under the draft-then-publish flow the release manager +# publishes an existing draft, and GitHub only reliably fires `published` +# for that transition — `released` can be skipped when a release moves +# from draft to published (see +# https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#release). +# `published` fires the moment the asset URLs become reachable. Prereleases +# and drafts are skipped via the `if:` guard since the Homebrew cask we ship +# is stable-only. +# +# History: when changing this workflow, please ping @radical (Ankit) for +# review — he set up the original HomebrewValidateJob in the AzDO +# pipeline and knows the cross-channel implications best. + +name: Validate Homebrew Cask against released GitHub Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_version: + description: 'Release version to validate (e.g. 13.4.3). Required for manual runs.' + required: true + type: string + +# Avoid double-validating the same tag if both the `release: published` +# event and a manual workflow_dispatch fire near each other. +concurrency: + group: homebrew-validate-${{ github.event.release.tag_name || github.event.inputs.release_version }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + validate: + name: Validate cask against live release + # `release: published` fires for any published release, so check + # explicitly for a stable, non-draft, non-prerelease release here (this + # also covers workflow_dispatch, which can be aimed at any version). We + # also gate on the repo to make sure forks don't try to validate against + # microsoft/aspire releases. + if: >- + github.repository == 'microsoft/aspire' + && ( + (github.event_name == 'release' && github.event.release.prerelease == false && github.event.release.draft == false) + || github.event_name == 'workflow_dispatch' + ) + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Resolve release version + id: resolve + env: + EVENT_VERSION: ${{ github.event.release.tag_name }} + INPUT_VERSION: ${{ github.event.inputs.release_version }} + run: | + set -euo pipefail + + # release.tag_name comes in as "vX.Y.Z" for our tags; the + # workflow_dispatch input is supplied without the leading "v" + # (matching docs/release-process.md). Normalize to bare X.Y.Z. + if [[ -n "${EVENT_VERSION:-}" ]]; then + version="${EVENT_VERSION#v}" + else + version="${INPUT_VERSION#v}" + fi + + if [[ -z "$version" ]]; then + echo "::error::Could not determine release version from event or inputs" + exit 1 + fi + + # Aspire's tags are vMAJOR.MINOR.PATCH for stable. The Homebrew + # cask we ship targets stable only — refuse anything else. + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version '$version' does not match X.Y.Z (stable). Homebrew validation is stable-only." + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Resolved release version: $version" + + - name: Generate cask file for released version + id: cask + run: | + set -euo pipefail + version="${{ steps.resolve.outputs.version }}" + out_dir="$RUNNER_TEMP/homebrew-cask" + mkdir -p "$out_dir" + + # generate-cask.sh computes SHA256s by downloading the + # aspire-cli-osx-{arm64,x64}-.tar.gz tarballs from the + # public github.com/.../releases/download/v/... URL, + # then renders eng/homebrew/aspire.rb.template with the + # resulting hashes. This depends on the release assets being + # public, which is why this workflow runs on release: [published] + # (when the human publishes the draft) rather than at + # draft-creation time, when the asset URLs don't resolve yet. + # + # NOTE (@radical, please review): the old AzDO HomebrewValidateJob + # consumed the cask file emitted by the source build directly + # (download artifact: homebrew-cask-stable). This workflow + # regenerates the cask from scratch instead, which means + # generate-cask.sh becomes the de-facto source of truth for the + # cask shape. If the source build ever customizes the cask + # template (custom test do block, per-build tweaks), the + # validation here will silently diverge from what ships. See + # eng/homebrew/README.md for the trade-off and aspire#18068 + # for the discussion that introduced this change. + eng/homebrew/generate-cask.sh \ + --version "$version" \ + --output "$out_dir/aspire.rb" + + echo "cask_file=$out_dir/aspire.rb" >> "$GITHUB_OUTPUT" + + - name: Validate cask against live GitHub release + run: | + set -euo pipefail + eng/homebrew/validate-cask-artifact.sh \ + --cask-file "${{ steps.cask.outputs.cask_file }}" \ + --channel stable \ + --validation-mode LiveRelease diff --git a/.github/workflows/milestone-changelog.lock.yml b/.github/workflows/milestone-changelog.lock.yml index 006feb62ab8..b144023d6fc 100644 --- a/.github/workflows/milestone-changelog.lock.yml +++ b/.github/workflows/milestone-changelog.lock.yml @@ -1,6 +1,6 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a5b8f2169fdf09e15e5bf5c018e3a1f6671b92949456bf59f483f53f15440765","body_hash":"57136f88b12a8ca98c6913fe6de4eaed46719adb4880b9625eae320e26cbfd2e","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/download-artifact","sha":"d3f86a106a0bac45b974a628896c90dbdf5c8093","version":"v4"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"actions/upload-artifact","sha":"ea165f8d65b6e75b540449e92b4886f43607fa02","version":"v4"},{"repo":"github/gh-aw-actions/setup","sha":"2709137ea6c5b0e19aa621454dc643ea8dc526b1","version":"v0.85.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} -# This file was automatically generated by gh-aw (v0.85.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a5b8f2169fdf09e15e5bf5c018e3a1f6671b92949456bf59f483f53f15440765","body_hash":"57136f88b12a8ca98c6913fe6de4eaed46719adb4880b9625eae320e26cbfd2e","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/download-artifact","sha":"d3f86a106a0bac45b974a628896c90dbdf5c8093","version":"v4"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"actions/upload-artifact","sha":"ea165f8d65b6e75b540449e92b4886f43607fa02","version":"v4"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} +# This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ # / _ \ | | (_) @@ -40,7 +40,6 @@ # - REPO: (main workflow) # # Secrets used: -# - COPILOT_GITHUB_TOKEN # - GH_AW_GITHUB_MCP_SERVER_TOKEN # - GH_AW_GITHUB_TOKEN # - GITHUB_TOKEN @@ -54,15 +53,15 @@ # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 # - actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 -# - github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 +# - github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 # - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 # - ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 -# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 +# - ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f # - ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 -# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 +# - ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e name: "Milestone Changelog Generator" on: @@ -115,14 +114,14 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} env: GH_AW_SETUP_WORKFLOW_NAME: "Milestone Changelog Generator" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/milestone-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate agentic run info @@ -131,9 +130,9 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.78" - GH_AW_INFO_AGENT_VERSION: "1.0.78" - GH_AW_INFO_CLI_VERSION: "v0.85.4" + GH_AW_INFO_VERSION: "1.0.79" + GH_AW_INFO_AGENT_VERSION: "1.0.79" + GH_AW_INFO_CLI_VERSION: "v0.86.2" GH_AW_INFO_WORKFLOW_NAME: "Milestone Changelog Generator" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -143,6 +142,7 @@ jobs: GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_AGENT_RUNTIME: "" GH_AW_COMPILED_STRICT: "true" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -155,7 +155,6 @@ jobs: id: check-oauth-tokens run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - name: Checkout .github and .agents folders @@ -175,8 +174,8 @@ jobs: env: GH_AW_AGENT_FOLDERS: ".agents .github" GH_AW_AGENT_FILES: "AGENTS.md" - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" - name: Check workflow lock file id: check-lock-file uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -192,7 +191,7 @@ jobs: - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.85.4" + GH_AW_COMPILED_VERSION: "v0.86.2" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -203,9 +202,12 @@ jobs: if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" - name: Create prompt with built-in context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ACTIONS_DIR: ${{ runner.temp }}/gh-aw/actions + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}]}" GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -214,63 +216,22 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - # poutine:ignore untrusted_checkout_exec - run: | - bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" - { - cat << 'GH_AW_PROMPT_8db497e5a3cf9f7b_EOF' - - GH_AW_PROMPT_8db497e5a3cf9f7b_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_8db497e5a3cf9f7b_EOF' - - Tools: missing_tool, missing_data, noop, publish - - GH_AW_PROMPT_8db497e5a3cf9f7b_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_8db497e5a3cf9f7b_EOF' - - The following GitHub context information is available for this workflow: - {{#if github.actor}} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if github.repository}} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if github.workspace}} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} - - **issue-number**: #__GH_AW_EXPR_802A9F6A__ - {{/if}} - {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} - - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ - {{/if}} - {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} - - **pull-request-number**: #__GH_AW_EXPR_463A214A__ - {{/if}} - {{#if github.event.comment.id || github.aw.context.comment_id}} - - **comment-id**: __GH_AW_EXPR_FF1D34CE__ - {{/if}} - {{#if github.run_id}} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_8db497e5a3cf9f7b_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_8db497e5a3cf9f7b_EOF' - - {{#runtime-import .github/workflows/milestone-changelog.md}} - GH_AW_PROMPT_8db497e5a3cf9f7b_EOF - } > "$GH_AW_PROMPT" + GH_AW_PROMPT_CONTENT_0000: "\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: missing_tool, missing_data, noop, publish\n" + GH_AW_PROMPT_CONTENT_0002: "\n" + GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_802A9F6A__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_1A3A194A__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_463A214A__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_FF1D34CE__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n\n\n" + GH_AW_PROMPT_CONTENT_0004: "\n" + GH_AW_PROMPT_CONTENT_0005: "{{#runtime-import .github/workflows/milestone-changelog.md}}\n" + with: + script: | + const { setupGlobals } = require(process.env.GH_AW_ACTIONS_DIR + '/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(process.env.GH_AW_ACTIONS_DIR + '/create_prompt.cjs'); + await main(core); - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_ENGINE_ID: "copilot" with: script: | @@ -281,7 +242,7 @@ jobs: - name: Substitute placeholders uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -315,14 +276,18 @@ jobs: }); - name: Validate prompt placeholders env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Stage prompt files for artifact upload + run: | + mkdir -p /tmp/gh-aw/aw-prompts + cp -a "${RUNNER_TEMP}/gh-aw/aw-prompts/." /tmp/gh-aw/aw-prompts/ - name: Upload activation artifact if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -390,7 +355,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -399,7 +364,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Milestone Changelog Generator" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/milestone-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths @@ -450,11 +415,13 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 --rootless - name: Determine automatic lockdown mode for GitHub MCP Server @@ -491,7 +458,7 @@ jobs: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" @@ -625,22 +592,23 @@ jobs: export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export MCP_GATEWAY_ALLOWED_MOUNT_ROOTS="${GITHUB_WORKSPACE}:rw,${RUNNER_TEMP}/gh-aw:ro,${RUNNER_TEMP}/gh-aw/safeoutputs:rw,/opt:ro,/tmp:rw,/usr/bin/gh:ro" export DEBUG="*" export GH_AW_ENGINE="copilot" MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.8' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e MCP_GATEWAY_ALLOWED_MOUNT_ROOTS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.9' mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_b300e50cd08d2fb5_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_fecaa78952dd2e64_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "container": "ghcr.io/github/github-mcp-server:v1.9.0", "env": { "GITHUB_FEATURES": "fields_param", "GITHUB_HOST": "${GITHUB_SERVER_URL}", @@ -700,7 +668,7 @@ jobs: "startupTimeout": 120 } } - GH_AW_MCP_CONFIG_b300e50cd08d2fb5_EOF + GH_AW_MCP_CONFIG_fecaa78952dd2e64_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -762,6 +730,18 @@ jobs: printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -786,8 +766,8 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(cd)'\'' --allow-tool '\''shell(cp)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(gh:*)'\'' --allow-tool '\''shell(github:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(mkdir)'\'' --allow-tool '\''shell(mv)'\'' --allow-tool '\''shell(printf)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(python3)'\'' --allow-tool '\''shell(rm)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(xargs)'\'' --allow-tool '\''shell(xxd)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(cd)'\'' --allow-tool '\''shell(cp)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(gh:*)'\'' --allow-tool '\''shell(github:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(mkdir)'\'' --allow-tool '\''shell(mv)'\'' --allow-tool '\''shell(printf)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(python3)'\'' --allow-tool '\''shell(rm)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(xargs)'\'' --allow-tool '\''shell(xxd)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE @@ -801,7 +781,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_TIMEOUT_MINUTES: 30 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -992,7 +972,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1001,7 +981,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Milestone Changelog Generator" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/milestone-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1198,7 +1178,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1207,7 +1187,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Milestone Changelog Generator" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/milestone-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1259,25 +1239,7 @@ jobs: - name: Prepare threat detection files if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - rm -f /tmp/gh-aw/agent_usage.json - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then - echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context." - fi - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - for f in /tmp/gh-aw/aw-*.bundle; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + bash "${RUNNER_TEMP}/gh-aw/actions/prepare_threat_detection_files.sh" - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1297,18 +1259,20 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - rm -f /tmp/gh-aw/threat-detection/step-summary.md - touch /tmp/gh-aw/threat-detection/step-summary.md + rm -f /tmp/gh-aw/step-summary.md + touch /tmp/gh-aw/step-summary.md - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Execute GitHub Copilot CLI @@ -1324,6 +1288,18 @@ jobs: mkdir -p "$HOME/.copilot" printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -1350,9 +1326,10 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: + GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode @@ -1364,14 +1341,13 @@ jobs: GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] @@ -1380,6 +1356,23 @@ jobs: RUNNER_TEMP: ${{ runner.temp }} S2STOKENS: true TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Echo detection step summary + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + run: | + if [ -s /tmp/gh-aw/step-summary.md ]; then + cat /tmp/gh-aw/step-summary.md + fi + - name: Render detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/render_detection_log.cjs'); + await main(); - name: Parse threat detection token usage for step summary id: parse_detection_token_usage if: always() @@ -1863,15 +1856,20 @@ jobs: code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_items_applied: ${{ steps.process_safe_outputs.outputs.items_applied }} + process_safe_outputs_items_cancelled: ${{ steps.process_safe_outputs.outputs.items_cancelled }} + process_safe_outputs_items_deferred: ${{ steps.process_safe_outputs.outputs.items_deferred }} process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }} + process_safe_outputs_items_skipped: ${{ steps.process_safe_outputs.outputs.items_skipped }} process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }} + process_safe_outputs_items_warnings: ${{ steps.process_safe_outputs.outputs.items_warnings }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1880,7 +1878,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Milestone Changelog Generator" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/milestone-changelog.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact diff --git a/.github/workflows/pr-docs-check.lock.yml b/.github/workflows/pr-docs-check.lock.yml index 0c2f06b88f3..58297552eeb 100644 --- a/.github/workflows/pr-docs-check.lock.yml +++ b/.github/workflows/pr-docs-check.lock.yml @@ -1,6 +1,6 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"25e0fe19c95e8ba536dd030e20724e289d3c9025acf55d08d90d78d24ac03077","body_hash":"43bd509e196c1c74c40d28da32a72b91cea8a64165f8c1a40e9adb414848b8b6","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78"}} -# gh-aw-manifest: {"version":1,"secrets":["ASPIRE_BOT_APP_ID","ASPIRE_BOT_PRIVATE_KEY","COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"34e114876b0b11c390a56381ad16ebd13914f8d5","version":"v4"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"},{"repo":"actions/create-github-app-token","sha":"1b10c78c7865c340bc4f6099eb2f838309f1e8c3","version":"v3.1.1"},{"repo":"actions/create-github-app-token","sha":"bcd2ba49218906704ab6c1aa796996da409d3eb1","version":"v3.2.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/download-artifact","sha":"d3f86a106a0bac45b974a628896c90dbdf5c8093","version":"v4"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"2709137ea6c5b0e19aa621454dc643ea8dc526b1","version":"v0.85.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}],"has_pull_request":true} -# This file was automatically generated by gh-aw (v0.85.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"96ed6ed956aad627b8c1756b9aa47854ca342449bda6cd60e40c6fb38a71cb0b","body_hash":"43bd509e196c1c74c40d28da32a72b91cea8a64165f8c1a40e9adb414848b8b6","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-manifest: {"version":1,"secrets":["ASPIRE_BOT_APP_ID","ASPIRE_BOT_PRIVATE_KEY","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"34e114876b0b11c390a56381ad16ebd13914f8d5","version":"v4"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"},{"repo":"actions/create-github-app-token","sha":"1b10c78c7865c340bc4f6099eb2f838309f1e8c3","version":"v3.1.1"},{"repo":"actions/create-github-app-token","sha":"bcd2ba49218906704ab6c1aa796996da409d3eb1","version":"v3.2.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/download-artifact","sha":"d3f86a106a0bac45b974a628896c90dbdf5c8093","version":"v4"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}],"has_pull_request":true} +# This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ # / _ \ | | (_) @@ -37,7 +37,6 @@ # Secrets used: # - ASPIRE_BOT_APP_ID # - ASPIRE_BOT_PRIVATE_KEY -# - COPILOT_GITHUB_TOKEN # - GH_AW_CI_TRIGGER_TOKEN # - GH_AW_GITHUB_MCP_SERVER_TOKEN # - GH_AW_GITHUB_TOKEN @@ -55,15 +54,15 @@ # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 +# - github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 # - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 # - ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 -# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 +# - ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f # - ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 -# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 +# - ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e name: "PR Documentation Check" on: @@ -123,7 +122,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -132,7 +131,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Documentation Check" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-docs-check.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate agentic run info @@ -141,9 +140,9 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.78" - GH_AW_INFO_AGENT_VERSION: "1.0.78" - GH_AW_INFO_CLI_VERSION: "v0.85.4" + GH_AW_INFO_VERSION: "1.0.79" + GH_AW_INFO_AGENT_VERSION: "1.0.79" + GH_AW_INFO_CLI_VERSION: "v0.86.2" GH_AW_INFO_WORKFLOW_NAME: "PR Documentation Check" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -153,6 +152,7 @@ jobs: GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_AGENT_RUNTIME: "" GH_AW_COMPILED_STRICT: "true" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -165,7 +165,6 @@ jobs: id: check-oauth-tokens run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - name: Checkout .github and .agents folders @@ -185,12 +184,12 @@ jobs: env: GH_AW_AGENT_FOLDERS: ".agents .github" GH_AW_AGENT_FILES: "AGENTS.md" - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.85.4" + GH_AW_COMPILED_VERSION: "v0.86.2" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -212,9 +211,12 @@ jobs: if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" - name: Create prompt with built-in context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ACTIONS_DIR: ${{ runner.temp }}/gh-aw/actions + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"file\":\"safe_outputs_create_pull_request.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}]}" GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_2697560F: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -225,79 +227,22 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - # poutine:ignore untrusted_checkout_exec - run: | - bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" - { - cat << 'GH_AW_PROMPT_c924f63d142a5e34_EOF' - - GH_AW_PROMPT_c924f63d142a5e34_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_c924f63d142a5e34_EOF' - - Tools: create_pull_request, missing_tool, missing_data, noop, notify_source_pr - GH_AW_PROMPT_c924f63d142a5e34_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" - cat << 'GH_AW_PROMPT_c924f63d142a5e34_EOF' - - GH_AW_PROMPT_c924f63d142a5e34_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_c924f63d142a5e34_EOF' - - The following GitHub context information is available for this workflow: - {{#if github.actor}} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if github.repository}} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if github.workspace}} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} - - **issue-number**: #__GH_AW_EXPR_802A9F6A__ - {{/if}} - {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} - - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ - {{/if}} - {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} - - **pull-request-number**: #__GH_AW_EXPR_463A214A__ - {{/if}} - {{#if github.event.comment.id || github.aw.context.comment_id}} - - **comment-id**: __GH_AW_EXPR_FF1D34CE__ - {{/if}} - {{#if github.run_id}} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - **checkouts**: The following repositories have been checked out and are available in the workspace: - - repo `microsoft/aspire.dev` → `$GITHUB_WORKSPACE` (cwd) (**current** - this is the repository you are working on; use this as the target for all GitHub operations unless otherwise specified) [shallow clone, fetch-depth=1 (default)] - - **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches). - - **Warning: No git credentials are available to the agent.** Credentials are - intentionally removed after the checkout step for security. This means any git - operation that needs to authenticate to the remote will fail. In private repositories, that includes: - - `git fetch`, `git pull`, `git clone`, and `git push` (direct push, not via safe-output tools) - - Checking out or switching to a remote branch that is not already fetched - - Deepening a shallow clone (`git fetch --unshallow`) - - On-demand blob fetches in partial/blobless clones (operations on files not in the initial checkout) - Do NOT attempt to configure credentials, run `git credential fill`, or modify `.gitconfig` — - authentication will not succeed. If you encounter credential prompts or authentication errors, - stop immediately and report the limitation rather than spending turns trying to work around it. - - - GH_AW_PROMPT_c924f63d142a5e34_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_c924f63d142a5e34_EOF' - - {{#runtime-import .github/workflows/pr-docs-check.md}} - GH_AW_PROMPT_c924f63d142a5e34_EOF - } > "$GH_AW_PROMPT" + GH_AW_PROMPT_CONTENT_0000: "\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: create_pull_request, missing_tool, missing_data, noop, notify_source_pr\n" + GH_AW_PROMPT_CONTENT_0002: "\n" + GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_802A9F6A__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_1A3A194A__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_463A214A__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_FF1D34CE__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n- **checkouts**: The following repositories have been checked out and are available in the workspace:\n - repo `microsoft/aspire.dev` → `$GITHUB_WORKSPACE` (cwd) (**current** - this is the repository you are working on; use this as the target for all GitHub operations unless otherwise specified) [shallow clone, fetch-depth=1 (default)]\n - **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: [\"refs/pulls/open/*\"]` for all open PR refs, or `fetch: [\"main\", \"feature/my-branch\"]` for specific branches).\n - **Warning: No git credentials are available to the agent.** Credentials are\n intentionally removed after the checkout step for security. This means any git\n operation that needs to authenticate to the remote will fail. In private repositories, that includes:\n - `git fetch`, `git pull`, `git clone`, and `git push` (direct push, not via safe-output tools)\n - Checking out or switching to a remote branch that is not already fetched\n - Deepening a shallow clone (`git fetch --unshallow`)\n - On-demand blob fetches in partial/blobless clones (operations on files not in the initial checkout)\n Do NOT attempt to configure credentials, run `git credential fill`, or modify `.gitconfig` —\n authentication will not succeed. If you encounter credential prompts or authentication errors,\n stop immediately and report the limitation rather than spending turns trying to work around it.\n\n\n" + GH_AW_PROMPT_CONTENT_0004: "\n" + GH_AW_PROMPT_CONTENT_0005: "{{#runtime-import .github/workflows/pr-docs-check.md}}\n" + with: + script: | + const { setupGlobals } = require(process.env.GH_AW_ACTIONS_DIR + '/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(process.env.GH_AW_ACTIONS_DIR + '/create_prompt.cjs'); + await main(core); - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_ENGINE_ID: "copilot" GH_AW_EXPR_2697560F: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} GH_AW_GITHUB_EVENT_PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }} @@ -310,7 +255,7 @@ jobs: - name: Substitute placeholders uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_2697560F: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -350,14 +295,18 @@ jobs: }); - name: Validate prompt placeholders env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Stage prompt files for artifact upload + run: | + mkdir -p /tmp/gh-aw/aw-prompts + cp -a "${RUNNER_TEMP}/gh-aw/aw-prompts/." /tmp/gh-aw/aw-prompts/ - name: Upload activation artifact if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -419,7 +368,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -428,7 +377,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Documentation Check" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-docs-check.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths @@ -504,11 +453,13 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 --rootless - name: Generate GitHub App token @@ -559,19 +510,6 @@ jobs: env: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - - name: Stage GitHub Copilot CLI for agent execution - run: | - COPILOT_BIN="$(command -v copilot || true)" - if [[ -z "${COPILOT_BIN}" || ! -x "${COPILOT_BIN}" ]]; then - echo "::error::The GitHub Copilot CLI installer did not provide an executable." - exit 1 - fi - - if [[ "${COPILOT_BIN}" != "/usr/local/bin/copilot" ]]; then - sudo cp "${COPILOT_BIN}" /usr/local/bin/copilot - sudo chmod 755 /usr/local/bin/copilot - fi - /usr/local/bin/copilot --version - name: Check out pre-agent scripts uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # de0fac2e4500dabe0009e67214ff5f5447ce83dd with: @@ -603,7 +541,7 @@ jobs: run: "set -euo pipefail\n\nmkdir -p .pr-docs-check\nOUT=.pr-docs-check/signals.json\n\nif [ -z \"${PR_NUMBER}\" ]; then\n echo \"ERROR: PR_NUMBER is empty; cannot compute signals.\" >&2\n exit 1\nfi\n# Same input validation as the target-branch resolver above — reject\n# anything that isn't a positive integer so a bad `workflow_dispatch`\n# input fails loudly here rather than producing an opaque gh api error.\nif ! [[ \"${PR_NUMBER}\" =~ ^[1-9][0-9]*$ ]]; then\n echo \"ERROR: PR_NUMBER '${PR_NUMBER}' is not a positive integer.\" >&2\n exit 1\nfi\n\necho \"Computing user-facing signals for microsoft/aspire#${PR_NUMBER}\"\n\n# --- 1. Fetch PR metadata (body) and changed files (paths + patches) -\n# The files endpoint returns at most 30 files per page and is capped\n# at 300 files / 3000 lines total per the GitHub API; that's fine\n# for our purposes because signal detection only needs path patterns\n# and patch hunks, and any change large enough to overflow that cap\n# is almost certainly user-facing on path patterns alone. The\n# script also emits `diff_scan_skipped_due_to_missing_patch` when\n# the API omits a `patch` for a file matched by a diff trigger,\n# so very-large diffs gate conservatively.\n#\n# https://docs.github.com/en/rest/pulls/pulls#list-pull-requests-files\nPR_JSON=\"$(mktemp)\"\nFILES_JSON=\"$(mktemp)\"\nREVIEWS_JSON=\"$(mktemp)\"\ngh api \"/repos/microsoft/aspire/pulls/${PR_NUMBER}\" > \"${PR_JSON}\"\ngh api --paginate \"/repos/microsoft/aspire/pulls/${PR_NUMBER}/files?per_page=100\" \\\n | jq -s 'add // []' > \"${FILES_JSON}\"\n# Reviews drive SME resolution below. One paginated call here replaces a\n# GitHub tool round-trip the agent would otherwise make inside the loop.\ngh api --paginate \"/repos/microsoft/aspire/pulls/${PR_NUMBER}/reviews?per_page=100\" \\\n | jq -s 'add // []' > \"${REVIEWS_JSON}\"\n\nFILE_COUNT=\"$(jq 'length' \"${FILES_JSON}\")\"\necho \"Files in PR : ${FILE_COUNT}\"\n\n# --- 2. Run signal detection ----------------------------------------\n# The signal catalog lives in a standalone Python script (preinstalled\n# on ubuntu-latest) so a malformed regex fails the step loudly instead\n# of being silently absorbed by a shell pipeline, the catalog can be\n# reviewed with syntax highlighting, and it has its own unittest suite.\npython3 _repos/aspire/.github/workflows/pr-docs-check/compute_signals.py \\\n \"${PR_JSON}\" \"${FILES_JSON}\" \"${OUT}\"\n\n# --- 3. Build compact PR context ------------------------------------\n# Reuse the same PR + files payloads (already fetched above) to write\n# .pr-docs-check/pr.json — the curated metadata the agent reads in\n# Step 1 instead of re-gathering it with several GitHub tool calls.\n# That gathering is fully deterministic, so doing it once here removes\n# those round-trips and the verbose API responses they add to context.\n# See compute_pr_context.py.\nPR_CONTEXT_OUT=.pr-docs-check/pr.json\npython3 _repos/aspire/.github/workflows/pr-docs-check/compute_pr_context.py \\\n \"${PR_JSON}\" \"${FILES_JSON}\" \"${PR_CONTEXT_OUT}\"\n\n# --- 4. Resolve the subject-matter expert (SME) ---------------------\n# SME selection from assignees/reviews is a deterministic algorithm, so\n# it runs once here (reading the curated pr.json + reviews) instead of\n# inside the agent loop. The agent reads .pr-docs-check/sme.json in\n# Step 2. Only the fuzzy CODEOWNERS hint is left to the agent, signalled\n# via \"needs_codeowners_fallback\". See resolve_sme.py.\nSME_OUT=.pr-docs-check/sme.json\npython3 _repos/aspire/.github/workflows/pr-docs-check/resolve_sme.py \\\n \"${PR_CONTEXT_OUT}\" \"${REVIEWS_JSON}\" \"${SME_OUT}\"\n\nrm -f \"${PR_JSON}\" \"${FILES_JSON}\" \"${REVIEWS_JSON}\"\n\necho \"--- ${OUT} ---\"\ncat \"${OUT}\"\necho \"--- ${PR_CONTEXT_OUT} ---\"\ncat \"${PR_CONTEXT_OUT}\"\necho \"--- ${SME_OUT} ---\"\ncat \"${SME_OUT}\"" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" @@ -693,6 +631,10 @@ jobs: "type": "string", "maxLength": 256 }, + "temporary_id": { + "type": "string", + "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$" + }, "title": { "required": true, "type": "string", @@ -806,22 +748,23 @@ jobs: export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export MCP_GATEWAY_ALLOWED_MOUNT_ROOTS="${GITHUB_WORKSPACE}:rw,${RUNNER_TEMP}/gh-aw:ro,${RUNNER_TEMP}/gh-aw/safeoutputs:rw,/opt:ro,/tmp:rw,/usr/bin/gh:ro" export DEBUG="*" export GH_AW_ENGINE="copilot" MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.8' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e MCP_GATEWAY_ALLOWED_MOUNT_ROOTS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.9' mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_b3c497501797fe3b_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_0bde3c464be51648_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "container": "ghcr.io/github/github-mcp-server:v1.9.0", "env": { "GITHUB_FEATURES": "fields_param", "GITHUB_HOST": "${GITHUB_SERVER_URL}", @@ -883,7 +826,7 @@ jobs: "startupTimeout": 120 } } - GH_AW_MCP_CONFIG_b3c497501797fe3b_EOF + GH_AW_MCP_CONFIG_0bde3c464be51648_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -917,6 +860,18 @@ jobs: printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -941,8 +896,8 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE @@ -956,7 +911,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -1151,7 +1106,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1160,7 +1115,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Documentation Check" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-docs-check.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate GitHub App token @@ -1375,7 +1330,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1384,7 +1339,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Documentation Check" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-docs-check.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1436,45 +1391,7 @@ jobs: - name: Prepare threat detection files if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - rm -f /tmp/gh-aw/agent_usage.json - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then - echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context." - fi - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - for f in /tmp/gh-aw/aw-*.bundle; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - - name: Install GitHub Copilot CLI for threat detection staging - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" - env: - GH_AW_COMPILED_VERSION: v0.85.4 - GH_HOST: github.com - - name: Stage GitHub Copilot CLI for threat detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - COPILOT_BIN="$(command -v copilot || true)" - if [[ -z "${COPILOT_BIN}" || ! -x "${COPILOT_BIN}" ]]; then - echo "::error::The GitHub Copilot CLI installer did not provide an executable." - exit 1 - fi - - if [[ "${COPILOT_BIN}" != "/usr/local/bin/copilot" ]]; then - sudo cp "${COPILOT_BIN}" /usr/local/bin/copilot - sudo chmod 755 /usr/local/bin/copilot - fi - /usr/local/bin/copilot --version + bash "${RUNNER_TEMP}/gh-aw/actions/prepare_threat_detection_files.sh" - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1494,18 +1411,20 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - rm -f /tmp/gh-aw/threat-detection/step-summary.md - touch /tmp/gh-aw/threat-detection/step-summary.md + rm -f /tmp/gh-aw/step-summary.md + touch /tmp/gh-aw/step-summary.md - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Execute GitHub Copilot CLI @@ -1521,6 +1440,18 @@ jobs: mkdir -p "$HOME/.copilot" printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -1547,9 +1478,10 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: + GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode @@ -1561,14 +1493,13 @@ jobs: GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] @@ -1577,6 +1508,23 @@ jobs: RUNNER_TEMP: ${{ runner.temp }} S2STOKENS: true TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Echo detection step summary + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + run: | + if [ -s /tmp/gh-aw/step-summary.md ]; then + cat /tmp/gh-aw/step-summary.md + fi + - name: Render detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/render_detection_log.cjs'); + await main(); - name: Parse threat detection token usage for step summary id: parse_detection_token_usage if: always() @@ -1912,14 +1860,14 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} env: GH_AW_SETUP_WORKFLOW_NAME: "PR Documentation Check" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-docs-check.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Check team membership for workflow @@ -1971,15 +1919,20 @@ jobs: create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} created_pr_number: ${{ steps.process_safe_outputs.outputs.created_pr_number }} created_pr_url: ${{ steps.process_safe_outputs.outputs.created_pr_url }} + process_safe_outputs_items_applied: ${{ steps.process_safe_outputs.outputs.items_applied }} + process_safe_outputs_items_cancelled: ${{ steps.process_safe_outputs.outputs.items_cancelled }} + process_safe_outputs_items_deferred: ${{ steps.process_safe_outputs.outputs.items_deferred }} process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }} + process_safe_outputs_items_skipped: ${{ steps.process_safe_outputs.outputs.items_skipped }} process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }} + process_safe_outputs_items_warnings: ${{ steps.process_safe_outputs.outputs.items_warnings }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1988,7 +1941,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "PR Documentation Check" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/pr-docs-check.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -2096,16 +2049,33 @@ jobs: f"found {len(create_items)}." ) - base_branch = create_items[0].get("base_branch") + # gh-aw v0.86.2 records the supported tool input as: + # {"type":"create_pull_request","base":"release/13.5",...} + # Older outputs record the normalized value as "base_branch" instead. + create_item = create_items[0] + has_base = "base" in create_item + has_base_branch = "base_branch" in create_item + base = create_item.get("base") + base_branch = create_item.get("base_branch") + if has_base and not isinstance(base, str): + raise SystemExit("Canonical create_pull_request base is invalid.") + if has_base_branch and not isinstance(base_branch, str): + raise SystemExit("Canonical create_pull_request base_branch is invalid.") + if has_base and has_base_branch and base != base_branch: + raise SystemExit( + "Canonical create_pull_request base and base_branch disagree." + ) + + target_branch = base if has_base else base_branch if ( - not isinstance(base_branch, str) - or re.fullmatch(r"main|release/[0-9]+\.[0-9]+(?:\.[0-9]+)?", base_branch) + not isinstance(target_branch, str) + or re.fullmatch(r"main|release/[0-9]+\.[0-9]+(?:\.[0-9]+)?", target_branch) is None ): - raise SystemExit("Canonical create_pull_request base_branch is invalid.") + raise SystemExit("Canonical create_pull_request target branch is invalid.") with open(sys.argv[1], "a", encoding="utf-8") as github_output: - github_output.write(f"branch={base_branch}\n") + github_output.write(f"branch={target_branch}\n") PY - name: Process Safe Outputs id: process_safe_outputs diff --git a/.github/workflows/pr-docs-check.md b/.github/workflows/pr-docs-check.md index d5d265d1880..38d207f3079 100644 --- a/.github/workflows/pr-docs-check.md +++ b/.github/workflows/pr-docs-check.md @@ -184,32 +184,6 @@ safe-outputs: private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} owner: "microsoft" repositories: ["aspire.dev", "aspire"] - # Work around https://github.com/github/gh-aw/issues/50906 in gh-aw v0.85.4. - # Threat detection runs on a fresh runner, and its custom steps run before the - # generated Copilot installer. Run the same verified installer here so the - # following step can stage a cached CLI where the generated AWF command expects - # it. Remove these steps after upgrading to a compiler containing - # https://github.com/github/gh-aw/pull/50908. - threat-detection: - steps: - - name: Install GitHub Copilot CLI for threat detection staging - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" - env: - GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 - - name: Stage GitHub Copilot CLI for threat detection - run: | - COPILOT_BIN="$(command -v copilot || true)" - if [[ -z "${COPILOT_BIN}" || ! -x "${COPILOT_BIN}" ]]; then - echo "::error::The GitHub Copilot CLI installer did not provide an executable." - exit 1 - fi - - if [[ "${COPILOT_BIN}" != "/usr/local/bin/copilot" ]]; then - sudo cp "${COPILOT_BIN}" /usr/local/bin/copilot - sudo chmod 755 /usr/local/bin/copilot - fi - /usr/local/bin/copilot --version # gh-aw generates the target-repository checkout required by create-pull-request. # An additional actions/checkout step would trigger https://github.com/github/gh-aw/issues/50905 # in v0.85.4 and downgrade the app token from contents: write to contents: read. @@ -243,16 +217,33 @@ safe-outputs: f"found {len(create_items)}." ) - base_branch = create_items[0].get("base_branch") + # gh-aw v0.86.2 records the supported tool input as: + # {"type":"create_pull_request","base":"release/13.5",...} + # Older outputs record the normalized value as "base_branch" instead. + create_item = create_items[0] + has_base = "base" in create_item + has_base_branch = "base_branch" in create_item + base = create_item.get("base") + base_branch = create_item.get("base_branch") + if has_base and not isinstance(base, str): + raise SystemExit("Canonical create_pull_request base is invalid.") + if has_base_branch and not isinstance(base_branch, str): + raise SystemExit("Canonical create_pull_request base_branch is invalid.") + if has_base and has_base_branch and base != base_branch: + raise SystemExit( + "Canonical create_pull_request base and base_branch disagree." + ) + + target_branch = base if has_base else base_branch if ( - not isinstance(base_branch, str) - or re.fullmatch(r"main|release/[0-9]+\.[0-9]+(?:\.[0-9]+)?", base_branch) + not isinstance(target_branch, str) + or re.fullmatch(r"main|release/[0-9]+\.[0-9]+(?:\.[0-9]+)?", target_branch) is None ): - raise SystemExit("Canonical create_pull_request base_branch is invalid.") + raise SystemExit("Canonical create_pull_request target branch is invalid.") with open(sys.argv[1], "a", encoding="utf-8") as github_output: - github_output.write(f"branch={base_branch}\n") + github_output.write(f"branch={target_branch}\n") PY create-pull-request: title-prefix: "[docs] " @@ -573,22 +564,6 @@ safe-outputs: # agent starts and writes the result to .pr-docs-check/target.json. The # agent reads that file verbatim and never re-derives the branch. pre-agent-steps: - # gh-aw v0.85.4 can select a cached Copilot CLI but still hard-codes - # /usr/local/bin/copilot in the AWF command. Stage the selected binary there - # until the compiler includes https://github.com/github/gh-aw/pull/50908. - - name: Stage GitHub Copilot CLI for agent execution - run: | - COPILOT_BIN="$(command -v copilot || true)" - if [[ -z "${COPILOT_BIN}" || ! -x "${COPILOT_BIN}" ]]; then - echo "::error::The GitHub Copilot CLI installer did not provide an executable." - exit 1 - fi - - if [[ "${COPILOT_BIN}" != "/usr/local/bin/copilot" ]]; then - sudo cp "${COPILOT_BIN}" /usr/local/bin/copilot - sudo chmod 755 /usr/local/bin/copilot - fi - /usr/local/bin/copilot --version - name: Check out pre-agent scripts # The `checkout:` block above made microsoft/aspire.dev the current # workspace because that's where the doc PR is authored. We need a sparse, diff --git a/.github/workflows/pr-docs-check/test_validate_outcome.py b/.github/workflows/pr-docs-check/test_validate_outcome.py index dd95fae355e..46c2dbb4f90 100644 --- a/.github/workflows/pr-docs-check/test_validate_outcome.py +++ b/.github/workflows/pr-docs-check/test_validate_outcome.py @@ -39,17 +39,18 @@ def payload( } -def create_pull_request_item(base_branch: object = "release/13.5") -> dict: - return { +def create_pull_request_item(**target_fields: object) -> dict: + item = { "type": "create_pull_request", "title": "Draft docs", "body": "Docs", - "base_branch": base_branch, } + item.update(target_fields or {"base": "release/13.5"}) + return item class ValidateOutcomeTests(unittest.TestCase): - def test_drafted_with_correct_actual_base_passes(self) -> None: + def test_drafted_with_current_base_passes(self) -> None: drafted_payload = payload("drafted", target_branch="release/13.5") drafted_payload["items"].append(create_pull_request_item()) @@ -65,6 +66,108 @@ def test_drafted_with_correct_actual_base_passes(self) -> None: message, ) + def test_drafted_with_legacy_base_branch_passes(self) -> None: + drafted_payload = payload("drafted", target_branch="release/13.5") + drafted_payload["items"].append( + create_pull_request_item(base_branch="release/13.5") + ) + + message = validate_outcome( + drafted_payload, + "https://github.com/microsoft/aspire.dev/pull/1447", + EXPECTED_SOURCE_PR_NUMBER, + "release/13.5", + ) + + self.assertIn("Confirmed drafted documentation PR", message) + + def test_drafted_with_agreeing_base_fields_passes(self) -> None: + drafted_payload = payload("drafted", target_branch="release/13.5") + drafted_payload["items"].append( + create_pull_request_item( + base="release/13.5", + base_branch="release/13.5", + ) + ) + + message = validate_outcome( + drafted_payload, + "https://github.com/microsoft/aspire.dev/pull/1447", + EXPECTED_SOURCE_PR_NUMBER, + "release/13.5", + ) + + self.assertIn("Confirmed drafted documentation PR", message) + + def test_drafted_with_disagreeing_base_fields_fails(self) -> None: + drafted_payload = payload("drafted", target_branch="release/13.5") + drafted_payload["items"].append( + create_pull_request_item( + base="release/13.5", + base_branch="main", + ) + ) + + with self.assertRaisesRegex( + OutcomeValidationError, + "Canonical create_pull_request base and base_branch disagree", + ): + validate_outcome( + drafted_payload, + "https://github.com/microsoft/aspire.dev/pull/1447", + EXPECTED_SOURCE_PR_NUMBER, + "release/13.5", + ) + + def test_drafted_with_invalid_canonical_target_fails(self) -> None: + cases = { + "missing": {}, + "invalid current base": {"base": "release/latest"}, + "current base wrong type": {"base": 13.5}, + "invalid legacy base": {"base_branch": "release/latest"}, + "legacy base wrong type": {"base_branch": 13.5}, + "invalid current base with valid legacy base": { + "base": "release/latest", + "base_branch": "release/13.5", + }, + "valid current base with invalid legacy base": { + "base": "release/13.5", + "base_branch": "release/latest", + }, + } + for name, target_fields in cases.items(): + with self.subTest(name=name): + drafted_payload = payload("drafted", target_branch="release/13.5") + create_item = create_pull_request_item() + create_item.pop("base") + create_item.update(target_fields) + drafted_payload["items"].append(create_item) + + with self.assertRaises(OutcomeValidationError): + validate_outcome( + drafted_payload, + "https://github.com/microsoft/aspire.dev/pull/1447", + EXPECTED_SOURCE_PR_NUMBER, + "release/13.5", + ) + + def test_drafted_with_duplicate_create_items_fails(self) -> None: + drafted_payload = payload("drafted", target_branch="release/13.5") + drafted_payload["items"].extend( + [create_pull_request_item(), create_pull_request_item()] + ) + + with self.assertRaisesRegex( + OutcomeValidationError, + "Expected exactly one create_pull_request item for a drafted outcome, found 2", + ): + validate_outcome( + drafted_payload, + "https://github.com/microsoft/aspire.dev/pull/1447", + EXPECTED_SOURCE_PR_NUMBER, + "release/13.5", + ) + def test_drafted_with_actual_base_mismatch_fails(self) -> None: drafted_payload = payload("drafted", target_branch="release/13.5") drafted_payload["items"].append(create_pull_request_item()) @@ -72,7 +175,7 @@ def test_drafted_with_actual_base_mismatch_fails(self) -> None: with self.assertRaisesRegex( OutcomeValidationError, "Drafted PR base branch main does not match canonical " - "create_pull_request base_branch release/13.5", + "create_pull_request target branch release/13.5", ): validate_outcome( drafted_payload, @@ -87,7 +190,7 @@ def test_drafted_with_notification_target_mismatch_fails(self) -> None: with self.assertRaisesRegex( OutcomeValidationError, - "Canonical create_pull_request base_branch release/13.5 does not match " + "Canonical create_pull_request target branch release/13.5 does not match " "notify_source_pr target_branch main", ): validate_outcome( diff --git a/.github/workflows/pr-docs-check/validate_outcome.py b/.github/workflows/pr-docs-check/validate_outcome.py index bf1fe8ec77f..552110cdf8d 100644 --- a/.github/workflows/pr-docs-check/validate_outcome.py +++ b/.github/workflows/pr-docs-check/validate_outcome.py @@ -104,6 +104,37 @@ def _require_target_branch(value: object, field_name: str) -> str: return value +def _get_create_pull_request_target(payload: Any) -> str: + create_pull_request = _get_create_pull_request(payload) + has_base = "base" in create_pull_request + has_base_branch = "base_branch" in create_pull_request + if not has_base and not has_base_branch: + raise OutcomeValidationError( + "Canonical create_pull_request target branch is missing." + ) + + if has_base and has_base_branch: + base = _require_target_branch( + create_pull_request.get("base"), + "canonical create_pull_request base", + ) + base_branch = _require_target_branch( + create_pull_request.get("base_branch"), + "canonical create_pull_request base_branch", + ) + if base != base_branch: + raise OutcomeValidationError( + "Canonical create_pull_request base and base_branch disagree." + ) + return base + + field_name = "base" if has_base else "base_branch" + return _require_target_branch( + create_pull_request.get(field_name), + f"canonical create_pull_request {field_name}", + ) + + def _validate_drafted_base_contract( payload: Any, notification: dict[str, Any], @@ -115,18 +146,14 @@ def _validate_drafted_base_contract( "Safe outputs returned an invalid microsoft/aspire.dev pull request URL." ) - create_pull_request = _get_create_pull_request(payload) - canonical_base = _require_target_branch( - create_pull_request.get("base_branch"), - "canonical create_pull_request base_branch", - ) + canonical_base = _get_create_pull_request_target(payload) notification_target = _require_target_branch( notification.get("target_branch"), "notify_source_pr target_branch", ) if notification_target != canonical_base: raise OutcomeValidationError( - "Canonical create_pull_request base_branch " + "Canonical create_pull_request target branch " f"{canonical_base} does not match notify_source_pr target_branch " f"{notification_target}." ) @@ -135,7 +162,7 @@ def _validate_drafted_base_contract( if actual_base != canonical_base: raise OutcomeValidationError( f"Drafted PR base branch {actual_base} does not match canonical " - f"create_pull_request base_branch {canonical_base}." + f"create_pull_request target branch {canonical_base}." ) diff --git a/.github/workflows/release-github-tasks.yml b/.github/workflows/release-github-tasks.yml index 136f31a8dbf..012aed2a16a 100644 --- a/.github/workflows/release-github-tasks.yml +++ b/.github/workflows/release-github-tasks.yml @@ -192,10 +192,10 @@ jobs: # --- App token and checkout (shared by all subsequent steps) ---------- # Mint the aspire-repo-bot App installation token. Used by every step # that needs to fire a downstream event (release create, PR create, - # branch push). Using the App token instead of the default GITHUB_TOKEN - # causes the `release: released` event to actually trigger - # release-notes-generate.lock.yml (events fired by GITHUB_TOKEN do not - # cascade into other workflow runs). + # branch push) and to dispatch release-notes-generate. Using the App + # token instead of the default GITHUB_TOKEN is what lets those actions + # cascade into other workflow runs (events fired by GITHUB_TOKEN do not + # trigger further workflows). - name: Generate GitHub App Token id: app-token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 @@ -284,11 +284,15 @@ jobs: run: | # Write a short placeholder. The real release notes are generated # asynchronously by the release-notes-generate agentic workflow, - # which fires on the `release: [released]` event once `gh release - # create` below publishes the release, and edits this body in - # place. + # which we dispatch explicitly (via workflow_dispatch) right after + # creating the draft release below. It edits this body in place while + # the release is still a draft. (We can't rely on the + # `release: [created]` event because GitHub Actions does not fire + # release events for draft releases.) The release manager publishes + # the draft as a final manual step once both the notes and the CLI + # assets are attached. cat << EOF > release_notes.md - *Release notes are being generated automatically and will be added to this release shortly. If they haven't appeared within a few hours, ping the Aspire team.* + *This release is currently a draft. Release notes are being generated automatically and CLI assets are being uploaded — both will appear here within a few minutes. Once everything looks correct, the release manager will publish the draft. If notes or assets are missing after ~30 minutes, ping the Aspire team.* --- *Full commit: [${{ inputs.commit_sha }}](https://github.com/${{ github.repository }}/commit/${{ inputs.commit_sha }})* @@ -296,13 +300,14 @@ jobs: echo "Placeholder release notes generated" - - name: Create GitHub Release + - name: Create GitHub Release (draft) if: inputs.skip_github_release != true && steps.check-release.outputs.release_exists != 'true' env: - # Use the App token here so the `release: released` event is - # authored by aspire-repo-bot and downstream workflows - # (release-notes-generate.lock.yml) get triggered. Events fired by - # GITHUB_TOKEN intentionally do NOT trigger other workflows. + # Use the App token so the draft release is authored by + # aspire-repo-bot, consistent with the rest of the release flow. + # (release-notes-generate is triggered by an explicit + # workflow_dispatch in the next step, not by a release event — + # draft releases don't fire release events.) GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | TAG_NAME="v${{ inputs.release_version }}" @@ -312,12 +317,21 @@ jobs: PRERELEASE_FLAG="--prerelease" fi + # IMPORTANT: We always create the release as a draft. Immutable + # releases (https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) + # lock assets and the tag at publish time, so every mutation + # (release-notes-generate agentic workflow rewriting the body, + # PublishReleaseAssetsJob uploading aspire-cli-* archives) must + # happen before publish. A human publishes the draft as the final + # step after reviewing it — see docs/release-process.md "Publish + # the draft release" for the procedure. if [ "$DRY_RUN" == "true" ]; then - echo "🔍 [DRY RUN] Would create GitHub Release:" + echo "🔍 [DRY RUN] Would create DRAFT GitHub Release:" echo " Tag: $TAG_NAME" echo " Title: Aspire ${{ inputs.release_version }}" echo " Target: ${{ inputs.commit_sha }}" echo " Prerelease: ${{ inputs.is_prerelease }}" + echo " Draft: true" echo "" echo " Release notes content:" echo " ─────────────────────────────────────" @@ -325,14 +339,49 @@ jobs: echo " ─────────────────────────────────────" else gh release create "$TAG_NAME" \ + --draft \ --title "Aspire ${{ inputs.release_version }}" \ --notes-file release_notes.md \ --target "${{ inputs.commit_sha }}" \ $PRERELEASE_FLAG - echo "✓ Created GitHub Release: $TAG_NAME" + echo "✓ Created DRAFT GitHub Release: $TAG_NAME" + echo " Release manager must publish the draft manually after notes + assets are attached." fi + - name: Dispatch release-notes-generate workflow + # Draft releases do not fire the `release: [created]` event, so we + # explicitly dispatch the release-notes-generate agentic workflow to + # rewrite the placeholder body while the release is still a draft. + # + # We dispatch on every eligible non-dry run — including reruns where the + # draft already existed — and deliberately do NOT gate on + # `release_exists`. If a first run created the draft but died before + # dispatching, gating on `release_exists` would permanently skip note + # generation on the rerun and leave the placeholder body in place. + # Re-dispatching is safe: release-notes-generate is idempotent (it exits + # early unless the body is still the exact placeholder, and exits if the + # release was already published or edited), so a duplicate dispatch is a + # guaranteed no-op. We still skip on dry runs and when GitHub-release + # creation was skipped entirely. + if: inputs.skip_github_release != true && inputs.dry_run != true + env: + # The App token (aspire-repo-bot) is used so the dispatch is authored + # by the same identity the gh-aw workflow's `bots:` allow-list and + # GitHub App integration expect. Dispatching with GITHUB_TOKEN would + # run it as github-actions[bot], which the workflow's activation gate + # is not configured for. + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + TAG_NAME="v${{ inputs.release_version }}" + echo "Dispatching release-notes-generate for $TAG_NAME" + # The runnable workflow is the gh-aw-compiled lock file; dispatch it + # on the default branch (where workflow_dispatch requires it to live). + gh workflow run release-notes-generate.lock.yml \ + --repo "${{ github.repository }}" \ + --field tag_name="$TAG_NAME" + echo "✓ Dispatched release-notes-generate (tag_name=$TAG_NAME)" + # ---------------------------------------------------------------------------- # merge-pr: runs in parallel with baseline-pr after the release job. A # failure here must NOT block baseline-pr (and vice versa) — that matches @@ -643,10 +692,33 @@ jobs: | Job | Result | |-----|--------| - | Release Tasks (tag + GitHub release) | ${RELEASE_STATUS} | + | Release Tasks (tag + draft GitHub release) | ${RELEASE_STATUS} | | Create Merge-Back PR | ${MERGE_STATUS} | | Create Baseline Version Branch/PR | ${BASELINE_STATUS} | + ## ⚠️ Manual step required — publish the draft release + + The GitHub release was created as a **draft** so the release-notes-generate + agentic workflow can rewrite its body and the AzDO pipeline's + PublishReleaseAssetsJob can attach the \`aspire-cli-*\` archives before + publish. This is required for compatibility with GitHub's + [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases), + which lock assets and the tag at publish time. + + The release manager must, after both notes and assets are in place: + + 1. Open the [Releases page](https://github.com/${{ github.repository }}/releases) + and find the draft for \`v${{ inputs.release_version }}\` at the + top (drafts are assigned a random \`untagged-\` URL by + GitHub, so there is no stable deep link until publish). + 2. Confirm the auto-generated release notes look correct. + 3. Confirm the \`aspire-cli-*\` archives are attached. + 4. Click **Publish release**. + + Publishing the draft fires \`release: [published]\`, which triggers + downstream workflows such as \`release-update-support-mdx\` and + \`homebrew-validate-release\`. + ## Details - **Tag**: v${{ inputs.release_version }} - **Commit**: \`${{ inputs.commit_sha }}\` @@ -659,6 +731,6 @@ jobs: - **Skip Baseline PR**: ${{ inputs.skip_baseline_pr }} ## Links - - [Release](https://github.com/${{ github.repository }}/releases/tag/v${{ inputs.release_version }}) + - [Releases page](https://github.com/${{ github.repository }}/releases) - [Tag](https://github.com/${{ github.repository }}/tree/v${{ inputs.release_version }}) EOF diff --git a/.github/workflows/release-notes-generate.lock.yml b/.github/workflows/release-notes-generate.lock.yml index d8fcd461b3e..7f8ddb325c1 100644 --- a/.github/workflows/release-notes-generate.lock.yml +++ b/.github/workflows/release-notes-generate.lock.yml @@ -1,6 +1,6 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"0764c1d16c5a3103aeda0d7b3de60fb59c933d361a8ecd2005c32e1d6eaca96b","body_hash":"f38b4c1674fbbd210f225c757a6a63f2b91fc2c9c0d4fb151b38a01b56c75288","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78"}} -# gh-aw-manifest: {"version":1,"secrets":["ASPIRE_BOT_APP_ID","ASPIRE_BOT_PRIVATE_KEY","COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/create-github-app-token","sha":"bcd2ba49218906704ab6c1aa796996da409d3eb1","version":"v3.2.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"2709137ea6c5b0e19aa621454dc643ea8dc526b1","version":"v0.85.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} -# This file was automatically generated by gh-aw (v0.85.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"a87b12aa0df1dc9ee2e5fa0c594afe12d840a825eefeb8bc9fbf1bed393d52d8","body_hash":"5443873cf7068d805c1ed767019bf3b649566a8518529b47e55d789d953aeadf","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-manifest: {"version":1,"secrets":["ASPIRE_BOT_APP_ID","ASPIRE_BOT_PRIVATE_KEY","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/create-github-app-token","sha":"bcd2ba49218906704ab6c1aa796996da409d3eb1","version":"v3.2.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} +# This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ # / _ \ | | (_) @@ -33,7 +33,6 @@ # Secrets used: # - ASPIRE_BOT_APP_ID # - ASPIRE_BOT_PRIVATE_KEY -# - COPILOT_GITHUB_TOKEN # - GH_AW_GITHUB_MCP_SERVER_TOKEN # - GH_AW_GITHUB_TOKEN # - GITHUB_TOKEN @@ -46,23 +45,20 @@ # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 +# - github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 # - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 # - ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 -# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 +# - ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f # - ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 -# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 +# - ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e name: "Generate release notes for a new stable Aspire release" on: # bots: # Bots processed as bot check in pre-activation job # - aspire-repo-bot # Bots processed as bot check in pre-activation job - release: - types: - - published # stale-check: false # Stale-check processed as frontmatter hash check step in activation job workflow_dispatch: inputs: @@ -80,18 +76,14 @@ permissions: {} concurrency: cancel-in-progress: false - group: release-notes-generate-${{ github.event.release.tag_name || github.event.inputs.tag_name }} + group: release-notes-generate-${{ github.event.inputs.tag_name }} run-name: "Generate release notes for a new stable Aspire release" jobs: activation: needs: pre_activation - if: > - needs.pre_activation.outputs.activated == 'true' && (github.repository == 'microsoft/aspire' && ( - (github.event_name == 'release' && github.event.release.prerelease == false && github.event.release.draft == false) - || github.event_name == 'workflow_dispatch' - )) + if: needs.pre_activation.outputs.activated == 'true' && (github.repository == 'microsoft/aspire') runs-on: ubuntu-slim permissions: contents: read @@ -110,7 +102,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -119,7 +111,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Generate release notes for a new stable Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-notes-generate.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate agentic run info @@ -128,9 +120,9 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.78" - GH_AW_INFO_AGENT_VERSION: "1.0.78" - GH_AW_INFO_CLI_VERSION: "v0.85.4" + GH_AW_INFO_VERSION: "1.0.79" + GH_AW_INFO_AGENT_VERSION: "1.0.79" + GH_AW_INFO_CLI_VERSION: "v0.86.2" GH_AW_INFO_WORKFLOW_NAME: "Generate release notes for a new stable Aspire release" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -140,6 +132,7 @@ jobs: GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_AGENT_RUNTIME: "" GH_AW_COMPILED_STRICT: "true" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -152,7 +145,6 @@ jobs: id: check-oauth-tokens run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - name: Checkout .github and .agents folders @@ -172,12 +164,12 @@ jobs: env: GH_AW_AGENT_FOLDERS: ".agents .github" GH_AW_AGENT_FILES: "AGENTS.md" - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.85.4" + GH_AW_COMPILED_VERSION: "v0.86.2" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -188,85 +180,39 @@ jobs: if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" - name: Create prompt with built-in context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ACTIONS_DIR: ${{ runner.temp }}/gh-aw/actions + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}]}" GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} - GH_AW_EXPR_7F72DC02: ${{ github.event.release.tag_name || github.event.inputs.tag_name }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_INPUTS_TAG_NAME: ${{ github.event.inputs.tag_name }} - GH_AW_GITHUB_EVENT_NAME: ${{ github.event_name }} - GH_AW_GITHUB_EVENT_RELEASE_NAME: ${{ github.event.release.name }} - GH_AW_GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - # poutine:ignore untrusted_checkout_exec - run: | - bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" - { - cat << 'GH_AW_PROMPT_79d56285e61f5d49_EOF' - - GH_AW_PROMPT_79d56285e61f5d49_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_79d56285e61f5d49_EOF' - - Tools: update_release, missing_tool, missing_data, noop - - GH_AW_PROMPT_79d56285e61f5d49_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_79d56285e61f5d49_EOF' - - The following GitHub context information is available for this workflow: - {{#if github.actor}} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if github.repository}} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if github.workspace}} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} - - **issue-number**: #__GH_AW_EXPR_802A9F6A__ - {{/if}} - {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} - - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ - {{/if}} - {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} - - **pull-request-number**: #__GH_AW_EXPR_463A214A__ - {{/if}} - {{#if github.event.comment.id || github.aw.context.comment_id}} - - **comment-id**: __GH_AW_EXPR_FF1D34CE__ - {{/if}} - {{#if github.run_id}} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_79d56285e61f5d49_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_79d56285e61f5d49_EOF' - - {{#runtime-import .github/workflows/release-notes-generate.md}} - GH_AW_PROMPT_79d56285e61f5d49_EOF - } > "$GH_AW_PROMPT" + GH_AW_PROMPT_CONTENT_0000: "\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: update_release, missing_tool, missing_data, noop\n" + GH_AW_PROMPT_CONTENT_0002: "\n" + GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_802A9F6A__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_1A3A194A__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_463A214A__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_FF1D34CE__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n\n\n" + GH_AW_PROMPT_CONTENT_0004: "\n" + GH_AW_PROMPT_CONTENT_0005: "{{#runtime-import .github/workflows/release-notes-generate.md}}\n" + with: + script: | + const { setupGlobals } = require(process.env.GH_AW_ACTIONS_DIR + '/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(process.env.GH_AW_ACTIONS_DIR + '/create_prompt.cjs'); + await main(core); - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_ENGINE_ID: "copilot" GH_AW_GITHUB_EVENT_INPUTS_TAG_NAME: ${{ github.event.inputs.tag_name }} - GH_AW_GITHUB_EVENT_RELEASE_NAME: ${{ github.event.release.name }} - GH_AW_EXPR_7F72DC02: ${{ github.event.release.tag_name || github.event.inputs.tag_name }} - GH_AW_GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} - GH_AW_GITHUB_EVENT_NAME: ${{ github.event_name }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -276,17 +222,13 @@ jobs: - name: Substitute placeholders uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} - GH_AW_EXPR_7F72DC02: ${{ github.event.release.tag_name || github.event.inputs.tag_name }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} GH_AW_GITHUB_ACTOR: ${{ github.actor }} GH_AW_GITHUB_EVENT_INPUTS_TAG_NAME: ${{ github.event.inputs.tag_name }} - GH_AW_GITHUB_EVENT_NAME: ${{ github.event_name }} - GH_AW_GITHUB_EVENT_RELEASE_NAME: ${{ github.event.release.name }} - GH_AW_GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} @@ -305,14 +247,10 @@ jobs: substitutions: { GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A, GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A, - GH_AW_EXPR_7F72DC02: process.env.GH_AW_EXPR_7F72DC02, GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A, GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE, GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, GH_AW_GITHUB_EVENT_INPUTS_TAG_NAME: process.env.GH_AW_GITHUB_EVENT_INPUTS_TAG_NAME, - GH_AW_GITHUB_EVENT_NAME: process.env.GH_AW_GITHUB_EVENT_NAME, - GH_AW_GITHUB_EVENT_RELEASE_NAME: process.env.GH_AW_GITHUB_EVENT_RELEASE_NAME, - GH_AW_GITHUB_EVENT_RELEASE_TAG_NAME: process.env.GH_AW_GITHUB_EVENT_RELEASE_TAG_NAME, GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, @@ -322,14 +260,18 @@ jobs: }); - name: Validate prompt placeholders env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Stage prompt files for artifact upload + run: | + mkdir -p /tmp/gh-aw/aw-prompts + cp -a "${RUNNER_TEMP}/gh-aw/aw-prompts/." /tmp/gh-aw/aw-prompts/ - name: Upload activation artifact if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -356,9 +298,6 @@ jobs: contents: read copilot-requests: write pull-requests: read - concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" - queue: max env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_AW_ASSETS_ALLOWED_EXTS: "" @@ -393,7 +332,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -402,7 +341,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Generate release notes for a new stable Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-notes-generate.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths @@ -448,11 +387,13 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 --rootless - name: Generate GitHub App token @@ -501,7 +442,7 @@ jobs: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" @@ -653,22 +594,23 @@ jobs: export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export MCP_GATEWAY_ALLOWED_MOUNT_ROOTS="${GITHUB_WORKSPACE}:rw,${RUNNER_TEMP}/gh-aw:ro,${RUNNER_TEMP}/gh-aw/safeoutputs:rw,/opt:ro,/tmp:rw,/usr/bin/gh:ro" export DEBUG="*" export GH_AW_ENGINE="copilot" MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.8' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e MCP_GATEWAY_ALLOWED_MOUNT_ROOTS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.9' mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_c3984b671fa9f53f_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_5614418005a60134_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "container": "ghcr.io/github/github-mcp-server:v1.9.0", "env": { "GITHUB_FEATURES": "fields_param", "GITHUB_HOST": "${GITHUB_SERVER_URL}", @@ -730,7 +672,7 @@ jobs: "startupTimeout": 120 } } - GH_AW_MCP_CONFIG_c3984b671fa9f53f_EOF + GH_AW_MCP_CONFIG_5614418005a60134_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -755,6 +697,24 @@ jobs: - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): + # --allow-tool github + # --allow-tool safeoutputs + # --allow-tool shell(cat) + # --allow-tool shell(date) + # --allow-tool shell(echo) + # --allow-tool shell(github:*) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(ls) + # --allow-tool shell(printf) + # --allow-tool shell(pwd) + # --allow-tool shell(safeoutputs:*) + # --allow-tool shell(sort) + # --allow-tool shell(tail) + # --allow-tool shell(uniq) + # --allow-tool shell(wc) + # --allow-tool shell(yq) + # --allow-tool write timeout-minutes: 20 run: | set -o pipefail @@ -764,6 +724,18 @@ jobs: printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -788,8 +760,8 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(github:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(printf)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE @@ -803,7 +775,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -995,7 +967,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1004,7 +976,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Generate release notes for a new stable Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-notes-generate.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1201,7 +1173,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1210,7 +1182,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Generate release notes for a new stable Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-notes-generate.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1262,25 +1234,7 @@ jobs: - name: Prepare threat detection files if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - rm -f /tmp/gh-aw/agent_usage.json - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then - echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context." - fi - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - for f in /tmp/gh-aw/aw-*.bundle; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + bash "${RUNNER_TEMP}/gh-aw/actions/prepare_threat_detection_files.sh" - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1300,18 +1254,20 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - rm -f /tmp/gh-aw/threat-detection/step-summary.md - touch /tmp/gh-aw/threat-detection/step-summary.md + rm -f /tmp/gh-aw/step-summary.md + touch /tmp/gh-aw/step-summary.md - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Execute GitHub Copilot CLI @@ -1327,6 +1283,18 @@ jobs: mkdir -p "$HOME/.copilot" printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -1353,9 +1321,10 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: + GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode @@ -1367,14 +1336,13 @@ jobs: GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] @@ -1383,6 +1351,23 @@ jobs: RUNNER_TEMP: ${{ runner.temp }} S2STOKENS: true TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Echo detection step summary + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + run: | + if [ -s /tmp/gh-aw/step-summary.md ]; then + cat /tmp/gh-aw/step-summary.md + fi + - name: Render detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/render_detection_log.cjs'); + await main(); - name: Parse threat detection token usage for step summary id: parse_detection_token_usage if: always() @@ -1437,11 +1422,7 @@ jobs: } pre_activation: - if: > - github.repository == 'microsoft/aspire' && ( - (github.event_name == 'release' && github.event.release.prerelease == false && github.event.release.draft == false) - || github.event_name == 'workflow_dispatch' - ) + if: github.repository == 'microsoft/aspire' runs-on: ubuntu-slim env: GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} @@ -1454,14 +1435,14 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} env: GH_AW_SETUP_WORKFLOW_NAME: "Generate release notes for a new stable Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-notes-generate.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Check team membership for workflow @@ -1509,15 +1490,20 @@ jobs: code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_items_applied: ${{ steps.process_safe_outputs.outputs.items_applied }} + process_safe_outputs_items_cancelled: ${{ steps.process_safe_outputs.outputs.items_cancelled }} + process_safe_outputs_items_deferred: ${{ steps.process_safe_outputs.outputs.items_deferred }} process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }} + process_safe_outputs_items_skipped: ${{ steps.process_safe_outputs.outputs.items_skipped }} process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }} + process_safe_outputs_items_warnings: ${{ steps.process_safe_outputs.outputs.items_warnings }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1526,7 +1512,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Generate release notes for a new stable Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-notes-generate.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact diff --git a/.github/workflows/release-notes-generate.md b/.github/workflows/release-notes-generate.md index 33a2132fadb..92a147f7028 100644 --- a/.github/workflows/release-notes-generate.md +++ b/.github/workflows/release-notes-generate.md @@ -10,8 +10,16 @@ description: | max-daily-ai-credits: -1 on: - release: - types: [published] + # Dispatched explicitly by .github/workflows/release-github-tasks.yml right + # after it creates the draft GitHub release. We deliberately do NOT use the + # `release: [created]` event: GitHub Actions does not fire release events for + # draft releases at all + # (https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#release + # — "Workflows are not triggered for the created, edited, or deleted activity + # types for draft releases"). The placeholder body has to be rewritten while + # the release is still a draft, because immutable releases lock the release + # at publish time, so an explicit workflow_dispatch is the only workable + # trigger. workflow_dispatch: inputs: tag_name: @@ -29,18 +37,13 @@ on: # other bot-triggered gh-aw workflows in this repo. bots: [aspire-repo-bot] -if: >- - github.repository == 'microsoft/aspire' - && ( - (github.event_name == 'release' && github.event.release.prerelease == false && github.event.release.draft == false) - || github.event_name == 'workflow_dispatch' - ) +if: github.repository == 'microsoft/aspire' -# Serialize runs for the same tag so that a duplicate release event (or a -# manual workflow_dispatch rerun on top of an in-flight automatic run) can't -# race and double-edit the release body. +# Serialize runs for the same tag so that a manual workflow_dispatch rerun on +# top of an in-flight automatic dispatch can't race and double-edit the +# release body. concurrency: - group: release-notes-generate-${{ github.event.release.tag_name || github.event.inputs.tag_name }} + group: release-notes-generate-${{ github.event.inputs.tag_name }} cancel-in-progress: false # Agent runs read-only; the release-body update is performed by a separate, @@ -66,6 +69,9 @@ safe-outputs: max: 1 tools: + # Shell access is explicit because the unfiltered GitHub integrity setting + # requires an intentional allowlist. + bash: ["cat", "ls", "grep", "head", "tail", "wc"] github: # `repos` exposes get_release_by_tag / list_releases and commit-comparison # APIs. `pull_requests` and `search` are used to enrich commits with PR @@ -93,46 +99,54 @@ timeout-minutes: 20 # Generate release notes for a new stable Aspire release -The GitHub Release for this tag was just created by +The GitHub Release for this tag was just created **as a draft** by `.github/workflows/release-github-tasks.yml` with a short placeholder body. Your job is to replace that placeholder with real, human-readable release notes that match the tone and structure of recent stable Aspire releases. - -The release is already published, so for **benign no-op cases** (release -missing, prerelease/draft, tag doesn't match `vX.Y.Z`, body has already -been edited and no longer contains the placeholder phrase) write a clear -diagnostic to the run summary and **exit successfully**. But for **real -errors** when actually trying to update the release (API rejection, -permission denied on `update_release`, malformed payload, etc.) **fail the -workflow** — a maintainer needs to see a red X so they can investigate or -manually backfill. Do **not** open issues to "ask a maintainer to paste -notes" as a fallback; the workflow has no `create_issue` capability by -design. +The release manager will manually publish the draft as a final step once +both notes and CLI assets are attached. + +The release is still a draft, so for **benign no-op cases** (release +missing, prerelease, tag doesn't match `vX.Y.Z`, body has already been +edited and no longer contains the placeholder phrase, **or the release is +no longer a draft because the release manager already published it**) +write a clear diagnostic to the run summary and **exit successfully**. +But for **real errors** when actually trying to update the release (API +rejection, permission denied on `update_release`, malformed payload, etc.) +**fail the workflow** — a maintainer needs to see a red X so they can +investigate or manually backfill. Do **not** open issues to "ask a +maintainer to paste notes" as a fallback; the workflow has no +`create_issue` capability by design. ## Context - **Repository**: `microsoft/aspire` -- **Trigger event**: `${{ github.event_name }}` -- **Release tag**: `${{ github.event.release.tag_name || github.event.inputs.tag_name }}` -- **Release name**: `${{ github.event.release.name }}` +- **Release tag**: `${{ github.event.inputs.tag_name }}` ## Step 1: Resolve the release -Determine the tag this run is processing: - -- If `github.event_name == 'release'`, the tag is in `${{ github.event.release.tag_name }}`. -- If `github.event_name == 'workflow_dispatch'`, the tag is in `${{ github.event.inputs.tag_name }}`. +The tag this run is processing is in `${{ github.event.inputs.tag_name }}` +(this workflow is always dispatched via `workflow_dispatch` with that input). Fetch the full release record for `microsoft/aspire` by tag (`GET /repos/microsoft/aspire/releases/tags/`). Capture its `id`, `tag_name`, `name`, `body`, `published_at`, `html_url`, `draft`, and -`prerelease`. +`prerelease`. The workflow runs with a GitHub App token that can see +drafts in `microsoft/aspire`, so this call returns the draft record that +`release-github-tasks.yml` created just before dispatching this workflow. **Exit successfully with a diagnostic** if any of these are true (do not -fail the run — the release is already live): +fail the run — these are expected states the workflow shouldn't act on): - The release can't be found. -- `release.draft == true` or `release.prerelease == true`. +- `release.draft == false` — the release manager has already published the + draft, so the body should no longer be overwritten by automation. If + this run was a `workflow_dispatch` rerun on a published release, the + expectation is that a human has reviewed the notes and explicitly chose + to publish; another rewrite would clobber human edits. Direct the + maintainer to edit the release body manually if a correction is needed. +- `release.prerelease == true` — release-notes-generate is a stable-only + workflow. - The tag does not match `^v\d+\.\d+\.\d+$`. Parse the version: strip the leading `v` and split into @@ -286,7 +300,9 @@ Two distinct failure classes — handle them differently: These are the early-exit cases already covered by Steps 1 and 2: - Release not found by tag. -- `release.draft == true` or `release.prerelease == true`. +- `release.draft == false` (release manager already published the draft — + body is no longer safe to overwrite by automation). +- `release.prerelease == true`. - Tag does not match `^v\d+\.\d+\.\d+$`. - Release body has already been edited (does not contain the placeholder phrase) — Step 2 idempotency check. diff --git a/.github/workflows/release-update-support-mdx.lock.yml b/.github/workflows/release-update-support-mdx.lock.yml index db093386e92..28507045105 100644 --- a/.github/workflows/release-update-support-mdx.lock.yml +++ b/.github/workflows/release-update-support-mdx.lock.yml @@ -1,6 +1,6 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8062e35148345efde4f53e5e42b964cde11e880ffc8e831241b7a9983028a0b9","body_hash":"ebac7ee631141acb8a4a1cd85e5a562c48891c52fee06cce3b5b10d02effa477","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78"}} -# gh-aw-manifest: {"version":1,"secrets":["ASPIRE_BOT_APP_ID","ASPIRE_BOT_PRIVATE_KEY","COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/create-github-app-token","sha":"bcd2ba49218906704ab6c1aa796996da409d3eb1","version":"v3.2.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"2709137ea6c5b0e19aa621454dc643ea8dc526b1","version":"v0.85.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} -# This file was automatically generated by gh-aw (v0.85.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8062e35148345efde4f53e5e42b964cde11e880ffc8e831241b7a9983028a0b9","body_hash":"ebac7ee631141acb8a4a1cd85e5a562c48891c52fee06cce3b5b10d02effa477","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-manifest: {"version":1,"secrets":["ASPIRE_BOT_APP_ID","ASPIRE_BOT_PRIVATE_KEY","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/create-github-app-token","sha":"bcd2ba49218906704ab6c1aa796996da409d3eb1","version":"v3.2.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} +# This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ # / _ \ | | (_) @@ -36,7 +36,6 @@ # Secrets used: # - ASPIRE_BOT_APP_ID # - ASPIRE_BOT_PRIVATE_KEY -# - COPILOT_GITHUB_TOKEN # - GH_AW_CI_TRIGGER_TOKEN # - GH_AW_GITHUB_MCP_SERVER_TOKEN # - GH_AW_GITHUB_TOKEN @@ -50,15 +49,15 @@ # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 +# - github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 # - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 # - ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 -# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 +# - ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f # - ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 -# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 +# - ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e name: "Update aspire.dev support page for a new Aspire release" on: @@ -114,7 +113,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -123,7 +122,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Update aspire.dev support page for a new Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-update-support-mdx.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate agentic run info @@ -132,9 +131,9 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.78" - GH_AW_INFO_AGENT_VERSION: "1.0.78" - GH_AW_INFO_CLI_VERSION: "v0.85.4" + GH_AW_INFO_VERSION: "1.0.79" + GH_AW_INFO_AGENT_VERSION: "1.0.79" + GH_AW_INFO_CLI_VERSION: "v0.86.2" GH_AW_INFO_WORKFLOW_NAME: "Update aspire.dev support page for a new Aspire release" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -144,6 +143,7 @@ jobs: GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_AGENT_RUNTIME: "" GH_AW_COMPILED_STRICT: "true" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -156,7 +156,6 @@ jobs: id: check-oauth-tokens run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - name: Checkout .github and .agents folders @@ -176,12 +175,12 @@ jobs: env: GH_AW_AGENT_FOLDERS: ".agents .github" GH_AW_AGENT_FILES: "AGENTS.md" - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.85.4" + GH_AW_COMPILED_VERSION: "v0.86.2" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -192,9 +191,12 @@ jobs: if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" - name: Create prompt with built-in context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ACTIONS_DIR: ${{ runner.temp }}/gh-aw/actions + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"file\":\"safe_outputs_create_pull_request.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}]}" GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_7F72DC02: ${{ github.event.release.tag_name || github.event.inputs.tag_name }} @@ -208,79 +210,22 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - # poutine:ignore untrusted_checkout_exec - run: | - bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" - { - cat << 'GH_AW_PROMPT_9129824896cac104_EOF' - - GH_AW_PROMPT_9129824896cac104_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_9129824896cac104_EOF' - - Tools: create_pull_request, missing_tool, missing_data, noop - GH_AW_PROMPT_9129824896cac104_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" - cat << 'GH_AW_PROMPT_9129824896cac104_EOF' - - GH_AW_PROMPT_9129824896cac104_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_9129824896cac104_EOF' - - The following GitHub context information is available for this workflow: - {{#if github.actor}} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if github.repository}} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if github.workspace}} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} - - **issue-number**: #__GH_AW_EXPR_802A9F6A__ - {{/if}} - {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} - - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ - {{/if}} - {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} - - **pull-request-number**: #__GH_AW_EXPR_463A214A__ - {{/if}} - {{#if github.event.comment.id || github.aw.context.comment_id}} - - **comment-id**: __GH_AW_EXPR_FF1D34CE__ - {{/if}} - {{#if github.run_id}} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - **checkouts**: The following repositories have been checked out and are available in the workspace: - - repo `microsoft/aspire.dev` → `$GITHUB_WORKSPACE` (cwd) (**current** - this is the repository you are working on; use this as the target for all GitHub operations unless otherwise specified) [shallow clone, fetch-depth=1 (default)] - - **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches). - - **Warning: No git credentials are available to the agent.** Credentials are - intentionally removed after the checkout step for security. This means any git - operation that needs to authenticate to the remote will fail. In private repositories, that includes: - - `git fetch`, `git pull`, `git clone`, and `git push` (direct push, not via safe-output tools) - - Checking out or switching to a remote branch that is not already fetched - - Deepening a shallow clone (`git fetch --unshallow`) - - On-demand blob fetches in partial/blobless clones (operations on files not in the initial checkout) - Do NOT attempt to configure credentials, run `git credential fill`, or modify `.gitconfig` — - authentication will not succeed. If you encounter credential prompts or authentication errors, - stop immediately and report the limitation rather than spending turns trying to work around it. - - - GH_AW_PROMPT_9129824896cac104_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_9129824896cac104_EOF' - - {{#runtime-import .github/workflows/release-update-support-mdx.md}} - GH_AW_PROMPT_9129824896cac104_EOF - } > "$GH_AW_PROMPT" + GH_AW_PROMPT_CONTENT_0000: "\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: create_pull_request, missing_tool, missing_data, noop\n" + GH_AW_PROMPT_CONTENT_0002: "\n" + GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_802A9F6A__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_1A3A194A__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_463A214A__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_FF1D34CE__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n- **checkouts**: The following repositories have been checked out and are available in the workspace:\n - repo `microsoft/aspire.dev` → `$GITHUB_WORKSPACE` (cwd) (**current** - this is the repository you are working on; use this as the target for all GitHub operations unless otherwise specified) [shallow clone, fetch-depth=1 (default)]\n - **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: [\"refs/pulls/open/*\"]` for all open PR refs, or `fetch: [\"main\", \"feature/my-branch\"]` for specific branches).\n - **Warning: No git credentials are available to the agent.** Credentials are\n intentionally removed after the checkout step for security. This means any git\n operation that needs to authenticate to the remote will fail. In private repositories, that includes:\n - `git fetch`, `git pull`, `git clone`, and `git push` (direct push, not via safe-output tools)\n - Checking out or switching to a remote branch that is not already fetched\n - Deepening a shallow clone (`git fetch --unshallow`)\n - On-demand blob fetches in partial/blobless clones (operations on files not in the initial checkout)\n Do NOT attempt to configure credentials, run `git credential fill`, or modify `.gitconfig` —\n authentication will not succeed. If you encounter credential prompts or authentication errors,\n stop immediately and report the limitation rather than spending turns trying to work around it.\n\n\n" + GH_AW_PROMPT_CONTENT_0004: "\n" + GH_AW_PROMPT_CONTENT_0005: "{{#runtime-import .github/workflows/release-update-support-mdx.md}}\n" + with: + script: | + const { setupGlobals } = require(process.env.GH_AW_ACTIONS_DIR + '/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(process.env.GH_AW_ACTIONS_DIR + '/create_prompt.cjs'); + await main(core); - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_ENGINE_ID: "copilot" GH_AW_GITHUB_EVENT_INPUTS_TAG_NAME: ${{ github.event.inputs.tag_name }} GH_AW_GITHUB_EVENT_RELEASE_NAME: ${{ github.event.release.name }} @@ -296,7 +241,7 @@ jobs: - name: Substitute placeholders uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_7F72DC02: ${{ github.event.release.tag_name || github.event.inputs.tag_name }} @@ -342,14 +287,18 @@ jobs: }); - name: Validate prompt placeholders env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Stage prompt files for artifact upload + run: | + mkdir -p /tmp/gh-aw/aw-prompts + cp -a "${RUNNER_TEMP}/gh-aw/aw-prompts/." /tmp/gh-aw/aw-prompts/ - name: Upload activation artifact if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -413,7 +362,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -422,7 +371,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Update aspire.dev support page for a new Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-update-support-mdx.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths @@ -497,11 +446,13 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 --rootless - name: Generate GitHub App token @@ -552,7 +503,7 @@ jobs: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" @@ -606,6 +557,10 @@ jobs: "type": "string", "maxLength": 256 }, + "temporary_id": { + "type": "string", + "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$" + }, "title": { "required": true, "type": "string", @@ -719,22 +674,23 @@ jobs: export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export MCP_GATEWAY_ALLOWED_MOUNT_ROOTS="${GITHUB_WORKSPACE}:rw,${RUNNER_TEMP}/gh-aw:ro,${RUNNER_TEMP}/gh-aw/safeoutputs:rw,/opt:ro,/tmp:rw,/usr/bin/gh:ro" export DEBUG="*" export GH_AW_ENGINE="copilot" MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.8' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e MCP_GATEWAY_ALLOWED_MOUNT_ROOTS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.9' mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_4c54f505c38e359f_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_f55df6cf4d1d307e_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "container": "ghcr.io/github/github-mcp-server:v1.9.0", "env": { "GITHUB_FEATURES": "fields_param", "GITHUB_HOST": "${GITHUB_SERVER_URL}", @@ -796,7 +752,7 @@ jobs: "startupTimeout": 120 } } - GH_AW_MCP_CONFIG_4c54f505c38e359f_EOF + GH_AW_MCP_CONFIG_f55df6cf4d1d307e_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -830,6 +786,18 @@ jobs: printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -854,8 +822,8 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE @@ -869,7 +837,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_TIMEOUT_MINUTES: 15 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -1062,7 +1030,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1071,7 +1039,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Update aspire.dev support page for a new Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-update-support-mdx.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate GitHub App token @@ -1286,7 +1254,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1295,7 +1263,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Update aspire.dev support page for a new Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-update-support-mdx.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1347,25 +1315,7 @@ jobs: - name: Prepare threat detection files if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - rm -f /tmp/gh-aw/agent_usage.json - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then - echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context." - fi - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - for f in /tmp/gh-aw/aw-*.bundle; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + bash "${RUNNER_TEMP}/gh-aw/actions/prepare_threat_detection_files.sh" - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1385,18 +1335,20 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - rm -f /tmp/gh-aw/threat-detection/step-summary.md - touch /tmp/gh-aw/threat-detection/step-summary.md + rm -f /tmp/gh-aw/step-summary.md + touch /tmp/gh-aw/step-summary.md - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Execute GitHub Copilot CLI @@ -1412,6 +1364,18 @@ jobs: mkdir -p "$HOME/.copilot" printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -1438,9 +1402,10 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: + GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode @@ -1452,14 +1417,13 @@ jobs: GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] @@ -1468,6 +1432,23 @@ jobs: RUNNER_TEMP: ${{ runner.temp }} S2STOKENS: true TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Echo detection step summary + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + run: | + if [ -s /tmp/gh-aw/step-summary.md ]; then + cat /tmp/gh-aw/step-summary.md + fi + - name: Render detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/render_detection_log.cjs'); + await main(); - name: Parse threat detection token usage for step summary id: parse_detection_token_usage if: always() @@ -1539,14 +1520,14 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} env: GH_AW_SETUP_WORKFLOW_NAME: "Update aspire.dev support page for a new Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-update-support-mdx.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Check team membership for workflow @@ -1598,15 +1579,20 @@ jobs: create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} created_pr_number: ${{ steps.process_safe_outputs.outputs.created_pr_number }} created_pr_url: ${{ steps.process_safe_outputs.outputs.created_pr_url }} + process_safe_outputs_items_applied: ${{ steps.process_safe_outputs.outputs.items_applied }} + process_safe_outputs_items_cancelled: ${{ steps.process_safe_outputs.outputs.items_cancelled }} + process_safe_outputs_items_deferred: ${{ steps.process_safe_outputs.outputs.items_deferred }} process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }} + process_safe_outputs_items_skipped: ${{ steps.process_safe_outputs.outputs.items_skipped }} process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }} + process_safe_outputs_items_warnings: ${{ steps.process_safe_outputs.outputs.items_warnings }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1615,7 +1601,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Update aspire.dev support page for a new Aspire release" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/release-update-support-mdx.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact diff --git a/.github/workflows/repo-pulse.lock.yml b/.github/workflows/repo-pulse.lock.yml index 042c276a261..cd7b1ad19df 100644 --- a/.github/workflows/repo-pulse.lock.yml +++ b/.github/workflows/repo-pulse.lock.yml @@ -1,6 +1,6 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8f20ffc2805d6661eb63b093ce5d47e9ebaad59646ed7dbc434fc7084e89bfee","body_hash":"dff652e509d2a0a9235f278c99bf3657a7495a9ecfc63ba41bf7adcf44b81cd4","compiler_version":"v0.85.4","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"2709137ea6c5b0e19aa621454dc643ea8dc526b1","version":"v0.85.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.8","digest":"sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} -# This file was automatically generated by gh-aw (v0.85.4). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"dc3c220f0ad09525ead1ec145c0dc0c88d7912df57e11cc4c47a903b65fea764","body_hash":"dff652e509d2a0a9235f278c99bf3657a7495a9ecfc63ba41bf7adcf44b81cd4","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} +# This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ # / _ \ | | (_) @@ -34,7 +34,6 @@ # the resulting JSON files and renders the issue body. # # Secrets used: -# - COPILOT_GITHUB_TOKEN # - GH_AW_GITHUB_MCP_SERVER_TOKEN # - GH_AW_GITHUB_TOKEN # - GITHUB_TOKEN @@ -46,15 +45,15 @@ # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 +# - github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 # - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 # - ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 -# - ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 +# - ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f # - ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 -# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 +# - ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e name: "Repo Pulse — Daily Report" on: @@ -98,14 +97,14 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} env: GH_AW_SETUP_WORKFLOW_NAME: "Repo Pulse — Daily Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-pulse.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate agentic run info @@ -114,9 +113,9 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.78" - GH_AW_INFO_AGENT_VERSION: "1.0.78" - GH_AW_INFO_CLI_VERSION: "v0.85.4" + GH_AW_INFO_VERSION: "1.0.79" + GH_AW_INFO_AGENT_VERSION: "1.0.79" + GH_AW_INFO_CLI_VERSION: "v0.86.2" GH_AW_INFO_WORKFLOW_NAME: "Repo Pulse — Daily Report" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -126,6 +125,7 @@ jobs: GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_INFO_AGENT_RUNTIME: "" GH_AW_COMPILED_STRICT: "true" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -138,7 +138,6 @@ jobs: id: check-oauth-tokens run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - name: Checkout .github and .agents folders @@ -158,8 +157,8 @@ jobs: env: GH_AW_AGENT_FOLDERS: ".agents .github" GH_AW_AGENT_FILES: "AGENTS.md" - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" - name: Check workflow lock file id: check-lock-file uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -175,7 +174,7 @@ jobs: - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.85.4" + GH_AW_COMPILED_VERSION: "v0.86.2" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -186,9 +185,12 @@ jobs: if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" - name: Create prompt with built-in context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ACTIONS_DIR: ${{ runner.temp }}/gh-aw/actions + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_PROMPT_CONFIG: "{\"items\":[{\"content_env\":\"GH_AW_PROMPT_CONTENT_0000\"},{\"file\":\"xpia.md\"},{\"file\":\"temp_folder_prompt.md\"},{\"file\":\"markdown.md\"},{\"file\":\"safe_outputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0001\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0002\"},{\"file\":\"mcp_cli_tools_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0003\"},{\"file\":\"github_mcp_tools_with_safeoutputs_prompt.md\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0004\"},{\"content_env\":\"GH_AW_PROMPT_CONTENT_0005\"}]}" GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -197,63 +199,22 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - # poutine:ignore untrusted_checkout_exec - run: | - bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" - { - cat << 'GH_AW_PROMPT_9495a7868ee358fe_EOF' - - GH_AW_PROMPT_9495a7868ee358fe_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" - cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_9495a7868ee358fe_EOF' - - Tools: update_issue, missing_tool, missing_data, noop - - GH_AW_PROMPT_9495a7868ee358fe_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_9495a7868ee358fe_EOF' - - The following GitHub context information is available for this workflow: - {{#if github.actor}} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if github.repository}} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if github.workspace}} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} - - **issue-number**: #__GH_AW_EXPR_802A9F6A__ - {{/if}} - {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} - - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ - {{/if}} - {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} - - **pull-request-number**: #__GH_AW_EXPR_463A214A__ - {{/if}} - {{#if github.event.comment.id || github.aw.context.comment_id}} - - **comment-id**: __GH_AW_EXPR_FF1D34CE__ - {{/if}} - {{#if github.run_id}} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_9495a7868ee358fe_EOF - cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_9495a7868ee358fe_EOF' - - {{#runtime-import .github/workflows/repo-pulse.md}} - GH_AW_PROMPT_9495a7868ee358fe_EOF - } > "$GH_AW_PROMPT" + GH_AW_PROMPT_CONTENT_0000: "\n" + GH_AW_PROMPT_CONTENT_0001: "\nTools: update_issue, missing_tool, missing_data, noop\n" + GH_AW_PROMPT_CONTENT_0002: "\n" + GH_AW_PROMPT_CONTENT_0003: "\nThe following GitHub context information is available for this workflow:\n{{#if github.actor}}\n- **actor**: __GH_AW_GITHUB_ACTOR__\n{{/if}}\n{{#if github.repository}}\n- **repository**: __GH_AW_GITHUB_REPOSITORY__\n{{/if}}\n{{#if github.workspace}}\n- **workspace**: __GH_AW_GITHUB_WORKSPACE__\n{{/if}}\n{{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}\n- **issue-number**: #__GH_AW_EXPR_802A9F6A__\n{{/if}}\n{{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}\n- **discussion-number**: #__GH_AW_EXPR_1A3A194A__\n{{/if}}\n{{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}\n- **pull-request-number**: #__GH_AW_EXPR_463A214A__\n{{/if}}\n{{#if github.event.comment.id || github.aw.context.comment_id}}\n- **comment-id**: __GH_AW_EXPR_FF1D34CE__\n{{/if}}\n{{#if github.run_id}}\n- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__\n{{/if}}\n\n\n" + GH_AW_PROMPT_CONTENT_0004: "\n" + GH_AW_PROMPT_CONTENT_0005: "{{#runtime-import .github/workflows/repo-pulse.md}}\n" + with: + script: | + const { setupGlobals } = require(process.env.GH_AW_ACTIONS_DIR + '/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require(process.env.GH_AW_ACTIONS_DIR + '/create_prompt.cjs'); + await main(core); - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_ENGINE_ID: "copilot" with: script: | @@ -264,7 +225,7 @@ jobs: - name: Substitute placeholders uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} @@ -298,14 +259,18 @@ jobs: }); - name: Validate prompt placeholders env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" - name: Print prompt env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - # poutine:ignore untrusted_checkout_exec - run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + GH_AW_PROMPT: ${{ runner.temp }}/gh-aw/aw-prompts/prompt.txt + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Stage prompt files for artifact upload + run: | + mkdir -p /tmp/gh-aw/aw-prompts + cp -a "${RUNNER_TEMP}/gh-aw/aw-prompts/." /tmp/gh-aw/aw-prompts/ - name: Upload activation artifact if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -370,7 +335,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -379,7 +344,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Repo Pulse — Daily Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-pulse.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths @@ -425,11 +390,13 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 --rootless - name: Determine automatic lockdown mode for GitHub MCP Server @@ -473,7 +440,7 @@ jobs: run: "set -euo pipefail\n\nmkdir -p .repo-pulse\n\n# Accumulates human-readable warnings about data collection issues\n# (fetch failures, partial results from the search API, etc.). Each\n# line becomes one entry in meta.data_quality_warnings, which the\n# agent renders as a banner at the top of the report so readers know\n# the dashboard may be incomplete.\nWARNINGS_FILE=\"$(mktemp)\"\n: > \"${WARNINGS_FILE}\"\n\n# Window: last 3 days (72 hours) in UTC.\nWINDOW_DAYS=3\nNOW_UTC=\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"\nCUTOFF_UTC=\"$(date -u -d \"${WINDOW_DAYS} days ago\" +%Y-%m-%dT%H:%M:%SZ)\"\nCUTOFF_DATE=\"$(date -u -d \"${WINDOW_DAYS} days ago\" +%Y-%m-%d)\"\nGENERATED_DISPLAY=\"$(date -u +'%Y-%m-%d %H:%M UTC')\"\n\necho \"Repo: $REPO\"\necho \"Now (UTC): $NOW_UTC\"\necho \"Cutoff (UTC): $CUTOFF_UTC ($WINDOW_DAYS days)\"\n\n# Shared jq helper: project each search-issues item down to just the\n# fields the report uses.\n#\n# Title normalization:\n# - Escape backticks (\\u0060 in jq string) — also avoids any chance\n# of bash interpreting a literal backtick during variable\n# expansion into the jq expression below.\n# - Strip HTML comments .\n# - Trim whitespace.\n# Labels are extracted as plain strings.\nread -r -d '' SAFE_FIELDS <<'JQ' || true\n{\n number: .number,\n title: (\n (.title // \"\")\n | gsub(\"\"; \"\")\n | gsub(\"\\u0060\"; \"'\")\n | sub(\"^\\\\s+\"; \"\") | sub(\"\\\\s+$\"; \"\")\n ),\n author: (.user.login // \"unknown\"),\n created_at: .created_at,\n updated_at: .updated_at,\n closed_at: .closed_at,\n merged_at: (.pull_request.merged_at // null),\n is_pr: (.pull_request != null),\n is_draft: (.draft // false),\n state: .state,\n comments: (.comments // 0),\n labels: [ .labels[]?.name // empty ],\n html_url: .html_url\n}\nJQ\n\n# URL-encode a raw search query for use in an https://github.com/.../issues?q=... link.\nurlencode() {\n jq -Rr @uri <<< \"$1\"\n}\n\n# Fetch all pages of a GitHub search-issues query, project each item\n# down to the fields the report uses, and write a JSON array to $2.\n#\n# Resilience:\n# - A single fetch failure must not abort the entire daily report.\n# On failure (non-zero gh exit), we write `[]` to the outfile,\n# record a warning, and return non-zero so the caller can\n# continue with `|| true`.\n# - If the search API sets `incomplete_results: true` on any page\n# (backend timeout), we record a warning so the report header\n# can tell readers this section may be partial.\nfetch_search() {\n local query=\"$1\"\n local outfile=\"$2\"\n local section=\"$3\"\n echo \"--> fetch [$section]: $query\"\n local tmp_pages\n tmp_pages=\"$(mktemp)\"\n # Run the paginated API call without -e propagation so we can\n # observe the exit code and respond in-script.\n set +e\n gh api --paginate -X GET \"search/issues\" -f \"q=${query}\" -f \"per_page=100\" > \"${tmp_pages}\"\n local api_rc=$?\n set -e\n if [ \"${api_rc}\" -ne 0 ]; then\n echo \" WARN: gh api failed (exit=${api_rc}) — writing empty array and continuing\"\n echo \"${section}: data collection failed (gh api exit=${api_rc}); this section is empty\" >> \"${WARNINGS_FILE}\"\n echo \"[]\" > \"${outfile}\"\n rm -f \"${tmp_pages}\"\n return 1\n fi\n # Check for incomplete_results across all pages. The search API\n # sets this to true when the backend times out; items[] is then\n # only a partial slice of the real match set.\n local incomplete\n incomplete=\"$(jq -s 'map(.incomplete_results // false) | any' \"${tmp_pages}\")\"\n if [ \"${incomplete}\" = \"true\" ]; then\n echo \" WARN: search reported incomplete_results=true — section may be partial\"\n echo \"${section}: GitHub search API reported incomplete_results (backend timeout); items shown may be a partial subset\" >> \"${WARNINGS_FILE}\"\n fi\n jq -s \"map(.items) | add | map(${SAFE_FIELDS})\" \"${tmp_pages}\" > \"${outfile}\"\n rm -f \"${tmp_pages}\"\n local count\n count=\"$(jq 'length' \"${outfile}\")\"\n echo \" wrote ${count} items -> ${outfile}\"\n return 0\n}\n\n# --- 1. Merged PRs in window ---\nQ_MERGED=\"repo:${REPO} is:pr is:merged merged:>=${CUTOFF_DATE}\"\nfetch_search \"$Q_MERGED\" .repo-pulse/merged-prs.raw.json \"merged_prs\" || true\njq 'sort_by(.merged_at) | reverse\n | map({number, title, author, merged_at, labels, html_url,\n age_hours: ((now - (.merged_at | fromdateiso8601)) / 3600 | floor)})' \\\n .repo-pulse/merged-prs.raw.json > .repo-pulse/merged-prs.json\n\n# --- 2. Opened PRs in window, still open ---\nQ_OPENED=\"repo:${REPO} is:pr is:open created:>=${CUTOFF_DATE}\"\nfetch_search \"$Q_OPENED\" .repo-pulse/opened-prs.raw.json \"opened_prs\" || true\njq 'sort_by(.created_at) | reverse\n | map({number, title, author, created_at, is_draft, labels, html_url,\n age_hours: ((now - (.created_at | fromdateiso8601)) / 3600 | floor)})' \\\n .repo-pulse/opened-prs.raw.json > .repo-pulse/opened-prs.json\n\n# --- 3. Filed issues in window ---\nQ_ISSUES=\"repo:${REPO} is:issue created:>=${CUTOFF_DATE}\"\nfetch_search \"$Q_ISSUES\" .repo-pulse/filed-issues.raw.json \"filed_issues\" || true\njq 'sort_by(.created_at) | reverse\n | map({number, title, author, created_at, labels, html_url,\n age_hours: ((now - (.created_at | fromdateiso8601)) / 3600 | floor)})' \\\n .repo-pulse/filed-issues.raw.json > .repo-pulse/filed-issues.json\n\n# --- 4. PRs awaiting review (any age) ---\n# `review:required` = review is requested but not yet given.\nQ_AWAITING=\"repo:${REPO} is:pr is:open review:required draft:false\"\nfetch_search \"$Q_AWAITING\" .repo-pulse/awaiting-review.raw.json \"awaiting_review\" || true\njq 'sort_by(.created_at)\n | map({number, title, author, created_at, labels, html_url,\n age_days: ((now - (.created_at | fromdateiso8601)) / 86400 | floor)})' \\\n .repo-pulse/awaiting-review.raw.json > .repo-pulse/awaiting-review.json\n\n# --- 5. Activity highlights (items updated in window with many comments) ---\n# Exclude items labeled quarantined-test or failing-test, per team request:\n# label churn on those surfaces is noise, not \"attention going somewhere new\".\nQ_ACTIVITY=\"repo:${REPO} updated:>=${CUTOFF_DATE} comments:>=3 -label:quarantined-test -label:failing-test\"\nfetch_search \"$Q_ACTIVITY\" .repo-pulse/activity-highlights.raw.json \"activity_highlights\" || true\njq 'sort_by(.updated_at) | reverse\n | map({number, title, author, is_pr, updated_at, comments, labels, html_url})' \\\n .repo-pulse/activity-highlights.raw.json > .repo-pulse/activity-highlights.json\n\n# --- Precompute \"See all\" search URLs (agent should not build these) ---\nALL_MERGED_URL=\"https://github.com/${REPO}/pulls?q=$(urlencode \"is:pr is:merged merged:>=${CUTOFF_DATE}\")\"\nALL_OPENED_URL=\"https://github.com/${REPO}/pulls?q=$(urlencode \"is:pr is:open created:>=${CUTOFF_DATE}\")\"\nALL_ISSUES_URL=\"https://github.com/${REPO}/issues?q=$(urlencode \"is:issue created:>=${CUTOFF_DATE}\")\"\nALL_AWAITING_URL=\"https://github.com/${REPO}/pulls?q=$(urlencode \"is:pr is:open review:required draft:false\")\"\nALL_ACTIVITY_URL=\"https://github.com/${REPO}/issues?q=$(urlencode \"updated:>=${CUTOFF_DATE} comments:>=3 -label:quarantined-test -label:failing-test sort:updated-desc\")\"\n\n# Fold any accumulated warnings into a JSON array for meta.json.\nif [ -s \"${WARNINGS_FILE}\" ]; then\n WARNINGS_JSON=\"$(jq -R -s 'split(\"\\n\") | map(select(length > 0))' \"${WARNINGS_FILE}\")\"\nelse\n WARNINGS_JSON=\"[]\"\nfi\nrm -f \"${WARNINGS_FILE}\"\n\njq -n \\\n --arg repo \"$REPO\" \\\n --arg now_utc \"$NOW_UTC\" \\\n --arg generated_display \"$GENERATED_DISPLAY\" \\\n --arg cutoff_utc \"$CUTOFF_UTC\" \\\n --arg cutoff_date \"$CUTOFF_DATE\" \\\n --arg window_days \"$WINDOW_DAYS\" \\\n --arg run_url \"$RUN_URL\" \\\n --arg all_merged_url \"$ALL_MERGED_URL\" \\\n --arg all_opened_url \"$ALL_OPENED_URL\" \\\n --arg all_issues_url \"$ALL_ISSUES_URL\" \\\n --arg all_awaiting_url \"$ALL_AWAITING_URL\" \\\n --arg all_activity_url \"$ALL_ACTIVITY_URL\" \\\n --argjson warnings \"$WARNINGS_JSON\" \\\n '{\n repo: $repo,\n generated_utc: $now_utc,\n generated_display: $generated_display,\n cutoff_utc: $cutoff_utc,\n cutoff_date: $cutoff_date,\n window_days: ($window_days | tonumber),\n run_url: $run_url,\n display_cap: 25,\n data_quality_warnings: $warnings,\n see_all_urls: {\n merged_prs: $all_merged_url,\n opened_prs: $all_opened_url,\n filed_issues: $all_issues_url,\n awaiting_review: $all_awaiting_url,\n activity_highlights: $all_activity_url\n }\n }' > .repo-pulse/meta.json\n\n# Remove intermediate raw bundles — only the cleaned ones are input to the agent.\nrm -f .repo-pulse/*.raw.json\n\necho \"--- Repo Pulse data bundle ---\"\nls -la .repo-pulse/\necho \"--- meta.json ---\"\ncat .repo-pulse/meta.json\necho \"--- counts ---\"\nfor f in merged-prs opened-prs filed-issues awaiting-review activity-highlights; do\n printf \"%-22s %s\\n\" \"$f.json\" \"$(jq 'length' \".repo-pulse/${f}.json\")\"\ndone" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8 ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" @@ -650,22 +617,23 @@ jobs: export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export MCP_GATEWAY_ALLOWED_MOUNT_ROOTS="${GITHUB_WORKSPACE}:rw,${RUNNER_TEMP}/gh-aw:ro,${RUNNER_TEMP}/gh-aw/safeoutputs:rw,/opt:ro,/tmp:rw,/usr/bin/gh:ro" export DEBUG="*" export GH_AW_ENGINE="copilot" MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.8' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -e MCP_GATEWAY_ALLOWED_MOUNT_ROOTS -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.9' mkdir -p "$HOME/.copilot" GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_32c95f97bcc4bf55_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_3d76effbe9e274f4_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "container": "ghcr.io/github/github-mcp-server:v1.9.0", "env": { "GITHUB_FEATURES": "fields_param", "GITHUB_HOST": "${GITHUB_SERVER_URL}", @@ -725,7 +693,7 @@ jobs: "startupTimeout": 120 } } - GH_AW_MCP_CONFIG_32c95f97bcc4bf55_EOF + GH_AW_MCP_CONFIG_3d76effbe9e274f4_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -750,6 +718,24 @@ jobs: - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): + # --allow-tool github + # --allow-tool safeoutputs + # --allow-tool shell(cat) + # --allow-tool shell(date) + # --allow-tool shell(echo) + # --allow-tool shell(github:*) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(ls) + # --allow-tool shell(printf) + # --allow-tool shell(pwd) + # --allow-tool shell(safeoutputs:*) + # --allow-tool shell(sort) + # --allow-tool shell(tail) + # --allow-tool shell(uniq) + # --allow-tool shell(wc) + # --allow-tool shell(yq) + # --allow-tool write timeout-minutes: 20 run: | set -o pipefail @@ -759,6 +745,18 @@ jobs: printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -783,8 +781,8 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(github:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(printf)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE @@ -798,7 +796,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -987,7 +985,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -996,7 +994,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Repo Pulse — Daily Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-pulse.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1193,7 +1191,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1202,7 +1200,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Repo Pulse — Daily Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-pulse.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1254,25 +1252,7 @@ jobs: - name: Prepare threat detection files if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - rm -f /tmp/gh-aw/agent_usage.json - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then - echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context." - fi - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - for f in /tmp/gh-aw/aw-*.bundle; do - if [ -f "$f" ]; then - cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - fi - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + bash "${RUNNER_TEMP}/gh-aw/actions/prepare_threat_detection_files.sh" - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1292,18 +1272,20 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - rm -f /tmp/gh-aw/threat-detection/step-summary.md - touch /tmp/gh-aw/threat-detection/step-summary.md + rm -f /tmp/gh-aw/step-summary.md + touch /tmp/gh-aw/step-summary.md - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false + - name: Install ripgrep + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_ripgrep.sh" - name: Install GitHub Copilot CLI run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" env: GH_HOST: github.com - GH_AW_COMPILED_VERSION: v0.85.4 + GH_AW_COMPILED_VERSION: v0.86.2 - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.44 - name: Execute GitHub Copilot CLI @@ -1319,6 +1301,18 @@ jobs: mkdir -p "$HOME/.copilot" printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" + GH_AW_COPILOT_SRC="$(command -v copilot 2>/dev/null || true)" + if [ -z "$GH_AW_COPILOT_SRC" ] || [ ! -x "$GH_AW_COPILOT_SRC" ]; then + echo "GitHub Copilot CLI executable not found on PATH after installation" >&2 + exit 127 + fi + GH_AW_COPILOT_BIN="${RUNNER_TEMP}/gh-aw/bin/copilot" + mkdir -p "${RUNNER_TEMP}/gh-aw/bin" + if [ "$GH_AW_COPILOT_SRC" != "$GH_AW_COPILOT_BIN" ]; then + cp "$GH_AW_COPILOT_SRC" "$GH_AW_COPILOT_BIN" + fi + chmod 755 "$GH_AW_COPILOT_BIN" + touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN @@ -1345,9 +1339,10 @@ jobs: fi fi # shellcheck disable=SC1003,SC2016,SC2086 - awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ + -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: + GITHUB_STEP_SUMMARY: /tmp/gh-aw/step-summary.md AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode @@ -1359,14 +1354,13 @@ jobs: GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_TIMEOUT_MINUTES: 20 - GH_AW_VERSION: v0.85.4 + GH_AW_VERSION: v0.86.2 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md GITHUB_WORKSPACE: ${{ github.workspace }} GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com GIT_AUTHOR_NAME: github-actions[bot] @@ -1375,6 +1369,23 @@ jobs: RUNNER_TEMP: ${{ runner.temp }} S2STOKENS: true TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Echo detection step summary + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + run: | + if [ -s /tmp/gh-aw/step-summary.md ]; then + cat /tmp/gh-aw/step-summary.md + fi + - name: Render detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/render_detection_log.cjs'); + await main(); - name: Parse threat detection token usage for step summary id: parse_detection_token_usage if: always() @@ -1458,15 +1469,20 @@ jobs: code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_items_applied: ${{ steps.process_safe_outputs.outputs.items_applied }} + process_safe_outputs_items_cancelled: ${{ steps.process_safe_outputs.outputs.items_cancelled }} + process_safe_outputs_items_deferred: ${{ steps.process_safe_outputs.outputs.items_deferred }} process_safe_outputs_items_failed: ${{ steps.process_safe_outputs.outputs.items_failed }} + process_safe_outputs_items_skipped: ${{ steps.process_safe_outputs.outputs.items_skipped }} process_safe_outputs_items_succeeded: ${{ steps.process_safe_outputs.outputs.items_succeeded }} + process_safe_outputs_items_warnings: ${{ steps.process_safe_outputs.outputs.items_warnings }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_status: ${{ steps.process_safe_outputs.outputs.status }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@2709137ea6c5b0e19aa621454dc643ea8dc526b1 # v0.85.4 + uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1475,7 +1491,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Repo Pulse — Daily Report" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-pulse.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact diff --git a/.github/workflows/repo-pulse.md b/.github/workflows/repo-pulse.md index 181cfd5f24b..04f884d985d 100644 --- a/.github/workflows/repo-pulse.md +++ b/.github/workflows/repo-pulse.md @@ -30,6 +30,9 @@ permissions: network: defaults tools: + # Shell access is explicit because the unfiltered GitHub integrity setting + # requires an intentional allowlist for reading the preloaded data bundle. + bash: ["cat", "ls", "grep", "head", "tail", "wc"] github: # Data collection runs in pre-agent-steps via `gh api`; the agent # does not need to search GitHub itself. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 858223935c4..3f546a5c47d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -286,7 +286,8 @@ jobs: uses: ./.github/workflows/build-cli-native-archives.yml with: versionOverrideArg: ${{ inputs.versionOverrideArg }} - targets: '[{"os": "windows-latest", "runner": "windows-11-arm", "rids": "win-arm64"}]' + # Use the newer VS 2026 ARM linker; the VS 2022 image fails on Aspire.Dashboard.obj with LNK1322. + targets: '[{"os": "windows-latest", "runner": "windows-11-vs2026-arm", "rids": "win-arm64"}]' build_cli_archive_macos: name: Build native CLI archive (macOS) @@ -569,8 +570,9 @@ jobs: name: Run VS Code extension unit tests (Windows) runs-on: windows-latest needs: [setup_for_tests] - # Also gated on run_extension_e2e (not just run_extension_unit): extension_e2e_tests `needs` this - # job, so gating it off while e2e runs would skip e2e via need-propagation. + # Also gated on run_extension_e2e (not just run_extension_unit): this job packages and uploads + # the aspire-extension VSIX that extension_e2e_tests installs, so skipping it would leave the + # shards with nothing to install. if: ${{ needs.setup_for_tests.outputs.run_extension_unit == 'true' || needs.setup_for_tests.outputs.run_extension_e2e == 'true' }} env: NPM_REGISTRY: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ @@ -639,14 +641,19 @@ jobs: run: corepack yarn install --frozen-lockfile --non-interactive - name: Run tests run: corepack yarn test + # The VSIX steps from here down run even when 'Run tests' failed. extension_e2e_tests + # installs the artifact this job uploads but is deliberately not gated on the unit-test + # result, so the packaging has to happen regardless or that decoupling buys nothing. - name: Override extension version for PR builds - if: ${{ inputs.extensionVersionOverride != '' }} + if: ${{ !cancelled() && inputs.extensionVersionOverride != '' }} run: corepack yarn version --new-version "${{ inputs.extensionVersionOverride }}" --no-git-tag-version - name: Package VSIX + if: ${{ !cancelled() }} env: ASPIRE_EXTENSION_E2E_INCLUDE_BRIDGE: "true" run: corepack yarn run vsce package --pre-release -o out/aspire-extension.vsix - name: Assert E2E VSIX contains bridge + if: ${{ !cancelled() }} run: ../.github/scripts/assert-extension-e2e-bridge-vsix.ps1 -VsixPath out/aspire-extension.vsix -Expected Present # Packaged separately (not reusing out/aspire-extension.vsix) because the step above already # proved that VSIX contains the bridge on purpose; asserting Absent against it would be @@ -655,10 +662,13 @@ jobs: # GitHub Actions step `env:` blocks don't leak into later steps, so this reproduces exactly # what Extension.proj's shipping build packages, with no ability to inherit the opt-in above. - name: Package production VSIX + if: ${{ !cancelled() }} run: corepack yarn run vsce package --pre-release -o out/aspire-extension-production.vsix - name: Assert production VSIX excludes bridge + if: ${{ !cancelled() }} run: ../.github/scripts/assert-extension-e2e-bridge-vsix.ps1 -VsixPath out/aspire-extension-production.vsix -Expected Absent - name: Upload VSIX + if: ${{ !cancelled() }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: aspire-extension @@ -722,8 +732,21 @@ jobs: extension_e2e_tests: name: Run VS Code extension E2E tests - if: ${{ needs.setup_for_tests.outputs.run_extension_e2e == 'true' }} uses: ./.github/workflows/extension-e2e-tests.yml + # extension_tests_win stays in `needs` because it uploads the aspire-extension VSIX these shards + # install, but E2E is deliberately not gated on its unit-test *result*: !cancelled() keeps the + # shards running when those tests fail, and the VSIX packaging steps in that job carry the same + # condition so the artifact is published either way. Before this, one unit failure - or an npm + # blip on that single Windows runner - skipped all 30 shards and erased the E2E signal entirely. + # The remaining producers are still required by result, since a missing CLI archive or package + # feed makes every shard fail on a download rather than tell us anything. + if: >- + ${{ !cancelled() && + needs.setup_for_tests.outputs.run_extension_e2e == 'true' && + needs.build_packages.result == 'success' && + needs.build_cli_archive_linux.result == 'success' && + needs.build_cli_archive_windows.result == 'success' && + needs.extension_bootstrap_linux.result == 'success' }} needs: [setup_for_tests, build_packages, build_cli_archive_linux, build_cli_archive_windows, extension_tests_win, extension_bootstrap_linux] typescript_sdk_tests: diff --git a/.github/workflows/update-nix-cli-flake.yml b/.github/workflows/update-nix-cli-flake.yml index fc080736e9a..bbb0d83c09c 100644 --- a/.github/workflows/update-nix-cli-flake.yml +++ b/.github/workflows/update-nix-cli-flake.yml @@ -1,9 +1,16 @@ # Update Nix CLI Flake Workflow # -# This workflow updates eng/nix/versions.json after the AzDO release pipeline -# uploads the stable aspire-cli-* archives and their .sha512 sidecars to the -# GitHub release. It is dispatched by release-publish-nuget.yml after -# PublishReleaseAssetsJob, so the release asset URLs it pins are already live. +# This workflow updates eng/nix/versions.json for a stable Aspire CLI release. +# It is dispatched by release-publish-nuget.yml, which passes the aspire-cli-* +# .sha512 digests it already downloaded and verified from the signed source +# build's BlobArtifacts. Because the checksums come from the build (not the +# GitHub release), the manifest can be produced while the release is still an +# unpublished draft — required for immutable releases, where draft assets aren't +# served from the public releases/download/... URL. The url values pinned in +# versions.json are still the public release download URLs (what Nix consumers +# fetch once the release is published); only the checksum source changed. +# Manual runs against an already-published release may omit the sha512_* inputs, +# in which case the updater reads the .sha512 assets from the release directly. # The changes are committed to the update-baseline- branch created by # release-github-tasks.yml, then the workflow creates or updates the PR from # that branch so the post-release stable-version updates land together. @@ -22,6 +29,31 @@ on: required: false type: boolean default: false + # Optional build-provided SHA512 digests (hex), one per platform RID. When + # supplied (all four required), the manifest updater uses them directly and + # does NOT read the .sha512 assets from the GitHub release. The AzDO release + # pipeline passes the digests it already downloaded and verified from the + # signed source build's BlobArtifacts, so the Nix manifest can be built + # while the GitHub release is still an unpublished draft (draft assets are + # not served from the public releases/download/... URL). Manual runs against + # an already-published release can omit these and fall back to the release + # read. See eng/nix/update-versions.sh for the offline-mode contract. + sha512_osx_arm64: + description: 'SHA512 (hex) of aspire-cli-osx-arm64-.tar.gz. Optional; omit to read from the published release.' + required: false + type: string + sha512_osx_x64: + description: 'SHA512 (hex) of aspire-cli-osx-x64-.tar.gz. Optional; omit to read from the published release.' + required: false + type: string + sha512_linux_arm64: + description: 'SHA512 (hex) of aspire-cli-linux-arm64-.tar.gz. Optional; omit to read from the published release.' + required: false + type: string + sha512_linux_x64: + description: 'SHA512 (hex) of aspire-cli-linux-x64-.tar.gz. Optional; omit to read from the published release.' + required: false + type: string concurrency: group: update-nix-cli-flake-${{ inputs.release_version }} @@ -131,8 +163,36 @@ jobs: - name: Update Nix release manifest if: inputs.dry_run != true + env: + # Passed through env (not string-interpolated into the run body) so the + # digest values can't break the shell quoting. Empty when the caller + # didn't supply build-provided checksums (manual/post-publish runs). + SHA512_OSX_ARM64: ${{ inputs.sha512_osx_arm64 }} + SHA512_OSX_X64: ${{ inputs.sha512_osx_x64 }} + SHA512_LINUX_ARM64: ${{ inputs.sha512_linux_arm64 }} + SHA512_LINUX_X64: ${{ inputs.sha512_linux_x64 }} run: | - eng/nix/update-versions.sh --version "${{ inputs.release_version }}" + set -euo pipefail + args=(--version "${{ inputs.release_version }}") + + # If any build-provided digest is present, run the updater in offline + # mode (no GitHub release read). Require all four so the manifest is + # complete and reproducible — a partial set is a pipeline wiring bug. + if [[ -n "$SHA512_OSX_ARM64" || -n "$SHA512_OSX_X64" || -n "$SHA512_LINUX_ARM64" || -n "$SHA512_LINUX_X64" ]]; then + if [[ -z "$SHA512_OSX_ARM64" || -z "$SHA512_OSX_X64" || -z "$SHA512_LINUX_ARM64" || -z "$SHA512_LINUX_X64" ]]; then + echo "::error::Partial SHA512 inputs supplied. All four platform checksums (osx-arm64, osx-x64, linux-arm64, linux-x64) are required for offline mode." + exit 1 + fi + echo "Using build-provided SHA512 checksums (offline mode); not reading the GitHub release." + args+=(--sha512 "osx-arm64=$SHA512_OSX_ARM64") + args+=(--sha512 "osx-x64=$SHA512_OSX_X64") + args+=(--sha512 "linux-arm64=$SHA512_LINUX_ARM64") + args+=(--sha512 "linux-x64=$SHA512_LINUX_X64") + else + echo "No SHA512 inputs supplied; reading checksums from the published GitHub release assets." + fi + + eng/nix/update-versions.sh "${args[@]}" - name: Show Nix manifest diff if: inputs.dry_run != true diff --git a/.gitignore b/.gitignore index 7f2f2fa1532..d9600fc6b80 100644 --- a/.gitignore +++ b/.gitignore @@ -140,9 +140,14 @@ target/ dependency-reduced-pom.xml # Java AppHost build artifacts -playground/**/.java-build/ +# +# .java-build is where the CLI compiles a single-file Java AppHost before launching it with +# `java -cp .java-build AppHost`, so it appears next to any Java AppHost - the playgrounds, the +# polyglot test fixtures, and the scratch workspace the extension E2E runner creates under +# extension/. Scoping the rule to known directories left the others untracked-but-visible, which is +# how build output ends up in a commit. +.java-build/ playground/**/*.class -tests/PolyglotAppHosts/**/.java-build/ tests/PolyglotAppHosts/**/Java/**/*.class # Ignore cache created with the Angular CLI. diff --git a/Aspire-Core.slnf b/Aspire-Core.slnf index 17d1cb72d31..c8199adc2e8 100644 --- a/Aspire-Core.slnf +++ b/Aspire-Core.slnf @@ -29,6 +29,7 @@ "src\\Aspire.Hosting.EntityFrameworkCore\\Aspire.Hosting.EntityFrameworkCore.csproj", "src\\Aspire.Hosting.Go\\Aspire.Hosting.Go.csproj", "src\\Aspire.Hosting.Integration.Analyzers\\Aspire.Hosting.Integration.Analyzers.csproj", + "src\\Aspire.Hosting.Java\\Aspire.Hosting.Java.csproj", "src\\Aspire.Hosting.JavaScript\\Aspire.Hosting.JavaScript.csproj", "src\\Aspire.Hosting.PostgreSQL\\Aspire.Hosting.PostgreSQL.csproj", "src\\Aspire.Hosting.Python\\Aspire.Hosting.Python.csproj", @@ -57,6 +58,7 @@ "tests\\Aspire.Hosting.Analyzers.Tests\\Aspire.Hosting.Analyzers.Tests.csproj", "tests\\Aspire.Hosting.Containers.Tests\\Aspire.Hosting.Containers.Tests.csproj", "tests\\Aspire.Hosting.EntityFrameworkCore.Tests\\Aspire.Hosting.EntityFrameworkCore.Tests.csproj", + "tests\\Aspire.Hosting.Java.Tests\\Aspire.Hosting.Java.Tests.csproj", "tests\\Aspire.Hosting.Rust.Tests\\Aspire.Hosting.Rust.Tests.csproj", "tests\\Aspire.Hosting.Testing.Tests\\Aspire.Hosting.Testing.Tests.csproj", "tests\\Aspire.Hosting.Tests\\Aspire.Hosting.Tests.csproj", diff --git a/Aspire.slnx b/Aspire.slnx index 343ef13d947..d157c96c6f4 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -59,6 +59,7 @@ + @@ -368,6 +369,15 @@ + + + + + + + + + @@ -538,6 +548,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index c0af8b06bf8..98ccecb9c15 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -55,7 +55,7 @@ - $(MSBuildThisFileDirectory)/artifacts/bin/Aspire.Dashboard/$(Configuration)/net8.0/ + $(MSBuildThisFileDirectory)/artifacts/bin/Aspire.Dashboard/$(Configuration)/net11.0/ $(MSBuildThisFileDirectory)/artifacts/bin/Aspire.TerminalHost/$(Configuration)/net8.0/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 8dfbb15494e..0540e8f712a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -101,28 +101,28 @@ - - + + - + - - - + + + - - + + - - - + + + @@ -133,36 +133,36 @@ - + - - - + + + - - + + - + - + - + - - - + + + - + @@ -198,7 +198,7 @@ - + @@ -234,6 +234,9 @@ + + @@ -307,4 +310,11 @@ + + + + + + + diff --git a/NuGet.config b/NuGet.config index 68f1e3a83e4..f3bf72016af 100644 --- a/NuGet.config +++ b/NuGet.config @@ -19,6 +19,7 @@ + @@ -38,6 +39,9 @@ + + + diff --git a/THIRD-PARTY-NOTICES.TXT b/THIRD-PARTY-NOTICES.TXT index 5c4ac631770..c993b722b84 100644 --- a/THIRD-PARTY-NOTICES.TXT +++ b/THIRD-PARTY-NOTICES.TXT @@ -13,7 +13,7 @@ License notice for plotly.js The MIT License (MIT) -Copyright (c) 2021 Plotly, Inc +Copyright (c) 2016-2024 Plotly Technologies Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj b/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj index d075e13ae28..49d1ddf9ea7 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj +++ b/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net11.0 Aspire.Dashboard.Benchmarks Aspire.Dashboard.Benchmarks enable diff --git a/docs/ci/native-cli-packaging.md b/docs/ci/native-cli-packaging.md index 70421b85337..56a7e43a5f1 100644 --- a/docs/ci/native-cli-packaging.md +++ b/docs/ci/native-cli-packaging.md @@ -41,12 +41,12 @@ Linux native jobs do not sign ELF binaries in `build_sign_native`. For Linux, ex The root `flake.nix` packages the stable Aspire CLI from the versioned GitHub release archive URLs and hashes tracked in `eng/nix/versions.json`. It is a binary package, not a Nix source build of this repository. This keeps the Nix package aligned with the same canonical signed native archive consumed by the other installers. -For stable releases, the GitHub release assets must exist before the Nix manifest is updated. `release-publish-nuget` dispatches `.github/workflows/update-nix-cli-flake.yml` after `PublishReleaseAssetsJob` uploads the `aspire-cli-*` archives and `.sha512` sidecars to the GitHub release. The workflow runs: +For stable releases, `release-publish-nuget`'s `UpdateNixPackageJob` dispatches `.github/workflows/update-nix-cli-flake.yml`. The job reads the `aspire-cli-*.tar.gz.sha512` checksums from the signed source build's `BlobArtifacts` and passes them to the workflow as inputs, so the manifest is built from the build — not from the GitHub release. This lets it run while the release is still a **draft** (immutable releases don't serve draft assets from the public download URL). The workflow runs: ```sh -eng/nix/update-versions.sh --version +eng/nix/update-versions.sh --version --sha512 osx-arm64= --sha512 osx-x64= --sha512 linux-arm64= --sha512 linux-x64= ``` -The workflow commits the Nix manifest change to the `update-baseline-` branch created by `release-github-tasks.yml`, then creates or updates the baseline PR. Merging that PR is the in-repo Nix "ship" step: it publishes the flake metadata that points at the already-published release assets. The updater reads the official `.sha512` assets and writes Nix-compatible SRI hashes. Do not point the manifest at mutable `aka.ms` channel URLs; Nix fixed-output fetches require stable versioned URLs. +The workflow commits the Nix manifest change to the `update-baseline-` branch created by `release-github-tasks.yml`, then creates or updates the baseline PR. Merging that PR is the in-repo Nix "ship" step: it publishes the flake metadata that points at the versioned release download URLs (which go live when the release manager publishes the draft). When the `--sha512` inputs are supplied the updater uses them directly; run manually without them (against an already-published release) it downloads the official `.sha512` assets instead. Either way it writes Nix-compatible SRI hashes. Do not point the manifest at mutable `aka.ms` channel URLs; Nix fixed-output fetches require stable versioned URLs. The Nix derivation writes `{"source":"nix"}` to `.aspire-install.json` next to the packaged native binary. `BundleService` treats this route as read-only and extracts the embedded bundle payload into the user-owned Aspire home instead of the Nix store. diff --git a/docs/release-process.md b/docs/release-process.md index 5aa5bc34b99..15630f5f9df 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -13,29 +13,48 @@ The Aspire release process uses these main automation components: - Publishes Aspire CLI npm packages through ESRP/MicroBuild. - Promotes the build to the GA channel via darc. - Submits WinGet manifest PRs. - - Optionally validates the Homebrew cask against the live GitHub release when `SkipHomebrewValidation=false` (cask version bumps themselves are submitted by upstream autobump; see [Installer channels](#installer-channels)). - Optionally publishes the signed VS Code extension to the Visual Studio Marketplace. - Dispatches the GitHub Actions workflow below as the `aspire-repo-bot` GitHub App and waits for it to complete. - - Uploads `aspire-cli-*` archives from the source build's `BlobArtifacts` onto the GitHub Release as the `aspire-repo-bot`. - - Dispatches the Nix flake update workflow after stable release assets are live. + - Uploads `aspire-cli-*` archives from the source build's `BlobArtifacts` onto the **draft** GitHub Release as the `aspire-repo-bot`. + - Dispatches the Nix flake update workflow, passing the `aspire-cli-*.tar.gz.sha512` checksums read from the source build's `BlobArtifacts` (so the manifest is built from the build, not the draft release). 2. **GitHub Actions workflow** (`.github/workflows/release-github-tasks.yml`) - Creates Git tags. - - Creates GitHub Releases. + - Creates **draft** GitHub Releases (see [Draft-then-publish release flow](#draft-then-publish-release-flow)). - Creates merge-back PRs. - Creates baseline version update branches and, when not deferred, PRs. - Normally dispatched automatically by the AzDO pipeline above; it can also be run manually as a fallback for partial-failure re-runs. -3. **GitHub Actions workflow** (`.github/workflows/update-nix-cli-flake.yml`) - - Reads the stable release's official `.sha512` assets after `release-publish-nuget` uploads them. +3. **GitHub Actions workflow** (`.github/workflows/release-notes-generate.md`) — agentic + - Triggered when the draft release is created. + - Replaces the placeholder release notes body with auto-generated notes against the live draft (via the gh-aw `update-release` safe output). +4. **GitHub Actions workflow** (`.github/workflows/update-nix-cli-flake.yml`) + - Receives the CLI `.sha512` checksums from the AzDO pipeline (sourced from the source build's `BlobArtifacts`), so it can run while the release is still a draft. Manual runs against a published release omit the inputs and read the release's `.sha512` assets instead. - Updates `eng/nix/versions.json`. - Commits the Nix manifest update to the `update-baseline-` branch created by `release-github-tasks.yml`, then creates or updates the PR. - Normally dispatched automatically by the AzDO pipeline; it can also be run manually as a fallback after the GitHub release assets are live. -4. **GitHub Actions workflow** (`.github/workflows/extension-release.yml`) +5. **GitHub Actions workflow** (`.github/workflows/homebrew-validate-release.yml`) + - Triggered when the release manager publishes the draft (i.e. `release: [published]`). + - Generates the Homebrew cask file from the just-published `aspire-cli-osx-*` assets and runs `brew audit --cask --online --signing` + a real `brew install`/`brew uninstall` cycle to catch problems before Homebrew/homebrew-cask's autobump PR is opened. +6. **GitHub Actions workflow** (`.github/workflows/extension-release.yml`) - Prepares a VS Code extension release PR. - Bumps `extension/package.json`. - Generates or updates `extension/CHANGELOG.md`. - Defaults its comparison baseline to the latest stable Marketplace VSIX's embedded `extension/.version` commit SHA. - Can run with `dry_run: true` in forks to validate changelog generation without bot secrets. +## Draft-then-publish release flow + +GitHub's [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) lock release assets and the underlying tag at publish time. Once a release is published, the assets attached to it (and the tag itself) cannot be changed. The body and title remain editable, but everything else is frozen. + +Aspire's release flow has historically depended on mutating the released artifact: the agentic release-notes-generate workflow rewrites the body, and the AzDO pipeline's `PublishReleaseAssetsJob` uploads `aspire-cli-*` archives onto the release *after* it was created. With immutable releases enabled, all of those mutations must happen **before** the release is published. + +To support this, the release flow now creates the GitHub release as a **draft**: + +1. `release-github-tasks.yml` runs `gh release create --draft`, which writes the tag and creates a draft release with a placeholder body. +2. `release-github-tasks.yml` then explicitly dispatches `release-notes-generate` via `workflow_dispatch` (passing the tag name), which rewrites the body via the gh-aw `update-release` safe output. It uses an explicit dispatch because GitHub Actions does [not fire `release` events](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#release) (including `created`) for draft releases. (Draft releases support body edits via the Releases REST API.) +3. The AzDO pipeline's `PublishReleaseAssetsJob` uploads `aspire-cli-*` archives onto the draft via `gh release upload`. (Draft releases support asset upload via the Releases REST API.) +4. **The release manager publishes the draft manually as a final step** — see [Step 5: Publish the draft release](#step-5-publish-the-draft-release). +5. Publishing the draft fires `release: [published]`, which kicks off downstream workflows that need the live release: `release-update-support-mdx` (opens a PR on `microsoft/aspire.dev`) and `homebrew-validate-release` (validates the cask against the live download URLs). Both subscribe to `published` rather than `released` because GitHub does not reliably fire `released` when a release is published from a draft. + ## Installer channels Aspire ships through several channels. The release pipeline either submits the bump itself or validates a bump submitted upstream; the per-channel docs describe manifest shape, validation modes, and dogfooding workflows in detail. @@ -44,10 +63,10 @@ Aspire ships through several channels. The release pipeline either submits the b |---------|------------------------------|------------------| | **NuGet** (libraries, AppHost SDK, `Aspire.Cli` tool packages) | `release-publish-nuget` pushes to NuGet.org | This document | | **WinGet** (`winget install Microsoft.Aspire`) | `release-publish-nuget` submits manifest PRs to `microsoft/winget-pkgs` via `wingetcreate` | [`eng/winget/README.md`](../eng/winget/README.md) | -| **Homebrew cask** (`brew install --cask aspire`) | Upstream Homebrew/homebrew-cask's [autobump workflow](https://github.com/Homebrew/homebrew-cask/blob/master/.github/workflows/autobump.yml) opens the bump PR on a 3-hour schedule, detecting the new version via the cask's `livecheck` block. `release-publish-nuget` validates the cask against the live GitHub release after asset upload only when `SkipHomebrewValidation=false` (skipped by default). | [`eng/homebrew/README.md`](../eng/homebrew/README.md) | +| **Homebrew cask** (`brew install --cask aspire`) | Upstream Homebrew/homebrew-cask's [autobump workflow](https://github.com/Homebrew/homebrew-cask/blob/master/.github/workflows/autobump.yml) opens the bump PR on a 3-hour schedule, detecting the new version via the cask's `livecheck` block. `homebrew-validate-release.yml` validates the cask against the live GitHub release after the release manager publishes it. | [`eng/homebrew/README.md`](../eng/homebrew/README.md) | | **`dotnet tool install -g Aspire.Cli`** | `release-publish-nuget` pushes the per-RID `Aspire.Cli.*.nupkg` packages to NuGet.org alongside the libraries | [`docs/specs/install-routes.md`](specs/install-routes.md) | | **Install script** (`get-aspire-cli.sh` / `.ps1`) | No separate publication — the script downloads directly from the GitHub release assets attached in Step 1 | [`docs/specs/install-routes.md`](specs/install-routes.md), `eng/scripts/get-aspire-cli.*` | -| **Nix flake** (`nix run github:microsoft/aspire#aspire-cli`) | `release-publish-nuget` dispatches `.github/workflows/update-nix-cli-flake.yml` after stable GitHub release assets are uploaded; the workflow adds the manifest update to the baseline version branch and creates or updates the PR | [`eng/nix/README.md`](../eng/nix/README.md), [`docs/ci/native-cli-packaging.md`](ci/native-cli-packaging.md) | +| **Nix flake** (`nix run github:microsoft/aspire#aspire-cli`) | `release-publish-nuget` dispatches `.github/workflows/update-nix-cli-flake.yml`, passing the CLI `.sha512` checksums from the source build; the workflow adds the manifest update to the baseline version branch and creates or updates the PR | [`eng/nix/README.md`](../eng/nix/README.md), [`docs/ci/native-cli-packaging.md`](ci/native-cli-packaging.md) | The CLI identifies which channel installed it via a per-install sidecar so that self-update can route back through the same channel. See [`docs/specs/install-routes.md`](specs/install-routes.md). @@ -104,8 +123,7 @@ Before starting a release: | `SkipWinGetPublish` | Set `true` if re-running after WinGet success. | `true` | | `SkipGitHubTasks` | Set `true` to skip dispatching the GH workflow. | `false` | | `SkipReleaseAssets` | Set `true` to skip uploading `aspire-cli-*` assets to the GitHub release. | `false` | - | `SkipHomebrewValidation` | Set `false` to run Homebrew cask validation against the live GitHub release. | `true` | - | `SkipNixPackageUpdate` | Set `true` if the Nix flake update was already added to the baseline version PR. Stable releases dispatch the updater after the GitHub release assets are uploaded; prereleases skip it. | `false` | + | `SkipNixPackageUpdate` | Set `true` if the Nix flake update was already added to the baseline version PR. Stable releases dispatch the updater with checksums from the source build; prereleases skip it. | `false` | | `SkipVSCodeExtensionPublish` | Set `false` to publish the signed `aspire-vscode-extension` artifact to the Visual Studio Marketplace. | `true` | | `NpmPublishOwners` | Single ESRP owner alias or email. Overrides must be one of the required owner aliases in `eng/pipelines/release-publish-nuget.yml` (currently `joperezr` or `ankj`). | `joperezr` | | `NpmPublishApprovers` | Single ESRP approver alias or email. The approver must be a Microsoft address and must not be the same alias as the owner. | `adamratzman` | @@ -115,8 +133,8 @@ Before starting a release: 4. Select the **Resources** button in the bottom right, then select the source build from the `aspire-build` dropdown. - The picker shows all recent builds from the `microsoft-aspire` pipeline regardless of branch. Pick the build that corresponds to the release branch and version you intend to ship. - Each build's tags are shown alongside its number. Verify the `release-version - X.Y.Z` tag matches the version you intend to ship before clicking **Run**. If the tag is missing, either re-run the source build after the tag-emitting change in `azure-pipelines.yml` is on that release branch or pass an explicit `ReleaseVersion` override. -5. Click **Run** and monitor the pipeline. The final stage (`GitHubTasks`) dispatches `release-github-tasks.yml`, waits for it to complete, uploads the `aspire-cli-*` archives from the source build's `BlobArtifacts` onto the newly-created GitHub release, and then dispatches `update-nix-cli-flake.yml` for stable releases so `eng/nix/versions.json` is bumped from the live `.sha512` assets on the `update-baseline-` branch before the baseline PR is created or updated. If `SkipHomebrewValidation=false`, it also validates the Homebrew cask against that live release. The AzDO pipeline only succeeds if the enabled GitHub tasks, asset upload, Nix update dispatch, and optional Homebrew validation succeed. -6. Verify packages appear on NuGet.org and npm, verify that the `aspire-cli-*` archives are attached to the GitHub release, and review the generated baseline version PR including the Nix flake manifest update. +5. Click **Run** and monitor the pipeline. The final stage (`GitHubTasks`) dispatches `release-github-tasks.yml`, waits for it to complete, uploads the `aspire-cli-*` archives from the source build's `BlobArtifacts` onto the newly-created **draft** GitHub release, and then dispatches `update-nix-cli-flake.yml` for stable releases so `eng/nix/versions.json` is bumped from the source build's `.sha512` checksums on the `update-baseline-` branch before the baseline PR is created or updated. The AzDO pipeline only succeeds if the enabled GitHub tasks, asset upload, and Nix update dispatch succeed. +6. Verify packages appear on NuGet.org and npm, verify that the `aspire-cli-*` archives are attached to the draft GitHub release, and review the generated baseline version PR including the Nix flake manifest update. Then proceed to [Step 5: Publish the draft release](#step-5-publish-the-draft-release). To publish only the VS Code extension after merging an extension release PR, run the same `release-publish-nuget` pipeline, select the signed source build from that merge, and set: @@ -130,7 +148,6 @@ To publish only the VS Code extension after merging an extension release PR, run | `SkipNpmPointerPublish` | `true` | | `SkipChannelPromotion` | `true` | | `SkipWinGetPublish` | `true` | -| `SkipHomebrewValidation` | `true` | | `SkipGitHubTasks` | `true` | | `SkipReleaseAssets` | `true` | | `SkipNixPackageUpdate` | `true` | @@ -167,7 +184,7 @@ Run this step only when releasing the VS Code extension independently of the nor The GitHub workflow is normally dispatched by the AzDO pipeline as the `aspire-repo-bot` GitHub App, with its `authorize` job bypassed for the bot. If a GitHub-side step fails partway through and you need to re-run only the GitHub work, you can: -1. Re-run the AzDO pipeline with completed AzDO-side work skipped, such as `SkipNuGetPublish`, `SkipNpmRidPublish`, `SkipNpmPointerPublish`, `SkipChannelPromotion`, `SkipWinGetPublish`, `SkipNixPackageUpdate`, `SkipHomebrewValidation`, and `SkipReleaseAssets` set as appropriate, keeping `SkipGitHubTasks: false`. The `GitHubTasks` stage will dispatch the workflow again with the right inputs, and the workflow's own `skip_*` idempotency makes the completed steps no-ops. +1. Re-run the AzDO pipeline with completed AzDO-side work skipped, such as `SkipNuGetPublish`, `SkipNpmRidPublish`, `SkipNpmPointerPublish`, `SkipChannelPromotion`, `SkipWinGetPublish`, `SkipNixPackageUpdate`, and `SkipReleaseAssets` set as appropriate, keeping `SkipGitHubTasks: false`. The `GitHubTasks` stage will dispatch the workflow again with the right inputs, and the workflow's own `skip_*` idempotency makes the completed steps no-ops. 2. Or, navigate to Actions → **Release GitHub Tasks**, click **Run workflow**, and fill in the parameters manually: | Parameter | Description | Example | @@ -178,26 +195,48 @@ The GitHub workflow is normally dispatched by the AzDO pipeline as the `aspire-r | `is_prerelease` | `true` for preview releases. | `false` | | `dry_run` | `true` to validate without making changes. | `false` | | `skip_tagging` | Skip if tag already created. | `false` | - | `skip_github_release` | Skip if release already exists. | `false` | + | `skip_github_release` | Skip if draft release already exists. | `false` | | `skip_merge_pr` | Skip if PR already created. | `false` | | `skip_baseline_pr` | Skip if PR already created. | `false` | Manual runs go through the normal `authorize` check (admin/maintain permission required). -### Step 4: Post-Release Tasks (Manual) +### Step 4: Verify and prepare to publish After automation completes: -1. **Review and merge automatically created PRs**: +1. **Review and merge the merge-back PR**: - Merge-back PR: `$RELEASE_BRANCH` → `main`. - - Baseline version PR: updates `PackageValidationBaselineVersion`. - - Baseline version PR also includes `eng/nix/versions.json` for stable releases. -2. **Verify the release**: - - Check the [GitHub Releases page](https://github.com/microsoft/aspire/releases). + - **Do not merge the baseline version PR yet.** It updates `PackageValidationBaselineVersion` and, for stable releases, `eng/nix/versions.json`, which points at the release's public `releases/download/v/...` asset URLs. Those URLs return 404 until the draft is published, so merging this PR before Step 5 would break `nix run github:microsoft/aspire#aspire-cli` on `main`. You merge it after publishing — see [Step 5](#step-5-publish-the-draft-release). +2. **Verify draft release contents**: + - Open the draft on the [GitHub Releases page](https://github.com/microsoft/aspire/releases) (drafts appear above published releases). + - Confirm the `aspire-cli-*` archives are attached. + - Confirm the release-notes-generate workflow has rewritten the body. If the body still contains the placeholder text after ~30 minutes, check the [`release-notes-generate` runs](https://github.com/microsoft/aspire/actions/workflows/release-notes-generate.lock.yml) for failures and rerun via `workflow_dispatch` if needed. +3. **Verify upstream channels**: - Verify packages on [NuGet.org](https://www.nuget.org/packages?q=Aspire). - Verify npm packages on the Microsoft npm profile. - - Test installation: `dotnet new install Aspire.ProjectTemplates::VERSION` and `aspire update --self`. -3. **Communicate**: + +### Step 5: Publish the draft release + +This is a **manual** step performed by the release manager. The release is created as a draft so that all mutations (release notes rewrite, CLI asset upload) can complete before publish — once published, GitHub's [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) policy locks the assets and tag. Publishing must therefore happen *after* Step 4 verification, not before. + +1. Navigate to the draft release on the [GitHub Releases page](https://github.com/microsoft/aspire/releases). +2. Click **Edit** to do a final review — confirm the title is `Aspire `, the body looks correct, the tag is `v` pointing at the release commit, and the `aspire-cli-*` archives are attached. +3. Uncheck **Set as a pre-release** if it is checked but this is a stable release (or check it for a preview release). +4. Click **Publish release**. +5. **Now merge the baseline version PR.** With the release published, its `eng/nix/versions.json` asset URLs resolve, so merging it updates `PackageValidationBaselineVersion` and the Nix manifest on `main` without breaking the flake. + +Publishing the draft fires the `release: [published]` event, which triggers: + +- [`release-update-support-mdx`](https://github.com/microsoft/aspire/actions/workflows/release-update-support-mdx.lock.yml): opens a draft PR on `microsoft/aspire.dev` to update the support mdx with the new release info. +- [`homebrew-validate-release`](https://github.com/microsoft/aspire/actions/workflows/homebrew-validate-release.yml): runs `brew audit --cask --online --signing` + a real `brew install`/`brew uninstall` cycle against the cask generated from the just-published `aspire-cli-osx-*` assets. + +If either downstream workflow fails, the release itself is fine — the release is published and immutable. Investigate the failure on the workflow run, fix the underlying issue, and rerun via `workflow_dispatch` against the published tag. + +### Step 6: Post-Release Communication + +1. **Test installation**: `dotnet new install Aspire.ProjectTemplates::VERSION` and `aspire update --self`. +2. **Communicate**: - Update any tracking issues. - Notify stakeholders. @@ -218,11 +257,11 @@ These automations are designed to be idempotent and safe to re-run. | MicroBuild npm Publish | Check the ESRP release result. If RID packages published but the pointer package did not, re-run with `SkipNuGetPublish: true`, `SkipNpmRidPublish: true`, `SkipNpmPointerPublish: false`, and `SkipChannelPromotion: true`; do not set `SkipNpmPointerPublish` until the pointer package is published. | | Validate Published npm Package from Registry | Confirm the pointer package is visible on npm and that `npm install -g @microsoft/aspire-cli@` works. If registry propagation is slow, re-run with completed publish steps skipped after the package is visible. | | Promote Build to Channel | Re-run with completed publish steps skipped. | -| WinGet publishing / Homebrew validation | Re-run with the corresponding skip flags for completed work. | +| WinGet publishing | Re-run with the corresponding skip flag for completed work. | | Publish VS Code Extension to Marketplace | Check that `aspire-vscode-extension` contains one `.vsix`, `.manifest`, and `.signature.p7s`; verify the `AspireSecurePublishPipelineMarketplaceConnectionWithManagedIdentity` service connection identity is a Contributor on the Visual Studio Marketplace `microsoft-aspire` publisher; re-run with the already-completed `Skip*` flags set to `true`. | | GitHubTasks dispatch | Re-run with completed AzDO-side work skipped and `SkipGitHubTasks: false`; set `SkipReleaseAssets` according to whether release asset upload already completed. | -| Release asset upload | Re-run with `SkipGitHubTasks: true` and `SkipReleaseAssets: false` after the GitHub release exists. | -| Nix flake update dispatch | Re-run with `SkipGitHubTasks: true`, `SkipReleaseAssets: true`, and `SkipNixPackageUpdate: false` after the stable GitHub release assets and `update-baseline-` branch exist. If the Nix manifest was already committed to the baseline PR, set `SkipNixPackageUpdate: true` instead. For prereleases, leave it skipped because the Nix manifest only tracks stable `x.y.z` releases. | +| Release asset upload | Re-run with `SkipGitHubTasks: true` and `SkipReleaseAssets: false` after the draft GitHub release exists. | +| Nix flake update dispatch | Re-run with `SkipGitHubTasks: true`, `SkipReleaseAssets: true`, and `SkipNixPackageUpdate: false` after the `update-baseline-` branch exists. The job sources checksums from the source build's `BlobArtifacts`, so it does not require the GitHub release assets to be uploaded first. If the Nix manifest was already committed to the baseline PR, set `SkipNixPackageUpdate: true` instead. For prereleases, leave it skipped because the Nix manifest only tracks stable `x.y.z` releases. | ### GitHub Actions failures @@ -235,7 +274,7 @@ Re-run with the corresponding `skip_*` input set to `true` to skip steps that ha | Authorize | Caller lacks admin/maintain permission, or the AzDO bot identity check failed. | | Validate Version Format / Commit SHA | Fix the input parameters and re-run. | | Create Tag | If the tag exists with the wrong SHA, resolve it manually. | -| Create GitHub Release | Re-run with `skip_tagging: true`. | +| Create GitHub Release (draft) | Re-run with `skip_tagging: true`. | | Create Merge PR | Re-run with `skip_tagging: true` and `skip_github_release: true`. | | Create Baseline PR | Re-run with all prior skips set to `true`. | @@ -350,8 +389,8 @@ Official source build -> signed native archives / native CLI packages -> npm tarballs verified against the native archive -> PackageArtifacts: NuGet packages - -> BlobArtifacts: microsoft-aspire-cli*.tgz and aspire-cli-* release assets - -> eng/nix/versions.json can be updated from the published aspire-cli-* release assets + -> BlobArtifacts: microsoft-aspire-cli*.tgz and aspire-cli-* archives + .sha512 sidecars + -> eng/nix/versions.json is updated from the build's aspire-cli-*.tar.gz.sha512 checksums Azure DevOps release-publish-nuget.yml -> PrepareArtifacts @@ -369,22 +408,39 @@ Azure DevOps release-publish-nuget.yml -> WinGetJob -> GitHubTasks -> dispatch release-github-tasks.yml as aspire-repo-bot - -> upload aspire-cli-* assets to the GitHub release - -> dispatch update-nix-cli-flake.yml as aspire-repo-bot (stable releases) - -> validate Homebrew cask against the live release (only when SkipHomebrewValidation=false) + -> upload aspire-cli-* assets to the draft GitHub release + -> dispatch update-nix-cli-flake.yml as aspire-repo-bot (stable releases), + passing the build's aspire-cli-*.tar.gz.sha512 checksums as inputs GitHub release-github-tasks.yml -> create tag - -> create GitHub release + -> create DRAFT GitHub release (with placeholder body) -> create merge-back PR -> create baseline version branch -> create baseline version PR immediately only when Nix update is skipped or the workflow is run manually without deferral -GitHub update-nix-cli-flake.yml - -> read .sha512 assets from the live GitHub release - -> update eng/nix/versions.json +GitHub update-nix-cli-flake.yml (dispatched by UpdateNixPackageJob, stable releases) + -> receive the build's aspire-cli-*.tar.gz.sha512 checksums as inputs + -> update eng/nix/versions.json (no draft-release read needed) -> commit the Nix manifest update to update-baseline- -> create or update the baseline version PR + +GitHub release-notes-generate.lock.yml (dispatched by release-github-tasks.yml + via workflow_dispatch — draft releases + do not fire `release: created`) + -> agent rewrites the draft release body with real notes + via the gh-aw `update-release` safe output + +[Release manager] + -> reviews the draft release on github.com + -> clicks "Publish release" + +GitHub release-update-support-mdx.lock.yml (triggered on `release: published`) + -> opens draft PR on microsoft/aspire.dev + +GitHub homebrew-validate-release.yml (triggered on `release: published`) + -> generates aspire.rb from the just-published aspire-cli-osx-* assets + -> runs `brew audit --cask --online --signing` + brew install/uninstall ``` ## Related documentation diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index 290dfe128e7..629c52efec0 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -31,10 +31,11 @@ This document specifies the **Aspire Bundle**, a self-contained distribution pac The Aspire Bundle is a platform-specific archive containing the Aspire CLI and all runtime components: - **Aspire CLI** (native AOT executable, includes native certificate management) -- **Aspire Managed** (unified self-contained binary: Dashboard + AppHost Server + NuGet Helper) +- **Aspire Managed** (self-contained AppHost Server, NuGet Helper, and Terminal Host) +- **Aspire Dashboard** (Native AOT executable) - **Developer Control Plane (DCP)** (no longer distributed via NuGet) -**Key change**: DCP and Dashboard are now bundled with the CLI installation, not downloaded as NuGet packages. Dashboard, AppHost Server, and NuGet Helper are consolidated into a single `aspire-managed` binary that dispatches via subcommands. Certificate management is handled natively in the CLI (no subprocess needed). This: +**Key change**: DCP and Dashboard are bundled with the CLI installation, not downloaded on first run. The Dashboard ships as a Native AOT executable, while AppHost Server, NuGet Helper, and Terminal Host share `aspire-managed`. Certificate management is handled natively in the CLI. - Eliminates large NuGet package downloads on first run - Ensures version consistency between CLI and runtime components @@ -142,7 +143,7 @@ When a user runs `aspire run` with a TypeScript app host: 4. **CLI generates `appsettings.json`** for the AppHost Server with integration list 5. **CLI starts AppHost Server** using aspire-managed's server subcommand 6. **CLI starts guest app host** (TypeScript) which connects via JSON-RPC -7. **AppHost Server orchestrates** containers, Dashboard (via aspire-managed dashboard), and DCP +7. **AppHost Server orchestrates** containers, the Native AOT Dashboard, and DCP --- @@ -156,18 +157,21 @@ aspire-{version}-{platform}/ ├── aspire[.exe] # Native AOT CLI (~25 MB) │ # (includes native certificate management) │ -├── managed/ # Unified managed binary (~65 MB) -│ └── aspire-managed[.exe] # Self-contained single-file executable -│ # Subcommands: dashboard | server | nuget +├── managed/ +│ └── aspire-managed[.exe] # Self-contained server/NuGet/terminal executable +│ +├── dashboard/ +│ ├── Aspire.Dashboard[.exe] # Native AOT Dashboard +│ └── wwwroot/ # Dashboard static assets │ ├── dcp/ # Developer Control Plane (~127 MB) │ ├── dcp[.exe] # Native executable │ └── ... │ -└── (no more runtime/, dashboard/, aspire-server/, tools/ directories) +└── (no more runtime/, aspire-server/, tools/ directories) ``` -**Key change from previous layout**: The separate `.NET Runtime` (~106 MB), `dashboard/` (~42 MB), `aspire-server/` (~19 MB), `tools/aspire-nuget/` (~5 MB), and `tools/dev-certs/` directories have been consolidated into a single `managed/aspire-managed` self-contained binary. Certificate management has been moved natively into the CLI itself, eliminating the need for a separate dev-certs tool. +The bundle keeps the Native AOT Dashboard and its static assets separate from the self-contained `aspire-managed` host used for server, NuGet, and terminal operations. **Total Bundle Size:** - **Unzipped:** ~220 MB (down from ~323 MB — eliminated separate runtime) @@ -295,7 +299,7 @@ The parent directory check supports the installed layout where the CLI binary li |----------|-------------|---------| | `ASPIRE_LAYOUT_PATH` | Root of the bundle | `/opt/aspire` | | `ASPIRE_DCP_PATH` | DCP binaries location | `/opt/aspire/dcp` | -| `ASPIRE_DASHBOARD_PATH` | Path used by Aspire.Hosting to locate the dashboard binary (now points to `aspire-managed`) | `/opt/aspire/managed/aspire-managed` | +| `ASPIRE_DASHBOARD_PATH` | Path used by Aspire.Hosting to locate the Native AOT Dashboard | `/opt/aspire/dashboard/Aspire.Dashboard` | | `ASPIRE_MANAGED_PATH` | CLI-only path for the `aspire-managed` binary | `/opt/aspire/managed/aspire-managed` | | `ASPIRE_HOME` | Default Aspire state root and fallback bundle extraction root when no install-route sidecar selects an install-owned location | `/home/user/.aspire` | | `ASPIRE_INTEGRATION_LIBS_PATH` | Path to copied project-reference integration DLLs for aspire-server assembly resolution | `/home/user/myapp/.aspire/integrations/apphosts/app-hash/project-layouts/items/fingerprint/libs` | @@ -720,8 +724,12 @@ The bundle installs components as siblings under `~/.aspire/`, with the CLI bina │ ├── .aspire-bundle-version # Version marker (hex FNV-1a hash, written after extraction) │ -├── managed/ # Unified managed binary (self-contained) -│ └── aspire-managed # Subcommands: dashboard | server | nuget +├── managed/ +│ └── aspire-managed # Subcommands: server | nuget | terminalhost +│ +├── dashboard/ +│ ├── Aspire.Dashboard # Native AOT Dashboard +│ └── wwwroot/ # Dashboard static assets │ ├── dcp/ # Developer Control Plane │ └── dcp @@ -737,7 +745,7 @@ The bundle installs components as siblings under `~/.aspire/`, with the CLI bina - The CLI lives at `~/.aspire/bin/aspire` regardless of install method - With self-extracting binaries, the CLI in `bin/` contains the embedded payload; `aspire setup` extracts siblings - `.aspire-bundle-version` tracks the extracted version — extraction is skipped when hash matches -- `aspire-managed` is a single self-contained binary replacing separate runtime, dashboard, aspire-server, and tools directories +- `aspire-managed` remains the self-contained server/NuGet/terminal binary; the Dashboard is a separate Native AOT executable - Certificate management is native to the CLI (no external tool needed) - NuGet hives and settings are preserved across installations and re-extractions - `LayoutDiscovery` finds the bundle by checking the CLI's parent directory for components @@ -825,7 +833,8 @@ AppHost integration restore artifacts are cached under `/.aspire/inte | Component | On Disk | Zipped | |-----------|---------|--------| | DCP (platform-specific) | ~286 MB | ~100 MB | -| Aspire Managed (self-contained: Dashboard + Server + NuGet + .NET Runtime) | ~65 MB | ~25 MB | +| Aspire Managed (self-contained Server + NuGet + Terminal Host) | ~114 MB | varies | +| Aspire Dashboard (Native AOT) | ~60 MB | varies | | CLI (native AOT, includes certificate management) | ~22 MB | ~10 MB | | **Total** | **~373 MB** | **~135 MB** | @@ -1313,11 +1322,12 @@ This section tracks the implementation progress of the bundle feature. - `ICertificateToolRunner` abstraction - `NativeCertificateToolRunner` - calls `CertificateManager` directly (no subprocess) - `CertificateGeneration/` - vendored from aspnetcore, EventSource replaced with ILogger -- [x] **Aspire Managed unified binary** - `src/Aspire.Managed/` - - Self-contained single binary: `aspire-managed dashboard|server|nuget` - - Replaces separate runtime, dashboard, aspire-server, and tools directories +- [x] **Aspire Managed binary** - `src/Aspire.Managed/` + - Self-contained binary: `aspire-managed server|nuget|terminalhost` +- [x] **Native AOT Dashboard** - `src/Aspire.Dashboard/` - [x] **Bundle build tooling** - `tools/CreateLayout/` - - Builds aspire-managed as self-contained single-file binary + - Builds aspire-managed as a self-contained single-file binary + - Builds Aspire.Dashboard as a Native AOT executable - Copies DCP - `--embed-in-cli` option creates self-extracting binary - [x] **Installation scripts** - `eng/scripts/get-aspire-cli-pr.sh`, `eng/scripts/get-aspire-cli-pr.ps1` @@ -1381,13 +1391,11 @@ The bundle is built using the `tools/CreateLayout` tool, which assembles all com ### Aspire Managed Build -The `aspire-managed` binary is published as a self-contained single-file executable, which includes the .NET runtime. This eliminates the need to separately download and bundle the .NET SDK/runtime. +The `aspire-managed` binary is published as a self-contained single-file executable. The Dashboard is published separately with Native AOT so Blazor does not expand the reflection and dynamic-code requirements of the server, NuGet, and terminal tools. ```text -aspire-managed (self-contained, ~65 MB) +aspire-managed (self-contained) ├── .NET 10 Runtime (embedded) -├── ASP.NET Core Framework (embedded) -├── Aspire.Dashboard (embedded) ├── Aspire.Hosting.RemoteHost / aspire-server (embedded) ├── NuGet Commands (embedded) └── All managed dependencies @@ -1401,10 +1409,11 @@ aspire-managed (self-contained, ~65 MB) ### Build Steps -1. **Build aspire-managed** as a self-contained single-file binary (includes .NET runtime, Dashboard, AppHost Server, NuGet operations) -2. **Download and copy DCP** binaries -3. **Create archive** (tar.gz for Unix, ZIP for Windows) with `COPYFILE_DISABLE=1` to suppress macOS xattr headers -4. **Create self-extracting binary** — appends tar.gz payload + 32-byte trailer to native AOT CLI +1. **Build aspire-managed** as a self-contained single-file binary +2. **Build Aspire.Dashboard** as a Native AOT executable +3. **Download and copy DCP** binaries +4. **Create archive** with both executables and Dashboard static assets +5. **Create self-extracting binary** — appends tar.gz payload + 32-byte trailer to native AOT CLI ### Self-Extracting Binary Build diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index 350f22e00e8..e184f364fe7 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -508,6 +508,8 @@ The `devtools` category surfaces development-tooling recommendations. The `vscod } ``` +The `isolated-launch.v1` capability indicates that `aspire run` accepts the `--isolated` option. + ## MCP tooling ### `aspire mcp tools` diff --git a/eng/Build.props b/eng/Build.props index ff9d3b3bd51..a6c621c3e36 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -60,10 +60,17 @@ Other projects in the AspireWithMaui playground can still be built. The aspireify-eval projects are intentionally un-aspirified sample apps used as eval inputs for the aspireify skill — they do not import the repo's - Directory.Build.props/targets and are not meant to be built by CI. --> + Directory.Build.props/targets and are not meant to be built by CI. + `.aspire/` holds throwaway projects the CLI scaffolds while running a polyglot + AppHost (for example integration-restore/IntegrationRestore.csproj). They pin the + released Aspire.Hosting version, so building them against the repo's -dev version + fails with NU1605. They are gitignored, but that does not keep them out of an + MSBuild glob — so running any polyglot playground would otherwise break the next + `./build.sh` until the directory was deleted by hand. --> diff --git a/eng/Bundle.proj b/eng/Bundle.proj index 4f6c79f644a..8f77476ef64 100644 --- a/eng/Bundle.proj +++ b/eng/Bundle.proj @@ -5,10 +5,11 @@ Usage: dotnet msbuild eng/Bundle.proj /p:TargetRid=linux-x64 /p:Configuration=Release This project: - 1. Publishes the unified managed binary (Aspire.Managed) as self-contained - 2. Publishes native CLI (unless SkipNativeBuild=true) - 3. Restores DCP package - 4. Runs CreateLayout to assemble the bundle + 1. Publishes Aspire.Managed as self-contained + 2. Publishes the Dashboard with Native AOT + 3. Publishes native CLI (unless SkipNativeBuild=true) + 4. Restores DCP package + 5. Runs CreateLayout to assemble the bundle Options: - SkipNativeBuild=true: Skip building native CLI (use pre-built from artifacts) @@ -38,6 +39,7 @@ $(RepoRoot)src\Aspire.Cli\Aspire.Cli.csproj $(RepoRoot)src\Aspire.Managed\Aspire.Managed.csproj + $(RepoRoot)src\Aspire.Dashboard\Aspire.Dashboard.csproj $(RepoRoot)src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj $(RepoRoot)tools\CreateLayout\CreateLayout.csproj @@ -97,10 +99,13 @@ <_ManagedBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishManaged.binlog + <_DashboardBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishDashboard.binlog + + diff --git a/eng/Signing.props b/eng/Signing.props index f31dc899365..892689f3c84 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -68,10 +68,13 @@ + + + @@ -107,6 +110,8 @@ + + $(DotNetRuntimeNet8VersionForTesting) $(DotNetRuntimeNet9VersionForTesting) @@ -55,27 +55,24 @@ 10.8.0 10.8.0 - 1.15.2 - 1.15.1 - 1.15.3 - 1.15.1 - 1.15.3 - 1.8.0 + 1.17.0 + 1.17.0 + 1.17.0 + 1.17.0 + 1.17.0 + 1.8.3 2.23.32-alpha 1.0.0 - - - 10.0.8 - 11.0.0-preview.6.26359.118 - - 11.0.0-preview.6.26359.118 + 11.0.0-rc.1.26413.103 + 11.0.0-rc.1.26413.103 + 11.0.0-rc.1.26413.103 + 11.0.0-rc.1.26413.103 + 11.0.0-rc.1.26413.103 diff --git a/eng/clipack/Common.projitems b/eng/clipack/Common.projitems index 9baf2383d61..9b855a33292 100644 --- a/eng/clipack/Common.projitems +++ b/eng/clipack/Common.projitems @@ -182,7 +182,7 @@ <_ExtractedNativeBinaryPath Condition="$(CliRuntime.StartsWith('win-'))">$(_ExtractedNativeBinaryPath).exe <_CliToolPublishBaseDir>$([MSBuild]::NormalizeDirectory($(IntermediateOutputPath), 'tool-publish')) <_CliToolPublishDir>$([MSBuild]::NormalizeDirectory($(_CliToolPublishBaseDir), '$(CliRuntime)')) - <_CliToolPointerPublishDir>$([MSBuild]::NormalizeDirectory($(_CliToolPublishBaseDir), 'pointer')) + <_CliToolPointerPublishDir>$([MSBuild]::NormalizeDirectory($(_CliToolPublishBaseDir), 'pointer-$(CliRuntime)')) <_CliNpmPackageStagingDir>$([MSBuild]::NormalizeDirectory($(IntermediateOutputPath), 'npm-packages', '$(CliRuntime)')) <_CliNpmPackageStagingDirArg>$(_CliNpmPackageStagingDir.TrimEnd('\').TrimEnd('/')) <_PackageOutputPathArg>$(PackageOutputPath.TrimEnd('\').TrimEnd('/')) @@ -190,9 +190,7 @@ - <_SharedPackToolProperties Include="CreateRidSpecificToolPackages=true" /> <_SharedPackToolProperties Include="PreBuiltCliBinaryPath=$(_ExtractedNativeBinaryPath)" /> - <_SharedPackToolProperties Include="PublishAot=true" /> <_SharedPackToolProperties Include="BundlePayloadPath=" /> <_SharedPackToolProperties Include="Configuration=$(Configuration)" /> <_SharedPackToolProperties Include="ContinuousIntegrationBuild=$(ContinuousIntegrationBuild)" /> @@ -223,6 +221,8 @@ <_PackToolProperties Include="@(_SharedPackToolProperties)" /> + <_PackToolProperties Include="CreateRidSpecificToolPackages=true" /> + <_PackToolProperties Include="PublishAot=true" /> <_PackToolProperties Include="RuntimeIdentifier=$(CliRuntime)" /> <_PackToolProperties Include="PublishDir=$(_CliToolPublishDir)" /> @@ -244,6 +244,8 @@ <_PackPointerProperties Include="@(_SharedPackToolProperties)" /> + <_PackPointerProperties Include="CreateRidSpecificToolPackages=false" /> + <_PackPointerProperties Include="PublishAot=false" /> <_PackPointerProperties Include="PublishDir=$(_CliToolPointerPublishDir)" /> <_PackPointerProperties Include="NoBuild=true" /> diff --git a/eng/dashboardpack/Common.projitems b/eng/dashboardpack/Common.projitems index 556d61fcfcf..0f3233125c6 100644 --- a/eng/dashboardpack/Common.projitems +++ b/eng/dashboardpack/Common.projitems @@ -4,12 +4,15 @@ - net8.0 - true + net11.0 + true + true + true + $(CanPublishDashboardNativeAot) true - true + $(CanPublishDashboardNativeAot) false false $(ArtifactsShippingPackagesDir) @@ -33,20 +36,29 @@ - + + Condition="'$(SkipDashboardPublish)' != 'true'" + Properties="Configuration=$(Configuration);Platform=$(Platform);TargetFramework=$(TargetFramework);RuntimeIdentifier=$(DashboardRuntime);PlatformName=$(DashboardRuntime);PublishAot=true;SelfContained=true" /> + + <_DashboardExecutableName>Aspire.Dashboard + <_DashboardExecutableName Condition="$(DashboardRuntime.StartsWith('win-'))">$(_DashboardExecutableName).exe + <_DashboardPublishDirectory>$(DotNetOutputPath)Aspire.Dashboard/$(DashboardRuntime)/$(Configuration)/$(TargetFramework)/$(DashboardRuntime)/publish + + + <_PublishItems - Include="$(DotNetOutputPath)Aspire.Dashboard/$(DashboardRuntime)/$(Configuration)/$(TargetFramework)/$(DashboardRuntime)/publish/**/*" /> + Include="$(_DashboardPublishDirectory)/**/*" /> diff --git a/eng/dashboardpack/Sdk.targets b/eng/dashboardpack/Sdk.targets index f903ca475d9..53efbd5df8c 100644 --- a/eng/dashboardpack/Sdk.targets +++ b/eng/dashboardpack/Sdk.targets @@ -7,7 +7,6 @@ $([MSBuild]::EnsureTrailingSlash('$(AspireDashboardDir)')) $([MSBuild]::NormalizePath($(AspireDashboardDir), 'Aspire.Dashboard')) $(AspireDashboardPath).exe - $(AspireDashboardPath).dll diff --git a/eng/github-ci/test-trigger-map.yml b/eng/github-ci/test-trigger-map.yml index fc720dcc42f..359f33930a6 100644 --- a/eng/github-ci/test-trigger-map.yml +++ b/eng/github-ci/test-trigger-map.yml @@ -343,6 +343,7 @@ affected_project_rules: - Aspire.AppHost.Sdk - Aspire.Hosting.CodeGeneration.* - Aspire.Hosting.Go + - Aspire.Hosting.Java - Aspire.Hosting.JavaScript - Aspire.Hosting.Python - Aspire.Hosting.Rust diff --git a/eng/homebrew/README.md b/eng/homebrew/README.md index 280d11d2a29..e1719ffc581 100644 --- a/eng/homebrew/README.md +++ b/eng/homebrew/README.md @@ -74,20 +74,34 @@ what `brew install` fetches from the GitHub release URL. |---|---|---|---| | `.github/workflows/tests.yml` | Prerelease casks (artifacts only) | — | — | | `azure-pipelines.yml` (prepare stage) | Stable or prerelease casks (artifacts only) | — | — | -| `release-publish-nuget.yml` (release) | — | Stable cask, LiveRelease mode (only when `SkipHomebrewValidation=false`) | — (autobump handles bumps; see below) | - -The release pipeline's `HomebrewValidateJob` runs `validate-cask-artifact.sh` -in LiveRelease mode against the cask emitted by the source build, after the -release-asset upload step has attached the `aspire-cli-osx-*.tar.gz` -archives to the GitHub release. This is the first point at which the -cask's `url` (a `v#{version}` GitHub release-asset URL) actually resolves; -the source-build prepare stage can only validate offline because the -GitHub release for the version being built does not exist yet. Failures -in this job catch problems that would otherwise only surface to end -users running `brew install aspire`, or block Homebrew/homebrew-cask's -autobump PR a few hours later. Gated by `SkipHomebrewValidation`, which -defaults to `true`; the job only runs when `SkipHomebrewValidation=false` -(opt in per release, or when re-running after a partial failure). +| `.github/workflows/homebrew-validate-release.yml` (post-publish) | — | Stable cask, LiveRelease mode | — (autobump handles bumps; see below) | + +`.github/workflows/homebrew-validate-release.yml` runs `validate-cask-artifact.sh` +in LiveRelease mode after the release manager publishes the draft GitHub +release (it triggers on `release: [published]`). This is the first point +at which the cask's `url` (a `v#{version}` GitHub release-asset URL) +actually resolves; the source-build prepare stage can only validate +offline because the GitHub release for the version being built does not +exist yet, and the AzDO release pipeline now creates the GitHub release +as a draft (assets on a draft are not served from the public +`releases/download/v/...` URL that `--online`/`brew install` +need). Failures in this workflow catch problems that would otherwise +only surface to end users running `brew install aspire`, or block +Homebrew/homebrew-cask's autobump PR a few hours later. Manual re-runs +go through `workflow_dispatch` with the release version as input. + +> **Note:** The workflow regenerates the cask via +> `eng/homebrew/generate-cask.sh --version ` rather than consuming a +> prebuilt cask artifact from the source build. That makes +> `generate-cask.sh` the de-facto single source of truth for the cask +> file shape — if the source build ever needs to customize the cask +> beyond what `generate-cask.sh` produces (custom `test do` block, +> per-build template tweaks, etc.), this workflow will silently validate +> a cask that doesn't match what would ship. Keep the source-build cask +> generation and `generate-cask.sh` in sync, or upload the source-build +> cask as a release asset and have this workflow download+validate it +> instead. See [aspire#18068](https://github.com/microsoft/aspire/pull/18068) +> for the discussion that introduced this trade-off. ### Submission: upstream autobump @@ -129,7 +143,7 @@ cask URL points at validation time: | Mode | Cask URL resolves? | Audit args | Used by | |---|---|---|---| -| `LiveRelease` | Yes — points at a live GitHub release | `brew audit --cask --online --signing` + `brew install`/`brew uninstall` | `release-publish-nuget.yml` `HomebrewValidateJob`, after `PublishReleaseAssetsJob` uploads the archives | +| `LiveRelease` | Yes — points at a live GitHub release | `brew audit --cask --online --signing` + `brew install`/`brew uninstall` | `.github/workflows/homebrew-validate-release.yml`, on `release: [published]` after the human publishes the draft | | `LiveArchives` | Not yet — release for `v#{version}` hasn't been published | `brew audit --cask --no-signing` (no `--online`) | `azure-pipelines.yml` Homebrew Cask job; `.github/workflows/tests.yml`; `dogfood.sh` PR validation | Common to both modes: @@ -151,12 +165,14 @@ source-build archives are unsigned CI artifacts. The price is that LiveArchives doesn't run the `--online`-only checks: github/gitlab repo probes, homepage redirect/404 detection, livecheck -strategy resolution. LiveRelease in `HomebrewValidateJob` runs all of -them on every released version, so a regression in any surfaces there. +strategy resolution. LiveRelease in `homebrew-validate-release.yml` runs +all of them on every released version, so a regression in any surfaces +there. `LiveRelease` is the contract that matches what `Homebrew/homebrew-cask`'s own CI runs on the autobump PR — a clean run -in `HomebrewValidateJob` implies the autobump PR will audit cleanly too. +in `homebrew-validate-release.yml` implies the autobump PR will audit +cleanly too. To dogfood a GitHub Actions artifact locally, download the `homebrew-cask-prerelease` artifact and the `cli-native-archives-osx-*` diff --git a/eng/homebrew/prepare-cask-artifact.sh b/eng/homebrew/prepare-cask-artifact.sh index 94e17ee48e4..02097d3ea91 100755 --- a/eng/homebrew/prepare-cask-artifact.sh +++ b/eng/homebrew/prepare-cask-artifact.sh @@ -31,7 +31,8 @@ OUTPUT_DIR="" # resolve yet — the GH release for v#{version} hasn't been published". Callers # that want the full upstream-CI-equivalent audit + brew install/uninstall must # do so against an already-published cask via validate-cask-artifact.sh -# directly, as HomebrewValidateJob does in release-publish-nuget.yml. +# directly, as the homebrew-validate-release.yml GitHub workflow does on +# release: [published]. VALIDATION_MODE="LiveArchives" while [[ $# -gt 0 ]]; do diff --git a/eng/homebrew/validate-cask-artifact.sh b/eng/homebrew/validate-cask-artifact.sh index 36bb0387157..46bb6768897 100755 --- a/eng/homebrew/validate-cask-artifact.sh +++ b/eng/homebrew/validate-cask-artifact.sh @@ -185,7 +185,7 @@ echo "" # * github/gitlab repo probes (e.g. "is microsoft/aspire archived?") # * homepage redirect / 404 detection against aspire.dev # * livecheck strategy resolution -# All of these run in LiveRelease in `HomebrewValidateJob` for every +# All of these run in LiveRelease in `homebrew-validate-release.yml` for every # released version, so a regression in any of them surfaces there. echo "Auditing cask via local tap..." audit_args=(--cask --online --signing) diff --git a/eng/nix/README.md b/eng/nix/README.md index 97eebf7e2ad..bb32cc8193f 100644 --- a/eng/nix/README.md +++ b/eng/nix/README.md @@ -86,17 +86,17 @@ Use the overlay the same way: ## Updating the packaged version -The stable GitHub release assets must exist before this manifest is updated. The `release-publish-nuget` Azure DevOps pipeline dispatches `.github/workflows/update-nix-cli-flake.yml` after stable CLI archives and `.sha512` sidecars are uploaded to the GitHub release. That workflow runs the updater below, commits the Nix manifest change to the `update-baseline-` branch created by `release-github-tasks.yml`, and creates or updates the baseline PR so the post-release stable version updates stay together. +The `release-publish-nuget` Azure DevOps pipeline dispatches `.github/workflows/update-nix-cli-flake.yml` as part of a stable release. The pipeline reads the `aspire-cli-*.tar.gz.sha512` checksums from the signed source build's `BlobArtifacts` (the same artifacts it verifies and uploads to the release) and passes them to the workflow as inputs, so the manifest is built from the build rather than the GitHub release. This lets the manifest be produced while the release is still an unpublished **draft** — required for GitHub's immutable releases, where a draft's assets are not served from the public `releases/download/...` URL. The workflow commits the Nix manifest change to the `update-baseline-` branch created by `release-github-tasks.yml`, and creates or updates the baseline PR so the post-release stable version updates stay together. -Merging the baseline PR is the in-repo Nix "ship" step: it updates the flake metadata in `main` to point at the already-published GitHub release assets and hashes. There is no separate Nix registry publish step in this repository. +Merging the baseline PR is the in-repo Nix "ship" step: it updates the flake metadata in `main` to point at the GitHub release assets and hashes (the `url` fields are always the public versioned release download URLs, which become live once the release manager publishes the draft). There is no separate Nix registry publish step in this repository. -To update the manifest manually after a stable Aspire release publishes native CLI assets to GitHub, pass the same stable version used by the release baseline PR: +To update the manifest manually against an **already-published** stable release, pass the same stable version used by the release baseline PR: ```sh eng/nix/update-versions.sh --version ``` -The script downloads each official `.sha512` checksum asset and rewrites `versions.json` with Nix SRI hashes. The manifest must use versioned GitHub release URLs, not mutable `aka.ms` channel redirects, so Nix fixed-output fetches remain reproducible. +Run manually like this, the script downloads each official `.sha512` checksum asset from the published release and rewrites `versions.json` with Nix SRI hashes. When the release pipeline runs it instead, it passes `--sha512 =` for all four platforms so no release read happens (see the script header for the offline-mode contract). Either way the manifest uses versioned GitHub release URLs, not mutable `aka.ms` channel redirects, so Nix fixed-output fetches remain reproducible. ## Relationship to nixpkgs diff --git a/eng/nix/update-versions.sh b/eng/nix/update-versions.sh index 530a7929075..a893fe093eb 100755 --- a/eng/nix/update-versions.sh +++ b/eng/nix/update-versions.sh @@ -2,21 +2,47 @@ set -euo pipefail -# This updater intentionally runs after a stable GitHub release already exists. # The Nix package is a fixed-output binary package, so the manifest must point # at immutable release asset URLs plus their hashes instead of branch builds, -# mutable channel redirects, or locally produced artifacts. +# mutable channel redirects, or locally produced artifacts. The `url` fields +# written below are always the public github.com/.../releases/download/v/... +# URLs that Nix consumers fetch after the release is published. +# +# The per-platform SHA512 digests can come from one of two sources: +# 1. Default: read each official `.sha512` asset from the published GitHub +# release over the public download URL (`curl`). Used by manual/post-publish +# runs where the release already exists. +# 2. Offline (`--sha512 =`): the digests are supplied directly by the +# caller. The AzDO release pipeline uses this to pass the `.sha512` values it +# already downloaded and verified from the signed source build's +# BlobArtifacts, so the Nix manifest can be built before the draft release is +# published (GitHub's immutable releases require every mutation to happen +# while the release is still a draft, and draft assets are not served from +# the public download URL). The digests are identical either way — they are +# the same `.sha512` files that get uploaded to the release verbatim. repo="microsoft/aspire" version="" output_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/versions.json" +# Collected `=` overrides from --sha512. When any are supplied +# the script runs in offline mode and every platform must be provided (no curl). +sha512_overrides=() show_help() { cat <<'EOF' Usage: eng/nix/update-versions.sh --version VERSION [--repo OWNER/REPO] [--output-path PATH] + [--sha512 RID=HEX ...] Updates eng/nix/versions.json from stable Aspire CLI GitHub release assets. The script reads each official .sha512 checksum asset and converts it to the Nix SRI hash format used by fetchurl. + +By default the .sha512 digests are read from the published GitHub release over +the public download URL. Pass --sha512 RID=HEX (one per platform RID: +osx-arm64, osx-x64, linux-arm64, linux-x64) to supply the digests directly and +skip the network read entirely. When any --sha512 is passed, all four platforms +must be supplied. This is how the release pipeline feeds the checksums it +already verified from the source build so the manifest can be produced before +the draft release is published. EOF } @@ -46,6 +72,21 @@ while [[ $# -gt 0 ]]; do output_path="$2" shift 2 ;; + --sha512) + # Format: =, e.g. osx-arm64=1a2b... The value is + # validated (128-char hex) later, in hex_sha512_to_sri, so a malformed + # digest fails the same way whether it came from --sha512 or from curl. + if [[ $# -lt 2 || -z "$2" ]]; then + echo "error: --sha512 requires a value of the form =" >&2 + exit 1 + fi + if [[ "$2" != *=* ]]; then + echo "error: --sha512 value '$2' must be of the form =" >&2 + exit 1 + fi + sha512_overrides+=("$2") + shift 2 + ;; -h|--help) show_help exit 0 @@ -63,6 +104,30 @@ if [[ -z "$version" ]]; then exit 1 fi +# Offline mode is engaged when the caller supplies any --sha512 override. In that +# mode every platform must be provided so the manifest is complete and no curl +# happens; mixing supplied and fetched digests is rejected to keep the result +# reproducible. +offline_mode="false" +if [[ ${#sha512_overrides[@]} -gt 0 ]]; then + offline_mode="true" +fi + +# Returns the supplied SHA512 hex for a RID from the --sha512 overrides, or a +# non-zero exit if none was provided. Portable to bash 3.2 (no associative +# arrays) so the script still runs on stock macOS bash used by local test runs. +sha512_override_for() { + local rid="$1" + local pair + for pair in "${sha512_overrides[@]}"; do + if [[ "$pair" == "$rid="* ]]; then + printf '%s' "${pair#*=}" + return 0 + fi + done + return 1 +} + # The in-repo flake tracks latest stable CLI acquisition assets. Preview/daily # channels can move independently and are intentionally not written here because # Nix fixed-output fetches must be reproducible after a consumer pins this repo. @@ -117,15 +182,25 @@ temp_path="${output_path}.tmp" archive_url="${asset_base_url}/${archive_name}" checksum_url="${archive_url}.sha512" - echo "Reading ${checksum_url}" >&2 - # curl must fail loudly here. A missing asset usually means the release - # pipeline dispatched this workflow before PublishReleaseAssetsJob finished - # or the release was re-run with CLI asset upload skipped. - checksum_contents="$(curl -fsSL "$checksum_url")" - # The release checksum sidecar can be either: + # The checksum sidecar can be either: # # # Only the first token is the digest consumed by Nix's SRI hash format. + # This is true for both the release .sha512 asset and the build-provided + # override, so the same parse applies to whichever source is used. + if [[ "$offline_mode" == "true" ]]; then + if ! checksum_contents="$(sha512_override_for "$rid")"; then + echo "error: no --sha512 override supplied for rid '$rid'. Offline mode requires all four platforms (osx-arm64, osx-x64, linux-arm64, linux-x64)." >&2 + exit 1 + fi + echo "Using supplied SHA512 for ${archive_name} (offline mode; no release read)" >&2 + else + echo "Reading ${checksum_url}" >&2 + # curl must fail loudly here. A missing asset usually means the release + # pipeline dispatched this workflow before PublishReleaseAssetsJob finished + # or the release was re-run with CLI asset upload skipped. + checksum_contents="$(curl -fsSL "$checksum_url")" + fi read -r checksum _ <<< "$checksum_contents" hash="$(hex_sha512_to_sri "$checksum")" diff --git a/eng/pipelines/azure-pipelines.yml b/eng/pipelines/azure-pipelines.yml index c49dbe1a760..bddb62145ef 100644 --- a/eng/pipelines/azure-pipelines.yml +++ b/eng/pipelines/azure-pipelines.yml @@ -365,8 +365,9 @@ extends: # IMPORTANT: This artifact is ALSO consumed by the `template_tests` # stage. It must contain every Aspire.* nupkg the templates # transitively reference — including per-RID arch-specific - # Aspire.Hosting.Orchestration. and Aspire.Dashboard.Sdk. - # packages produced by the managed build. If a future refactor + # Aspire.Hosting.Orchestration. packages produced by the + # managed build. Native Dashboard RID packages are produced by + # the matching native jobs. If a future refactor # moves any of those out of Shipping/, template_tests will start # failing with restore errors. - task: 1ES.PublishPipelineArtifact@1 @@ -375,16 +376,6 @@ extends: path: '$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)/Shipping' artifactName: managed_packages_shipping - # Dashboard runtime zips ship from artifacts/DashboardArtifacts/// - # (per eng/dashboardpack/Common.projitems) and are picked up by - # Publishing.props _DashboardFilesToPublish. They sit outside packages/, - # so they need their own pipeline-artifact handoff to assemble. - - task: 1ES.PublishPipelineArtifact@1 - displayName: 🟣Publish dashboard artifacts for assemble - inputs: - path: '$(Build.SourcesDirectory)/artifacts/DashboardArtifacts/$(_BuildConfig)' - artifactName: managed_dashboard_artifacts - - ${{ if and(notin(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/main')) }}: - template: /eng/common/templates-official/job/onelocbuild.yml@self parameters: @@ -565,7 +556,8 @@ extends: -Project '$(System.TeamProject)' ` -BuildId '$(Build.BuildId)' ` -ArchivesTargetDir "$(Build.SourcesDirectory)/artifacts/signed-archives/$(_BuildConfig)" ` - -NupkgsTargetDir "$(Build.SourcesDirectory)/artifacts/native-cli-packages/$(_BuildConfig)" + -NupkgsTargetDir "$(Build.SourcesDirectory)/artifacts/native-cli-packages/$(_BuildConfig)" ` + -DashboardArtifactsTargetDir "$(Build.SourcesDirectory)/artifacts/DashboardArtifacts/$(_BuildConfig)" displayName: 🟣Download native archives (parallel) env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) @@ -653,13 +645,26 @@ extends: artifact: aspire-vscode-extension targetPath: '$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)/vscode' - # Dashboard runtime zips originate at artifacts/DashboardArtifacts// - # and are scanned by _DashboardFilesToPublish at publish time. - - task: DownloadPipelineArtifact@2 - displayName: 🟣Download dashboard artifacts - inputs: - artifact: managed_dashboard_artifacts - targetPath: '$(Build.SourcesDirectory)/artifacts/DashboardArtifacts/$(_BuildConfig)' + # Native jobs package the signed Dashboard executable for their RID. + # Stage those nupkgs only after the npm signing pass so Arcade does + # not reopen and re-sign their already-signed nested executable. + - pwsh: | + $ErrorActionPreference = 'Stop' + $sourceRoot = "$(Build.SourcesDirectory)/artifacts/native-cli-packages/$(_BuildConfig)" + $shipping = "$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)/Shipping" + $packages = @(Get-ChildItem -Path $sourceRoot -Filter "Aspire.Dashboard.Sdk.*.nupkg" -File -Recurse) + if ($packages.Count -ne 7) { + throw "Expected 7 Native AOT Dashboard RID packages, found $($packages.Count): $($packages.FullName -join ', ')" + } + foreach ($package in $packages) { + Copy-Item -LiteralPath $package.FullName -Destination $shipping -Force + } + + $dashboardArchives = @(Get-ChildItem -Path "$(Build.SourcesDirectory)/artifacts/DashboardArtifacts/$(_BuildConfig)" -Filter "aspire-dashboard-*.zip" -File -Recurse) + if ($dashboardArchives.Count -ne 7) { + throw "Expected 7 Native AOT Dashboard runtime archives, found $($dashboardArchives.Count): $($dashboardArchives.FullName -join ', ')" + } + displayName: 🟣Stage Native AOT Dashboard packages - pwsh: | Get-ChildItem -Path "$(Build.SourcesDirectory)/artifacts/packages" -File -Recurse | diff --git a/eng/pipelines/release-publish-nuget.yml b/eng/pipelines/release-publish-nuget.yml index 99a6395c819..6125911d0bb 100644 --- a/eng/pipelines/release-publish-nuget.yml +++ b/eng/pipelines/release-publish-nuget.yml @@ -4,17 +4,20 @@ # 1. Downloads signed packages from a specified build # 2. Publishes packages to NuGet.org and npm # 3. Promotes the build to the Aspire GA channel via darc -# 4. Submits WinGet manifests and, when SkipHomebrewValidation=false, -# validates the Homebrew cask +# 4. Submits WinGet manifests # 5. Optionally publishes the signed VS Code extension to the Marketplace # 6. Dispatches the release-github-tasks GitHub Actions workflow as the -# aspire-repo-bot GitHub App (tag, GitHub Release, merge-back PR, +# aspire-repo-bot GitHub App (tag, DRAFT GitHub Release, merge-back PR, # baseline PR) and waits for it to complete # 7. Uploads aspire-cli-* archives from the source build's BlobArtifacts -# onto the GitHub Release as the aspire-repo-bot +# onto the draft GitHub Release as the aspire-repo-bot # 8. Dispatches the Nix flake manifest update workflow after stable release # assets are live # +# The release manager publishes the prepared draft release manually as the +# final step. Homebrew cask validation runs post-publish in +# .github/workflows/homebrew-validate-release.yml (on release: [published]). +# # For full documentation, see: docs/release-process.md trigger: none # Manual trigger only @@ -86,11 +89,6 @@ parameters: type: boolean default: false - - name: SkipHomebrewValidation - displayName: '[Advanced] Skip Homebrew cask validation against live GitHub release (default; set false to opt in)' - type: boolean - default: true - - name: SkipNixPackageUpdate displayName: '[Advanced] Skip Nix flake update on baseline PR (re-run after success)' type: boolean @@ -2116,13 +2114,7 @@ extends: } elseif ("${{ parameters.DryRun }}" -eq "true") { Write-Host " (verify only; no upload in dry-run)" } else { - Write-Host " (uploaded after GitHub Tasks job)" - } - Write-Host "║ Homebrew Val: ${{ parameters.SkipHomebrewValidation }}" -NoNewline - if ("${{ parameters.SkipHomebrewValidation }}" -eq "true") { - Write-Host " (SKIPPED)" - } else { - Write-Host " (validates cask against live GH release after asset upload)" + Write-Host " (uploaded to draft release after GitHub Tasks job)" } Write-Host "║ Nix Flake: ${{ parameters.SkipNixPackageUpdate }}" -NoNewline if ("${{ parameters.SkipNixPackageUpdate }}" -eq "true") { @@ -2346,56 +2338,56 @@ extends: dryRun: ${{ parameters.DryRun }} # ===== STAGE 3: GITHUB TASKS ===== - # Four responsibilities, all running as (or against) the aspire-repo-bot + # Three responsibilities, all running as (or against) the aspire-repo-bot # GitHub App: # # 1. DispatchGitHubTasksJob — dispatches the release-github-tasks - # GitHub Actions workflow (tag, GitHub Release, merge-back PR, + # GitHub Actions workflow (tag, DRAFT GitHub Release, merge-back PR, # baseline PR) and polls the run to completion. Fails the pipeline # if the workflow doesn't succeed. # # 2. PublishReleaseAssetsJob — downloads aspire-cli-* archives from # the signed source build's BlobArtifacts, verifies their SHA512s, - # and uploads them as assets on the GitHub release that the - # dispatch job just created. + # and uploads them as assets on the draft GitHub release that the + # dispatch job just created. Asset upload against drafts is + # supported by the Releases REST API. # # 3. UpdateNixPackageJob — dispatches the update-nix-cli-flake - # GitHub Actions workflow after release asset upload. The workflow - # reads the live .sha512 assets, commits eng/nix/versions.json to - # the update-baseline- branch, and opens the PR from that - # branch (release-github-tasks.yml created only the branch via + # GitHub Actions workflow. It reads the aspire-cli-*.tar.gz.sha512 + # checksums from the signed source build's BlobArtifacts and passes + # them to the workflow as inputs, so the manifest is built from the + # build (not the draft release, whose assets aren't publicly + # downloadable). The workflow commits eng/nix/versions.json to the + # update-baseline- branch and opens the PR from that branch + # (release-github-tasks.yml created only the branch via # defer_baseline_pr=true; PR creation is handled here). # - # 4. HomebrewValidateJob — runs the full upstream-equivalent cask - # validation (`brew audit --cask --online --signing` + real - # `brew install`/`brew uninstall`) against the cask generated by - # the source build, with the live GitHub release URL it points - # at. This is the moment when the URL is actually reachable — - # the source-build prepare stage can only validate offline because - # the GitHub release for the version being built does not exist - # yet. Catches problems that would otherwise only surface to - # end users (or block Homebrew/homebrew-cask's autobump PR). + # After these jobs succeed the release is a fully-prepared DRAFT — the + # release manager publishes it manually as a final step (see + # docs/release-process.md "Publish the draft release"). The draft-first + # flow is required for compatibility with GitHub's immutable releases + # (https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases): + # publishing the release locks assets + tag, so every mutation must + # happen before publish. + # + # Homebrew cask validation against the live GitHub release used to + # run as a fourth job in this stage. It now lives in + # .github/workflows/homebrew-validate-release.yml and runs on the + # `release: [published]` event, i.e. only once the release manager + # publishes the draft and the asset URLs become public. See that + # workflow for details. # # Each job has its own skip flag (SkipGitHubTasks / SkipReleaseAssets / - # SkipNixPackageUpdate / SkipHomebrewValidation) so partial-failure - # re-runs can recover any piece independently. Skipping all four skips - # the stage entirely. + # SkipNixPackageUpdate) so partial-failure re-runs can recover any piece + # independently. Skipping all three skips the stage entirely. # # DryRun behavior: this stage still runs when DryRun=true. DryRun is # propagated to the GH workflow as dry_run=true (keeps it from # creating tags/releases/PRs) and to the asset script (downloads and - # verifies SHA512s but skips the gh release upload). When - # SkipHomebrewValidation=false, HomebrewValidateJob also runs under DryRun - # — it does not publish anything, it only audits and `brew install`/ - # `brew uninstall` against a public URL. The Nix workflow is dispatched - # with dry_run=true and intentionally does not read release assets in - # that mode because the AzDO dry-run path does not create/upload a live - # GitHub release. If the release for the version being validated does not - # yet exist when Homebrew validation runs (DryRun, or a re-run after - # assets were already uploaded), the job is expected to surface that as a - # real failure rather than be silently skipped. This lets a release - # manager validate the full AzDO → GitHub path end-to-end without - # performing a real release. + # verifies SHA512s but skips the gh release upload). The Nix workflow is + # dispatched with dry_run=true and intentionally does not read release + # assets in that mode because the AzDO dry-run path does not create/upload + # a live GitHub release. - stage: GitHubTasks displayName: 'Dispatch GitHub Release Tasks' # PrepareArtifacts is listed explicitly (in addition to Release) so the @@ -2417,8 +2409,7 @@ extends: and( eq('${{ parameters.SkipNixPackageUpdate }}', 'false'), eq('${{ parameters.IsPrerelease }}', 'false') - ), - eq('${{ parameters.SkipHomebrewValidation }}', 'false') + ) ) ) jobs: @@ -2650,13 +2641,17 @@ extends: ASPIRE_BOT_APP_ID: $(aspire-bot-app-id) ASPIRE_BOT_PRIVATE_KEY: $(aspire-bot-private-key) - # Dispatch the Nix manifest update workflow only after the GitHub - # release assets are live. The updater reads the release .sha512 - # assets, commits eng/nix/versions.json to the update-baseline- - # branch created by release-github-tasks.yml (which deferred PR creation - # via defer_baseline_pr=true), and opens the PR from that branch. - # Accepting 'Skipped' supports partial-failure re-runs where the - # assets were already uploaded and SkipReleaseAssets=true is used. + # Dispatch the Nix manifest update workflow. The checksums come from + # the signed source build's BlobArtifacts (the .sha512 sidecars this + # job downloads below), NOT from the GitHub release, so the manifest + # can be produced while the release is still an unpublished draft — + # required for immutable releases. The updater commits + # eng/nix/versions.json to the update-baseline- branch created + # by release-github-tasks.yml (which deferred PR creation via + # defer_baseline_pr=true) and opens the PR from that branch. dependsOn + # PublishReleaseAssetsJob is kept for ordering (branch creation + + # single release upload), and accepting 'Skipped' supports + # partial-failure re-runs where SkipReleaseAssets=true is used. - job: UpdateNixPackageJob displayName: 'Update baseline PR with Nix flake manifest' dependsOn: PublishReleaseAssetsJob @@ -2687,10 +2682,56 @@ extends: displayName: 'Checkout repo (for dispatch script)' fetchDepth: 1 + # Download the aspire-cli-* .sha512 sidecars from the source build. + # These are the checksums we forward to the Nix updater so it never + # has to read the (draft, non-public) GitHub release assets. Only + # the tiny .sha512 files are needed here — not the archives — so the + # glob is narrowed accordingly. See PublishReleaseAssetsJob for the + # broader '**/aspire-cli-*' download and the reason the '**/' prefix + # is required (the artifact name is a path prefix on each item). + - download: aspire-build + displayName: 'Download aspire-cli-*.sha512 from Source Build' + artifact: BlobArtifacts + patterns: '**/aspire-cli-*.sha512' + - pwsh: | + $ErrorActionPreference = 'Stop' + $version = "$(ReleaseVersionEffective)" + $assetsDir = Join-Path "$(Pipeline.Workspace)" 'aspire-build/BlobArtifacts' + + # The Nix flake ships the .tar.gz (macOS/Linux) archives only, so + # read just those four RIDs' checksums. Map each to the workflow + # input name expected by update-nix-cli-flake.yml. + $ridToInput = [ordered]@{ + 'osx-arm64' = 'sha512_osx_arm64' + 'osx-x64' = 'sha512_osx_x64' + 'linux-arm64' = 'sha512_linux_arm64' + 'linux-x64' = 'sha512_linux_x64' + } + $inputs = @{ - release_version = "$(ReleaseVersionEffective)" - dry_run = ([System.Convert]::ToBoolean("${{ parameters.DryRun }}")).ToString().ToLowerInvariant() + release_version = $version + dry_run = ([System.Convert]::ToBoolean("${{ parameters.DryRun }}")).ToString().ToLowerInvariant() + } + + foreach ($rid in $ridToInput.Keys) { + $shaPath = Join-Path $assetsDir "aspire-cli-$rid-$version.tar.gz.sha512" + if (-not (Test-Path $shaPath)) { + Write-Host "ERROR: No .sha512 companion found for aspire-cli-$rid-$version.tar.gz (expected at $shaPath). The source build must produce the CLI archives and their checksums before the Nix manifest can be built." -ForegroundColor Red + exit 1 + } + + # .sha512 files emitted by the build are a single line: + # [whitespace]. Take the first token and + # lowercase it, matching publish-release-cli-assets.ps1. + $hex = ((Get-Content $shaPath -Raw).Trim() -split '\s+')[0].ToLower() + if ($hex -notmatch '^[0-9a-f]{128}$') { + Write-Host "ERROR: '$shaPath' did not contain a 128-char hex SHA512 (got '$hex')." -ForegroundColor Red + exit 1 + } + + $inputs[$ridToInput[$rid]] = $hex + Write-Host " $($ridToInput[$rid]) = $hex" } & "$(Build.SourcesDirectory)/eng/pipelines/scripts/dispatch-release-github-tasks.ps1" ` @@ -2714,67 +2755,3 @@ extends: condition: always() env: DISPATCHED_RUN_URL: $(DispatchedRunUrl) - - # Validate the generated Homebrew cask against the live GitHub - # release. Runs `brew audit --cask --online --signing` and a real - # `brew install`/`brew uninstall` against the cask file produced by - # the source build. The cask URL points at - # github.com/microsoft/aspire/releases/download/v#{version}/..., so - # the release for the version being built must already exist for - # this job to pass — normally that happens earlier in this stage - # via PublishReleaseAssetsJob. The dependsOn below is purely - # temporal; the cask artifact itself comes from the aspire-build - # resource, not from PublishReleaseAssetsJob. Accepting 'Skipped' - # alongside Succeeded lets you re-run with SkipReleaseAssets=true - # against a previously uploaded release; if the release is missing - # the job is expected to fail loudly rather than be silently - # gated out. - # - # Modeled on PublishReleaseAssetsJob (regular buildJob with checkout - # + a `download: aspire-build` step) rather than WingetJob's - # releaseJob + templateContext.inputs pattern, because we need both - # to check out the repo (for eng/homebrew/validate-cask-artifact.sh) - # and to consume an artifact from the source build — and 1ES PT's - # releaseJob enforces `checkout: none`. - - job: HomebrewValidateJob - displayName: 'Validate Homebrew Cask against live release' - dependsOn: PublishReleaseAssetsJob - condition: | - and( - eq('${{ parameters.SkipHomebrewValidation }}', 'false'), - in(dependencies.PublishReleaseAssetsJob.result, 'Succeeded', 'SucceededWithIssues', 'Skipped') - ) - timeoutInMinutes: 30 - pool: - name: Azure Pipelines - vmImage: macOS-latest-internal - os: macOS - templateContext: - # No ESRP publish in this job (read-only brew audit / brew install of a public cask). - # Skip the auto-injected publish auth task. - mb: - publish: - enabled: false - steps: - - checkout: self - displayName: 'Checkout repo (for validate-cask-artifact.sh)' - fetchDepth: 1 - - - download: aspire-build - displayName: 'Download Homebrew Cask from Source Build' - artifact: homebrew-cask-stable - - - bash: | - set -euo pipefail - cask_file="$(Pipeline.Workspace)/aspire-build/homebrew-cask-stable/aspire.rb" - if [[ ! -f "$cask_file" ]]; then - echo "##[error]Expected cask file not found: $cask_file" - ls -la "$(Pipeline.Workspace)/aspire-build/homebrew-cask-stable" || true - exit 1 - fi - - "$(Build.SourcesDirectory)/eng/homebrew/validate-cask-artifact.sh" \ - --cask-file "$cask_file" \ - --channel stable \ - --validation-mode LiveRelease - displayName: '🟣Validate cask against live GitHub release' diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index 9dd1188f9dc..c34a9c0e2fa 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -202,6 +202,21 @@ jobs: /bl:$(Build.Arcade.LogsPath)PublishManaged.binlog displayName: 🟣Prepare aspire-managed + - script: >- + $(Build.SourcesDirectory)/$(dotnetScript) + publish + $(Build.SourcesDirectory)/src/Aspire.Dashboard/Aspire.Dashboard.csproj + -c $(_BuildConfig) + -r ${{ targetRid }} + --self-contained + /p:PublishAot=true + /p:PlatformName=${{ targetRid }} + /p:GenerateDocumentationFile=false + /p:EnforceCodeStyleInBuild=false + ${{ parameters.extraBuildArgs }} + /bl:$(Build.Arcade.LogsPath)PublishDashboard.binlog + displayName: 🟣Prepare Native AOT Dashboard + # On macOS, ad-hoc codesign aspire-managed with JIT entitlements BEFORE Arcade signing. # MicroBuild (MacDeveloperHardenWithNotarization) preserves entitlements from the prior # ad-hoc signature when re-signing with the real certificate. Without this step, @@ -219,7 +234,7 @@ jobs: $(Build.SourcesDirectory)/$(scriptName) -restore -sign ${{ parameters.extraBuildArgs }} - -projects $(Build.SourcesDirectory)/src/Aspire.Managed/Aspire.Managed.csproj + -projects "$(Build.SourcesDirectory)/src/Aspire.Managed/Aspire.Managed.csproj;$(Build.SourcesDirectory)/src/Aspire.Dashboard/Aspire.Dashboard.csproj" /bl:$(Build.Arcade.LogsPath)SignManaged.binlog displayName: 🟣Sign aspire-managed env: @@ -230,9 +245,33 @@ jobs: # to the default umask (typically 644). The execute bit must be restored before # CreateLayout packs the binary into the bundle archive. - ${{ if and(eq(parameters.codeSign, true), ne(parameters.agentOs, 'windows')) }}: - - script: >- + - script: | chmod +x $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed - displayName: 🟣Restore execute permission on aspire-managed + chmod +x $(Build.SourcesDirectory)/artifacts/bin/Aspire.Dashboard/${{ targetRid }}/Release/net11.0/${{ targetRid }}/publish/Aspire.Dashboard + displayName: 🟣Restore execute permissions on managed executables + + # Package the already-published/signed Dashboard without republishing it, + # which would replace the signed executable. + - script: >- + $(Build.SourcesDirectory)/$(dotnetScript) + build + $(Build.SourcesDirectory)/eng/dashboardpack/Aspire.Dashboard.Sdk.${{ targetRid }}.csproj + -c $(_BuildConfig) + /p:SkipDashboardPublish=true + ${{ parameters.extraBuildArgs }} + /bl:$(Build.Arcade.LogsPath)PackDashboard.binlog + displayName: 🟣Pack Native AOT Dashboard + + # The native_archives artifact publishes only artifacts/packages. Stage + # the Dashboard runtime zip there so Assemble can recover it alongside + # the RID-specific Dashboard nupkg. + - pwsh: | + $ErrorActionPreference = 'Stop' + $source = "$(Build.SourcesDirectory)/artifacts/DashboardArtifacts/$(_BuildConfig)/${{ targetRid }}/aspire-dashboard-${{ targetRid }}.zip" + $destination = "$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)/DashboardArtifacts/${{ targetRid }}" + New-Item -ItemType Directory -Force -Path $destination | Out-Null + Copy-Item -LiteralPath $source -Destination $destination -Force + displayName: 🟣Stage Native AOT Dashboard artifact - task: NodeTool@0 displayName: 🟣Install node.js diff --git a/eng/pipelines/templates/prepare-homebrew-cask.yml b/eng/pipelines/templates/prepare-homebrew-cask.yml index a19124d33dd..639e0021b19 100644 --- a/eng/pipelines/templates/prepare-homebrew-cask.yml +++ b/eng/pipelines/templates/prepare-homebrew-cask.yml @@ -45,8 +45,9 @@ steps: # prepare-cask-artifact.sh defaults to LiveArchives mode, which is what # source-build prep needs — the cask URL points at a github.com release # that doesn't exist yet at prep time. The full LiveRelease audit + real - # brew install runs later in release-publish-nuget.yml's - # HomebrewValidateJob, against the cask emitted by this prep stage. + # brew install runs later in the homebrew-validate-release.yml GitHub + # workflow, which fires on release: [published] after the release manager + # publishes the draft GitHub release. args=( --version "$(CliVersion)" --channel "$(HomebrewChannel)" diff --git a/eng/scripts/download-native-archives.ps1 b/eng/scripts/download-native-archives.ps1 index e08c03a5e4f..95bfa14351a 100644 --- a/eng/scripts/download-native-archives.ps1 +++ b/eng/scripts/download-native-archives.ps1 @@ -21,6 +21,8 @@ ///aspire-cli-*.{zip,tar.gz} ///Aspire.Cli*.nupkg ///microsoft-aspire-cli*.tgz + ///Aspire.Dashboard.Sdk.*.nupkg + ///aspire-dashboard-*.zip The path component is preserved so that stage-native-cli-tool-packages.ps1 (which the assemble job runs next) sees @@ -48,11 +50,15 @@ Created if it does not already exist. .PARAMETER NupkgsTargetDir - Destination directory for Aspire.Cli*.nupkg AND microsoft-aspire-cli*.tgz + Destination directory for Aspire.Cli*.nupkg, Aspire.Dashboard.Sdk.*.nupkg, + and microsoft-aspire-cli*.tgz files. Created if it does not already exist. (Both land here because stage-native-cli-tool-packages.ps1 then walks this directory for both shapes when called with -RequireNpmPackages.) +.PARAMETER DashboardArtifactsTargetDir + Destination directory for Native AOT Dashboard runtime zip files. + .PARAMETER ArtifactNamePattern Wildcard for selecting artifacts. Defaults to `native_archives_*`. @@ -83,7 +89,8 @@ -Project 'internal' ` -BuildId 2987730 ` -ArchivesTargetDir 'artifacts/signed-archives/Release' ` - -NupkgsTargetDir 'artifacts/native-cli-packages/Release' + -NupkgsTargetDir 'artifacts/native-cli-packages/Release' ` + -DashboardArtifactsTargetDir 'artifacts/DashboardArtifacts/Release' .NOTES Only Build/Container artifacts (the type produced by @@ -111,6 +118,9 @@ param( [Parameter(Mandatory)] [string]$NupkgsTargetDir, + [Parameter(Mandatory)] + [string]$DashboardArtifactsTargetDir, + [string]$ArtifactNamePattern = 'native_archives_*', [ValidateRange(1, 64)] @@ -206,6 +216,7 @@ $DownloadOneArtifact = { [string]$DownloadUrl, [string]$ArchivesTargetDir, [string]$NupkgsTargetDir, + [string]$DashboardArtifactsTargetDir, [int]$MaxDownloadAttempts, [int]$RetryBaseDelaySeconds ) @@ -284,16 +295,28 @@ $DownloadOneArtifact = { $archivesCount = 0 $nupkgsCount = 0 + $dashboardArchivesCount = 0 try { foreach ($entry in $zip.Entries) { # Skip directory entries (empty Name). if (-not $entry.Name) { continue } $isArchive = ($entry.Name -like 'aspire-cli-*.zip') -or ($entry.Name -like 'aspire-cli-*.tar.gz') - $isNupkg = ($entry.Name -like 'Aspire.Cli*.nupkg') -or ($entry.Name -like 'microsoft-aspire-cli*.tgz') - if (-not ($isArchive -or $isNupkg)) { continue } - - $target = if ($isArchive) { $ArchivesTargetDir } else { $NupkgsTargetDir } + $isNupkg = ($entry.Name -like 'Aspire.Cli*.nupkg') -or + ($entry.Name -like 'Aspire.Dashboard.Sdk.*.nupkg') -or + ($entry.Name -like 'microsoft-aspire-cli*.tgz') + $isDashboardArchive = $entry.Name -like 'aspire-dashboard-*.zip' + if (-not ($isArchive -or $isNupkg -or $isDashboardArchive)) { continue } + + $target = if ($isArchive) { + $ArchivesTargetDir + } + elseif ($isDashboardArchive) { + $DashboardArtifactsTargetDir + } + else { + $NupkgsTargetDir + } # Preserve the original layout: //. # stage-native-cli-tool-packages.ps1 walks for `native_archives_*` @@ -320,19 +343,27 @@ $DownloadOneArtifact = { } [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) - if ($isArchive) { $archivesCount++ } else { $nupkgsCount++ } + if ($isArchive) { + $archivesCount++ + } + elseif ($isDashboardArchive) { + $dashboardArchivesCount++ + } + else { + $nupkgsCount++ + } } } finally { $zip.Dispose() } - if (($archivesCount + $nupkgsCount) -eq 0) { + if (($archivesCount + $nupkgsCount + $dashboardArchivesCount) -eq 0) { # Zero matches isn't necessarily an error (an artifact might be all # symbols or sdl analysis files), but log so devs investigating an # empty-asset-manifest failure can see exactly what each artifact # contributed. - Write-Warning "Artifact '$ArtifactName' had no entries matching aspire-cli-*, Aspire.Cli*.nupkg, or microsoft-aspire-cli*.tgz shapes. Inspect $tempZip if this was unexpected." + Write-Warning "Artifact '$ArtifactName' had no entries matching CLI or Dashboard shipping artifact shapes. Inspect $tempZip if this was unexpected." } else { # Only remove the temp zip when at least something was extracted — @@ -347,6 +378,7 @@ $DownloadOneArtifact = { TotalMs = $sw.ElapsedMilliseconds Archives = $archivesCount Nupkgs = $nupkgsCount + DashboardArchives = $dashboardArchivesCount } } @@ -363,6 +395,7 @@ function Invoke-ParallelDownloads { [Parameter(Mandatory)] [string]$AccessToken, [Parameter(Mandatory)] [string]$ArchivesTargetDir, [Parameter(Mandatory)] [string]$NupkgsTargetDir, + [Parameter(Mandatory)] [string]$DashboardArtifactsTargetDir, [Parameter(Mandatory)] [scriptblock]$Worker, [Parameter(Mandatory)] [int]$ThrottleLimit, [Parameter(Mandatory)] [int]$MaxDownloadAttempts, @@ -401,7 +434,7 @@ function Invoke-ParallelDownloads { $jobs = @(foreach ($a in $Artifacts) { Start-ThreadJob ` -ScriptBlock $Worker ` - -ArgumentList @($AccessToken, $a.name, $a.resource.downloadUrl, $ArchivesTargetDir, $NupkgsTargetDir, $MaxDownloadAttempts, $RetryBaseDelaySeconds) ` + -ArgumentList @($AccessToken, $a.name, $a.resource.downloadUrl, $ArchivesTargetDir, $NupkgsTargetDir, $DashboardArtifactsTargetDir, $MaxDownloadAttempts, $RetryBaseDelaySeconds) ` -ThrottleLimit $ThrottleLimit ` -Name $a.name }) @@ -412,8 +445,8 @@ function Invoke-ParallelDownloads { foreach ($job in $jobs) { try { $result = $job | Receive-Job -ErrorAction Stop - Write-Host (" {0,-32} download={1,7:N0}ms total={2,7:N0}ms archives={3} nupkgs={4}" ` - -f $result.ArtifactName, $result.DownloadMs, $result.TotalMs, $result.Archives, $result.Nupkgs) + Write-Host (" {0,-32} download={1,7:N0}ms total={2,7:N0}ms archives={3} nupkgs={4} dashboardArchives={5}" ` + -f $result.ArtifactName, $result.DownloadMs, $result.TotalMs, $result.Archives, $result.Nupkgs, $result.DashboardArchives) } catch { $failures += [pscustomobject]@{ Name = $job.Name; Error = $_ } @@ -433,7 +466,7 @@ function Invoke-ParallelDownloads { # --- Main --- -New-Item -ItemType Directory -Force -Path $ArchivesTargetDir, $NupkgsTargetDir | Out-Null +New-Item -ItemType Directory -Force -Path $ArchivesTargetDir, $NupkgsTargetDir, $DashboardArtifactsTargetDir | Out-Null $artifacts = Get-MatchingArtifacts ` -CollectionUri $CollectionUri ` @@ -448,6 +481,7 @@ Invoke-ParallelDownloads ` -AccessToken $AccessToken ` -ArchivesTargetDir $ArchivesTargetDir ` -NupkgsTargetDir $NupkgsTargetDir ` + -DashboardArtifactsTargetDir $DashboardArtifactsTargetDir ` -Worker $DownloadOneArtifact ` -ThrottleLimit $ThrottleLimit ` -MaxDownloadAttempts $MaxDownloadAttempts ` diff --git a/eng/scripts/test-native-dashboard.ps1 b/eng/scripts/test-native-dashboard.ps1 new file mode 100644 index 00000000000..b53382e7d66 --- /dev/null +++ b/eng/scripts/test-native-dashboard.ps1 @@ -0,0 +1,78 @@ +param( + [Parameter(Mandatory)] + [string]$DashboardPath +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $DashboardPath -PathType Leaf)) { + throw "Native AOT Dashboard executable was not found at '$DashboardPath'." +} + +$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) +$listener.Start() +$port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port +$listener.Stop() + +$dashboardUrl = "http://127.0.0.1:$port" +$testDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-dashboard-smoke-$([guid]::NewGuid().ToString('N'))" +$null = New-Item -ItemType Directory -Path $testDirectory +$stdoutPath = Join-Path $testDirectory 'stdout.log' +$stderrPath = Join-Path $testDirectory 'stderr.log' +$dashboardProcess = $null + +try { + $dashboardProcess = Start-Process ` + -FilePath $DashboardPath ` + -ArgumentList @( + "--ASPNETCORE_URLS=$dashboardUrl", + '--ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true' + ) ` + -WorkingDirectory (Split-Path -Parent $DashboardPath) ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru + + $deadline = [DateTime]::UtcNow.AddMinutes(1) + while ([DateTime]::UtcNow -lt $deadline) { + if ($dashboardProcess.HasExited) { + throw "Native AOT Dashboard exited before becoming ready with exit code $($dashboardProcess.ExitCode)." + } + + try { + $assetResponse = Invoke-WebRequest -Uri "$dashboardUrl/_framework/blazor.web.js" -TimeoutSec 5 -UseBasicParsing + if ($assetResponse.StatusCode -eq 200 -and + $assetResponse.RawContentLength -gt 0) { + Write-Host "Native AOT Dashboard smoke test passed at $dashboardUrl." + return + } + } + catch { + # The server can accept TCP connections before static assets are ready. + } + + [System.Threading.Thread]::Sleep(500) + } + + throw 'Timed out waiting for the Native AOT Dashboard HTTP endpoints.' +} +catch { + $failure = $_ + [Console]::Error.WriteLine($failure) + if (Test-Path -LiteralPath $stdoutPath) { + Write-Host 'Dashboard stdout:' + Get-Content -LiteralPath $stdoutPath + } + if (Test-Path -LiteralPath $stderrPath) { + Write-Host 'Dashboard stderr:' + Get-Content -LiteralPath $stderrPath + } + throw $failure +} +finally { + if ($dashboardProcess -and -not $dashboardProcess.HasExited) { + Stop-Process -Id $dashboardProcess.Id -Force + $dashboardProcess.WaitForExit() + } + Remove-Item -LiteralPath $testDirectory -Recurse -Force -ErrorAction SilentlyContinue +} \ No newline at end of file diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index 5c266c5a85d..4008924df47 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -1,5 +1,21 @@ # Aspire VS Code Extension Changelog +## v1.18.0 + + + +### Features + +- Add a Java hosting integration, including Spring Boot and Quarkus app support, container publishing, and VS Code debugger wiring ([#18033](https://github.com/microsoft/aspire/pull/18033)). +- Add a Rust hosting package with VS Code debugger wiring ([#18906](https://github.com/microsoft/aspire/pull/18906)). + +### Fixes + +- Remember the selected AppHost folder correctly in multi-root workspaces ([#19342](https://github.com/microsoft/aspire/issues/19342), [#19359](https://github.com/microsoft/aspire/pull/19359)). +- Honor the AppHost launch profile selected in `launch.json` instead of falling back to the default profile's environment and URL ([#19387](https://github.com/microsoft/aspire/issues/19387), [#19400](https://github.com/microsoft/aspire/pull/19400)). +- Scope AppHost start/stop to the current git worktree and fix AppHost launch arguments being dropped or flattened when forwarded through VS Code ([#19357](https://github.com/microsoft/aspire/issues/19357), [#19384](https://github.com/microsoft/aspire/pull/19384)). +- Harden Aspire Skills bundle integrity checks by switching to SHA-512 and hide the remote-fetch preview toggle from user-facing settings ([#19303](https://github.com/microsoft/aspire/pull/19303)). + ## v1.17.0 @@ -16,6 +32,7 @@ ### Fixes +- Remember the selected AppHost folder when debugging multi-root workspaces ([#19342](https://github.com/microsoft/aspire/issues/19342)). - Make C# Dev Kit Hot Reload discoverable while debugging ([#19067](https://github.com/microsoft/aspire/pull/19067)). - Keep launch-configuration AppHost targets out of the workspace default list ([#19126](https://github.com/microsoft/aspire/pull/19126)). - Honor `ASPIRE_HOME` for deployment state ([#19244](https://github.com/microsoft/aspire/pull/19244)). diff --git a/extension/CONTRIBUTING.md b/extension/CONTRIBUTING.md index 1e25052b29e..9de36e50210 100644 --- a/extension/CONTRIBUTING.md +++ b/extension/CONTRIBUTING.md @@ -59,7 +59,15 @@ Run `build.ps1` (Windows) or `build.sh` (Mac/Linux) from the repository root to ### Optional: set the CLI path -If you want to effectively debug the Aspire CLI together with the Aspire VS Code extension, set the `Aspire Cli Executable Path` setting to the Aspire CLI output path. The raw local build output path, relative to the Aspire repository root directory, is `artifacts/bin/Aspire.Cli/Debug/net10.0/aspire`. Pointing at the raw build output makes the extension invoke your dev CLI, but that raw path is intentionally not forwarded as `AspireCliPath` (see Dogfooding below). To also dogfood bundle metadata, use an installed/bundled CLI layout instead. +To debug the Aspire CLI together with the extension, set `Aspire Cli Executable Path` to the CLI output path. You can use an absolute path or a workspace token. If you opened the `extension` folder, the local build is: + +```json +"aspire.aspireCliExecutablePath": "${workspaceFolder}/../artifacts/bin/Aspire.Cli/Debug/net10.0/aspire" +``` + +`${workspaceFolder}` uses the folder that owns the current operation. In a multi-root workspace, `${workspaceFolder:name}` selects the uniquely named folder. Plain relative paths are not resolved; use a workspace token when the path should move with the checkout. + +Pointing at the raw build output makes the extension invoke your dev CLI, but that path is intentionally not forwarded as `AspireCliPath` (see Dogfooding below). To also dogfood bundle metadata, use an installed/bundled CLI layout instead. You may also want to use the `Run Extension (cli stop on entry)` launch configuration, as `Run Extension` does not prevent the Aspire CLI from executing immediately. @@ -67,9 +75,9 @@ You can use the `Aspire: Extension settings` command to open VS Code settings di #### Dogfooding a CLI build alongside the extension -When `Aspire Cli Executable Path` points at an existing **absolute** path that is not a raw framework-dependent local CLI build output, the extension forwards that value as the `AspireCliPath` MSBuild property/environment variable to every terminal, task, and debug process it creates. The Aspire SDK's `ResolveAspireCliBundle` task uses `AspireCliPath` (defined in [`src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs`](/src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs)) to locate the matching bundle layout — DCP, dashboard, and terminal-host binaries — and bakes those paths into the built AppHost as `[AssemblyMetadata]` attributes. Without this forwarding, MSBuild probes `PATH` and can stamp the *stable* CLI's bundle into the AppHost while the extension is launching it through the *dev* CLI, producing surprising runtime mismatches such as `` even though the new CLI is correctly invoked (tracked in [issue #18073](https://github.com/microsoft/aspire/issues/18073)). +When `Aspire Cli Executable Path` resolves to an existing **absolute** path that is not a raw framework-dependent local CLI build output, the extension forwards that value as the `AspireCliPath` MSBuild property/environment variable to terminals, tasks, and debug processes in that workspace folder. The Aspire SDK's `ResolveAspireCliBundle` task uses `AspireCliPath` (defined in [`src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs`](/src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs)) to locate the matching bundle layout — DCP, dashboard, and terminal-host binaries — and bakes those paths into the built AppHost as `[AssemblyMetadata]` attributes. Without this forwarding, MSBuild probes `PATH` and can stamp the *stable* CLI's bundle into the AppHost while the extension is launching it through the *dev* CLI, producing surprising runtime mismatches such as `` even though the new CLI is correctly invoked (tracked in [issue #18073](https://github.com/microsoft/aspire/issues/18073)). -A relative value (for example the bare `aspire` literal) or an absolute path that no longer exists is intentionally not forwarded because `ResolveAspireCliBundle` stops with a warning for invalid explicit `AspireCliPath` values instead of probing `PATH`. A raw framework-dependent local build output such as `artifacts/bin/Aspire.Cli/Debug/net10.0/aspire` is also not forwarded because it can make `ResolveAspireCliBundle` fall back to unrelated `ASPIRE_HOME` metadata. Symlinks to that raw local build output are filtered the same way. To dogfood bundle metadata end-to-end, point the setting at an installed/bundled CLI layout with a sidecar or adjacent bundle assets. Clear the setting to revert to default PATH/ASPIRE_HOME resolution. +A tokenized setting is expanded to an absolute path before validation. A plain relative value (for example the bare `aspire` literal) or an absolute path that no longer exists is intentionally not forwarded because `ResolveAspireCliBundle` stops with a warning for invalid explicit `AspireCliPath` values instead of probing `PATH`. A raw framework-dependent local build output such as `artifacts/bin/Aspire.Cli/Debug/net10.0/aspire` is also not forwarded because it can make `ResolveAspireCliBundle` fall back to unrelated `ASPIRE_HOME` metadata. Symlinks to that raw local build output are filtered the same way. To dogfood bundle metadata end-to-end, point the setting at an installed/bundled CLI layout with a sidecar or adjacent bundle assets. Clear the setting to revert to default PATH/ASPIRE_HOME resolution. ## Running tests @@ -115,6 +123,7 @@ The suite can be sharded by running separate VS Code windows/processes, which is ASPIRE_EXTENSION_E2E_SHARD=command-palette ASPIRE_EXTENSION_E2E_SPEC=out/test-e2e/test-e2e/commandPalette.e2e.test.js ASPIRE_EXTENSION_E2E_CLI_PATH=/path/to/aspire corepack yarn test:e2e ASPIRE_EXTENSION_E2E_SHARD=settings-files ASPIRE_EXTENSION_E2E_SPEC=out/test-e2e/test-e2e/settingsFiles.e2e.test.js ASPIRE_EXTENSION_E2E_CLI_PATH=/path/to/aspire corepack yarn test:e2e ASPIRE_EXTENSION_E2E_SHARD=discovery-configuration ASPIRE_EXTENSION_E2E_SPEC=out/test-e2e/test-e2e/discoveryConfiguration.e2e.test.js ASPIRE_EXTENSION_E2E_CLI_PATH=/path/to/aspire corepack yarn test:e2e +ASPIRE_EXTENSION_E2E_SHARD=dynamic-debug-configuration ASPIRE_EXTENSION_E2E_SPEC=out/test-e2e/test-e2e/dynamicDebugConfiguration.e2e.test.js ASPIRE_EXTENSION_E2E_CLI_PATH=/path/to/aspire corepack yarn test:e2e ASPIRE_EXTENSION_E2E_SHARD=apphost-tree ASPIRE_EXTENSION_E2E_SPEC=out/test-e2e/test-e2e/appHostTree.e2e.test.js ASPIRE_EXTENSION_E2E_CLI_PATH=/path/to/aspire corepack yarn test:e2e ASPIRE_EXTENSION_E2E_SHARD=tree-actions ASPIRE_EXTENSION_E2E_SPEC=out/test-e2e/test-e2e/treeActions.e2e.test.js ASPIRE_EXTENSION_E2E_CLI_PATH=/path/to/aspire corepack yarn test:e2e ASPIRE_EXTENSION_E2E_SHARD=debug-dashboard ASPIRE_EXTENSION_E2E_SPEC=out/test-e2e/test-e2e/debugDashboard.e2e.test.js ASPIRE_EXTENSION_E2E_CLI_PATH=/path/to/aspire corepack yarn test:e2e @@ -135,7 +144,7 @@ ASPIRE_EXTENSION_E2E_SHARD=fast-subset ASPIRE_EXTENSION_E2E_SPEC='out/test-e2e/t ASPIRE_EXTENSION_E2E_SHARD=tree-subset ASPIRE_EXTENSION_E2E_SPEC='out/test-e2e/test-e2e/*Tree.e2e.test.js' ASPIRE_EXTENSION_E2E_CLI_PATH=/path/to/aspire corepack yarn test:e2e ``` -The current shards cover command palette and terminal routing, settings-file creation/opening with an isolated Aspire home, workspace AppHost discovery/configuration changes, AppHost run/stop/resource rendering, tree action commands for copy/open/log/resource operations, debug/dashboard lifecycle, Azure Functions HTTPS startup and termination through the real Functions extension, a zero-to-running flow that routes the Aspire new/add terminal commands, creates a C# AppHost through the CLI, adds an integration package, registers a source breakpoint, and debugs the generated AppHost, the `package.json` contribution surface including exact activation events, command registration, menu/view/settings inventory, JSON validation, walkthrough command registration, CodeLens routing, and debug launch command routing, plus negative-path edge cases for invalid control payloads, missing tree targets, CLI-independent settings commands, and launch-state cleanup. +The current shards cover command palette and terminal routing, settings-file creation/opening with an isolated Aspire home, workspace AppHost discovery/configuration changes, remembered multi-root dynamic debug configuration selection, AppHost run/stop/resource rendering, tree action commands for copy/open/log/resource operations, debug/dashboard lifecycle, Azure Functions HTTPS startup and termination through the real Functions extension, a zero-to-running flow that routes the Aspire new/add terminal commands, creates a C# AppHost through the CLI, adds an integration package, registers a source breakpoint, and debugs the generated AppHost, the `package.json` contribution surface including exact activation events, command registration, menu/view/settings inventory, JSON validation, walkthrough command registration, CodeLens routing, and debug launch command routing, plus negative-path edge cases for invalid control payloads, missing tree targets, CLI-independent settings commands, and launch-state cleanup. Diagnostics are left under `extension/.test-results`, `extension/.test-storage`, and `extension/.test-workspaces`, with shard-specific subdirectories when `ASPIRE_EXTENSION_E2E_SHARD` is set. The runner also sets `ASPIRE_HOME` to an isolated per-run directory and copies it into diagnostics before cleanup so settings-file failures do not touch or depend on the real user profile. These folders are ignored by git and are uploaded by CI when the E2E job runs. diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 6cdfaed0474..d00aa1be9af 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -25,6 +25,9 @@ AppHosts + + Arguments containing %NAME% cannot be forwarded through a Windows .cmd or .bat shim, because cmd.exe expands them before the Aspire CLI receives the value. Configure aspire.aspireCliExecutablePath to point at aspire.exe instead of the .cmd shim and try again. + Aspire @@ -82,6 +85,9 @@ Aspire: Launch default AppHost + + Aspire: Launch default AppHost ({0}: {1}) + Attempted to start unsupported resource type: {0}. @@ -391,6 +397,18 @@ Invalid token provided. + + Java + + + Java AppHosts are launched through the Java debugger extension. Install {0} from the Extensions view, then start the AppHost again. + + + Java resources are started by the IDE rather than attached to, and the app host requested an attach session. Aspire cannot start one because no debug address is available. + + + Java: {0} + Latest build from main via the install script @@ -511,6 +529,9 @@ Open Aspire terminal + + Open Setting + Open Settings @@ -566,7 +587,7 @@ Polling interval in milliseconds for fetching running AppHosts. Minimum: 1000. - Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Start an Aspire AppHost that Aspire has already discovered in the current workspace, using the editor's own debug lifecycle. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. Also requires whether to start it in 'run' mode (no debugger attached) or 'debug' mode (debugger attached). Does not create, pick, or guess an AppHost: if the path does not name a discovered AppHost, or names more than one, the call fails and the result lists the AppHosts you can pass. If the AppHost is already starting or already running, no second process is started. + Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Start an Aspire AppHost that Aspire has already discovered in the current workspace, using the editor's own debug lifecycle. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. Also requires whether to start it in 'run' mode (no debugger attached) or 'debug' mode (debugger attached). Optional 'isolated' starts the AppHost with randomized ports and isolated user secrets; when omitted, linked git worktrees start isolated automatically so they do not collide with the primary checkout. Explicit true or false overrides that inference. Does not create, pick, or guess an AppHost: if the path does not name a discovered AppHost, or names more than one, the call fails and the result lists the AppHosts you can pass. If the AppHost is already starting or already running, no second process is started. A successful new launch includes the verified effective 'isolated' value; idempotent or uncertain results omit it. Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Stop a running Aspire AppHost that Aspire has already discovered in the current workspace. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. AppHosts started by this editor stop through the coordinated debug lifecycle. AppHosts started outside the editor stop through 'aspire stop --apphost' for the same discovered path. The extension never kills arbitrary processes. If it cannot determine whether the AppHost is running, the call fails rather than reporting that nothing is running. @@ -673,6 +694,9 @@ Select the default AppHost to launch when starting an Aspire debug session + + Select the workspace folder for the Aspire command + Select what should happen with the Aspire Dashboard when this launch configuration starts @@ -712,6 +736,9 @@ Start an Aspire AppHost from this workspace in run or debug mode. + + Start the Aspire AppHost {0} in {1} mode with isolation? + Start the Aspire AppHost {0} in {1} mode? @@ -781,9 +808,15 @@ The Aspire panel workspace view requires Aspire CLI 13.2.0 or newer and an AppHost that references Aspire.Hosting 13.2.0 or newer. Install or update the Aspire CLI and update your AppHost package to get started. [Update Aspire CLI](command:aspire-vscode.updateSelf) [Refresh](command:aspire-vscode.globalRefreshAppHosts) + + The Java AppHost launch command was not recognized and cannot be debugged. + The command completed, but its output could not be opened: {0} + + The configured Aspire CLI path could not be used, so a different Aspire CLI is running instead: {0}. Check that aspire.aspireCliExecutablePath points to the aspire executable itself and that it runs. + The configured VS Code task shell is not supported for Azure Functions launch arguments. Configure terminal.integrated.automationProfile to use PowerShell, Command Prompt, bash, zsh, fish, or WSL. @@ -797,11 +830,17 @@ The default launch profile '{0}' is an Executable profile, so dotnet run-api does not return the file-based app {1}; launching it with dotnet run without debugger attach. Breakpoints will not be hit for this resource. - The path to the Aspire CLI executable. If not set, the extension will attempt to use 'aspire' from the system PATH. + The path to the Aspire CLI executable. Use '${workspaceFolder}' for the operation's workspace folder or '${workspaceFolder:name}' for a named folder. If not set, the extension will attempt to use 'aspire' from the system PATH. The pipeline step name to execute when command is 'do' + + The selected Aspire CLI does not support the requested isolation mode. + + + The selected Aspire CLI isolation capability could not be verified. + This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs. @@ -847,6 +886,15 @@ Use "{0}" + + Use Aspire Run or Debug instead. The Java extension runs this file directly, so the AppHost starts with no Aspire session: none of its resources are launched, and the dashboard never appears. + + + Use Aspire Run or Debug instead. The Spring Boot Dashboard starts the app on its own, so it receives none of the service discovery, connection string, or OpenTelemetry settings Aspire injects. + + + Use Aspire controls instead. The Java extension starts another java process outside the running Aspire session. + Use the configured dashboard browser behavior, falling back to VS Code's integrated browser for compatibility. @@ -886,6 +934,9 @@ Welcome to Aspire + + When true, start with randomized ports and isolated user secrets. When false, do not isolate. When omitted, linked git worktrees start isolated automatically. + Whether to register the Aspire MCP server when a workspace is open. @@ -967,5 +1018,14 @@ {0}: {1} + + ⚠️ Do not click the Java Run or Debug actions; they bypass Aspire + + + ⚠️ Do not click the Java Run or Debug actions; this AppHost is already running in Aspire + + + ⚠️ Do not start this app from the Spring Boot Dashboard; it bypasses Aspire + \ No newline at end of file diff --git a/extension/package.json b/extension/package.json index 900c72c2789..bc53881928f 100644 --- a/extension/package.json +++ b/extension/package.json @@ -3,7 +3,7 @@ "displayName": "Aspire", "description": "%extension.description%", "publisher": "microsoft-aspire", - "version": "1.17.0", + "version": "1.18.0", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "icon": "aspire-logo-128.png", "license": "SEE LICENSE IN LICENSE.TXT", @@ -49,6 +49,9 @@ "workspaceContains:**/apphost.mjs", "workspaceContains:**/apphost.cjs", "workspaceContains:**/apphost.rs", + "workspaceContains:**/AppHost.java", + "workspaceContains:**/apphost.java", + "workspaceContains:**/src/main/java/AppHost.java", "onCommand:aspire-vscode.installCli", "onCommand:aspire-vscode.verifyCliInstalled", "onLanguageModelTool:aspire_apphost_start", @@ -85,6 +88,10 @@ "debug" ], "description": "%languageModelTool.aspireAppHostStart.mode.description%" + }, + "isolated": { + "type": "boolean", + "description": "%languageModelTool.aspireAppHostStart.isolated.description%" } }, "required": [ @@ -974,7 +981,8 @@ "type": "boolean", "default": false, "description": "%configuration.aspire.registerMcpServerInWorkspace%", - "scope": "window" + "scope": "window", + "restricted": true }, "aspire.appHostsPollingInterval": { "type": "number", @@ -1158,6 +1166,7 @@ "jsonc-parser": "3.3.1", "node-forge": "1.4.0", "tree-sitter-c-sharp": "0.23.5", + "tree-sitter-java": "0.23.5", "tree-sitter-rust": "0.24.0", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.26.9", diff --git a/extension/package.nls.json b/extension/package.nls.json index 4abbefb1bec..6f02d9c1bfc 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -25,7 +25,7 @@ "command.openLocalSettings": "Open local Aspire settings", "command.openGlobalSettings": "Open global Aspire settings", "configuration.aspire.enableSettingsFileCreationPromptOnStartup": "Enable AppHost discovery on extension activation and prompt to setup .aspire/settings.json.appHostPath if it does not exist in the workspace.", - "configuration.aspire.aspireCliExecutablePath": "The path to the Aspire CLI executable. If not set, the extension will attempt to use 'aspire' from the system PATH.", + "configuration.aspire.aspireCliExecutablePath": "The path to the Aspire CLI executable. Use '${workspaceFolder}' for the operation's workspace folder or '${workspaceFolder:name}' for a named folder. If not set, the extension will attempt to use 'aspire' from the system PATH.", "configuration.aspire.enableAspireCliDebugLogging": "Enable console debug logging for Aspire CLI commands executed by the extension.", "configuration.aspire.enableAspireDcpDebugLogging": "Enable Developer Control Plane (DCP) debug logging for Aspire applications. Logs will be stored in the workspace's .aspire/dcp/logs-{debugSessionId} folder.", "configuration.aspire.enableAspireDashboardAutoLaunch": "Legacy setting for Aspire Dashboard launch behavior. Use Aspire: Dashboard Browser instead. Explicit Aspire: Dashboard Browser none or notification values win. Otherwise, legacy notification or off values override browser-opening choices; launch uses Aspire: Dashboard Browser, falling back to VS Code's integrated browser for compatibility.", @@ -70,6 +70,8 @@ "aspire-vscode.strings.requiredCapability": "Required capability: {0}.", "aspire-vscode.strings.aspireTerminalName": "Aspire terminal", "aspire-vscode.strings.aspireCliPathEnvironmentDescription": "Forwards aspire.aspireCliExecutablePath as AspireCliPath so MSBuild bundle resolution and integrated terminals use the configured Aspire CLI.", + "aspire-vscode.strings.configuredCliPathRejected": "The configured Aspire CLI path could not be used, so a different Aspire CLI is running instead: {0}. Check that aspire.aspireCliExecutablePath points to the aspire executable itself and that it runs.", + "aspire-vscode.strings.configuredCliPathRejectedOpenSetting": "Open Setting", "aspire-vscode.strings.installCliPlaceholder": "Select how to install the Aspire CLI", "aspire-vscode.strings.installCliViewAllOptions": "View all installation options", "aspire-vscode.strings.installCliViewAllOptionsDescription": "Open the installation guide (includes the install script)", @@ -77,6 +79,7 @@ "aspire-vscode.strings.installCliDailyBuildDescription": "Latest build from main via the install script", "aspire-vscode.strings.terminalCommandArgumentControlCharacters": "Aspire terminal command arguments cannot contain control characters.", "aspire-vscode.strings.terminalCommandUnsafeLiteral": "Aspire terminal command syntax can only contain command names and flags.", + "aspire-vscode.strings.cmdShimPercentArgument": "Arguments containing %NAME% cannot be forwarded through a Windows .cmd or .bat shim, because cmd.exe expands them before the Aspire CLI receives the value. Configure aspire.aspireCliExecutablePath to point at aspire.exe instead of the .cmd shim and try again.", "aspire-vscode.strings.azureFunctionsUnsupportedTaskShell": "The configured VS Code task shell is not supported for Azure Functions launch arguments. Configure terminal.integrated.automationProfile to use PowerShell, Command Prompt, bash, zsh, fish, or WSL.", "aspire-vscode.strings.azureFunctionsCmdPercentArgument": "Azure Functions launch arguments containing \"%\" are not supported by cmd.exe. Configure terminal.integrated.automationProfile.windows to use PowerShell and try again.", "aspire-vscode.strings.azureFunctionsCmdDelayedExpansion": "Azure Functions launch arguments containing \"!\" are not supported by cmd.exe because delayed environment variable expansion may change them. Configure terminal.integrated.automationProfile.windows to use PowerShell and try again.", @@ -100,6 +103,7 @@ "aspire-vscode.strings.errorRetrievingAppHosts": "Error retrieving AppHosts in the current workspace. Debug options may be incomplete.", "aspire-vscode.strings.launchingWithDirectory": "Launching Aspire debug session using directory {0}: attempting to determine effective AppHost...", "aspire-vscode.strings.launchingWithAppHost": "Launching Aspire debug session for AppHost {0}...", + "aspire-vscode.strings.defaultConfigurationNameForWorkspaceFolder": "Aspire: Launch default AppHost ({0}: {1})", "aspire-vscode.strings.launchingRunWithDirectory": "Launching Aspire run session using directory {0}: attempting to determine effective AppHost...", "aspire-vscode.strings.launchingRunWithAppHost": "Launching Aspire run session for AppHost {0}...", "aspire-vscode.strings.disconnectingFromSession": "Disconnecting from Aspire debug session... Child processes will be stopped.", @@ -114,6 +118,7 @@ "aspire-vscode.strings.debugSessionsFailedToStop": "{0} debug sessions failed to stop: {1}", "aspire-vscode.strings.invalidTokenProvided": "Invalid token provided.", "aspire-vscode.strings.noWorkspaceFolder": "No workspace folder found.", + "aspire-vscode.strings.selectWorkspaceFolderForAspireCommand": "Select the workspace folder for the Aspire command", "aspire-vscode.strings.aspireConfigExists": "Aspire launch configuration already exists in launch.json.", "aspire-vscode.strings.failedToConfigureLaunchJson": "Failed to configure launch.json: {0}.", "aspire-vscode.strings.debugSessionAlreadyExists": "A debug session is already active for id {0}.", @@ -301,21 +306,36 @@ "aspire-vscode.strings.rustBuildStderrTruncated": "[cargo stderr truncated to the last {0} characters.]", "aspire-vscode.strings.rustLaunchConfigurationMissingExecutable": "The Aspire app host did not report which executable the Rust app in {0} produces, so there is nothing to debug.", "aspire-vscode.strings.rustWindowsGnuDebuggerUnsupported": "The cppvsdbg debugger cannot debug Rust target {0} because GNU Windows targets use DWARF symbols. Install CodeLLDB or build an MSVC Rust target such as x86_64-pc-windows-msvc.", + "aspire-vscode.strings.javaAppHostCommandNotRecognized": "The Java AppHost launch command was not recognized and cannot be debugged.", + "aspire-vscode.strings.javaAttachNotSupported": "Java resources are started by the IDE rather than attached to, and the app host requested an attach session. Aspire cannot start one because no debug address is available.", + "aspire-vscode.strings.javaDebuggerExtensionNotInstalled": "Java AppHosts are launched through the Java debugger extension. Install {0} from the Extensions view, then start the AppHost again.", "aspire-vscode.strings.rustDebuggerExtensionNotInstalled": "Rust AppHosts are launched through a native debugger extension. Install {0} from the Extensions view, then start the AppHost again.", + "aspire-vscode.strings.javaDisplayName": "Java: {0}", + "aspire-vscode.strings.javaLabel": "Java", + "aspire-vscode.strings.codeLensSpringBootDashboardBypassesAspire": "⚠️ Do not start this app from the Spring Boot Dashboard; it bypasses Aspire", + "aspire-vscode.strings.codeLensSpringBootDashboardBypassesAspireTooltip": "Use Aspire Run or Debug instead. The Spring Boot Dashboard starts the app on its own, so it receives none of the service discovery, connection string, or OpenTelemetry settings Aspire injects.", + "aspire-vscode.strings.codeLensJavaAppHostAlreadyRunning": "⚠️ Do not click the Java Run or Debug actions; this AppHost is already running in Aspire", + "aspire-vscode.strings.codeLensJavaAppHostAlreadyRunningTooltip": "Use Aspire controls instead. The Java extension starts another java process outside the running Aspire session.", + "aspire-vscode.strings.codeLensJavaAppHostUseAspire": "⚠️ Do not click the Java Run or Debug actions; they bypass Aspire", + "aspire-vscode.strings.codeLensJavaAppHostUseAspireTooltip": "Use Aspire Run or Debug instead. The Java extension runs this file directly, so the AppHost starts with no Aspire session: none of its resources are launched, and the dashboard never appears.", "aspire-vscode.strings.appHostLifecycleStartConfirmationTitle": "Start Aspire AppHost", "aspire-vscode.strings.appHostLifecycleStopConfirmationTitle": "Stop Aspire AppHost", "aspire-vscode.strings.appHostLifecycleStartConfirmationMessage": "Start the Aspire AppHost {0} in {1} mode?", + "aspire-vscode.strings.appHostLifecycleStartConfirmationMessageIsolated": "Start the Aspire AppHost {0} in {1} mode with isolation?", "aspire-vscode.strings.appHostLifecycleStopConfirmationMessage": "Stop the Aspire AppHost {0}?", "aspire-vscode.strings.appHostLifecycleStartInvocationMessage": "Starting Aspire AppHost {0}...", "aspire-vscode.strings.appHostLifecycleStopInvocationMessage": "Stopping Aspire AppHost {0}...", "aspire-vscode.strings.appHostLifecycleUnspecifiedMode": "unspecified", "aspire-vscode.strings.appHostLifecycleUnresolvedPath": "an unresolved path", "aspire-vscode.strings.appHostLifecycleBusy": "Another start or stop operation for this Aspire AppHost is still in progress. Wait for it to finish and try again.", + "aspire-vscode.strings.appHostLifecycleIsolationModeNotSupported": "The selected Aspire CLI does not support the requested isolation mode.", + "aspire-vscode.strings.appHostLifecycleIsolationCapabilityCouldNotBeVerified": "The selected Aspire CLI isolation capability could not be verified.", "aspire-vscode.strings.appHostLifecycleLaunchAlreadyClaimed": "This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs.", "languageModelTool.aspireAppHostStart.displayName": "Start Aspire AppHost", - "languageModelTool.aspireAppHostStart.modelDescription": "Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Start an Aspire AppHost that Aspire has already discovered in the current workspace, using the editor's own debug lifecycle. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. Also requires whether to start it in 'run' mode (no debugger attached) or 'debug' mode (debugger attached). Does not create, pick, or guess an AppHost: if the path does not name a discovered AppHost, or names more than one, the call fails and the result lists the AppHosts you can pass. If the AppHost is already starting or already running, no second process is started.", + "languageModelTool.aspireAppHostStart.modelDescription": "Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Start an Aspire AppHost that Aspire has already discovered in the current workspace, using the editor's own debug lifecycle. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. Also requires whether to start it in 'run' mode (no debugger attached) or 'debug' mode (debugger attached). Optional 'isolated' starts the AppHost with randomized ports and isolated user secrets; when omitted, linked git worktrees start isolated automatically so they do not collide with the primary checkout. Explicit true or false overrides that inference. Does not create, pick, or guess an AppHost: if the path does not name a discovered AppHost, or names more than one, the call fails and the result lists the AppHosts you can pass. If the AppHost is already starting or already running, no second process is started. A successful new launch includes the verified effective 'isolated' value; idempotent or uncertain results omit it.", "languageModelTool.aspireAppHostStart.userDescription": "Start an Aspire AppHost from this workspace in run or debug mode.", "languageModelTool.aspireAppHostStart.mode.description": "How to start the AppHost: 'run' starts it without attaching the debugger, 'debug' starts it with the debugger attached.", + "languageModelTool.aspireAppHostStart.isolated.description": "When true, start with randomized ports and isolated user secrets. When false, do not isolate. When omitted, linked git worktrees start isolated automatically.", "languageModelTool.aspireAppHostStop.displayName": "Stop Aspire AppHost", "languageModelTool.aspireAppHostStop.modelDescription": "Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Stop a running Aspire AppHost that Aspire has already discovered in the current workspace. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. AppHosts started by this editor stop through the coordinated debug lifecycle. AppHosts started outside the editor stop through 'aspire stop --apphost' for the same discovered path. The extension never kills arbitrary processes. If it cannot determine whether the AppHost is running, the call fails rather than reporting that nothing is running.", "languageModelTool.aspireAppHostStop.userDescription": "Stop a running Aspire AppHost from this workspace.", diff --git a/extension/scripts/e2e-download-cache.js b/extension/scripts/e2e-download-cache.js index 0e2da5bac70..6d04cedfc5b 100644 --- a/extension/scripts/e2e-download-cache.js +++ b/extension/scripts/e2e-download-cache.js @@ -32,6 +32,12 @@ const UNREADABLE_DIRECTORY_ERROR_CODES = new Set(['ENOENT', 'ENOTDIR', 'ELOOP', const MAX_PUBLISH_ATTEMPTS = 8; const MAX_CANDIDATE_CREATE_ATTEMPTS = 3; +// A late writer racing cleanup settles within a few hundred milliseconds once its process is gone, +// so a handful of sweeps covers it while still failing fast against a process that never stops +// writing. See removeDirectoryContentsAndSelf for why re-sweeping is what clears ENOTEMPTY. +const MAX_DIRECTORY_SWEEP_RETRIES = 5; +const DIRECTORY_SWEEP_RETRY_DELAY_MS = 100; + // Candidates only exist while a download is in flight, so a non-generation group child this old is // debris left by a crash or by an older cache layout. Published generations are never swept on // age; see sweepAbandonedGroupChildren. @@ -549,11 +555,51 @@ function removePathWithoutFollowingLinks(targetPath, options = {}) { return; } - for (const entry of fs.readdirSync(targetPath)) { - removePathWithoutFollowingLinks(path.join(targetPath, entry), options); - } + removeDirectoryContentsAndSelf(targetPath, options); +} + +/** + * Empties a directory and removes it, re-listing it when `rmdir` reports it is not empty. + * + * A directory can gain an entry between the listing and the `rmdir`: the Java language server and + * Maven/Gradle daemons keep writing for a moment after a test finishes, so a tree that was walked + * empty is not empty by the time it is removed. Observed in CI as a shard that passed its tests and + * then failed in cleanup with `ENOTEMPTY: directory not empty, rmdir`. + * + * Retrying the `rmdir` alone cannot clear that, because the entry that appeared is still on disk - + * only a fresh listing can remove it - which is why this re-sweeps instead of leaning on the + * `maxRetries` budget. That budget stays useful for its own case: on Windows a removed entry can + * linger until the last handle closes, and there retrying the same `rmdir` does eventually succeed. + */ +function removeDirectoryContentsAndSelf(directoryPath, options) { + for (let sweep = 0; ; sweep++) { + let entries; + try { + entries = fs.readdirSync(directoryPath); + } catch (error) { + if (error && error.code === 'ENOENT') { + return; + } - removeEmptyDirectory(targetPath, options); + throw error; + } + + for (const entry of entries) { + removePathWithoutFollowingLinks(path.join(directoryPath, entry), options); + } + + try { + removeEmptyDirectory(directoryPath, options); + return; + } catch (error) { + // Bounded so a process writing continuously fails the cleanup instead of hanging the shard. + if (!error || error.code !== 'ENOTEMPTY' || sweep >= MAX_DIRECTORY_SWEEP_RETRIES) { + throw error; + } + + sleepSync((sweep + 1) * DIRECTORY_SWEEP_RETRY_DELAY_MS); + } + } } function removeEmptyDirectory(directoryPath, options) { diff --git a/extension/scripts/run-e2e.js b/extension/scripts/run-e2e.js index 53997f1b58d..793ef8a9942 100644 --- a/extension/scripts/run-e2e.js +++ b/extension/scripts/run-e2e.js @@ -36,6 +36,23 @@ const requestedTempRoot = verifyExtesterFeedOnly ? '' : process.env.ASPIRE_EXTEN // why `prepareRunDirectories` is a function rather than a module-scope block. const testSpec = process.env.ASPIRE_EXTENSION_E2E_SPEC || 'out/test-e2e/**/*.e2e.test.js'; const matchedTestSpecs = verifyExtesterFeedOnly ? [] : findSpecMatches(testSpec); +// Java specs need the Java language server and the Java debug adapter (neither of which Aspire +// ships) and a Java workspace in place of the scaffolded C# one, so they cannot share a run with +// the other specs. Which specs are running is the whole requirement, so it is read off them rather +// than from an opt-in variable: as an opt-in, both Java shards ran in CI with nothing setting it, +// skipped every test, and reported green. +// +// ASPIRE_EXTENSION_E2E_ENABLE_JAVA remains as an explicit override for running a Java spec through +// a glob, or for forcing the Java workspace off while debugging the runner. +const enableJavaE2E = process.env.ASPIRE_EXTENSION_E2E_ENABLE_JAVA + ? process.env.ASPIRE_EXTENSION_E2E_ENABLE_JAVA === 'true' + : matchedTestSpecs.length > 0 && matchedTestSpecs.every(isJavaSpecPath); +// redhat.java supplies the language server, which is what produces workspace diagnostics and the +// classpath the debug adapter launches against. vscjava.vscode-java-debug supplies the `java` debug +// adapter the Aspire debugger delegates to, and vscjava.vscode-java-dependency is a hard activation +// dependency of it. capabilities.ts only advertises the `java` capability - which is what makes the +// CLI hand the AppHost launch back to the extension - when the first two are both installed. +const REQUIRED_JAVA_EXTENSION_IDS = ['redhat.java', 'vscjava.vscode-java-debug', 'vscjava.vscode-java-dependency']; const extesterVersion = extensionPackageJson.devDependencies?.['vscode-extension-tester']; if (!extesterVersion) { throw new Error('vscode-extension-tester must be pinned in extension/package.json devDependencies.'); @@ -57,9 +74,23 @@ const shortRunRoot = verifyExtesterFeedOnly ? '' : fs.mkdtempSync(path.join(temp const isolatedAspireHome = path.join(shortRunRoot, 'aspire-home'); const storageDir = path.join(shortRunRoot, 'storage'); const extensionsDir = path.join(shortRunRoot, 'extensions'); +// A single-file Java AppHost is restored and launched by the CLI's AppHost server. In a dev build +// that server resolves Aspire packages through the repository's own NuGet configuration, so a +// workspace under the OS temp directory fails with "No code generator found for language: Java" +// (and, with an isolated ASPIRE_HOME, "No Aspire AppHost server is available"). The Java workspace +// therefore lives inside the repository. +// +// It deliberately is not one of the gitignored scratch directories: `aspire ls` skips ignored +// paths, so an AppHost under `.test-workspaces/` is discovered as zero candidates. It also sits +// under `extension/` rather than the repository root, because an AppHost at the root fails code +// generation with "No code generator found for language: Java". The runner deletes this directory +// at the start and end of every run. +const javaScratchWorkspaceRoot = path.join(extensionRoot, 'java-e2e-workspace'); const workspaceRoot = process.env.ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT ? path.resolve(process.env.ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT) - : path.join(shortRunRoot, 'workspace'); + : enableJavaE2E + ? javaScratchWorkspaceRoot + : path.join(shortRunRoot, 'workspace'); const workspaceMarkerFile = path.join(workspaceRoot, '.aspire-extension-e2e-workspace'); const storageDiagnosticsDir = path.join(diagnosticsStorageRoot, shardName, runId); const workspaceDiagnosticsDir = path.join(extensionRoot, '.test-workspaces', shardName, runId); @@ -275,11 +306,23 @@ function logE2eConfiguration() { console.log(` download cache: ${downloadCacheRoot}`); console.log(` current CLI regressions: ${process.env.ASPIRE_EXTENSION_E2E_SKIP_CURRENT_CLI_REGRESSIONS === 'true' ? 'skipped' : 'included'}`); console.log(` Azure Functions: ${enableAzureFunctionsE2E ? 'enabled' : 'disabled'}`); + console.log(` Java: ${enableJavaE2E ? 'enabled' : 'disabled'}`); console.log(` results: ${path.relative(extensionRoot, resultsDir)}`); console.log(` storage diagnostics: ${path.relative(extensionRoot, storageDiagnosticsDir)}`); console.log(` workspace diagnostics: ${path.relative(extensionRoot, workspaceDiagnosticsDir)}`); } +/** + * Reports whether a compiled spec file belongs to the Java suites. + * + * Naming is the contract: every Java spec is `java*.e2e.test.js`, which is also what the workflow's + * `java-*` shards point at. Matching on the file name rather than a hard-coded list means a new + * Java spec is picked up by adding the shard, with nothing else to remember. + */ +function isJavaSpecPath(specPath) { + return path.basename(specPath).toLowerCase().startsWith('java'); +} + function logStep(name) { console.log(`\n--- ${name} ---`); } @@ -546,11 +589,17 @@ async function main() { prepareRunDirectories(); logE2eConfiguration(); - const cliPath = isolateCliPath(resolveCliPath()); + const bundledCliPath = resolveCliPath(); + // A dev-build CLI resolves its AppHost server and code generators relative to its own location, + // so a copy under the temp root cannot run a single-file Java AppHost - it fails with "No code + // generator found for language: Java". Java runs therefore use the CLI in place. Nothing in the + // Java spec writes to the CLI directory, and cleanup only ever invokes `aspire stop`. + const cliPath = enableJavaE2E ? bundledCliPath : isolateCliPath(bundledCliPath); cliPathForCleanup = cliPath; validateCliPath(cliPath); const appHostSdkVersion = resolveAppHostSdkVersion(cliPath); prepareWorkspaceFixture(cliPath, appHostSdkVersion); + copyJavaPlaygroundIntoWorkspace(bundledCliPath); restoreWorkspaceFixture(); const vsixPath = process.env.ASPIRE_EXTENSION_E2E_VSIX ? path.resolve(process.env.ASPIRE_EXTENSION_E2E_VSIX) @@ -578,6 +627,9 @@ async function main() { ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT: workspaceRoot, ASPIRE_EXTENSION_E2E_STATE_FILE: stateFile, ASPIRE_EXTENSION_E2E_CONTROL_FILE: controlFile, + // The state and control files live at a stable per-shard path, so both sides stamp this to + // ignore an extension host left behind by an earlier run that is still polling them. + ASPIRE_EXTENSION_E2E_RUN_ID: runId, ASPIRE_EXTENSION_E2E_ENABLE_BRIDGE: 'true', ASPIRE_EXTENSION_E2E_SKIP_CURRENT_CLI_REGRESSIONS: process.env.ASPIRE_EXTENSION_E2E_SKIP_CURRENT_CLI_REGRESSIONS === 'true' ? 'true' : 'false', ASPIRE_EXTENSION_E2E_PRIMARY_APPHOST: primaryAppHostProject, @@ -620,12 +672,19 @@ async function main() { console.log(`Extension E2E download cache ${downloadCache.cacheHit ? 'hit' : 'populated'}: ${downloadCache.cacheDirectory}`); projectDownloadCache(downloadCache, storageDir); + // Installed before any VSIX because the fallback path copies unpacked extension directories in. + // VS Code only scans the extensions directory while extensions.json is absent; once install-vsix + // has written that file it is the authoritative list, and a directory that is not in it is + // ignored and then removed. Copying after the first install therefore silently installs nothing. + installJavaExtensions(extestEnv); + logStep('Installing VSIX'); run(process.execPath, [extesterCli, 'install-vsix', '--storage', storageDir, '--extensions_dir', extensionsDir, '--vsix_file', vsixPath], extestEnv, { timeout: 300000 }); for (const azureFunctionsVsix of azureFunctionsVsixPaths) { logStep(`Installing ${azureFunctionsVsix.displayName} VSIX`); run(process.execPath, [extesterCli, 'install-vsix', '--storage', storageDir, '--extensions_dir', extensionsDir, '--vsix_file', azureFunctionsVsix.path], extestEnv, { timeout: 300000 }); } + assertJavaExtensionsRegistered(); recording = startRecording(); try { @@ -662,7 +721,11 @@ async function main() { if (cleanupErrors.length > 0) { cleanupFailed = true; - const cleanupFailure = new AggregateError(cleanupErrors, 'One or more E2E cleanup steps failed.'); + // Node prints an AggregateError without its `errors`, so a cleanup failure would otherwise + // reach CI as a bare "one or more steps failed" with nothing naming the step that broke. + const cleanupFailure = new AggregateError( + cleanupErrors, + `One or more E2E cleanup steps failed:\n ${cleanupErrors.map(error => error.stack ?? error.message).join('\n ')}`); if (testFailure) { console.error(cleanupFailure); } @@ -786,8 +849,322 @@ function resolveAzureFunctionsVsixPaths() { ]; } -function resolveRequiredVsixPath(environmentVariable) { - const configuredPath = process.env[environmentVariable]; +/** + * Copies the Java Spring Boot playground into the run's workspace. + * + * The playground cannot be used as the workspace root directly, because `prepareWorkspaceFixture` + * deletes and rewrites whatever it is pointed at. Copying also keeps each run reproducible: the + * generated SDK, the Gradle build output, and the language server's own `bin/` all start absent, so + * a test asserting that build inputs were not copied into the output directory is measuring this + * run rather than whatever a previous local build left behind. + */ +function copyJavaPlaygroundIntoWorkspace(bundledCliPath) { + if (!enableJavaE2E) { + return; + } + + const source = path.join(repoRoot, 'playground', 'JavaSpringBoot'); + if (!fs.existsSync(source)) { + throw new Error(`The Java E2E specs require the Java playground at ${source}.`); + } + + assertWorkspaceRootIsNotGitIgnored(); + + // `.aspire/` is generated rather than checked in, so it has to exist before the copy: it is what + // the AppHost's `import aspire.*` statements resolve against, and the generated sources are the + // very thing the diagnostics test measures. + ensureJavaAppHostSdkGenerated(bundledCliPath, source); + + logStep('Copying the Java Spring Boot playground into the E2E workspace'); + fs.cpSync(source, workspaceRoot, { + recursive: true, + // Anything the language server or a build produced locally would defeat the point of a clean + // run, and `bin/` in particular is what one of the tests asserts about. `.aspire/` is kept + // deliberately - it holds the generated SDK under test. + filter: sourcePath => !/[\\/](?:\.gradle|build|bin|target|node_modules)(?:[\\/]|$)/.test(sourcePath), + }); + + // The scaffolded settings point at the isolated CLI copy, which the Java AppHost needs just as + // much as the C# one. Merge rather than replace so that stays in effect. + const settingsPath = path.join(workspaceRoot, '.vscode', 'settings.json'); + const settings = fs.existsSync(settingsPath) ? JSON.parse(fs.readFileSync(settingsPath, 'utf8')) : {}; + + // A Gradle import that fetches wrapper distributions and toolchains on demand takes far longer + // than the language server's default readiness window, and an unimported project reports every + // Aspire import as unresolved. + settings['java.import.gradle.wrapper.enabled'] = true; + settings['java.configuration.updateBuildConfiguration'] = 'automatic'; + // The language server keeps its project metadata, and therefore its compiler output, in its own + // workspace storage by default. That hides the directories the "does not copy build inputs" spec + // asserts about: with nothing under the project's own bin/, the spec passes without ever + // observing what the build produced. Putting the metadata back at the project root is what makes + // that assertion able to fail. + settings['java.import.generatesMetadataFilesAtProjectRoot'] = true; + fs.writeFileSync(settingsPath, JSON.stringify(settings, undefined, 2)); + + // The scaffolded C# fixture cannot coexist with a repository-internal workspace: it inherits the + // repository's central package management and fails to restore with NU1507. The Java spec never + // uses it, so remove it and unpin the pinned AppHost so discovery finds the Java AppHost. + for (const scaffolded of ['AspireE2E.AppHost', 'AspireE2E.Worker']) { + fs.rmSync(path.join(workspaceRoot, scaffolded), { recursive: true, force: true }); + } + + fs.rmSync(path.join(workspaceRoot, 'aspire.config.json'), { force: true }); +} + +/** + * Fails when the Java workspace root is excluded by a .gitignore rule. + * + * The Java workspace has to live inside the repository (see the note on `javaScratchWorkspaceRoot`), + * which puts it within reach of the repository's own ignore rules. That matters because `aspire ls` + * discovers AppHosts from `git ls-files` when it is run inside a work tree: an ignored workspace + * yields zero files, so `aspire ls` reports no candidates at all and every Java spec fails in its + * `before all` hook with nothing pointing at the cause. Adding `extension/java-e2e-workspace/` to + * .gitignore to keep `git status` tidy is exactly the well-intentioned change that breaks this, so + * name the cause here instead of leaving it to be rediscovered from an empty candidate list. + */ +function assertWorkspaceRootIsNotGitIgnored() { + const result = spawnSync('git', ['check-ignore', '-q', workspaceRoot], { + cwd: repoRoot, + shell: false, + encoding: 'utf8', + }); + + // git check-ignore exits 0 when the path is ignored, 1 when it is not, and >1 on error. Treat an + // error (no git, not a work tree) as "not ignored": the CLI falls back to a filesystem scan under + // the same conditions, so there is nothing to warn about. + if (result.status === 0) { + throw new Error( + `The Java E2E workspace root ${workspaceRoot} is excluded by a .gitignore rule. ` + + '`aspire ls` discovers AppHosts from `git ls-files`, so an ignored workspace reports zero ' + + 'AppHost candidates and every Java spec fails while waiting for one. Remove the ignore rule ' + + 'covering this directory.'); + } +} + +/** + * Makes sure the playground's generated Aspire Java SDK exists before it is copied. + * + * `aspire restore` is run in the playground itself rather than in the copied workspace because the + * generator assemblies resolve relative to the repository's package feed; the same command run + * against a copy under a temporary directory fails to discover the Java code generator. + */ +function ensureJavaAppHostSdkGenerated(bundledCliPath, playgroundRoot) { + const appHostDirectory = path.join(playgroundRoot, 'JavaSpringBoot.AppHost.Java'); + const generatedModules = path.join(appHostDirectory, '.aspire', 'modules'); + if (fs.existsSync(generatedModules) && fs.readdirSync(generatedModules).length > 0) { + return; + } + + logStep('Generating the Aspire Java SDK in the playground'); + const result = spawnSync(bundledCliPath, ['restore'], { + cwd: appHostDirectory, + shell: false, + encoding: 'utf8', + timeout: 600000, + }); + + if (result.error) { + throw new Error(`Unable to run 'aspire restore' in ${appHostDirectory}: ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error(`'aspire restore' failed in ${appHostDirectory} with code ${result.status ?? `signal ${result.signal ?? 'unknown'}`}.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + } +} + +/** + * Puts the Java language server and debug adapter into the run's extensions directory. + * + * The extension advertises the `java` capability only when both are installed, and the CLI only + * hands a Java AppHost back to the extension to launch when that capability is advertised, so + * without these the AppHost still runs and no breakpoint in it can ever bind. + */ +function installJavaExtensions(extestEnv) { + if (!enableJavaE2E) { + return; + } + + const configuredVsixPaths = resolveJavaVsixPaths(); + if (configuredVsixPaths.length > 0) { + for (const vsixPath of configuredVsixPaths) { + logStep(`Installing ${path.basename(vsixPath)}`); + run(process.execPath, [extesterCli, 'install-vsix', '--storage', storageDir, '--extensions_dir', extensionsDir, '--vsix_file', vsixPath], extestEnv, { timeout: 600000 }); + } + } + else { + copyLocallyInstalledJavaExtensions(); + } +} + +/** + * Resolves the Java extension VSIXes the run was given, if any. + * + * CI downloads them from the marketplace and passes them here, because a hosted runner has no VS + * Code profile to borrow them from. The value is a list rather than one variable per extension + * because the set is an implementation detail of what redhat.java and the debug adapter need from + * each other, and that set has changed before. + */ +function resolveJavaVsixPaths() { + const configured = process.env.ASPIRE_EXTENSION_E2E_JAVA_VSIX; + if (!configured) { + return []; + } + + return configured + .split(path.delimiter) + .map(entry => entry.trim()) + .filter(entry => entry.length > 0) + .map(entry => { + const resolvedPath = path.resolve(entry); + if (!fs.existsSync(resolvedPath)) { + throw new Error(`ASPIRE_EXTENSION_E2E_JAVA_VSIX points to a missing file: ${resolvedPath}`); + } + + validateVsix(resolvedPath); + return resolvedPath; + }); +} + +/** + * Falls back to the Java extensions already installed in a developer's VS Code. + * + * They are installed as already-unpacked directories rather than VSIXes. VS Code treats every + * immediate subdirectory of --extensions-dir that has a package.json as an installed extension, so + * copying is equivalent to `install-vsix` and avoids a marketplace download in a run that is + * otherwise offline. ASPIRE_EXTENSION_E2E_JAVA_EXTENSIONS_DIR names the directory holding them. + * + * Without that variable both the stable and Insiders extension directories are searched, and the + * first one holding every required extension wins. A developer with the Extension Pack for Java in + * Insiders only would otherwise be told the extensions are missing while they are plainly + * installed, which is a confusing way to fail a run that has already spent minutes downloading. + */ +function copyLocallyInstalledJavaExtensions() { + const configuredRoot = process.env.ASPIRE_EXTENSION_E2E_JAVA_EXTENSIONS_DIR; + const candidateRoots = configuredRoot + ? [path.resolve(configuredRoot)] + : [path.join(os.homedir(), '.vscode', 'extensions'), path.join(os.homedir(), '.vscode-insiders', 'extensions')]; + + const searched = []; + let sourceRoot; + let available = []; + for (const candidateRoot of candidateRoots) { + if (!fs.existsSync(candidateRoot)) { + searched.push(`${candidateRoot} (not found)`); + continue; + } + + const entries = fs.readdirSync(candidateRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name); + // Directory names carry a version, and platform-specific builds add a target triple, so match + // on the `.-` prefix rather than an exact name. + if (REQUIRED_JAVA_EXTENSION_IDS.every(identifier => entries.some(name => name.startsWith(`${identifier}-`)))) { + sourceRoot = candidateRoot; + available = entries; + break; + } + + searched.push(`${candidateRoot} (missing ${REQUIRED_JAVA_EXTENSION_IDS.filter(identifier => !entries.some(name => name.startsWith(`${identifier}-`))).join(', ')})`); + } + + if (!sourceRoot) { + throw new Error(`The Java E2E specs require ${REQUIRED_JAVA_EXTENSION_IDS.join(', ')} to be installed. Searched: ${searched.join('; ')}. Install the Extension Pack for Java, set ASPIRE_EXTENSION_E2E_JAVA_EXTENSIONS_DIR to a directory that has them, or pass VSIXes through ASPIRE_EXTENSION_E2E_JAVA_VSIX.`); + } + + for (const identifier of REQUIRED_JAVA_EXTENSION_IDS) { + // Directory names carry a version, and platform-specific builds add a target triple, so match + // on the `.-` prefix rather than an exact name. + const directoryName = available.find(name => name.startsWith(`${identifier}-`)); + if (!directoryName) { + throw new Error(`The Java E2E specs require ${identifier} to be installed under ${sourceRoot}. Found: ${available.join(', ') || '(none)'}`); + } + + const source = path.join(sourceRoot, directoryName); + assertExtensionSupportsVsCodeVersion(source, directoryName); + + logStep(`Installing ${directoryName}`); + fs.cpSync(source, path.join(extensionsDir, directoryName), { recursive: true, verbatimSymlinks: true }); + } +} + +/** + * Fails the run when the extensions directory does not end up holding every Java extension. + * + * Both install paths can appear to succeed without producing what the specs need: a VSIX for the + * wrong extension installs cleanly, and a copied directory is ignored outright unless VS Code scans + * it before extensions.json exists. Either way the specs would launch, find no `java` capability, + * and fail minutes later with a timeout that says nothing about the missing extension. + * + * extensions.json is what VS Code loads from, so it is the only check that distinguishes an + * extension that is present on disk from one that will actually activate. + */ +function assertJavaExtensionsRegistered() { + if (!enableJavaE2E) { + return; + } + + const manifestPath = path.join(extensionsDir, 'extensions.json'); + if (!fs.existsSync(manifestPath)) { + throw new Error(`VS Code did not write ${manifestPath}, so no extension is registered for the run.`); + } + + let registered; + try { + registered = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } + catch (error) { + throw new Error(`Unable to read the installed extension list at ${manifestPath}: ${error.message}`); + } + + const registeredIds = registered + .map(entry => entry?.identifier?.id) + .filter(Boolean); + + for (const identifier of REQUIRED_JAVA_EXTENSION_IDS) { + const entry = registered.find(candidate => candidate?.identifier?.id?.toLowerCase() === identifier.toLowerCase()); + if (!entry) { + throw new Error(`${identifier} is not registered in ${manifestPath}, so VS Code will not load it. Registered: ${registeredIds.join(', ') || '(none)'}`); + } + + assertExtensionSupportsVsCodeVersion(path.join(extensionsDir, entry.relativeLocation), entry.relativeLocation); + } + + console.log(`Java extensions registered for the run: ${REQUIRED_JAVA_EXTENSION_IDS.join(', ')}.`); +} + +/** + * Fails fast when a copied extension declares an engine range the pinned VS Code cannot satisfy. + * + * VS Code silently refuses to activate an incompatible extension, which would surface much later as + * an empty diagnostics list that looks like a passing test. + */ +function assertExtensionSupportsVsCodeVersion(extensionDirectory, directoryName) { + const manifest = JSON.parse(fs.readFileSync(path.join(extensionDirectory, 'package.json'), 'utf8')); + const engine = manifest.engines?.vscode; + if (typeof engine !== 'string' || engine === '*') { + return; + } + + const minimum = engine.match(/^\^?(\d+)\.(\d+)\.(\d+)$/); + if (!minimum) { + return; + } + + const required = [Number(minimum[1]), Number(minimum[2]), Number(minimum[3])]; + const actual = vscodeVersion.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((actual[i] ?? 0) > required[i]) { + return; + } + + if ((actual[i] ?? 0) < required[i]) { + throw new Error(`${directoryName} requires VS Code ${engine} but the E2E run is pinned to ${vscodeVersion}.`); + } + } +} + +function resolveRequiredVsixPath(environmentVariable) { const configuredPath = process.env[environmentVariable]; if (!configuredPath) { throw new Error(`${environmentVariable} is required when ASPIRE_EXTENSION_E2E_ENABLE_AZURE_FUNCTIONS=true.`); } @@ -933,6 +1310,11 @@ function restoreWorkspaceFixture() { return; } + // The Java run deletes the C# fixture projects, so there is nothing here to prewarm. + if (enableJavaE2E) { + return; + } + if (!fs.existsSync(workspaceNuGetConfigPath)) { console.warn('Skipping Aspire E2E fixture restore prewarm because no local NuGet package source was found.'); return; @@ -1257,6 +1639,16 @@ function getAspireCliEnvironment(extraEnv = {}) { DOTNET_NOLOGO: '1', MSBUILDTERMINALLOGGER: 'false', features__updateNotificationsEnabled: 'false', + // A single-file Java AppHost is started by the CLI's AppHost server, which the CLI locates + // either from an installed bundle or, in dev mode, from the Aspire repository root. The E2E + // workspace CLI copy has no bundle, so without this the CLI fails with "No Aspire AppHost + // server is available". Java support is also still experimental, and `aspire ls` silently + // omits Java AppHosts unless both feature flags are on - the isolated ASPIRE_HOME has neither. + ...(enableJavaE2E ? { + ASPIRE_REPO_ROOT: repoRoot, + features__polyglotSupportEnabled: 'true', + features__experimentalPolyglot__java: 'true', + } : {}), ...extraEnv, }; } @@ -1481,13 +1873,50 @@ function assertWorkspaceRootSafeForDeletion() { return; } + // The Java run deliberately places its workspace inside the repository rather than the temp root, + // so that the CLI resolves packages the way it does for the playground. The marker-file check + // still applies, so this is not a weakening of the delete guard. + if (isSamePath(resolvedWorkspaceRoot, resolveExistingPathForSafety(javaScratchWorkspaceRoot))) { + assertWorkspaceRootCarriesMarker(); + return; + } + if (process.env.ASPIRE_EXTENSION_E2E_ALLOW_EXTERNAL_WORKSPACE_ROOT_CLEANUP !== 'true') { throw new Error(`ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT must be under the runner temp root unless ASPIRE_EXTENSION_E2E_ALLOW_EXTERNAL_WORKSPACE_ROOT_CLEANUP=true is set. Refusing to delete ${workspaceRoot}.`); } - if (fs.existsSync(workspaceRoot) && !fs.existsSync(workspaceMarkerFile)) { - throw new Error(`Refusing to delete external E2E workspace root without marker file ${workspaceMarkerFile}.`); + assertWorkspaceRootCarriesMarker(); +} + +/** + * Refuses to delete a workspace root that holds content the runner did not create. + * + * An empty directory is accepted because it cannot hold anything worth protecting, and because + * `prepareWorkspaceFixture` creates the directory before it writes the marker: a run interrupted in + * that window, or one that failed before writing the marker, otherwise leaves a markerless + * directory that blocks every later run with no way to recover but manual deletion. + */ +function assertWorkspaceRootCarriesMarker() { + if (isWorkspaceRootOwnedByRunner()) { + return; } + + throw new Error( + `Refusing to delete external E2E workspace root without marker file ${workspaceMarkerFile}. ` + + `Delete ${workspaceRoot} manually if it is not yours, then re-run.`); +} + +/** + * Reports whether the workspace root is the runner's to delete: absent, marked by a previous run, + * or empty. Both the pre-run fixture reset and the post-run cleanup consult this, so a developer + * directory that happens to sit at the workspace path survives a run intact. + */ +function isWorkspaceRootOwnedByRunner() { + if (!fs.existsSync(workspaceRoot) || fs.existsSync(workspaceMarkerFile)) { + return true; + } + + return fs.readdirSync(workspaceRoot).length === 0; } function resolveExistingPathForSafety(value) { @@ -1773,6 +2202,16 @@ function cleanupTemporaryRunRoot() { return; } + // The Java workspace lives inside the repository and is deliberately not gitignored, so leaving it + // behind would show up as untracked changes and could be committed by accident. + // + // Only remove it when the runner owns it. `prepareWorkspaceFixture` refuses to delete a workspace + // root that holds content the runner did not create, and deleting it here regardless would defeat + // that guard and destroy a developer's directory that happens to sit at this path. + if (enableJavaE2E && workspaceRoot === javaScratchWorkspaceRoot && isWorkspaceRootOwnedByRunner()) { + removePathWithoutFollowingLinks(javaScratchWorkspaceRoot, { recursive: true, force: true }); + } + // The storage directory under this root holds the projected VS Code and ChromeDriver artifacts, // which are junctions on Windows, and recursive removal descends junctions there. Tearing this // tree down recursively would delete the shared download cache every other run depends on, so diff --git a/extension/src/AspireExtensionContext.ts b/extension/src/AspireExtensionContext.ts index 2ea9bfd5942..6160420113b 100644 --- a/extension/src/AspireExtensionContext.ts +++ b/extension/src/AspireExtensionContext.ts @@ -185,7 +185,7 @@ export class AspireExtensionContext implements vscode.Disposable { stopFailures.push(...await this._drainLateDebugSessionStops(true)); } finally { - this._forceTerminateCliProcesses(); + await this._forceTerminateCliProcesses(); this._disposeCore(); } @@ -229,10 +229,12 @@ export class AspireExtensionContext implements vscode.Disposable { } if (finalizeShutdown) { - // Finalize in the same synchronous turn that observes an empty drain. Returning first - // would create one last microtask window where a newly registered session could be - // queued after the drain but before shared infrastructure is disposed. - this._forceTerminateCliProcesses(); + await this._forceTerminateCliProcesses(); + // A session can arrive while process termination is awaited. Drain it before taking + // the final synchronous empty-check-and-dispose step. + while (this._lateDebugSessionStops.length > 0) { + stopFailures.push(...await this._drainLateDebugSessionStops()); + } this._disposeCore(); } @@ -256,7 +258,7 @@ export class AspireExtensionContext implements vscode.Disposable { } finally { try { - debugSession.terminateCliProcessTree({ force: true }); + await this._terminateCliProcesses([debugSession]); } finally { debugSession.finalizeForExtensionShutdown(); @@ -275,8 +277,9 @@ export class AspireExtensionContext implements vscode.Disposable { void debugSession.requestCliStopForExtensionShutdown().catch(error => { extensionLogOutputChannel.warn(`Failed to stop Aspire CLI during final extension teardown: ${error}`); }); - debugSession.terminateCliProcessTree({ force: true }); - debugSession.finalizeForExtensionShutdown(); + void debugSession.terminateCliProcessTree({ force: true }).then( + () => debugSession.finalizeForExtensionShutdown(), + () => debugSession.finalizeForExtensionShutdown()); } private async _waitForCliStopRequests(): Promise { @@ -296,21 +299,39 @@ export class AspireExtensionContext implements vscode.Disposable { } } - private _forceTerminateCliProcesses(): void { + private async _forceTerminateCliProcesses(): Promise { + await this._terminateCliProcesses([...this._aspireDebugSessions]); + } + + private async _terminateCliProcesses(sessions: AspireDebugSession[]): Promise { // A cooperative stop that resolved, rejected or timed out proves only what happened to the // RPC request; the CLI process can still be running. Signal any that are, so deactivation // cannot leave an AppHost and its resource processes orphaned. - for (const session of [...this._aspireDebugSessions]) { + const terminations = Promise.all(sessions.map(async session => { try { // Force rather than signal-and-schedule: `terminateCliProcess` escalates to a hard // kill on an `unref`'d timer, and `_deactivateCore` resolves immediately after this // sweep, so the extension host can exit before that timer fires. The cooperative // deadline above was this CLI's grace period; there is no second one. - session.terminateCliProcessTree({ force: true }); + await session.terminateCliProcessTree({ force: true }); } catch (error) { extensionLogOutputChannel.warn(`Failed to terminate the Aspire CLI process during extension deactivation: ${error}`); } + })); + let timeout: ReturnType | undefined; + const completed = await Promise.race([ + terminations.then(() => true), + new Promise(resolve => { + timeout = setTimeout(() => resolve(false), AspireExtensionContext._cliStopTimeoutMs); + }), + ]); + if (timeout) { + clearTimeout(timeout); + } + if (!completed) { + extensionLogOutputChannel.warn( + `Timed out after ${AspireExtensionContext._cliStopTimeoutMs}ms waiting for Aspire CLI process-tree termination; continuing extension teardown.`); } } diff --git a/extension/src/activation/registerCliCommands.ts b/extension/src/activation/registerCliCommands.ts index 7731b81a982..ad782b92ed1 100644 --- a/extension/src/activation/registerCliCommands.ts +++ b/extension/src/activation/registerCliCommands.ts @@ -12,30 +12,38 @@ import { updateCommand, updateSelfCommand } from '../commands/update'; import { settingsCommand } from '../commands/settings'; import { openLocalSettingsCommand, openGlobalSettingsCommand } from '../commands/openSettings'; import { installCliCommand, verifyCliInstalledCommand } from '../commands/walkthroughCommands'; -import { cliNotAvailable, dismissLabel, errorMessage, openCliInstallInstructions } from '../loc/strings'; +import { cliNotAvailable, dismissLabel, errorMessage, noAppHostInWorkspace, openCliInstallInstructions, selectWorkspaceFolderForAspireCommand } from '../loc/strings'; import { isCommandCancellation, withCommandTelemetry } from '../utils/telemetry'; import { checkCliAvailableOrRedirect } from '../utils/workspace'; +import { CliPathResolutionTarget, windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; import { isE2eBridgeEnabled } from '../testing/e2eStateFileBridge'; import { registerInstrumentedCommand } from './instrumentedCommand'; +import { AppHostCommandTarget, getAppHostArgs } from '../utils/appHostArgs'; +import { getCliPathTargetForUri } from '../utils/cliPathVariables'; + +interface CommandInvocation { + readonly target: CliPathResolutionTarget; + readonly appHost?: AppHostCommandTarget; +} export function registerCliCommands( terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider, ): vscode.Disposable[] { - const cliAddCommandRegistration = vscode.commands.registerCommand('aspire-vscode.add', () => tryExecuteCommand('aspire-vscode.add', terminalProvider, (tp) => addCommand(tp, editorCommandProvider))); - const cliNewCommandRegistration = vscode.commands.registerCommand('aspire-vscode.new', () => tryExecuteCommand('aspire-vscode.new', terminalProvider, newCommand)); - const cliInitCommandRegistration = vscode.commands.registerCommand('aspire-vscode.init', () => tryExecuteCommand('aspire-vscode.init', terminalProvider, initCommand)); + const cliAddCommandRegistration = vscode.commands.registerCommand('aspire-vscode.add', () => tryExecuteCommand('aspire-vscode.add', terminalProvider, (tp, invocation, cliPath) => addCommand(tp, editorCommandProvider, invocation.appHost ?? {}, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider))); + const cliNewCommandRegistration = vscode.commands.registerCommand('aspire-vscode.new', () => tryExecuteCommand('aspire-vscode.new', terminalProvider, (tp, invocation, cliPath) => newCommand(tp, invocation.target, cliPath), selectCommandInvocation)); + const cliInitCommandRegistration = vscode.commands.registerCommand('aspire-vscode.init', () => tryExecuteCommand('aspire-vscode.init', terminalProvider, (tp, invocation, cliPath) => initCommand(tp, invocation.target, cliPath), selectCommandInvocation)); const cliDeployCommandRegistration = vscode.commands.registerCommand('aspire-vscode.deploy', () => tryExecuteCommand('aspire-vscode.deploy', terminalProvider, () => deployCommand(editorCommandProvider))); const cliPublishCommandRegistration = vscode.commands.registerCommand('aspire-vscode.publish', () => tryExecuteCommand('aspire-vscode.publish', terminalProvider, () => publishCommand(editorCommandProvider))); - const cliDoCommandRegistration = vscode.commands.registerCommand('aspire-vscode.do', () => tryExecuteCommand('aspire-vscode.do', terminalProvider, (tp) => doCommand(tp, editorCommandProvider))); - const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, (tp) => updateCommand(tp, editorCommandProvider))); + const cliDoCommandRegistration = vscode.commands.registerCommand('aspire-vscode.do', () => tryExecuteCommand('aspire-vscode.do', terminalProvider, (tp, invocation, cliPath) => doCommand(tp, editorCommandProvider, invocation.appHost?.appHostPath, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider, true))); + const cliUpdateCommandRegistration = vscode.commands.registerCommand('aspire-vscode.update', () => tryExecuteCommand('aspire-vscode.update', terminalProvider, (tp, invocation, cliPath) => updateCommand(tp, editorCommandProvider, invocation.appHost ?? {}, invocation.target, cliPath), () => selectAppHostCommandInvocation(editorCommandProvider))); const cliUpdateSelfCommandRegistration = vscode.commands.registerCommand('aspire-vscode.updateSelf', () => tryExecuteCommand('aspire-vscode.updateSelf', terminalProvider, updateSelfCommand)); - const openTerminalCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openTerminal', () => tryExecuteCommand('aspire-vscode.openTerminal', terminalProvider, openTerminalCommand)); + const openTerminalCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openTerminal', () => tryExecuteCommand('aspire-vscode.openTerminal', terminalProvider, (tp, invocation, cliPath) => openTerminalCommand(tp, invocation.target, cliPath), selectCommandInvocation)); const configureLaunchJsonCommandRegistration = vscode.commands.registerCommand('aspire-vscode.configureLaunchJson', () => tryExecuteCommand('aspire-vscode.configureLaunchJson', terminalProvider, configureLaunchJsonCommand)); const settingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.settings', () => tryExecuteCommand('aspire-vscode.settings', terminalProvider, settingsCommand)); - const openLocalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openLocalSettings', () => tryExecuteCommand('aspire-vscode.openLocalSettings', terminalProvider, openLocalSettingsCommand)); + const openLocalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openLocalSettings', () => tryExecuteCommand('aspire-vscode.openLocalSettings', terminalProvider, (tp, invocation, cliPath) => openLocalSettingsCommand(tp, invocation.target, cliPath), selectCommandInvocation)); const openGlobalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openGlobalSettings', () => tryExecuteCommand('aspire-vscode.openGlobalSettings', terminalProvider, openGlobalSettingsCommand)); const runAppHostCommandRegistration = registerInstrumentedCommand('aspire-vscode.runAppHostCommand', 'editor', () => editorCommandProvider.tryExecuteRunAppHost(true)); const debugAppHostCommandRegistration = registerInstrumentedCommand('aspire-vscode.debugAppHostCommand', 'editor', () => editorCommandProvider.tryExecuteRunAppHost(false)); @@ -65,9 +73,57 @@ export function registerCliCommands( ]; } -async function tryExecuteCommand(commandName: string, terminalProvider: AspireTerminalProvider, command: (terminalProvider: AspireTerminalProvider) => Promise): Promise { +export async function selectCommandTarget(): Promise { + const activeUri = vscode.window.activeTextEditor?.document.uri; + const activeFolder = activeUri ? vscode.workspace.getWorkspaceFolder(activeUri) : undefined; + if (activeFolder) { + return workspaceFolderCliPathTarget(activeFolder); + } + + const folders = vscode.workspace.workspaceFolders ?? []; + if (folders.length === 1) { + return workspaceFolderCliPathTarget(folders[0]); + } + if (folders.length > 1) { + const selected = await vscode.window.showWorkspaceFolderPick({ + placeHolder: selectWorkspaceFolderForAspireCommand, + }); + if (!selected) { + throw new vscode.CancellationError(); + } + return workspaceFolderCliPathTarget(selected); + } + + return windowCliPathTarget; +} + +async function selectCommandInvocation(): Promise { + return { target: await selectCommandTarget() }; +} + +async function selectAppHostCommandInvocation(editorCommandProvider: AspireEditorCommandProvider, requireAppHost = false): Promise { + const appHost = await getAppHostArgs(editorCommandProvider); + if (!appHost.appHostPath && requireAppHost) { + vscode.window.showErrorMessage(noAppHostInWorkspace); + throw new vscode.CancellationError(); + } + + const target = appHost.appHostPath + ? getCliPathTargetForUri(vscode.Uri.file(appHost.appHostPath)) + : await selectCommandTarget(); + return { target, appHost }; +} + +async function tryExecuteCommand( + commandName: string, + terminalProvider: AspireTerminalProvider, + command: (terminalProvider: AspireTerminalProvider, invocation: CommandInvocation, cliPath: string) => Promise, + prepareInvocation: () => Promise = async () => ({ target: windowCliPathTarget }), +): Promise { try { await withCommandTelemetry(commandName, async () => { + const invocation = await prepareInvocation(); + let cliPath = ''; const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson", "aspire-vscode.updateSelf"]; if (!cliCheckExcludedCommands.includes(commandName)) { if (isE2eBridgeEnabled() && process.env.ASPIRE_EXTENSION_E2E_FORCE_CLI_UNAVAILABLE === 'true') { @@ -79,7 +135,7 @@ async function tryExecuteCommand(commandName: string, terminalProvider: AspireTe throw new vscode.CancellationError(); } - const result = await checkCliAvailableOrRedirect('command_gate'); + const result = await checkCliAvailableOrRedirect('command_gate', invocation.target); if (!result.available) { // The command body never ran — the user was redirected to install the // CLI. Throwing a cancellation makes withCommandTelemetry record this @@ -87,10 +143,11 @@ async function tryExecuteCommand(commandName: string, terminalProvider: AspireTe // suppresses the error toast (the redirect already informed the user). throw new vscode.CancellationError(); } + cliPath = result.cliPath; } - await command(terminalProvider); + await command(terminalProvider, invocation, cliPath); }, { source: 'command_palette' }); } catch (error) { diff --git a/extension/src/activation/registerCodeLensCommands.ts b/extension/src/activation/registerCodeLensCommands.ts index 0e560b04c78..2cf3c77b5e1 100644 --- a/extension/src/activation/registerCodeLensCommands.ts +++ b/extension/src/activation/registerCodeLensCommands.ts @@ -5,6 +5,7 @@ import { AspireTerminalProvider, shellArg } from '../utils/AspireTerminalProvide import { AspireCodeLensProvider } from '../editor/AspireCodeLensProvider'; import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; import { getSupportedLanguageIds } from '../editor/parsers/AppHostResourceParser'; +import { getPlainTextScannableLanguageIds } from '../editor/parsers/plainTextInactiveOffsets'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { isEnabledCommand } from '../views/treePresentation'; import { collectResourceCommandArguments } from '../views/ResourceCommandArguments'; @@ -12,6 +13,7 @@ import { createResourceCommandArgumentLoader } from '../views/ResourceCommandArg import { executeResourceCommand } from '../views/resourceCommandExecution'; import { AppHostDataRepository, isMatchingAppHostPath, ResourceCommandJson } from '../data/AppHostDataRepository'; import { registerInstrumentedCommand } from './instrumentedCommand'; +import { getCliPathTargetForUri, windowCliPathTarget } from '../utils/cliPathVariables'; export function registerCodeLensCommands( appHostTreeProvider: AspireAppHostTreeProvider, @@ -22,7 +24,11 @@ export function registerCodeLensCommands( secretWarningState: vscode.Memento, ): vscode.Disposable[] { const codeLensProvider = new AspireCodeLensProvider(appHostTreeProvider, dataRepository); - const languageFilters = getSupportedLanguageIds().map(lang => ({ language: lang, scheme: 'file' })); + // Languages without a resource parser are registered too. They produce no state or action lenses, + // but they can declare a Java resource that launches through Spring Boot, and that warning is the + // one lens that does not need a parsed resource model. + const lensLanguageIds = new Set([...getSupportedLanguageIds(), ...getPlainTextScannableLanguageIds()]); + const languageFilters = [...lensLanguageIds].map(lang => ({ language: lang, scheme: 'file' })); const codeLensRegistration = vscode.languages.registerCodeLensProvider(languageFilters, codeLensProvider); const codeLensDebugPipelineStepRegistration = registerInstrumentedCommand('aspire-vscode.codeLensDebugPipelineStep', 'codelens', (stepName: string) => editorCommandProvider.tryExecuteDoAppHost(false, stepName)); const codeLensResourceActionRegistration = registerInstrumentedCommand('aspire-vscode.codeLensResourceAction', 'codelens', async (resourceName: string, action: string, appHostPath: string, resourceCommand?: ResourceCommandJson) => { @@ -63,7 +69,10 @@ export function registerCodeLensCommands( const command = appHostPath ? ['logs', shellArg(resourceName), '--apphost', shellArg(appHostPath), '--follow'] : ['logs', shellArg(resourceName), '--follow']; - terminalProvider.sendAspireCommandToAspireTerminal(command); + const target = appHostPath + ? getCliPathTargetForUri(vscode.Uri.file(appHostPath)) + : windowCliPathTarget; + terminalProvider.sendAspireCommandToAspireTerminal(command, true, undefined, { target }); }); const codeLensRevealResourceRegistration = registerInstrumentedCommand('aspire-vscode.codeLensRevealResource', 'codelens', (resourceName: string, appHostPath?: string) => { const element = appHostTreeProvider.findResourceElement(resourceName, appHostPath); @@ -87,7 +96,10 @@ export function registerCodeLensCommands( additionalArgs.push('--apphost', appHostPath); } additionalArgs.push('--follow'); - terminalProvider.sendAspireCommandToAspireTerminal('logs', true, additionalArgs); + const target = appHostPath + ? getCliPathTargetForUri(vscode.Uri.file(appHostPath)) + : windowCliPathTarget; + terminalProvider.sendAspireCommandToAspireTerminal('logs', true, additionalArgs, { target }); }); return [ diff --git a/extension/src/capabilities.ts b/extension/src/capabilities.ts index 7e234fb3c5c..b986d6dc772 100644 --- a/extension/src/capabilities.ts +++ b/extension/src/capabilities.ts @@ -24,6 +24,8 @@ export type Capability = | 'browser' // Support for browser debugging (built-in to VS Code via js-debug) | 'maui' // Support for running .NET MAUI projects | 'ms-dotnettools.dotnet-maui' // MAUI debug adapter extension identifier + | 'java' // Support for running Java projects + | 'vscjava.vscode-java-debug' // Java debug adapter extension identifier | 'azure-functions'; // Support for running Azure Functions projects export type Capabilities = Capability[]; @@ -88,6 +90,19 @@ export function isBunInstalled() { return isExtensionInstalled("oven.bun-vscode"); } +// The Java debug adapter cannot launch anything on its own: it resolves main classes, the +// classpath and project metadata through the redhat.java language server, which is why +// vscjava.vscode-java-debug declares redhat.java as an extension dependency and both ship together +// in the "Extension Pack for Java". java.ts also calls the redhat.java API directly to refresh the +// project configuration, so advertise Java support only when both are present. +// https://github.com/microsoft/vscode-java-debug#requirements +export const javaLanguageExtensionId = 'redhat.java'; +export const javaDebugExtensionId = 'vscjava.vscode-java-debug'; + +export function isJavaInstalled(extensionInstalled: (extensionId: string) => boolean = isExtensionInstalled) { + return extensionInstalled(javaLanguageExtensionId) && extensionInstalled(javaDebugExtensionId); +} + export function getSupportedCapabilities(platform: NodeJS.Platform = process.platform): Capabilities { const capabilities: Capabilities = ['prompting', 'baseline.v1', 'secret-prompts.v1', 'file-pickers.v1', 'build-dotnet-using-cli']; @@ -138,6 +153,11 @@ export function getSupportedCapabilities(platform: NodeJS.Platform = process.pla capabilities.push("ms-dotnettools.dotnet-maui"); } + if (isJavaInstalled()) { + capabilities.push("java"); + capabilities.push(javaDebugExtensionId); + } + return capabilities; } diff --git a/extension/src/commands/add.ts b/extension/src/commands/add.ts index a80dfccdb03..3cd4e38ac8a 100644 --- a/extension/src/commands/add.ts +++ b/extension/src/commands/add.ts @@ -1,8 +1,14 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; -import { getAppHostArgs } from '../utils/appHostArgs'; +import { AppHostCommandTarget } from '../utils/appHostArgs'; +import { CliPathResolutionTarget } from '../utils/cliPathVariables'; -export async function addCommand(terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider) { - const appHostArgs = await getAppHostArgs(editorCommandProvider); - await terminalProvider.sendAspireCommandToAspireTerminal('add', true, appHostArgs); +export async function addCommand( + terminalProvider: AspireTerminalProvider, + _editorCommandProvider: AspireEditorCommandProvider, + appHost: AppHostCommandTarget, + target: CliPathResolutionTarget, + cliPath: string, +) { + await terminalProvider.sendAspireCommandToAspireTerminal('add', true, appHost.args, { target, cliPath }); } diff --git a/extension/src/commands/do.ts b/extension/src/commands/do.ts index 913d2be9ec6..91b033f4824 100644 --- a/extension/src/commands/do.ts +++ b/extension/src/commands/do.ts @@ -2,14 +2,26 @@ import * as vscode from 'vscode'; import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { ConfigInfoProvider } from '../utils/configInfoProvider'; -import { enterPipelineStep } from '../loc/strings'; +import { enterPipelineStep, noAppHostInWorkspace } from '../loc/strings'; +import { CliPathResolutionTarget } from '../utils/cliPathVariables'; -export async function doCommand(terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider) { - const step = await resolveStep(terminalProvider); +export async function doCommand( + terminalProvider: AspireTerminalProvider, + editorCommandProvider: AspireEditorCommandProvider, + appHostPath: string | undefined, + target: CliPathResolutionTarget, + cliPath: string, +) { + if (!appHostPath) { + vscode.window.showErrorMessage(noAppHostInWorkspace); + throw new vscode.CancellationError(); + } + + const step = await resolveStep(terminalProvider, target, cliPath); if (step === undefined) { throw new vscode.CancellationError(); } - await editorCommandProvider.tryExecuteDoAppHost(false, step ?? undefined); + await editorCommandProvider.tryExecuteDoAppHost(false, step ?? undefined, appHostPath, target, cliPath); } /** @@ -18,9 +30,9 @@ export async function doCommand(terminalProvider: AspireTerminalProvider, editor * Returns the user-provided step name if the CLI doesn't support interactive prompting (old CLI). * Returns undefined if the user cancels. */ -async function resolveStep(terminalProvider: AspireTerminalProvider): Promise { +async function resolveStep(terminalProvider: AspireTerminalProvider, target: CliPathResolutionTarget, cliPath: string): Promise { const configInfoProvider = new ConfigInfoProvider(terminalProvider); - if (await configInfoProvider.hasCapability('pipelines')) { + if (await configInfoProvider.hasCapability('pipelines', { target, cliPath })) { // New CLI: it will prompt for the step via interaction service return null; } diff --git a/extension/src/commands/init.ts b/extension/src/commands/init.ts index 3d6c60e25d9..a346080cae6 100644 --- a/extension/src/commands/init.ts +++ b/extension/src/commands/init.ts @@ -1,5 +1,6 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; +import { CliPathResolutionTarget } from '../utils/cliPathVariables'; -export async function initCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('init'); +export async function initCommand(terminalProvider: AspireTerminalProvider, target: CliPathResolutionTarget, cliPath: string) { + await terminalProvider.sendAspireCommandToAspireTerminal('init', true, undefined, { target, cliPath }); }; \ No newline at end of file diff --git a/extension/src/commands/new.ts b/extension/src/commands/new.ts index ab2936e0af3..3313ce0d7d8 100644 --- a/extension/src/commands/new.ts +++ b/extension/src/commands/new.ts @@ -1,5 +1,6 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; +import { CliPathResolutionTarget } from '../utils/cliPathVariables'; -export async function newCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('new'); +export async function newCommand(terminalProvider: AspireTerminalProvider, target: CliPathResolutionTarget, cliPath: string) { + await terminalProvider.sendAspireCommandToAspireTerminal('new', true, undefined, { target, cliPath }); }; diff --git a/extension/src/commands/openSettings.ts b/extension/src/commands/openSettings.ts index b04e6b6ffcd..5dd4a57902c 100644 --- a/extension/src/commands/openSettings.ts +++ b/extension/src/commands/openSettings.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { ConfigInfoProvider } from '../utils/configInfoProvider'; +import { CliPathResolutionTarget, windowCliPathTarget } from '../utils/cliPathVariables'; /** * Opens the local or global Aspire configuration file. @@ -13,8 +14,8 @@ import { ConfigInfoProvider } from '../utils/configInfoProvider'; * in the current workspace. The path is resolved by the CLI via `aspire config info`. * Creates the file with an empty JSON object if it doesn't exist. */ -export async function openLocalSettingsCommand(terminalProvider: AspireTerminalProvider): Promise { - const configInfo = await new ConfigInfoProvider(terminalProvider).getConfigInfo(); +export async function openLocalSettingsCommand(terminalProvider: AspireTerminalProvider, target: CliPathResolutionTarget, cliPath: string): Promise { + const configInfo = await new ConfigInfoProvider(terminalProvider).getConfigInfo({ target, cliPath }); if (!configInfo) { throw new vscode.CancellationError(); } @@ -32,7 +33,7 @@ export async function openLocalSettingsCommand(terminalProvider: AspireTerminalP * Creates the file with an empty JSON object if it doesn't exist. */ export async function openGlobalSettingsCommand(terminalProvider: AspireTerminalProvider): Promise { - const configInfo = await new ConfigInfoProvider(terminalProvider).getConfigInfo(); + const configInfo = await new ConfigInfoProvider(terminalProvider).getConfigInfo({ target: windowCliPathTarget }); if (!configInfo) { throw new vscode.CancellationError(); } diff --git a/extension/src/commands/openTerminal.ts b/extension/src/commands/openTerminal.ts index 84d489f88d3..e738b11a099 100644 --- a/extension/src/commands/openTerminal.ts +++ b/extension/src/commands/openTerminal.ts @@ -1,7 +1,12 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { CliPathResolutionTarget } from '../utils/cliPathVariables'; -export async function openTerminalCommand(terminalProvider: AspireTerminalProvider): Promise { +export async function openTerminalCommand( + terminalProvider: AspireTerminalProvider, + target: CliPathResolutionTarget, + cliPath: string, +): Promise { // Ensure the Aspire terminal exists and show it - const aspireTerminal = terminalProvider.getAspireTerminal(); + const aspireTerminal = terminalProvider.getAspireTerminal(false, target, cliPath); aspireTerminal.terminal.show(); } diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts index f9286a99afd..06a96bb76df 100644 --- a/extension/src/commands/update.ts +++ b/extension/src/commands/update.ts @@ -1,12 +1,18 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; -import { getAppHostArgs } from '../utils/appHostArgs'; +import { AppHostCommandTarget } from '../utils/appHostArgs'; +import { CliPathResolutionTarget, windowCliPathTarget } from '../utils/cliPathVariables'; -export async function updateCommand(terminalProvider: AspireTerminalProvider, editorCommandProvider: AspireEditorCommandProvider) { - const appHostArgs = await getAppHostArgs(editorCommandProvider); - await terminalProvider.sendAspireCommandToAspireTerminal('update', true, appHostArgs); +export async function updateCommand( + terminalProvider: AspireTerminalProvider, + _editorCommandProvider: AspireEditorCommandProvider, + appHost: AppHostCommandTarget, + target: CliPathResolutionTarget, + cliPath: string, +) { + await terminalProvider.sendAspireCommandToAspireTerminal('update', true, appHost.args, { target, cliPath }); } export async function updateSelfCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('update --self'); + await terminalProvider.sendAspireCommandToAspireTerminal('update --self', true, undefined, { target: windowCliPathTarget }); } diff --git a/extension/src/data/AppHostDataRepository.ts b/extension/src/data/AppHostDataRepository.ts index a9ff58047f9..bb15a4a317e 100644 --- a/extension/src/data/AppHostDataRepository.ts +++ b/extension/src/data/AppHostDataRepository.ts @@ -17,6 +17,7 @@ import { AppHostCliRunner, isDescribeUnsupportedOutput, isIncludeDisabledCommand import { isMatchingAppHostInstance, isMatchingAppHostPath, isPathInWorkspace } from './appHostPathMatching'; import { AppHostPsPoller } from './appHostPsPoller'; import { filterResourceCommandStatusOutput } from './resourceCommandStatusOutput'; +import { getCliPathTargetForUri } from '../utils/cliPathVariables'; export * from './appHostCliContracts'; export { shortenPath, shortenPaths }; @@ -53,10 +54,6 @@ interface DescribeStream { interface DescribeNoDataError { message: string | undefined; isCompatibilityError: boolean; - // True only when the error is a fact about the installed CLI (too old to `describe` at all), - // so it applies identically to every host and any working stream disproves it. Host-scoped - // errors (including host-scoped compatibility) leave this false. - isCliWideError: boolean; } interface PostStopRefreshTimer { @@ -93,13 +90,6 @@ export class AppHostDataRepository { private _openAppHostPaths: readonly string[] = []; private _hasEverBeenDataActive = false; - // ── Workspace mode state (describe --follow) ── - // Whether `aspire describe` accepts the hidden `--include-disabled-commands` flag. Resolved - // lazily from the CLI's advertised capabilities (`aspire config info --json`) so we don't pass - // the flag to an older CLI that would reject it and emit no resource data. Starts optimistic so - // that, if capability resolution fails (e.g. a CLI too old to support `config info`), we still - // attempt the flag and rely on the locale-independent no-data fallback below. - private _includeDisabledCommandsSupported = true; private readonly _configInfoProvider: ConfigInfoProvider; // ── Running AppHost state (ps polling) ── @@ -143,7 +133,6 @@ export class AppHostDataRepository { // ── Error state ── private _describeErrorMessage: string | undefined; private _describeErrorIsCompatibility = false; - private _describeErrorIsCliWide = false; private _describeErrorAppHostPath: string | undefined; private _psErrorMessage: string | undefined; private _errorMessage: string | undefined; @@ -198,12 +187,6 @@ export class AppHostDataRepository { this._psPoller.startPsPolling(); } }); - // Kick off the CLI capability probe eagerly (fire-and-forget) so the cached describe gate is - // ready by the time a describe stream starts. We must NOT await capabilities on the describe - // start path: an await there would reorder the describe spawn after other streams (e.g. ps) - // and change observable process ordering. Until the probe resolves we use the optimistic - // default and the per-stream no-data fallback corrects a stale CLI. - void this._resolveDescribeCapability(); } // ── Public accessors ── @@ -505,6 +488,7 @@ export class AppHostDataRepository { */ async runResourceCommand(resourceName: string, appHostPath: string | undefined, commandName: string, additionalArgs: readonly string[] = [], cancellationToken?: vscode.CancellationToken): Promise { const args = ['resource', resourceName, commandName, '--non-interactive']; + let target; if (appHostPath !== undefined) { const trimmedAppHostPath = appHostPath.trim(); if (!trimmedAppHostPath || !path.isAbsolute(trimmedAppHostPath)) { @@ -512,6 +496,7 @@ export class AppHostDataRepository { } args.push('--apphost', trimmedAppHostPath); + target = getCliPathTargetForUri(vscode.Uri.file(trimmedAppHostPath)); } if (additionalArgs.length > 0) { @@ -524,6 +509,7 @@ export class AppHostDataRepository { stdoutBufferLimit: AppHostDataRepository._oneShotOutputBufferLimit, cancellationToken, env: nonInteractiveCliEnvironment, + target, }); return { stdout: filterResourceCommandStatusOutput(output.stdout, resourceName, commandName), @@ -962,21 +948,6 @@ export class AppHostDataRepository { // ── describe --follow ── - /** - * Reads the CLI's advertised capabilities and maps the describe `--include-disabled-commands` - * capability onto {@link _includeDisabledCommandsSupported}. Best-effort: on a missing/older CLI - * the optimistic default and per-stream no-data fallback still cover us. - */ - private async _resolveDescribeCapability(): Promise { - const configInfo = await this._configInfoProvider.getConfigInfo({ suppressErrors: true }); - if (this._disposed || !configInfo) { - return; - } - - this._includeDisabledCommandsSupported = configInfo.capabilities?.includes(describeIncludeDisabledCommandsCapability) ?? false; - extensionLogOutputChannel.info(`CLI capability '${describeIncludeDisabledCommandsCapability}' ${this._includeDisabledCommandsSupported ? 'advertised' : 'not advertised'}; describe --include-disabled-commands ${this._includeDisabledCommandsSupported ? 'enabled' : 'disabled'}.`); - } - /** * Starts a single `aspire describe --follow --apphost ` stream, held in * {@link _describeStreams} keyed by `appHostPath`. Every stream is an equal peer for resource @@ -984,13 +955,10 @@ export class AppHostDataRepository { * `_appHosts`, restarts with backoff. A describe is only ever started for a host `aspire ps` has * confirmed running, so there is no proactive/eager start. * - * The describe error banner distinguishes two kinds of failure. A CLI-wide compatibility problem - * (the installed CLI is too old to `describe` at all) may be surfaced by any peer and, once any - * stream works, cleared by any peer, since it is a fact about the CLI rather than a single host. A - * host-scoped no-data or spawn error belongs to the selected workspace AppHost - * (`_workspaceAppHostPath`): only that host sets it, and only its own working describe clears it, so - * a non-selected peer's failure never masquerades as the selected host's and its success never - * hides one. + * Describe errors belong to the selected workspace AppHost (`_workspaceAppHostPath`): only that + * host sets the shared banner, and only its own working describe clears it. Each workspace folder + * can resolve a different CLI, so a non-selected peer's failure or recovery says nothing about the + * selected host's compatibility. */ private _startDescribe(appHostPath: string, forceIncludeDisabledCommands?: boolean, initialRestartDelay?: number): void { if (this._disposed) { @@ -1014,157 +982,180 @@ export class AppHostDataRepository { }; this._describeStreams.set(appHostPath, stream); const startVersion = ++stream.version; + const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath)); - this._terminalProvider.getAspireCliExecutablePath().then(cliPath => { + this._terminalProvider.getAspireCliExecutablePath(target).then(async cliPath => { if (this._disposed || this._describeStreams.get(appHostPath) !== stream || startVersion !== stream.version) { return; } - // Read the cached capability synchronously — see constructor for why we don't await here. - const includeDisabledCommands = forceIncludeDisabledCommands ?? this._includeDisabledCommandsSupported; - const args = this._cliRunner.withNoLogo(['describe', '--follow', '--format', 'json']); + // The capability is a property of the CLI this AppHost resolves to, not of the window: a + // multi-root workspace can point each folder at a different aspire.cliPath, so a single + // eagerly-probed flag would describe the wrong CLI. Probing per stream is safe to await + // because the resolved value now travels with the stream — the retry paths below pass the + // decision they already made rather than re-reading shared state that another stream + // could have changed underneath them. + const configInfo = await this._configInfoProvider.getConfigInfo({ + suppressErrors: true, + cliPath, + target, + }); + if (this._disposed || this._describeStreams.get(appHostPath) !== stream || startVersion !== stream.version) { + return; + } + + const includeDisabledCommands = forceIncludeDisabledCommands + ?? configInfo?.capabilities?.includes(describeIncludeDisabledCommandsCapability) + ?? false; + const args = this._cliRunner.withNoLogo(['describe', '--follow', '--format', 'json'], cliPath); if (includeDisabledCommands) { args.push('--include-disabled-commands'); } args.push('--apphost', appHostPath); - extensionLogOutputChannel.info(`Starting aspire describe --follow (--apphost ${appHostPath})`); + this._startResolvedDescribeProcess(stream, startVersion, cliPath, args); + }).catch(error => { + if (this._disposed || this._describeStreams.get(appHostPath) !== stream || startVersion !== stream.version) { + return; + } + // Resolving the CLI path failed, so no describe stream can run. Only the selected workspace + // AppHost surfaces this through the shared describe banner (workspace mode shows it; other modes + // swallow it, matching prior behavior); a non-selected peer is logged only. + extensionLogOutputChannel.warn(`Failed to start describe watch (--apphost ${appHostPath}): ${error}`); + this._describeStreams.delete(appHostPath); + if (isMatchingAppHostPath(appHostPath, this._workspaceAppHostPath)) { + this._setDescribeError(errorFetchingAppHosts(String(error)), { appHostPath }); + } + }); + } - stream.receivedData = false; - const describeProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { - createProcessGroup: true, - noExtensionVariables: true, - lineCallback: (line) => { - if (this._describeStreams.get(appHostPath) !== stream || stream.process !== describeProcess) { - return; - } - const handled = this._handleDescribeLine(stream, line); - if (!handled && stream.nonJsonLines.length < 20) { - stream.nonJsonLines.push(line); - } - }, - stderrCallback: (data) => { - if (this._describeStreams.get(appHostPath) !== stream || stream.process !== describeProcess) { - return; - } - extensionLogOutputChannel.warn(`aspire describe --follow (--apphost ${appHostPath}) stderr: ${data}`); - if (stream.stderr.length < 4000) { - stream.stderr += data; - } - }, - exitCallback: (code) => { - if (this._describeStreams.get(appHostPath) !== stream || stream.process !== describeProcess) { - return; - } + private _startResolvedDescribeProcess(stream: DescribeStream, startVersion: number, cliPath: string, args: string[]): void { + const appHostPath = stream.appHostPath; + if (this._disposed || this._describeStreams.get(appHostPath) !== stream || startVersion !== stream.version) { + return; + } - extensionLogOutputChannel.info(`aspire describe --follow (--apphost ${appHostPath}) exited with code ${code}`); - stream.process = undefined; + const includeDisabledCommands = args.includes('--include-disabled-commands'); - if (this._disposed) { - return; - } + extensionLogOutputChannel.info(`Starting aspire describe --follow (--apphost ${appHostPath})`); - if (code !== 0 && this._cliRunner.disableNoLogoForRetry(args, stream.nonJsonLines.join('\n'), stream.stderr, `aspire describe --follow --apphost ${appHostPath}`)) { - this._describeStreams.delete(appHostPath); - this._startDescribe(appHostPath, forceIncludeDisabledCommands); - return; - } + stream.receivedData = false; + const describeProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + createProcessGroup: true, + noExtensionVariables: true, + lineCallback: (line) => { + if (this._describeStreams.get(appHostPath) !== stream || stream.process !== describeProcess) { + return; + } + const handled = this._handleDescribeLine(stream, line); + if (!handled && stream.nonJsonLines.length < 20) { + stream.nonJsonLines.push(line); + } + }, + stderrCallback: (data) => { + if (this._describeStreams.get(appHostPath) !== stream || stream.process !== describeProcess) { + return; + } + extensionLogOutputChannel.warn(`aspire describe --follow (--apphost ${appHostPath}) stderr: ${data}`); + if (stream.stderr.length < 4000) { + stream.stderr += data; + } + }, + exitCallback: (code) => { + if (this._describeStreams.get(appHostPath) !== stream || stream.process !== describeProcess) { + return; + } - // Capability fallback: a CLI too old to accept `--include-disabled-commands` exits - // without data. Retry once without the flag. - if (includeDisabledCommands && !stream.receivedData && isIncludeDisabledCommandsUnsupportedOutput(stream.nonJsonLines, stream.stderr)) { - this._includeDisabledCommandsSupported = false; - this._describeStreams.delete(appHostPath); - this._startDescribe(appHostPath, false); - return; - } + extensionLogOutputChannel.info(`aspire describe --follow (--apphost ${appHostPath}) exited with code ${code}`); + stream.process = undefined; - // Host no longer running: drop the stream silently (the app stopped — not an error). - if (!this._appHosts.some(appHost => isMatchingAppHostPath(appHost.appHostPath, appHostPath))) { - stream.resources.clear(); - this._describeStreams.delete(appHostPath); - this._attachResourcesToAppHosts(); - this._onDidChangeData.fire(); - return; - } + if (this._disposed) { + return; + } - // ps is the authority on whether the host stopped, so a describe stream exiting - // does not necessarily indicate the host has stopped. - if (stream.receivedData) { - extensionLogOutputChannel.info(`aspire describe --follow (--apphost ${appHostPath}) exited (code ${code}) after producing data; restarting.`); - } else { - // A stream that never produced resources and exits (cleanly or with an error) means - // the CLI cannot describe the host. A CLI-wide compatibility problem (the installed - // CLI is too old to `describe` at all) may be surfaced by any peer, since it's a fact - // about the CLI. A host-scoped no-data error belongs to the selected workspace - // AppHost, so a non-selected peer never surfaces one and can't masquerade as the - // selected host's error. - extensionLogOutputChannel.warn(`aspire describe --follow (--apphost ${appHostPath}) exited (code ${code}) without producing data.`); - if (isMatchingAppHostPath(appHostPath, this._workspaceAppHostPath) || isDescribeUnsupportedOutput(stream.nonJsonLines, stream.stderr)) { - const noDataError = this._getDescribeNoDataError(code, stream.nonJsonLines, stream.stderr); - if (noDataError.message) { - this._setDescribeError(noDataError.message, { - compatibility: noDataError.isCompatibilityError, - cliWide: noDataError.isCliWideError, - appHostPath: noDataError.isCliWideError ? undefined : appHostPath, - }); - } - } - } + if (code !== 0 && this._cliRunner.disableNoLogoForRetry(cliPath, args, stream.nonJsonLines.join('\n'), stream.stderr, `aspire describe --follow --apphost ${appHostPath}`)) { + this._describeStreams.delete(appHostPath); + this._startDescribe(appHostPath, includeDisabledCommands); + return; + } + // Capability fallback: a CLI too old to accept `--include-disabled-commands` exits + // without data. Retry once without the flag. + if (includeDisabledCommands && !stream.receivedData && isIncludeDisabledCommandsUnsupportedOutput(stream.nonJsonLines, stream.stderr)) { + this._describeStreams.delete(appHostPath); + this._startDescribe(appHostPath, false); + return; + } + + // Host no longer running: drop the stream silently (the app stopped — not an error). + if (!this._appHosts.some(appHost => isMatchingAppHostPath(appHost.appHostPath, appHostPath))) { stream.resources.clear(); + this._describeStreams.delete(appHostPath); this._attachResourcesToAppHosts(); this._onDidChangeData.fire(); - this._scheduleDescribeRestart(appHostPath, stream); - this._psPoller.refreshAppHostsFromAuthoritativeSnapshot(); - }, - errorCallback: (error) => { - if (this._describeStreams.get(appHostPath) !== stream || stream.process !== describeProcess) { - return; - } - - if (this._disposed) { - return; - } + return; + } - // Spawn/stream error (as opposed to a clean exit). Only the selected workspace AppHost - // surfaces it through the shared describe banner; a non-selected peer is logged only so it - // can't masquerade as the selected host's error. - extensionLogOutputChannel.warn(`aspire describe --follow --apphost ${appHostPath} error: ${error.message}`); - stream.process = undefined; - stream.resources.clear(); + // ps is the authority on whether the host stopped, so a describe stream exiting + // does not necessarily indicate the host has stopped. + if (stream.receivedData) { + extensionLogOutputChannel.info(`aspire describe --follow (--apphost ${appHostPath}) exited (code ${code}) after producing data; restarting.`); + } else { + // A stream that never produced resources and exits (cleanly or with an error) means + // the CLI cannot describe that host. Only the selected workspace AppHost surfaces + // this through the shared banner; another folder can resolve a different CLI. + extensionLogOutputChannel.warn(`aspire describe --follow (--apphost ${appHostPath}) exited (code ${code}) without producing data.`); if (isMatchingAppHostPath(appHostPath, this._workspaceAppHostPath)) { - this._setDescribeError(errorFetchingAppHosts(error.message), { appHostPath }); + const noDataError = this._getDescribeNoDataError(code, stream.nonJsonLines, stream.stderr); + if (noDataError.message) { + this._setDescribeError(noDataError.message, { + compatibility: noDataError.isCompatibilityError, + appHostPath, + }); + } } + } - // Host no longer running: drop the stream silently - if (!this._appHosts.some(appHost => isMatchingAppHostPath(appHost.appHostPath, appHostPath))) { - this._describeStreams.delete(appHostPath); - this._attachResourcesToAppHosts(); - this._onDidChangeData.fire(); - return; - } + stream.resources.clear(); + this._attachResourcesToAppHosts(); + this._onDidChangeData.fire(); + this._scheduleDescribeRestart(appHostPath, stream); + this._psPoller.refreshAppHostsFromAuthoritativeSnapshot(); + }, + errorCallback: (error) => { + if (this._describeStreams.get(appHostPath) !== stream || stream.process !== describeProcess) { + return; + } + if (this._disposed) { + return; + } + + // Spawn/stream error (as opposed to a clean exit). Only the selected workspace AppHost + // surfaces it through the shared describe banner; a non-selected peer is logged only so it + // can't masquerade as the selected host's error. + extensionLogOutputChannel.warn(`aspire describe --follow --apphost ${appHostPath} error: ${error.message}`); + stream.process = undefined; + stream.resources.clear(); + if (isMatchingAppHostPath(appHostPath, this._workspaceAppHostPath)) { + this._setDescribeError(errorFetchingAppHosts(error.message), { appHostPath }); + } + + // Host no longer running: drop the stream silently + if (!this._appHosts.some(appHost => isMatchingAppHostPath(appHost.appHostPath, appHostPath))) { + this._describeStreams.delete(appHostPath); this._attachResourcesToAppHosts(); this._onDidChangeData.fire(); - this._scheduleDescribeRestart(appHostPath, stream); - this._psPoller.refreshAppHostsFromAuthoritativeSnapshot(); + return; } - }); - stream.process = describeProcess; - }).catch(error => { - if (this._disposed || this._describeStreams.get(appHostPath) !== stream || startVersion !== stream.version) { - return; - } - // Resolving the CLI path failed, so no describe stream can run. Only the selected workspace - // AppHost surfaces this through the shared describe banner (workspace mode shows it; other modes - // swallow it, matching prior behavior); a non-selected peer is logged only. - extensionLogOutputChannel.warn(`Failed to start describe watch (--apphost ${appHostPath}): ${error}`); - this._describeStreams.delete(appHostPath); - if (isMatchingAppHostPath(appHostPath, this._workspaceAppHostPath)) { - this._setDescribeError(errorFetchingAppHosts(String(error)), { appHostPath }); + + this._attachResourcesToAppHosts(); + this._onDidChangeData.fire(); + this._scheduleDescribeRestart(appHostPath, stream); + this._psPoller.refreshAppHostsFromAuthoritativeSnapshot(); } }); + stream.process = describeProcess; } private _scheduleDescribeRestart(appHostPath: string, stream: DescribeStream): void { @@ -1198,15 +1189,14 @@ export class AppHostDataRepository { stream.resources.set(resource.name, resource); stream.receivedData = true; stream.restartDelay = 5000; - // A working stream proves the CLI supports `describe`, so any peer clears a CLI-wide - // banner. Once a host raises a scoped error, only that host's recovery clears it, even if - // workspace selection changes while the stream is restarting. Ownerless errors retain the - // selected-workspace-host behavior used for discovery failures. + // Once a host raises an error, only that host's recovery clears it, even if workspace + // selection changes while the stream is restarting. Ownerless errors retain the selected + // workspace-host behavior used for discovery failures. const recoveredOwnedError = this._describeErrorAppHostPath !== undefined && isMatchingAppHostPath(stream.appHostPath, this._describeErrorAppHostPath); const recoveredOwnerlessError = this._describeErrorAppHostPath === undefined && isMatchingAppHostPath(stream.appHostPath, this._workspaceAppHostPath); - if (this._describeErrorIsCliWide || recoveredOwnedError || recoveredOwnerlessError) { + if (recoveredOwnedError || recoveredOwnerlessError) { this._setDescribeError(undefined); } this._attachResourcesToAppHosts(); @@ -1229,7 +1219,6 @@ export class AppHostDataRepository { return { message: aspireCliDescribeNotSupported(aspireDescribeMinimumVersion), isCompatibilityError: true, - isCliWideError: true, }; } @@ -1237,7 +1226,6 @@ export class AppHostDataRepository { return { message: errorFetchingAppHosts(stderr || `exit code ${exitCode ?? 1}`), isCompatibilityError: false, - isCliWideError: false, }; } @@ -1248,14 +1236,12 @@ export class AppHostDataRepository { return { message: appHostDescribeMayNotBeSupported(aspireDescribeMinimumVersion), isCompatibilityError: true, - isCliWideError: false, }; } return { message: undefined, isCompatibilityError: false, - isCliWideError: false, }; } @@ -1315,7 +1301,10 @@ export class AppHostDataRepository { } private async _fetchAppHostResourcesOnce(appHostPath: string): Promise { - const snapshot = await this._runCliJson('aspire describe', this._cliRunner.withNoLogo(['describe', '--format', 'json', '--apphost', appHostPath])); + const snapshot = await this._runCliJson( + 'aspire describe', + this._cliRunner.withNoLogo(['describe', '--format', 'json', '--apphost', appHostPath]), + { target: getCliPathTargetForUri(vscode.Uri.file(appHostPath)) }); return snapshot.resources ?? []; } @@ -1333,7 +1322,9 @@ export class AppHostDataRepository { if (stream.process) { const childProcess = stream.process; stream.process = undefined; - terminateCliProcess(childProcess, `aspire describe --follow (${appHostPath})`, { suppressTimeoutWarning: true }); + void terminateCliProcess(childProcess, `aspire describe --follow (${appHostPath})`, { suppressTimeoutWarning: true }).catch(error => { + extensionLogOutputChannel.error(`Failed to terminate aspire describe --follow (${appHostPath}): ${String(error)}`); + }); } } @@ -1419,23 +1410,19 @@ export class AppHostDataRepository { private _clearErrors(): void { this._describeErrorMessage = undefined; this._describeErrorIsCompatibility = false; - this._describeErrorIsCliWide = false; this._describeErrorAppHostPath = undefined; this._psErrorMessage = undefined; this._updateErrorMessage(); } - private _setDescribeError(message: string | undefined, options?: { compatibility?: boolean; cliWide?: boolean; appHostPath?: string }): void { + private _setDescribeError(message: string | undefined, options?: { compatibility?: boolean; appHostPath?: string }): void { const compatibility = message !== undefined && (options?.compatibility ?? false); - const cliWide = message !== undefined && (options?.cliWide ?? false); - const appHostPath = message !== undefined && !cliWide ? options?.appHostPath : undefined; + const appHostPath = message !== undefined ? options?.appHostPath : undefined; if (this._describeErrorMessage !== message || this._describeErrorIsCompatibility !== compatibility - || this._describeErrorIsCliWide !== cliWide || !isMatchingAppHostPath(this._describeErrorAppHostPath, appHostPath)) { this._describeErrorMessage = message; this._describeErrorIsCompatibility = compatibility; - this._describeErrorIsCliWide = cliWide; this._describeErrorAppHostPath = appHostPath; this._updateErrorMessage(); } diff --git a/extension/src/data/appHostCliRunner.ts b/extension/src/data/appHostCliRunner.ts index d51b87132e1..cff00b7da5d 100644 --- a/extension/src/data/appHostCliRunner.ts +++ b/extension/src/data/appHostCliRunner.ts @@ -7,6 +7,7 @@ import { aspireCliCommandTimedOut, aspireCommandOutputTruncated } from '../loc/s import { isNoLogoUnsupportedOutput, noLogoOption, removeRootNoLogoOption } from '../utils/cliCompatibility'; import { AspireCliFailedError, AspireCliNotInstalledError } from './appHostCliContracts'; import { normalizeResourceCommandStatusLine } from './resourceCommandStatusOutput'; +import { CliPathResolutionTarget, windowCliPathTarget } from '../utils/cliPathVariables'; export const oneShotOutputBufferLimit = 64 * 1024; @@ -15,42 +16,52 @@ export interface RunCliCommandOptions { stdoutBufferLimit?: number | null; cancellationToken?: vscode.CancellationToken; env?: { name: string; value: string }[]; + target?: CliPathResolutionTarget; } export class AppHostCliRunner implements vscode.Disposable { private static readonly _oneShotCommandTimeoutMs = 30000; - private _noLogoSupported = true; + private _noLogoUnsupportedCliPaths = new Set(); private _oneShotProcesses = new Set(); constructor(private readonly _terminalProvider: AspireTerminalProvider) { } - withNoLogo(args: string[]): string[] { - if (!this._noLogoSupported) { - return args; + withNoLogo(args: string[], cliPath?: string): string[] { + if (cliPath && this._noLogoUnsupportedCliPaths.has(cliPath)) { + return removeRootNoLogoOption(args); } + if (args.includes(noLogoOption)) { + return args; + } const appHostIndex = args.indexOf('--apphost'); const insertIndex = appHostIndex === -1 ? args.length : appHostIndex; return [...args.slice(0, insertIndex), noLogoOption, ...args.slice(insertIndex)]; } + normalizeNoLogoArgs(cliPath: string, args: string[]): string[] { + return this._noLogoUnsupportedCliPaths.has(cliPath) + ? removeRootNoLogoOption(args) + : args; + } + // Returns the args to retry with when the installed CLI does not recognize --nologo, or // undefined when this failure is unrelated to --nologo. Has the intentional side effect of - // flipping _noLogoSupported to false the first time the unsupported pattern is observed so - // subsequent withNoLogo calls stop adding the option for the lifetime of the runner. + // recording the exact concrete CLI path the first time the unsupported pattern is observed so + // subsequent calls through that executable stop adding the option for the lifetime of the runner. // // Callers that own their own retry args use the returned value directly; long-lived watch // restarters (describe/ps follow) use disableNoLogoForRetry below and intentionally discard // the returned args because the watch starter rebuilds args via withNoLogo. - tryGetNoLogoRetryArgs(args: string[], stdout: string, stderr: string, operation: string): string[] | undefined { + tryGetNoLogoRetryArgs(cliPath: string, args: string[], stdout: string, stderr: string, operation: string): string[] | undefined { if (!isNoLogoUnsupportedOutput(args, stdout, stderr)) { return undefined; } - if (this._noLogoSupported) { - this._noLogoSupported = false; + if (!this._noLogoUnsupportedCliPaths.has(cliPath)) { + this._noLogoUnsupportedCliPaths.add(cliPath); extensionLogOutputChannel.info(`Installed Aspire CLI does not recognize ${noLogoOption}; retrying ${operation} without it.`); } @@ -61,18 +72,19 @@ export class AppHostCliRunner implements vscode.Disposable { // withNoLogo when they restart. These call sites only need to know "did we just disable // --nologo support for the rest of this session?" — the recomputed args from // tryGetNoLogoRetryArgs would be thrown away. - disableNoLogoForRetry(args: string[], stdout: string, stderr: string, operation: string): boolean { - return this.tryGetNoLogoRetryArgs(args, stdout, stderr, operation) !== undefined; + disableNoLogoForRetry(cliPath: string, args: string[], stdout: string, stderr: string, operation: string): boolean { + return this.tryGetNoLogoRetryArgs(cliPath, args, stdout, stderr, operation) !== undefined; } async runCliCommand(command: string, args: string[], options: RunCliCommandOptions = {}): Promise<{ stdout: string; stderr: string }> { - const cliPath = await this._terminalProvider.getAspireCliExecutablePath().catch(error => { + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(options.target ?? windowCliPathTarget).catch(error => { throw new AspireCliNotInstalledError(String(error)); }); if (options.cancellationToken?.isCancellationRequested) { throw new vscode.CancellationError(); } + const invocationArgs = this.normalizeNoLogoArgs(cliPath, args); return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { let settled = false; @@ -105,7 +117,9 @@ export class AppHostCliRunner implements vscode.Disposable { if (cliProcess) { this._oneShotProcesses.delete(cliProcess); if (cliProcess.exitCode === null && !cliProcess.killed) { - terminateCliProcess(cliProcess, command); + void terminateCliProcess(cliProcess, command).catch(error => { + extensionLogOutputChannel.error(`Failed to terminate ${command}: ${String(error)}`); + }); } } else { settledBeforeTracking = true; @@ -123,7 +137,7 @@ export class AppHostCliRunner implements vscode.Disposable { settle(() => reject(new vscode.CancellationError())); }); - cliProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + cliProcess = spawnCliProcess(this._terminalProvider, cliPath, invocationArgs, { createProcessGroup: true, noExtensionVariables: true, env: options.env, @@ -131,7 +145,7 @@ export class AppHostCliRunner implements vscode.Disposable { stderrCallback: (data) => { stderr.append(data); }, exitCallback: (code) => { if (code !== 0) { - const retryArgs = this.tryGetNoLogoRetryArgs(args, stdout.value, stderr.value, command); + const retryArgs = this.tryGetNoLogoRetryArgs(cliPath, invocationArgs, stdout.value, stderr.value, command); if (retryArgs) { settle(() => { this.runCliCommand(command, retryArgs, options).then(resolve, reject); @@ -154,7 +168,9 @@ export class AppHostCliRunner implements vscode.Disposable { // never track it, and terminate it if it is somehow still alive (a cancellation or // timeout that raced the spawn would otherwise orphan a live process). if (cliProcess.exitCode === null && !cliProcess.killed) { - terminateCliProcess(cliProcess, command); + void terminateCliProcess(cliProcess, command).catch(error => { + extensionLogOutputChannel.error(`Failed to terminate ${command}: ${String(error)}`); + }); } return; @@ -166,7 +182,9 @@ export class AppHostCliRunner implements vscode.Disposable { stopOneShotProcesses(): void { for (const process of this._oneShotProcesses) { - terminateCliProcess(process, 'one-shot aspire command'); + void terminateCliProcess(process, 'one-shot aspire command').catch(error => { + extensionLogOutputChannel.error(`Failed to terminate one-shot aspire command: ${String(error)}`); + }); } this._oneShotProcesses.clear(); } diff --git a/extension/src/data/appHostPsPoller.ts b/extension/src/data/appHostPsPoller.ts index 35a30a78777..69e571048b3 100644 --- a/extension/src/data/appHostPsPoller.ts +++ b/extension/src/data/appHostPsPoller.ts @@ -5,6 +5,7 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { extensionLogOutputChannel } from '../utils/logging'; import { errorFetchingAppHosts } from '../loc/strings'; import { AppHostCliRunner, LimitedOutputBuffer, oneShotOutputBufferLimit } from './appHostCliRunner'; +import { windowCliPathTarget } from '../utils/cliPathVariables'; export interface PsOutput { readonly stdout: string; @@ -118,7 +119,9 @@ export class AppHostPsPoller implements vscode.Disposable { extensionLogOutputChannel.info(`aspire ps polling stopped`); } for (const psProcess of this._psProcesses) { - terminateCliProcess(psProcess, 'aspire ps'); + void terminateCliProcess(psProcess, 'aspire ps').catch(error => { + extensionLogOutputChannel.error(`Failed to terminate aspire ps: ${String(error)}`); + }); } this._psProcesses.clear(); } @@ -141,7 +144,7 @@ export class AppHostPsPoller implements vscode.Disposable { this._psFollowStartPending = true; let cliPath: string; try { - cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + cliPath = await this._terminalProvider.getAspireCliExecutablePath(windowCliPathTarget); } catch (error) { if (this._isCurrentPsFetch(fetchVersion)) { this._psFollowStartPending = false; @@ -169,7 +172,7 @@ export class AppHostPsPoller implements vscode.Disposable { } }; - const args = this._cliRunner.withNoLogo(['ps', '--follow', '--format', 'json']); + const args = this._cliRunner.withNoLogo(['ps', '--follow', '--format', 'json'], cliPath); const psFollowStdout = new LimitedOutputBuffer(AppHostPsPoller._oneShotOutputBufferLimit); const psFollowStderr = new LimitedOutputBuffer(AppHostPsPoller._oneShotOutputBufferLimit); @@ -201,7 +204,7 @@ export class AppHostPsPoller implements vscode.Disposable { } if (code !== 0) { - if (this._cliRunner.disableNoLogoForRetry(args, psFollowStdout.value, psFollowStderr.value, 'aspire ps --follow')) { + if (this._cliRunner.disableNoLogoForRetry(cliPath, args, psFollowStdout.value, psFollowStderr.value, 'aspire ps --follow')) { this._startPsFollow(); return; } @@ -329,7 +332,7 @@ export class AppHostPsPoller implements vscode.Disposable { let cliPath: string; try { - cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + cliPath = await this._terminalProvider.getAspireCliExecutablePath(windowCliPathTarget); } catch (error) { if (isCurrentPsCommand()) { const rawErrorMessage = String(error); @@ -342,6 +345,7 @@ export class AppHostPsPoller implements vscode.Disposable { if (!isCurrentPsCommand()) { return; } + const invocationArgs = this._cliRunner.normalizeNoLogoArgs(cliPath, args); let stdout = ''; let stderr = ''; @@ -357,7 +361,7 @@ export class AppHostPsPoller implements vscode.Disposable { } }; - psProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + psProcess = spawnCliProcess(this._terminalProvider, cliPath, invocationArgs, { createProcessGroup: true, noExtensionVariables: true, stdoutCallback: (data) => { stdout += data; }, @@ -366,7 +370,7 @@ export class AppHostPsPoller implements vscode.Disposable { removePsProcess(); if (!callbackInvoked) { if ((code ?? 1) !== 0) { - const retryArgs = this._cliRunner.tryGetNoLogoRetryArgs(args, stdout, stderr, 'aspire ps'); + const retryArgs = this._cliRunner.tryGetNoLogoRetryArgs(cliPath, invocationArgs, stdout, stderr, 'aspire ps'); if (retryArgs) { this._runPsCommand(retryArgs, callback, options); return; diff --git a/extension/src/dcp/types.ts b/extension/src/dcp/types.ts index cea0d72063a..64528ca6ac2 100644 --- a/extension/src/dcp/types.ts +++ b/extension/src/dcp/types.ts @@ -134,6 +134,33 @@ export function isMauiLaunchConfiguration(obj: any): obj is MauiLaunchConfigurat return obj && obj.type === 'maui'; } +export interface JavaLaunchConfiguration extends ExecutableLaunchConfiguration { + type: "java"; + request?: "launch" | "attach"; + working_directory?: string; + // A fully qualified class name, optionally prefixed with a Java module name + // ("[module/]com.example.Api"), or the path of the .java source file declaring main. Absent when + // the IDE should resolve the entry point itself. A JAR path is never valid here; an executable + // JAR is sent on class_paths with its manifest Main-Class here. + // See src/Aspire.Hosting.Java/JavaLaunchConfiguration.cs. + main_class?: string; + // The name the Java tooling imported this resource's project under. Only sent when main_class + // could not be determined, to scope the adapter's entry point search to a single project. + project_name?: string; + // Classpath entries to launch the JVM with, used when the resource runs a prebuilt JAR. Absent + // when the IDE should resolve the classpath from the project itself. + class_paths?: string[]; + // JVM options (e.g. "-Xmx512m"). These are the JVM's own arguments, not the application's. + vm_args?: string[]; + // "maven" or "gradle", or absent when the resource runs a prebuilt JAR and therefore has no + // build files whose classpath the Java language server could refresh. + build_tool?: string; +} + +export function isJavaLaunchConfiguration(obj: any): obj is JavaLaunchConfiguration { + return obj && obj.type === 'java'; +} + export interface EnvVar { name: string; value: string; @@ -145,10 +172,12 @@ export interface RunSessionPayload { args?: string[]; } +export type DebugConfigurationArguments = string | string[]; + export interface DebugLaunchSettings { [key: string]: unknown; env?: { [key: string]: string }; - args?: string[]; + args?: DebugConfigurationArguments; launchProfile?: string; disableLaunchProfile?: boolean; } @@ -229,6 +258,7 @@ export interface AspireExtendedDebugConfiguration extends vscode.DebugConfigurat args?: string[]; step?: string; skipCliAvailabilityCheck?: boolean; + resolvedCliPath?: string; env?: { [key: string]: string }; [appHostLaunchTokenConfigKey]?: number; [appHostRestartSourceSessionIdConfigKey]?: string; diff --git a/extension/src/debugger/AspireDebugConfigurationMetadata.ts b/extension/src/debugger/AspireDebugConfigurationMetadata.ts index fd70657ea9a..1522d5c0e17 100644 --- a/extension/src/debugger/AspireDebugConfigurationMetadata.ts +++ b/extension/src/debugger/AspireDebugConfigurationMetadata.ts @@ -4,8 +4,8 @@ export const appHostLaunchTokenConfigKey = '__aspireAppHostLaunchToken'; export const appHostRestartSourceSessionIdConfigKey = '__aspireAppHostRestartSourceSessionId'; // This internal field survives VS Code's two debug-configuration resolver stages so the -// eventual CLI process can distinguish a launch.json-owned target from a persisted default. +// eventual CLI process can distinguish invocation-scoped selections from persistent choices. export const appHostSelectionOriginConfigKey = '__aspireAppHostSelectionOrigin'; /** Who chose the AppHost this session launches. */ -export type AppHostSelectionOrigin = 'explicit-launch-configuration' | 'default-discovery' | 'user-selection'; +export type AppHostSelectionOrigin = 'explicit-launch-configuration' | 'explicit-cli' | 'default-discovery' | 'user-selection'; diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts index ed301daafa7..4633e4a5336 100644 --- a/extension/src/debugger/AspireDebugConfigurationProvider.ts +++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts @@ -1,17 +1,21 @@ import * as vscode from 'vscode'; -import { appHostLifecycleLaunchAlreadyClaimed, defaultConfigurationName } from '../loc/strings'; -import type { AspireExtendedDebugConfiguration } from '../dcp/types'; +import * as path from 'path'; +import { appHostLifecycleLaunchAlreadyClaimed, defaultConfigurationName, defaultConfigurationNameForWorkspaceFolder } from '../loc/strings'; +import type { AspireCommandType, AspireExtendedDebugConfiguration } from '../dcp/types'; import { AppHostDiscoveryService, getDebugTargetForCandidate, isSamePath } from '../utils/appHostDiscovery'; import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { compareAppHostIdentity } from '../utils/appHostIdentity'; import { checkCliAvailableOrRedirect } from '../utils/workspace'; +import { getCliPathTargetForUri, getCliPathTargetKey, windowCliPathTarget, workspaceFolderCliPathTarget, type CliPathResolutionTarget } from '../utils/cliPathVariables'; import { extensionLogOutputChannel } from '../utils/logging'; import { appHostLaunchReservationIdConfigKey, appHostSelectionOriginConfigKey, appHostTelemetryTargetPathConfigKey } from './AspireDebugConfigurationMetadata'; import { getAspireDebugConfigurationCommand } from '../services/AppHostLaunchService'; -import { getAspireDebugConfigurationExternalLaunchReservation, isAspireDebugConfigurationExtensionOwned, markAspireDebugConfigurationAsExtensionOwned, markAspireDebugConfigurationWithExternalLaunchReservation } from './AspireDebugConfigurationProviderInternal'; +import { getAspireDebugConfigurationExternalLaunchReservation, getAspireDebugConfigurationResolvedCliPath, getAspireDebugConfigurationResolvedCliPathScope, isAspireDebugConfigurationExtensionOwned, markAspireDebugConfigurationAsExtensionOwned, markAspireDebugConfigurationWithExternalLaunchReservation, markAspireDebugConfigurationWithResolvedCliPath, markAspireDebugConfigurationWithResolvedCliPathScope } from './AspireDebugConfigurationProviderInternal'; export { stripAspireDebugConfigurationProviderInternalProperties } from './AspireDebugConfigurationProviderInternal'; +const legacyDynamicConfigurationOwnerWorkspaceStateKey = 'aspire.debugger.legacyDynamicConfigurationOwnerUri'; + /** * The part of `AppHostLaunchService` this provider needs to make a `launch.json`/F5 * launch visible to the shared launching reservation. @@ -19,14 +23,33 @@ export { stripAspireDebugConfigurationProviderInternalProperties } from './Aspir export interface ExternalLaunchReservation { /** Returns the reservation ID, or `false` when another launch or run session already owns this AppHost. */ tryReserveExternalLaunch(appHostPath: string, isDirectoryScope?: boolean): string | false; + /** + * Validates and refreshes this launch's reservation, reacquiring it with a new ID when + * it expired, or returns `false` when another launch now owns the AppHost. + */ + validateOrReacquireExternalLaunchReservation(appHostPath: string, reservationId: string, isDirectoryScope?: boolean): string | false; /** Replaces this resolver's previous reservation, or returns `false` when the new AppHost is already owned. */ replaceExternalLaunchReservation(previousAppHostPath: string, previousReservationId: string, appHostPath: string, isDirectoryScope?: boolean): string | false; + /** Releases the reservation only when the path and reservation ID still identify the same launch. */ + releaseExternalLaunchReservation(appHostPath: string, reservationId: string): void; + /** Prepares root Aspire CLI args for the exact executable that will handle this launch. */ + prepareLaunchArguments( + appHostPath: string, + command: AspireCommandType, + args: string[] | undefined, + token: vscode.CancellationToken, + cliPath?: string, + target?: CliPathResolutionTarget, + ): Promise<{ args: string[] | undefined }>; } export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider { + private _legacyDynamicConfigurationOwnerUri: string | undefined; + constructor( private readonly _appHostDiscoveryService: AppHostDiscoveryService, private readonly _launchReservation: ExternalLaunchReservation, + private readonly _workspaceState: vscode.Memento, private readonly _triggerKind: vscode.DebugConfigurationProviderTriggerKind = vscode.DebugConfigurationProviderTriggerKind.Dynamic) { } @@ -37,33 +60,34 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati const activeEditor = vscode.window.activeTextEditor; if (!activeEditor) { - return [this.createDefaultConfiguration(folder)]; + return this.createDefaultConfigurations(folder); } const activeEditorFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri); if (activeEditorFolder?.uri.toString() !== folder.uri.toString()) { - return [this.createDefaultConfiguration(folder)]; + return this.createDefaultConfigurations(folder); } const candidate = await this.tryFindCandidateForEditorFile(activeEditor.document.uri.fsPath, folder); if (!candidate) { - return [this.createDefaultConfiguration(folder)]; + return this.createDefaultConfigurations(folder); } - return [this.withProvidedSelectionOrigin({ - type: 'aspire', - request: 'launch', - name: defaultConfigurationName, - program: getDebugTargetForCandidate(candidate) - })]; + return this.createProvidedConfigurations(folder, getDebugTargetForCandidate(candidate)); } async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { const aspireConfig = config as AspireExtendedDebugConfiguration; this.ensureAppHostSelectionOrigin(aspireConfig); if (!aspireConfig.skipCliAvailabilityCheck) { - const result = await checkCliAvailableOrRedirect('debug_gate'); - if (!result.available) { + const program = typeof config.program === 'string' ? config.program : undefined; + const programFolder = program && path.isAbsolute(program) && !program.includes('${') + ? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(program)) + : undefined; + const target = programFolder + ? workspaceFolderCliPathTarget(programFolder) + : folder ? workspaceFolderCliPathTarget(folder) : windowCliPathTarget; + if (!(await this.validateAndTrustCliPath(config, target))) { return undefined; // Cancel the debug session } } @@ -102,6 +126,13 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati // property. A launch.json can spell `launchedByExtension`, but it cannot know the // per-activation value that makes the property authoritative. const launchedByExtension = isAspireDebugConfigurationExtensionOwned(config); + const resolvedCliPath = await this.reresolveCliPathForSubstitutedProgram(config); + if (resolvedCliPath !== undefined) { + aspireConfig.resolvedCliPath = resolvedCliPath; + } + else if (!launchedByExtension) { + delete aspireConfig.resolvedCliPath; + } const existingExternalReservation = getAspireDebugConfigurationExternalLaunchReservation(config); if (launchedByExtension) { markAspireDebugConfigurationAsExtensionOwned(config); @@ -112,9 +143,11 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati existingExternalReservation.reservationId, existingExternalReservation.appHostPath, existingExternalReservation.isDirectoryScope); + } + if (existingExternalReservation) { configRecord[appHostLaunchReservationIdConfigKey] = existingExternalReservation.reservationId; } - else { + else if (!launchedByExtension) { delete configRecord[appHostLaunchReservationIdConfigKey]; } delete aspireConfig.skipCliAvailabilityCheck; @@ -137,6 +170,46 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati delete config[appHostTelemetryTargetPathConfigKey]; } + const command = getAspireDebugConfigurationCommand(aspireConfig); + const launchTargetPath = telemetryTarget?.path ?? (typeof config.program === 'string' ? config.program : undefined); + if (!launchedByExtension && command === 'run' && launchTargetPath) { + const cliPath = aspireConfig.resolvedCliPath ?? await this.validateAndTrustCliPath( + config, + getCliPathTargetForUri(vscode.Uri.file(launchTargetPath))); + if (!cliPath) { + return undefined; + } + + const cancellationToken = token ?? { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => { } }), + } as vscode.CancellationToken; + let prepared: Awaited>; + try { + prepared = await this._launchReservation.prepareLaunchArguments( + launchTargetPath, + command, + Array.isArray(config.args) ? [...config.args] : undefined, + cancellationToken, + cliPath, + getCliPathTargetForUri(vscode.Uri.file(launchTargetPath))); + } + catch (error) { + if (existingExternalReservation) { + this._launchReservation.releaseExternalLaunchReservation( + existingExternalReservation.appHostPath, + existingExternalReservation.reservationId); + } + throw error; + } + if (prepared.args === undefined) { + delete config.args; + } + else { + config.args = prepared.args; + } + } + // This is the last hook before VS Code creates the session, and it is the only // point a `launch.json`/F5 launch shares with the tool-driven path, which goes // through `AppHostLaunchService`. Claiming here is what stops an agent from @@ -147,8 +220,8 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati // default `${workspaceFolder}` configuration deliberately leaves `program` as // the directory, and a directory is not the same identity as the AppHost inside // it, so claiming the directory would leave the tool free to start a duplicate. - if (!launchedByExtension && getAspireDebugConfigurationCommand(aspireConfig) === 'run') { - const claimedPath = telemetryTarget?.path ?? (typeof config.program === 'string' ? config.program : undefined); + if (!launchedByExtension && command === 'run') { + const claimedPath = launchTargetPath; if (!claimedPath) { return config; } @@ -161,7 +234,10 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati } else if (existingExternalReservation.isDirectoryScope === isDirectoryScope && compareAppHostIdentity(existingExternalReservation.appHostPath, claimedPath) === 'same') { - reservationId = existingExternalReservation.reservationId; + reservationId = this._launchReservation.validateOrReacquireExternalLaunchReservation( + existingExternalReservation.appHostPath, + existingExternalReservation.reservationId, + isDirectoryScope); // Keep the path where the reservation was actually stored. The identity // can become ambiguous on a later resolver pass if sibling files appear. reservationPath = existingExternalReservation.appHostPath; @@ -220,19 +296,94 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati } } - private createDefaultConfiguration(folder: vscode.WorkspaceFolder): vscode.DebugConfiguration { - return this.withProvidedSelectionOrigin({ + private createDefaultConfigurations(folder: vscode.WorkspaceFolder): Promise { + return this.createProvidedConfigurations(folder, folder.uri.fsPath); + } + + private async createProvidedConfigurations(folder: vscode.WorkspaceFolder, program: string): Promise { + const isDynamic = this._triggerKind === vscode.DebugConfigurationProviderTriggerKind.Dynamic; + const config: vscode.DebugConfiguration = { type: 'aspire', request: 'launch', - name: defaultConfigurationName, - program: folder.uri.fsPath - }); + name: isDynamic + ? await this.getDynamicConfigurationName(folder) + : defaultConfigurationName, + program + }; + + if (!isDynamic) { + return [config]; + } + + return [{ ...config, [appHostSelectionOriginConfigKey]: 'default-discovery' }]; + } + + private async getDynamicConfigurationName(folder: vscode.WorkspaceFolder): Promise { + let ownerUri = this._legacyDynamicConfigurationOwnerUri + ?? this._workspaceState.get(legacyDynamicConfigurationOwnerWorkspaceStateKey); + if (ownerUri === undefined) { + // Keep the first folder's shipped configuration name across workspace changes. VS Code + // does not hide dynamic configurations through `presentation.hidden`, so ownership must + // be persisted instead of returning a compatibility alias that becomes a duplicate. + ownerUri = vscode.workspace.workspaceFolders?.[0]?.uri.toString() ?? folder.uri.toString(); + this._legacyDynamicConfigurationOwnerUri = ownerUri; + await this._workspaceState.update(legacyDynamicConfigurationOwnerWorkspaceStateKey, ownerUri); + } + else { + this._legacyDynamicConfigurationOwnerUri = ownerUri; + } + + return ownerUri === folder.uri.toString() + ? defaultConfigurationName + : defaultConfigurationNameForWorkspaceFolder(folder.name, folder.uri.toString()); } - private withProvidedSelectionOrigin(config: vscode.DebugConfiguration): vscode.DebugConfiguration { - return this._triggerKind === vscode.DebugConfigurationProviderTriggerKind.Dynamic - ? { ...config, [appHostSelectionOriginConfigKey]: 'default-discovery' } - : config; + /** + * Returns the CLI path the session should launch with, re-resolving when variable substitution + * revealed that the program belongs to a workspace folder other than the one the availability gate + * used. + * + * `resolveDebugConfiguration` runs before VS Code substitutes variables, so a `program` such as + * `${workspaceFolder:other}/AppHost.java`, or a relative one, is still opaque there and the gate + * can only fall back to the initiating folder. Without this the target folder's AppHost would be + * launched with the initiating folder's `aspire.cliPath` — in a multi-root workspace that is + * frequently a different CLI build entirely, and the mismatch is silent. + */ + private async reresolveCliPathForSubstitutedProgram(config: vscode.DebugConfiguration): Promise { + const gatedCliPath = getAspireDebugConfigurationResolvedCliPath(config); + if (gatedCliPath === undefined) { + // No gate ran (an extension-owned launch, or the check was skipped), so there is nothing + // to correct. + return undefined; + } + + const program = typeof config.program === 'string' ? config.program : undefined; + if (!program || !path.isAbsolute(program) || program.includes('${')) { + return gatedCliPath; + } + + const programFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(program)); + if (!programFolder) { + return gatedCliPath; + } + + const target = workspaceFolderCliPathTarget(programFolder); + if (getCliPathTargetKey(target) === getAspireDebugConfigurationResolvedCliPathScope(config)) { + // Same scope the gate already used, so re-resolving would only repeat work and risk a + // second availability prompt. + return gatedCliPath; + } + + const result = await checkCliAvailableOrRedirect('debug_gate', target); + if (!result.available) { + // Keep the gated path rather than cancelling: the gate already proved a usable CLI, and the + // session is better served by that one than by failing outright. + return gatedCliPath; + } + + markAspireDebugConfigurationWithResolvedCliPath(config, result.cliPath); + markAspireDebugConfigurationWithResolvedCliPathScope(config, getCliPathTargetKey(target)); + return result.cliPath; } private isWorkspaceFolderRoot(program: string, folder: vscode.WorkspaceFolder | undefined): boolean { @@ -247,4 +398,19 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati : 'default-discovery'; } } + + private async validateAndTrustCliPath(config: vscode.DebugConfiguration, target: CliPathResolutionTarget): Promise { + const result = await checkCliAvailableOrRedirect( + 'debug_gate', + target, + getAspireDebugConfigurationResolvedCliPath(config)); + if (!result.available) { + return undefined; + } + + (config as AspireExtendedDebugConfiguration).resolvedCliPath = result.cliPath; + markAspireDebugConfigurationWithResolvedCliPath(config, result.cliPath); + markAspireDebugConfigurationWithResolvedCliPathScope(config, getCliPathTargetKey(target)); + return result.cliPath; + } } diff --git a/extension/src/debugger/AspireDebugConfigurationProviderInternal.ts b/extension/src/debugger/AspireDebugConfigurationProviderInternal.ts index 10bb7c19c43..3d9b1f1b331 100644 --- a/extension/src/debugger/AspireDebugConfigurationProviderInternal.ts +++ b/extension/src/debugger/AspireDebugConfigurationProviderInternal.ts @@ -5,6 +5,8 @@ import type { AspireExtendedDebugConfiguration } from '../dcp/types'; const extensionOwnedConfigurationMarker = `__aspireAppHostLaunchServiceConfiguration_${randomUUID()}`; const extensionOwnedConfigurationValue = randomUUID(); const externalLaunchReservationMarker = `__aspireExternalLaunchReservation_${randomUUID()}`; +const resolvedCliPathMarker = `__aspireResolvedCliPath_${randomUUID()}`; +const resolvedCliPathScopeMarker = `__aspireResolvedCliPathScope_${randomUUID()}`; interface ExternalLaunchReservationMarker { reservationId: string; @@ -46,9 +48,39 @@ export function getAspireDebugConfigurationExternalLaunchReservation(configurati : undefined; } +export function markAspireDebugConfigurationWithResolvedCliPath(configuration: vscode.DebugConfiguration, cliPath: string): void { + (configuration as Record)[resolvedCliPathMarker] = cliPath; +} + +export function getAspireDebugConfigurationResolvedCliPath(configuration: vscode.DebugConfiguration): string | undefined { + const cliPath = (configuration as Record)[resolvedCliPathMarker]; + return typeof cliPath === 'string' ? cliPath : undefined; +} + +/** + * Records which configuration scope the CLI availability gate resolved against. + * + * The gate runs before VS Code substitutes variables, so a `program` such as + * `${workspaceFolder:other}/AppHost.java` — or a relative one — is still opaque and the gate can only + * use the initiating folder. Recording the scope lets the substituted resolver notice that the + * concrete program belongs to a different folder and re-resolve, instead of launching that folder's + * AppHost with another folder's configured CLI. + */ +export function markAspireDebugConfigurationWithResolvedCliPathScope(configuration: vscode.DebugConfiguration, scope: string): void { + (configuration as Record)[resolvedCliPathScopeMarker] = scope; +} + +/** @see markAspireDebugConfigurationWithResolvedCliPathScope */ +export function getAspireDebugConfigurationResolvedCliPathScope(configuration: vscode.DebugConfiguration): string | undefined { + const scope = (configuration as Record)[resolvedCliPathScopeMarker]; + return typeof scope === 'string' ? scope : undefined; +} + export function stripAspireDebugConfigurationProviderInternalProperties(configuration: vscode.DebugConfiguration): void { const configRecord = configuration as Record; delete configRecord[extensionOwnedConfigurationMarker]; delete configRecord[externalLaunchReservationMarker]; + delete configRecord[resolvedCliPathMarker]; + delete configRecord[resolvedCliPathScopeMarker]; delete configRecord.launchedByExtension; } diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 7b68658a14b..e9221358516 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -2,17 +2,18 @@ import * as vscode from "vscode"; import { EventEmitter } from "vscode"; import { promises as fs } from "fs"; import { createDebugAdapterTracker, AppHostOutputHandler, AppHostRestartHandler } from "./adapterTracker"; -import { AspireResourceExtendedDebugConfiguration, AspireResourceDebugSession, EnvVar, AspireExtendedDebugConfiguration, NodeLaunchConfiguration, ProcessRestartedNotification, ProjectLaunchConfiguration, RustLaunchConfiguration, SessionTerminatedNotification, StartAppHostOptions, AspireOperationKind } from "../dcp/types"; +import { AspireResourceExtendedDebugConfiguration, AspireResourceDebugSession, EnvVar, AspireExtendedDebugConfiguration, NodeLaunchConfiguration, ProcessRestartedNotification, ProjectLaunchConfiguration, JavaLaunchConfiguration, RustLaunchConfiguration, SessionTerminatedNotification, StartAppHostOptions, AspireOperationKind } from "../dcp/types"; import { extensionLogOutputChannel } from "../utils/logging"; import AspireDcpServer, { generateDcpIdPrefix } from "../dcp/AspireDcpServer"; -import { spawnCliProcess, terminateCliProcess } from "../utils/process/cliProcess"; -import { disconnectingFromSession, launchingWithAppHost, launchingWithDirectory, processExceptionOccurred, processExitedWithCode, appHostSessionTerminated, debugSessionsFailedToStop, debugSessionStartTimedOut, debugSessionStopTimedOut, rustDebuggerExtensionNotInstalled } from "../loc/strings"; +import { redactCliArgsForLogging, spawnCliProcess, terminateCliProcess } from "../utils/process/cliProcess"; +import { disconnectingFromSession, launchingWithAppHost, launchingWithDirectory, processExceptionOccurred, processExitedWithCode, appHostSessionTerminated, debugSessionsFailedToStop, debugSessionStartTimedOut, debugSessionStopTimedOut, rustDebuggerExtensionNotInstalled, javaDebuggerExtensionNotInstalled, javaAppHostCommandNotRecognized } from "../loc/strings"; import { isExtensionInstalled } from "../capabilities"; import { projectDebuggerExtension } from "./languages/dotnet"; import { AnsiColors } from "../utils/AspireTerminalProvider"; import { applyTextStyle } from "../utils/strings"; import { nodeDebuggerExtension } from "./languages/node"; import { createDefaultRustDebuggerExtension } from "./languages/rust"; +import { javaDebuggerExtension, parseJavaAppHostCommand, resolveJavaClassPaths } from "./languages/java"; import { cleanupRun } from "./runCleanupRegistry"; import { runWithRunStartWrappers } from "./runStartRegistry"; import AspireRpcServer from "../server/AspireRpcServer"; @@ -29,9 +30,12 @@ import { bucketAspireCommand } from "../utils/telemetryBuckets"; import { getAppHostTargetVersion } from "../utils/appHostTargetVersion"; import type { AspireDebugConsoleOutputEvent } from "../types/extensionApi"; import { appHostRestartSourceSessionIdConfigKey, appHostSelectionOriginConfigKey, appHostTelemetryTargetPathConfigKey } from "./AspireDebugConfigurationMetadata"; +import { markAspireDebugConfigurationWithResolvedCliPath, markAspireDebugConfigurationWithResolvedCliPathScope } from "./AspireDebugConfigurationProviderInternal"; import { AppHostParentOutputFilter } from "./session/appHostParentOutputFilter"; +import { getCliPathTargetForUri, getCliPathTargetKey, windowCliPathTarget } from "../utils/cliPathVariables"; import { DashboardLauncher, type DashboardBrowserType, type DashboardLauncherHost } from "./session/dashboardLauncher"; import { describeStopFailure, startStop, stopSessionInBackground } from "./session/stopHelpers"; +import { hasRootNoLogoOption } from "../utils/cliCompatibility"; export type AppHostDebugSessionTracker = (owner: AspireDebugSession, appHostPath: string, debugSession: AspireResourceDebugSession) => void; @@ -52,26 +56,63 @@ function getOperationKind(value: unknown): AspireOperationKind { } export function getLoggableDebugConfiguration(debugConfig: AspireResourceExtendedDebugConfiguration, includeEnvironment: boolean): vscode.DebugConfiguration { + // Debugger argument lists can include forwarded AppHost secrets. Redact them before the + // environment branches so the include-environment fast path also returns a safe clone. + const loggableConfig = { + ...debugConfig, + args: debugConfig.args === undefined ? undefined : '', + runtimeArgs: debugConfig.runtimeArgs === undefined ? undefined : '', + }; + if (includeEnvironment && !debugConfigurationsWithSensitiveEnvironment.has(debugConfig)) { if (debugConfig.type !== 'maui') { - return debugConfig; + return loggableConfig; } return { - ...debugConfig, + ...loggableConfig, environmentVariables: debugConfig.environmentVariables ? '' : undefined, }; } return { - ...debugConfig, + ...loggableConfig, env: debugConfig.env ? '' : undefined, environment: debugConfig.environment ? '' : undefined, environmentVariables: debugConfig.environmentVariables ? '' : undefined, + // A JVM system property is the ordinary way configuration - including credentials - reaches a + // Java process (-Dspring.datasource.password=..., -Djavax.net.ssl.trustStorePassword=...), so + // vmArgs belongs in the same class as the environment rather than alongside plain arguments. + ...redactedJavaLaunchFields(debugConfig), msbuildProperties: debugConfig.msbuildProperties instanceof Map ? Object.fromEntries(debugConfig.msbuildProperties) : debugConfig.msbuildProperties, }; } +/** + * Redactions for the Java-specific launch fields, which the extension log would otherwise persist to + * disk in full. + * + * Classpaths are absolute and name the developer's home directory and private project names, so only + * their count survives - enough to tell an empty classpath from a populated one when diagnosing a + * `ClassNotFoundException`, without recording the paths themselves. Both fields are logged verbatim + * when `aspire.enableDebugConfigEnvironmentLogging` is on, which is the existing opt-in for exactly + * this trade. + */ +function redactedJavaLaunchFields(debugConfig: AspireResourceExtendedDebugConfiguration): Record { + const redacted: Record = {}; + + if (debugConfig.vmArgs !== undefined) { + redacted.vmArgs = ''; + } + + if (Array.isArray(debugConfig.classPaths)) { + const count = debugConfig.classPaths.length; + redacted.classPaths = ``; + } + + return redacted; +} + export class AspireDebugSession implements vscode.DebugAdapter, DashboardLauncherHost { private static readonly _mauiDebugStartMaxAttempts = 3; private static readonly _mauiDebugStartRetryDelayMs = 5000; @@ -87,6 +128,21 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche * involved, where per-session timeouts would scale the worst case with the resource count. */ private static readonly _stopSessionsTimeoutMs = 10000; + /** + * Portion of {@link _stopSessionsTimeoutMs} withheld from the resource and dashboard stops so the + * AppHost stop and the Aspire parent stop always get a usable budget. + * + * Without a reserve the phases compete for one deadline, and a resource adapter that is slow to + * acknowledge takes all of it. That is not a rare case: a Java, .NET or Node adapter suspended at + * a breakpoint does not acknowledge `stopDebugging()` until the runtime resumes, so the common + * "stop while stopped at a breakpoint" gesture reliably drains the whole budget. The AppHost stop + * then starts with 0ms, times out immediately, and the AppHost is left running in the Call Stack + * pane even though the debug session disappeared - the reported symptom. + * + * The resource phase still gets the majority of the budget because it is the phase with an + * unbounded number of participants; the reserve only has to cover two further stops. + */ + private static readonly _appHostStopReserveMs = 4000; /** * How long the cooperative `stopCli` RPC has to bring the CLI down before its process group is * signalled. Long enough for the CLI to stop containers and other resources cleanly, short @@ -153,6 +209,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche private _cliProcess: ChildProcessWithoutNullStreams | undefined; private _cliTerminationTimer: ReturnType | undefined; private _cliProcessTreeTerminationAttempted = false; + private _cliProcessTreeTerminationPromise: Promise | undefined; private _extensionShutdownRequested = false; // Timestamp for the `debug/apphost/end` duration measurement. Captured the first // time we observe a `launch` request so it covers the actual user-visible session @@ -362,6 +419,10 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche // One deadline for the whole shutdown rather than one per stop, so the worst case does not grow // with the number of resources. See _stopSessionsTimeoutMs for why this has to be bounded. const deadline = Date.now() + AspireDebugSession._stopSessionsTimeoutMs; + // Resource and dashboard stops run against an earlier deadline so that whatever they consume, + // the AppHost and parent stops below still have _appHostStopReserveMs to work with. See + // _appHostStopReserveMs for why a single shared deadline leaves the AppHost running. + const resourceDeadline = deadline - AspireDebugSession._appHostStopReserveMs; // A dashboard or resource launched under a debugger can keep the AppHost shutdown in flight // until its debug session exits. Stop those sessions before the AppHost to avoid waiting on a @@ -373,11 +434,11 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche // the path where a resource is most likely to be left behind. The rejection is kept and // rethrown after the AppHost and the synthetic Aspire parent have been stopped. const [dashboardResult, ...resourceResults] = await Promise.allSettled([ - this._dashboardLauncher.stopDashboardWithinBudget(deadline), + this._dashboardLauncher.stopDashboardWithinBudget(resourceDeadline), ...resourceDebugSessions.map(session => this.stopWithinBudget( () => session.stopSession(), session.session.name, - deadline, + resourceDeadline, () => session.resetStopSessionAttempt?.())), ]); const stopFailures: unknown[] = [dashboardResult, ...resourceResults] @@ -391,8 +452,8 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche this._resourceDebugSessions = this._resourceDebugSessions.filter( session => unstoppedResourceSessions.has(session) || session.id === this._appHostDebugSession?.id); - let pendingStartBudgetExhausted = await this.drainPendingDebugSessionStarts(deadline, stopFailures); - await this.drainLateResourceStops(deadline, stopFailures); + let pendingStartBudgetExhausted = await this.drainPendingDebugSessionStarts(resourceDeadline, stopFailures); + await this.drainLateResourceStops(resourceDeadline, stopFailures); // Global/E2E stop requests target the synthetic Aspire session. Stop the real AppHost session // explicitly before the parent so we do not rely on VS Code cascading termination before the @@ -410,6 +471,18 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche } catch (err) { stopFailures.push(err); + + // The AppHost did not confirm it stopped, which in practice means its adapter is wedged - + // most often suspended at a breakpoint in the AppHost itself, where `stopDebugging()` is + // not acknowledged until the runtime resumes. VS Code still tears the session down, so the + // user sees the debug session disappear while the AppHost process keeps running and its + // resources stay in the Call Stack pane. Escalate to the CLI process tree, which owns that + // process: the cooperative `stopCli` gets the grace period first and the hard kill follows + // only if it does not land. + void this.requestCliStopForExtensionShutdown().catch(cliStopError => { + extensionLogOutputChannel.warn(`Failed to stop Aspire CLI after AppHost debug-session shutdown failed: ${cliStopError}`); + }); + this.scheduleCliProcessTermination(); } } @@ -583,28 +656,52 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche * signalled directly whenever the cooperative path did not finish the job. The leader may already * have exited by then, but that does not prove its descendants exited too. */ - terminateCliProcessTree(options?: { force?: boolean }): void { + terminateCliProcessTree(options?: { force?: boolean }): Promise { this.cancelScheduledCliProcessTermination(); const cliProcess = this._cliProcess; if (!cliProcess) { - return; + return Promise.resolve(); } // A force sweep can run after the CLI leader has exited. Never aim another signal at that // recorded PID afterward: on Windows the PID may already have been recycled, and `taskkill /t` // would then target an unrelated process tree. if (this._cliProcessTreeTerminationAttempted) { - return; + return this._cliProcessTreeTerminationPromise ?? Promise.resolve(); } // Deliberately not skipped once the leader has exited. `terminateCliProcess` reaps the surviving // members of a managed process group in that case, and that is the only path that collects // AppHost and resource processes which outlived the CLI that owned them. this._cliProcessTreeTerminationAttempted = true; - terminateCliProcess(cliProcess, `Aspire CLI for debug session ${this.debugSessionId}`, options); - if (this._disposed) { - this.releaseExtensionContextOwnership(); - } + const termination = terminateCliProcess( + cliProcess, + `Aspire CLI for debug session ${this.debugSessionId}`, + options); + let trackedTermination!: Promise; + trackedTermination = (async () => { + try { + await termination; + } + catch (error) { + extensionLogOutputChannel.error(`Failed to terminate Aspire CLI for debug session ${this.debugSessionId}: ${String(error)}`); + throw error; + } + finally { + if (this._cliProcessTreeTerminationPromise === trackedTermination) { + this._cliProcessTreeTerminationPromise = undefined; + } + if (this._disposed) { + this.releaseExtensionContextOwnership(); + } + } + })(); + this._cliProcessTreeTerminationPromise = trackedTermination; + // Exit callbacks and timers cannot await this method. Observe failures here while returning + // the same promise to lifecycle owners that can include it in bounded shutdown. + void this._cliProcessTreeTerminationPromise.catch(() => { }); + + return this._cliProcessTreeTerminationPromise; } private scheduleCliProcessTermination(): void { @@ -617,8 +714,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche // context, so use the hard-kill path rather than scheduling another unref'd escalation. this._cliTerminationTimer = setTimeout(() => { this._cliTerminationTimer = undefined; - this.terminateCliProcessTree({ force: true }); - this.releaseExtensionContextOwnership(); + void this.terminateCliProcessTree({ force: true }); }, AspireDebugSession._cliCooperativeStopGraceMs); this._cliTerminationTimer.unref?.(); } @@ -652,7 +748,9 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche } private releaseExtensionContextOwnership(): void { - if (this._removedFromExtensionContext) { + if (this._removedFromExtensionContext || + this._cliTerminationTimer || + this._cliProcessTreeTerminationPromise) { return; } @@ -885,18 +983,12 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche this._appHostIsDirectoryAtLaunch = appHostIsDirectory ? 'true' : 'false'; this._appHostLanguageAtLaunchPromise = this.resolveAppHostLanguageAtLaunch(appHostPath, appHostIsDirectory, appHostTelemetryTargetPath); - // For 'do' with an explicit step (old CLI fallback), pass it as a positional argument - const step = this.configuration.step; - if (command === 'do' && step && commandArgs.length === 0) { - extensionArgs.push(step); - } - // --start-debug-session tells the CLI to launch the AppHost via the extension with debugger attached if (!noDebug) { extensionArgs.push('--start-debug-session'); } - if (!commandArgs.includes('--nologo')) { + if (!hasRootNoLogoOption(commandArgs)) { extensionArgs.push('--nologo'); } @@ -912,11 +1004,11 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche extensionArgs.push('--debug'); } - if (!appHostIsDirectory) { + if (!appHostIsDirectory || appHostSelectionOrigin === 'explicit-cli') { extensionArgs.push('--apphost', appHostPath); } - const args = buildAspireCommandArgs(command, commandArgs, extensionArgs); + const args = buildAspireCommandArgs(command, commandArgs, extensionArgs, this.configuration.step); const commandLabel = `aspire ${command}`; const sessionType = noDebug ? 'run' : 'debug'; @@ -1023,7 +1115,15 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche return partial; }; - const cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + // Prefer the AppHost path this session actually resolved to, falling back to the raw + // configured program, then to the working directory when neither identifies an AppHost. + // A path outside every open workspace folder falls back to the window scope. + const cliPathTargetSource = this.resolvedAppHostPath ?? this.appHostPath ?? workingDirectory; + const cliPathTarget = cliPathTargetSource !== undefined + ? getCliPathTargetForUri(vscode.Uri.file(cliPathTargetSource)) + : windowCliPathTarget; + const cliPath = this.configuration.resolvedCliPath + ?? await this._terminalProvider.getAspireCliExecutablePath(cliPathTarget); if (this.isShuttingDown) { // CLI resolution can outlive shutdown. Spawning now would create a detached `aspire run` // after every teardown owner has already started or completed its cleanup. @@ -1057,7 +1157,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche // sweep of that same spent PID once the grace period elapses, so retire it here instead // of only skipping the immediate call. if (process.platform !== 'win32') { - this.terminateCliProcessTree({ force: true }); + void this.terminateCliProcessTree({ force: true }); } else { this.abandonCliProcessTree(); @@ -1095,7 +1195,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche void this.requestCliStopForExtensionShutdown().catch((err) => { extensionLogOutputChannel.info(`stopCli failed (connection may already be closed): ${err}`); }); - extensionLogOutputChannel.info(`Requested Aspire CLI exit with args: ${args.join(' ')}`); + extensionLogOutputChannel.info(`Requested Aspire CLI exit with args: ${redactCliArgsForLogging(args).join(' ')}`); // `stopCli` is cooperative and cannot be the only stop mechanism: it resolves without // effect when the transport is already closed, and never settles when the CLI has stopped // servicing the connection. Escalate to signalling the process group once the CLI has had @@ -1123,6 +1223,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche private static readonly _nodeAppHostExtensions = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts']; private static readonly _csharpAppHostExtensions = ['.cs', '.csproj']; private static readonly _rustAppHostExtensions = ['.rs']; + private static readonly _javaAppHostExtensions = ['.java']; private _appHostRestartRequested = false; private _preserveAppHostRestartSourceSessionId = false; @@ -1133,12 +1234,24 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche const isNodeAppHost = AspireDebugSession._nodeAppHostExtensions.includes(fileExtension); const isCSharpAppHost = AspireDebugSession._csharpAppHostExtensions.includes(fileExtension); const isRustAppHost = AspireDebugSession._rustAppHostExtensions.includes(fileExtension); + const isJavaAppHost = AspireDebugSession._javaAppHostExtensions.includes(fileExtension); + + // The CLI only routes an AppHost here when the language declares ExtensionLaunchCapability, so + // this is parsed before choosing a debugger: an unrecognised command means we cannot build a + // launch configuration for it, and guessing would start a JVM with the wrong arguments. + const javaCommand = isJavaAppHost ? parseJavaAppHostCommand(args) : null; + + if (isJavaAppHost && !javaCommand) { + throw new Error(javaAppHostCommandNotRecognized()); + } const debuggerExtension = isNodeAppHost ? nodeDebuggerExtension : isRustAppHost ? createDefaultRustDebuggerExtension() - : projectDebuggerExtension; + : isJavaAppHost + ? javaDebuggerExtension + : projectDebuggerExtension; // Resource launches are gated by getResourceDebuggerExtensions, which omits Rust when no native // debugger extension is installed. This path builds the descriptor directly, so without the same @@ -1149,6 +1262,12 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche throw new Error(rustDebuggerExtensionNotInstalled(debuggerExtension.extensionId)); } + // Same gate for Java: getResourceDebuggerExtensions only offers the Java adapter when the + // Debugger for Java extension is present, and this path bypasses that check. + if (isJavaAppHost && debuggerExtension.extensionId && !isExtensionInstalled(debuggerExtension.extensionId)) { + throw new Error(javaDebuggerExtensionNotInstalled(debuggerExtension.extensionId)); + } + // Register the adapter tracker with an app host restart handler. // When the user clicks "restart" on the app host child session, // we suppress VS Code's automatic child restart and restart the @@ -1203,15 +1322,30 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche working_directory: path.dirname(projectFile), } as RustLaunchConfiguration; } + else if (isJavaAppHost) { + // javaCommand is parsed above so the debugger choice can depend on it. The AppHost is + // compiled by the runtime spec's pre-execute step before this runs, so the classes the + // adapter needs already exist on disk whichever toolchain produced them. + appHostArgs = javaCommand!.appHostArgs; + launchConfig = { + type: 'java', + main_class: javaCommand!.mainClass, + class_paths: resolveJavaClassPaths(javaCommand!.classPaths, path.dirname(projectFile)), + working_directory: path.dirname(projectFile), + // build_tool is deliberately absent: it only drives a language server project reimport, + // and the classpath is sent explicitly here, so the launch never depends on one. + ...(javaCommand!.vmArgs.length > 0 ? { vm_args: javaCommand!.vmArgs } : {}) + } as JavaLaunchConfiguration; + } else { // The CLI sends the full dotnet CLI args (e.g., ["run", "--no-build", "--project", "...", "--", ...appHostArgs]). // Since we launch the apphost directly via the debugger (not via dotnet run), extract only the args after "--". const separatorIndex = args.indexOf('--'); - appHostArgs = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : args; + appHostArgs = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : []; launchConfig = { project_path: projectFile, type: 'project' } as ProjectLaunchConfiguration; } - extensionLogOutputChannel.info(`Starting AppHost for project: ${projectFile} with args: ${appHostArgs.join(' ')}`); + extensionLogOutputChannel.info(`Starting AppHost for project: ${projectFile} with argument count: ${appHostArgs.length}`); const appHostDebugSessionConfiguration = await createDebugSessionConfiguration( this.configuration, @@ -1265,11 +1399,27 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche void this.requestCliStopForExtensionShutdown().catch(cliStopError => { extensionLogOutputChannel.warn(`Failed to stop Aspire CLI after AppHost restart cleanup failed: ${cliStopError}`); }); - this.terminateCliProcessTree({ force: true }); + try { + await this.terminateCliProcessTree({ force: true }); + } + catch { + // terminateCliProcessTree already records the failure; the restart is aborted either way. + } return; } extensionLogOutputChannel.info('AppHost restart requested, restarting Aspire debug session'); + // The descriptor factory strips the private CLI marker before the adapter starts. + // Re-establish it for the replacement without marking that launch as owned by the old + // generation, so the provider validates this exact executable and reserves afresh. + if (typeof config.resolvedCliPath === 'string') { + const cliPathTargetSource = this.resolvedAppHostPath ?? this.appHostPath ?? config.program; + const cliPathTarget = typeof cliPathTargetSource === 'string' + ? getCliPathTargetForUri(vscode.Uri.file(cliPathTargetSource)) + : windowCliPathTarget; + markAspireDebugConfigurationWithResolvedCliPath(config, config.resolvedCliPath); + markAspireDebugConfigurationWithResolvedCliPathScope(config, getCliPathTargetKey(cliPathTarget)); + } await vscode.debug.startDebugging(undefined, config); } else { @@ -1622,9 +1772,7 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche this._onDidSendDebugConsoleOutput.dispose(); // Keep this disposed session tracked while its delayed CLI termination is pending, so // extension deactivation can still force-drain the process tree before VS Code exits. - if (!this._cliTerminationTimer) { - this.releaseExtensionContextOwnership(); - } + this.releaseExtensionContextOwnership(); // Telemetry: emit `debug/apphost/end` after a short grace window so any // pending `sessionTerminated` notifications kicked off by the child-stop @@ -1710,8 +1858,12 @@ export class AspireDebugSession implements vscode.DebugAdapter, DashboardLaunche } } -export function buildAspireCommandArgs(command: string, commandArgs: string[], extensionArgs: string[]): string[] { +export function buildAspireCommandArgs(command: string, commandArgs: string[], extensionArgs: string[], step?: string): string[] { const args = [command]; + if (command === 'do' && step) { + args.push(step); + } + const separatorIndex = commandArgs.indexOf('--'); if (separatorIndex < 0) { args.push(...commandArgs, ...extensionArgs); diff --git a/extension/src/debugger/debuggerExtensions.ts b/extension/src/debugger/debuggerExtensions.ts index 7a229e7e323..e06be72dfb8 100644 --- a/extension/src/debugger/debuggerExtensions.ts +++ b/extension/src/debugger/debuggerExtensions.ts @@ -4,7 +4,7 @@ import { debugProject, runProject } from "../loc/strings"; import { getEnvironmentWithoutE2EBridgeVariables, mergeEnvs } from "../utils/environment"; import { extensionLogOutputChannel } from "../utils/logging"; import { projectDebuggerExtension } from "./languages/dotnet"; -import { isAzureFunctionsExtensionInstalled, isBunInstalled, isCsharpInstalled, isGoInstalled, isMauiInstalled, isPythonInstalled, isRustInstalled } from '../capabilities'; +import { isAzureFunctionsExtensionInstalled, isBunInstalled, isCsharpInstalled, isGoInstalled, isJavaInstalled, isMauiInstalled, isPythonInstalled, isRustInstalled } from '../capabilities'; import { pythonDebuggerExtension } from "./languages/python"; import { nodeDebuggerExtension } from "./languages/node"; import { browserDebuggerExtension } from "./languages/browser"; @@ -12,6 +12,7 @@ import { azureFunctionsDebuggerExtension } from "./languages/azureFunctions"; import { goDebuggerExtension } from "./languages/go"; import { createDefaultRustDebuggerExtension } from "./languages/rust"; import { bunDebuggerExtension } from "./languages/bun"; +import { javaDebuggerExtension } from "./languages/java"; import { mauiDebuggerExtension } from "./languages/maui"; import { isDirectory } from "../utils/io"; import { waitForRunStartIdle } from "./runStartRegistry"; @@ -114,6 +115,10 @@ export function getResourceDebuggerExtensions(platform: NodeJS.Platform = proces extensions.push(createDefaultRustDebuggerExtension(platform)); } + if (isJavaInstalled()) { + extensions.push(javaDebuggerExtension); + } + extensions.push(nodeDebuggerExtension); extensions.push(browserDebuggerExtension); diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts index b853df99c43..44569f2d9e3 100644 --- a/extension/src/debugger/languages/dotnet.ts +++ b/extension/src/debugger/languages/dotnet.ts @@ -8,7 +8,7 @@ import * as readline from 'readline'; import * as os from 'os'; import * as fs from 'fs'; import { doesFileExist } from '../../utils/io'; -import { AspireResourceExtendedDebugConfiguration, EnvVar, ExecutableLaunchConfiguration, isProjectLaunchConfiguration, LaunchOptions, ProjectLaunchConfiguration } from '../../dcp/types'; +import { AspireResourceExtendedDebugConfiguration, DebugConfigurationArguments, EnvVar, ExecutableLaunchConfiguration, isProjectLaunchConfiguration, LaunchOptions, ProjectLaunchConfiguration } from '../../dcp/types'; import { ResourceDebuggerExtension } from '../debuggerExtensions'; import { readLaunchSettings, @@ -17,14 +17,15 @@ import { mergeEnvironmentVariables, determineArguments, determineWorkingDirectory, - determineServerReadyAction, LaunchProfileCommandName, LaunchProfile, LaunchSettings, expandEnvironmentVariables } from '../launchProfiles'; import { AspireDebugSession } from '../AspireDebugSession'; -import { createAspireCliPathProcessEnvironment } from '../../utils/cliPathEnvironment'; +import { createResolvedAspireCliPathProcessEnvironment } from '../../utils/cliPathEnvironment'; +import { resolveCliPath } from '../../utils/cliPath'; +import { getCliPathTargetForUri } from '../../utils/cliPathVariables'; import { getHotReloadDiagnostics, logHotReloadDiagnostics, showHotReloadDisabledAdvisoryIfNeeded } from '../hotReload'; import { deleteEnvironmentVariable, getEnvironmentWithoutE2EBridgeVariables, setEnvironmentVariable } from '../../utils/environment'; @@ -69,48 +70,52 @@ export class DotNetService implements IDotNetService { extensionLogOutputChannel.info(`Building .NET project: ${projectFile} using dotnet CLI`); const args = ['build', projectFile]; - const buildProcess = spawn('dotnet', args, { - // The .NET SDK searches for global.json from the process working directory, not the - // project argument. Run from the project directory so extension and CLI builds select - // the same SDK and repository configuration. - cwd: path.dirname(projectFile), - env: createAspireCliPathProcessEnvironment() - }); - let stdoutOutput = ''; - let stderrOutput = ''; + void (async () => { + const { cliPath } = await resolveCliPath(getCliPathTargetForUri(vscode.Uri.file(projectFile))); + const buildProcess = spawn('dotnet', args, { + // The .NET SDK searches for global.json from the process working directory, not the + // project argument. Run from the project directory so extension and CLI builds select + // the same SDK and repository configuration. + cwd: path.dirname(projectFile), + env: createResolvedAspireCliPathProcessEnvironment(cliPath) + }); - // Stream stdout in real-time - buildProcess.stdout?.on('data', (data: Buffer) => { - const output = data.toString(); - stdoutOutput += output; - this.writeToDebugConsole(output, 'stdout'); - }); + let stdoutOutput = ''; + let stderrOutput = ''; - // Stream stderr in real-time - buildProcess.stderr?.on('data', (data: Buffer) => { - const output = data.toString(); - stderrOutput += output; - this.writeToDebugConsole(output, 'stderr'); - }); + // Stream stdout in real-time + buildProcess.stdout?.on('data', (data: Buffer) => { + const output = data.toString(); + stdoutOutput += output; + this.writeToDebugConsole(output, 'stdout'); + }); - buildProcess.on('error', (err) => { - extensionLogOutputChannel.error(`dotnet build process error: ${err}`); - reject(new Error(buildFailedForProjectWithError(projectFile, err.message))); - }); + // Stream stderr in real-time + buildProcess.stderr?.on('data', (data: Buffer) => { + const output = data.toString(); + stderrOutput += output; + this.writeToDebugConsole(output, 'stderr'); + }); - buildProcess.on('close', (code) => { - if (code === 0) { - // if build succeeds, simply return. otherwise throw to trigger error handling - if (stderrOutput) { - reject(createErrorWithStreamedDebugConsoleOutput(stderrOutput)); + buildProcess.on('error', (err) => { + extensionLogOutputChannel.error(`dotnet build process error: ${err}`); + reject(new Error(buildFailedForProjectWithError(projectFile, err.message))); + }); + + buildProcess.on('close', (code) => { + if (code === 0) { + // if build succeeds, simply return. otherwise throw to trigger error handling + if (stderrOutput) { + reject(createErrorWithStreamedDebugConsoleOutput(stderrOutput)); + } else { + resolve(); + } } else { - resolve(); + reject(createErrorWithStreamedDebugConsoleOutput(buildFailedForProjectWithError(projectFile, stdoutOutput || stderrOutput || `Exit code ${code}`))); } - } else { - reject(createErrorWithStreamedDebugConsoleOutput(buildFailedForProjectWithError(projectFile, stdoutOutput || stderrOutput || `Exit code ${code}`))); - } - }); + }); + })().catch(reject); }); } @@ -124,10 +129,11 @@ export class DotNetService implements IDotNetService { '-property:GenerateFullPaths=true' ]; try { + const { cliPath } = await resolveCliPath(getCliPathTargetForUri(vscode.Uri.file(projectFile))); const { stdout } = await this.execFileAsync('dotnet', args, { cwd: path.dirname(projectFile), encoding: 'utf8', - env: createAspireCliPathProcessEnvironment() + env: createResolvedAspireCliPathProcessEnvironment(cliPath) }); const output = stdout.trim(); if (!output) { @@ -141,20 +147,21 @@ export class DotNetService implements IDotNetService { } async getDotNetRunApiOutput(projectPath: string, environment?: NodeJS.ProcessEnv): Promise { - let childProcess: ChildProcessWithoutNullStreams; + const { cliPath } = await resolveCliPath(getCliPathTargetForUri(vscode.Uri.file(projectPath))); + let childProcess: ChildProcessWithoutNullStreams | undefined; - return new Promise(async (resolve, reject) => { - try { - const timeout = setTimeout(() => { - childProcess?.kill(); - reject(new Error('Timeout while waiting for dotnet run-api response')); - }, 10_000); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + childProcess?.kill(); + reject(new Error('Timeout while waiting for dotnet run-api response')); + }, 10_000); + try { extensionLogOutputChannel.info('dotnet run-api - starting process'); childProcess = spawn('dotnet', ['run-api'], { cwd: path.dirname(projectPath), - env: createAspireCliPathProcessEnvironment({ ...process.env, ...environment }), + env: createResolvedAspireCliPathProcessEnvironment(cliPath, { ...process.env, ...environment }), stdio: ['pipe', 'pipe', 'pipe'] }); @@ -178,9 +185,10 @@ export class DotNetService implements IDotNetService { childProcess.stdin.write(message + os.EOL); childProcess.stdin.end(); } catch (e) { + clearTimeout(timeout); reject(e); } - }).finally(() => childProcess.removeAllListeners()); + }).finally(() => childProcess?.removeAllListeners()); } } @@ -263,20 +271,98 @@ function collectProfileDotnetHostEnvVarNames(profile: LaunchProfile | null | und return names; } +function parseSdkSerializedArguments(argumentsText: string): string[] { + // `dotnet run-api` exposes ProcessStartInfo.Arguments after the SDK serializes an argument array, + // for example: + // exec "/workspace/output with spaces/apphost.dll" + // Use the same CRT-compatible parser as src/Shared/CommandLineArgsParser.cs. That helper is copied + // from System.Diagnostics.Process: Windows processes use these rules natively, while the .NET runtime + // deliberately applies the same rules to ProcessStartInfo.Arguments before exec on Unix. + // https://github.com/dotnet/runtime/blob/main/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Unix.cs + const parsedArguments: string[] = []; + let index = 0; + + while (index < argumentsText.length) { + while (index < argumentsText.length && (argumentsText[index] === ' ' || argumentsText[index] === '\t')) { + index++; + } + + if (index === argumentsText.length) { + break; + } + + let currentArgument = ''; + let inQuotes = false; + + while (index < argumentsText.length) { + let backslashCount = 0; + while (index < argumentsText.length && argumentsText[index] === '\\') { + index++; + backslashCount++; + } + + if (backslashCount > 0) { + if (index >= argumentsText.length || argumentsText[index] !== '"') { + currentArgument += '\\'.repeat(backslashCount); + } else { + currentArgument += '\\'.repeat(Math.floor(backslashCount / 2)); + if (backslashCount % 2 !== 0) { + currentArgument += '"'; + index++; + } + } + + continue; + } + + const character = argumentsText[index]; + if (character === '"') { + if (inQuotes && index < argumentsText.length - 1 && argumentsText[index + 1] === '"') { + currentArgument += '"'; + index++; + } else { + inQuotes = !inQuotes; + } + + index++; + continue; + } + + if ((character === ' ' || character === '\t') && !inQuotes) { + break; + } + + currentArgument += character; + index++; + } + + parsedArguments.push(currentArgument); + } + + return parsedArguments; +} + // Combine the SDK host arguments from `dotnet run-api` (the built app DLL that is passed to the `dotnet` // launcher) with the user/launch-profile application arguments that were already resolved onto the debug // configuration. `hostArguments` is present only when the program is the `dotnet` launcher; // for an apphost-executable build it is undefined and only the application arguments remain. -// The host arguments must come first because they identify what to run; the user application arguments -// follow and are passed to the app. The result is kept as a single command-line string so the quoting the -// SDK already applied to CommandLineArguments is preserved. -function combineRunApiArguments(hostArguments: string | undefined, applicationArguments: string | string[] | undefined): string | string[] | undefined { - const applicationArgumentsText = Array.isArray(applicationArguments) ? applicationArguments.join(' ') : applicationArguments; - const combined = [hostArguments, applicationArgumentsText] - .filter((part): part is string => part !== undefined && part.length > 0) - .join(' '); - - return combined.length > 0 ? combined : undefined; +// The host arguments must come first because they identify what to run. Preserve the existing string form +// when application arguments are absent or launch-profile-authored text, but deserialize SDK host text when +// the application arguments are already tokens so the complete result can remain losslessly tokenized. +function combineRunApiArguments(hostArguments: string | undefined, applicationArguments: DebugConfigurationArguments | undefined): DebugConfigurationArguments | undefined { + if (!hostArguments) { + return applicationArguments; + } + + if (applicationArguments === undefined) { + return hostArguments; + } + + if (Array.isArray(applicationArguments)) { + return [...parseSdkSerializedArguments(hostArguments), ...applicationArguments]; + } + + return `${hostArguments} ${applicationArguments}`; } function createErrorWithStreamedDebugConsoleOutput(message: string): Error { @@ -371,9 +457,25 @@ function createDotNetRunArguments(projectPath: string, baseProfileArgs: string | return dotnetRunArgs; } +function expandDebugConfigurationArguments(argumentsValue: DebugConfigurationArguments | undefined): DebugConfigurationArguments | undefined { + if (argumentsValue === undefined) { + return undefined; + } + + if (Array.isArray(argumentsValue)) { + // Run-session arguments are already serialized argv tokens. Expanding them here would + // reinterpret literal `$(NAME)` and `%NAME%` values that the AppHost intended to receive. + return [...argumentsValue]; + } + + // Launch-profile arguments are authored as one command-line string and Visual Studio expands + // their environment-variable references before starting an Executable profile. + return expandEnvironmentVariables(argumentsValue); +} + function configureDotNetRunDebugConfiguration( debugConfiguration: AspireResourceExtendedDebugConfiguration, - args: string[] | string, + args: DebugConfigurationArguments, environment: NodeJS.ProcessEnv, processWorkingDirectory?: string): void { debugConfiguration.program = 'dotnet'; @@ -536,16 +638,26 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess // Configure debug session with launch profile settings debugConfiguration.cwd = determineWorkingDirectory(projectPath, baseProfile); - debugConfiguration.args = determineArguments(baseProfile?.commandLineArgs, args); + let resolvedArguments = determineArguments(baseProfile?.commandLineArgs, args); + debugConfiguration.args = resolvedArguments; debugConfiguration.executablePath = baseProfile?.executablePath; debugConfiguration.checkForDevCert = baseProfile?.useSSL; - // The apphost's application URL is the Aspire dashboard URL. We already get the dashboard login URL later on, - // so avoid generating a serverReadyAction for the apphost and manually open the browser ourselves. - // For project resources, launch settings supply a default only when debugger settings did not provide one. - if (!launchOptions.isApphost && debugConfiguration.serverReadyAction === undefined) { - debugConfiguration.serverReadyAction = determineServerReadyAction(baseProfile?.launchBrowser, baseProfile?.applicationUrl, baseProfile?.launchUrl); - } + // `launchBrowser` from launchSettings.json is deliberately not honoured here. Every project that + // reaches this callback is started by the app host, and the app host owns its endpoints: it + // assigns ports and can front the project with a proxy, so the `applicationUrl` on disk is + // routinely not where the resource actually listens. The Aspire dashboard resource is the + // extreme case, because the app host both replaces its URLs and puts a login token on the real + // address, so honouring the profile opened a stale port and an unauthenticated page. + // + // The run-session payload carries no endpoint data, so the extension cannot correct the URL. + // The CLI resolves this by ignoring the setting outright — `LaunchProfile.LaunchBrowser` is + // parsed but never read anywhere in Aspire.Hosting or Aspire.Cli, so `aspire run` opens nothing + // for a project resource and leaves URLs to the dashboard. Matching that keeps the two front + // ends consistent instead of having VS Code open a URL the CLI never would. + // + // A serverReadyAction the user configured explicitly in launch.json is still respected; it is + // read from `debugConfiguration` above and never overwritten here. // TODO: Remove this block — the dashboard no longer recognizes ASPIRE_DASHBOARD_AI_DISABLED. // See https://github.com/microsoft/aspire/issues/18751 @@ -577,12 +689,8 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess } debugConfiguration.program = expandEnvironmentVariables(baseProfile.executablePath); - if (debugConfiguration.args) { - debugConfiguration.args = expandEnvironmentVariables(debugConfiguration.args); - } else if (baseProfile.commandLineArgs) { - // Fall back to launch profile args if run session args were empty - debugConfiguration.args = expandEnvironmentVariables(baseProfile.commandLineArgs); - } + resolvedArguments = expandDebugConfigurationArguments(resolvedArguments); + debugConfiguration.args = resolvedArguments; debugConfiguration.env = createProjectEnvironment( launchSettings, baseProfile, @@ -682,7 +790,8 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess debugConfiguration.program = runApiConfig.executablePath; const hostArguments = isDotnetLauncher(runApiConfig.executablePath) ? runApiConfig.commandLineArguments : undefined; - debugConfiguration.args = combineRunApiArguments(hostArguments, debugConfiguration.args); + resolvedArguments = combineRunApiArguments(hostArguments, resolvedArguments); + debugConfiguration.args = resolvedArguments; // Intentionally do NOT consume run-api's WorkingDirectory: it carries the SDK default profile's // working directory, whereas cwd was already resolved from the (possibly different) selected diff --git a/extension/src/debugger/languages/java.ts b/extension/src/debugger/languages/java.ts new file mode 100644 index 00000000000..26af5bcb1c2 --- /dev/null +++ b/extension/src/debugger/languages/java.ts @@ -0,0 +1,414 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; +import { javaDebugExtensionId, javaLanguageExtensionId } from '../../capabilities'; +import { AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, isJavaLaunchConfiguration, JavaLaunchConfiguration } from "../../dcp/types"; +import { invalidLaunchConfiguration, javaAttachNotSupported, javaDisplayName, javaLabel } from "../../loc/strings"; +import { extensionLogOutputChannel } from "../../utils/logging"; +import { ResourceDebuggerExtension } from "../debuggerExtensions"; +import { AspireDebugSession, markDebugConfigurationEnvironmentSensitive } from "../AspireDebugSession"; + +// Commands contributed by redhat.java. They only exist once the language server has activated, so +// every call site has to tolerate them being missing. +const JAVA_EXECUTE_WORKSPACE_COMMAND = 'java.execute.workspaceCommand'; +const JAVA_RESOLVE_BUILD_FILES_COMMAND = 'vscode.java.resolveBuildFiles'; +const JAVA_PROJECT_CONFIGURATION_UPDATE_COMMAND = 'java.projectConfiguration.update'; + +// Long enough for a cold language server import on a large multi-module project, short enough that a +// mode transition that never comes does not look like a hang. +const javaLanguageServerReadyTimeoutMs = 30_000; + +// Sampling interval for the stop check that aborts the readiness wait. Only needs to be short relative +// to the readiness timeout above, since it exists to keep a stop responsive rather than to be precise. +const javaLanguageServerStopPollIntervalMs = 250; + +// Subset of the redhat.java extension API surface we use. +// https://github.com/redhat-developer/vscode-java#extension-api +interface JavaExtensionApi { + serverMode: string; + serverReady: () => Promise; +} + +async function getJavaExtensionApi(): Promise { + const extension = vscode.extensions.getExtension(javaLanguageExtensionId); + + if (!extension) { + return null; + } + + if (!extension.isActive) { + // Activation can fail (no JDK, corrupt workspace metadata, ...). Treat that the same as the + // extension being absent so the launch can still proceed without classpath refresh. + await extension.activate(); + } + + return extension.exports ?? null; +} + +async function waitForJavaLanguageServerReady(debugSession: AspireDebugSession | undefined): Promise { + try { + const api = await getJavaExtensionApi(); + + if (!api) { + extensionLogOutputChannel.warn(`The Java language server (${javaLanguageExtensionId}) is not installed or exposes no API.`); + return false; + } + + extensionLogOutputChannel.info(`Java language server is in ${api.serverMode} mode, waiting for readiness...`); + + let readyTimer: NodeJS.Timeout | undefined; + let stopPoll: NodeJS.Timeout | undefined; + + try { + // serverReady() resolves when the *standard* language server is ready. In LightWeight mode that + // transition is triggered by opening a project file, which may never happen, so an unbounded await + // would hold the resource launch open indefinitely. The refresh this gates is only a convenience, + // so a timeout degrades to launching without it rather than blocking the user's F5. + // serverReady() resolves false when the server settled into a mode that will never serve + // classpath queries, so the resolved value is honoured rather than treated as a bare signal. + const ready = await Promise.race([ + api.serverReady().then(result => result !== false), + new Promise(resolve => { readyTimer = setTimeout(() => resolve(false), javaLanguageServerReadyTimeoutMs); }), + // This wait is the only slow step between the app host asking for a resource and the + // adapter starting it, so a stop arriving mid-wait would otherwise be held up for the + // full timeout above. AspireDebugSession publishes stop and disposal as state rather + // than events, so this samples it; the interval only needs to be short relative to the + // readiness timeout, not precise. + new Promise(resolve => { + if (!debugSession) { + return; + } + + stopPoll = setInterval(() => { + if (debugSession.isStopAttemptInProgress || debugSession.isDisposed) { + resolve(false); + } + }, javaLanguageServerStopPollIntervalMs); + }) + ]); + + if (!ready) { + extensionLogOutputChannel.warn(`The Java language server did not become ready within ${javaLanguageServerReadyTimeoutMs}ms (mode: ${api.serverMode}).`); + } + + return ready; + } + finally { + // Promise.race abandons the losers but does not cancel them, so without this a launch that + // wins the race still leaves a 30 second timer armed, holding the event loop open and firing + // long after the session it belonged to has gone. + if (readyTimer) { + clearTimeout(readyTimer); + } + + if (stopPoll) { + clearInterval(stopPoll); + } + } + } catch (e) { + extensionLogOutputChannel.warn(`Error waiting for Java language server readiness: ${e}`); + } + + return false; +} + +async function updateJavaProjectConfiguration(buildTool: string): Promise { + const buildFiles = await vscode.commands.executeCommand( + JAVA_EXECUTE_WORKSPACE_COMMAND, + JAVA_RESOLVE_BUILD_FILES_COMMAND + ); + + if (!buildFiles?.length) { + extensionLogOutputChannel.info(`The Java language server reported no ${buildTool} build files to refresh.`); + return; + } + + extensionLogOutputChannel.info(`Updating ${buildTool} project configuration for ${buildFiles.length} build file(s)...`); + + for (const buildFile of buildFiles) { + await vscode.commands.executeCommand(JAVA_PROJECT_CONFIGURATION_UPDATE_COMMAND, vscode.Uri.parse(buildFile)); + } +} + +// Refreshing the classpath is a convenience for fresh clones and for projects whose build files +// changed since the language server last imported them; nothing about launching depends on it. +// redhat.java is therefore treated as optional at runtime: when it is missing or still starting, its +// commands are not registered and executeCommand rejects with "command not found", which would +// otherwise surface as an opaque failure that aborts the whole resource launch. +async function tryRefreshJavaProjectConfiguration(launchConfig: JavaLaunchConfiguration, debugSession: AspireDebugSession | undefined): Promise { + // A null build_tool means the resource runs a prebuilt JAR, so there are no build files to + // reimport and no reason to pay for language server startup. + if (!launchConfig.build_tool) { + extensionLogOutputChannel.info('Skipping Java project configuration refresh because the resource does not declare a build tool.'); + return; + } + + if (!await waitForJavaLanguageServerReady(debugSession)) { + extensionLogOutputChannel.warn(`Skipping the ${launchConfig.build_tool} project configuration refresh because the Java language server is unavailable. Launching anyway.`); + return; + } + + try { + await updateJavaProjectConfiguration(launchConfig.build_tool); + } catch (e) { + extensionLogOutputChannel.warn(`Failed to refresh the ${launchConfig.build_tool} project configuration: ${e}. Launching anyway.`); + } +} + +// path.isAbsolute resolves against the *host* platform, but the app host can hand us a Windows path +// while the extension runs on POSIX (remote/WSL/container scenarios), so check both flavours. +// path.win32.isAbsolute also accepts POSIX-rooted paths, but being explicit keeps the intent clear. +function isAbsolutePath(value: string): boolean { + return path.win32.isAbsolute(value) || path.posix.isAbsolute(value); +} + +// main_class is either a fully qualified class name (com.example.Api), optionally prefixed with a +// module name (app/com.example.Api), or the path of a .java source file. Only the class name is +// worth showing in the Call Stack view; a file path is less specific than the project directory the +// user recognises. +function isFullyQualifiedClassName(mainClass: string): boolean { + return mainClass.includes('.') + && !mainClass.toLowerCase().endsWith('.java') + && !isAbsolutePath(mainClass); +} + +function getProjectFile(launchConfig: ExecutableLaunchConfiguration): string { + if (isJavaLaunchConfiguration(launchConfig)) { + // The Java project directory is the only path the app host sends. It also feeds the central + // cwd derivation in prepareDebugSession, which the callback below then overrides explicitly. + return launchConfig.working_directory || ''; + } + + throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); +} + +export const javaDebuggerExtension: ResourceDebuggerExtension = { + resourceType: 'java', + debugAdapter: 'java', + extensionId: javaDebugExtensionId, + + getDisplayName: (launchConfig: ExecutableLaunchConfiguration) => { + if (!isJavaLaunchConfiguration(launchConfig)) { + return javaLabel; + } + + const mainClass = launchConfig.main_class; + if (mainClass && isFullyQualifiedClassName(mainClass)) { + return javaDisplayName(mainClass); + } + + // asRelativePath keeps the Call Stack view readable. Rendering the directory through + // vscode.Uri.file(...).toString() instead produces a percent-encoded URI such as + // "Java: file:///c%3A/repo/api". + const workingDirectory = launchConfig.working_directory; + + return workingDirectory ? javaDisplayName(vscode.workspace.asRelativePath(workingDirectory)) : javaLabel; + }, + + getSupportedFileTypes: () => ['.java'], + + getProjectFile: (launchConfig) => getProjectFile(launchConfig), + + createDebugSessionConfigurationCallback: async ( + launchConfig: ExecutableLaunchConfiguration, + args: string[] | undefined, + _env: { name: string; value: string }[], + launchOptions: { debug: boolean;[key: string]: any }, + debugConfiguration: AspireResourceExtendedDebugConfiguration + ): Promise => { + // The resolved resource environment reaches the adapter through this configuration and can + // include connection strings, `OTEL_EXPORTER_OTLP_HEADERS` and the extension certificate, so + // keep it available to the adapter without letting the diagnostic setting that logs other + // launch environments persist this one. `aspire.enableDebugConfigEnvironmentLogging` defaults + // to true, so without this the resolved environment is written to the output channel by + // default. Matches the Rust debugger extension. + markDebugConfigurationEnvironmentSensitive(debugConfiguration); + + if (!isJavaLaunchConfiguration(launchConfig)) { + extensionLogOutputChannel.info(`The resource type was not java for ${JSON.stringify(launchConfig)}`); + throw new Error(invalidLaunchConfiguration(JSON.stringify(launchConfig))); + } + + await tryRefreshJavaProjectConfiguration(launchConfig, launchOptions.debugSession); + + debugConfiguration.type = 'java'; + + // An attach session needs hostName and port, and the wire schema carries neither, so an attach + // configuration assembled here is always rejected by the adapter — with a message about the + // adapter's own schema rather than about the app host that asked for it. The app host only ever + // sends "launch" (JavaLaunchConfiguration.Request is fixed at "launch"), so this rejects a shape + // that has never been able to work rather than removing behaviour anyone relies on. + if (launchConfig.request === 'attach') { + throw new Error(javaAttachNotSupported); + } + + debugConfiguration.request = 'launch'; + debugConfiguration.noDebug = !launchOptions.debug; + + if (launchConfig.working_directory) { + debugConfiguration.cwd = launchConfig.working_directory; + } + + // vscjava.vscode-java-debug requires mainClass to start a launch session, and accepts a fully + // qualified class name, optionally prefixed with a module name, or the path of a .java source + // file. + // https://github.com/microsoft/vscode-java-debug/blob/main/Configuration.md#main + // + // When it is absent the adapter resolves the entry point itself, and in a workspace holding + // several Java resources it finds one main class per project and prompts the user to pick. + // projectName narrows that search to this resource's project, and the app host sends it + // whenever it could read the project's name out of its build file -- with or without a main + // class. Scoping matters in both cases: without a main class it stops the prompt, and with one + // it stops the adapter failing with "Main class ... isn't unique in the workspace" when the + // same class is visible through two projects. + // https://github.com/microsoft/vscode-java-debug/blob/main/Configuration.md#projectname + if (launchConfig.main_class) { + debugConfiguration.mainClass = launchConfig.main_class; + } + + if (launchConfig.project_name) { + debugConfiguration.projectName = launchConfig.project_name; + } + + // A resource that runs a prebuilt JAR has no language server project containing its classes, + // so the adapter cannot resolve the classpath on its own and would launch a JVM that fails + // with NoClassDefFoundError. Sending the archive explicitly is what makes such a resource + // debuggable; Maven and Gradle resources omit this and let the adapter resolve the project. + // https://github.com/microsoft/vscode-java-debug/blob/main/Configuration.md#classpaths + if (launchConfig.class_paths?.length) { + debugConfiguration.classPaths = launchConfig.class_paths; + } + + // JVM options, kept separate from `args` because the adapter passes these to the JVM and + // `args` to main(String[]). Merging them would hand "-Xmx512m" to the application. + // https://github.com/microsoft/vscode-java-debug/blob/main/Configuration.md#vmargs + if (launchConfig.vm_args?.length) { + debugConfiguration.vmArgs = launchConfig.vm_args; + } + + // These are the application's own arguments. The app host strips the mvnw/gradlew wrapper + // arguments for java launch configurations (the wrappers fork a second JVM that a debugger + // attached to the wrapper would never see), so everything left here belongs to main(String[]). + // https://github.com/microsoft/vscode-java-debug/blob/main/Configuration.md#arguments + debugConfiguration.args = args ?? []; + + // `env` is deliberately left alone. prepareDebugSession already set it to + // mergeEnvs(getEnvironmentWithoutE2EBridgeVariables(), env), i.e. the full inherited + // environment with the resource's variables layered on top. Reassigning it from `env` alone + // would launch the JVM without PATH or JAVA_HOME, so the adapter could not find `java`. + } +}; + +// Splits the Java AppHost launch command the CLI sends into the pieces the debug adapter needs. +// +// Every Java AppHost toolchain (plain javac, Maven, and Gradle) produces the same shape, because +// JavaLanguageSupport compiles in a separate pre-execute step and JavaAppHostToolchainResolver +// launches directly rather than through `mvn exec:java` or `gradle run`: +// +// ["java", "-cp", "/path/.java-build", "AppHost", "--operation", "run", "--socket", "/tmp/x.sock"] +// ["java", "-cp", "target/classes:target/aspire-deps/*", "AppHost", "--operation", "run", ...] +// +// The classpath value can also follow "-classpath" or "--class-path", and JVM options may appear +// before the main class, so the main class is located as the first non-option argument that is not +// itself an option's value. Returns null when the command does not match, which keeps an +// unrecognised command on the non-debug launch path instead of starting a JVM with wrong arguments. +export function parseJavaAppHostCommand(args: string[]): { mainClass: string; classPaths: string[]; vmArgs: string[]; appHostArgs: string[] } | null { + // args[0] is the "java" executable itself, prepended by the CLI. + if (args.length < 2) { + return null; + } + + // Only a direct JVM invocation can be turned into a launch configuration. A build-tool wrapper + // ("./mvnw exec:java", "./gradlew run") forks its own JVM, so its arguments are the tool's rather + // than the JVM's and the first bare token is a goal or task, not a main class. Without this check + // "exec:java" would be handed to the debug adapter as the class to launch. + // The path may be absolute (a JAVA_HOME-qualified launcher), so compare only the file name. + const executable = args[0].split(/[\\/]/).pop() ?? args[0]; + if (executable.toLowerCase().replace(/\.exe$/, '') !== 'java') { + return null; + } + + const classPathOptions = new Set(['-cp', '-classpath', '--class-path']); + + // JVM options whose value is a *separate* following argument. They have to be recognised by name, + // because otherwise the value is just a bare token: the loop below would stop at it and report it + // as the main class, and the adapter would launch something the user never asked for without + // anything looking wrong. The "--name=value" spelling is a single token and needs no entry here. + // https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html + const valueTakingOptions = new Set([ + '--limit-modules', + '--add-exports', '--add-opens', '--add-reads', + '--patch-module', + '--enable-native-access', + '--source', + '-splash' + ]); + + let classPaths: string[] = []; + const vmArgs: string[] = []; + + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + + if (classPathOptions.has(arg)) { + // The JVM accepts the platform path separator here, and expands a trailing "/*" itself. + const value = args[i + 1]; + if (value === undefined) { + return null; + } + + classPaths = value.split(path.delimiter).filter(entry => entry.length > 0); + i++; + continue; + } + + // "-jar" takes the entry point from the archive's Main-Class manifest attribute, and + // "-m"/"--module" takes it from a module descriptor. Neither puts a class name on the command + // line, and the adapter documents mainClass as a class name or .java path and never opens an + // archive, so there is nothing valid to send. Report the command as unrecognised so the + // AppHost starts without a debugger instead of with the wrong entry point. + if (arg === '-jar' || arg === '-m' || arg === '--module') { + return null; + } + + if (valueTakingOptions.has(arg)) { + const value = args[i + 1]; + if (value === undefined) { + return null; + } + + vmArgs.push(arg, value); + i++; + continue; + } + + if (arg.startsWith('-')) { + vmArgs.push(arg); + continue; + } + + // First bare token after the options is the main class; everything after it is the + // application's own arguments, which the JVM never interprets. + return { mainClass: arg, classPaths, vmArgs, appHostArgs: args.slice(i + 1) }; + } + + return null; +} + +// Turns the AppHost's classpath into absolute entries. +// +// The CLI builds that classpath relative to the AppHost directory, because that is the working +// directory it runs `java` from: ".java-build", "build/classes/java/main", "target/classes", +// "target/aspire-deps/*". vscjava.vscode-java-debug does not resolve classPaths against the launch +// configuration's cwd, so those entries are looked up somewhere else entirely, the JVM starts with a +// classpath that holds no AppHost class, and the process dies before the Aspire server ever connects: +// +// Error: Could not find or load main class AppHost +// Caused by: java.lang.ClassNotFoundException: AppHost +// +// Resolving here leaves the wire format the CLI already emits alone and makes the launch independent +// of how the adapter interprets a relative entry. A "dir/*" entry stays intact, because the asterisk +// is a path segment to path.resolve and is expanded by the JVM rather than by a shell. +// https://github.com/microsoft/vscode-java-debug/blob/main/Configuration.md#classpaths +export function resolveJavaClassPaths(classPaths: readonly string[], appHostDirectory: string): string[] { + return classPaths.map(entry => path.resolve(appHostDirectory, entry)); +} diff --git a/extension/src/debugger/launchProfiles.ts b/extension/src/debugger/launchProfiles.ts index 9235bd538d2..850f5bcd1ef 100644 --- a/extension/src/debugger/launchProfiles.ts +++ b/extension/src/debugger/launchProfiles.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import * as fs from 'fs'; -import { ExecutableLaunchConfiguration, EnvVar, ProjectLaunchConfiguration } from '../dcp/types'; +import { DebugConfigurationArguments, ExecutableLaunchConfiguration, EnvVar, ProjectLaunchConfiguration } from '../dcp/types'; import { extensionLogOutputChannel } from '../utils/logging'; import { isFileBasedApp } from './languages/dotnet'; import { stripComments, parseTree, findNodeAtLocation } from 'jsonc-parser'; @@ -307,18 +307,19 @@ export function mergeEnvironmentVariables( } /** - * Determines the final arguments array according to launch profile rules + * Determines the final debugger arguments according to launch profile rules. + * Launch-profile-authored text stays a string, while forwarded run-session tokens stay tokenized. * If run session args are present (including empty array), they completely replace launch profile args * If run session args are absent/null, launch profile args are used if available */ export function determineArguments( baseProfileArgs: string | undefined, runSessionArgs: string[] | undefined | null -): string | undefined { +): DebugConfigurationArguments | undefined { // If run session args are explicitly provided (including empty array), use them if (runSessionArgs !== undefined && runSessionArgs !== null) { - extensionLogOutputChannel.debug(`Using run session arguments: ${JSON.stringify(runSessionArgs)}`); - return runSessionArgs.join(' '); + extensionLogOutputChannel.debug(`Using run session arguments (count: ${runSessionArgs.length})`); + return [...runSessionArgs]; } // If run session args are absent/null, use launch profile args if available @@ -358,64 +359,3 @@ export function determineWorkingDirectory( extensionLogOutputChannel.debug(`Using default working directory (project directory): ${projectDir}`); return projectDir; } - -interface ServerReadyAction { - action: "openExternally"; - pattern: "\\bNow listening on:\\s+https?://\\S+"; - uriFormat: string; -} - -export function determineServerReadyAction(launchBrowser?: boolean, applicationUrl?: string, launchUrl?: string): ServerReadyAction | undefined { - if (!launchBrowser || !applicationUrl) { - return undefined; - } - - let uriFormat = applicationUrl.includes(';') ? applicationUrl.split(';')[0] : applicationUrl; - - if (launchUrl) { - uriFormat = resolveLaunchUrl(launchUrl, uriFormat); - } - - return { - action: "openExternally", - pattern: "\\bNow listening on:\\s+https?://\\S+", - uriFormat: uriFormat - }; -} - -function resolveLaunchUrl(launchUrl: string, applicationUrl: string): string { - const absoluteLaunchUrl = tryCreateUrl(launchUrl); - if (absoluteLaunchUrl) { - return getHttpUrlOrFallback(absoluteLaunchUrl, applicationUrl, launchUrl); - } - - const resolvedLaunchUrl = tryCreateUrl(launchUrl, applicationUrl); - if (resolvedLaunchUrl) { - return getHttpUrlOrFallback(resolvedLaunchUrl, applicationUrl, launchUrl); - } - - extensionLogOutputChannel.warn(`Failed to resolve launchUrl '${launchUrl}' against applicationUrl '${applicationUrl}'. Falling back to applicationUrl.`); - return applicationUrl; -} - -function tryCreateUrl(url: string, base?: string): URL | undefined { - try { - return base ? new URL(url, base) : new URL(url); - } catch { - return undefined; - } -} - -function getHttpUrlOrFallback(url: URL, fallback: string, launchUrl: string): string { - if (url.protocol === 'http:' || url.protocol === 'https:') { - if (url.hostname === '*') { - extensionLogOutputChannel.warn(`Ignoring launchUrl '${launchUrl}' because it resolves to wildcard host '*'. Falling back to applicationUrl.`); - return fallback; - } - - return url.href; - } - - extensionLogOutputChannel.warn(`Ignoring launchUrl '${launchUrl}' because it resolves to unsupported scheme '${url.protocol}'. Falling back to applicationUrl.`); - return fallback; -} diff --git a/extension/src/editor/AspireCodeLensProvider.ts b/extension/src/editor/AspireCodeLensProvider.ts index 082a26e882b..acaeb748b55 100644 --- a/extension/src/editor/AspireCodeLensProvider.ts +++ b/extension/src/editor/AspireCodeLensProvider.ts @@ -1,7 +1,9 @@ import * as vscode from 'vscode'; import { AppHostResourceParser, getParserForDocument } from './parsers/AppHostResourceParser'; +import { filterActiveOffsetsInPlainText } from './parsers/plainTextInactiveOffsets'; // Import parsers to trigger self-registration import './parsers/csharpAppHostParser'; +import './parsers/javaAppHostParser'; import './parsers/jsTsAppHostParser'; import './parsers/rustAppHostParser'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; @@ -9,6 +11,7 @@ import { isCommandVisibleToUi, isEnabledCommand } from '../views/treePresentatio import { compareResourceCommands, getParameterValueDescription, getResourceStateDescription } from '../utils/resourceDisplay'; import { AppHostDataRepository, ResourceJson, AppHostDisplayInfo, ResourceCommandJson } from '../data/AppHostDataRepository'; import { findResourceState, findWorkspaceResourceState, matchesAppHostPathOrDirectory } from './resourceStateUtils'; +import { extensionLogOutputChannel } from '../utils/logging'; import { ResourceState, HealthStatus, StateStyle, ResourceType } from './resourceConstants'; import { codeLensDebugPipelineStep, @@ -37,9 +40,103 @@ import { codeLensRustAppHostAlreadyRunningTooltip, codeLensRustAppHostUseAspire, codeLensRustAppHostUseAspireTooltip, + codeLensJavaAppHostAlreadyRunning, + codeLensJavaAppHostAlreadyRunningTooltip, + codeLensJavaAppHostUseAspire, + codeLensJavaAppHostUseAspireTooltip, + codeLensSpringBootDashboardBypassesAspire, + codeLensSpringBootDashboardBypassesAspireTooltip, codeLensResourceValueMissing, } from '../loc/strings'; +/** + * Extension that contributes the Spring Boot Dashboard view. Its Run/Debug buttons start the + * application directly, which is the hazard the Spring Boot warning lens exists to flag. + */ +const springBootDashboardExtensionId = 'vscjava.vscode-spring-boot-dashboard'; + +/** + * Matches an AppHost statement that launches a Java resource through Spring Boot's own plugin, e.g. + * + * builder.AddJavaApp("api", "../api").WithMavenGoal("spring-boot:run"); // C# + * builder.addJavaApp('api', '../api').withGradleTask("bootRun"); // JS/TS + * builder.add_java_app("api", "../api")?.with_maven_goal("spring-boot:run")?; // Rust + * + * AddSpringBootApp is matched on the call itself because it configures the same `spring-boot:run` or + * `bootRun` launch internally, choosing between them from the build file. It is the form the README + * leads with, so matching only the explicit goal would miss the common case entirely. + * + * builder.AddSpringBootApp("catalog", "../catalog"); // C# + * builder.add_spring_boot_app("catalog", "../catalog")?; // Rust, Python + * + * The optional underscores plus the case-insensitive flag cover all three casings from one pattern, + * which matters because the warning is about the Java *resource* and therefore has to work no matter + * which language the AppHost is written in. C# verbatim/interpolated prefixes are allowed on the + * literal; raw string literals are not, because nothing in the goal or task name needs escaping. + * + * AddQuarkusApp is deliberately absent: the warning names the Spring Boot Dashboard, which does not + * offer to run a Quarkus resource. + */ +const springBootLaunchPattern = /\b(?:with_?(?:maven_?goal|gradle_?task)\s*\(\s*[@$]*(['"])(?:spring-boot:run|bootRun)\1|add_?spring_?boot_?app\s*\()/gi; + +/** + * Per-language warning shown on the AppHost entry point, for languages whose ecosystem extension + * offers a Run/Debug action that starts the AppHost outside Aspire. Running an AppHost that way + * starts it with no Aspire session at all, so no resources launch and no dashboard appears -- quiet + * enough to look like the AppHost itself is broken. + * + * Unlike the Spring Boot Dashboard warning this is not gated on the other extension being installed. + * Neither rust-analyzer nor the Java extension pack is realistically absent when someone is editing + * an AppHost in that language, and an ungated warning is also what lets the E2E harness assert on it + * without standing up a language server first. + */ +const entryPointWarningsByLanguage: ReadonlyMap = new Map([ + ['rust', { + alreadyRunning: codeLensRustAppHostAlreadyRunning, + alreadyRunningTooltip: codeLensRustAppHostAlreadyRunningTooltip, + useAspire: codeLensRustAppHostUseAspire, + useAspireTooltip: codeLensRustAppHostUseAspireTooltip, + }], + ['java', { + alreadyRunning: codeLensJavaAppHostAlreadyRunning, + alreadyRunningTooltip: codeLensJavaAppHostAlreadyRunningTooltip, + useAspire: codeLensJavaAppHostUseAspire, + useAspireTooltip: codeLensJavaAppHostUseAspireTooltip, + }], +]); + +/** + * The conventional AppHost file name for each language that has no resource parser, matched + * case-insensitively to mirror the CLI's own detection. + * + * The parser-backed languages are already narrowed by their parser, which only accepts a document it + * can parse as an AppHost. The parserless languages have nothing playing that role, so without this the + * provider would scan every Python and Go file in the workspace. Gating on the file name rather + * than on a discovered AppHost is deliberate: the warning is most useful before the AppHost has ever + * been run through Aspire, which is exactly when discovery has nothing to match against. + */ +const parserlessAppHostFileNames: ReadonlyMap = new Map([ + ['python', 'apphost.py'], + ['go', 'apphost.go'], +]); + +function isParserlessAppHostDocument(document: vscode.TextDocument): boolean { + const expected = parserlessAppHostFileNames.get(document.languageId); + if (expected === undefined) { + return false; + } + + // uri.path is always '/'-separated, including on Windows, so this does not need the platform separator. + const fileName = document.uri.path.split('/').pop() ?? ''; + + return fileName.toLowerCase() === expected; +} + export class AspireCodeLensProvider implements vscode.CodeLensProvider { private readonly _onDidChangeCodeLenses = new vscode.EventEmitter(); readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; @@ -49,6 +146,7 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { constructor( private readonly _treeProvider: AspireAppHostTreeProvider, private readonly _dataRepository: AppHostDataRepository, + private readonly _isExtensionInstalled: (extensionId: string) => boolean = extensionId => vscode.extensions.getExtension(extensionId) !== undefined, ) { // Re-compute lenses whenever the polling data changes this._disposables.push( @@ -57,7 +155,17 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { } provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): vscode.ProviderResult { - return this._provideCodeLensesAsync(document, token); + // A parser failure (a tree-sitter grammar that will not load, an unparseable document) would + // otherwise surface as an editor with no lenses and nothing written anywhere, which is + // indistinguishable from "this document legitimately has no lenses". Log it and degrade to no + // lenses rather than letting the rejection disable the provider for the rest of the session. + return this._provideCodeLensesAsync(document, token).then(lenses => { + extensionLogOutputChannel.debug(`Computed ${lenses?.length ?? 0} Aspire CodeLens(es) for ${document.uri.fsPath} (languageId '${document.languageId}')${lenses?.length ? `: ${lenses.map(lens => lens.command?.title).join(' | ')}` : ''}`); + return lenses; + }).catch(error => { + extensionLogOutputChannel.error(`Failed to compute Aspire CodeLenses for ${document.uri.fsPath}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + return []; + }); } private async _provideCodeLensesAsync(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { @@ -71,7 +179,17 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { } if (!parser) { - return []; + // Python and Go AppHosts have no resource parser, so none of the state or action + // lenses apply. The Spring Boot warning still does: it is about the Java resource being + // declared, not about the language declaring it. + if (!isParserlessAppHostDocument(document)) { + return []; + } + + const warningOnlyLenses: vscode.CodeLens[] = []; + await this._addSpringBootDashboardLenses(warningOnlyLenses, document, parser); + + return warningOnlyLenses; } const resources = await parser.parseResources(document); @@ -97,6 +215,8 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { // Add* resource calls were found in the file. await this._addBuilderStatementLenses(lenses, document, parser, workspaceAppHostPath, workspaceResources); + await this._addSpringBootDashboardLenses(lenses, document, parser); + if (resources.length === 0) { return lenses; } @@ -154,6 +274,69 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { })); } + /** + * Warns, on each Java resource launched through Spring Boot's Maven plugin or Gradle task, that + * the Spring Boot Dashboard's Run/Debug buttons start the app outside Aspire. + * + * Only shown when that extension is installed: unlike rust-analyzer on a Rust AppHost, the + * Spring Boot Dashboard is not implied by the presence of a Java resource, and warning about an + * extension the user does not have is noise. + * + * The scan is textual rather than parser-driven because a Java resource can be declared from any + * AppHost language and the goal or task name is the only reliable in-document signal that the + * resource is a Spring Boot app. Deciding it from the project's own `pom.xml`/`build.gradle` + * would mean reading files off disk on every keystroke. The matches are then filtered through the + * document's parser so a commented-out or quoted example does not produce a warning about a + * resource that does not exist. + */ + private async _addSpringBootDashboardLenses( + lenses: vscode.CodeLens[], + document: vscode.TextDocument, + parser: AppHostResourceParser | undefined, + ): Promise { + if (!this._isExtensionInstalled(springBootDashboardExtensionId)) { + return; + } + + const text = document.getText(); + // Shared regex literals keep `lastIndex` between calls, so reset before iterating. + springBootLaunchPattern.lastIndex = 0; + + const offsets: number[] = []; + let match: RegExpExecArray | null; + while ((match = springBootLaunchPattern.exec(text)) !== null) { + offsets.push(match.index); + } + + if (offsets.length === 0) { + return; + } + + // Without a parser the same question is answered by scanning the text. Skipping the filter + // entirely would make a commented-out call warn, which is the behaviour the parser-backed + // languages deliberately do not have. + const activeOffsets = parser?.filterActiveOffsets + ? await parser.filterActiveOffsets(document, offsets) + : filterActiveOffsetsInPlainText(document.languageId, text, offsets); + + const warnedLines = new Set(); + for (const offset of activeOffsets) { + const line = document.positionAt(offset).line; + if (warnedLines.has(line)) { + continue; + } + + warnedLines.add(line); + const range = new vscode.Range(line, 0, line, 0); + // An empty command id renders the warning as plain text rather than an inert link. + lenses.push(new vscode.CodeLens(range, { + title: codeLensSpringBootDashboardBypassesAspire, + command: '', + tooltip: codeLensSpringBootDashboardBypassesAspireTooltip, + })); + } + } + private async _addBuilderStatementLenses( lenses: vscode.CodeLens[], document: vscode.TextDocument, @@ -168,7 +351,8 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { const runningAppHostPath = this._resolveAppHostPathForDocument(document, workspaceAppHostPath, workspaceResources); - if (document.languageId === 'rust') { + const entryPointWarning = entryPointWarningsByLanguage.get(document.languageId); + if (entryPointWarning) { const entryPointLine = await parser.findAppHostEntryPointLine?.(document) ?? builderLine; const range = new vscode.Range(entryPointLine, 0, entryPointLine, 0); // The tree only holds running AppHosts, so revealing a stopped one has nothing to select. @@ -176,15 +360,15 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { // whose click does nothing. lenses.push(new vscode.CodeLens(range, runningAppHostPath ? { - title: codeLensRustAppHostAlreadyRunning, + title: entryPointWarning.alreadyRunning, command: 'aspire-vscode.codeLensRevealAppHost', - tooltip: codeLensRustAppHostAlreadyRunningTooltip, + tooltip: entryPointWarning.alreadyRunningTooltip, arguments: [runningAppHostPath], } : { - title: codeLensRustAppHostUseAspire, + title: entryPointWarning.useAspire, command: '', - tooltip: codeLensRustAppHostUseAspireTooltip, + tooltip: entryPointWarning.useAspireTooltip, })); } diff --git a/extension/src/editor/AspireEditorCommandProvider.ts b/extension/src/editor/AspireEditorCommandProvider.ts index 2e8f531e99a..b98d14d8755 100644 --- a/extension/src/editor/AspireEditorCommandProvider.ts +++ b/extension/src/editor/AspireEditorCommandProvider.ts @@ -7,6 +7,7 @@ import { AppHostDiscoveryService, getDebugTargetForCandidate, selectWorkspaceApp import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { extensionLogOutputChannel } from '../utils/logging'; import { AppHostLaunchService } from '../services/AppHostLaunchService'; +import type { CliPathResolutionTarget } from '../utils/cliPathVariables'; export class AspireEditorCommandProvider implements vscode.Disposable { private _disposables: vscode.Disposable[] = []; @@ -130,18 +131,25 @@ export class AspireEditorCommandProvider implements vscode.Disposable { await this.launchAspireDebugSession('publish', noDebug); } - public async tryExecuteDoAppHost(noDebug: boolean, doStep?: string): Promise { - await this.launchAspireDebugSession('do', noDebug, doStep); + public async tryExecuteDoAppHost(noDebug: boolean, doStep?: string, appHostPath?: string, target?: CliPathResolutionTarget, cliPath?: string): Promise { + await this.launchAspireDebugSession('do', noDebug, doStep, appHostPath, target, cliPath); } - private async launchAspireDebugSession(aspireCommand: AspireCommandType, noDebug: boolean, doStep?: string): Promise { - const appHostToRun = await this.getAppHostPath(); + private async launchAspireDebugSession( + aspireCommand: AspireCommandType, + noDebug: boolean, + doStep?: string, + selectedAppHostPath?: string, + target?: CliPathResolutionTarget, + cliPath?: string, + ): Promise { + const appHostToRun = selectedAppHostPath ?? await this.getAppHostPath(); if (!appHostToRun) { vscode.window.showErrorMessage(noAppHostInWorkspace); return; } - await this._launchService.launch(appHostToRun, aspireCommand, noDebug, doStep); + await this._launchService.launch(appHostToRun, aspireCommand, noDebug, doStep, target, cliPath); } dispose() { diff --git a/extension/src/editor/AspireGutterDecorationProvider.ts b/extension/src/editor/AspireGutterDecorationProvider.ts index 0305db049a3..d0884fa20a1 100644 --- a/extension/src/editor/AspireGutterDecorationProvider.ts +++ b/extension/src/editor/AspireGutterDecorationProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { getParserForDocument } from './parsers/AppHostResourceParser'; // Trigger parser self-registration import './parsers/csharpAppHostParser'; +import './parsers/javaAppHostParser'; import './parsers/jsTsAppHostParser'; import './parsers/rustAppHostParser'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; diff --git a/extension/src/editor/parsers/AppHostResourceParser.ts b/extension/src/editor/parsers/AppHostResourceParser.ts index 55cb0bd3f9b..8f41b6e4289 100644 --- a/extension/src/editor/parsers/AppHostResourceParser.ts +++ b/extension/src/editor/parsers/AppHostResourceParser.ts @@ -39,6 +39,18 @@ export interface AppHostResourceParser { * Returns the 0-based line of the start of the statement, or `undefined` if not found. */ findBuilderStatementLine?(document: vscode.TextDocument): Promise; + + /** + * Narrows a set of document offsets to those that are part of executable code, dropping any that + * fall inside a comment or a string literal. + * + * Some editor affordances are driven by a textual scan rather than by {@link parseResources}, + * because the signal they look for is an argument to an arbitrary chained call rather than a + * resource declaration. Passing the scan's offsets through here keeps a commented-out or quoted + * example from being treated as real code. Offsets are filtered as a batch so the document is + * parsed once. + */ + filterActiveOffsets?(document: vscode.TextDocument, offsets: readonly number[]): Promise; } const _parsers: AppHostResourceParser[] = []; @@ -90,6 +102,7 @@ function extensionToLanguageId(ext: string): string | undefined { case '.cjs': case '.js': return 'javascript'; case '.rs': return 'rust'; + case '.java': return 'java'; default: return undefined; } } diff --git a/extension/src/editor/parsers/csharpAppHostParser.ts b/extension/src/editor/parsers/csharpAppHostParser.ts index 0f087c900bd..23c9e72dcac 100644 --- a/extension/src/editor/parsers/csharpAppHostParser.ts +++ b/extension/src/editor/parsers/csharpAppHostParser.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { Language, Node as TreeSitterNode, Parser, Tree } from 'web-tree-sitter'; import { AppHostResourceParser, ParsedResource, registerParser } from './AppHostResourceParser'; import { initializeTreeSitter, resolveBundledWasmAssetPath } from './treeSitter'; -import { visit } from './treeSitterHelpers'; +import { isInInactiveNode, visit } from './treeSitterHelpers'; /** * C# AppHost resource parser. @@ -74,6 +74,14 @@ class CSharpAppHostParser implements AppHostResourceParser { }); } + async filterActiveOffsets(document: vscode.TextDocument, offsets: readonly number[]): Promise { + if (offsets.length === 0) { + return []; + } + + return await withCSharpTree(document.getText(), tree => + offsets.filter(offset => !isInInactiveNode(tree.rootNode, offset))); + } } // Self-register on import @@ -134,19 +142,6 @@ function hasActiveSdkDirective(text: string, rootNode: TreeSitterNode): boolean return false; } -function isInInactiveNode(rootNode: TreeSitterNode, index: number): boolean { - let node = rootNode.descendantForIndex(index); - while (node) { - if (node.type === 'comment' || node.type.includes('string')) { - return true; - } - - node = node.parent; - } - - return false; -} - function findInvocation(rootNode: TreeSitterNode, predicate: (node: TreeSitterNode) => boolean): TreeSitterNode | undefined { let result: TreeSitterNode | undefined; visit(rootNode, node => { diff --git a/extension/src/editor/parsers/javaAppHostParser.ts b/extension/src/editor/parsers/javaAppHostParser.ts new file mode 100644 index 00000000000..a9f72c0d9f6 --- /dev/null +++ b/extension/src/editor/parsers/javaAppHostParser.ts @@ -0,0 +1,286 @@ +import * as vscode from 'vscode'; +import { Language, Node as TreeSitterNode, Parser, Tree } from 'web-tree-sitter'; +import { AppHostResourceParser, ParsedResource, registerParser } from './AppHostResourceParser'; +import { initializeTreeSitter, resolveBundledWasmAssetPath } from './treeSitter'; +import { isInInactiveNode, visit } from './treeSitterHelpers'; + +/** + * Java AppHost resource parser. + * + * Modelled on the C# parser rather than the Rust one: Java spells a resource call + * `builder.addRedis("cache")`, which parses to a single `method_invocation` carrying `object`, + * `name` and `arguments` fields, and that is much closer to C#'s `invocation_expression` than to + * Rust's `call_expression` wrapping a separate `field_expression`. + */ +class JavaAppHostParser implements AppHostResourceParser { + getSupportedExtensions(): string[] { + return ['.java']; + } + + async isAppHostFile(document: vscode.TextDocument): Promise { + return await withJavaTree(document.getText(), tree => + findInvocation(tree.rootNode, isCreateBuilderCall) !== undefined); + } + + async parseResources(document: vscode.TextDocument): Promise { + return await withJavaTree(document.getText(), tree => { + const results: ParsedResource[] = []; + visit(tree.rootNode, node => { + if (node.type !== 'method_invocation') { + return; + } + + // A resource call is always invoked on something (`builder.addRedis(...)`), never + // bare. Requiring the object also skips a local helper that happens to be named + // `addSomething`. + if (!node.childForFieldName('object')) { + return; + } + + const methodName = node.childForFieldName('name')?.text; + if (!methodName || !/^add[A-Z][A-Za-z0-9]*$/.test(methodName)) { + return; + } + + const resourceNameNode = getFirstArgument(node); + if (!resourceNameNode) { + return; + } + + const resourceName = getStringLiteralValue(resourceNameNode); + if (resourceName === undefined) { + return; + } + + results.push({ + name: resourceName, + methodName, + range: new vscode.Range( + document.positionAt(getInvocationDotStart(node)), + document.positionAt(resourceNameNode.endIndex)), + kind: methodName === 'addStep' ? 'pipelineStep' : 'resource', + statementStartLine: findContainingStatementStartLine(node), + }); + }); + + return results.sort((left, right) => document.offsetAt(left.range.start) - document.offsetAt(right.range.start)); + }); + } + + async findAppHostEntryPointLine(document: vscode.TextDocument): Promise { + return await withJavaTree(document.getText(), tree => findMainMethod(tree.rootNode)?.startPosition.row); + } + + async findBuilderStatementLine(document: vscode.TextDocument): Promise { + return await withJavaTree(document.getText(), tree => { + const builderInvocation = findInvocation(tree.rootNode, isCreateBuilderCall); + return builderInvocation ? findContainingStatementStartLine(builderInvocation) : undefined; + }); + } + + async filterActiveOffsets(document: vscode.TextDocument, offsets: readonly number[]): Promise { + if (offsets.length === 0) { + return []; + } + + return await withJavaTree(document.getText(), tree => + offsets.filter(offset => !isInInactiveNode(tree.rootNode, offset))); + } +} + +registerParser(new JavaAppHostParser()); + +let languagePromise: Promise | undefined; + +async function withJavaTree(text: string, callback: (tree: Tree) => T): Promise { + const language = await getJavaLanguage(); + const parser = new Parser(); + parser.setLanguage(language); + + const tree = parser.parse(text); + if (!tree) { + parser.delete(); + throw new Error('Failed to parse Java AppHost document.'); + } + + try { + return callback(tree); + } + finally { + tree.delete(); + parser.delete(); + } +} + +async function getJavaLanguage(): Promise { + languagePromise ??= loadJavaLanguage().catch(error => { + languagePromise = undefined; + throw error; + }); + + return await languagePromise; +} + +async function loadJavaLanguage(): Promise { + await initializeTreeSitter(); + + return await Language.load(getJavaTreeSitterWasmPath()); +} + +function getJavaTreeSitterWasmPath(): string { + const resolvedPath = require.resolve('tree-sitter-java/tree-sitter-java.wasm'); + return typeof resolvedPath === 'string' + ? resolvedPath + : resolveBundledWasmAssetPath(require('tree-sitter-java/tree-sitter-java.wasm')); +} + +function findInvocation(rootNode: TreeSitterNode, predicate: (node: TreeSitterNode) => boolean): TreeSitterNode | undefined { + let result: TreeSitterNode | undefined; + visit(rootNode, node => { + if (node.type === 'method_invocation' && predicate(node)) { + result = node; + return false; + } + + return true; + }); + + return result; +} + +/** + * Matches `DistributedApplication.CreateBuilder()` and `DistributedApplication.CreateBuilder(args)`, + * whether the type is imported or written out in full as `aspire.DistributedApplication`. + * The object is checked as well as the method name so an unrelated local `CreateBuilder()` helper + * does not make an arbitrary Java file look like an AppHost. Only the segment after the last dot is + * compared, so a qualified call matches while `ReportBuilder.CreateBuilder()` still does not. + */ +function isCreateBuilderCall(node: TreeSitterNode): boolean { + if (node.childForFieldName('name')?.text !== 'CreateBuilder') { + return false; + } + + const objectText = node.childForFieldName('object')?.text; + if (objectText === undefined) { + return false; + } + + // A qualified name may be split across lines, so the segment is trimmed before it is compared. + return objectText.slice(objectText.lastIndexOf('.') + 1).trim() === 'DistributedApplication'; +} + +/** + * Finds the entry point, covering both shapes an AppHost can take: a JEP 512 implicitly declared + * `void main()`, and a conventional `public static void main(String[])` inside a class, which is what + * a Maven or Gradle AppHost project uses. See https://openjdk.org/jeps/512. + */ +function findMainMethod(rootNode: TreeSitterNode): TreeSitterNode | undefined { + let result: TreeSitterNode | undefined; + visit(rootNode, node => { + if (node.type === 'method_declaration' && node.childForFieldName('name')?.text === 'main') { + result = node; + return false; + } + + return true; + }); + + return result; +} + +function getFirstArgument(node: TreeSitterNode): TreeSitterNode | undefined { + const argumentsNode = node.childForFieldName('arguments'); + if (argumentsNode?.hasError) { + return undefined; + } + + return argumentsNode?.namedChildren.find(child => !child.isExtra); +} + +/** + * The lens anchors on the `.` before the method name so it lines up with the other languages. + * `method_invocation` children are `object`, `.`, `name`, `arguments`, so the dot is a direct child. + */ +function getInvocationDotStart(node: TreeSitterNode): number { + return node.children.find(child => child.type === '.')?.startIndex ?? node.startIndex; +} + +/** + * Java spells a local declaration `local_variable_declaration`, which does not end in `_statement` + * the way `expression_statement` does, so both are matched explicitly. + */ +function findContainingStatementStartLine(node: TreeSitterNode): number { + let current: TreeSitterNode | null = node; + while (current) { + if (current.type === 'local_variable_declaration' || current.type === 'expression_statement') { + return current.startPosition.row; + } + + current = current.parent; + } + + return node.startPosition.row; +} + +/** + * Reads a Java string literal, e.g. `"catalog"` or `"c:\\tools"`. + * + * Text blocks (`"""..."""`) are also `string_literal` in this grammar but are rejected: a resource + * name never needs one, and treating the opening delimiter as content would silently produce a wrong + * name. See https://docs.oracle.com/javase/specs/jls/se21/html/jls-3.html#jls-3.10.5. + */ +function getStringLiteralValue(node: TreeSitterNode): string | undefined { + if (node.hasError || node.type !== 'string_literal') { + return undefined; + } + + const text = node.text; + if (!text.startsWith('"') || text.startsWith('"""') || !text.endsWith('"') || text.length < 2) { + return undefined; + } + + let value = ''; + for (const child of node.namedChildren) { + if (child.type !== 'escape_sequence') { + value += child.text; + continue; + } + + const decoded = decodeEscapeSequence(child.text); + if (decoded === undefined) { + return undefined; + } + + value += decoded; + } + + return value; +} + +/** + * Decodes the escapes JLS 3.10.7 defines, plus the `\\uXXXX` form. Anything else is rejected rather + * than passed through, because a name the parser cannot read exactly would anchor a lens to a + * resource the CLI never reports. + */ +function decodeEscapeSequence(text: string): string | undefined { + switch (text) { + case '\\b': return '\b'; + case '\\s': return ' '; + case '\\t': return '\t'; + case '\\n': return '\n'; + case '\\f': return '\f'; + case '\\r': return '\r'; + case '\\"': return '"'; + case "\\'": return "'"; + case '\\\\': return '\\'; + default: break; + } + + const unicode = /^\\u+([0-9a-fA-F]{4})$/.exec(text); + if (unicode) { + return String.fromCharCode(parseInt(unicode[1], 16)); + } + + const octal = /^\\([0-3]?[0-7]{1,2})$/.exec(text); + return octal ? String.fromCharCode(parseInt(octal[1], 8)) : undefined; +} diff --git a/extension/src/editor/parsers/jsTsAppHostParser.ts b/extension/src/editor/parsers/jsTsAppHostParser.ts index df768bd3560..018b2d7bf85 100644 --- a/extension/src/editor/parsers/jsTsAppHostParser.ts +++ b/extension/src/editor/parsers/jsTsAppHostParser.ts @@ -92,6 +92,14 @@ class JsTsAppHostParser implements AppHostResourceParser { return builderLine; } + async filterActiveOffsets(document: vscode.TextDocument, offsets: readonly number[]): Promise { + if (offsets.length === 0) { + return []; + } + + const sourceFile = createSourceFile(document); + return offsets.filter(offset => !isInactiveOffset(sourceFile, offset)); + } } // Self-register on import @@ -101,6 +109,39 @@ interface ParsedResourceWithStart extends ParsedResource { matchStart: number; } +/** + * Returns true when the offset is inside a comment or a string literal. + * + * The TypeScript AST does not model comments as nodes: they are leading trivia on the token that + * follows them, so the deepest node containing the offset starts *after* it. That gap is what + * identifies a comment, and it also covers the whitespace between tokens, which no textual scan can + * match anyway. + */ +function isInactiveOffset(sourceFile: ts.SourceFile, offset: number): boolean { + let node: ts.Node = sourceFile; + for (;;) { + const child = node.getChildren(sourceFile) + .find(candidate => offset >= candidate.getFullStart() && offset < candidate.getEnd()); + if (!child) { + break; + } + + node = child; + } + + if (offset < node.getStart(sourceFile)) { + return true; + } + + for (let current: ts.Node | undefined = node; current; current = current.parent) { + if (ts.isStringLiteralLike(current) || ts.isTemplateExpression(current) || ts.isRegularExpressionLiteral(current)) { + return true; + } + } + + return false; +} + function createSourceFile(document: vscode.TextDocument): ts.SourceFile { return ts.createSourceFile( document.uri.fsPath, diff --git a/extension/src/editor/parsers/plainTextInactiveOffsets.ts b/extension/src/editor/parsers/plainTextInactiveOffsets.ts new file mode 100644 index 00000000000..5abf7bca5e5 --- /dev/null +++ b/extension/src/editor/parsers/plainTextInactiveOffsets.ts @@ -0,0 +1,178 @@ +/** + * Offset classification for AppHost languages that have no resource parser. + * + * The tree-sitter and TypeScript parsers answer "is this offset inside a comment or a string?" from a + * real syntax tree. Java, Python and Go AppHosts have no parser — they are recognised only well enough + * to surface the Spring Boot Dashboard warning — so this scanner answers the same question from the + * source text. It is not a lexer for those languages: it tracks only the three states a warning needs + * to distinguish (code, comment, string literal), which is exactly enough to avoid warning about a + * commented-out or quoted `withMavenGoal("spring-boot:run")`. + */ + +interface StringDelimiter { + /** The opening (and, for every language handled here, closing) delimiter. */ + readonly delimiter: string; + /** Whether a backslash escapes the following character, as in Java's `"` but not Go's backtick. */ + readonly supportsEscapes: boolean; + /** Whether the literal may span lines, as in a Java text block but not a Java string. */ + readonly multiline: boolean; +} + +interface PlainTextSyntax { + readonly lineComment: string; + readonly blockCommentOpen?: string; + readonly blockCommentClose?: string; + /** + * Ordered longest-first so that a Java text block or a Python triple-quote is matched before the + * single-character delimiter it starts with. + */ + readonly strings: readonly StringDelimiter[]; +} + +const cFamilyStrings: readonly StringDelimiter[] = [ + { delimiter: '"""', supportsEscapes: true, multiline: true }, + { delimiter: '"', supportsEscapes: true, multiline: false }, + { delimiter: "'", supportsEscapes: true, multiline: false }, +]; + +const syntaxByLanguageId: ReadonlyMap = new Map([ + // Text blocks (`"""`) are Java 15+ and can legally contain `//`, so they are matched first. + ['java', { lineComment: '//', blockCommentOpen: '/*', blockCommentClose: '*/', strings: cFamilyStrings }], + [ + 'go', + { + lineComment: '//', + blockCommentOpen: '/*', + blockCommentClose: '*/', + // A raw string literal spans lines and gives backslash no special meaning. + // https://go.dev/ref/spec#String_literals + strings: [ + { delimiter: '`', supportsEscapes: false, multiline: true }, + { delimiter: '"', supportsEscapes: true, multiline: false }, + { delimiter: "'", supportsEscapes: true, multiline: false }, + ], + }, + ], + [ + 'python', + { + lineComment: '#', + strings: [ + { delimiter: '"""', supportsEscapes: true, multiline: true }, + { delimiter: "'''", supportsEscapes: true, multiline: true }, + { delimiter: '"', supportsEscapes: true, multiline: false }, + { delimiter: "'", supportsEscapes: true, multiline: false }, + ], + }, + ], +]); + +/** The AppHost languages this scanner can classify offsets for. */ +export function getPlainTextScannableLanguageIds(): string[] { + return [...syntaxByLanguageId.keys()]; +} + +/** + * Returns the subset of that fall in code rather than in a comment or a + * string literal, or all of them when the language is not one this scanner knows. + * + * A single forward scan classifies the whole document, which is cheaper than restarting at each offset + * and is what makes the scanner correct for multi-line constructs: whether a given `//` opens a comment + * depends entirely on the state left behind by everything before it. + */ +export function filterActiveOffsetsInPlainText(languageId: string, text: string, offsets: readonly number[]): number[] { + const syntax = syntaxByLanguageId.get(languageId); + if (!syntax) { + return [...offsets]; + } + + const inactive = classifyInactiveRegions(syntax, text); + + return offsets.filter(offset => !inactive(offset)); +} + +/** + * Scans once and returns a predicate over offsets. Regions are collected as half-open intervals in + * ascending order, so the lookup is a binary search rather than a rescan. + */ +function classifyInactiveRegions(syntax: PlainTextSyntax, text: string): (offset: number) => boolean { + const regions: Array<{ start: number; end: number }> = []; + let index = 0; + + while (index < text.length) { + if (text.startsWith(syntax.lineComment, index)) { + const newline = text.indexOf('\n', index); + const end = newline < 0 ? text.length : newline; + regions.push({ start: index, end }); + index = end; + continue; + } + + if (syntax.blockCommentOpen && syntax.blockCommentClose && text.startsWith(syntax.blockCommentOpen, index)) { + const close = text.indexOf(syntax.blockCommentClose, index + syntax.blockCommentOpen.length); + // An unterminated block comment runs to the end of the file, which is also how a compiler + // sees it — the rest of the document really is commented out. + const end = close < 0 ? text.length : close + syntax.blockCommentClose.length; + regions.push({ start: index, end }); + index = end; + continue; + } + + const opened = syntax.strings.find(candidate => text.startsWith(candidate.delimiter, index)); + if (opened) { + const end = findStringEnd(text, index, opened); + regions.push({ start: index, end }); + index = end; + continue; + } + + index++; + } + + return offset => { + let low = 0; + let high = regions.length - 1; + while (low <= high) { + const middle = (low + high) >> 1; + const region = regions[middle]; + if (offset < region.start) { + high = middle - 1; + } else if (offset >= region.end) { + low = middle + 1; + } else { + return true; + } + } + + return false; + }; +} + +/** Returns the offset just past the literal's closing delimiter, or the point at which it is abandoned. */ +function findStringEnd(text: string, start: number, delimiter: StringDelimiter): number { + let index = start + delimiter.delimiter.length; + + while (index < text.length) { + const character = text[index]; + + if (delimiter.supportsEscapes && character === '\\') { + index += 2; + continue; + } + + // A single-quoted literal that reaches a newline was never a literal — most often an apostrophe + // in a comment-like sentence. Ending it at the newline stops one stray quote from swallowing the + // remainder of the file and suppressing every later warning. + if (!delimiter.multiline && character === '\n') { + return index; + } + + if (text.startsWith(delimiter.delimiter, index)) { + return index + delimiter.delimiter.length; + } + + index++; + } + + return text.length; +} diff --git a/extension/src/editor/parsers/rustAppHostParser.ts b/extension/src/editor/parsers/rustAppHostParser.ts index e1ee65cc4f6..012ce36ee14 100644 --- a/extension/src/editor/parsers/rustAppHostParser.ts +++ b/extension/src/editor/parsers/rustAppHostParser.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { Language, Node as TreeSitterNode, Parser, Tree } from 'web-tree-sitter'; import { AppHostResourceParser, ParsedResource, registerParser } from './AppHostResourceParser'; import { initializeTreeSitter, resolveBundledWasmAssetPath } from './treeSitter'; -import { visit } from './treeSitterHelpers'; +import { isInInactiveNode, visit } from './treeSitterHelpers'; /** * Rust AppHost resource parser. @@ -66,6 +66,15 @@ class RustAppHostParser implements AppHostResourceParser { return builderCall ? findContainingStatementStartLine(builderCall) : undefined; }); } + + async filterActiveOffsets(document: vscode.TextDocument, offsets: readonly number[]): Promise { + if (offsets.length === 0) { + return []; + } + + return await withRustTree(document.getText(), tree => + offsets.filter(offset => !isInInactiveNode(tree.rootNode, offset))); + } } registerParser(new RustAppHostParser()); diff --git a/extension/src/editor/parsers/treeSitterHelpers.ts b/extension/src/editor/parsers/treeSitterHelpers.ts index 51b4f833f88..3723a6263ba 100644 --- a/extension/src/editor/parsers/treeSitterHelpers.ts +++ b/extension/src/editor/parsers/treeSitterHelpers.ts @@ -13,3 +13,26 @@ export function visit(node: TreeSitterNode, visitor: (node: TreeSitterNode) => b return true; } + +/** + * Returns true when the offset falls inside a comment or a string literal, and is therefore not + * executable code. Textual scans over a document use this to skip commented-out or quoted examples. + * + * The check walks up from the innermost node because tree-sitter reports the pieces of a literal + * (`string_content`, `escape_sequence`, an interpolation's surrounding text) as children of the + * literal rather than as strings themselves. + */ +export function isInInactiveNode(rootNode: TreeSitterNode, index: number): boolean { + let node: TreeSitterNode | null = rootNode.descendantForIndex(index); + while (node) { + // Grammars name these differently: C# has a single `comment`, Rust splits them into + // `line_comment` and `block_comment`, and both spell literals `*string*`. + if (node.type.includes('comment') || node.type.includes('string')) { + return true; + } + + node = node.parent; + } + + return false; +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 2a028de6f64..da4239e2e65 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -29,7 +29,9 @@ import { cloneAppHostState, createStateSnapshot, getDashboardUrl } from './exten import { createE2eStateFileBridge } from './testing/e2eStateFileBridge'; import type { AspireAppHostState, AspireExtensionApi, AspireExtensionStateSnapshot, WaitForStateOptions } from './types/extensionApi'; import { AppHostsViewTelemetry } from './views/AppHostsViewTelemetry'; -import { initializeCliPathEnvironmentSync } from './utils/cliPathEnvironment'; +import { CliPathEnvironmentSynchronizer } from './utils/cliPathEnvironment'; +import { CliPathRejectionNotifier } from './utils/cliPathRejectionNotifier'; +import { cliPathResolver } from './utils/cliPath'; import { AppHostLifecycleToolService, registerAppHostLifecycleTools } from './lm/appHostLifecycleTools'; import { registerInstrumentedCommand } from './activation/instrumentedCommand'; import { registerCliCommands } from './activation/registerCliCommands'; @@ -49,7 +51,7 @@ export async function activate(context: vscode.ExtensionContext) { workspace_folders: vscode.workspace.workspaceFolders?.length ?? 0, }); - const terminalProvider = new AspireTerminalProvider(context.subscriptions); + const terminalProvider = new AspireTerminalProvider(context.subscriptions, undefined, cliPathResolver); const testRunSessionManager = new TestRunSessionManager(); // Keep VS Code's contributed terminal/task environment in sync with the @@ -58,9 +60,16 @@ export async function activate(context: vscode.ExtensionContext) { // the extension (https://github.com/microsoft/aspire/issues/18073). Start // resolution before other activation work, then await it before returning so // the first user-initiated terminal already inherits AspireCliPath. - const cliPathEnvironmentInitialization = initializeCliPathEnvironmentSync(context.environmentVariableCollection, context.subscriptions, undefined, () => { - terminalProvider.invalidateSharedAspireTerminal(); - }).catch(error => { + const cliPathEnvironmentSynchronizer = new CliPathEnvironmentSynchronizer( + context.environmentVariableCollection, + cliPathResolver, + context.subscriptions, + target => terminalProvider.invalidateSharedAspireTerminal(target)); + context.subscriptions.push(cliPathEnvironmentSynchronizer); + // A rejected configured CLI path otherwise only appears in the output channel, which hides the + // fact that commands are running a different CLI than the one the user pinned. + context.subscriptions.push(new CliPathRejectionNotifier()); + const cliPathEnvironmentInitialization = cliPathEnvironmentSynchronizer.initialize().catch(error => { extensionLogOutputChannel.warn(`Initial Aspire CLI path resolution failed: ${String(error)}`); }); @@ -99,7 +108,7 @@ export async function activate(context: vscode.ExtensionContext) { engagement = new MeaningfulEngagementReporter(appHostDiscoveryService); context.subscriptions.push(engagement); - const appHostLaunchService = new AppHostLaunchService(); + const appHostLaunchService = new AppHostLaunchService(configInfoProvider); context.subscriptions.push(appHostLaunchService); const editorCommandProvider = new AspireEditorCommandProvider(appHostDiscoveryService, appHostLaunchService); @@ -169,8 +178,8 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(...cliCommandRegistrations); - const dynamicDebugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService, appHostLaunchService, vscode.DebugConfigurationProviderTriggerKind.Dynamic); - const initialDebugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService, appHostLaunchService, vscode.DebugConfigurationProviderTriggerKind.Initial); + const dynamicDebugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService, appHostLaunchService, context.workspaceState, vscode.DebugConfigurationProviderTriggerKind.Dynamic); + const initialDebugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService, appHostLaunchService, context.workspaceState, vscode.DebugConfigurationProviderTriggerKind.Initial); context.subscriptions.push( vscode.debug.registerDebugConfigurationProvider('aspire', dynamicDebugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic) ); @@ -192,7 +201,7 @@ export async function activate(context: vscode.ExtensionContext) { // Register Aspire MCP server definition provider so the Aspire MCP server // appears automatically in VS Code's MCP tools list for Aspire workspaces. - const mcpProvider = new AspireMcpServerDefinitionProvider(); + const mcpProvider = new AspireMcpServerDefinitionProvider(cliPathResolver); if (typeof vscode.lm?.registerMcpServerDefinitionProvider === 'function') { context.subscriptions.push(vscode.lm.registerMcpServerDefinitionProvider('aspire-mcp-server', mcpProvider)); context.subscriptions.push(mcpProvider); diff --git a/extension/src/lm/appHostLifecycleToolAdapters.ts b/extension/src/lm/appHostLifecycleToolAdapters.ts index fba63acc3a9..a2a22527251 100644 --- a/extension/src/lm/appHostLifecycleToolAdapters.ts +++ b/extension/src/lm/appHostLifecycleToolAdapters.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { appHostLifecycleStartConfirmationMessage, + appHostLifecycleStartConfirmationMessageIsolated, appHostLifecycleStartConfirmationTitle, appHostLifecycleStartInvocationMessage, appHostLifecycleStopConfirmationMessage, @@ -30,13 +31,16 @@ export class AppHostStartLanguageModelTool implements vscode.LanguageModelTool, token: vscode.CancellationToken): Promise { - const displayPath = escapeMarkdown(await this._service.describeTarget(options.input?.appHostPath, token)); + const description = await this._service.describeStartTarget(options.input, token); + const displayPath = escapeMarkdown(description.displayPath); const displayMode = describeRequestedMode(options.input?.mode); return { invocationMessage: appHostLifecycleStartInvocationMessage(displayPath), confirmationMessages: { title: appHostLifecycleStartConfirmationTitle, - message: appHostLifecycleStartConfirmationMessage(displayPath, displayMode), + message: description.isolated + ? appHostLifecycleStartConfirmationMessageIsolated(displayPath, displayMode) + : appHostLifecycleStartConfirmationMessage(displayPath, displayMode), }, }; } diff --git a/extension/src/lm/appHostLifecycleToolContracts.ts b/extension/src/lm/appHostLifecycleToolContracts.ts index b7c16e71379..dab793e5953 100644 --- a/extension/src/lm/appHostLifecycleToolContracts.ts +++ b/extension/src/lm/appHostLifecycleToolContracts.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { type CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { type AppHostIdentityRelation } from '../utils/appHostIdentity'; -import { type AppHostStopResult } from '../services/AppHostLaunchService'; +import { type AppHostLaunchIsolation, type AppHostStopResult } from '../services/AppHostLaunchService'; /** * Names of the contributed language model tools. These must match the `name` @@ -48,6 +48,8 @@ export type AppHostLifecycleOutcome = export interface AppHostStartToolInput { appHostPath: string; mode: AppHostLifecycleMode; + /** When omitted, linked git worktrees start isolated. Explicit true/false overrides that. */ + isolated?: boolean; } export interface AppHostStopToolInput { @@ -68,6 +70,8 @@ export interface AppHostLifecycleToolResult { appHostPath: string; requestedMode?: AppHostLifecycleMode; effectiveMode?: AppHostLifecycleMode; + /** Present on start results only when a known effective isolation value exists. */ + isolated?: boolean; controller: AppHostLifecycleController; /** * The selectors the tool accepts, returned only when the requested one did not @@ -94,7 +98,8 @@ export interface AppHostLifecycleLaunchService { getRunningAppHosts(token: vscode.CancellationToken): Promise; compareAppHostIdentity(left: string | undefined, right: string | undefined): AppHostIdentityRelation; runWithAppHostLifecycleLock(appHostPath: string, token: vscode.CancellationToken, action: (token: vscode.CancellationToken) => Promise): Promise; - launchFromLifecycleOwner(appHostPath: string, command: 'run', noDebug: boolean, token: vscode.CancellationToken): Promise; + resolveLaunchIsolation(appHostPath: string, isolated: boolean | undefined, token: vscode.CancellationToken): Promise; + launchFromLifecycleOwner(appHostPath: string, command: 'run', noDebug: boolean, isolated: boolean | undefined, token: vscode.CancellationToken): Promise; stopAppHost(appHostPath: string, token: vscode.CancellationToken): Promise; stopAppHostFromLifecycleOwner(appHostPath: string, token: vscode.CancellationToken): Promise; } @@ -164,6 +169,7 @@ export function createResult( requestedMode: AppHostLifecycleMode | undefined, effectiveMode: AppHostLifecycleMode | undefined, knownAppHosts?: readonly string[], + isolated?: boolean, ): AppHostLifecycleToolResult { const result: AppHostLifecycleToolResult = { tool, outcome, appHostPath, controller }; if (requestedMode) { @@ -178,6 +184,10 @@ export function createResult( result.knownAppHosts = knownAppHosts; } + if (tool === aspireAppHostStartToolName && isolated !== undefined) { + result.isolated = isolated; + } + return result; } @@ -186,9 +196,13 @@ export function parseMode(value: unknown): AppHostLifecycleMode | undefined { } export function isValidStartInput(value: unknown): value is AppHostStartToolInput { - return hasOnlyProperties(value, ['appHostPath', 'mode']) && - typeof value.appHostPath === 'string' && - parseMode(value.mode) !== undefined; + if (!hasOnlyProperties(value, ['appHostPath', 'mode'], ['isolated']) || + typeof value.appHostPath !== 'string' || + parseMode(value.mode) === undefined) { + return false; + } + + return !('isolated' in value) || typeof value.isolated === 'boolean'; } export function isValidStopInput(value: unknown): value is AppHostStopToolInput { @@ -196,12 +210,13 @@ export function isValidStopInput(value: unknown): value is AppHostStopToolInput typeof value.appHostPath === 'string'; } -function hasOnlyProperties(value: unknown, properties: readonly T[]): value is Record { +function hasOnlyProperties(value: unknown, properties: readonly T[], optional: readonly string[] = []): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false; } + const allowed = new Set([...properties, ...optional]); const actualProperties = Object.keys(value); - return actualProperties.length === properties.length && - properties.every(property => Object.prototype.hasOwnProperty.call(value, property)); + return properties.every(property => Object.prototype.hasOwnProperty.call(value, property)) && + actualProperties.every(property => allowed.has(property)); } diff --git a/extension/src/lm/appHostLifecycleToolService.ts b/extension/src/lm/appHostLifecycleToolService.ts index ee34fce7e70..3bfc256a9e6 100644 --- a/extension/src/lm/appHostLifecycleToolService.ts +++ b/extension/src/lm/appHostLifecycleToolService.ts @@ -3,6 +3,7 @@ import * as vscode from 'vscode'; import { appHostLifecycleUnresolvedPath } from '../loc/strings'; import { canonicalizeAppHostPath } from '../utils/appHostIdentity'; +import { isLinkedGitWorktree } from '../utils/gitWorktree'; import { extensionLogOutputChannel } from '../utils/logging'; import { isCommandCancellation } from '../utils/telemetry'; import { AppHostLifecycleLockTimeoutError, AppHostStopCancellationError, AppHostStopError, type AppHostStopResult } from '../services/AppHostLaunchService'; @@ -135,6 +136,32 @@ export class AppHostLifecycleToolService implements vscode.Disposable { return resolution.resolved ? resolution.target.displayPath : appHostLifecycleUnresolvedPath; } + async describeStartTarget(input: AppHostStartToolInput | undefined, token: vscode.CancellationToken): Promise<{ displayPath: string; isolated: boolean }> { + if (!vscode.workspace.isTrusted) { + return { displayPath: appHostLifecycleUnresolvedPath, isolated: false }; + } + + const resolution = await this.resolveTarget(input?.appHostPath, token); + if (!resolution.resolved) { + return { displayPath: appHostLifecycleUnresolvedPath, isolated: false }; + } + + // Confirmation reports the isolation the launch will actually request. Lifecycle-tool + // launches go through `launchFromLifecycleOwner`, which applies the + // `linked-worktree-default` policy, so an omitted `isolated` becomes true in a linked + // worktree. That half of the inference is a synchronous filesystem check, so it can be + // shown here and must be: this dialog gates "Always allow", and consenting to a + // non-isolated launch that then runs isolated is a consent mismatch. Only the CLI + // *capability* half stays deferred - it spawns the CLI, and degradation for older CLIs + // is negotiated at launch. + // + // `prepareInvocation` receives unvalidated model input, so only a real boolean counts as + // an explicit choice; anything else is rejected by `isValidStartInput` at invoke time. + const explicitIsolation = typeof input?.isolated === 'boolean' ? input.isolated : undefined; + const isolated = explicitIsolation ?? isLinkedGitWorktree(resolution.target.absolutePath); + return { displayPath: resolution.target.displayPath, isolated }; + } + async start(input: AppHostStartToolInput, token: vscode.CancellationToken): Promise { if (!isValidStartInput(input)) { return createResult(aspireAppHostStartToolName, 'invalidInput', '', 'none', undefined, undefined); @@ -177,6 +204,10 @@ export class AppHostLifecycleToolService implements vscode.Disposable { const current = recheck.target; const owned = this.findEditorSessions(current.absolutePath); + // These outcomes observe an existing launch rather than creating one. The + // tool therefore knows only that *some* process owns the AppHost, not + // which effective isolation its launcher negotiated, so `isolated` stays + // absent on each of them. // A session that finished startup is checked before the launching flag on // purpose. That flag is only cleared once `aspire ps` reconciliation observes // the process, which can lag far behind the session itself. @@ -220,11 +251,13 @@ export class AppHostLifecycleToolService implements vscode.Disposable { try { // `noDebug` is the only lever the tool exposes; the Aspire command is pinned // to `run` so an agent can never reach deploy/publish/do through this surface. - await this._dependencies.launchService.launchFromLifecycleOwner( + const launchedIsolation = await this._dependencies.launchService.launchFromLifecycleOwner( current.absolutePath, 'run', requestedMode === 'run', + input.isolated, lockToken); + return createResult(aspireAppHostStartToolName, 'started', current.relativePath, 'editor', requestedMode, requestedMode, undefined, launchedIsolation?.effective); } catch (error) { // The launch path clears its own reservation once it owns it, but a @@ -233,8 +266,6 @@ export class AppHostLifecycleToolService implements vscode.Disposable { this._dependencies.launchService.clearLaunching(current.absolutePath); return this.createErrorResult(aspireAppHostStartToolName, error, current.relativePath, 'editor', requestedMode, undefined); } - - return createResult(aspireAppHostStartToolName, 'started', current.relativePath, 'editor', requestedMode, requestedMode); }); } catch (error) { diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 197016f24ad..6567ee1fec6 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -21,6 +21,8 @@ export const aspireCliVersion = (version: string) => vscode.l10n.t('Aspire CLI V export const requiredCapability = (capability: string) => vscode.l10n.t('Required capability: {0}.', capability); export const aspireTerminalName = vscode.l10n.t('Aspire terminal'); export const aspireCliPathEnvironmentDescription = vscode.l10n.t('Forwards aspire.aspireCliExecutablePath as AspireCliPath so MSBuild bundle resolution and integrated terminals use the configured Aspire CLI.'); +export const configuredCliPathRejected = (configuredPath: string) => vscode.l10n.t('The configured Aspire CLI path could not be used, so a different Aspire CLI is running instead: {0}. Check that aspire.aspireCliExecutablePath points to the aspire executable itself and that it runs.', configuredPath); +export const configuredCliPathRejectedOpenSetting = vscode.l10n.t('Open Setting'); export const installCliPlaceholder = vscode.l10n.t('Select how to install the Aspire CLI'); export const installCliViewAllOptions = vscode.l10n.t('View all installation options'); export const installCliViewAllOptionsDescription = vscode.l10n.t('Open the installation guide (includes the install script)'); @@ -28,6 +30,7 @@ export const installCliDailyBuild = vscode.l10n.t('Daily build (dev)'); export const installCliDailyBuildDescription = vscode.l10n.t('Latest build from main via the install script'); export const terminalCommandArgumentControlCharacters = vscode.l10n.t('Aspire terminal command arguments cannot contain control characters.'); export const terminalCommandUnsafeLiteral = vscode.l10n.t('Aspire terminal command syntax can only contain command names and flags.'); +export const cmdShimPercentArgument = vscode.l10n.t('Arguments containing %NAME% cannot be forwarded through a Windows .cmd or .bat shim, because cmd.exe expands them before the Aspire CLI receives the value. Configure aspire.aspireCliExecutablePath to point at aspire.exe instead of the .cmd shim and try again.'); export const azureFunctionsUnsupportedTaskShell = vscode.l10n.t('The configured VS Code task shell is not supported for Azure Functions launch arguments. Configure terminal.integrated.automationProfile to use PowerShell, Command Prompt, bash, zsh, fish, or WSL.'); export const azureFunctionsCmdPercentArgument = vscode.l10n.t('Azure Functions launch arguments containing "%" are not supported by cmd.exe. Configure terminal.integrated.automationProfile.windows to use PowerShell and try again.'); export const azureFunctionsCmdDelayedExpansion = vscode.l10n.t('Azure Functions launch arguments containing "!" are not supported by cmd.exe because delayed environment variable expansion may change them. Configure terminal.integrated.automationProfile.windows to use PowerShell and try again.'); @@ -67,9 +70,11 @@ export const debugSessionStartTimedOut = (sessionName: string, seconds: number) export const debugSessionStopTimedOut = (sessionName: string, seconds: number) => vscode.l10n.t("Timed out after {1} seconds waiting for debug session '{0}' to stop.", sessionName, seconds); export const invalidTokenProvided = vscode.l10n.t('Invalid token provided.'); export const noWorkspaceFolder = vscode.l10n.t('No workspace folder found.'); +export const selectWorkspaceFolderForAspireCommand = vscode.l10n.t('Select the workspace folder for the Aspire command'); export const aspireConfigExists = vscode.l10n.t('Aspire launch configuration already exists in launch.json.'); export const failedToConfigureLaunchJson = (error: any) => vscode.l10n.t('Failed to configure launch.json: {0}.', error); export const defaultConfigurationName = vscode.l10n.t('Aspire: Launch default AppHost'); +export const defaultConfigurationNameForWorkspaceFolder = (workspaceFolderName: string, workspaceFolderUri: string) => vscode.l10n.t('Aspire: Launch default AppHost ({0}: {1})', workspaceFolderName, workspaceFolderUri); export const debugSessionAlreadyExists = (id: string) => vscode.l10n.t('A debug session is already active for id {0}.', id); export const selectDashboardLaunchBehavior = vscode.l10n.t('Select what should happen with the Aspire Dashboard when this launch configuration starts'); export const dashboardLaunchNoneLabel = vscode.l10n.t('Do not open the dashboard'); @@ -185,6 +190,8 @@ export const bunDisplayName = (script: string) => `Bun: ${script}`; export const bunLabel = 'Bun'; export const nodeDisplayName = (script: string) => `Node.js: ${script}`; export const nodeLabel = 'Node.js'; +export const javaDisplayName = (mainClass: string) => vscode.l10n.t('Java: {0}', mainClass); +export const javaLabel = vscode.l10n.t('Java'); export const dontShowAgainLabel = vscode.l10n.t("Don't Show Again"); export const doYouWantToSetDefaultApphost = (appHost: string) => vscode.l10n.t('Do you want to set {0} as the default AppHost for this workspace?', appHost); export const doYouWantToSelectDefaultApphost = vscode.l10n.t('Do you want to select the default AppHost for this workspace?'); @@ -205,6 +212,9 @@ export const dotNetRunFallbackDisablesDebugger = (outputPath: string, projectPat export const dotNetRunFileBasedExecutableProfileFallback = (profileName: string, projectPath: string) => vscode.l10n.t('The default launch profile \'{0}\' is an Executable profile, so dotnet run-api does not return the file-based app {1}; launching it with dotnet run without debugger attach. Breakpoints will not be hit for this resource.', profileName, projectPath); export const executableLaunchProfileMissingExecutablePath = (profileName: string) => vscode.l10n.t('Launch profile \'{0}\' uses commandName \'Executable\' but does not specify an executablePath. Add an executablePath to the launch profile.', profileName); export const lookingForDevkitBuildTask = vscode.l10n.t('C# Dev Kit is installed, looking for C# Dev Kit build task...'); +export const javaDebuggerExtensionNotInstalled = (extensionId: string) => vscode.l10n.t('Java AppHosts are launched through the Java debugger extension. Install {0} from the Extensions view, then start the AppHost again.', extensionId); +export const javaAppHostCommandNotRecognized = () => vscode.l10n.t('The Java AppHost launch command was not recognized and cannot be debugged.'); +export const javaAttachNotSupported = vscode.l10n.t('Java resources are started by the IDE rather than attached to, and the app host requested an attach session. Aspire cannot start one because no debug address is available.'); export const csharpDevKitNotInstalled = vscode.l10n.t('C# Dev Kit is not installed, building using dotnet CLI...'); export const hotReloadDisabledNotice = vscode.l10n.t('C# Dev Kit Hot Reload is disabled. When enabled, it can apply .NET code edits while debugging where supported.'); export const openSettingsLabel = vscode.l10n.t('Open Settings'); @@ -260,14 +270,23 @@ export const codeLensRustAppHostAlreadyRunning = vscode.l10n.t('⚠️ Do not cl export const codeLensRustAppHostAlreadyRunningTooltip = vscode.l10n.t('Use Aspire controls instead. rust-analyzer starts another Cargo process outside the running Aspire session.'); export const codeLensRustAppHostUseAspire = vscode.l10n.t('⚠️ Do not click the rust-analyzer Run or Debug actions; they bypass Aspire'); export const codeLensRustAppHostUseAspireTooltip = vscode.l10n.t('Use Aspire Run or Debug instead. rust-analyzer starts Cargo directly, so VS Code does not create or attach to an Aspire AppHost session.'); +export const codeLensSpringBootDashboardBypassesAspire = vscode.l10n.t('⚠️ Do not start this app from the Spring Boot Dashboard; it bypasses Aspire'); +export const codeLensSpringBootDashboardBypassesAspireTooltip = vscode.l10n.t('Use Aspire Run or Debug instead. The Spring Boot Dashboard starts the app on its own, so it receives none of the service discovery, connection string, or OpenTelemetry settings Aspire injects.'); +export const codeLensJavaAppHostAlreadyRunning = vscode.l10n.t('⚠️ Do not click the Java Run or Debug actions; this AppHost is already running in Aspire'); +export const codeLensJavaAppHostAlreadyRunningTooltip = vscode.l10n.t('Use Aspire controls instead. The Java extension starts another java process outside the running Aspire session.'); +export const codeLensJavaAppHostUseAspire = vscode.l10n.t('⚠️ Do not click the Java Run or Debug actions; they bypass Aspire'); +export const codeLensJavaAppHostUseAspireTooltip = vscode.l10n.t('Use Aspire Run or Debug instead. The Java extension runs this file directly, so the AppHost starts with no Aspire session: none of its resources are launched, and the dashboard never appears.'); export const appHostLifecycleStartConfirmationTitle = vscode.l10n.t('Start Aspire AppHost'); export const appHostLifecycleStopConfirmationTitle = vscode.l10n.t('Stop Aspire AppHost'); export const appHostLifecycleStartConfirmationMessage = (appHostPath: string, mode: string) => vscode.l10n.t('Start the Aspire AppHost {0} in {1} mode?', appHostPath, mode); +export const appHostLifecycleStartConfirmationMessageIsolated = (appHostPath: string, mode: string) => vscode.l10n.t('Start the Aspire AppHost {0} in {1} mode with isolation?', appHostPath, mode); export const appHostLifecycleStopConfirmationMessage = (appHostPath: string) => vscode.l10n.t('Stop the Aspire AppHost {0}?', appHostPath); export const appHostLifecycleStartInvocationMessage = (appHostPath: string) => vscode.l10n.t('Starting Aspire AppHost {0}...', appHostPath); export const appHostLifecycleStopInvocationMessage = (appHostPath: string) => vscode.l10n.t('Stopping Aspire AppHost {0}...', appHostPath); export const appHostLifecycleUnspecifiedMode = vscode.l10n.t('unspecified'); export const appHostLifecycleUnresolvedPath = vscode.l10n.t('an unresolved path'); export const appHostLifecycleBusy = vscode.l10n.t('Another start or stop operation for this Aspire AppHost is still in progress. Wait for it to finish and try again.'); +export const appHostLifecycleIsolationModeNotSupported = vscode.l10n.t('The selected Aspire CLI does not support the requested isolation mode.'); +export const appHostLifecycleIsolationCapabilityCouldNotBeVerified = vscode.l10n.t('The selected Aspire CLI isolation capability could not be verified.'); export const appHostLifecycleLaunchAlreadyClaimed = vscode.l10n.t('This Aspire AppHost is already starting or running. The new debug session was cancelled so only one AppHost runs.'); diff --git a/extension/src/mcp/AspireMcpServerDefinitionProvider.ts b/extension/src/mcp/AspireMcpServerDefinitionProvider.ts index 154a53ff456..9f1b576ec14 100644 --- a/extension/src/mcp/AspireMcpServerDefinitionProvider.ts +++ b/extension/src/mcp/AspireMcpServerDefinitionProvider.ts @@ -1,12 +1,10 @@ import * as vscode from 'vscode'; -import { - onDidChangeConfiguredCliPathRejection, - onDidChangeResolvedCliPathForForwarding, - resolveCliPath, -} from '../utils/cliPath'; +import { CliPathResolver, cliPathResolver } from '../utils/cliPath'; +import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { extensionLogOutputChannel } from '../utils/logging'; import { getCmdShimSpawnCommandWithoutVerbatimArguments, shouldWrapWithCmd } from '../utils/cmdShim'; import { getRegisterMcpServerInWorkspace, registerMcpServerInWorkspaceSetting } from '../utils/settings'; +import { ASPIRE_CLI_PATH_ENV_VAR, getForwardableResolvedAspireCliPath, ResolvedCliPathDependencies } from '../utils/cliPathEnvironment'; const mcpServerLabel = 'Aspire'; const mcpServerArgs = ['agent', 'mcp']; @@ -20,13 +18,31 @@ const aspireCliExecutablePathSetting = 'aspire.aspireCliExecutablePath'; * literal. See: * https://github.com/microsoft/vscode/blob/1.102.3/src/vs/workbench/api/node/extHostMcpNode.ts#L141-L167 */ -export function createAspireMcpServerDefinition(cliPath: string): vscode.McpStdioServerDefinition { +export function createAspireMcpServerDefinition( + cliPath: string, + label = mcpServerLabel, + cwd?: vscode.Uri, + deps?: ResolvedCliPathDependencies, +): vscode.McpStdioServerDefinition { + // `aspire agent mcp` can build an AppHost, and that build inherits this environment. An + // unbundled framework-dependent CLI path makes MSBuild's ResolveAspireCliBundle bind bundle + // assets to a CLI that has no bundle layout (ASPIRE009), so it must not be forwarded. Every + // other AspireCliPath producer applies the same guard; omitting the variable lets the build + // fall back to PATH probing, exactly as those sites do. + const forwardableCliPath = deps === undefined + ? getForwardableResolvedAspireCliPath(cliPath) + : getForwardableResolvedAspireCliPath(cliPath, deps); + const env = forwardableCliPath === undefined ? undefined : { [ASPIRE_CLI_PATH_ENV_VAR]: forwardableCliPath }; + let definition: vscode.McpStdioServerDefinition; if (!shouldWrapWithCmd(cliPath)) { - return new vscode.McpStdioServerDefinition(mcpServerLabel, cliPath, [...mcpServerArgs]); + definition = new vscode.McpStdioServerDefinition(label, cliPath, [...mcpServerArgs], env); } - - const { command, args } = getCmdShimSpawnCommandWithoutVerbatimArguments(cliPath, mcpServerArgs); - return new vscode.McpStdioServerDefinition(mcpServerLabel, command, args); + else { + const { command, args } = getCmdShimSpawnCommandWithoutVerbatimArguments(cliPath, mcpServerArgs); + definition = new vscode.McpStdioServerDefinition(label, command, args, env); + } + definition.cwd = cwd; + return definition; } /** @@ -38,15 +54,14 @@ export class AspireMcpServerDefinitionProvider implements vscode.McpServerDefini private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChangeMcpServerDefinitions = this._onDidChange.event; - private _cliPath: string | undefined; - private _cliAvailable: boolean = false; - private _shouldProvide: boolean = false; + private _definitions: vscode.McpStdioServerDefinition[] = []; private _refreshGeneration = 0; private _configChangeDisposable: vscode.Disposable | undefined; private _workspaceFolderChangeDisposable: vscode.Disposable | undefined; + private _workspaceTrustGrantDisposable: vscode.Disposable | undefined; private _cliPathForwardingChangeDisposable: vscode.Disposable | undefined; - constructor() { + constructor(private readonly _resolver: CliPathResolver = cliPathResolver) { // Re-evaluate when the setting changes this._configChangeDisposable = vscode.workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration(registerMcpServerInWorkspaceSetting) @@ -60,58 +75,95 @@ export class AspireMcpServerDefinitionProvider implements vscode.McpServerDefini this.refresh(); }); + this._workspaceTrustGrantDisposable = vscode.workspace.onDidGrantWorkspaceTrust(() => { + this.refresh(); + }); + // Another CLI consumer can discover that the configured path stopped // working or that an unpersisted fallback changed. Re-resolve the MCP // command so it cannot keep serving the stale path. - this._cliPathForwardingChangeDisposable = vscode.Disposable.from( - onDidChangeConfiguredCliPathRejection(() => this.refresh()), - onDidChangeResolvedCliPathForForwarding(() => this.refresh()), - ); + this._cliPathForwardingChangeDisposable = this._resolver.onDidChangeForwarding(() => this.refresh()); } async refresh(): Promise { const refreshGeneration = ++this._refreshGeneration; - const [cliResult, shouldProvide] = await Promise.all([ - resolveCliPath(), - checkShouldProvideMcpServer(), - ]); + const workspaceFolders = vscode.workspace.workspaceFolders ?? []; + const shouldProvide = await checkShouldProvideMcpServer(); + const cliResults = shouldProvide + ? await Promise.all(workspaceFolders.map(folder => this._resolver.resolve(workspaceFolderCliPathTarget(folder)))) + : []; if (refreshGeneration !== this._refreshGeneration) { return; } - const changed = - this._cliAvailable !== cliResult.available || - this._cliPath !== cliResult.cliPath || - this._shouldProvide !== shouldProvide; + const folderNameCounts = new Map(); + for (const folder of workspaceFolders) { + folderNameCounts.set(folder.name, (folderNameCounts.get(folder.name) ?? 0) + 1); + } + const reservedFolderLabels = new Set(workspaceFolders.map(folder => folder.name)); + const allocatedFolderLabels = new Set(); + const folderNameOrdinals = new Map(); + const folderLabels = workspaceFolders.map(folder => { + let folderLabel = folder.name; + if ((folderNameCounts.get(folder.name) ?? 0) > 1) { + let ordinal = folderNameOrdinals.get(folder.name) ?? 0; + do { + ordinal++; + folderLabel = `${folder.name} ${ordinal}`; + } while (reservedFolderLabels.has(folderLabel) || allocatedFolderLabels.has(folderLabel)); + folderNameOrdinals.set(folder.name, ordinal); + } + allocatedFolderLabels.add(folderLabel); + return folderLabel; + }); + const definitions = cliResults.flatMap((result, index) => { + if (!result.available) { + return []; + } - this._cliAvailable = cliResult.available; - this._cliPath = cliResult.cliPath; - this._shouldProvide = shouldProvide; + const folder = workspaceFolders[index]; + const label = workspaceFolders.length === 1 ? mcpServerLabel : `${mcpServerLabel} (${folderLabels[index]})`; + return [createAspireMcpServerDefinition(result.cliPath, label, folder.uri)]; + }); + const changed = !areMcpDefinitionsEqual(this._definitions, definitions); + this._definitions = definitions; if (changed) { - extensionLogOutputChannel.info(`Aspire MCP server definition changed: cliAvailable=${cliResult.available}, shouldProvide=${shouldProvide}`); + extensionLogOutputChannel.info(`Aspire MCP server definitions changed: count=${definitions.length}, shouldProvide=${shouldProvide}`); this._onDidChange.fire(); } } provideMcpServerDefinitions(_token: vscode.CancellationToken): vscode.ProviderResult { - if (!this._cliAvailable || !this._shouldProvide || !this._cliPath) { - return []; - } - - return [createAspireMcpServerDefinition(this._cliPath)]; + return [...this._definitions]; } dispose(): void { this._refreshGeneration++; this._configChangeDisposable?.dispose(); this._workspaceFolderChangeDisposable?.dispose(); + this._workspaceTrustGrantDisposable?.dispose(); this._cliPathForwardingChangeDisposable?.dispose(); this._onDidChange.dispose(); } } +function areMcpDefinitionsEqual( + left: readonly vscode.McpStdioServerDefinition[], + right: readonly vscode.McpStdioServerDefinition[], +): boolean { + return left.length === right.length && left.every((definition, index) => { + const other = right[index]; + return definition.label === other.label + && definition.command === other.command + && definition.cwd?.toString() === other.cwd?.toString() + && definition.args.length === other.args.length + && definition.args.every((argument, argumentIndex) => argument === other.args[argumentIndex]) + && JSON.stringify(definition.env) === JSON.stringify(other.env); + }); +} + /** * Determines whether the Aspire MCP server should be provided. * @@ -119,6 +171,10 @@ export class AspireMcpServerDefinitionProvider implements vscode.McpServerDefini * "aspire.registerMcpServerInWorkspace" setting is enabled. */ async function checkShouldProvideMcpServer(): Promise { + if (!vscode.workspace.isTrusted) { + return false; + } + if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) { return false; } diff --git a/extension/src/server/interactionService.ts b/extension/src/server/interactionService.ts index a0db375f8ae..55989428aff 100644 --- a/extension/src/server/interactionService.ts +++ b/extension/src/server/interactionService.ts @@ -12,6 +12,7 @@ import { AnsiColors } from '../utils/AspireTerminalProvider'; import { AspireDebugSession } from '../debugger/AspireDebugSession'; import type { DashboardLaunchBehavior } from '../debugger/AspireDebugSession'; import { appHostSelectionOriginConfigKey } from '../debugger/AspireDebugConfigurationMetadata'; +import type { AppHostSelectionOrigin } from '../debugger/AspireDebugConfigurationMetadata'; import { isDirectory } from '../utils/io'; import { sendTelemetryEvent } from '../utils/telemetry'; import { dashboardDefaultChangedNotificationKey } from '../utils/dashboardNotificationState'; @@ -162,6 +163,7 @@ type DebugSessionOptions = { command?: string; args?: string[]; env?: { [key: string]: string }; + appHostSelectionOrigin?: AppHostSelectionOrigin; }; export class InteractionService implements IInteractionService { @@ -678,6 +680,8 @@ export class InteractionService implements IInteractionService { this.clearProgressNotification(); const command = options?.command ?? 'run'; + const appHostSelectionOrigin = options?.appHostSelectionOrigin + ?? (projectFile ? 'user-selection' : 'default-discovery'); const debugConfiguration: AspireExtendedDebugConfiguration = { type: 'aspire', @@ -688,7 +692,7 @@ export class InteractionService implements IInteractionService { args: options?.args, env: options?.env, noDebug: !debug, - [appHostSelectionOriginConfigKey]: projectFile ? 'user-selection' : 'default-discovery', + [appHostSelectionOriginConfigKey]: appHostSelectionOrigin, }; const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(workingDirectory)); diff --git a/extension/src/services/AppHostLaunchService.ts b/extension/src/services/AppHostLaunchService.ts index 9a5ca76ccbf..ca8c7752800 100644 --- a/extension/src/services/AppHostLaunchService.ts +++ b/extension/src/services/AppHostLaunchService.ts @@ -1,19 +1,45 @@ import * as vscode from 'vscode'; import { AspireCommandType, AspireExtendedDebugConfiguration, type AspireResourceDebugSession } from '../dcp/types'; -import { startDebuggingDeclined } from '../loc/strings'; +import { appHostLifecycleIsolationCapabilityCouldNotBeVerified, appHostLifecycleIsolationModeNotSupported, startDebuggingDeclined } from '../loc/strings'; +import { ensureIsolatedCliArg, getRootIsolatedCliArg, isLinkedGitWorktree } from '../utils/gitWorktree'; import { compareAppHostIdentity, getAppHostIdentityKeyInfo, isAppHostPathWithinDirectory, type AppHostIdentityKeyInfo, type AppHostIdentityRelation } from '../utils/appHostIdentity'; import { classifyError, isCommandCancellation, sendTelemetryEvent, type EventProperties } from '../utils/telemetry'; import { extensionLogOutputChannel } from '../utils/logging'; import { checkCliAvailableOrRedirect } from '../utils/workspace'; +import { CliPathResolutionTarget, getCliPathTargetForUri } from '../utils/cliPathVariables'; import { appHostLaunchReservationIdConfigKey, appHostLaunchTokenConfigKey, appHostRestartSourceSessionIdConfigKey, appHostSelectionOriginConfigKey, appHostTelemetryTargetPathConfigKey, type AppHostSelectionOrigin } from '../debugger/AspireDebugConfigurationMetadata'; import { markAspireDebugConfigurationAsExtensionOwned } from '../debugger/AspireDebugConfigurationProviderInternal'; import { AppHostLifecycleLockTimeoutError, AppHostStopCancellationError, AppHostStopError, appHostLifecycleLockMaxHoldMs, appHostLifecycleLockWaitTimeoutMs, type AppHostDebugSessionTerminatedEvent, type AppHostEditorSessions, type AppHostLaunchRequestedEvent, type AppHostLaunchSession, type AppHostStopResult, type RunningAppHost } from './appHostLaunchContracts'; import { AppHostLaunchReservations } from './appHostLaunchReservations'; import { getLaunchTelemetryProperties, isE2eDebugLaunchSuppressed } from './appHostLaunchTelemetry'; +import { isolatedLaunchCapability, isolatedLaunchMinimumVersion, type CapabilityStatus } from '../types/configInfo'; export { AppHostLifecycleLockTimeoutError, AppHostStopCancellationError, AppHostStopError, appHostLifecycleLockMaxHoldMs, appHostLifecycleLockWaitTimeoutMs, externalLaunchReservationTimeoutMs } from './appHostLaunchContracts'; export type { AppHostDebugSessionTerminatedEvent, AppHostEditorSessions, AppHostLaunchRequestedEvent, AppHostLaunchSession, AppHostStopResult, RunningAppHost } from './appHostLaunchContracts'; +export interface AppHostLaunchCapabilityProvider { + getCapabilityStatus(capability: string, options?: { + suppressErrors?: boolean; + forceRefresh?: boolean; + cliPath?: string; + cancellationToken?: vscode.CancellationToken; + minimumVersion?: string; + target?: CliPathResolutionTarget; + }): Promise; +} + +export interface AppHostLaunchIsolation { + readonly effective: boolean; + readonly option: boolean | undefined; +} + +type AppHostLaunchIsolationPolicy = 'explicit-only' | 'linked-worktree-default'; + +export interface PreparedAppHostLaunchArguments { + readonly args: string[] | undefined; + readonly isolation: AppHostLaunchIsolation; +} + function isAspireCommandType(value: unknown): value is AspireCommandType { return value === 'run' || value === 'deploy' || value === 'publish' || value === 'do'; } @@ -83,7 +109,7 @@ export class AppHostLaunchService implements vscode.Disposable { private readonly _debugSessionSubscription: vscode.Disposable; - constructor() { + constructor(private readonly _capabilityProvider: AppHostLaunchCapabilityProvider) { const startSubscription = vscode.debug.onDidStartDebugSession(session => { const launchToken = session.configuration?.[appHostLaunchTokenConfigKey]; if (typeof launchToken === 'number') { @@ -485,10 +511,18 @@ export class AppHostLaunchService implements vscode.Disposable { return this._reservations.tryReserveExternalLaunch(appHostPath, isDirectoryScope); } + validateOrReacquireExternalLaunchReservation(appHostPath: string, reservationId: string, isDirectoryScope = false): string | false { + return this._reservations.validateOrReacquireExternalLaunchReservation(appHostPath, reservationId, isDirectoryScope); + } + replaceExternalLaunchReservation(previousAppHostPath: string, previousReservationId: string, appHostPath: string, isDirectoryScope = false): string | false { return this._reservations.replaceExternalLaunchReservation(previousAppHostPath, previousReservationId, appHostPath, isDirectoryScope); } + releaseExternalLaunchReservation(appHostPath: string, reservationId: string): void { + this._reservations.clearMatchingLaunching(appHostPath, reservationId); + } + private hasActiveLifecycleOperationWithinDirectory(directoryPath: string): boolean { return Array.from(this._lifecycleLockPathKeys.values()) .some(activePathKeys => Array.from(activePathKeys) @@ -540,7 +574,7 @@ export class AppHostLaunchService implements vscode.Disposable { * @param noDebug When true, launches without the debugger attached. * @param doStep Optional step name for the 'do' command. */ - async launch(appHostPath: string, command: AspireCommandType, noDebug: boolean, doStep?: string): Promise { + async launch(appHostPath: string, command: AspireCommandType, noDebug: boolean, doStep?: string, target?: CliPathResolutionTarget, cliPath?: string): Promise { const launchToken = this.trackPendingRun(appHostPath, command); try { return await this.runWithAppHostLifecycleLock(appHostPath, this._lifecycleCancellationSource.token, async lockToken => { @@ -552,7 +586,7 @@ export class AppHostLaunchService implements vscode.Disposable { throw new vscode.CancellationError(); } - await this.launchCore(appHostPath, command, noDebug, doStep, 'user-selection', launchToken, lockToken); + await this.launchCore(appHostPath, command, noDebug, doStep, 'user-selection', launchToken, lockToken, undefined, target, cliPath); }); } catch (error) { @@ -561,7 +595,7 @@ export class AppHostLaunchService implements vscode.Disposable { } } - async launchFromLifecycleOwner(appHostPath: string, command: 'run', noDebug: boolean, token: vscode.CancellationToken): Promise { + async launchFromLifecycleOwner(appHostPath: string, command: 'run', noDebug: boolean, isolated: boolean | undefined, token: vscode.CancellationToken): Promise { if (this._disposed) { throw new vscode.CancellationError(); } @@ -570,7 +604,7 @@ export class AppHostLaunchService implements vscode.Disposable { // establish a missing default, but must not replace an existing workspace choice. const launchToken = this.trackPendingRun(appHostPath, command); try { - await this.launchCore(appHostPath, command, noDebug, undefined, 'explicit-launch-configuration', launchToken, token); + return await this.launchCore(appHostPath, command, noDebug, undefined, 'explicit-launch-configuration', launchToken, token, isolated, undefined, undefined, 'linked-worktree-default'); } catch (error) { this._pendingRunPathByToken.delete(launchToken); @@ -578,6 +612,91 @@ export class AppHostLaunchService implements vscode.Disposable { } } + /** + * Computes the root Aspire CLI args for a launch without reserving or starting anything. + * + * The launch.json/F5 resolver reuses this so it can negotiate isolation with the exact + * CLI it already selected, rather than recursing back through `startDebugging`. + */ + async prepareLaunchArguments( + appHostPath: string, + command: AspireCommandType, + args: string[] | undefined, + token: vscode.CancellationToken, + cliPath?: string, + target: CliPathResolutionTarget = getCliPathTargetForUri(vscode.Uri.file(appHostPath)), + isolated: boolean | undefined = getRootIsolatedCliArg(args), + isolationPolicy: AppHostLaunchIsolationPolicy = 'explicit-only', + ): Promise { + if (command !== 'run') { + return { + args, + isolation: { effective: false, option: undefined }, + }; + } + + const launchIsolation = await this.resolveLaunchIsolation(appHostPath, isolated, token, cliPath, isolationPolicy, target); + return { + args: ensureIsolatedCliArg(args, launchIsolation.option), + isolation: launchIsolation, + }; + } + + /** + * Resolves requested or inferred isolation against the selected CLI's advertised + * capabilities. Known older CLIs may omit inferred isolation or explicit false, but an + * explicit choice is never changed when capability support could not be determined. + */ + async resolveLaunchIsolation( + appHostPath: string, + isolated: boolean | undefined, + token: vscode.CancellationToken, + cliPath?: string, + isolationPolicy: AppHostLaunchIsolationPolicy = 'explicit-only', + target: CliPathResolutionTarget = getCliPathTargetForUri(vscode.Uri.file(appHostPath)), + ): Promise { + throwIfCancelled(token); + const inferredIsolation = isolationPolicy === 'linked-worktree-default' && isLinkedGitWorktree(appHostPath); + const effective = isolated ?? inferredIsolation; + const needsCapability = effective || isolated === false; + if (!needsCapability) { + return { effective: false, option: undefined }; + } + + const supportStatus = await this._capabilityProvider.getCapabilityStatus(isolatedLaunchCapability, { + suppressErrors: true, + forceRefresh: cliPath !== undefined, + cliPath, + cancellationToken: token, + minimumVersion: isolatedLaunchMinimumVersion, + target, + }); + throwIfCancelled(token); + if (supportStatus === 'supported') { + return { effective, option: isolated ?? true }; + } + + if (cliPath === undefined && isolated !== undefined) { + // Preflight capability data may describe an earlier PATH or setting snapshot. + // Preserve explicit user input for confirmation and let the exact-CLI refresh + // immediately before launch decide whether that executable can honor it. + return { effective: isolated, option: isolated }; + } + + const mustFailSafely = isolated === true || + (supportStatus === 'unavailable' && (effective || (isolated === false && inferredIsolation))); + if (mustFailSafely) { + const reason = supportStatus === 'unsupported' + ? appHostLifecycleIsolationModeNotSupported + : appHostLifecycleIsolationCapabilityCouldNotBeVerified; + throw new Error(reason); + } + + // An unconfirmed inferred preference may fall back for compatibility with CLIs that + // predate isolation. A known older CLI can also honor explicit false by omission. + return { effective: false, option: undefined }; + } + private async launchCore( appHostPath: string, command: AspireCommandType, @@ -586,7 +705,11 @@ export class AppHostLaunchService implements vscode.Disposable { selectionOrigin: AppHostSelectionOrigin, launchToken: number, token: vscode.CancellationToken, - ): Promise { + isolated: boolean | undefined, + target?: CliPathResolutionTarget, + cliPath?: string, + isolationPolicy: AppHostLaunchIsolationPolicy = 'explicit-only', + ): Promise { // Reserve before the first await. The awaits below (telemetry, the CLI gate) run // before `startDebugging`, so reserving later would leave a window in which a // concurrent F5 or tool-driven start sees no launch in flight for this AppHost. @@ -659,6 +782,8 @@ export class AppHostLaunchService implements vscode.Disposable { }); abortIfCancelled(); if (executionSuppressed) { + await releaseReservationOnFailure( + () => this.prepareLaunchArguments(appHostPath, command, config.args, token, undefined, target, isolated, isolationPolicy)); this.clearMatchingLaunching(appHostPath, reservationId); sendTelemetryEvent('aspire/vscode/apphost/launch/result', { ...telemetryProperties, @@ -666,17 +791,38 @@ export class AppHostLaunchService implements vscode.Disposable { }, { duration_ms: Date.now() - startTime, }); - return; + // E2E suppression exercises launch routing without starting an AppHost, so no + // effective isolation mode was established for the lifecycle result to report. + return undefined; } try { - const cliAvailability = await checkCliAvailableOrRedirect('debug_gate'); - if (!cliAvailability.available) { - throw new vscode.CancellationError(); + let resolvedCliPath = cliPath; + if (!resolvedCliPath) { + const cliAvailability = await checkCliAvailableOrRedirect('debug_gate', target ?? getCliPathTargetForUri(vscode.Uri.file(appHostPath))); + if (!cliAvailability.available) { + throw new vscode.CancellationError(); + } + resolvedCliPath = cliAvailability.cliPath; } throwIfCancelled(token); config.skipCliAvailabilityCheck = true; - + const launchPreparation = await this.prepareLaunchArguments( + appHostPath, + command, + config.args, + token, + resolvedCliPath, + target, + isolated, + isolationPolicy); + if (launchPreparation.args === undefined) { + delete config.args; + } + else { + config.args = launchPreparation.args; + } + config.resolvedCliPath = resolvedCliPath; const started = await vscode.debug.startDebugging(undefined, config); if (!started) { // A false result means VS Code declined the launch before the @@ -694,6 +840,7 @@ export class AppHostLaunchService implements vscode.Disposable { }, { duration_ms: Date.now() - startTime, }); + return launchPreparation.isolation; } catch (err) { this._pendingRunPathByToken.delete(launchToken); this.clearMatchingLaunching(appHostPath, reservationId); diff --git a/extension/src/services/AppHostStopper.ts b/extension/src/services/AppHostStopper.ts index b8229724fc8..6101cf0273e 100644 --- a/extension/src/services/AppHostStopper.ts +++ b/extension/src/services/AppHostStopper.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { spawnCliProcess, terminateCliProcess } from '../utils/process/cliProcess'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { getCliPathTargetForUri } from '../utils/cliPathVariables'; const maxRetainedStderrLength = 16 * 1024; @@ -10,7 +11,7 @@ export async function stopExternalAppHost( cancellationToken: vscode.CancellationToken, ): Promise { - const cliPath = await terminalProvider.getAspireCliExecutablePath(); + const cliPath = await terminalProvider.getAspireCliExecutablePath(getCliPathTargetForUri(vscode.Uri.file(appHostPath))); if (cancellationToken.isCancellationRequested) { throw new vscode.CancellationError(); } @@ -39,13 +40,10 @@ export async function stopExternalAppHost( return; } - if (cliProcess.exitCode !== null || cliProcess.signalCode !== null) { - settleCancellation(); - return; - } - termination ??= terminateCliProcess(cliProcess, 'aspire stop'); - void termination.then(settleCancellation); + void termination.then( + settleCancellation, + error => settle(() => reject(error))); }; cancellationRegistration = cancellationToken.onCancellationRequested(() => { @@ -68,7 +66,7 @@ export async function stopExternalAppHost( }, exitCallback: code => { if (cancellationRequested) { - settleCancellation(); + terminateForCancellation(); return; } @@ -85,7 +83,7 @@ export async function stopExternalAppHost( }, errorCallback: error => { if (cancellationRequested) { - settleCancellation(); + terminateForCancellation(); } else { settle(() => reject(error)); } diff --git a/extension/src/services/appHostLaunchReservations.ts b/extension/src/services/appHostLaunchReservations.ts index 49161512d03..662b909aba5 100644 --- a/extension/src/services/appHostLaunchReservations.ts +++ b/extension/src/services/appHostLaunchReservations.ts @@ -186,9 +186,7 @@ export class AppHostLaunchReservations implements vscode.Disposable { return false; } } - const key = getAppHostPathComparisonKey(appHostPath); - this.cancelExternalReservationExpiry(key); const reservationId = String(++this._nextLaunchReservationId); this._launchReservationIds.set(key, reservationId); this.recordLatestLaunchReservation(appHostPath, reservationId); @@ -199,6 +197,34 @@ export class AppHostLaunchReservations implements vscode.Disposable { this._launchingPaths.add(key); this._onDidChangeLaunchingState.fire(); } + this.scheduleExternalReservationExpiry(key, reservationId); + return reservationId; + } + + /** + * Atomically validates a repeated resolver pass against the reservation generation it + * received. A live pending reservation keeps its ID and gets a fresh expiry window. An + * expired reservation may reacquire with a new ID, but an ID mismatch means a newer + * owner won and must not be replaced or released by the stale resolver pass. + */ + validateOrReacquireExternalLaunchReservation(appHostPath: string, reservationId: string, isDirectoryScope = false): string | false { + const key = getAppHostPathComparisonKey(appHostPath); + const ownsCurrentReservation = this._launchingPaths.has(key) && + this._launchReservationIds.get(key) === reservationId && + !this._lifecycleLaunchClaims.has(key) && + this._externalDirectoryLaunchReservations.has(key) === isDirectoryScope; + if (ownsCurrentReservation) { + if (this._externalReservationExpiries.has(key)) { + this.scheduleExternalReservationExpiry(key, reservationId); + } + return reservationId; + } + + return this.tryReserveExternalLaunch(appHostPath, isDirectoryScope); + } + + private scheduleExternalReservationExpiry(key: string, reservationId: string): void { + this.cancelExternalReservationExpiry(key); const expiry = setTimeout(() => { // Only expire while this timer is still the registered one for the key. Another // reservation arriving in the meantime cancels this timer, so reaching here means @@ -214,7 +240,6 @@ export class AppHostLaunchReservations implements vscode.Disposable { // A reservation must never be a reason for the host process to stay alive. expiry.unref?.(); this._externalReservationExpiries.set(key, expiry); - return reservationId; } /** diff --git a/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts b/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts index 54776ecbb97..48d2a077181 100644 --- a/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts +++ b/extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts @@ -2,11 +2,14 @@ import * as assert from 'assert'; import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { findRunningAppHost, getDebugLaunchCount, isSamePath, readStateFile, waitForDebugSessionStartup, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForWorkspaceAppHost } from './helpers/assertions'; -import { executeE2eControlCommand, runE2eTeardown, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +import { findRunningAppHost, getCommandInvocationCount, getDebugLaunchCount, isSamePath, readStateFile, waitForCommandOutcome, waitForDebugSessionStartup, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForSelectedWorkspaceAppHost, waitForWorkspaceAppHost } from './helpers/assertions'; +import { executeE2eControlCommand, getCliWrapperInvocations, restoreE2eCliPathForE2E, restoreWorkspaceAppHostConfig, restoreWorkspaceCliPath, runE2eTeardown, setE2eCliPathForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning, writeTokenlessStableCliWrapper, writeWorkspaceAppHostConfigForPath, writeWorkspaceCliPath } from './helpers/fixtures'; import { runProcess, terminateProcessTree } from './helpers/process'; -import { ensureDiagnosticsDir, getCliPath, getPrimaryAppHostProjectPath, getWorkspaceRoot } from './helpers/paths'; +import { getProcessEntry, listProcessEntries, type ProcessEntry } from './helpers/processArguments'; +import { ensureDiagnosticsDir, getCliPath, getPrimaryAppHostProjectPath, getRepoRoot, getRunRoot, getWorkspaceRoot } from './helpers/paths'; import { acceptModalDialog, openAspireView, type AcceptedModalDialog } from './helpers/vscode'; +import { assertLinkedAppHostCliLaunch, commandLineArgumentEquals } from '../test/helpers/processArguments'; +import { getCmdShimSpawnCommand, shouldWrapWithCmd } from '../utils/cmdShimCommand'; interface LifecycleToolResult { tool: string; @@ -14,6 +17,7 @@ interface LifecycleToolResult { appHostPath: string; requestedMode?: string; effectiveMode?: string; + isolated?: boolean; controller: string; } @@ -36,10 +40,28 @@ interface ExternalAppHostRun { getOutput(): { stdout: string; stderr: string }; } +interface LinkedWorktreeAppHostFixture { + seedRepositoryPath: string; + linkedWorktreePath: string; + appHostPath: string; + gitFilePath: string; + gitFileContents: string; + adminDirectoryPath: string; + adminBackpointerPath: string; + adminBackpointerContents: string; +} + +interface ExtensionSpawnLog { + path: string; + line: string; +} + const startToolName = 'aspire_apphost_start'; const stopToolName = 'aspire_apphost_stop'; +const appHostArgvEvidenceEnvironmentVariable = 'ASPIRE_EXTENSION_E2E_APPHOST_ARGV_EVIDENCE'; +const appHostArgvEvidenceArgumentPrefix = '--e2e-argv-evidence='; -suite('Aspire AppHost lifecycle language model tools E2E', function () { +suite('Aspire AppHost lifecycle E2E', function () { this.timeout(900000); teardown(async () => { @@ -219,8 +241,958 @@ suite('Aspire AppHost lifecycle language model tools E2E', function () { } } }); + + suite('linked-worktree isolation', function () { + const launchConfigurationName = 'Aspire linked worktree exact argv proof'; + const launchJsonPath = path.join(getWorkspaceRoot(), '.vscode', 'launch.json'); + let originalLaunchJson: string | undefined; + let fixture: LinkedWorktreeAppHostFixture | undefined; + + suiteSetup(async () => { + await openAspireView(); + await waitForWorkspaceAppHost(); + await waitForRepositoryIdle(); + originalLaunchJson = fs.existsSync(launchJsonPath) ? fs.readFileSync(launchJsonPath, 'utf8') : undefined; + fixture = await createLinkedWorktreeAppHostFixture(); + }); + + teardown(async () => { + if (fixture) { + await resetLinkedWorktreeAppHostFixture(fixture, launchJsonPath, originalLaunchJson); + } + }); + + suiteTeardown(async () => { + if (fixture) { + await cleanupLinkedWorktreeAppHostFixture(fixture, launchJsonPath, originalLaunchJson); + } + }); + + test('runAspireCli requires a workspace-relative working directory', async () => { + const linkedFixture = fixture; + assert.ok(linkedFixture); + const relativeWorkingDirectory = path.relative(getWorkspaceRoot(), linkedFixture.linkedWorktreePath); + + await assert.rejects( + () => invokeControlCommand({ + name: 'runAspireCli', + args: ['--version'], + workingDirectory: linkedFixture.linkedWorktreePath, + }), + /runAspireCli workingDirectory must be workspace-relative/); + await assert.rejects( + () => invokeControlCommand({ + name: 'runAspireCli', + args: ['--version'], + workingDirectory: path.join('..', path.basename(linkedFixture.linkedWorktreePath)), + }), + /runAspireCli workingDirectory must stay inside the configured E2E workspace root/); + + const result = await invokeControlCommand<{ exitCode: number | null }>({ + name: 'runAspireCli', + args: ['--version'], + workingDirectory: relativeWorkingDirectory, + }); + assert.strictEqual(result.exitCode, 0); + }); + + test('runAspireCli waits for timed-out process tree cleanup', async () => { + const linkedFixture = fixture; + assert.ok(linkedFixture); + const wrapper = writeTimeoutCliWrapper(); + let descendantPid: number | undefined; + + try { + await writeWorkspaceCliPath(wrapper.cliPath); + await setE2eCliPathForE2E(wrapper.cliPath); + const invocation = invokeControlCommand({ + name: 'runAspireCli', + args: ['timeout-tree'], + workingDirectory: path.relative(getWorkspaceRoot(), linkedFixture.linkedWorktreePath), + timeoutMs: 1000, + }, 30000); + invocation.catch(() => undefined); + + descendantPid = await waitForProcessIdFile(wrapper.pidPath, 10000); + await assert.rejects(invocation, /timed out after 1000ms/); + assert.strictEqual(isProcessRunning(descendantPid), false, `Expected descendant process ${descendantPid} to be gone before runAspireCli rejected.`); + } + finally { + await runE2eTeardown([ + () => restoreE2eCliPathForE2E(), + () => restoreWorkspaceCliPath(), + ], 'runAspireCli timeout process tree E2E cleanup failed.'); + if (descendantPid !== undefined && isProcessRunning(descendantPid)) { + terminateProcessTree(descendantPid, 'SIGKILL'); + } + fs.rmSync(wrapper.directory, { recursive: true, force: true }); + } + }); + + test('runAspireCli redacts forwarded values and output from errors', async () => { + const linkedFixture = fixture; + assert.ok(linkedFixture); + const wrapper = writeTimeoutCliWrapper(); + const forwardedValue = `forwarded-${process.pid}-${Date.now()}`; + let descendantPid: number | undefined; + + try { + await writeWorkspaceCliPath(wrapper.cliPath); + await setE2eCliPathForE2E(wrapper.cliPath); + + const timeoutError = await captureError(() => invokeControlCommand({ + name: 'runAspireCli', + args: ['timeout-tree', '--', forwardedValue], + workingDirectory: path.relative(getWorkspaceRoot(), linkedFixture.linkedWorktreePath), + timeoutMs: 1000, + }, 30000)); + descendantPid = await waitForProcessIdFile(wrapper.pidPath, 10000); + if (timeoutError.message.includes(forwardedValue)) { + throw new Error('runAspireCli timeout diagnostics exposed a forwarded argument or command output.'); + } + assert.match(timeoutError.message, /timeout-tree -- timed out after 1000ms/); + assert.strictEqual(timeoutError.message.includes('stdout:'), false); + assert.strictEqual(timeoutError.message.includes('stderr:'), false); + + const nonzeroError = await captureError(() => invokeControlCommand({ + name: 'runAspireCli', + args: ['fail', '--', forwardedValue], + workingDirectory: path.relative(getWorkspaceRoot(), linkedFixture.linkedWorktreePath), + }, 30000)); + if (nonzeroError.message.includes(forwardedValue)) { + throw new Error('runAspireCli nonzero-exit diagnostics exposed a forwarded argument or command output.'); + } + assert.match(nonzeroError.message, /fail -- exited with code 23/); + assert.strictEqual(nonzeroError.message.includes('stdout:'), false); + assert.strictEqual(nonzeroError.message.includes('stderr:'), false); + } + finally { + await runE2eTeardown([ + () => restoreE2eCliPathForE2E(), + () => restoreWorkspaceCliPath(), + ], 'runAspireCli redaction E2E cleanup failed.'); + if (descendantPid !== undefined && isProcessRunning(descendantPid)) { + terminateProcessTree(descendantPid, 'SIGKILL'); + } + fs.rmSync(wrapper.directory, { recursive: true, force: true }); + } + }); + + test('starts a linked-worktree AppHost with inferred isolation through vscode.lm.invokeTool', async () => { + assert.ok(fixture); + let artifact: Record = {}; + + try { + const relativeAppHostPath = path.relative(getWorkspaceRoot(), fixture.appHostPath).split(path.sep).join('/'); + artifact = { + status: 'created', + ...fixture, + relativeAppHostPath, + cli: { + path: getCliPath(), + version: (await runProcess(getCliPath(), ['--version'], { timeoutMs: 60000 })).stdout.trim(), + repositoryHead: (await runProcess('git', ['rev-parse', 'HEAD'], { cwd: getRepoRoot(), timeoutMs: 30000 })).stdout.trim(), + }, + }; + writeLinkedWorktreeArtifact('lm', artifact); + + writeWorkspaceAppHostConfigForPath(fixture.appHostPath); + const refreshBefore = getCommandInvocationCount('aspire-vscode.refreshAppHosts'); + await executeE2eControlCommand({ name: 'refreshAppHosts' }); + await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 60000, refreshBefore); + const discovered = await waitForSelectedWorkspaceAppHost(fixture.appHostPath); + assert.ok(discovered.state.workspaceAppHostPath && isSamePath(discovered.state.workspaceAppHostPath, fixture.appHostPath)); + + const preparedStart = await invokeControlCommand({ + name: 'prepareLanguageModelToolInvocation', + toolName: startToolName, + input: { appHostPath: relativeAppHostPath, mode: 'debug' }, + }); + // The AppHost lives in a linked worktree and `isolated` was omitted, so the + // lifecycle tool infers isolation. The confirmation has to disclose that, because + // this dialog is what "Always allow" is granted against. + const expectedConfirmation = `Start the Aspire AppHost ${relativeAppHostPath} in debug mode with isolation?`; + assert.strictEqual(preparedStart.confirmationTitle, 'Start Aspire AppHost'); + assert.strictEqual(preparedStart.confirmationMessage, expectedConfirmation); + + const startInvocation = await invokeLifecycleTool({ + name: 'invokeLanguageModelTool', + toolName: startToolName, + input: { appHostPath: relativeAppHostPath, mode: 'debug' }, + }, 600000, 1, 'apphost-lifecycle-linked-worktree-start-confirmation'); + assert.strictEqual(startInvocation.dialogs[0].message, 'Start Aspire AppHost'); + assert.strictEqual(startInvocation.dialogs[0].details, expectedConfirmation); + assert.deepStrictEqual(startInvocation.results, [{ + tool: startToolName, + outcome: 'started', + appHostPath: relativeAppHostPath, + requestedMode: 'debug', + effectiveMode: 'debug', + isolated: true, + controller: 'editor', + }]); + + await waitForDebugSessionStartup(fixture.appHostPath, 600000); + const processInfoStatus = await executeE2eControlCommand({ name: 'getDebugSessionProcessInfo', appHostPath: fixture.appHostPath }); + const processInfo = processInfoStatus.result as { appHostPath?: string; cliPid?: number; appHostPid?: number }; + assert.ok(processInfo.appHostPath && isSamePath(processInfo.appHostPath, fixture.appHostPath)); + assert.ok(processInfo.cliPid, `Expected the E2E state bridge to report the linked AppHost CLI process: ${JSON.stringify(processInfoStatus)}`); + + const cliProcess = await waitForLinkedAppHostCliProcess(processInfo.cliPid, fixture.appHostPath, 180000); + const extensionLog = await waitForLinkedAppHostSpawnLog(fixture.appHostPath, 60000); + const runningState = readStateFile(); + assert.ok(runningState.state.workspaceAppHostPath && isSamePath(runningState.state.workspaceAppHostPath, fixture.appHostPath)); + const activeDebugSession = runningState.state.debugSessions.find(session => + fixture && session.appHostPath && isSamePath(session.appHostPath, fixture.appHostPath) && session.startupCompleted); + assert.ok(activeDebugSession, `Expected an active debug session for ${fixture.appHostPath}.`); + + Object.assign(artifact, { + status: 'running', + preparedStart, + startConfirmation: startInvocation.dialogs[0], + startResult: startInvocation.results[0], + processInfo, + cliProcess, + extensionLog, + workspaceAppHostPath: runningState.state.workspaceAppHostPath, + activeDebugSession, + }); + writeLinkedWorktreeArtifact('lm', artifact); + + const stopInvocation = await invokeLifecycleTool({ + name: 'invokeLanguageModelTool', + toolName: stopToolName, + input: { appHostPath: relativeAppHostPath }, + }, 300000, 1, 'apphost-lifecycle-linked-worktree-stop-confirmation'); + assert.strictEqual(stopInvocation.dialogs[0].message, 'Stop Aspire AppHost'); + assert.strictEqual(stopInvocation.dialogs[0].details, `Stop the Aspire AppHost ${relativeAppHostPath}?`); + assert.strictEqual(stopInvocation.results.length, 1); + assert.strictEqual(stopInvocation.results[0].tool, stopToolName); + assert.strictEqual(stopInvocation.results[0].outcome, 'stopped'); + assert.strictEqual(stopInvocation.results[0].appHostPath, relativeAppHostPath); + assert.strictEqual(stopInvocation.results[0].controller, 'editor'); + + await waitForNoDebugSessions(180000); + await waitForNoRunningAppHost(180000, fixture.appHostPath); + Object.assign(artifact, { + status: 'passed', + stopConfirmation: stopInvocation.dialogs[0], + stopResult: stopInvocation.results[0], + }); + writeLinkedWorktreeArtifact('lm', artifact); + } + catch (error) { + Object.assign(artifact, { + status: 'failed', + error: error instanceof Error ? `${error.name}: ${error.message.split(/\r?\n/, 1)[0]}` : String(error), + }); + writeLinkedWorktreeArtifact('lm', artifact); + throw error; + } + }); + + test('Aspire CLI start-debug-session forwards exact linked-worktree AppHost argv without inferred root isolation', async () => { + assert.ok(fixture); + const diagnosticsDirectory = ensureDiagnosticsDir(); + const argvEvidencePath = path.join(diagnosticsDirectory, 'apphost-direct-cli-argv-live.json'); + const appHostArguments = [ + `${appHostArgvEvidenceArgumentPrefix}${argvEvidencePath}`, + '--custom', + 'value with spaces', + '', + 'literal "quote"', + path.join('e2e path with spaces', 'backslash\\segment'), + '--detach', + ]; + const cliArguments = [ + 'run', + '--start-debug-session', + '--apphost', + fixture.appHostPath, + '--', + ...appHostArguments, + ]; + const workingDirectory = path.relative(getWorkspaceRoot(), fixture.linkedWorktreePath); + let artifact: Record = {}; + + try { + fs.rmSync(argvEvidencePath, { force: true }); + artifact = { + status: 'created', + ...fixture, + cliArguments, + appHostArguments, + workingDirectory, + argvEvidencePath, + cli: { + path: getCliPath(), + version: (await runProcess(getCliPath(), ['--version'], { timeoutMs: 60000 })).stdout.trim(), + repositoryHead: (await runProcess('git', ['rev-parse', 'HEAD'], { cwd: getRepoRoot(), timeoutMs: 30000 })).stdout.trim(), + }, + }; + writeLinkedWorktreeArtifact('direct', artifact); + + const cliResult = await invokeControlCommand<{ exitCode: number | null; stdout: string; stderr: string }>({ + name: 'runAspireCli', + args: cliArguments, + workingDirectory, + timeoutMs: 600000, + }, 600000); + assert.strictEqual(cliResult.exitCode, 0); + + await waitForDebugSessionStartup(fixture.appHostPath, 600000); + const processInfo = await invokeControlCommand<{ appHostPath?: string; cliPid?: number; appHostPid?: number }>({ + name: 'getDebugSessionProcessInfo', + appHostPath: fixture.appHostPath, + }); + assert.ok(processInfo.cliPid, `Expected the E2E state bridge to report the direct CLI process: ${JSON.stringify(processInfo)}`); + const cliProcess = await waitForExactLinkedAppHostCliProcess( + processInfo.cliPid, + getCliPath(), + fixture.appHostPath, + appHostArguments, + 180000, + false); + const appHostArgv = await waitForAppHostArgvEvidence(argvEvidencePath, 180000); + assert.deepStrictEqual(appHostArgv, appHostArguments); + + const runningState = readStateFile(); + const activeDebugSession = runningState.state.debugSessions.find(session => + fixture && session.appHostPath && isSamePath(session.appHostPath, fixture.appHostPath) && session.startupCompleted); + assert.ok(activeDebugSession, `Expected an active debug session for ${fixture.appHostPath}.`); + + Object.assign(artifact, { + status: 'passed', + cliResult, + cliProcess, + processInfo, + appHostArgv, + activeDebugSession, + }); + writeLinkedWorktreeArtifact('direct', artifact); + } + catch (error) { + Object.assign(artifact, { + status: 'failed', + error: error instanceof Error ? `${error.name}: ${error.message.split(/\r?\n/, 1)[0]}` : String(error), + }); + writeLinkedWorktreeArtifact('direct', artifact); + throw error; + } + finally { + await resetLinkedWorktreeAppHostFixture(fixture, launchJsonPath, originalLaunchJson); + fs.rmSync(argvEvidencePath, { force: true }); + } + }); + + test('launch.json F5 preserves exact linked-worktree .NET AppHost argv twice without inferred root isolation', async () => { + assert.ok(fixture); + const diagnosticsDirectory = ensureDiagnosticsDir(); + const argvEvidencePath = path.join(diagnosticsDirectory, 'apphost-f5-argv-live.json'); + const cliInvocationLogPath = path.join(diagnosticsDirectory, 'apphost-f5-cli-invocations.jsonl'); + const appHostArguments = [ + '--custom', + 'value with spaces', + '', + 'literal "quote"', + path.join('e2e path with spaces', 'backslash\\segment'), + ]; + let artifact: Record = {}; + + try { + const cliWrapperPath = writeTokenlessStableCliWrapper(cliInvocationLogPath); + const relativeAppHostPath = path.relative(getWorkspaceRoot(), fixture.appHostPath).split(path.sep).join('/'); + artifact = { + status: 'created', + ...fixture, + relativeAppHostPath, + launchJsonPath, + launchConfigurationName, + appHostArguments, + argvEvidencePath, + cliInvocationLogPath, + cli: { + wrapperPath: cliWrapperPath, + realPath: getCliPath(), + wrapperVersion: '13.2.0', + realVersion: (await runProcess(getCliPath(), ['--version'], { timeoutMs: 60000 })).stdout.trim(), + repositoryHead: (await runProcess('git', ['rev-parse', 'HEAD'], { cwd: getRepoRoot(), timeoutMs: 30000 })).stdout.trim(), + }, + }; + writeLinkedWorktreeArtifact('f5', artifact); + + await writeWorkspaceCliPath(cliWrapperPath); + await setE2eCliPathForE2E(cliWrapperPath); + + writeLaunchJson(launchJsonPath, { + version: '0.2.0', + configurations: [{ + type: 'aspire', + request: 'launch', + name: launchConfigurationName, + program: fixture.appHostPath, + command: 'run', + args: ['--', ...appHostArguments], + env: { + [appHostArgvEvidenceEnvironmentVariable]: argvEvidencePath, + }, + }], + }); + + const passes: Record[] = []; + for (let pass = 1; pass <= 2; pass++) { + fs.rmSync(argvEvidencePath, { force: true }); + const invocationCountBeforeLaunch = getCliWrapperInvocations(cliInvocationLogPath).length; + const debugLaunchesBefore = getDebugLaunchCount(); + const startStatus = await executeE2eControlCommand({ + name: 'startDebugging', + configurationName: launchConfigurationName, + }, { timeoutMs: 600000 }); + assert.strictEqual(startStatus.result, true, `Expected VS Code to start launch.json configuration '${launchConfigurationName}' on pass ${pass}.`); + + await waitForDebugSessionStartup(fixture.appHostPath, 600000); + const debugLaunchCountDelta = getDebugLaunchCount() - debugLaunchesBefore; + + const processInfoStatus = await executeE2eControlCommand({ name: 'getDebugSessionProcessInfo', appHostPath: fixture.appHostPath }); + const processInfo = processInfoStatus.result as { appHostPath?: string; cliPid?: number; appHostPid?: number }; + assert.ok(processInfo.appHostPath && isSamePath(processInfo.appHostPath, fixture.appHostPath)); + assert.ok(processInfo.cliPid, `Expected the E2E state bridge to report the linked AppHost CLI process on pass ${pass}: ${JSON.stringify(processInfoStatus)}`); + + const cliProcess = await waitForExactLinkedAppHostCliProcess( + processInfo.cliPid, + cliWrapperPath, + fixture.appHostPath, + appHostArguments, + 180000, + false); + const cliInvocations = waitForCliFallbackAndLaunchInvocations( + cliInvocationLogPath, + invocationCountBeforeLaunch, + fixture.appHostPath, + appHostArguments, + false); + const extensionLog = await waitForLinkedAppHostSpawnLog(fixture.appHostPath, 60000); + const appHostArgv = await waitForAppHostArgvEvidence(argvEvidencePath, 180000); + assert.deepStrictEqual(appHostArgv, appHostArguments); + + const retainedArgvEvidencePath = path.join(diagnosticsDirectory, `apphost-f5-argv-pass-${pass}.json`); + fs.copyFileSync(argvEvidencePath, retainedArgvEvidencePath); + const runningState = readStateFile(); + const activeDebugSession = runningState.state.debugSessions.find(session => + fixture && session.appHostPath && isSamePath(session.appHostPath, fixture.appHostPath) && session.startupCompleted); + assert.ok(activeDebugSession, `Expected an active debug session for ${fixture.appHostPath} on pass ${pass}.`); + + passes.push({ + pass, + startResult: startStatus.result, + debugLaunchCountDelta, + processInfo, + cliProcess, + cliInvocations, + extensionLog, + appHostArgv, + retainedArgvEvidencePath, + activeDebugSession, + }); + Object.assign(artifact, { + status: `pass-${pass}-running`, + passes, + workspaceAppHostPath: runningState.state.workspaceAppHostPath, + }); + writeLinkedWorktreeArtifact('f5', artifact); + + await executeE2eControlCommand({ name: 'stopDebugging' }); + await waitForNoDebugSessions(180000); + await waitForNoRunningAppHost(180000, fixture.appHostPath); + } + + Object.assign(artifact, { + status: 'passed', + passes, + }); + writeLinkedWorktreeArtifact('f5', artifact); + } + catch (error) { + Object.assign(artifact, { + status: 'failed', + error: error instanceof Error ? `${error.name}: ${error.message.split(/\r?\n/, 1)[0]}` : String(error), + }); + writeLinkedWorktreeArtifact('f5', artifact); + throw error; + } + }); + }); }); +async function createLinkedWorktreeAppHostFixture(): Promise { + const runRoot = getRunRoot(); + assert.ok(runRoot, 'ASPIRE_EXTENSION_E2E_RUN_ROOT is required to create a linked-worktree AppHost fixture.'); + + const seedRepositoryPath = path.join(runRoot, 'apphost lifecycle linked worktree seed'); + const linkedWorktreePath = path.join(getWorkspaceRoot(), 'AspireE2E Linked Worktree'); + await removeLinkedWorktreePaths(seedRepositoryPath, linkedWorktreePath); + fs.mkdirSync(seedRepositoryPath, { recursive: true }); + + try { + await runProcess('git', ['init'], { cwd: seedRepositoryPath, timeoutMs: 30000 }); + await runProcess('git', ['config', 'user.email', 'aspire-extension-e2e@example.invalid'], { cwd: seedRepositoryPath, timeoutMs: 30000 }); + await runProcess('git', ['config', 'user.name', 'Aspire Extension E2E'], { cwd: seedRepositoryPath, timeoutMs: 30000 }); + + const sdkVersion = process.env.ASPIRE_EXTENSION_E2E_APPHOST_SDK_VERSION; + assert.ok(sdkVersion, 'ASPIRE_EXTENSION_E2E_APPHOST_SDK_VERSION is required to create a linked-worktree AppHost fixture.'); + const projectDirectory = path.join(seedRepositoryPath, 'LinkedAppHost'); + fs.mkdirSync(projectDirectory, { recursive: true }); + fs.writeFileSync(path.join(projectDirectory, 'LinkedAppHost.csproj'), ` + + + Exe + net8.0 + enable + enable + + + +`); + fs.writeFileSync(path.join(projectDirectory, 'AppHost.cs'), `// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; + +const string argvEvidenceArgumentPrefix = "${appHostArgvEvidenceArgumentPrefix}"; +var appHostArguments = Environment.GetCommandLineArgs().Skip(1).ToArray(); +var argvEvidenceArgument = appHostArguments.FirstOrDefault(argument => + argument.StartsWith(argvEvidenceArgumentPrefix, StringComparison.Ordinal)); +var argvEvidencePath = argvEvidenceArgument is not null + ? argvEvidenceArgument[argvEvidenceArgumentPrefix.Length..] + : Environment.GetEnvironmentVariable("${appHostArgvEvidenceEnvironmentVariable}"); +if (!string.IsNullOrEmpty(argvEvidencePath)) +{ + var evidenceDirectory = Path.GetDirectoryName(Path.GetFullPath(argvEvidencePath)); + if (evidenceDirectory is not null) + { + Directory.CreateDirectory(evidenceDirectory); + } + + File.WriteAllText(argvEvidencePath, JsonSerializer.Serialize(appHostArguments)); +} + +var builder = DistributedApplication.CreateBuilder(args); + +builder.Build().Run(); +`); + + await runProcess('git', ['add', '.'], { cwd: seedRepositoryPath, timeoutMs: 30000 }); + await runProcess('git', ['commit', '-m', 'Seed linked AppHost'], { cwd: seedRepositoryPath, timeoutMs: 30000 }); + await runProcess('git', ['worktree', 'add', '-b', 'e2e-linked-worktree', linkedWorktreePath], { cwd: seedRepositoryPath, timeoutMs: 60000 }); + + const appHostPath = path.join(linkedWorktreePath, 'LinkedAppHost', 'LinkedAppHost.csproj'); + assert.ok(fs.existsSync(appHostPath), `Expected the linked worktree to contain ${appHostPath}.`); + + const gitFilePath = path.join(linkedWorktreePath, '.git'); + assert.strictEqual(fs.statSync(gitFilePath).isFile(), true, 'Expected a genuine linked worktree .git file.'); + const gitFileContents = fs.readFileSync(gitFilePath, 'utf8').trim(); + const gitDirectoryMatch = /^gitdir:\s*(.+)$/i.exec(gitFileContents); + assert.ok(gitDirectoryMatch, `Expected ${gitFilePath} to contain a gitdir pointer.`); + const adminDirectoryPath = resolveGitMetadataPath(path.dirname(gitFilePath), gitDirectoryMatch[1]); + assert.strictEqual(path.basename(path.dirname(adminDirectoryPath)), 'worktrees', 'Expected the linked-worktree admin directory below worktrees/.'); + assert.strictEqual(fs.statSync(adminDirectoryPath).isDirectory(), true, 'Expected the linked-worktree admin directory to exist.'); + + const adminBackpointerPath = path.join(adminDirectoryPath, 'gitdir'); + const adminBackpointerContents = fs.readFileSync(adminBackpointerPath, 'utf8').trim(); + const resolvedBackpointer = fs.realpathSync.native(resolveGitMetadataPath(adminDirectoryPath, adminBackpointerContents)); + assert.ok( + isSamePath(resolvedBackpointer, fs.realpathSync.native(gitFilePath)), + `Expected ${adminBackpointerPath} to point back to ${gitFilePath}.`); + + return { + seedRepositoryPath, + linkedWorktreePath, + appHostPath, + gitFilePath, + gitFileContents, + adminDirectoryPath, + adminBackpointerPath, + adminBackpointerContents, + }; + } + catch (error) { + await removeLinkedWorktreePaths(seedRepositoryPath, linkedWorktreePath); + throw error; + } +} + +async function cleanupLinkedWorktreeAppHostFixture( + fixture: LinkedWorktreeAppHostFixture, + launchJsonPath: string, + originalLaunchJson: string | undefined +): Promise { + await runE2eTeardown([ + () => resetLinkedWorktreeAppHostFixture(fixture, launchJsonPath, originalLaunchJson), + () => removeLinkedWorktreePaths(fixture.seedRepositoryPath, fixture.linkedWorktreePath), + ], 'Linked-worktree AppHost fixture cleanup failed.'); +} + +async function resetLinkedWorktreeAppHostFixture( + fixture: LinkedWorktreeAppHostFixture, + launchJsonPath: string, + originalLaunchJson: string | undefined +): Promise { + await runE2eTeardown([ + () => executeE2eControlCommand({ name: 'stopDebugging' }), + () => stopAppHostIfRunning(fixture.appHostPath), + () => waitForNoDebugSessions(180000), + () => waitForNoRunningAppHost(180000, fixture.appHostPath), + () => restoreE2eCliPathForE2E(), + () => restoreWorkspaceCliPath(), + () => restoreLaunchJson(launchJsonPath, originalLaunchJson), + () => restorePrimaryWorkspaceAppHostSelection(), + ], 'Linked-worktree AppHost lifecycle E2E cleanup failed.'); +} + +async function restorePrimaryWorkspaceAppHostSelection(): Promise { + restoreWorkspaceAppHostConfig(); + const refreshBefore = getCommandInvocationCount('aspire-vscode.refreshAppHosts'); + await executeE2eControlCommand({ name: 'refreshAppHosts' }); + await waitForCommandOutcome('aspire-vscode.refreshAppHosts', 'success', 60000, refreshBefore); + await waitForSelectedWorkspaceAppHost(getPrimaryAppHostProjectPath(), 120000); +} + +async function removeLinkedWorktreePaths(seedRepositoryPath: string, linkedWorktreePath: string): Promise { + const gitDirectoryPath = path.join(seedRepositoryPath, '.git'); + if (fs.existsSync(gitDirectoryPath)) { + for (let attempt = 0; attempt < (process.platform === 'win32' ? 10 : 2) && fs.existsSync(linkedWorktreePath); attempt++) { + const removal = await runProcess('git', ['worktree', 'remove', '--force', linkedWorktreePath], { + cwd: seedRepositoryPath, + timeoutMs: 30000, + rejectOnNonZeroExit: false, + }).catch(() => undefined); + if (removal?.exitCode === 0 || !fs.existsSync(linkedWorktreePath)) { + break; + } + + await delay(250); + } + } + + await removePathWithRetry(linkedWorktreePath); + if (fs.existsSync(gitDirectoryPath)) { + await runProcess('git', ['worktree', 'prune', '--expire', 'now'], { + cwd: seedRepositoryPath, + timeoutMs: 30000, + rejectOnNonZeroExit: false, + }).catch(() => undefined); + } + await removePathWithRetry(seedRepositoryPath); + + assert.strictEqual(fs.existsSync(linkedWorktreePath), false, `Expected linked worktree cleanup to remove ${linkedWorktreePath}.`); + assert.strictEqual(fs.existsSync(seedRepositoryPath), false, `Expected seed repository cleanup to remove ${seedRepositoryPath}.`); +} + +async function removePathWithRetry(targetPath: string): Promise { + const maximumAttempts = process.platform === 'win32' ? 40 : 3; + for (let attempt = 1; ; attempt++) { + try { + fs.rmSync(targetPath, { recursive: true, force: true }); + return; + } + catch (error) { + if (attempt >= maximumAttempts) { + throw error; + } + + await delay(250); + } + } +} + +function resolveGitMetadataPath(baseDirectory: string, value: string): string { + return path.resolve(baseDirectory, value); +} + +function writeLaunchJson(launchJsonPath: string, value: unknown): void { + fs.mkdirSync(path.dirname(launchJsonPath), { recursive: true }); + fs.writeFileSync(launchJsonPath, JSON.stringify(value, undefined, 2)); +} + +function restoreLaunchJson(launchJsonPath: string, originalLaunchJson: string | undefined): void { + if (originalLaunchJson === undefined) { + fs.rmSync(launchJsonPath, { force: true }); + return; + } + + fs.writeFileSync(launchJsonPath, originalLaunchJson); +} + +async function waitForLinkedAppHostCliProcess( + cliPid: number, + appHostPath: string, + timeoutMs: number, + expectRootIsolation = true, +): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const cliProcess = await getProcessEntry(cliPid); + if (cliProcess) { + assertLinkedAppHostCliLaunchExpectation(cliProcess.arguments, appHostPath, getCliPath(), expectRootIsolation); + return cliProcess; + } + + await delay(250); + } + + throw new Error(`Timed out after ${timeoutMs}ms waiting for Aspire CLI process ${cliPid} to launch ${appHostPath}.`); +} + +function assertLinkedAppHostCliLaunchExpectation( + argumentsList: readonly string[], + appHostPath: string, + cliPath: string, + expectRootIsolation: boolean, + platform = process.platform, +): void { + if (expectRootIsolation) { + assertLinkedAppHostCliLaunch(argumentsList, appHostPath, cliPath, platform); + return; + } + + const formattedArguments = JSON.stringify(argumentsList); + assert.ok( + argumentsList.length > 0 && commandLineArgumentEquals(argumentsList[0], cliPath, platform), + `Expected the current E2E CLI '${cliPath}' as argv[0] in: ${formattedArguments}`); + + const runIndex = argumentsList.indexOf('run', 1); + assert.ok(runIndex > 0, `Expected exact 'run' after the CLI path in: ${formattedArguments}`); + const separatorIndex = argumentsList.indexOf('--', runIndex + 1); + const rootArgumentsEnd = separatorIndex >= 0 ? separatorIndex : argumentsList.length; + + const isolatedIndex = argumentsList.indexOf('--isolated', runIndex + 1); + assert.ok(isolatedIndex < 0 || isolatedIndex >= rootArgumentsEnd, `Did not expect inferred root '--isolated' after 'run' in: ${formattedArguments}`); + assert.strictEqual( + argumentsList.slice(runIndex + 1, rootArgumentsEnd).some(argument => argument === '--isolated=false'), + false, + `Did not expect any root '--isolated=false' after 'run' in: ${formattedArguments}`); + + const startDebugSessionIndex = argumentsList.indexOf('--start-debug-session', runIndex + 1); + assert.ok(startDebugSessionIndex > runIndex && startDebugSessionIndex < rootArgumentsEnd, `Expected exact root '--start-debug-session' after 'run' in: ${formattedArguments}`); + + const appHostIndex = argumentsList.indexOf('--apphost', startDebugSessionIndex + 1); + assert.ok(appHostIndex > startDebugSessionIndex && appHostIndex < rootArgumentsEnd, `Expected exact root '--apphost' after '--start-debug-session' in: ${formattedArguments}`); + assert.ok( + appHostIndex + 1 < argumentsList.length && + commandLineArgumentEquals(argumentsList[appHostIndex + 1], appHostPath, platform), + `Expected exact --apphost path '${appHostPath}' immediately after '--apphost' in: ${formattedArguments}`); +} + +async function waitForExactLinkedAppHostCliProcess( + cliPid: number, + cliPath: string, + appHostPath: string, + appHostArguments: readonly string[], + timeoutMs: number, + expectRootIsolation = true, +): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const cliProcess = await getProcessEntry(cliPid); + if (cliProcess) { + if (shouldWrapWithCmd(cliPath)) { + const expectedArguments = getExpectedLinkedAppHostCliProcessArguments( + cliPath, + appHostPath, + appHostArguments, + expectRootIsolation); + assert.ok( + cliProcess.arguments.length >= 5 && + cliProcess.arguments.slice(0, 5).every((argument, index) => + commandLineArgumentEquals(argument, expectedArguments[index])), + `Expected the cmd.exe wrapper prefix ${JSON.stringify(expectedArguments.slice(0, 5))}, got ${JSON.stringify(cliProcess.arguments)}.`); + assert.ok( + cliProcess.commandLine.toLowerCase().includes(expectedArguments[5].toLowerCase()), + `Expected the raw cmd.exe command line to contain ${JSON.stringify(expectedArguments[5])}, got ${JSON.stringify(cliProcess.commandLine)}.`); + } + else { + assertExactLinkedAppHostCliLaunch( + cliProcess.arguments, + appHostPath, + cliPath, + appHostArguments, + expectRootIsolation); + } + return cliProcess; + } + + await delay(250); + } + + throw new Error(`Timed out after ${timeoutMs}ms waiting for exact argv from Aspire CLI process ${cliPid} that launches ${appHostPath}.`); +} + +function waitForCliFallbackAndLaunchInvocations( + invocationLogPath: string, + invocationCountBeforeLaunch: number, + appHostPath: string, + appHostArguments: readonly string[], + expectRootIsolation = true, +): string[][] { + const invocations = getCliWrapperInvocations(invocationLogPath).slice(invocationCountBeforeLaunch); + const expectedRunInvocation = getExpectedLinkedAppHostCliArguments(appHostPath, appHostArguments, expectRootIsolation); + const runInvocations = invocations.filter(invocation => invocation[0] === 'run'); + assert.strictEqual(runInvocations.length, 1, `Expected one run invocation, got ${JSON.stringify(runInvocations)}.`); + assert.strictEqual(runInvocations[0].length, expectedRunInvocation.length); + const appHostPathIndex = expectedRunInvocation.indexOf(appHostPath); + assert.ok(runInvocations[0].every((argument, index) => + index === appHostPathIndex + ? commandLineArgumentEquals(argument, expectedRunInvocation[index]) + : argument === expectedRunInvocation[index])); + + if (expectRootIsolation) { + const configInfoIndex = invocations.findIndex(invocation => + JSON.stringify(invocation) === JSON.stringify(['config', 'info', '--json', '--nologo'])); + assert.ok(configInfoIndex >= 0, `Expected tokenless capability negotiation before launch: ${JSON.stringify(invocations)}.`); + const versionFallbackIndex = invocations.findIndex( + (invocation, index) => index > configInfoIndex && JSON.stringify(invocation) === JSON.stringify(['--version'])); + assert.ok(versionFallbackIndex > configInfoIndex, `Expected stable 13.2 version fallback after tokenless config info: ${JSON.stringify(invocations)}.`); + } + + return invocations; +} + +function getExpectedLinkedAppHostCliArguments( + appHostPath: string, + appHostArguments: readonly string[], + expectRootIsolation = true, +): string[] { + return [ + 'run', + ...(expectRootIsolation ? ['--isolated'] : []), + '--start-debug-session', + '--nologo', + '--apphost', + appHostPath, + '--', + ...appHostArguments, + ]; +} + +function getExpectedLinkedAppHostCliProcessArguments( + cliPath: string, + appHostPath: string, + appHostArguments: readonly string[], + expectRootIsolation = true, +): string[] { + const cliArguments = getExpectedLinkedAppHostCliArguments(appHostPath, appHostArguments, expectRootIsolation); + if (shouldWrapWithCmd(cliPath)) { + const spawnCommand = getCmdShimSpawnCommand(cliPath, cliArguments); + return [spawnCommand.command, ...spawnCommand.args]; + } + + return [cliPath, ...cliArguments]; +} + +function assertExactLinkedAppHostCliLaunch( + argumentsList: readonly string[], + appHostPath: string, + cliPath: string, + appHostArguments: readonly string[], + expectRootIsolation = true, + platform = process.platform, +): void { + const expectedArguments = getExpectedLinkedAppHostCliProcessArguments( + cliPath, + appHostPath, + appHostArguments, + expectRootIsolation); + const appHostPathIndex = expectedArguments.indexOf(appHostPath); + const pathArgumentIndexes = shouldWrapWithCmd(cliPath) + ? [0] + : [0, appHostPathIndex]; + const argumentsMatch = argumentsList.length === expectedArguments.length && + argumentsList.every((argument, index) => + pathArgumentIndexes.includes(index) + ? commandLineArgumentEquals(argument, expectedArguments[index], platform) + : argument === expectedArguments[index]); + + assert.ok( + argumentsMatch, + `Expected exact Aspire CLI argv ${JSON.stringify(expectedArguments)}, got ${JSON.stringify(argumentsList)}.`); +} + +async function waitForAppHostArgvEvidence(evidencePath: string, timeoutMs: number): Promise { + const started = Date.now(); + let lastError: unknown; + while (Date.now() - started < timeoutMs) { + try { + const value = JSON.parse(fs.readFileSync(evidencePath, 'utf8')) as unknown; + if (Array.isArray(value) && value.every(item => typeof item === 'string')) { + return value; + } + + lastError = new Error(`Expected a JSON string array, got ${JSON.stringify(value)}.`); + } + catch (error) { + lastError = error; + } + + await delay(250); + } + + throw new Error(`Timed out after ${timeoutMs}ms waiting for exact AppHost argv evidence at ${evidencePath}. Last error: ${String(lastError)}`); +} + +async function waitForLinkedAppHostSpawnLog(appHostPath: string, timeoutMs: number): Promise { + const runRoot = getRunRoot(); + assert.ok(runRoot, 'ASPIRE_EXTENSION_E2E_RUN_ROOT is required to inspect Aspire Extension.log.'); + const logsRoot = path.join(runRoot, 'storage', 'settings', 'logs'); + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + for (const logPath of findFilesNamed(logsRoot, 'Aspire Extension.log')) { + const lines = fs.readFileSync(logPath, 'utf8').split(/\r?\n/); + const line = [...lines].reverse().find(candidate => + candidate.includes('Spawning Aspire CLI process:') && + candidate.includes('--start-debug-session') && + commandLineTextIncludes(candidate, `--apphost ${appHostPath}`) && + candidate.includes('; cwd=')); + if (line) { + return { path: logPath, line }; + } + } + + await delay(250); + } + + throw new Error(`Timed out after ${timeoutMs}ms waiting for Aspire Extension.log to record the linked AppHost launch for ${appHostPath}.`); +} + +function commandLineTextIncludes(value: string, expected: string): boolean { + return process.platform === 'win32' + ? value.toLowerCase().includes(expected.toLowerCase()) + : value.includes(expected); +} + +function findFilesNamed(rootPath: string, fileName: string): string[] { + if (!fs.existsSync(rootPath)) { + return []; + } + + return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap(entry => { + const entryPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) { + return findFilesNamed(entryPath, fileName); + } + + return entry.isFile() && entry.name === fileName ? [entryPath] : []; + }); +} + +function writeLinkedWorktreeArtifact(scenario: 'lm' | 'f5' | 'direct', artifact: Record): void { + const artifactPath = path.join(ensureDiagnosticsDir(), `apphost-lifecycle-linked-worktree-${scenario}.json`); + fs.writeFileSync(artifactPath, JSON.stringify(artifact, undefined, 2)); +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + function startExternalAppHost(appHostPath: string): ExternalAppHostRun { const spawnCommand = getExternalCliSpawnCommand(getCliPath(), ['run', '--non-interactive', '--nologo', '--apphost', appHostPath]); const child = spawn(spawnCommand.command, spawnCommand.args, { @@ -357,6 +1329,98 @@ function isProcessRunning(pid: number): boolean { } } +function writeTimeoutCliWrapper(): { cliPath: string; pidPath: string; directory: string } { + const directory = path.join(getWorkspaceRoot(), '.e2e-cli-wrappers', 'run-aspire-cli-timeout'); + const childScriptPath = path.join(directory, 'timeout-child.js'); + const pidPath = path.join(directory, 'timeout-child.pid'); + fs.rmSync(directory, { recursive: true, force: true }); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(childScriptPath, [ + "const fs = require('fs');", + 'const [pidPath, mode, ...args] = process.argv.slice(2);', + "const output = args.join(' ');", + 'console.log(output);', + 'console.error(output);', + "if (mode === 'fail') {", + ' process.exit(23);', + '}', + 'fs.writeFileSync(pidPath, String(process.pid));', + 'setInterval(() => undefined, 1000);', + '', + ].join('\n')); + + if (process.platform === 'win32') { + const cliPath = path.join(directory, 'aspire-timeout.cmd'); + fs.writeFileSync(cliPath, [ + '@echo off', + 'if "%~1"=="--version" (', + ' echo 99.0.0', + ' exit /b 0', + ')', + `${quoteWindowsBatchArgument(process.execPath)} ${quoteWindowsBatchArgument(childScriptPath)} ${quoteWindowsBatchArgument(pidPath)} %*`, + '', + ].join('\r\n')); + return { cliPath, pidPath, directory }; + } + + const cliPath = path.join(directory, 'aspire-timeout'); + fs.writeFileSync(cliPath, [ + '#!/bin/sh', + 'if [ "$1" = "--version" ]; then', + ' echo "99.0.0"', + ' exit 0', + 'fi', + 'mode=$1', + 'shift', + 'printf "%s\\n" "$*"', + 'printf "%s\\n" "$*" >&2', + 'if [ "$mode" = "fail" ]; then', + ' exit 23', + 'fi', + '/bin/sleep 600 &', + 'child_pid=$!', + `printf "%s" "$child_pid" > ${quotePosixShellArgument(pidPath)}`, + 'wait "$child_pid"', + '', + ].join('\n'), { mode: 0o755 }); + return { cliPath, pidPath, directory }; +} + +function quotePosixShellArgument(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function quoteWindowsBatchArgument(value: string): string { + return `"${value.replace(/%/g, '%%')}"`; +} + +async function waitForProcessIdFile(filePath: string, timeoutMs: number): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (fs.existsSync(filePath)) { + const pid = Number.parseInt(fs.readFileSync(filePath, 'utf8'), 10); + if (Number.isInteger(pid) && pid > 0) { + return pid; + } + } + + await delay(100); + } + + throw new Error(`Timed out after ${timeoutMs}ms waiting for process ID evidence at ${filePath}.`); +} + +async function captureError(action: () => Promise): Promise { + try { + await action(); + } + catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + + throw new Error('Expected the operation to fail.'); +} + async function invokeControlCommand(command: Parameters[0], timeoutMs = 120000): Promise { const status = await executeE2eControlCommand(command, { timeoutMs }); if (status.errorMessage) { @@ -414,44 +1478,21 @@ async function waitForAppHostProcessCount(appHostPath: string, expectedCount: nu * polled tree model, which is not an authoritative statement about running processes. */ async function findAppHostProcessIds(appHostPath: string): Promise { - const processes = process.platform === 'win32' - ? await listWindowsProcesses() - : await listPosixProcesses(); + const processes = await listProcessEntries('--start-debug-session'); return processes - .filter(entry => entry.commandLine.includes('--start-debug-session') && entry.commandLine.includes(appHostPath)) + .filter(entry => commandLineHasExactAppHost(entry.arguments, appHostPath)) .map(entry => entry.pid) .sort((left, right) => left - right); } -async function listPosixProcesses(): Promise<{ pid: number; commandLine: string }[]> { - // `ps -A -w -w -o pid=,args=` prints one process per line with no header, for example: - // " 51234 /path/to/aspire run --start-debug-session --nologo --apphost /path/App.csproj" - // The repeated `-w` disables the default command-line truncation, which would otherwise - // cut off the `--apphost` argument this match depends on. - const result = await runProcess('/bin/ps', ['-A', '-w', '-w', '-o', 'pid=,args='], { timeoutMs: 30000 }); - return result.stdout - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - .map(line => { - const separatorIndex = line.indexOf(' '); - return { pid: Number.parseInt(line.slice(0, separatorIndex), 10), commandLine: line.slice(separatorIndex + 1) }; - }) - .filter(entry => Number.isInteger(entry.pid)); -} - -async function listWindowsProcesses(): Promise<{ pid: number; commandLine: string }[]> { - const result = await runProcess('powershell.exe', [ - '-NoProfile', - '-NonInteractive', - '-Command', - 'Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress', - ], { timeoutMs: 60000 }); - - const parsed = JSON.parse(result.stdout) as { ProcessId: number; CommandLine: string | null }[] | { ProcessId: number; CommandLine: string | null }; - const entries = Array.isArray(parsed) ? parsed : [parsed]; - return entries.map(entry => ({ pid: entry.ProcessId, commandLine: entry.CommandLine ?? '' })); +function commandLineHasExactAppHost(argumentsList: readonly string[], appHostPath: string, platform = process.platform): boolean { + const startDebugSessionIndex = argumentsList.indexOf('--start-debug-session'); + const appHostIndex = argumentsList.indexOf('--apphost', startDebugSessionIndex + 1); + return startDebugSessionIndex >= 0 && + appHostIndex > startDebugSessionIndex && + appHostIndex + 1 < argumentsList.length && + commandLineArgumentEquals(argumentsList[appHostIndex + 1], appHostPath, platform); } function writeLifecycleToolArtifact(artifact: Record): void { diff --git a/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts new file mode 100644 index 00000000000..fd07af7c146 --- /dev/null +++ b/extension/src/test-e2e/cliPathRejectionNotification.e2e.test.ts @@ -0,0 +1,95 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { waitForRepositoryIdle, waitForWorkspaceAppHost } from './helpers/assertions'; +import { + getCliWrapperInvocationCount, + restoreE2eCliPathForE2E, + restoreWorkspaceCliPath, + runE2eTeardown, + setE2eCliPathForE2E, + touchPrimaryAppHostProject, + waitForCliWrapperInvocation, + writeTrackedStreamingDiscoveryCliWrapper, + writeWorkspaceCliPath, +} from './helpers/fixtures'; +import { getWorkspaceRoot } from './helpers/paths'; +import { VSBrowser } from './helpers/extester'; +import { openAspireView, waitForNotificationMessage, waitForWorkbenchText } from './helpers/vscode'; + +// Mirrors configuredCliPathRejected in src/loc/strings.ts. +const rejectionNotificationText = 'The configured Aspire CLI path could not be used'; +const openSettingActionText = 'Open Setting'; + +suite('Configured CLI path rejection E2E', function () { + this.timeout(300000); + + teardown(async () => { + await runE2eTeardown([ + () => restoreE2eCliPathForE2E(), + () => restoreWorkspaceCliPath(), + ], 'Configured CLI path rejection E2E teardown failed.'); + }); + + test('resolves a configured directory that contains the aspire executable', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + await waitForWorkspaceAppHost(); + + const wrapper = writeTrackedStreamingDiscoveryCliWrapper(0, 0); + + // Simulates pointing the setting at a build output folder such as + // artifacts/bin/Aspire.Cli/Debug/net10.0, which contains an `aspire` executable. + const cliDirectory = path.join(getWorkspaceRoot(), '.e2e-cli-wrappers', 'build-output-directory'); + fs.rmSync(cliDirectory, { recursive: true, force: true }); + fs.mkdirSync(cliDirectory, { recursive: true }); + const executableInDirectory = path.join(cliDirectory, process.platform === 'win32' ? 'aspire.cmd' : 'aspire'); + if (process.platform === 'win32') { + fs.writeFileSync(executableInDirectory, `@echo off\r\ncall "${wrapper.cliPath}" %*\r\n`); + } + else { + fs.writeFileSync(executableInDirectory, `#!/usr/bin/env sh\nexec ${JSON.stringify(wrapper.cliPath)} "$@"\n`); + fs.chmodSync(executableInDirectory, 0o755); + } + + await setE2eCliPathForE2E(undefined); + await writeWorkspaceCliPath(cliDirectory); + + touchPrimaryAppHostProject(); + await waitForCliWrapperInvocation(wrapper.invocationLogPath, 60_000); + await VSBrowser.instance.takeScreenshot('cli-path-directory-resolved').catch(() => undefined); + + assert.ok( + getCliWrapperInvocationCount(wrapper.invocationLogPath) > 0, + 'The CLI inside the configured directory was never invoked.'); + }); + + test('warns and opens the setting when the configured path is invalid', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + await waitForWorkspaceAppHost(); + + const invalidCliPath = path.join(getWorkspaceRoot(), '.e2e-cli-wrappers', 'not-an-aspire-cli'); + fs.rmSync(invalidCliPath, { recursive: true, force: true }); + + await setE2eCliPathForE2E(undefined); + await writeWorkspaceCliPath(invalidCliPath); + touchPrimaryAppHostProject(); + + const notification = await waitForNotificationMessage(rejectionNotificationText, 90_000); + const message = await notification.getMessage(); + await VSBrowser.instance.takeScreenshot('cli-path-rejection-notification').catch(() => undefined); + + assert.ok( + message.includes(invalidCliPath), + `The rejection notification did not name the configured path. Message: ${message}`); + + await notification.takeAction(openSettingActionText); + // The Settings editor renders as a webview-backed tab; assert on the visible workbench text + // so the check does not depend on how the tab title is localized. + const settingsText = await waitForWorkbenchText('aspireCliExecutablePath', 60_000); + await VSBrowser.instance.takeScreenshot('cli-path-rejection-open-setting').catch(() => undefined); + + assert.ok(settingsText.length > 0, 'The Aspire CLI executable path setting was not shown.'); + }); +}); diff --git a/extension/src/test-e2e/cliPathResolution.e2e.test.ts b/extension/src/test-e2e/cliPathResolution.e2e.test.ts new file mode 100644 index 00000000000..90205035e36 --- /dev/null +++ b/extension/src/test-e2e/cliPathResolution.e2e.test.ts @@ -0,0 +1,43 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { waitForRepositoryIdle, waitForWorkspaceAppHost } from './helpers/assertions'; +import { + getCliWrapperInvocationCount, + restoreE2eCliPathForE2E, + restoreWorkspaceCliPath, + runE2eTeardown, + setE2eCliPathForE2E, + touchPrimaryAppHostProject, + waitForCliWrapperInvocation, + writeTrackedStreamingDiscoveryCliWrapper, + writeWorkspaceCliPath, +} from './helpers/fixtures'; +import { getWorkspaceRoot } from './helpers/paths'; +import { openAspireView } from './helpers/vscode'; + +suite('Workspace-relative CLI path E2E', function () { + this.timeout(240000); + + teardown(async () => { + await runE2eTeardown([ + () => restoreE2eCliPathForE2E(), + () => restoreWorkspaceCliPath(), + ], 'Workspace-relative CLI path E2E teardown failed.'); + }); + + test('expands workspaceFolder before invoking the CLI', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + await waitForWorkspaceAppHost(); + + const wrapper = writeTrackedStreamingDiscoveryCliWrapper(0, 0); + const relativePath = path.relative(getWorkspaceRoot(), wrapper.cliPath).split(path.sep).join('/'); + await setE2eCliPathForE2E(undefined); + await writeWorkspaceCliPath(`\${workspaceFolder}/${relativePath}`); + + touchPrimaryAppHostProject(); + await waitForCliWrapperInvocation(wrapper.invocationLogPath, 30_000); + + assert.ok(getCliWrapperInvocationCount(wrapper.invocationLogPath) > 0); + }); +}); \ No newline at end of file diff --git a/extension/src/test-e2e/debugStartupTimeout.e2e.test.ts b/extension/src/test-e2e/debugStartupTimeout.e2e.test.ts index 014b46d2c86..056b5ad829b 100644 --- a/extension/src/test-e2e/debugStartupTimeout.e2e.test.ts +++ b/extension/src/test-e2e/debugStartupTimeout.e2e.test.ts @@ -3,7 +3,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCommandInvocationCount, waitForCommandOutcome, waitForDebugConsoleOutput, waitForDebugSessionStartup, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForWorkspaceAppHost } from './helpers/assertions'; import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, stopPrimaryAppHostIfRunning, writeFileWithRetry } from './helpers/fixtures'; -import { getPrimaryAppHostProjectPath, getRunRoot } from './helpers/paths'; +import { readCliLogs, readExtensionLogs } from './helpers/logs'; +import { getPrimaryAppHostProjectPath } from './helpers/paths'; import { openAspireView } from './helpers/vscode'; suite('Aspire debug startup timeout E2E', function () { @@ -98,44 +99,6 @@ async function waitForStartupDelayEvidence(startMarker: string, endMarker: strin throw new Error(`Timed out after ${timeoutMs}ms waiting for CLI log markers '${startMarker}' and '${endMarker}'. Last log content:\n${lastLogContent}`); } -function readCliLogs(): string { - const runRoot = getRunRoot(); - assert.ok(runRoot, 'ASPIRE_EXTENSION_E2E_RUN_ROOT is required to find isolated CLI logs.'); - - const logsRoot = path.join(runRoot, 'aspire-home', 'logs'); - if (!fs.existsSync(logsRoot)) { - return ''; - } - - return fs.readdirSync(logsRoot, { withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith('.log')) - .map(entry => fs.readFileSync(path.join(logsRoot, entry.name), 'utf8')) - .join('\n'); -} - -function readExtensionLogs(): string { - const runRoot = getRunRoot(); - assert.ok(runRoot, 'ASPIRE_EXTENSION_E2E_RUN_ROOT is required to find isolated extension logs.'); - - const logsRoot = path.join(runRoot, 'storage', 'settings', 'logs'); - if (!fs.existsSync(logsRoot)) { - return ''; - } - - return readFilesRecursively(logsRoot, 'Aspire Extension.log').join('\n'); -} - -function readFilesRecursively(directory: string, fileName: string): string[] { - return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - return readFilesRecursively(entryPath, fileName); - } - - return entry.isFile() && entry.name === fileName ? [fs.readFileSync(entryPath, 'utf8')] : []; - }); -} - function tryGetMarkerTimestamp(logContent: string, marker: string): Date | undefined { // AppHost console output is captured by the CLI log as: // [2026-06-20 04:55:00.029] [INFO] [AppHost] TIMEOUT_E2E_DELAY_START 2026-06-20T04:55:00.0252790+00:00 diff --git a/extension/src/test-e2e/dynamicDebugConfiguration.e2e.test.ts b/extension/src/test-e2e/dynamicDebugConfiguration.e2e.test.ts new file mode 100644 index 00000000000..b2451d15f55 --- /dev/null +++ b/extension/src/test-e2e/dynamicDebugConfiguration.e2e.test.ts @@ -0,0 +1,142 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { isSamePath, readStateFile, waitForExtensionState, waitForNoDebugSessions, waitForRepositoryIdle } from './helpers/assertions'; +import { executeE2eControlCommand, getRunningAppHostPid, removePath, restoreWorkspaceFoldersForE2E, runE2eTeardown, setWorkspaceFoldersForE2E, stopAppHostIfRunning, waitForKnownProcessExit } from './helpers/fixtures'; +import { getWorkspaceRoot } from './helpers/paths'; +import { chooseActiveQuickPick, chooseActiveQuickPickAtIndex, executeCommandFromPalette, getActiveQuickPickLabels, openAspireView, waitForEditorTitle } from './helpers/vscode'; + +suite('Aspire dynamic debug configuration E2E', function () { + this.timeout(240000); + + const fixtureRoot = path.join(getWorkspaceRoot(), '.e2e-dynamic-debug'); + const firstFolderPath = path.join(fixtureRoot, 'first'); + const secondFolderPath = path.join(fixtureRoot, 'second'); + const firstAppHostPath = path.join(firstFolderPath, 'apphost.cs'); + const appHostPath = path.join(secondFolderPath, 'apphost.cs'); + let appHostPidBeforeStop: number | undefined; + + setup(() => { + appHostPidBeforeStop = undefined; + }); + + teardown(async () => { + await runE2eTeardown([ + () => appHostPidBeforeStop ??= getRunningAppHostPid(appHostPath), + () => appHostPidBeforeStop ??= getRunningAppHostPid(firstAppHostPath), + () => executeE2eControlCommand({ name: 'stopDebugging' }), + () => stopAppHostIfRunning(appHostPath), + () => stopAppHostIfRunning(firstAppHostPath), + () => appHostPidBeforeStop === undefined + ? undefined + : waitForKnownProcessExit(appHostPidBeforeStop, 'the dynamic debug configuration AppHost process', 30000), + () => waitForNoDebugSessions().catch(() => undefined), + () => restoreWorkspaceFoldersForE2E(), + () => executeE2eControlCommand({ name: 'closeAllEditors' }), + () => removePath(fixtureRoot, { recursive: true, force: true }), + ], 'Dynamic debug configuration E2E teardown failed.'); + }); + + test('re-resolves the selected AppHost by dynamic configuration name in duplicate-alias workspaces', async () => { + createWorkspaceFixture(); + await openAspireView(); + const workspaceFolders = await setWorkspaceFoldersForE2E([ + { folderPath: firstFolderPath, name: 'src' }, + { folderPath: secondFolderPath, name: 'src' }, + ]); + assert.deepStrictEqual(workspaceFolders.map(folder => folder.name), ['src', 'src']); + + await waitForRepositoryIdle(); + await executeE2eControlCommand({ name: 'openFile', filePath: appHostPath }); + await waitForEditorTitle('apphost.cs'); + + await executeCommandFromPalette('Debug: Select and Start Debugging'); + let quickPickLabels = await getActiveQuickPickLabels(); + const aspireDebugger = quickPickLabels.find(label => + label.trimStart().startsWith('Aspire') && !label.trimStart().startsWith('Aspire: Launch default AppHost')); + if (aspireDebugger) { + await chooseActiveQuickPick(aspireDebugger); + quickPickLabels = await waitForQuickPickLabels('Aspire: Launch default AppHost'); + } + + const configurationIndexes = quickPickLabels + .map((label, index) => label.trimStart().startsWith('Aspire: Launch default AppHost') ? index : -1) + .filter(index => index >= 0); + assert.strictEqual(configurationIndexes.length, 2, `Expected two Aspire dynamic configurations. Visible labels: ${JSON.stringify(quickPickLabels)}`); + + const secondFolderConfigurationIndex = configurationIndexes[1]; + const secondFolderConfiguration = quickPickLabels[secondFolderConfigurationIndex]; + assert.ok( + secondFolderConfiguration.includes(workspaceFolders[1].uri), + `Expected the selected dynamic configuration to identify the second workspace folder URI '${workspaceFolders[1].uri}'. Selected label: ${JSON.stringify(secondFolderConfiguration)}`); + const beforeFirstLaunch = getDebugConsoleOutputCount(); + await chooseActiveQuickPickAtIndex(secondFolderConfigurationIndex); + + const firstLaunch = await waitForLaunchOutput(beforeFirstLaunch); + assert.ok(firstLaunch.appHostPath); + assert.ok(isSamePath(firstLaunch.appHostPath, appHostPath)); + await executeE2eControlCommand({ name: 'stopDebugging' }); + await waitForNoDebugSessions(); + + const beforeSecondLaunch = getDebugConsoleOutputCount(); + await executeCommandFromPalette('Debug: Start Debugging'); + + const secondLaunch = await waitForLaunchOutput(beforeSecondLaunch); + assert.ok(secondLaunch.appHostPath); + assert.ok(isSamePath(secondLaunch.appHostPath, appHostPath)); + }); + + function createWorkspaceFixture(): void { + removePath(fixtureRoot, { recursive: true, force: true }); + fs.mkdirSync(firstFolderPath, { recursive: true }); + fs.mkdirSync(secondFolderPath, { recursive: true }); + const appHostSdkVersion = process.env.ASPIRE_EXTENSION_E2E_APPHOST_SDK_VERSION; + assert.ok(appHostSdkVersion); + const appHostSource = `#:sdk Aspire.AppHost.Sdk@${appHostSdkVersion} + +var builder = DistributedApplication.CreateBuilder(args); + +builder.Build().Run(); +`; + fs.writeFileSync(firstAppHostPath, appHostSource); + fs.writeFileSync(appHostPath, appHostSource); + } +}); + +function getDebugConsoleOutputCount(): number { + return Math.max(0, ...readStateFile().debugConsoleOutputs.map(event => event.sequence)); +} + +async function waitForLaunchOutput(afterOutputSequence: number) { + const file = await waitForExtensionState( + stateFile => stateFile.debugConsoleOutputs.some(event => + event.sequence > afterOutputSequence && + event.appHostPath !== undefined), + 'dynamic debug configuration launch output', + 60000); + const event = file.debugConsoleOutputs.find(candidate => + candidate.sequence > afterOutputSequence && + candidate.appHostPath !== undefined); + assert.ok(event); + + return event; +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function waitForQuickPickLabels(prefix: string, timeoutMs = 30000): Promise { + const started = Date.now(); + let labels: string[] = []; + while (Date.now() - started < timeoutMs) { + labels = await getActiveQuickPickLabels(); + if (labels.some(label => label.trimStart().startsWith(prefix))) { + return labels; + } + + await delay(100); + } + + throw new Error(`Timed out waiting for a quick pick starting with '${prefix}'. Visible labels: ${JSON.stringify(labels)}`); +} diff --git a/extension/src/test-e2e/helpers/assertions.ts b/extension/src/test-e2e/helpers/assertions.ts index 52cad33b541..2156f4541d2 100644 --- a/extension/src/test-e2e/helpers/assertions.ts +++ b/extension/src/test-e2e/helpers/assertions.ts @@ -3,7 +3,7 @@ import * as http from 'http'; import * as https from 'https'; import * as path from 'path'; import type { AspireAppHostState as AppHostState, AspireDebugSessionState, AspireExtensionE2EControlStatus as ExtensionE2EControlStatus, AspireExtensionE2EStateFile as ExtensionE2EStateFile, AspireExtensionE2ETaskProcessEvent as TaskProcessEvent, AspireExtensionStateSnapshot as ExtensionStateSnapshot, AspireResourceState as ResourceState } from '../../types/extensionApi'; -import { getControlFilePath, getPrimaryAppHostProjectPath, getStateFilePath, getWorkspaceRoot } from './paths'; +import { getControlFilePath, getPrimaryAppHostProjectPath, getRunId, getStateFilePath, getWorkspaceRoot } from './paths'; type CommandInvocation = ExtensionE2EStateFile['commandInvocations'][number]; type BrowserDebugSession = ExtensionE2EStateFile['browserDebugSessions'][number]; @@ -21,12 +21,23 @@ export async function waitForRepositoryIdle(timeoutMs = 120000): Promise { + return await waitForWorkspaceAppHostCandidate(getPrimaryAppHostProjectPath(), timeoutMs); +} + +/** + * Waits for discovery to surface a specific AppHost. + * + * Opening the workspace folder is part of the wait rather than a precondition: discovery does not + * run at all while the harness still has its own folder open, so a caller that skips it observes an + * empty candidate list until the timeout. + */ +export async function waitForWorkspaceAppHostCandidate(appHostPath: string, timeoutMs = 120000): Promise { const deadline = createDeadline(timeoutMs); await ensureWorkspaceFolderOpen(deadline); return await waitForExtensionState( - file => file.state.workspaceAppHostCandidatePaths.some(candidate => isSamePath(candidate, getPrimaryAppHostProjectPath())), - 'workspace AppHost candidate', - getRemainingTimeout(deadline, 'workspace AppHost candidate')); + file => file.state.workspaceAppHostCandidatePaths.some(candidate => isSamePath(candidate, appHostPath)), + `workspace AppHost candidate '${appHostPath}'`, + getRemainingTimeout(deadline, `workspace AppHost candidate '${appHostPath}'`)); } export async function waitForSelectedWorkspaceAppHost(appHostPath = getPrimaryAppHostProjectPath(), timeoutMs = 120000): Promise { @@ -292,7 +303,18 @@ export function readStateFile(): ExtensionE2EStateFile { const maxAttempts = process.platform === 'win32' ? 10 : 1; for (let attempt = 1; ; attempt++) { try { - return JSON.parse(fs.readFileSync(getStateFilePath(), 'utf8')) as ExtensionE2EStateFile; + const stateFile = JSON.parse(fs.readFileSync(getStateFilePath(), 'utf8')) as ExtensionE2EStateFile; + // An extension host left behind by an earlier run keeps writing this file, so reject a + // foreign snapshot rather than asserting against another run's state. Waiters retry on + // a throw, which lets the intended host's next write win. + const runId = getRunId(); + if (runId !== undefined && stateFile.runId !== undefined && stateFile.runId !== runId) { + throw new Error( + `The E2E state file was written by run '${stateFile.runId}' but this run is '${runId}'. ` + + 'An extension host from an earlier run is still writing to it; close leftover VS Code instances.'); + } + + return stateFile; } catch (error) { if (attempt >= maxAttempts || !isRetryableStateFileReadError(error)) { @@ -369,7 +391,7 @@ export async function applyE2eControl(payload: Record, waitFor: } const revision = ++controlRevision; - writeJsonFileAtomic(controlFilePath, { revision, ...payload }); + writeJsonFileAtomic(controlFilePath, { revision, runId: getRunId(), ...payload }); const stateFile = await waitForExtensionState( file => file.control?.revision === revision && (file.control.status === 'error' || (waitFor === 'applied' ? file.control.status === 'applied' : file.control.startedObserved === true)), `E2E control revision ${revision}`, diff --git a/extension/src/test-e2e/helpers/extester.ts b/extension/src/test-e2e/helpers/extester.ts index 0018b2aeca4..aa3c4153bbf 100644 --- a/extension/src/test-e2e/helpers/extester.ts +++ b/extension/src/test-e2e/helpers/extester.ts @@ -86,6 +86,7 @@ export interface QuickPickItem { export interface Notification { getMessage(): Promise; dismiss(): Promise; + takeAction(title: string): Promise; } export interface ModalDialog { @@ -101,9 +102,18 @@ export interface TerminalView { getText(): Promise; } +export interface CodeLens { + getText(): Promise; +} + +export interface TextEditor { + getCodeLenses(): Promise; +} + export interface EditorView { getOpenEditorTitles(): Promise; closeAllEditors(): Promise; + openEditor(title: string): Promise; } export interface WebView { diff --git a/extension/src/test-e2e/helpers/fixtures.ts b/extension/src/test-e2e/helpers/fixtures.ts index 18f10605d35..5b8d83b05eb 100644 --- a/extension/src/test-e2e/helpers/fixtures.ts +++ b/extension/src/test-e2e/helpers/fixtures.ts @@ -87,6 +87,15 @@ export async function executeE2eControlCommand( return await applyE2eControl({ command }, options?.waitFor ?? 'applied', timeoutMs); } +export async function setWorkspaceFoldersForE2E(folders: readonly { folderPath: string; name?: string }[]): Promise> { + const status = await executeE2eControlCommand({ name: 'setWorkspaceFolders', folders }, { timeoutMs: 30000 }); + return status.result as Array<{ name: string; uri: string; fileName: string }>; +} + +export async function restoreWorkspaceFoldersForE2E(): Promise { + await setWorkspaceFoldersForE2E([{ folderPath: getWorkspaceRoot() }]); +} + export async function snapshotClipboardForE2E(): Promise { await executeE2eControlCommand({ name: 'snapshotClipboard' }); } @@ -228,6 +237,55 @@ export function writeConfigInfoUnsupportedCliWrapper(name = 'aspire-no-config-in }); } +export function writeTokenlessStableCliWrapper(invocationLogPath: string): string { + removePath(invocationLogPath, { force: true }); + if (process.platform !== 'win32') { + return writeTokenlessStablePosixCliWrapper(invocationLogPath); + } + + return writeCliWrapper('aspire-tokenless-stable-13-2', { + configInfoJson: createConfigInfo(), + invocationLogPath, + versionOutput: '13.2.0', + }, path.dirname(getCliPath())); +} + +function writeTokenlessStablePosixCliWrapper(invocationLogPath: string): string { + // AppHost targets validate that AspireCliPath belongs to a complete CLI bundle. Keep this + // capability/version shim beside the isolated real CLI so the configured path retains that + // bundle identity while the wrapper still controls the probe responses. + const wrapperDirectory = path.dirname(getCliPath()); + const wrapperPath = path.join(wrapperDirectory, 'aspire-tokenless-stable-13-2'); + const logInvocationScript = `require('fs').appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + '\\n')`; + fs.mkdirSync(wrapperDirectory, { recursive: true }); + + // The F5 argv assertion is tied to the configured CLI path. After handling the simulated + // version and capability probes, replace the shell with the real CLI while preserving that + // configured path as argv[0], so the long-lived process has the same identity as a native CLI. + fs.writeFileSync(wrapperPath, [ + '#!/usr/bin/env bash', + `${quotePosixShellArgument(process.execPath)} -e ${quotePosixShellArgument(logInvocationScript)} ${quotePosixShellArgument(invocationLogPath)} "$@"`, + 'if [ "$1" = "--version" ]; then', + ' echo "13.2.0"', + ' exit 0', + 'fi', + 'if [ "$1" = "config" ] && [ "$2" = "info" ]; then', + ` printf "%s\\n" ${quotePosixShellArgument(JSON.stringify(createConfigInfo()))}`, + ' exit 0', + 'fi', + 'for argument in "$@"; do', + ' if [ "$argument" = "--include-disabled-commands" ]; then', + ' echo "simulated old CLI does not support --include-disabled-commands" >&2', + ' exit 123', + ' fi', + 'done', + `exec -a "$0" ${quotePosixShellArgument(getCliPath())} "$@"`, + '', + ].join('\n'), { mode: 0o755 }); + + return wrapperPath; +} + export function writeStreamingDiscoveryCliWrapper(delayMs = 5_000, initialDelayMs = 1_500): string { return writeCliWrapper('aspire-streaming-discovery', { configInfoJson: createConfigInfo([lsJsonStreamCapability]), @@ -312,6 +370,19 @@ export function getCliWrapperInvocationCount(invocationLogPath: string): number .length; } +export async function waitForCliWrapperInvocation(invocationLogPath: string, timeoutMs: number): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (getCliWrapperInvocationCount(invocationLogPath) > 0) { + return; + } + + await delay(500); + } + + throw new Error(`Timed out after ${timeoutMs}ms waiting for an Aspire CLI wrapper invocation in ${invocationLogPath}.`); +} + export function touchPrimaryAppHostProject(): void { fs.appendFileSync(getPrimaryAppHostProjectPath(), '\n'); } @@ -799,9 +870,10 @@ function writeCliWrapper( psSnapshotReleaseFilePath?: string; psSnapshotAppHostPath?: string; psSnapshotAppHostPid?: number; + versionOutput?: string; }, + wrapperDirectory = path.join(getWorkspaceRoot(), '.e2e-cli-wrappers'), ): string { - const wrapperDirectory = path.join(getWorkspaceRoot(), '.e2e-cli-wrappers'); fs.mkdirSync(wrapperDirectory, { recursive: true }); const scriptPath = path.join(wrapperDirectory, `${name}.js`); @@ -824,6 +896,14 @@ function waitForReleaseFile(filePath, description) { } } +${options.versionOutput === undefined + ? '' + : `if (args.length === 1 && args[0] === '--version') { + console.log(${JSON.stringify(options.versionOutput)}); + process.exit(0); +} + +`} if (args.includes('--include-disabled-commands')) { console.error('simulated old CLI does not support --include-disabled-commands'); process.exit(123); @@ -948,6 +1028,10 @@ ${options.streamedLsCandidate === undefined ? '' : '}'} return wrapperPath; } +function quotePosixShellArgument(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + function getPackageSourceArgs(): string[] { const args: string[] = []; args.push(...getPackageVersionArgs()); @@ -1007,7 +1091,7 @@ function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } -function removePath(targetPath: string, options: fs.RmOptions): void { +export function removePath(targetPath: string, options: fs.RmOptions): void { const maxAttempts = process.platform === 'win32' ? 40 : 1; for (let attempt = 1; ; attempt++) { try { diff --git a/extension/src/test-e2e/helpers/java.ts b/extension/src/test-e2e/helpers/java.ts new file mode 100644 index 00000000000..5b5bc7d10c6 --- /dev/null +++ b/extension/src/test-e2e/helpers/java.ts @@ -0,0 +1,69 @@ +import * as path from 'path'; +import { waitForRepositoryIdle, waitForWorkspaceAppHostCandidate } from './assertions'; +import { executeE2eControlCommand } from './fixtures'; +import { getWorkspaceRoot } from './paths'; +import { openAspireView } from './vscode'; + +export const JAVA_APP_HOST_DIRECTORY = 'JavaSpringBoot.AppHost.Java'; +export const JAVA_APP_HOST_SOURCE = path.join(JAVA_APP_HOST_DIRECTORY, 'AppHost.java'); + +export function getJavaAppHostSourcePath(): string { + return path.join(getWorkspaceRoot(), JAVA_APP_HOST_SOURCE); +} + +/** + * Brings the window to the state every Java spec needs, and fails loudly when it cannot. + * + * The Aspire extension only activates on a view command or a workspace match, and the E2E state + * file does not exist until it has, so every spec has to open the view before it reads anything. + * The capability assertion then covers the other half: the specs are meaningless without the Java + * extensions, and every symptom of their absence is a timeout minutes later that names something + * unrelated. + */ +export async function prepareJavaWorkspace(): Promise { + await openAspireView(); + await assertJavaCapabilityAdvertised(); + await waitForJavaAppHostCandidate(); + await waitForRepositoryIdle(); +} + +/** + * Waits for discovery to surface the single-file Java AppHost. + * + * The shared no-argument helper looks for the scaffolded C# fixture, which the Java run removes from + * the workspace, so this matches on `AppHost.java` instead. + */ +export async function waitForJavaAppHostCandidate(timeoutMs?: number): Promise { + await waitForWorkspaceAppHostCandidate(getJavaAppHostSourcePath(), timeoutMs); +} + +/** + * Fails unless the extension advertises the `java` capability to the CLI. + * + * The CLI only asks the extension to launch a Java AppHost when this capability is present, and it + * silently spawns `java` itself when it is not, so an AppHost still starts and no breakpoint ever + * binds. The capability requires both redhat.java and vscjava.vscode-java-debug to be installed, + * which is the single thing most likely to be wrong about a Java E2E run. + */ +export async function assertJavaCapabilityAdvertised(): Promise { + const capabilities = (await executeE2eControlCommand({ name: 'getSupportedCapabilities' })).result as string[]; + if (!capabilities.includes('java')) { + // The runner already verified the extensions directory and extensions.json, so when the + // capability is still missing the useful question is what the extension host can see: a copied + // extension directory is only scanned while extensions.json is absent, which leaves both of the + // runner's checks passing while the host loads nothing. + const visibleExtensionIds = (await executeE2eControlCommand({ name: 'getVisibleExtensionIds' })).result as string[]; + throw new Error(`The extension is not advertising the 'java' capability, so the CLI will not ask it to launch a Java AppHost. Install redhat.java and vscjava.vscode-java-debug into the E2E instance. Advertised capabilities: ${capabilities.join(', ')}. Extensions visible to the extension host: ${visibleExtensionIds.join(', ')}`); + } +} + +/** + * Waits until redhat.java has imported the workspace. + * + * Diagnostics are empty both before the language server has looked at a file and after it has + * declared that file clean, so anything asserting on them has to wait for the import first or it is + * only asserting that nothing analysed the workspace. + */ +export async function waitForJavaLanguageServerImport(timeoutMs = 900000): Promise { + await executeE2eControlCommand({ name: 'waitForJavaLanguageServer', timeoutMs }, { timeoutMs: timeoutMs + 60000 }); +} diff --git a/extension/src/test-e2e/helpers/logs.ts b/extension/src/test-e2e/helpers/logs.ts new file mode 100644 index 00000000000..799dba45072 --- /dev/null +++ b/extension/src/test-e2e/helpers/logs.ts @@ -0,0 +1,45 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getRunRoot } from './paths'; + +export function readCliLogs(): string { + const runRoot = getRunRoot(); + if (!runRoot) { + throw new Error('ASPIRE_EXTENSION_E2E_RUN_ROOT is required to find isolated CLI logs.'); + } + + const logsRoot = path.join(runRoot, 'aspire-home', 'logs'); + if (!fs.existsSync(logsRoot)) { + return ''; + } + + return fs.readdirSync(logsRoot, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.log')) + .map(entry => fs.readFileSync(path.join(logsRoot, entry.name), 'utf8')) + .join('\n'); +} + +export function readExtensionLogs(): string { + const runRoot = getRunRoot(); + if (!runRoot) { + throw new Error('ASPIRE_EXTENSION_E2E_RUN_ROOT is required to find isolated extension logs.'); + } + + const logsRoot = path.join(runRoot, 'storage', 'settings', 'logs'); + if (!fs.existsSync(logsRoot)) { + return ''; + } + + return readFilesRecursively(logsRoot, 'Aspire Extension.log').join('\n'); +} + +function readFilesRecursively(directory: string, fileName: string): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return readFilesRecursively(entryPath, fileName); + } + + return entry.isFile() && entry.name === fileName ? [fs.readFileSync(entryPath, 'utf8')] : []; + }); +} diff --git a/extension/src/test-e2e/helpers/paths.ts b/extension/src/test-e2e/helpers/paths.ts index a40b83f5139..3aa58f457bf 100644 --- a/extension/src/test-e2e/helpers/paths.ts +++ b/extension/src/test-e2e/helpers/paths.ts @@ -41,6 +41,15 @@ export function getControlFilePath(): string | undefined { return process.env.ASPIRE_EXTENSION_E2E_CONTROL_FILE ? path.resolve(process.env.ASPIRE_EXTENSION_E2E_CONTROL_FILE) : undefined; } +/** + * The id the runner assigned to this E2E run. The state and control files live at a stable + * per-shard path, so this is what distinguishes the extension host launched for this run from one + * left behind by an earlier run that is still polling the same files. + */ +export function getRunId(): string | undefined { + return process.env.ASPIRE_EXTENSION_E2E_RUN_ID; +} + export function ensureDiagnosticsDir(): string { const diagnosticsDir = getDiagnosticsDir(); fs.mkdirSync(diagnosticsDir, { recursive: true }); diff --git a/extension/src/test-e2e/helpers/processArguments.ts b/extension/src/test-e2e/helpers/processArguments.ts new file mode 100644 index 00000000000..ed649bb44d0 --- /dev/null +++ b/extension/src/test-e2e/helpers/processArguments.ts @@ -0,0 +1,346 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { runProcess } from './process'; +import { getRunRoot } from './paths'; + +export interface ProcessEntry { + pid: number; + commandLine: string; + arguments: string[]; +} + +let macProcessArgumentsHelper: Promise | undefined; + +export async function getProcessEntry(pid: number): Promise { + if (!Number.isInteger(pid) || pid <= 0) { + return undefined; + } + + if (process.platform === 'linux') { + return readLinuxProcessEntry(pid); + } + + if (process.platform === 'darwin') { + return await readMacProcessEntry(pid); + } + + const entries = await listWindowsProcessEntries(); + return entries.find(entry => entry.pid === pid); +} + +export async function listProcessEntries(commandLineHint?: string): Promise { + if (process.platform === 'linux') { + return listLinuxProcessEntries(commandLineHint); + } + + if (process.platform === 'darwin') { + return await listMacProcessEntries(commandLineHint); + } + + const entries = await listWindowsProcessEntries(); + return commandLineHint === undefined + ? entries + : entries.filter(entry => entry.arguments.includes(commandLineHint)); +} + +function listLinuxProcessEntries(commandLineHint: string | undefined): ProcessEntry[] { + return fs.readdirSync('/proc', { withFileTypes: true }) + .filter(entry => entry.isDirectory() && /^\d+$/.test(entry.name)) + .flatMap(entry => { + const processEntry = readLinuxProcessEntry(Number.parseInt(entry.name, 10)); + if (!processEntry || (commandLineHint !== undefined && !processEntry.arguments.includes(commandLineHint))) { + return []; + } + + return [processEntry]; + }); +} + +function readLinuxProcessEntry(pid: number): ProcessEntry | undefined { + try { + const argumentsList = parseNullSeparatedArguments(fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8')); + return argumentsList.length === 0 + ? undefined + : { + pid, + commandLine: formatArgumentsForDiagnostics(argumentsList), + arguments: argumentsList, + }; + } + catch (error) { + if (isProcessLookupError(error)) { + return undefined; + } + + throw error; + } +} + +async function listMacProcessEntries(commandLineHint: string | undefined): Promise { + // macOS ps joins argv with spaces and does not preserve argument boundaries. Use it only + // to find a small candidate PID set, then read each candidate's real argv via KERN_PROCARGS2. + const result = await runProcess('/bin/ps', ['-A', '-w', '-w', '-o', 'pid=,args='], { timeoutMs: 30000 }); + const candidates = result.stdout + .split('\n') + .flatMap(line => { + const match = /^\s*(\d+)\s+(.*)$/.exec(line); + if (!match || (commandLineHint !== undefined && !match[2].includes(commandLineHint))) { + return []; + } + + return [{ pid: Number.parseInt(match[1], 10), commandLine: match[2] }]; + }); + + const entries: ProcessEntry[] = []; + for (const candidate of candidates) { + const entry = await readMacProcessEntry(candidate.pid, candidate.commandLine); + if (entry && (commandLineHint === undefined || entry.arguments.includes(commandLineHint))) { + entries.push(entry); + } + } + + return entries; +} + +async function readMacProcessEntry(pid: number, commandLine?: string): Promise { + const helperPath = await getMacProcessArgumentsHelper(); + const result = await runProcess(helperPath, [String(pid)], { + timeoutMs: 30000, + rejectOnNonZeroExit: false, + }); + if (result.exitCode === 3) { + return undefined; + } + if (result.exitCode !== 0) { + throw new Error(`Failed to read argv for macOS process ${pid} (exit code ${result.exitCode}).\nstderr:\n${result.stderr}`); + } + + const argumentsList = parseNullSeparatedArguments(result.stdout); + return argumentsList.length === 0 + ? undefined + : { + pid, + commandLine: commandLine ?? formatArgumentsForDiagnostics(argumentsList), + arguments: argumentsList, + }; +} + +function getMacProcessArgumentsHelper(): Promise { + macProcessArgumentsHelper ??= createMacProcessArgumentsHelper(); + return macProcessArgumentsHelper; +} + +async function createMacProcessArgumentsHelper(): Promise { + const runRoot = getRunRoot(); + if (!runRoot) { + throw new Error('ASPIRE_EXTENSION_E2E_RUN_ROOT is required to inspect macOS process arguments.'); + } + + const helperDirectory = path.join(runRoot, 'process-arguments-helper'); + const sourcePath = path.join(helperDirectory, 'read-process-arguments.c'); + const helperPath = path.join(helperDirectory, 'read-process-arguments'); + fs.mkdirSync(helperDirectory, { recursive: true }); + fs.writeFileSync(sourcePath, macProcessArgumentsSource); + + await runProcess('/usr/bin/xcrun', [ + 'clang', + '-std=c11', + '-O2', + sourcePath, + '-o', + helperPath, + ], { timeoutMs: 60000 }); + + return helperPath; +} + +async function listWindowsProcessEntries(): Promise { + // CommandLineToArgvW applies the Windows command-line quoting rules used by native + // processes instead of approximating them with a whitespace split. + // https://learn.microsoft.com/windows/win32/api/shellapi/nf-shellapi-commandlinetoargvw + const result = await runProcess('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-Command', + windowsProcessArgumentsScript, + ], { timeoutMs: 60000 }); + + const parsed = JSON.parse(result.stdout) as WindowsProcessEntry | WindowsProcessEntry[]; + const entries = Array.isArray(parsed) ? parsed : [parsed]; + return entries.map(entry => ({ + pid: entry.ProcessId, + commandLine: entry.CommandLine ?? '', + arguments: Array.isArray(entry.Arguments) + ? entry.Arguments + : entry.Arguments === null + ? [] + : [entry.Arguments], + })); +} + +function parseNullSeparatedArguments(value: string): string[] { + const argumentsList = value.split('\0'); + if (argumentsList.at(-1) === '') { + argumentsList.pop(); + } + + return argumentsList; +} + +function formatArgumentsForDiagnostics(argumentsList: readonly string[]): string { + return JSON.stringify(argumentsList); +} + +function isProcessLookupError(error: unknown): boolean { + return error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ESRCH' || error.code === 'EACCES' || error.code === 'EPERM'); +} + +interface WindowsProcessEntry { + ProcessId: number; + CommandLine: string | null; + Arguments: string[] | string | null; +} + +const windowsProcessArgumentsScript = String.raw` +$source = @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class AspireE2ECommandLine +{ + [DllImport("shell32.dll", SetLastError = true)] + private static extern IntPtr CommandLineToArgvW( + [MarshalAs(UnmanagedType.LPWStr)] string commandLine, + out int argumentCount); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr LocalFree(IntPtr memory); + + public static string[] Parse(string commandLine) + { + int argumentCount; + IntPtr argumentPointers = CommandLineToArgvW(commandLine, out argumentCount); + if (argumentPointers == IntPtr.Zero) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + try + { + var arguments = new string[argumentCount]; + for (var index = 0; index < argumentCount; index++) + { + IntPtr argumentPointer = Marshal.ReadIntPtr(argumentPointers, index * IntPtr.Size); + arguments[index] = Marshal.PtrToStringUni(argumentPointer) ?? string.Empty; + } + + return arguments; + } + finally + { + LocalFree(argumentPointers); + } + } +} +'@ + +Add-Type -TypeDefinition $source +Get-CimInstance Win32_Process | + ForEach-Object { + [PSCustomObject]@{ + ProcessId = [int]$_.ProcessId + CommandLine = $_.CommandLine + Arguments = if ($null -eq $_.CommandLine) { @() } else { [AspireE2ECommandLine]::Parse($_.CommandLine) } + } + } | + ConvertTo-Json -Compress -Depth 4 +`; + +// KERN_PROCARGS2 returns argc followed by the executable path, padding, argv, and +// environment strings. Reading argc and exactly that many NUL-terminated argv values +// avoids treating environment entries as command-line arguments. +// https://opensource.apple.com/source/xnu/xnu-7195.81.3/bsd/kern/kern_sysctl.c.auto.html +const macProcessArgumentsSource = String.raw`#include +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + if (argc != 2) + { + return 2; + } + + char *end = NULL; + errno = 0; + long parsed_pid = strtol(argv[1], &end, 10); + if (errno != 0 || end == argv[1] || *end != '\0' || parsed_pid <= 0) + { + return 2; + } + + int mib[] = { CTL_KERN, KERN_PROCARGS2, (int)parsed_pid }; + size_t size = 0; + if (sysctl(mib, 3, NULL, &size, NULL, 0) != 0) + { + // KERN_PROCARGS2 reports EINVAL instead of ESRCH when the process has already exited. + return errno == ESRCH || errno == EINVAL ? 3 : 4; + } + + char *buffer = malloc(size); + if (buffer == NULL) + { + return 4; + } + + if (sysctl(mib, 3, buffer, &size, NULL, 0) != 0) + { + int error = errno; + free(buffer); + return error == ESRCH || error == EINVAL ? 3 : 4; + } + + int argument_count = 0; + memcpy(&argument_count, buffer, sizeof(argument_count)); + char *cursor = buffer + sizeof(argument_count); + char *buffer_end = buffer + size; + + while (cursor < buffer_end && *cursor != '\0') + { + cursor++; + } + while (cursor < buffer_end && *cursor == '\0') + { + cursor++; + } + + for (int index = 0; index < argument_count; index++) + { + if (cursor >= buffer_end) + { + free(buffer); + return 4; + } + + size_t remaining = (size_t)(buffer_end - cursor); + size_t length = strnlen(cursor, remaining); + if (length == remaining) + { + free(buffer); + return 4; + } + + fwrite(cursor, 1, length, stdout); + fputc('\0', stdout); + cursor += length + 1; + } + + free(buffer); + return 0; +} +`; diff --git a/extension/src/test-e2e/helpers/vscode.ts b/extension/src/test-e2e/helpers/vscode.ts index 889aece53a0..b1c415d6b48 100644 --- a/extension/src/test-e2e/helpers/vscode.ts +++ b/extension/src/test-e2e/helpers/vscode.ts @@ -239,6 +239,31 @@ export async function chooseActiveQuickPick(label: string, timeoutMs = 30000): P await item.select(); } +export async function chooseActiveQuickPickAtIndex(index: number, timeoutMs = 30000): Promise { + const input = await VSBrowser.instance.driver.wait(async () => { + try { + return await InputBox.create(); + } + catch (error) { + throwIfWebDriverSessionFailure(error); + return false; + } + }, timeoutMs, 'Timed out waiting for active quick pick to appear.'); + let visibleLabels: string[] = []; + const item = await VSBrowser.instance.driver.wait(async () => { + try { + const picks = await input.getQuickPicks(); + visibleLabels = await Promise.all(picks.map(pick => pick.getLabel())); + return picks[index] ?? false; + } + catch (error) { + throwIfWebDriverSessionFailure(error); + return false; + } + }, timeoutMs, `Timed out waiting for quick pick index ${index}. Visible labels: ${visibleLabels.join(', ') || ''}.`); + await item.select(); +} + export async function getActiveQuickPickLabels(timeoutMs = 30000): Promise { const input = await VSBrowser.instance.driver.wait(async () => { try { @@ -368,6 +393,41 @@ export async function waitForEditorTitle(expectedText: string, timeoutMs = 60000 } } +/** + * Waits for a CodeLens whose text contains in the named editor. + * + * The widget spans are read directly rather than through `TextEditor.getCodeLenses()` because that + * API enumerates `.//span[contains(@widgetid, 'codelens.widget')]/a[@id]` -- only the *clickable* + * lenses. A lens contributed with an empty command id is rendered by VS Code as plain text rather + * than a link, so it has no anchor element and is structurally invisible to that API. Aspire's + * entry point warnings are exactly that shape: they state a fact and have nothing to navigate to. + * + * One widget exists per line and holds every lens on it, so the returned strings are per line and + * read like the editor does, e.g. `Run | Debug | ⚠️ Do not click the Java Run or Debug actions...`. + */ +export async function waitForCodeLensText(fileName: string, expectedText: string, timeoutMs = 60000): Promise { + let lastTexts: string[] = []; + + try { + return await VSBrowser.instance.driver.wait(async () => { + try { + await new EditorView().openEditor(fileName); + lastTexts = await VSBrowser.instance.driver.executeScript( + `return Array.from(document.querySelectorAll('[widgetid*="codelens.widget"]')).map(widget => widget.innerText || widget.textContent || '');`); + } + catch (error) { + throwIfWebDriverSessionFailure(error); + return false; + } + + return lastTexts.some(text => text.includes(expectedText)) ? lastTexts : false; + }, timeoutMs, `Timed out waiting for a CodeLens containing '${expectedText}' in '${fileName}'.`); + } + catch (error) { + throw withWaitDiagnostics(error, [`CodeLenses: ${formatDiagnosticList(lastTexts)}`]); + } +} + export async function waitForWorkbenchText(expectedText: string, timeoutMs = 30000): Promise { let lastText = ''; diff --git a/extension/src/test-e2e/javaAppHost.e2e.test.ts b/extension/src/test-e2e/javaAppHost.e2e.test.ts new file mode 100644 index 00000000000..02152ab236c --- /dev/null +++ b/extension/src/test-e2e/javaAppHost.e2e.test.ts @@ -0,0 +1,179 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { executeE2eControlCommand } from './helpers/fixtures'; +import { JAVA_APP_HOST_DIRECTORY, getJavaAppHostSourcePath, prepareJavaWorkspace, waitForJavaLanguageServerImport } from './helpers/java'; +import { getWorkspaceRoot } from './helpers/paths'; + +interface DiagnosticInfo { + message: string; + severity: number; + code?: string | number; +} + +interface BreakpointProofStackFrame { + line?: number; + name?: string; + source?: { path?: string }; +} + +interface BreakpointProof { + sourcePath: string; + line: number; + text?: string; + matchingStackFrame: BreakpointProofStackFrame; + topStackFrame?: BreakpointProofStackFrame; +} + +interface DebugProof { + proof: string; + appHostBreakpoint: BreakpointProof; + resourceBreakpoint: BreakpointProof; + debugSessions: { type: string; name: string }[]; + launchRequests: { sessionType: string; arguments?: Record }[]; +} + +// VS Code numbers DiagnosticSeverity from most to least severe, so Error is 0 and Warning is 1. +const DIAGNOSTIC_SEVERITY_ERROR = 0; +const DIAGNOSTIC_SEVERITY_WARNING = 1; + +const APP_HOST_DIRECTORY = JAVA_APP_HOST_DIRECTORY; +const CATALOG_CONTROLLER_SOURCE = path.join('catalog', 'src', 'main', 'java', 'com', 'example', 'catalog', 'CatalogController.java'); + +suite('Aspire Java AppHost E2E', function () { + // A cold Gradle import compiles the Spring Boot services and downloads a Java 21 toolchain, and + // the debug proof then runs the AppHost end to end on top of that. + this.timeout(1800000); + + // Importing the workspace is what the first two tests are about, so it is shared setup rather + // than the side effect of whichever test happens to run first. Without it, running either test + // on its own measured an empty workspace: the language server had produced neither diagnostics + // nor output directories, and both assertions hold trivially over nothing. + suiteSetup(async () => { + await prepareJavaWorkspace(); + await executeE2eControlCommand({ name: 'openFile', filePath: getJavaAppHostSourcePath() }); + await waitForJavaLanguageServerImport(); + }); + + test('imports the workspace without reporting problems in generated or authored sources', async () => { + const appHostSourcePath = getJavaAppHostSourcePath(); + + const generatedSources = findGeneratedSdkSources(); + assert.ok( + generatedSources.length > 100, + `Expected the generated Aspire Java SDK under ${APP_HOST_DIRECTORY}/.aspire/modules. Found ${generatedSources.length} files.`); + + const offenders: string[] = []; + for (const sourcePath of [appHostSourcePath, ...generatedSources]) { + const diagnostics = (await executeE2eControlCommand({ name: 'getDiagnostics', filePath: sourcePath })).result as DiagnosticInfo[]; + const problems = diagnostics.filter(diagnostic => + diagnostic.severity === DIAGNOSTIC_SEVERITY_ERROR || diagnostic.severity === DIAGNOSTIC_SEVERITY_WARNING); + + for (const problem of problems) { + offenders.push(`${path.relative(getWorkspaceRoot(), sourcePath)}: [${problem.severity === DIAGNOSTIC_SEVERITY_ERROR ? 'error' : 'warning'}] ${problem.message}`); + } + } + + assert.deepStrictEqual( + offenders, + [], + `Opening a Java AppHost must not report problems the user cannot act on. Checked ${generatedSources.length + 1} files.`); + }); + + test('does not copy build inputs into the language server output directory', () => { + // Rooting the java source set at '.' also points the resources source set there unless the + // build file says otherwise, and processResources then copies .gradle/, .aspire/ and the + // wrapper into build output. The language server digests those copies and fails to refresh + // the workspace once Gradle deletes them. + const appHostDirectory = path.join(getWorkspaceRoot(), APP_HOST_DIRECTORY); + + // Nothing below can fail while the project has no output at all, so require the output + // directory first. The workspace is copied in without one, which leaves the language + // server's own compile as the only thing that can produce it. + const outputDirectory = path.join(appHostDirectory, 'bin', 'main'); + assert.ok( + fs.existsSync(outputDirectory), + `The language server produced no output under ${path.relative(getWorkspaceRoot(), outputDirectory)}, so this test cannot observe what a build copies there. ${APP_HOST_DIRECTORY} holds: ${fs.readdirSync(appHostDirectory).join(', ')}`); + + const copied: string[] = []; + // build/ only appears when Gradle itself builds the project, which no test here does; it is + // listed because the same source set misconfiguration fills both directories, and a run that + // does build should fail on it too. + for (const candidate of ['build/resources', 'bin/main/.gradle', 'bin/main/.aspire', 'bin/main/gradlew', 'bin/main/build.gradle', 'bin/main/aspire.config.json']) { + if (fs.existsSync(path.join(appHostDirectory, candidate))) { + copied.push(candidate); + } + } + + assert.deepStrictEqual(copied, [], `Build inputs were copied into the AppHost's output directories: ${copied.join(', ')}.`); + }); + + test('stops on breakpoints in both the Java AppHost and a Java resource', async () => { + const appHostSourcePath = getJavaAppHostSourcePath(); + const resourceSourcePath = path.join(getWorkspaceRoot(), CATALOG_CONTROLLER_SOURCE); + + const proof = (await executeE2eControlCommand({ + name: 'proveAppHostAndResourceDebugging', + appHostPath: appHostSourcePath, + resourceName: 'catalog', + appHostSourcePath, + appHostBreakpointLine: findBreakpointLine(appHostSourcePath, 'builder.addSpringBootApp("catalog"'), + resourceSourcePath, + resourceBreakpointLine: findBreakpointLine(resourceSourcePath, 'return Products;'), + // The breakpoint sits in the /products handler, so that is the request the proof has to + // send for the line to run at all. + resourceRequestPath: '/products', + timeoutMs: 900000, + }, { + // The command runs the whole AppHost, so the harness has to wait longer than the default + // 10s for the control file to be marked applied - otherwise the wait fails while the + // proof is still legitimately running. + timeoutMs: 960000, + })).result as DebugProof; + + assert.strictEqual(proof.proof, 'aspire-apphost-and-resource-debug-breakpoints-hit'); + + // The stack frame is what makes this a proof rather than an assertion that a session started: + // it can only name these files if the adapter actually suspended there with source resolved. + assert.ok( + proof.appHostBreakpoint.matchingStackFrame.source?.path, + `The AppHost breakpoint hit did not resolve a source path: ${JSON.stringify(proof.appHostBreakpoint)}`); + assert.ok( + proof.resourceBreakpoint.matchingStackFrame.source?.path, + `The resource breakpoint hit did not resolve a source path: ${JSON.stringify(proof.resourceBreakpoint)}`); + + // Aspire delegates Java resources to vscjava.vscode-java-debug rather than attaching over JDWP + // itself, so a session of any other type would mean the delegation regressed. + const javaSessions = proof.debugSessions.filter(session => session.type === 'java'); + assert.ok( + javaSessions.length > 0, + `Expected at least one 'java' debug session. Saw: ${JSON.stringify(proof.debugSessions)}`); + }); +}); + +/** + * Finds the zero-based line of the first occurrence of a marker. + * + * Hard-coding line numbers makes the sample impossible to edit without silently moving the + * breakpoint onto an unrelated statement, which the proof would then report as a timeout. + */ +function findBreakpointLine(sourcePath: string, marker: string): number { + const lines = fs.readFileSync(sourcePath, 'utf8').split(/\r?\n/); + const index = lines.findIndex(line => line.includes(marker)); + if (index < 0) { + throw new Error(`Could not find '${marker}' in ${sourcePath} to place a breakpoint on.`); + } + + return index; +} + +function findGeneratedSdkSources(): string[] { + const modulesRoot = path.join(getWorkspaceRoot(), APP_HOST_DIRECTORY, '.aspire', 'modules'); + if (!fs.existsSync(modulesRoot)) { + return []; + } + + return fs.readdirSync(modulesRoot, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.java')) + .map(entry => path.join(entry.parentPath ?? modulesRoot, entry.name)); +} diff --git a/extension/src/test-e2e/javaAppHostDebugStartupTimeout.e2e.test.ts b/extension/src/test-e2e/javaAppHostDebugStartupTimeout.e2e.test.ts new file mode 100644 index 00000000000..fe1fb1fb269 --- /dev/null +++ b/extension/src/test-e2e/javaAppHostDebugStartupTimeout.e2e.test.ts @@ -0,0 +1,79 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import { getCommandInvocationCount, waitForCommandOutcome, waitForDebugConsoleOutput, waitForDebugSessionStartup, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle } from './helpers/assertions'; +import { executeE2eControlCommand, runE2eTeardown, writeFileWithRetry } from './helpers/fixtures'; +import { getJavaAppHostSourcePath, prepareJavaWorkspace, waitForJavaLanguageServerImport } from './helpers/java'; +import { readCliLogs, readExtensionLogs } from './helpers/logs'; + +suite('Aspire Java AppHost debug startup timeout E2E', function () { + this.timeout(1800000); + + const delayStartMarker = 'JAVA_TIMEOUT_E2E_DELAY_START'; + const delayEndMarker = 'JAVA_TIMEOUT_E2E_DELAY_END'; + + suiteSetup(async () => { + await prepareJavaWorkspace(); + await executeE2eControlCommand({ name: 'openFile', filePath: getJavaAppHostSourcePath() }); + await waitForJavaLanguageServerImport(); + }); + + test('debug startup waits past the old 60 second guest backchannel timeout', async function () { + if (!shouldRunStartupTimeoutProof()) { + this.skip(); + } + + const appHostSourcePath = getJavaAppHostSourcePath(); + const originalSource = fs.readFileSync(appHostSourcePath, 'utf8'); + + try { + const delayedSource = originalSource.replace( + ' var builder = DistributedApplication.CreateBuilder();', + ` System.out.println("${delayStartMarker} " + java.time.Instant.now());\n Thread.sleep(65_000);\n System.out.println("${delayEndMarker} " + java.time.Instant.now());\n var builder = DistributedApplication.CreateBuilder();`); + assert.notStrictEqual(delayedSource, originalSource, 'Expected the Java AppHost fixture to create its builder.'); + writeFileWithRetry(appHostSourcePath, delayedSource); + + const beforeDebug = getCommandInvocationCount('aspire-vscode.debugAppHost'); + await executeE2eControlCommand({ name: 'debugAppHost', appHostPath: appHostSourcePath }, { waitFor: 'started' }); + await waitForCommandOutcome('aspire-vscode.debugAppHost', 'success', 60000, beforeDebug); + + const startOutput = await waitForDebugConsoleOutput(delayStartMarker, appHostSourcePath, 120000); + const endOutput = await waitForDebugConsoleOutput(delayEndMarker, appHostSourcePath, 120000); + await waitForDebugSessionStartup(appHostSourcePath, 240000); + + const delayMs = getMarkerTimestamp(endOutput.output, delayEndMarker).getTime() + - getMarkerTimestamp(startOutput.output, delayStartMarker).getTime(); + assert.ok(delayMs >= 60000, `Expected the Java AppHost delay to cross the old 60 second timeout. Actual delay: ${delayMs}ms.`); + + const cliLogs = readCliLogs(); + assert.ok(!cliLogs.includes('Timed out waiting'), 'Expected Java debug startup to complete without a CLI timeout.'); + + const expectedTimeout = '86400'; + assert.strictEqual(process.env.ASPIRE_CLI_START_TIMEOUT, undefined, 'Expected the Java E2E proof to leave ASPIRE_CLI_START_TIMEOUT unset.'); + const extensionLogs = readExtensionLogs(); + assert.ok(extensionLogs.includes('run --start-debug-session'), 'Expected extension logs to include the Java AppHost CLI launch.'); + assert.ok(extensionLogs.includes(`ASPIRE_CLI_START_TIMEOUT=${expectedTimeout}`), `Expected the extension-spawned CLI to use ASPIRE_CLI_START_TIMEOUT=${expectedTimeout}.`); + } + finally { + await runE2eTeardown([ + () => writeFileWithRetry(appHostSourcePath, originalSource), + () => executeE2eControlCommand({ name: 'stopDebugging' }), + () => waitForNoDebugSessions().catch(() => undefined), + () => waitForNoRunningAppHost(90000, appHostSourcePath).catch(() => undefined), + () => waitForRepositoryIdle(), + ], 'Java debug startup timeout E2E cleanup failed.'); + } + }); +}); + +function shouldRunStartupTimeoutProof(): boolean { + return process.env.ASPIRE_EXTENSION_E2E_UNSET_CLI_START_TIMEOUT === 'true'; +} + +function getMarkerTimestamp(output: string, marker: string): Date { + const match = new RegExp(`${marker} ([^\\s]+)`).exec(output); + assert.ok(match, `Expected debug console output to contain a timestamp after '${marker}': ${output}`); + + const timestamp = new Date(match[1].replace(/(\.\d{3})\d+Z$/, '$1Z')); + assert.ok(!Number.isNaN(timestamp.getTime()), `Expected '${match[1]}' to be a valid timestamp.`); + return timestamp; +} diff --git a/extension/src/test-e2e/javaCodeLens.e2e.test.ts b/extension/src/test-e2e/javaCodeLens.e2e.test.ts new file mode 100644 index 00000000000..d2f95f84b3f --- /dev/null +++ b/extension/src/test-e2e/javaCodeLens.e2e.test.ts @@ -0,0 +1,56 @@ +import * as assert from 'assert'; +import { getJavaAppHostSourcePath, prepareJavaWorkspace, waitForJavaLanguageServerImport } from './helpers/java'; +import { executeE2eControlCommand } from './helpers/fixtures'; +import { isSamePath } from './helpers/assertions'; +import { closeAllEditors, waitForCodeLensText, waitForEditorTitle } from './helpers/vscode'; + +suite('Java AppHost CodeLens E2E', function () { + // Matches the other Java specs: the language server import below is allowed 15 minutes on a cold + // runner, so a 5 minute suite budget would abort the setup rather than the thing it waits for. + this.timeout(1800000); + + suiteSetup(async () => { + await prepareJavaWorkspace(); + + // Kept because VS Code renders one merged CodeLens set per document: a `java` file shows + // nothing until every registered provider has answered, including redhat.java's, and the + // other two Java specs wait here for the same reason. It is not what caused the + // `CodeLenses: (none)` failure though - that run reached Standard mode in 20 seconds and + // still saw nothing, because no editor had been opened at all. + await waitForJavaLanguageServerImport(); + }); + + suiteTeardown(async () => { + await closeAllEditors(); + }); + + test('shows the entry point warning on a Java AppHost', async () => { + const appHostPath = getJavaAppHostSourcePath(); + + // Open through the extension host rather than VSBrowser.openResources(). That helper shells + // out to `code -r ` (CodeUtil.open), which exited successfully here while opening + // nothing: the window sat on the welcome screen for the whole CodeLens wait, so the spec + // reported `CodeLenses: (none)` for a document that was never open. The other two Java specs + // already use this command, which is why they passed. It is also observable - it returns the + // active editor - so a silent no-op fails here instead of three minutes later as a blank wait. + const opened = await executeE2eControlCommand({ name: 'openFile', filePath: appHostPath }); + const openedFileName = (opened.result as { fileName?: string } | undefined)?.fileName; + assert.ok( + openedFileName && isSamePath(openedFileName, appHostPath), + `expected '${appHostPath}' to be the active editor, got '${openedFileName ?? ''}'`); + + // The command reports the extension host's view; this proves the tab actually rendered, + // which is the specific thing that was missing when the window sat on the welcome screen. + await waitForEditorTitle('AppHost.java'); + + // The tab now exists, but the lenses are produced asynchronously after that, so this is + // polled rather than read once. The suiteSetup guarantees the Java language server reached + // Standard mode; VS Code still has to run the merged provider pass on this document + // afterwards, so this allows more than the 60s default. + const texts = await waitForCodeLensText('AppHost.java', 'bypass Aspire', 180000); + + assert.ok( + texts.some(text => text.includes('bypass Aspire')), + `expected the Run/Debug bypass warning, got: ${JSON.stringify(texts)}`); + }); +}); diff --git a/extension/src/test-e2e/packageSurface.e2e.test.ts b/extension/src/test-e2e/packageSurface.e2e.test.ts index 2f86f4f5688..c629afcb5ce 100644 --- a/extension/src/test-e2e/packageSurface.e2e.test.ts +++ b/extension/src/test-e2e/packageSurface.e2e.test.ts @@ -467,6 +467,9 @@ const expectedActivationEvents = [ 'workspaceContains:**/apphost.mjs', 'workspaceContains:**/apphost.cjs', 'workspaceContains:**/apphost.rs', + 'workspaceContains:**/AppHost.java', + 'workspaceContains:**/apphost.java', + 'workspaceContains:**/src/main/java/AppHost.java', 'onCommand:aspire-vscode.installCli', 'onCommand:aspire-vscode.verifyCliInstalled', 'onLanguageModelTool:aspire_apphost_start', @@ -478,6 +481,7 @@ const expectedSourceLanguageModelTools = createExpectedLanguageModelTools({ startModelDescription: '%languageModelTool.aspireAppHostStart.modelDescription%', startUserDescription: '%languageModelTool.aspireAppHostStart.userDescription%', startModeDescription: '%languageModelTool.aspireAppHostStart.mode.description%', + startIsolatedDescription: '%languageModelTool.aspireAppHostStart.isolated.description%', stopDisplayName: '%languageModelTool.aspireAppHostStop.displayName%', stopModelDescription: '%languageModelTool.aspireAppHostStop.modelDescription%', stopUserDescription: '%languageModelTool.aspireAppHostStop.userDescription%', @@ -486,9 +490,10 @@ const expectedSourceLanguageModelTools = createExpectedLanguageModelTools({ const expectedInstalledLanguageModelTools = createExpectedLanguageModelTools({ startDisplayName: 'Start Aspire AppHost', - startModelDescription: 'Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Start an Aspire AppHost that Aspire has already discovered in the current workspace, using the editor\'s own debug lifecycle. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. Also requires whether to start it in \'run\' mode (no debugger attached) or \'debug\' mode (debugger attached). Does not create, pick, or guess an AppHost: if the path does not name a discovered AppHost, or names more than one, the call fails and the result lists the AppHosts you can pass. If the AppHost is already starting or already running, no second process is started.', + startModelDescription: 'Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Start an Aspire AppHost that Aspire has already discovered in the current workspace, using the editor\'s own debug lifecycle. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. Also requires whether to start it in \'run\' mode (no debugger attached) or \'debug\' mode (debugger attached). Optional \'isolated\' starts the AppHost with randomized ports and isolated user secrets; when omitted, linked git worktrees start isolated automatically so they do not collide with the primary checkout. Explicit true or false overrides that inference. Does not create, pick, or guess an AppHost: if the path does not name a discovered AppHost, or names more than one, the call fails and the result lists the AppHosts you can pass. If the AppHost is already starting or already running, no second process is started. A successful new launch includes the verified effective \'isolated\' value; idempotent or uncertain results omit it.', startUserDescription: 'Start an Aspire AppHost from this workspace in run or debug mode.', startModeDescription: 'How to start the AppHost: \'run\' starts it without attaching the debugger, \'debug\' starts it with the debugger attached.', + startIsolatedDescription: 'When true, start with randomized ports and isolated user secrets. When false, do not isolate. When omitted, linked git worktrees start isolated automatically.', stopDisplayName: 'Stop Aspire AppHost', stopModelDescription: 'Prefer this tool over invoking Aspire AppHost lifecycle commands in a terminal whenever VS Code is active. Stop a running Aspire AppHost that Aspire has already discovered in the current workspace. Requires the workspace-relative path of one of the discovered AppHosts; absolute paths are rejected. AppHosts started by this editor stop through the coordinated debug lifecycle. AppHosts started outside the editor stop through \'aspire stop --apphost\' for the same discovered path. The extension never kills arbitrary processes. If it cannot determine whether the AppHost is running, the call fails rather than reporting that nothing is running.', stopUserDescription: 'Stop a running Aspire AppHost from this workspace.', @@ -500,6 +505,7 @@ function createExpectedLanguageModelTools(strings: { startModelDescription: string; startUserDescription: string; startModeDescription: string; + startIsolatedDescription?: string; stopDisplayName: string; stopModelDescription: string; stopUserDescription: string; @@ -525,6 +531,10 @@ function createExpectedLanguageModelTools(strings: { enum: ['run', 'debug'], description: strings.startModeDescription, }, + isolated: { + type: 'boolean', + description: strings.startIsolatedDescription, + }, }, required: ['appHostPath', 'mode'], additionalProperties: false, diff --git a/extension/src/test-e2e/workspaceTargetProof.e2e.test.ts b/extension/src/test-e2e/workspaceTargetProof.e2e.test.ts new file mode 100644 index 00000000000..843300848cc --- /dev/null +++ b/extension/src/test-e2e/workspaceTargetProof.e2e.test.ts @@ -0,0 +1,123 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCommandInvocationCount, getTerminalCommandCount, waitForCommandOutcome, waitForTerminalCommand } from './helpers/assertions'; +import { executeE2eControlCommand, restoreE2eCliPathForE2E, setE2eCliPathForE2E, setTerminalCommandExecutionSuppressedForE2E } from './helpers/fixtures'; +import { getWorkspaceRoot } from './helpers/paths'; +import { VSBrowser } from './helpers/extester'; +import { cancelActiveInput, chooseActiveQuickPick, executeCommandFromPalette, openAspireView } from './helpers/vscode'; + +suite('Workspace target proof E2E', function () { + this.timeout(900000); + + teardown(async () => { + await setTerminalCommandExecutionSuppressedForE2E(false); + await restoreE2eCliPathForE2E(); + }); + + test('isolates folder terminals and keeps global commands window scoped', async function () { + if (process.platform === 'win32') { + this.skip(); + } + + const workspaceRoot = getWorkspaceRoot(); + const folderA = createFolderFixture(workspaceRoot, 'folder-a'); + const folderB = createFolderFixture(workspaceRoot, 'folder-b'); + + await openAspireView(); + await addWorkspaceFolder(folderA.folderPath); + await addWorkspaceFolder(folderB.folderPath); + await setE2eCliPathForE2E(undefined); + + await invokeNewForFolder('folder-a', folderA); + await invokeNewForFolder('folder-b', folderB); + + const beforeCanceledInvocation = getCommandInvocationCount('aspire-vscode.new'); + const beforeCanceledTerminal = getTerminalCommandCount(); + await executeCommandFromPalette('Aspire: New Project'); + await cancelActiveInput(); + await waitForCommandOutcome('aspire-vscode.new', 'canceled', 60000, beforeCanceledInvocation); + assert.strictEqual(getTerminalCommandCount(), beforeCanceledTerminal); + + await setTerminalCommandExecutionSuppressedForE2E(true); + const beforeUpdateInvocation = getCommandInvocationCount('aspire-vscode.updateSelf'); + const beforeUpdateTerminal = getTerminalCommandCount(); + await executeE2eControlCommand({ name: 'executeAspireCommand', commandId: 'aspire-vscode.updateSelf' }); + await waitForCommandOutcome('aspire-vscode.updateSelf', 'success', 60000, beforeUpdateInvocation); + const updateCommand = await waitForTerminalCommand( + event => event.executionSuppressed && event.subcommand === 'update --self', + 'window-scoped update self command', + 60000, + beforeUpdateTerminal); + // The property under test is that a window-scoped command resolves its CLI outside any + // workspace folder. Asserting the literal 'aspire update --self' would also encode "no CLI + // is installed on this machine", which is true on CI and false on a developer box where the + // command legitimately resolves to an absolute path under ~/.aspire. + assert.ok(updateCommand.commandLine.endsWith('aspire update --self'), updateCommand.commandLine); + assert.ok(!updateCommand.commandLine.includes(workspaceRoot), updateCommand.commandLine); + assert.ok(!updateCommand.commandLine.includes(folderA.wrapperPath), updateCommand.commandLine); + assert.ok(!updateCommand.commandLine.includes(folderB.wrapperPath), updateCommand.commandLine); + + await VSBrowser.instance.takeScreenshot('workspace-target-proof.png'); + }); +}); + +interface FolderFixture { + folderPath: string; + wrapperPath: string; + invocationLogPath: string; +} + +function createFolderFixture(workspaceRoot: string, folderName: string): FolderFixture { + const folderPath = path.join(workspaceRoot, folderName); + const wrapperPath = path.join(folderPath, `aspire-${folderName}`); + const invocationLogPath = path.join(folderPath, 'invocations.log'); + fs.mkdirSync(path.join(folderPath, '.vscode'), { recursive: true }); + fs.writeFileSync(wrapperPath, `#!/bin/sh\nprintf '%s\\t%s\\n' "$PWD" "$*" >> '${invocationLogPath}'\nif [ "$1" = "--version" ]; then printf '13.5.0-proof\\n'; fi\n`); + fs.chmodSync(wrapperPath, 0o755); + fs.writeFileSync(path.join(folderPath, '.vscode', 'settings.json'), JSON.stringify({ + 'aspire.aspireCliExecutablePath': wrapperPath, + }, undefined, 2)); + return { folderPath, wrapperPath, invocationLogPath }; +} + +async function addWorkspaceFolder(folderPath: string): Promise { + // This used to drive `Workspaces: Add Folder to Workspace...` and its quick-open input. Adding the + // first folder converts the single-folder window into an untitled multi-root workspace, which + // reloads the window and restarts the extension host, and after that reload the second add never + // took: the command ran and the input confirmed, but the folder never reached `workspaceFolders`. + // Retrying only spent the budget - four attempts failed the same way - so the fragility is in the + // UI flow, not in the time allowed for it. + // + // What this spec proves is that CLI commands target the right workspace folder. How the folder is + // added is incidental, so it goes through the same API VS Code's own command calls. The bridge + // resolves only once the extension host has observed the folder, so there is nothing left to poll. + await VSBrowser.instance.waitForWorkbench(); + const response = await executeE2eControlCommand({ name: 'addWorkspaceFolder', folderPath }, { timeoutMs: 60000 }); + const folders = JSON.stringify(response.result); + if (!folders.includes(folderPath)) { + throw new Error(`Adding workspace folder '${folderPath}' reported success but the extension lists: ${folders}`); + } +} + +async function invokeNewForFolder(folderLabel: string, fixture: FolderFixture): Promise { + const beforeInvocation = getCommandInvocationCount('aspire-vscode.new'); + const beforeTerminal = getTerminalCommandCount(); + await executeCommandFromPalette('Aspire: New Project'); + await chooseActiveQuickPick(folderLabel); + await waitForCommandOutcome('aspire-vscode.new', 'success', 60000, beforeInvocation); + const terminalCommand = await waitForTerminalCommand( + event => event.subcommand === 'new' && event.commandLine.includes(fixture.wrapperPath), + `${folderLabel} terminal command`, + 60000, + beforeTerminal); + assert.strictEqual(terminalCommand.executionSuppressed, false); + await VSBrowser.instance.driver.wait(() => { + if (!fs.existsSync(fixture.invocationLogPath)) { + return false; + } + return fs.readFileSync(fixture.invocationLogPath, 'utf8') + .split(/\r?\n/) + .some(line => line === `${fixture.folderPath}\tnew`); + }, 30000, `Expected ${folderLabel} wrapper to run 'new' from '${fixture.folderPath}'.`); +} \ No newline at end of file diff --git a/extension/src/test/AspireExtensionContext.test.ts b/extension/src/test/AspireExtensionContext.test.ts index bc15d0e4360..6b6df4a89e1 100644 --- a/extension/src/test/AspireExtensionContext.test.ts +++ b/extension/src/test/AspireExtensionContext.test.ts @@ -592,6 +592,65 @@ suite('AspireExtensionContext', () => { ]); }); + test('deactivation waits for forced CLI process-tree termination before disposing shared infrastructure', async () => { + const order: string[] = []; + const context = createContext(order); + const termination = createDeferred(); + addSession( + context, + 'session', + () => Promise.resolve(), + () => order.push('dispose session'), + async () => { + order.push('terminate session'); + await termination.promise; + }); + + const shutdown = deactivateContext(context); + await new Promise(resolve => setImmediate(resolve)); + + assert.deepStrictEqual(order, ['terminate session']); + assert.strictEqual(order.includes('rpc server'), false); + + termination.resolve(); + await shutdown; + + assert.deepStrictEqual(order, [ + 'terminate session', + 'dispose session', + 'rpc server', + 'dcp server', + 'terminal provider', + 'editor command provider', + ]); + }); + + test('deactivation bounds forced CLI process-tree confirmation before disposing shared infrastructure', async () => { + const order: string[] = []; + const context = createContext(order); + const warnStub = sinon.stub(extensionLogOutputChannel, 'warn'); + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + addSession( + context, + 'session', + () => Promise.resolve(), + () => order.push('dispose session'), + () => new Promise(() => { })); + + try { + const shutdown = deactivateContext(context); + await clock.tickAsync(5_000); + await shutdown; + + assert.ok(order.indexOf('dispose session') < order.indexOf('rpc server')); + sinon.assert.calledWithMatch(warnStub, 'Timed out after 5000ms waiting for Aspire CLI process-tree termination'); + } + finally { + clock.restore(); + warnStub.restore(); + } + }); + test('deactivation terminates the CLI process group when the cooperative stop never settles', async () => { const order: string[] = []; const context = createContext(order); @@ -648,7 +707,7 @@ suite('AspireExtensionContext', () => { const context = createContext(order); const cliProcess = createFakeCliProcess(4321); const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').returns(cliProcess); - const terminateStub = sinon.stub(cliModule, 'terminateCliProcess'); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); const stopDebuggingStub = sinon.stub(vscode.debug, 'stopDebugging').resolves(); const aspireDebugSession = createSpawnedDebugSession(context); context.addAspireDebugSession(aspireDebugSession); @@ -697,6 +756,7 @@ suite('AspireExtensionContext', () => { async () => { order.push('stop debug sessions late'); }); + await new Promise(resolve => setImmediate(resolve)); assert.deepStrictEqual(context.aspireDebugSessions, []); assert.deepStrictEqual(order, [ @@ -734,7 +794,7 @@ function addSession( debugSessionId: string, stopCli: () => Promise, dispose: () => void, - terminateCliProcessTree: (options?: { force?: boolean }) => void = () => { }, + terminateCliProcessTree: (options?: { force?: boolean }) => unknown = () => { }, stopDebugging: () => Promise = () => Promise.resolve(), finalizeForExtensionShutdown: () => void = dispose): void { context.addAspireDebugSession({ @@ -743,7 +803,9 @@ function addSession( onDidSendDebugConsoleOutput: () => ({ dispose: () => { } }), stopDebugging, requestCliStopForExtensionShutdown: stopCli, - terminateCliProcessTree, + terminateCliProcessTree: async (options?: { force?: boolean }) => { + await terminateCliProcessTree(options); + }, finalizeForExtensionShutdown, dispose, } as unknown as AspireDebugSession); diff --git a/extension/src/test/appHostCliRunner.test.ts b/extension/src/test/appHostCliRunner.test.ts index b47500e53ff..5913d2270fb 100644 --- a/extension/src/test/appHostCliRunner.test.ts +++ b/extension/src/test/appHostCliRunner.test.ts @@ -4,6 +4,7 @@ import * as vscode from 'vscode'; import { EventEmitter } from 'events'; import { AppHostCliRunner, isDescribeUnsupportedOutput } from '../data/appHostCliRunner'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import * as cliModule from '../utils/process/cliProcess'; class TestChildProcess extends EventEmitter { @@ -68,6 +69,87 @@ suite('data/appHostCliRunner tests', () => { assert.strictEqual(cliProcess.killed, false); }); + test('resolves a one-shot command with its supplied workspace target', async () => { + const folder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }; + const target = workspaceFolderCliPathTarget(folder); + const cliProcess = new TestChildProcess(); + spawnStub.callsFake((_provider: unknown, _cliPath: string, _args: string[], options: cliModule.SpawnProcessOptions) => { + cliProcess.exitCode = 0; + options.exitCallback?.(0); + return cliProcess; + }); + + const runner = new AppHostCliRunner(terminalProvider); + try { + const options = { timeoutMs: undefined, target }; + await runner.runCliCommand('describe', ['describe'], options); + + assert.strictEqual(getCliPathStub.calledOnceWithExactly(target), true); + } finally { + runner.dispose(); + } + }); + + test('isolates nologo fallback by concrete CLI path', async () => { + const folderA: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace/a'), + name: 'a', + index: 0, + }; + const folderB: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace/b'), + name: 'b', + index: 1, + }; + const targetA = workspaceFolderCliPathTarget(folderA); + const targetB = workspaceFolderCliPathTarget(folderB); + getCliPathStub.callsFake(async target => target?.kind === 'workspaceFolder' + ? `/cli/${target.workspaceFolder.name}/aspire` + : '/cli/global/aspire'); + spawnStub.callsFake(() => new TestChildProcess()); + + const completeSpawn = (index: number, code: number, stderr = '') => { + const call = spawnStub.getCall(index); + const process = call.returnValue as TestChildProcess; + if (stderr) { + call.args[3].stderrCallback(stderr); + } + process.exitCode = code; + call.args[3].exitCallback(code); + }; + + const runner = new AppHostCliRunner(terminalProvider); + try { + const firstA = runner.runCliCommand('describe A', runner.withNoLogo(['describe']), { target: targetA }); + await waitForCondition(() => spawnStub.callCount === 1, 'expected first CLI A invocation'); + completeSpawn(0, 1, "Unrecognized command or argument '--nologo'."); + await waitForCondition(() => spawnStub.callCount === 2, 'expected CLI A fallback invocation'); + completeSpawn(1, 0); + await firstA; + + const firstB = runner.runCliCommand('describe B', runner.withNoLogo(['describe']), { target: targetB }); + await waitForCondition(() => spawnStub.callCount === 3, 'expected first CLI B invocation'); + completeSpawn(2, 0); + await firstB; + + const laterA = runner.runCliCommand('describe A', runner.withNoLogo(['describe']), { target: targetA }); + await waitForCondition(() => spawnStub.callCount === 4, 'expected later CLI A invocation'); + completeSpawn(3, 0); + await laterA; + + assert.deepStrictEqual(spawnStub.getCall(0).args.slice(1, 3), ['/cli/a/aspire', ['describe', '--nologo']]); + assert.deepStrictEqual(spawnStub.getCall(1).args.slice(1, 3), ['/cli/a/aspire', ['describe']]); + assert.deepStrictEqual(spawnStub.getCall(2).args.slice(1, 3), ['/cli/b/aspire', ['describe', '--nologo']]); + assert.deepStrictEqual(spawnStub.getCall(3).args.slice(1, 3), ['/cli/a/aspire', ['describe']]); + } finally { + runner.dispose(); + } + }); + test('tracks a one-shot process that is still running so it can be stopped', async () => { const cliProcess = new TestChildProcess(); let exitCallback: ((code: number | null) => void) | undefined; diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index af912903b6d..5e1edb2f4a7 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -14,7 +14,9 @@ import * as cliModule from '../utils/process/cliProcess'; import * as configInfoProvider from '../utils/configInfoProvider'; import { describeIncludeDisabledCommandsCapability, lsJsonStreamCapability } from '../types/configInfo'; import { errorFetchingAppHosts } from '../loc/strings'; +import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { removeDirectorySafely } from './testHelpers'; class TestChildProcess extends EventEmitter { stdout = new PassThrough(); stderr = new PassThrough(); @@ -137,6 +139,94 @@ suite('AppHostDataRepository', () => { return describeCall; } + async function startTwoFolderDescribeStreams(): Promise<{ + repository: AppHostDataRepository; + selectedDescribe: sinon.SinonSpyCall; + peerDescribe: sinon.SinonSpyCall; + dispose: () => void; + }> { + const selectedFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace/selected'), + name: 'selected', + index: 0, + }; + const peerFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace/peer'), + name: 'peer', + index: 1, + }; + const selectedAppHostPath = path.join(selectedFolder.uri.fsPath, 'AppHost.csproj'); + const peerAppHostPath = path.join(peerFolder.uri.fsPath, 'AppHost.csproj'); + const workspaceFoldersStub = stubWorkspaceFolders([selectedFolder, peerFolder]); + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => { + if (uri.fsPath.startsWith(selectedFolder.uri.fsPath)) { + return selectedFolder; + } + if (uri.fsPath.startsWith(peerFolder.uri.fsPath)) { + return peerFolder; + } + return undefined; + }); + getCliPathStub.callsFake(async target => target?.kind === 'workspaceFolder' + ? `/cli/${target.workspaceFolder.name}/aspire` + : '/cli/global/aspire'); + const candidateChangeEmitter = new vscode.EventEmitter(); + const discoveryService = { + discover: async (workspaceFolder: vscode.WorkspaceFolder): Promise => [{ + path: path.join(workspaceFolder.uri.fsPath, 'AppHost.csproj'), + language: 'csharp', + status: 'buildable', + selected: workspaceFolder.name === selectedFolder.name, + }], + onDidChangeCandidates: candidateChangeEmitter.event, + dispose: () => { }, + } as unknown as AppHostDiscoveryService; + const repository = new AppHostDataRepository(terminalProvider, discoveryService); + + repository.activate(); + repository.setPanelVisible(true); + await waitForCondition(() => repository.workspaceAppHostPath === selectedAppHostPath, 'expected selected workspace AppHost'); + const psCall = spawnStub.getCalls().find(call => { + const args = call.args[2] as string[]; + return args[0] === 'ps' && args.includes('--follow'); + }); + assert.ok(psCall, 'expected an aspire ps --follow watch to be running'); + psCall.args[3].lineCallback(JSON.stringify([{ + appHostPath: selectedAppHostPath, + appHostPid: 1, + }, { + appHostPath: peerAppHostPath, + appHostPid: 2, + }])); + await waitForCondition(() => spawnStub.getCalls().filter(call => (call.args[2] as string[])[0] === 'describe').length === 2, + 'expected a describe stream for each AppHost'); + + const selectedDescribe = spawnStub.getCalls().find(call => { + const args = call.args[2] as string[]; + return args[0] === 'describe' && args.at(-1) === selectedAppHostPath; + }); + const peerDescribe = spawnStub.getCalls().find(call => { + const args = call.args[2] as string[]; + return args[0] === 'describe' && args.at(-1) === peerAppHostPath; + }); + assert.ok(selectedDescribe); + assert.ok(peerDescribe); + assert.strictEqual(selectedDescribe.args[1], '/cli/selected/aspire'); + assert.strictEqual(peerDescribe.args[1], '/cli/peer/aspire'); + + return { + repository, + selectedDescribe, + peerDescribe, + dispose: () => { + repository.dispose(); + candidateChangeEmitter.dispose(); + getWorkspaceFolderStub.restore(); + workspaceFoldersStub.restore(); + }, + }; + } + test('activate does not start describe while panel is hidden', async () => { const repository = new AppHostDataRepository(terminalProvider); @@ -1000,6 +1090,104 @@ suite('AppHostDataRepository', () => { repository.dispose(); }); + test('describe resolves the CLI and capabilities from the AppHost URI owner', async () => { + const folder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }; + const target = workspaceFolderCliPathTarget(folder); + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder') + .callsFake(uri => uri.fsPath.startsWith(folder.uri.fsPath) ? folder : undefined); + getCliPathStub.callsFake(async resolutionTarget => resolutionTarget?.kind === 'workspaceFolder' + ? '/workspace/bin/aspire' + : '/global/bin/aspire'); + + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + repository.setAppHostFilesOpen(['/workspace/AppHost.csproj']); + await waitForMicrotasks(); + const describeCall = await startDescribeForRunningAppHost('/workspace/AppHost.csproj'); + + assert.strictEqual(getCliPathStub.calledWithExactly(target), true); + assert.strictEqual(describeCall.args[1], '/workspace/bin/aspire'); + assert.strictEqual(getConfigInfoStub.calledWithMatch({ + suppressErrors: true, + cliPath: '/workspace/bin/aspire', + target, + }), true); + } finally { + repository.dispose(); + getWorkspaceFolderStub.restore(); + } + }); + + test('describe capabilities are isolated by each AppHost concrete CLI', async () => { + const folderA: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace/a'), + name: 'a', + index: 0, + }; + const folderB: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace/b'), + name: 'b', + index: 1, + }; + const appHostA = path.join(folderA.uri.fsPath, 'AppHost.csproj'); + const appHostB = path.join(folderB.uri.fsPath, 'AppHost.csproj'); + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => { + if (uri.fsPath.startsWith(folderA.uri.fsPath)) { + return folderA; + } + if (uri.fsPath.startsWith(folderB.uri.fsPath)) { + return folderB; + } + return undefined; + }); + getCliPathStub.callsFake(async target => target?.kind === 'workspaceFolder' + ? `/cli/${target.workspaceFolder.name}/aspire` + : '/cli/global/aspire'); + getConfigInfoStub.callsFake(async options => ({ + capabilities: options?.cliPath === '/cli/a/aspire' + ? [describeIncludeDisabledCommandsCapability] + : [], + } as any)); + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setViewMode('global'); + repository.setPanelVisible(true); + await waitForMicrotasks(); + const psCall = spawnStub.getCalls().find(call => { + const args = call.args[2] as string[]; + return args[0] === 'ps' && args.includes('--follow'); + }); + assert.ok(psCall, 'expected an aspire ps --follow watch to be running'); + + psCall.args[3].lineCallback(JSON.stringify({ appHostPath: appHostA, appHostPid: 1 })); + psCall.args[3].lineCallback(JSON.stringify({ appHostPath: appHostB, appHostPid: 2 })); + await waitForCondition(() => spawnStub.getCalls().filter(call => (call.args[2] as string[])[0] === 'describe').length === 2, + 'expected a describe stream for each AppHost'); + + const describeCalls = spawnStub.getCalls().filter(call => (call.args[2] as string[])[0] === 'describe'); + const describeA = describeCalls.find(call => (call.args[2] as string[]).includes(appHostA)); + const describeB = describeCalls.find(call => (call.args[2] as string[]).includes(appHostB)); + assert.ok(describeA); + assert.ok(describeB); + assert.strictEqual(describeA.args[1], '/cli/a/aspire'); + assert.strictEqual((describeA.args[2] as string[]).includes('--include-disabled-commands'), true); + assert.strictEqual(describeB.args[1], '/cli/b/aspire'); + assert.strictEqual((describeB.args[2] as string[]).includes('--include-disabled-commands'), false); + } finally { + repository.dispose(); + getWorkspaceFolderStub.restore(); + } + }); + test('describe omits disabled command flag when CLI does not advertise the capability', async () => { // A CLI that responds to `config info` but doesn't list the capability is authoritative: // we must not pass the flag at all (no optimistic attempt, no error-text parsing). @@ -1166,7 +1354,18 @@ suite('AppHostDataRepository', () => { } }); - test('fetchAppHostsOnce uses ps without resources and describes each AppHost', async () => { + test('fetchAppHostsOnce uses window CLI for ps and the AppHost owner CLI for describe', async () => { + const folder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }; + const target = workspaceFolderCliPathTarget(folder); + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder') + .callsFake(uri => uri.fsPath.startsWith(folder.uri.fsPath) ? folder : undefined); + getCliPathStub.callsFake(async resolutionTarget => resolutionTarget?.kind === 'workspaceFolder' + ? '/workspace/bin/aspire' + : '/global/bin/aspire'); const psProcess = new TestChildProcess(); const describeProcess = new TestChildProcess(); spawnStub.onFirstCall().returns(psProcess); @@ -1178,6 +1377,8 @@ suite('AppHostDataRepository', () => { await waitForMicrotasks(); assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--format', 'json', '--nologo']); + assert.strictEqual(spawnStub.firstCall.args[1], '/global/bin/aspire'); + assert.deepStrictEqual(getCliPathStub.firstCall.args, [windowCliPathTarget]); assert.strictEqual(spawnStub.firstCall.args[3].noExtensionVariables, true); spawnStub.firstCall.args[3].stdoutCallback(JSON.stringify([{ @@ -1192,6 +1393,8 @@ suite('AppHostDataRepository', () => { await waitForMicrotasks(); assert.deepStrictEqual(spawnStub.secondCall.args[2], ['describe', '--format', 'json', '--nologo', '--apphost', '/workspace/AppHost.csproj']); + assert.strictEqual(spawnStub.secondCall.args[1], '/workspace/bin/aspire'); + assert.deepStrictEqual(getCliPathStub.secondCall.args, [target]); assert.strictEqual(spawnStub.secondCall.args[3].noExtensionVariables, true); spawnStub.secondCall.args[3].stdoutCallback(JSON.stringify({ @@ -1219,6 +1422,7 @@ suite('AppHostDataRepository', () => { assert.strictEqual(appHosts[0].resources?.[0].name, 'api'); } finally { repository.dispose(); + getWorkspaceFolderStub.restore(); } }); @@ -1314,7 +1518,18 @@ suite('AppHostDataRepository', () => { } }); - test('runResourceCommand uses one-shot CLI runner', async () => { + test('runResourceCommand uses the trimmed AppHost owner CLI', async () => { + const folder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }; + const target = workspaceFolderCliPathTarget(folder); + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder') + .callsFake(uri => uri.fsPath.startsWith(folder.uri.fsPath) ? folder : undefined); + getCliPathStub.callsFake(async resolutionTarget => resolutionTarget?.kind === 'workspaceFolder' + ? '/workspace/bin/aspire' + : '/global/bin/aspire'); const resourceProcess = new TestChildProcess(); spawnStub.returns(resourceProcess); const repository = new AppHostDataRepository(terminalProvider); @@ -1324,6 +1539,8 @@ suite('AppHostDataRepository', () => { await waitForMicrotasks(); assert.deepStrictEqual(spawnStub.firstCall.args[2], ['resource', 'api', 'stop', '--non-interactive', '--apphost', '/workspace/AppHost.csproj']); + assert.strictEqual(spawnStub.firstCall.args[1], '/workspace/bin/aspire'); + assert.deepStrictEqual(getCliPathStub.firstCall.args, [target]); assert.strictEqual(spawnStub.firstCall.args[3].noExtensionVariables, true); assert.deepStrictEqual(spawnStub.firstCall.args[3].env, [{ name: 'ASPIRE_NON_INTERACTIVE', value: 'true' }]); @@ -1333,6 +1550,7 @@ suite('AppHostDataRepository', () => { await runPromise; } finally { repository.dispose(); + getWorkspaceFolderStub.restore(); } }); @@ -1398,6 +1616,7 @@ suite('AppHostDataRepository', () => { await waitForMicrotasks(); assert.deepStrictEqual(spawnStub.firstCall.args[2], ['resource', 'cache', 'echo-arguments', '--non-interactive', '--', '--message=hello']); + assert.deepStrictEqual(getCliPathStub.firstCall.args, [windowCliPathTarget]); spawnStub.firstCall.args[3].stdoutCallback('rendered command value'); spawnStub.firstCall.args[3].stderrCallback('status: ok'); @@ -1670,9 +1889,9 @@ suite('AppHostDataRepository', () => { } }); - test('describe optimistically sends disabled command flag when capabilities cannot be read', async () => { - // If `config info` can't be read (e.g. a CLI too old to support it) we keep the optimistic - // default so newer-but-unprobeable CLIs still get the flag; the no-data fallback protects us. + test('describe omits disabled command flag when capabilities cannot be read', async () => { + // Only an advertised capability enables the hidden flag. A missing config-info response + // cannot establish support for the concrete CLI used by this stream. getConfigInfoStub.resolves(null); const repository = new AppHostDataRepository(terminalProvider); @@ -1698,7 +1917,7 @@ suite('AppHostDataRepository', () => { describeCall = spawnStub.getCalls().find(call => (call.args[2] as string[])[0] === 'describe'); return describeCall !== undefined; }, 'expected a describe stream to start'); - assert.deepStrictEqual(describeCall!.args[2], ['describe', '--follow', '--format', 'json', '--nologo', '--include-disabled-commands', '--apphost', '/workspace/AppHost.csproj']); + assert.deepStrictEqual(describeCall!.args[2], ['describe', '--follow', '--format', 'json', '--nologo', '--apphost', '/workspace/AppHost.csproj']); repository.dispose(); }); @@ -1787,7 +2006,22 @@ suite('AppHostDataRepository', () => { test('describe reports minimum CLI version when command help is returned', async () => { const executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); - const repository = new AppHostDataRepository(terminalProvider); + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + const discoveryService = { + discover: async () => [{ + path: '/workspace/AppHost.csproj', + language: 'csharp' as const, + status: 'buildable' as const, + selected: true, + }], + onDidChangeCandidates: () => ({ dispose: () => { } }), + dispose: () => { }, + } as unknown as AppHostDiscoveryService; + const repository = new AppHostDataRepository(terminalProvider, discoveryService); try { repository.activate(); @@ -1833,12 +2067,28 @@ suite('AppHostDataRepository', () => { } finally { repository.dispose(); executeCommandStub.restore(); + workspaceFoldersStub.restore(); } }); test('describe reports minimum CLI version when localized command help is returned', async () => { const executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); - const repository = new AppHostDataRepository(terminalProvider); + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + const discoveryService = { + discover: async () => [{ + path: '/workspace/AppHost.csproj', + language: 'csharp' as const, + status: 'buildable' as const, + selected: true, + }], + onDidChangeCandidates: () => ({ dispose: () => { } }), + dispose: () => { }, + } as unknown as AppHostDiscoveryService; + const repository = new AppHostDataRepository(terminalProvider, discoveryService); try { repository.activate(); @@ -1884,6 +2134,7 @@ suite('AppHostDataRepository', () => { } finally { repository.dispose(); executeCommandStub.restore(); + workspaceFoldersStub.restore(); } }); @@ -2480,6 +2731,38 @@ suite('AppHostDataRepository', () => { } }); + test('non-selected older CLI does not publish a compatibility error for the selected AppHost', async () => { + const context = await startTwoFolderDescribeStreams(); + + try { + context.peerDescribe.args[3].stderrCallback("Unrecognized command or argument 'describe'."); + (context.peerDescribe.returnValue as TestChildProcess).markExited(1); + context.peerDescribe.args[3].exitCallback(1); + + assert.strictEqual(context.repository.errorMessage, undefined); + } finally { + context.dispose(); + } + }); + + test('working peer CLI does not clear the selected AppHost compatibility error', async () => { + const context = await startTwoFolderDescribeStreams(); + + try { + context.selectedDescribe.args[3].stderrCallback("Unrecognized command or argument 'describe'."); + (context.selectedDescribe.returnValue as TestChildProcess).markExited(1); + context.selectedDescribe.args[3].exitCallback(1); + const selectedError = context.repository.errorMessage; + assert.ok(selectedError?.includes('13.2.0'), selectedError); + + context.peerDescribe.args[3].lineCallback(JSON.stringify({ name: 'peer-resource' })); + + assert.strictEqual(context.repository.errorMessage, selectedError); + } finally { + context.dispose(); + } + }); + test('ps cli-path failures surface a single fetch prefix in error message', async () => { const spawned: { args: string[]; options: any }[] = []; spawnStub.callsFake((_terminalProvider, _cliPath, args, options) => { @@ -3361,7 +3644,7 @@ suite('AppHostDataRepository', () => { } finally { repository?.dispose(); workspaceFoldersStub?.restore(); - fs.rmSync(workspaceRoot, { recursive: true, force: true }); + removeDirectorySafely(workspaceRoot); } }); @@ -5725,6 +6008,7 @@ suite('AppHostDataRepository', () => { }); test('stubborn describe is force killed', async () => { + const platformStub = sinon.stub(process, 'platform').value('linux'); const workspaceFoldersStub = stubWorkspaceFolders([{ uri: vscode.Uri.file('/workspace'), name: 'workspace', @@ -5750,6 +6034,7 @@ suite('AppHostDataRepository', () => { repository.dispose(); clock.restore(); workspaceFoldersStub.restore(); + platformStub.restore(); } }); @@ -5767,16 +6052,19 @@ suite('AppHostDataRepository', () => { spawnStub.callsFake((_terminalProvider, _command, args) => args[0] === 'describe' ? childProcess : new TestChildProcess()); const taskkillCalls: Array<{ command: string; args: string[]; windowsHide: boolean | undefined }> = []; + const taskkillProcesses: EventEmitter[] = []; spawnProcessStub.callsFake((command: string, args?: readonly string[], options?: nodeChildProcess.SpawnOptions) => { + const taskkillProcess = Object.assign(new EventEmitter(), { + unref: () => { }, + }); taskkillCalls.push({ command, args: [...(args ?? [])], windowsHide: options?.windowsHide, }); + taskkillProcesses.push(taskkillProcess); - return Object.assign(new EventEmitter(), { - unref: () => { }, - }) as nodeChildProcess.ChildProcess; + return taskkillProcess as nodeChildProcess.ChildProcess; }); const repository = new AppHostDataRepository(terminalProvider); @@ -5792,9 +6080,15 @@ suite('AppHostDataRepository', () => { args: ['/pid', '4242', '/t'], windowsHide: true, }]); + assert.strictEqual(taskkillProcesses.length, 1); assert.deepStrictEqual(childProcess.killSignals, []); - await clock.tickAsync(5000); + taskkillProcesses[0].emit('close', 0); + await waitForMicrotasks(); + + // The graceful and forced attempts share one five-second deadline. Escalation begins + // after four seconds, reserving the final second for forced taskkill. + await clock.tickAsync(4000); assert.deepStrictEqual(taskkillCalls, [ { @@ -5808,6 +6102,13 @@ suite('AppHostDataRepository', () => { windowsHide: true, }, ]); + assert.strictEqual(taskkillProcesses.length, 2); + assert.deepStrictEqual(childProcess.killSignals, []); + + taskkillProcesses[1].emit('close', 0); + childProcess.markExited(0); + childProcess.emit('close', 0); + await waitForMicrotasks(); } finally { repository.dispose(); clock.restore(); @@ -5893,6 +6194,25 @@ suite('AppHostDataRepository global polling', () => { repository.dispose(); }); + test('global ps follow and one-shot refresh resolve with the window target', async () => { + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setViewMode('global'); + repository.setPanelVisible(true); + await waitForCondition(() => getCliPathStub.callCount >= 1, 'global ps follow did not resolve the CLI'); + + repository.refresh(); + await waitForCondition(() => getCliPathStub.callCount >= 2, 'global one-shot ps did not resolve the CLI'); + + assert.deepStrictEqual(getCliPathStub.firstCall.args, [windowCliPathTarget]); + assert.deepStrictEqual(getCliPathStub.secondCall.args, [windowCliPathTarget]); + } finally { + repository.dispose(); + } + }); + test('switching into global view while ps is already polling clears the loading spinner', async () => { const repository = new AppHostDataRepository(terminalProvider); diff --git a/extension/src/test/appHostDiscovery.test.ts b/extension/src/test/appHostDiscovery.test.ts index de4a88778c5..1edfa82690d 100644 --- a/extension/src/test/appHostDiscovery.test.ts +++ b/extension/src/test/appHostDiscovery.test.ts @@ -14,7 +14,9 @@ import * as configInfoProvider from '../utils/configInfoProvider'; import { lsJsonStreamCapability } from '../types/configInfo'; import { __resetCommonPropertiesForTests, __setReporterForTests } from '../utils/telemetry'; import { appHostDiscoveryFindFilesMaxResults } from '../utils/workspaceFileSearch'; +import { workspaceFolderCliPathTarget, getCliPathTargetKey } from '../utils/cliPathVariables'; +import { removeDirectorySafely } from './testHelpers'; interface RecordedEvent { name: string; properties?: Record; @@ -472,6 +474,8 @@ suite('AppHost discovery', () => { assert.ok(watchedPatterns.includes('**/apphost.mjs')); assert.ok(watchedPatterns.includes('**/apphost.cjs')); assert.ok(watchedPatterns.includes('**/apphost.rs')); + // Case matters: watcher globs are case-sensitive on Linux and the file is AppHost.java. + assert.ok(watchedPatterns.includes('**/AppHost.java')); } finally { service.dispose(); @@ -1490,9 +1494,9 @@ suite('AppHost discovery', () => { { cliPath: 'old-aspire', args: ['ls', '--format', 'json', '--nologo'] }, { cliPath: 'new-aspire', args: ['ls', '--format', 'json', '--stream', '--nologo'] }, ]); - assert.deepStrictEqual(getConfigInfoStub.getCalls().map(call => call.args[0]), [ - { suppressErrors: true, forceRefresh: false, cliPath: 'old-aspire' }, - { suppressErrors: true, forceRefresh: false, cliPath: 'new-aspire' }, + assert.deepStrictEqual(getConfigInfoStub.getCalls().map(call => ({ ...call.args[0], target: getCliPathTargetKey(call.args[0].target) })), [ + { suppressErrors: true, forceRefresh: false, cliPath: 'old-aspire', target: getCliPathTargetKey(workspaceFolderCliPathTarget(makeWorkspaceFolder(buildPath('workspace-one')))) }, + { suppressErrors: true, forceRefresh: false, cliPath: 'new-aspire', target: getCliPathTargetKey(workspaceFolderCliPathTarget(makeWorkspaceFolder(buildPath('workspace-two')))) }, ]); } finally { @@ -1527,8 +1531,8 @@ suite('AppHost discovery', () => { ['ls', '--format', 'json', '--stream', '--nologo'], ]); assert.deepStrictEqual(getConfigInfoStub.getCalls().map(call => call.args[0]), [ - { suppressErrors: true, forceRefresh: false, cliPath: 'aspire' }, - { suppressErrors: true, forceRefresh: true, cliPath: 'aspire' }, + { suppressErrors: true, forceRefresh: false, cliPath: 'aspire', target: workspaceFolderCliPathTarget(workspaceFolder) }, + { suppressErrors: true, forceRefresh: true, cliPath: 'aspire', target: workspaceFolderCliPathTarget(workspaceFolder) }, ]); } finally { @@ -1913,7 +1917,7 @@ suite('AppHost discovery', () => { } } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2049,7 +2053,7 @@ suite('AppHost discovery', () => { } } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2090,7 +2094,7 @@ suite('AppHost discovery', () => { } } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2154,7 +2158,7 @@ suite('AppHost discovery', () => { } } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2225,7 +2229,7 @@ suite('AppHost discovery', () => { } } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2269,7 +2273,7 @@ suite('AppHost discovery', () => { } } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2319,7 +2323,7 @@ suite('AppHost discovery', () => { } } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2461,7 +2465,7 @@ suite('AppHost discovery', () => { } } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2524,7 +2528,7 @@ suite('AppHost discovery', () => { } } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2581,7 +2585,7 @@ suite('AppHost discovery', () => { } } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2614,7 +2618,7 @@ suite('AppHost discovery', () => { assert.strictEqual(selectedPath, matchingAppHostPath); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2644,7 +2648,7 @@ suite('AppHost discovery', () => { assert.strictEqual(selectedPath, undefined); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2672,6 +2676,49 @@ suite('AppHost discovery', () => { ]); }); }); + + suite('workspace-folder CLI resolution target', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + stubFileSystemWatchers(sandbox); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('resolves the CLI path and capability probe with the workspace folder target', async () => { + const workspaceFolder = makeWorkspaceFolder(buildPath('workspace')); + const getAspireCliExecutablePathStub = sandbox.stub().resolves('/repo/a/bin/aspire'); + const terminalProvider = { + getAspireCliExecutablePath: getAspireCliExecutablePathStub, + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const getConfigInfoStub = sandbox.stub().resolves({ capabilities: [] }); + const fakeConfigInfoProvider = { getConfigInfo: getConfigInfoStub } as unknown as configInfoProvider.ConfigInfoProvider; + sandbox.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.(JSON.stringify([])); + options?.exitCallback?.(0); + return { kill: () => true } as any; + }); + const service = new AppHostDiscoveryService(terminalProvider, fakeConfigInfoProvider); + + try { + await service.discover(workspaceFolder); + + assert.ok(getAspireCliExecutablePathStub.calledOnceWith(workspaceFolderCliPathTarget(workspaceFolder))); + assert.strictEqual(getConfigInfoStub.calledOnce, true); + const configInfoOptions = getConfigInfoStub.firstCall.args[0]; + assert.strictEqual(configInfoOptions.cliPath, '/repo/a/bin/aspire'); + assert.deepStrictEqual(configInfoOptions.target, workspaceFolderCliPathTarget(workspaceFolder)); + } + finally { + service.dispose(); + } + }); + }); }); function buildPath(...segments: string[]): string { diff --git a/extension/src/test/appHostLanguage.test.ts b/extension/src/test/appHostLanguage.test.ts index bbc9e6acc13..03b67f6fa95 100644 --- a/extension/src/test/appHostLanguage.test.ts +++ b/extension/src/test/appHostLanguage.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { summarizeAppHostLanguages, classifyAppHostPath, classifyAppHostDirectory } from '../utils/appHostLanguage'; import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; +import { removeDirectorySafely } from './testHelpers'; function c(language: string | null): CandidateAppHostDisplayInfo { return { path: '/x', language, status: 'buildable' }; } @@ -27,6 +28,10 @@ suite('appHostLanguage.summarizeAppHostLanguages', () => { assert.strictEqual(summarizeAppHostLanguages([c('javascript')]), 'typescript'); }); + test('returns java when every candidate is Java', () => { + assert.strictEqual(summarizeAppHostLanguages([c('java'), c('Java'), c('java/maven')]), 'java'); + }); + test('returns rust when every candidate is Rust', () => { assert.strictEqual(summarizeAppHostLanguages([c('rust'), c('Rust'), c('rust/cargo')]), 'rust'); }); @@ -38,6 +43,7 @@ suite('appHostLanguage.summarizeAppHostLanguages', () => { test('returns polyglot when Rust is mixed with another known language', () => { assert.strictEqual(summarizeAppHostLanguages([c('csharp'), c('rust')]), 'polyglot'); assert.strictEqual(summarizeAppHostLanguages([c('typescript'), c('rust')]), 'polyglot'); + assert.strictEqual(summarizeAppHostLanguages([c('java'), c('rust')]), 'polyglot'); }); test('returns polyglot when an unknown language is mixed with a known one', () => { @@ -94,6 +100,7 @@ suite('appHostLanguage.classifyAppHostPath', () => { assert.strictEqual(classifyAppHostPath('APPHOST.CSPROJ'), 'csharp'); assert.strictEqual(classifyAppHostPath('AppHost.TS'), 'typescript'); assert.strictEqual(classifyAppHostPath('AppHost.RS'), 'rust'); + assert.strictEqual(classifyAppHostPath('/abs/path/AppHost.java'), 'java'); }); }); @@ -114,7 +121,7 @@ suite('appHostLanguage.classifyAppHostDirectory', () => { teardown(() => { for (const dir of tempDirs) { if (existsSync(dir)) { - rmSync(dir, { recursive: true, force: true }); + removeDirectorySafely(dir); } } tempDirs.length = 0; @@ -122,7 +129,7 @@ suite('appHostLanguage.classifyAppHostDirectory', () => { suiteTeardown(() => { if (existsSync(tempParent)) { - rmSync(tempParent, { recursive: true, force: true }); + removeDirectorySafely(tempParent); } }); @@ -164,6 +171,36 @@ suite('appHostLanguage.classifyAppHostDirectory', () => { assert.strictEqual(await classifyAppHostDirectory(dir), 'rust'); }); + test('classifies directory containing an AppHost.java as java', async () => { + const dir = makeTempDir(); + writeFileSync(join(dir, 'AppHost.java'), 'class AppHost {}'); + assert.strictEqual(await classifyAppHostDirectory(dir), 'java'); + }); + + test('classifies a Maven AppHost that keeps its source under src/main/java as java', async () => { + const dir = makeTempDir(); + writeFileSync(join(dir, 'pom.xml'), ''); + mkdirSync(join(dir, 'src', 'main', 'java'), { recursive: true }); + writeFileSync(join(dir, 'src', 'main', 'java', 'AppHost.java'), 'class AppHost {}'); + assert.strictEqual(await classifyAppHostDirectory(dir), 'java'); + }); + + test('classifies a Gradle AppHost that keeps its source under src/main/java as java', async () => { + const dir = makeTempDir(); + writeFileSync(join(dir, 'build.gradle.kts'), ''); + mkdirSync(join(dir, 'src', 'main', 'java'), { recursive: true }); + writeFileSync(join(dir, 'src', 'main', 'java', 'AppHost.java'), 'class AppHost {}'); + assert.strictEqual(await classifyAppHostDirectory(dir), 'java'); + }); + + test('does not classify an ordinary Maven project without an AppHost as java', async () => { + const dir = makeTempDir(); + writeFileSync(join(dir, 'pom.xml'), ''); + mkdirSync(join(dir, 'src', 'main', 'java'), { recursive: true }); + writeFileSync(join(dir, 'src', 'main', 'java', 'Application.java'), 'class Application {}'); + assert.strictEqual(await classifyAppHostDirectory(dir), 'unknown'); + }); + test('returns unknown for a directory with no recognized AppHost markers', async () => { const dir = makeTempDir(); writeFileSync(join(dir, 'README.md'), ''); diff --git a/extension/src/test/appHostLaunchService.test.ts b/extension/src/test/appHostLaunchService.test.ts index ffc9fa248bd..d0112a5c3b5 100644 --- a/extension/src/test/appHostLaunchService.test.ts +++ b/extension/src/test/appHostLaunchService.test.ts @@ -7,11 +7,15 @@ import * as vscode from 'vscode'; import { AspireExtendedDebugConfiguration, type AspireResourceDebugSession } from '../dcp/types'; import { appHostLaunchReservationIdConfigKey, appHostTelemetryTargetPathConfigKey } from '../debugger/AspireDebugConfigurationMetadata'; import { isAspireDebugConfigurationExtensionOwned } from '../debugger/AspireDebugConfigurationProviderInternal'; +import * as locStrings from '../loc/strings'; import { appHostLifecycleBusy } from '../loc/strings'; -import { AppHostLaunchService, AppHostLifecycleLockTimeoutError, AppHostStopCancellationError, appHostLifecycleLockMaxHoldMs, appHostLifecycleLockWaitTimeoutMs, externalLaunchReservationTimeoutMs, type AppHostLaunchSession } from '../services/AppHostLaunchService'; +import { AppHostLaunchService, AppHostLifecycleLockTimeoutError, AppHostStopCancellationError, appHostLifecycleLockMaxHoldMs, appHostLifecycleLockWaitTimeoutMs, externalLaunchReservationTimeoutMs, type AppHostLaunchCapabilityProvider, type AppHostLaunchSession } from '../services/AppHostLaunchService'; import { getAppHostIdentityKey } from '../utils/appHostIdentity'; import * as cliPathModule from '../utils/cliPath'; +import { isolatedLaunchCapability, type CapabilityStatus } from '../types/configInfo'; +import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { __resetCommonPropertiesForTests, __setReporterForTests } from '../utils/telemetry'; +import { writeLinkedWorktreeMetadata } from './testGitWorktree'; interface RecordedEvent { name: string; @@ -54,6 +58,9 @@ function createAppHostDirectory(...entries: readonly string[]): string { const fixtureRoot = path.resolve(__dirname, '..', '..', '.test-workspace', 'launch-service'); const directory = path.join(fixtureRoot, `apphost-${crypto.randomBytes(6).toString('hex')}`); fs.mkdirSync(directory, { recursive: true }); + // Stop the ancestor walk so a checkout that is itself a linked worktree + // does not make every launch() infer --isolated. + fs.mkdirSync(path.join(directory, '.git')); for (const entry of entries) { fs.writeFileSync(path.join(directory, entry), ''); } @@ -61,8 +68,44 @@ function createAppHostDirectory(...entries: readonly string[]): string { return directory; } +class FakeCapabilityProvider implements AppHostLaunchCapabilityProvider { + readonly calls: Array<{ + capability: string; + options: { suppressErrors?: boolean; forceRefresh?: boolean; cliPath?: string; cancellationToken?: vscode.CancellationToken; minimumVersion?: string; target?: import('../utils/cliPathVariables').CliPathResolutionTarget } | undefined; + }> = []; + capabilityStatus: CapabilityStatus = 'supported'; + + async getCapabilityStatus( + capability: string, + options?: { suppressErrors?: boolean; forceRefresh?: boolean; cliPath?: string; cancellationToken?: vscode.CancellationToken; minimumVersion?: string; target?: import('../utils/cliPathVariables').CliPathResolutionTarget }, + ): Promise { + this.calls.push({ capability, options }); + return capability === isolatedLaunchCapability ? this.capabilityStatus : 'unsupported'; + } +} + +interface LaunchArgumentPreparer { + prepareLaunchArguments( + appHostPath: string, + command: 'run' | 'deploy' | 'publish' | 'do', + args: string[] | undefined, + token: vscode.CancellationToken, + cliPath?: string, + target?: import('../utils/cliPathVariables').CliPathResolutionTarget, + isolated?: boolean, + isolationPolicy?: 'explicit-only' | 'linked-worktree-default', + ): Promise<{ + args: string[] | undefined; + isolation: { + effective: boolean; + option: boolean | undefined; + }; + }>; +} + suite('AppHostLaunchService', () => { let service: AppHostLaunchService; + let capabilityProvider: FakeCapabilityProvider; let startDebuggingStub: sinon.SinonStub; let stopDebuggingStub: sinon.SinonStub; let resolveCliPathStub: sinon.SinonStub; @@ -80,10 +123,11 @@ suite('AppHostLaunchService', () => { onDidTerminateDebugSessionCallback = callback; return new vscode.Disposable(() => { }); }); - service = new AppHostLaunchService(); + capabilityProvider = new FakeCapabilityProvider(); + service = new AppHostLaunchService(capabilityProvider); startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); stopDebuggingStub = sinon.stub(vscode.debug, 'stopDebugging').resolves(); - resolveCliPathStub = sinon.stub(cliPathModule, 'resolveCliPath').resolves({ cliPath: 'aspire', available: true, source: 'path' }); + resolveCliPathStub = sinon.stub(cliPathModule, 'resolveCliPath').resolves({ cliPath: '/path/bin/aspire', available: true, source: 'path' }); }); teardown(() => { @@ -128,17 +172,396 @@ suite('AppHostLaunchService', () => { assert.strictEqual(config.noDebug, false); assert.strictEqual(config.step, undefined); assert.strictEqual(config.skipCliAvailabilityCheck, true); + assert.strictEqual(config.resolvedCliPath, '/path/bin/aspire'); assert.strictEqual(config.__aspireAppHostSelectionOrigin, 'user-selection'); }); + test('suppressed lifecycle launch does not report verified isolation', async () => { + const environmentVariables = [ + 'ASPIRE_EXTENSION_E2E_ENABLE_BRIDGE', + 'ASPIRE_EXTENSION_E2E_STATE_FILE', + 'ASPIRE_EXTENSION_E2E_CONTROL_FILE', + 'ASPIRE_EXTENSION_E2E_SUPPRESS_DEBUG_LAUNCH', + ] as const; + const originalValues = new Map(environmentVariables.map(name => [name, process.env[name]])); + + try { + process.env.ASPIRE_EXTENSION_E2E_ENABLE_BRIDGE = 'true'; + process.env.ASPIRE_EXTENSION_E2E_STATE_FILE = 'state.json'; + process.env.ASPIRE_EXTENSION_E2E_CONTROL_FILE = 'control.json'; + process.env.ASPIRE_EXTENSION_E2E_SUPPRESS_DEBUG_LAUNCH = 'true'; + + const isolation = await service.launchFromLifecycleOwner( + '/repo/AppHost.csproj', + 'run', + true, + true, + new vscode.CancellationTokenSource().token); + + assert.strictEqual(isolation, undefined); + assert.strictEqual(startDebuggingStub.called, false); + } + finally { + for (const [name, value] of originalValues) { + if (value === undefined) { + delete process.env[name]; + } + else { + process.env[name] = value; + } + } + } + }); + + test('launch reuses an already-verified CLI path', async () => { + const folder = { name: 'a', index: 0, uri: vscode.Uri.file('/repo') } as vscode.WorkspaceFolder; + const target = workspaceFolderCliPathTarget(folder); + + await service.launch('/repo/AppHost.csproj', 'do', false, undefined, target, '/repo/bin/aspire'); + + const config = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; + assert.strictEqual(resolveCliPathStub.called, false); + assert.strictEqual(config.resolvedCliPath, '/repo/bin/aspire'); + assert.strictEqual(config.skipCliAvailabilityCheck, true); + }); + test('lifecycle-owned launch does not replace an existing workspace default', async () => { const appHostPath = '/repo/AppHost.csproj'; assert.strictEqual(service.tryReserveLaunch(appHostPath), true); - await service.launchFromLifecycleOwner(appHostPath, 'run', true, new vscode.CancellationTokenSource().token); + await service.launchFromLifecycleOwner(appHostPath, 'run', true, undefined, new vscode.CancellationTokenSource().token); + + const config = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; + assert.strictEqual(config.__aspireAppHostSelectionOrigin, 'explicit-launch-configuration'); + assert.strictEqual(config.args, undefined); + }); + + test('lifecycle-owned launch forwards explicit isolation false', async () => { + const appHostPath = '/repo/AppHost.csproj'; + assert.strictEqual(service.tryReserveLaunch(appHostPath), true); + + await service.launchFromLifecycleOwner(appHostPath, 'run', true, false, new vscode.CancellationTokenSource().token); const config = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; assert.strictEqual(config.__aspireAppHostSelectionOrigin, 'explicit-launch-configuration'); + assert.deepStrictEqual(config.args, ['--isolated', 'false']); + }); + + test('lifecycle-owned launch forwards --isolated', async () => { + const appHostPath = '/repo/AppHost.csproj'; + assert.strictEqual(service.tryReserveLaunch(appHostPath), true); + + await service.launchFromLifecycleOwner(appHostPath, 'run', true, true, new vscode.CancellationTokenSource().token); + + const config = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; + assert.deepStrictEqual(config.args, ['--isolated']); + }); + + test('prepareLaunchArguments does not infer root isolation when only app args specify isolated', async () => { + const directory = createAppHostDirectory('AppHost.csproj'); + fs.rmSync(path.join(directory, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(directory, path.join(directory, 'common', '.git')); + const appHostPath = path.join(directory, 'AppHost.csproj'); + const cancellation = new vscode.CancellationTokenSource(); + + const prepared = await (service as unknown as LaunchArgumentPreparer).prepareLaunchArguments( + appHostPath, + 'run', + ['--', '--isolated', 'false'], + cancellation.token, + '/path/bin/aspire'); + + const rootArguments = prepared.args?.[0] === '--' + ? undefined + : prepared.args?.slice(0, prepared.args?.indexOf('--')); + const appHostArguments = prepared.args?.[0] === '--' + ? prepared.args + : prepared.args?.slice(prepared.args.indexOf('--')); + + assert.strictEqual(rootArguments, undefined); + assert.deepStrictEqual(appHostArguments, ['--', '--isolated', 'false']); + assert.deepStrictEqual(prepared.isolation, { effective: false, option: undefined }); + assert.deepStrictEqual(capabilityProvider.calls, []); + }); + + test('prepareLaunchArguments preserves explicit root isolated false', async () => { + const directory = createAppHostDirectory('AppHost.csproj'); + fs.rmSync(path.join(directory, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(directory, path.join(directory, 'common', '.git')); + const appHostPath = path.join(directory, 'AppHost.csproj'); + + const prepared = await (service as unknown as LaunchArgumentPreparer).prepareLaunchArguments( + appHostPath, + 'run', + ['--isolated', 'false', '--', '--isolated'], + new vscode.CancellationTokenSource().token, + '/path/bin/aspire'); + + assert.deepStrictEqual(prepared, { + args: ['--isolated', 'false', '--', '--isolated'], + isolation: { effective: false, option: false }, + }); + }); + + test('older CLI external launch removes separate root isolation while preserving AppHost args', async () => { + capabilityProvider.capabilityStatus = 'unsupported'; + const prepared = await (service as unknown as LaunchArgumentPreparer).prepareLaunchArguments( + '/repo/AppHost.csproj', + 'run', + ['--verbose', '--isolated', 'false', '--', '--isolated', 'false'], + new vscode.CancellationTokenSource().token, + '/path/bin/aspire'); + + assert.deepStrictEqual(prepared, { + args: ['--verbose', '--', '--isolated', 'false'], + isolation: { effective: false, option: undefined }, + }); + }); + + test('older CLI external launch removes equals root isolation while preserving AppHost args', async () => { + capabilityProvider.capabilityStatus = 'unsupported'; + const prepared = await (service as unknown as LaunchArgumentPreparer).prepareLaunchArguments( + '/repo/AppHost.csproj', + 'run', + ['--isolated=false', '--', '--isolated=false'], + new vscode.CancellationTokenSource().token, + '/path/bin/aspire'); + + assert.deepStrictEqual(prepared, { + args: ['--', '--isolated=false'], + isolation: { effective: false, option: undefined }, + }); + }); + + test('non-authoritative isolation probes preserve explicit choices from stale capability data', async () => { + const directory = createAppHostDirectory('AppHost.csproj'); + fs.rmSync(path.join(directory, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(directory, path.join(directory, 'common', '.git')); + const appHostPath = path.join(directory, 'AppHost.csproj'); + + for (const capabilityStatus of ['unsupported', 'unavailable'] as const) { + capabilityProvider.capabilityStatus = capabilityStatus; + for (const isolated of [true, false]) { + const cancellation = new vscode.CancellationTokenSource(); + const isolation = await service.resolveLaunchIsolation(appHostPath, isolated, cancellation.token); + + assert.deepStrictEqual(isolation, { effective: isolated, option: isolated }); + assert.strictEqual(capabilityProvider.calls.at(-1)?.options?.forceRefresh, false); + assert.strictEqual(capabilityProvider.calls.at(-1)?.options?.cliPath, undefined); + assert.strictEqual(capabilityProvider.calls.at(-1)?.options?.cancellationToken, cancellation.token); + assert.strictEqual(capabilityProvider.calls.at(-1)?.options?.minimumVersion, '13.2.0'); + } + } + }); + + test('minimum CLI version fallback honors explicit and inferred isolation at launch time', async () => { + capabilityProvider.capabilityStatus = 'supported'; + const explicitTruePath = '/repo/ExplicitTrue.csproj'; + const explicitFalsePath = '/repo/ExplicitFalse.csproj'; + const directory = createAppHostDirectory('Inferred.csproj'); + fs.rmSync(path.join(directory, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(directory, path.join(directory, 'common', '.git')); + const inferredPath = path.join(directory, 'Inferred.csproj'); + assert.strictEqual(service.tryReserveLaunch(explicitTruePath), true); + assert.strictEqual(service.tryReserveLaunch(explicitFalsePath), true); + assert.strictEqual(service.tryReserveLaunch(inferredPath), true); + const inferredCancellation = new vscode.CancellationTokenSource(); + + const explicitTrue = await service.launchFromLifecycleOwner( + explicitTruePath, + 'run', + true, + true, + new vscode.CancellationTokenSource().token); + const explicitFalse = await service.launchFromLifecycleOwner( + explicitFalsePath, + 'run', + true, + false, + new vscode.CancellationTokenSource().token); + await service.launchFromLifecycleOwner(inferredPath, 'run', true, undefined, inferredCancellation.token); + + assert.deepStrictEqual(explicitTrue, { effective: true, option: true }); + assert.deepStrictEqual(explicitFalse, { effective: false, option: false }); + assert.deepStrictEqual( + startDebuggingStub.getCalls().map(call => (call.args[1] as AspireExtendedDebugConfiguration).args), + [['--isolated'], ['--isolated', 'false'], ['--isolated']]); + assert.deepStrictEqual(capabilityProvider.calls.map(call => ({ + capability: call.capability, + cliPath: call.options?.cliPath, + forceRefresh: call.options?.forceRefresh, + minimumVersion: call.options?.minimumVersion, + })), [ + { capability: isolatedLaunchCapability, cliPath: '/path/bin/aspire', forceRefresh: true, minimumVersion: '13.2.0' }, + { capability: isolatedLaunchCapability, cliPath: '/path/bin/aspire', forceRefresh: true, minimumVersion: '13.2.0' }, + { capability: isolatedLaunchCapability, cliPath: '/path/bin/aspire', forceRefresh: true, minimumVersion: '13.2.0' }, + ]); + }); + + test('lifecycle-owned launch downgrades inferred isolation for an older CLI', async () => { + capabilityProvider.capabilityStatus = 'unsupported'; + const directory = createAppHostDirectory('AppHost.csproj'); + fs.rmSync(path.join(directory, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(directory, path.join(directory, 'common', '.git')); + const appHostPath = path.join(directory, 'AppHost.csproj'); + const cancellation = new vscode.CancellationTokenSource(); + assert.strictEqual(service.tryReserveLaunch(appHostPath), true); + + const isolation = await service.launchFromLifecycleOwner(appHostPath, 'run', true, undefined, cancellation.token); + + const config = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; + assert.strictEqual(config.args, undefined); + assert.strictEqual(config.resolvedCliPath, '/path/bin/aspire'); + assert.deepStrictEqual(isolation, { effective: false, option: undefined }); + assert.deepStrictEqual(capabilityProvider.calls, [{ + capability: isolatedLaunchCapability, + options: { + suppressErrors: true, + forceRefresh: true, + cliPath: '/path/bin/aspire', + cancellationToken: cancellation.token, + minimumVersion: '13.2.0', + target: windowCliPathTarget, + }, + }]); + }); + + test('lifecycle-owned launch honors explicit isolation false for an older CLI', async () => { + capabilityProvider.capabilityStatus = 'unsupported'; + const directory = createAppHostDirectory('AppHost.csproj'); + fs.rmSync(path.join(directory, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(directory, path.join(directory, 'common', '.git')); + const appHostPath = path.join(directory, 'AppHost.csproj'); + assert.strictEqual(service.tryReserveLaunch(appHostPath), true); + + const isolation = await service.launchFromLifecycleOwner( + appHostPath, + 'run', + true, + false, + new vscode.CancellationTokenSource().token); + + const config = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; + assert.strictEqual(config.args, undefined); + assert.deepStrictEqual(isolation, { effective: false, option: undefined }); + }); + + test('lifecycle-owned launch rejects explicit isolation true for an older CLI', async () => { + capabilityProvider.capabilityStatus = 'unsupported'; + const appHostPath = '/repo/AppHost.csproj'; + assert.strictEqual(service.tryReserveLaunch(appHostPath), true); + const message = (locStrings as Record).appHostLifecycleIsolationModeNotSupported; + + assert.strictEqual(message, 'The selected Aspire CLI does not support the requested isolation mode.'); + + await assert.rejects( + service.launchFromLifecycleOwner( + appHostPath, + 'run', + true, + true, + new vscode.CancellationTokenSource().token), + (error: Error) => { + assert.strictEqual(error.message, message); + return true; + }); + + assert.strictEqual(startDebuggingStub.called, false); + }); + + test('lifecycle-owned launch fails safely when exact CLI support is unavailable', async () => { + capabilityProvider.capabilityStatus = 'unavailable'; + const directory = createAppHostDirectory('AppHost.csproj'); + fs.rmSync(path.join(directory, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(directory, path.join(directory, 'common', '.git')); + const appHostPath = path.join(directory, 'AppHost.csproj'); + const message = (locStrings as Record).appHostLifecycleIsolationCapabilityCouldNotBeVerified; + + assert.strictEqual(message, 'The selected Aspire CLI isolation capability could not be verified.'); + + for (const isolated of [true, false, undefined]) { + assert.strictEqual(service.tryReserveLaunch(appHostPath), true); + await assert.rejects( + service.launchFromLifecycleOwner( + appHostPath, + 'run', + true, + isolated, + new vscode.CancellationTokenSource().token), + (error: Error) => { + assert.strictEqual(error.message, message); + return true; + }); + } + + assert.strictEqual(startDebuggingStub.called, false); + }); + + test('launch omits inferred isolation for a primary checkout AppHost path', async () => { + const directory = createAppHostDirectory('AppHost.csproj', 'Program.cs'); + + await service.launch(path.join(directory, 'AppHost.csproj'), 'run', true); + + const config = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; + assert.strictEqual(config.args, undefined); + }); + + test('launch does not infer --isolated for run commands in a linked worktree', async () => { + const directory = createAppHostDirectory('Run.csproj', 'Deploy.csproj', 'Publish.csproj', 'Do.csproj'); + fs.rmSync(path.join(directory, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(directory, path.join(directory, 'common', '.git')); + + await service.launch(path.join(directory, 'Run.csproj'), 'run', true); + await service.launch(path.join(directory, 'Deploy.csproj'), 'deploy', true); + await service.launch(path.join(directory, 'Publish.csproj'), 'publish', true); + await service.launch(path.join(directory, 'Do.csproj'), 'do', true, 'deploy'); + + const configs = startDebuggingStub.getCalls() + .map(call => call.args[1] as AspireExtendedDebugConfiguration) + .map(config => ({ command: config.command, args: config.args, step: config.step })); + assert.deepStrictEqual(configs, [ + { command: 'run', args: undefined, step: undefined }, + { command: 'deploy', args: undefined, step: undefined }, + { command: 'publish', args: undefined, step: undefined }, + { command: 'do', args: undefined, step: 'deploy' }, + ]); + }); + + test('launch argument preparation honors explicit --isolated in a linked worktree', async () => { + const directory = createAppHostDirectory('AppHost.csproj'); + fs.rmSync(path.join(directory, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(directory, path.join(directory, 'common', '.git')); + const appHostPath = path.join(directory, 'AppHost.csproj'); + const cancellation = new vscode.CancellationTokenSource(); + + const prepared = await (service as unknown as LaunchArgumentPreparer).prepareLaunchArguments( + appHostPath, + 'run', + ['--isolated'], + cancellation.token, + '/path/bin/aspire'); + + assert.deepStrictEqual(prepared, { + args: ['--isolated'], + isolation: { effective: true, option: true }, + }); + }); + + test('launch argument capability probes use the target workspace folder', async () => { + const folder = { name: 'target', index: 1, uri: vscode.Uri.file('/repo/target') } as vscode.WorkspaceFolder; + const target = workspaceFolderCliPathTarget(folder); + + await (service as unknown as LaunchArgumentPreparer).prepareLaunchArguments( + '/repo/target/AppHost.csproj', + 'run', + ['--isolated'], + new vscode.CancellationTokenSource().token, + '/path/bin/aspire', + target); + + assert.strictEqual( + (capabilityProvider.calls.at(-1)?.options as { target?: typeof target } | undefined)?.target, + target); }); test('launch includes step when doStep is provided', async () => { @@ -164,6 +587,33 @@ suite('AppHostLaunchService', () => { } }); + test('CLI availability probe resolves the target from the AppHost path workspace folder', async () => { + const folder = { name: 'a', index: 0, uri: vscode.Uri.file('/repo') } as vscode.WorkspaceFolder; + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns(folder); + + try { + await service.launch('/repo/AppHost.csproj', 'run', true); + + assert.ok(resolveCliPathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); + } + finally { + getWorkspaceFolderStub.restore(); + } + }); + + test('CLI availability probe falls back to the window target when no folder owns the AppHost path', async () => { + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns(undefined); + + try { + await service.launch('/outside/AppHost.csproj', 'run', true); + + assert.ok(resolveCliPathStub.calledOnceWith(windowCliPathTarget)); + } + finally { + getWorkspaceFolderStub.restore(); + } + }); + test('clearLaunching removes the path from launching state', async () => { await service.launch('/repo/AppHost.csproj', 'run', true); assert.strictEqual(service.isLaunching('/repo/AppHost.csproj'), true); @@ -347,6 +797,73 @@ suite('AppHostLaunchService', () => { assert.strictEqual(service.tryReserveExternalLaunch(projectPath), false); }); + test('a repeated external reservation refreshes its pending expiry', () => { + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + try { + const directory = createAppHostDirectory('AppHost.csproj', 'Program.cs'); + const projectPath = path.join(directory, 'AppHost.csproj'); + const reservationId = service.tryReserveExternalLaunch(projectPath); + assert.strictEqual(typeof reservationId, 'string'); + clock.tick(externalLaunchReservationTimeoutMs - 1); + + const refreshedReservationId = service.validateOrReacquireExternalLaunchReservation(projectPath, reservationId || ''); + + assert.strictEqual(refreshedReservationId, reservationId); + clock.tick(2); + assert.strictEqual(service.isLaunching(projectPath), true); + clock.tick(externalLaunchReservationTimeoutMs); + assert.strictEqual(service.isLaunching(projectPath), false); + } + finally { + clock.restore(); + } + }); + + test('an expired repeated external reservation reacquires a fresh launch generation', () => { + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + try { + const directory = createAppHostDirectory('AppHost.csproj', 'Program.cs'); + const projectPath = path.join(directory, 'AppHost.csproj'); + const expiredReservationId = service.tryReserveExternalLaunch(projectPath); + assert.strictEqual(typeof expiredReservationId, 'string'); + clock.tick(externalLaunchReservationTimeoutMs + 1); + assert.strictEqual(service.isLaunching(projectPath), false); + + const reacquiredReservationId = service.validateOrReacquireExternalLaunchReservation(projectPath, expiredReservationId || ''); + + assert.strictEqual(typeof reacquiredReservationId, 'string'); + assert.notStrictEqual(reacquiredReservationId, expiredReservationId); + assert.strictEqual(service.isLaunching(projectPath), true); + } + finally { + clock.restore(); + } + }); + + test('a stale repeated external reservation does not replace a newer claimant', () => { + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + try { + const directory = createAppHostDirectory('AppHost.csproj', 'Program.cs'); + const projectPath = path.join(directory, 'AppHost.csproj'); + const staleReservationId = service.tryReserveExternalLaunch(projectPath); + assert.strictEqual(typeof staleReservationId, 'string'); + clock.tick(externalLaunchReservationTimeoutMs + 1); + const currentReservationId = service.tryReserveExternalLaunch(projectPath); + assert.strictEqual(typeof currentReservationId, 'string'); + + const repeatedReservationId = service.validateOrReacquireExternalLaunchReservation(projectPath, staleReservationId || ''); + + assert.strictEqual(repeatedReservationId, false); + service.releaseExternalLaunchReservation(projectPath, staleReservationId || ''); + assert.strictEqual(service.isLaunching(projectPath), true); + service.releaseExternalLaunchReservation(projectPath, currentReservationId || ''); + assert.strictEqual(service.isLaunching(projectPath), false); + } + finally { + clock.restore(); + } + }); + test('an unresolved workspace launch blocks lifecycle operations for AppHosts inside it', async () => { const directory = createAppHostDirectory('AppHost.csproj', 'Program.cs'); const projectPath = path.join(directory, 'AppHost.csproj'); diff --git a/extension/src/test/appHostLifecycleTools.test.ts b/extension/src/test/appHostLifecycleTools.test.ts index 9e84cb87316..53a431fafb0 100644 --- a/extension/src/test/appHostLifecycleTools.test.ts +++ b/extension/src/test/appHostLifecycleTools.test.ts @@ -24,15 +24,20 @@ import { import { AppHostLifecycleLockTimeoutError, AppHostStopCancellationError, AppHostStopError, type AppHostStopResult } from '../services/AppHostLaunchService'; import { type CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { compareAppHostIdentity, type AppHostIdentityRelation } from '../utils/appHostIdentity'; +import { resolveIsolated } from '../utils/gitWorktree'; +import { writeLinkedWorktreeMetadata } from './testGitWorktree'; +import { removeDirectorySafely } from './testHelpers'; interface LaunchCall { appHostPath: string; command: string; noDebug: boolean; + isolated: boolean | undefined; } class FakeLaunchService implements AppHostLifecycleLaunchService { readonly launchCalls: LaunchCall[] = []; + readonly launchInputIsolations: Array = []; readonly stopCalls: string[] = []; launchingPaths = new Set(); editorSessions: FakeEditorSession[] = []; @@ -44,9 +49,13 @@ class FakeLaunchService implements AppHostLifecycleLaunchService { stopError: Error | undefined; markLaunchingOnLaunch = true; lifecycleLockError: Error | undefined; + onBeforeLaunch: (() => void) | undefined; onLifecycleLockHeld: (() => void) | undefined; reserveLaunchAttempts = 0; onRunningAppHostsRequested: (() => void) | undefined; + supportsIsolatedLaunch = true; + resolveLaunchIsolationCalls = 0; + resolveLaunchIsolationError: Error | undefined; private readonly lifecycleLocks = new Map>(); get pendingLifecycleLockCount(): number { @@ -137,8 +146,32 @@ class FakeLaunchService implements AppHostLifecycleLaunchService { } } - async launchFromLifecycleOwner(appHostPath: string, command: 'run', noDebug: boolean): Promise { - this.launchCalls.push({ appHostPath, command, noDebug }); + async resolveLaunchIsolation(appHostPath: string, isolated: boolean | undefined, token: vscode.CancellationToken): Promise<{ effective: boolean; option: boolean | undefined }> { + this.resolveLaunchIsolationCalls++; + if (token.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + if (this.resolveLaunchIsolationError) { + throw this.resolveLaunchIsolationError; + } + + const effective = resolveIsolated(isolated, appHostPath); + if (this.supportsIsolatedLaunch) { + return { effective, option: isolated ?? (effective ? true : undefined) }; + } + if (isolated === true) { + throw new Error('The requested isolation mode cannot be verified.'); + } + + return { effective: false, option: undefined }; + } + + async launchFromLifecycleOwner(appHostPath: string, command: 'run', noDebug: boolean, isolated: boolean | undefined, token: vscode.CancellationToken): Promise<{ effective: boolean; option: boolean | undefined }> { + this.launchInputIsolations.push(isolated); + this.onBeforeLaunch?.(); + const launchIsolation = await this.resolveLaunchIsolation(appHostPath, isolated, token); + this.launchCalls.push({ appHostPath, command, noDebug, isolated: launchIsolation.option }); if (this.launchDelay) { await this.launchDelay; } @@ -157,6 +190,8 @@ class FakeLaunchService implements AppHostLifecycleLaunchService { // reservation the tool took behind. this.launchingPaths.delete(path.resolve(appHostPath)); } + + return launchIsolation; } async stopAppHost(appHostPath: string, token: vscode.CancellationToken): Promise { @@ -287,6 +322,9 @@ function createFixtureDirectory(prefix: string): string { const fixtureRoot = path.resolve(__dirname, '..', '..', '.test-workspace', 'lm-tools'); const directory = path.join(fixtureRoot, `${prefix}-${crypto.randomBytes(6).toString('hex')}`); fs.mkdirSync(directory, { recursive: true }); + // Stop the ancestor walk at this fixture so a checkout that is itself a linked + // worktree does not make every LM-tool test infer isolated mode. + fs.mkdirSync(path.join(directory, '.git')); return directory; } @@ -298,6 +336,11 @@ function readToolResultPayload(result: vscode.LanguageModelToolResult): AppHostL return JSON.parse(value as string) as AppHostLifecycleToolResult; } +function assertResultOmitsIsolated(result: AppHostLifecycleToolResult): void { + assert.strictEqual(Object.prototype.hasOwnProperty.call(result, 'isolated'), false); + assert.strictEqual(result.isolated, undefined); +} + suite('AppHost lifecycle language model tools', () => { let workspaceRoot: string; let realWorkspaceRoot: string; @@ -341,8 +384,8 @@ suite('AppHost lifecycle language model tools', () => { service.dispose(); workspaceFoldersStub.restore(); isTrustedStub.restore(); - fs.rmSync(workspaceRoot, { recursive: true, force: true }); - fs.rmSync(outsideRoot, { recursive: true, force: true }); + removeDirectorySafely(workspaceRoot); + removeDirectorySafely(outsideRoot); }); suite('manifest and localization', () => { @@ -384,6 +427,8 @@ suite('AppHost lifecycle language model tools', () => { const startSchema = tools[0].inputSchema; assert.deepStrictEqual(startSchema.required, ['appHostPath', 'mode']); + assert.deepStrictEqual(Object.keys(startSchema.properties).sort(), ['appHostPath', 'isolated', 'mode']); + assert.strictEqual(startSchema.properties.isolated.type, 'boolean'); assert.deepStrictEqual(startSchema.properties.mode.enum, ['run', 'debug']); assert.match( packageNls['languageModelTool.aspireAppHostStart.modelDescription'], @@ -409,6 +454,7 @@ suite('AppHost lifecycle language model tools', () => { startTitle: packageNls['aspire-vscode.strings.appHostLifecycleStartConfirmationTitle'], stopTitle: packageNls['aspire-vscode.strings.appHostLifecycleStopConfirmationTitle'], startMessage: packageNls['aspire-vscode.strings.appHostLifecycleStartConfirmationMessage'], + startMessageIsolated: packageNls['aspire-vscode.strings.appHostLifecycleStartConfirmationMessageIsolated'], stopMessage: packageNls['aspire-vscode.strings.appHostLifecycleStopConfirmationMessage'], busy: packageNls['aspire-vscode.strings.appHostLifecycleBusy'], }, @@ -416,6 +462,7 @@ suite('AppHost lifecycle language model tools', () => { startTitle: 'Start Aspire AppHost', stopTitle: 'Stop Aspire AppHost', startMessage: 'Start the Aspire AppHost {0} in {1} mode?', + startMessageIsolated: 'Start the Aspire AppHost {0} in {1} mode with isolation?', stopMessage: 'Stop the Aspire AppHost {0}?', busy: 'Another start or stop operation for this Aspire AppHost is still in progress. Wait for it to finish and try again.', }); @@ -476,9 +523,12 @@ suite('AppHost lifecycle language model tools', () => { suite('selector resolution', () => { test('rejects a missing appHostPath without launching', async () => { - const result = await service.start({ mode: 'run' } as never, new vscode.CancellationTokenSource().token); + const result = await service.start( + { mode: 'run', isolated: true } as never, + new vscode.CancellationTokenSource().token); assert.strictEqual(result.outcome, 'invalidInput'); + assertResultOmitsIsolated(result); assert.strictEqual(launchService.launchCalls.length, 0); }); @@ -501,6 +551,17 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(discoveryService.discoverCalls, 0); }); + test('rejects a non-boolean isolated property before consulting the AppHost registry', async () => { + const result = await service.start({ + appHostPath: 'AppHost/AppHost.csproj', + mode: 'run', + isolated: 'true', + } as never, new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'invalidInput'); + assert.strictEqual(discoveryService.discoverCalls, 0); + }); + test('rejects a selector the AppHost registry does not list', async () => { const result = await service.start({ appHostPath: 'AppHost/Missing.csproj', mode: 'run' }, new vscode.CancellationTokenSource().token); @@ -532,7 +593,7 @@ suite('AppHost lifecycle language model tools', () => { const result = await service.start({ appHostPath: 'Ghost/AppHost.csproj', mode: 'run' }, new vscode.CancellationTokenSource().token); assert.strictEqual(result.outcome, 'started'); - assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: registryOnly, command: 'run', noDebug: true }]); + assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: registryOnly, command: 'run', noDebug: true, isolated: undefined }]); }); test('rejects a selector carrying invisible characters that the registry cannot match', async () => { @@ -588,7 +649,7 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(launchService.launchCalls.length, 0); } finally { - fs.rmSync(secondRoot, { recursive: true, force: true }); + removeDirectorySafely(secondRoot); } }); @@ -642,7 +703,7 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(launchService.launchCalls.length, 0); } finally { - fs.rmSync(secondRoot, { recursive: true, force: true }); + removeDirectorySafely(secondRoot); } }); @@ -663,7 +724,7 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(launchService.launchCalls.length, 0); } finally { - fs.rmSync(secondRoot, { recursive: true, force: true }); + removeDirectorySafely(secondRoot); } }); @@ -681,10 +742,10 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(result.outcome, 'started'); assert.strictEqual(result.appHostPath, 'AppHost/AppHost.csproj'); - assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: secondAppHost, command: 'run', noDebug: true }]); + assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: secondAppHost, command: 'run', noDebug: true, isolated: undefined }]); } finally { - fs.rmSync(secondRoot, { recursive: true, force: true }); + removeDirectorySafely(secondRoot); } }); @@ -807,36 +868,227 @@ suite('AppHost lifecycle language model tools', () => { test('maps run mode to a non-debug aspire run launch', async () => { const result = await service.start({ appHostPath: 'AppHost/AppHost.csproj', mode: 'run' }, new vscode.CancellationTokenSource().token); - assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: appHostProjectPath, command: 'run', noDebug: true }]); + assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: appHostProjectPath, command: 'run', noDebug: true, isolated: undefined }]); assert.deepStrictEqual( - { outcome: result.outcome, requestedMode: result.requestedMode, effectiveMode: result.effectiveMode, controller: result.controller }, - { outcome: 'started', requestedMode: 'run', effectiveMode: 'run', controller: 'editor' }); + { outcome: result.outcome, requestedMode: result.requestedMode, effectiveMode: result.effectiveMode, controller: result.controller, isolated: result.isolated }, + { outcome: 'started', requestedMode: 'run', effectiveMode: 'run', controller: 'editor', isolated: false }); }); test('maps debug mode to a debugger-attached aspire run launch', async () => { await service.start({ appHostPath: 'AppHost/AppHost.csproj', mode: 'debug' }, new vscode.CancellationTokenSource().token); - assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: appHostProjectPath, command: 'run', noDebug: false }]); + assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: appHostProjectPath, command: 'run', noDebug: false, isolated: undefined }]); + }); + + test('passes explicit isolated true through to the launch', async () => { + const result = await service.start( + { appHostPath: 'AppHost/AppHost.csproj', mode: 'run', isolated: true }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(result.isolated, true); + assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: appHostProjectPath, command: 'run', noDebug: true, isolated: true }]); + }); + + test('honors explicit isolated false in a linked worktree', async () => { + fs.rmSync(path.join(workspaceRoot, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(workspaceRoot, path.join(workspaceRoot, 'common', '.git')); + + const result = await service.start( + { appHostPath: 'AppHost/AppHost.csproj', mode: 'run', isolated: false }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(result.isolated, false); + assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: appHostProjectPath, command: 'run', noDebug: true, isolated: false }]); + }); + + test('infers isolated when starting from a linked worktree', async () => { + fs.rmSync(path.join(workspaceRoot, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(workspaceRoot, path.join(workspaceRoot, 'common', '.git')); + + const tool = new AppHostStartLanguageModelTool(service); + const prepared = await tool.prepareInvocation( + { input: { appHostPath: 'AppHost/AppHost.csproj', mode: 'run' } }, + new vscode.CancellationTokenSource().token); + const result = await service.start({ appHostPath: 'AppHost/AppHost.csproj', mode: 'run' }, new vscode.CancellationTokenSource().token); + + // The confirmation names the isolation the launch will request, so the user + // approves what actually runs. Only the CLI capability probe stays deferred. + assert.strictEqual( + prepared.confirmationMessages?.message, + 'Start the Aspire AppHost AppHost/AppHost.csproj in run mode with isolation?'); + assert.strictEqual( + launchService.resolveLaunchIsolationCalls, + 1, + 'Only the actual launch may negotiate effective isolation.'); + assert.strictEqual(result.isolated, true); + assert.deepStrictEqual(launchService.launchInputIsolations, [undefined]); + assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: appHostProjectPath, command: 'run', noDebug: true, isolated: true }]); + }); + + test('confirms explicit isolated false in a linked worktree instead of the inferred value', async () => { + fs.rmSync(path.join(workspaceRoot, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(workspaceRoot, path.join(workspaceRoot, 'common', '.git')); + + const tool = new AppHostStartLanguageModelTool(service); + const prepared = await tool.prepareInvocation( + { input: { appHostPath: 'AppHost/AppHost.csproj', mode: 'run', isolated: false } }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual( + prepared.confirmationMessages?.message, + 'Start the Aspire AppHost AppHost/AppHost.csproj in run mode?'); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 0); + }); + + test('confirms without isolation outside a linked worktree when isolated is omitted', async () => { + const tool = new AppHostStartLanguageModelTool(service); + + const prepared = await tool.prepareInvocation( + { input: { appHostPath: 'AppHost/AppHost.csproj', mode: 'run' } }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual( + prepared.confirmationMessages?.message, + 'Start the Aspire AppHost AppHost/AppHost.csproj in run mode?'); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 0); + }); + + test('falls back to non-isolated launch when the CLI lacks isolation support', async () => { + fs.rmSync(path.join(workspaceRoot, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(workspaceRoot, path.join(workspaceRoot, 'common', '.git')); + launchService.supportsIsolatedLaunch = false; + const tool = new AppHostStartLanguageModelTool(service); + + const prepared = await tool.prepareInvocation( + { input: { appHostPath: 'AppHost/AppHost.csproj', mode: 'run' } }, + new vscode.CancellationTokenSource().token); + const result = await service.start( + { appHostPath: 'AppHost/AppHost.csproj', mode: 'run' }, + new vscode.CancellationTokenSource().token); + + // Confirmation shows the requested isolation the worktree implies. Whether the + // selected CLI can honor it is only knowable by spawning it, which preparation + // deliberately never does, so degrading to a non-isolated launch happens later. + assert.strictEqual( + prepared.confirmationMessages?.message, + 'Start the Aspire AppHost AppHost/AppHost.csproj in run mode with isolation?'); + assert.strictEqual(result.isolated, false); + assert.deepStrictEqual(launchService.launchInputIsolations, [undefined]); + assert.deepStrictEqual(launchService.launchCalls, [{ + appHostPath: appHostProjectPath, + command: 'run', + noDebug: true, + isolated: undefined, + }]); + }); + + test('prepares confirmation without probing the selected CLI isolation capability', async () => { + launchService.resolveLaunchIsolationError = new Error('capability probe failed'); + const tool = new AppHostStartLanguageModelTool(service); + + const prepared = await tool.prepareInvocation( + { input: { appHostPath: 'AppHost/AppHost.csproj', mode: 'run', isolated: true } }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual( + prepared.confirmationMessages?.message, + 'Start the Aspire AppHost AppHost/AppHost.csproj in run mode with isolation?'); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 0); + }); + + test('preserves explicit false through the authoritative launch probe', async () => { + launchService.supportsIsolatedLaunch = false; + launchService.onBeforeLaunch = () => { + launchService.supportsIsolatedLaunch = true; + }; + + const result = await service.start( + { appHostPath: 'AppHost/AppHost.csproj', mode: 'run', isolated: false }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(result.isolated, false); + assert.deepStrictEqual(launchService.launchInputIsolations, [false]); + assert.deepStrictEqual(launchService.launchCalls, [{ + appHostPath: appHostProjectPath, + command: 'run', + noDebug: true, + isolated: false, + }]); + }); + + test('re-resolves unspecified isolation through the authoritative launch probe', async () => { + fs.rmSync(path.join(workspaceRoot, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(workspaceRoot, path.join(workspaceRoot, 'common', '.git')); + launchService.supportsIsolatedLaunch = false; + launchService.onBeforeLaunch = () => { + launchService.supportsIsolatedLaunch = true; + }; + + const result = await service.start( + { appHostPath: 'AppHost/AppHost.csproj', mode: 'run' }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(result.isolated, true); + assert.deepStrictEqual(launchService.launchInputIsolations, [undefined]); + assert.deepStrictEqual(launchService.launchCalls, [{ + appHostPath: appHostProjectPath, + command: 'run', + noDebug: true, + isolated: true, + }]); + }); + + test('uses only the launch-owned isolation probe after acquiring the lifecycle lock', async () => { + fs.rmSync(path.join(workspaceRoot, '.git'), { recursive: true, force: true }); + writeLinkedWorktreeMetadata(workspaceRoot, path.join(workspaceRoot, 'common', '.git')); + launchService.onLifecycleLockHeld = () => { + launchService.supportsIsolatedLaunch = false; + }; + + const result = await service.start( + { appHostPath: 'AppHost/AppHost.csproj', mode: 'run' }, + new vscode.CancellationTokenSource().token); + + assert.strictEqual(result.outcome, 'started'); + assert.strictEqual(result.isolated, false); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 1); + assert.deepStrictEqual(launchService.launchInputIsolations, [undefined]); + assert.deepStrictEqual(launchService.launchCalls, [{ + appHostPath: appHostProjectPath, + command: 'run', + noDebug: true, + isolated: undefined, + }]); }); test('returns alreadyStarting without launching a second process', async () => { launchService.launchingPaths.add(path.resolve(appHostProjectPath)); + launchService.resolveLaunchIsolationError = new Error('The requested isolation mode cannot be verified.'); const result = await service.start({ appHostPath: 'AppHost/AppHost.csproj', mode: 'debug' }, new vscode.CancellationTokenSource().token); assert.strictEqual(result.outcome, 'alreadyStarting'); assert.strictEqual(launchService.launchCalls.length, 0); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 0); + assertResultOmitsIsolated(result); }); test('returns alreadyStarting while the editor-owned session is still starting up', async () => { const session = new FakeEditorSession(appHostProjectPath, { noDebug: false }); session.startupCompleted = false; editorSessions.push(session); + launchService.resolveLaunchIsolationError = new Error('The requested isolation mode cannot be verified.'); const result = await service.start({ appHostPath: 'AppHost/AppHost.csproj', mode: 'debug' }, new vscode.CancellationTokenSource().token); assert.strictEqual(result.outcome, 'alreadyStarting'); assert.strictEqual(launchService.launchCalls.length, 0); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 0); + assertResultOmitsIsolated(result); }); // The launching flag is cleared by `aspire ps` reconciliation, which can lag well @@ -857,6 +1109,7 @@ suite('AppHost lifecycle language model tools', () => { test('returns alreadyRunning with the effective mode of the editor-owned session', async () => { editorSessions.push(new FakeEditorSession(appHostProjectPath, { noDebug: true })); + launchService.resolveLaunchIsolationError = new Error('The requested isolation mode cannot be verified.'); const result = await service.start({ appHostPath: 'AppHost/AppHost.csproj', mode: 'debug' }, new vscode.CancellationTokenSource().token); @@ -864,6 +1117,8 @@ suite('AppHost lifecycle language model tools', () => { { outcome: result.outcome, requestedMode: result.requestedMode, effectiveMode: result.effectiveMode, controller: result.controller }, { outcome: 'alreadyRunning', requestedMode: 'debug', effectiveMode: 'run', controller: 'editor' }); assert.strictEqual(launchService.launchCalls.length, 0); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 0); + assertResultOmitsIsolated(result); }); test('refuses to start over an externally owned AppHost that is already running', async () => { @@ -969,6 +1224,7 @@ suite('AppHost lifecycle language model tools', () => { const serialized = JSON.stringify(result); assert.strictEqual(result.outcome, 'failed'); + assertResultOmitsIsolated(result); assert.strictEqual(serialized.includes('super-secret-value'), false); assert.strictEqual(serialized.includes('/Users/private'), false); }); @@ -1013,6 +1269,7 @@ suite('AppHost lifecycle language model tools', () => { // probe never runs while the lock is held and the user's own Run/Debug keeps // its full 10s wait budget. launchService.runningAppHosts = [{ appHostPath: appHostProjectPath }]; + launchService.resolveLaunchIsolationError = new Error('The requested isolation mode cannot be verified.'); let lockTaken = false; launchService.onLifecycleLockHeld = () => { lockTaken = true; }; @@ -1022,6 +1279,8 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(result.controller, 'external'); assert.strictEqual(lockTaken, false, 'Expected the external-controller fast path to answer before the lifecycle lock was taken.'); assert.strictEqual(launchService.launchCalls.length, 0); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 0); + assertResultOmitsIsolated(result); }); test('revalidates the external controller after waiting for the lifecycle lock', async () => { @@ -1029,6 +1288,7 @@ suite('AppHost lifecycle language model tools', () => { // can take up to 10s, and an AppHost started from a terminal during that wait // leaves no editor session and no launching flag, so a cached negative result // would let the tool start a second process against the same project. + launchService.resolveLaunchIsolationError = new Error('The requested isolation mode cannot be verified.'); launchService.onLifecycleLockHeld = () => { launchService.runningAppHosts = [{ appHostPath: appHostProjectPath }]; }; @@ -1038,6 +1298,8 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(result.outcome, 'alreadyRunning'); assert.strictEqual(result.controller, 'external'); assert.strictEqual(launchService.launchCalls.length, 0); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 0); + assertResultOmitsIsolated(result); }); test('refuses to start when a session cannot be told apart from the requested AppHost', async () => { @@ -1052,11 +1314,14 @@ suite('AppHost lifecycle language model tools', () => { fs.writeFileSync(path.join(directory, 'Program.cs'), singleFileAppHostContents); discoveryService.registeredPaths.push(path.join(directory, 'First.csproj'), path.join(directory, 'Second.csproj')); editorSessions.push(new FakeEditorSession(path.join(directory, 'Program.cs'), { noDebug: false })); + launchService.resolveLaunchIsolationError = new Error('The requested isolation mode cannot be verified.'); const result = await service.start({ appHostPath: 'Ambiguous/First.csproj', mode: 'run' }, new vscode.CancellationTokenSource().token); assert.strictEqual(result.outcome, 'ambiguousSession'); assert.strictEqual(launchService.launchCalls.length, 0); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 0); + assertResultOmitsIsolated(result); }); test('claims the launching slot before launching so a concurrent editor launch cannot duplicate it', async () => { @@ -1069,6 +1334,7 @@ suite('AppHost lifecycle language model tools', () => { // by the in-lock `isLaunching` check instead. Reserve during the second probe, // the authoritative one that runs inside the lock immediately before the launch, // so only the synchronous claim is left to notice. + launchService.resolveLaunchIsolationError = new Error('capability probe failed'); launchService.onRunningAppHostsRequested = () => { if (launchService.runningAppHostRequests === 2) { launchService.launchingPaths.add(path.resolve(appHostProjectPath)); @@ -1080,6 +1346,8 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(result.outcome, 'alreadyStarting'); assert.strictEqual(result.controller, 'editor'); assert.strictEqual(launchService.launchCalls.length, 0); + assert.strictEqual(launchService.resolveLaunchIsolationCalls, 0); + assertResultOmitsIsolated(result); }); test('releases the launching claim when the launch itself fails', async () => { @@ -1348,6 +1616,7 @@ suite('AppHost lifecycle language model tools', () => { suite('confirmation', () => { test('always confirms a start with the action, relative path, and requested mode', async () => { const tool = new AppHostStartLanguageModelTool(service); + const discoverCallsBeforePreparation = discoveryService.discoverCalls; const prepared = await tool.prepareInvocation( { input: { appHostPath: 'AppHost/AppHost.csproj', mode: 'debug' } }, @@ -1356,6 +1625,7 @@ suite('AppHost lifecycle language model tools', () => { assert.strictEqual(prepared?.confirmationMessages?.title, 'Start Aspire AppHost'); assert.strictEqual(prepared?.confirmationMessages?.message, 'Start the Aspire AppHost AppHost/AppHost.csproj in debug mode?'); assert.strictEqual(prepared?.invocationMessage, 'Starting Aspire AppHost AppHost/AppHost.csproj...'); + assert.strictEqual(discoveryService.discoverCalls - discoverCallsBeforePreparation, 1); }); test('always confirms a stop with the action and relative path', async () => { @@ -1498,10 +1768,10 @@ suite('AppHost lifecycle language model tools', () => { prepared?.confirmationMessages?.message, 'Start the Aspire AppHost second/Other/AppHost.csproj in debug mode?'); assert.strictEqual(result.outcome, 'started'); - assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: secondAppHost, command: 'run', noDebug: false }]); + assert.deepStrictEqual(launchService.launchCalls, [{ appHostPath: secondAppHost, command: 'run', noDebug: false, isolated: undefined }]); } finally { - fs.rmSync(secondRoot, { recursive: true, force: true }); + removeDirectorySafely(secondRoot); } }); @@ -1559,7 +1829,8 @@ suite('AppHost lifecycle language model tools', () => { new vscode.CancellationTokenSource().token); const payload = readToolResultPayload(result); - assert.deepStrictEqual(Object.keys(payload).sort(), ['appHostPath', 'controller', 'effectiveMode', 'outcome', 'requestedMode', 'tool']); + assert.deepStrictEqual(Object.keys(payload).sort(), ['appHostPath', 'controller', 'effectiveMode', 'isolated', 'outcome', 'requestedMode', 'tool']); + assert.strictEqual(payload.isolated, false); assert.strictEqual(payload.tool, aspireAppHostStartToolName); assert.strictEqual(payload.appHostPath, 'AppHost/AppHost.csproj'); assert.strictEqual(JSON.stringify(payload).includes(workspaceRoot), false); diff --git a/extension/src/test/appHostStopper.test.ts b/extension/src/test/appHostStopper.test.ts index 25aaa84b8ce..4d63237c27f 100644 --- a/extension/src/test/appHostStopper.test.ts +++ b/extension/src/test/appHostStopper.test.ts @@ -6,6 +6,7 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { stopExternalAppHost } from '../services/AppHostStopper'; import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; suite('AppHostStopper', () => { test('waits for aspire stop to exit successfully', async () => { @@ -40,6 +41,69 @@ suite('AppHostStopper', () => { } }); + test('resolves the CLI using the target derived from the AppHost path workspace folder', async () => { + const childState = createTestChildProcess(); + const child = childState as unknown as nodeChildProcess.ChildProcessWithoutNullStreams; + sinon.stub(nodeChildProcess, 'spawn').returns(child); + const folder = { name: 'a', index: 0, uri: vscode.Uri.file('/repo') } as vscode.WorkspaceFolder; + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns(folder); + const getAspireCliExecutablePathStub = sinon.stub().resolves('/repo/bin/aspire'); + const terminalProvider = { + getAspireCliExecutablePath: getAspireCliExecutablePathStub, + createEnvironment: () => ({}), + sendAspireCommandToAspireTerminal: async () => { }, + } as unknown as AspireTerminalProvider; + + try { + const stopping = stopExternalAppHost( + terminalProvider, + '/repo/AppHost/AppHost.csproj', + new vscode.CancellationTokenSource().token); + await new Promise(resolve => setImmediate(resolve)); + + assert.ok(getAspireCliExecutablePathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); + + childState.exitCode = 0; + child.emit('close', 0); + await stopping; + } + finally { + getWorkspaceFolderStub.restore(); + (nodeChildProcess.spawn as sinon.SinonStub).restore(); + } + }); + + test('resolves the CLI using the window target when no folder owns the AppHost path', async () => { + const childState = createTestChildProcess(); + const child = childState as unknown as nodeChildProcess.ChildProcessWithoutNullStreams; + sinon.stub(nodeChildProcess, 'spawn').returns(child); + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns(undefined); + const getAspireCliExecutablePathStub = sinon.stub().resolves('/usr/local/bin/aspire'); + const terminalProvider = { + getAspireCliExecutablePath: getAspireCliExecutablePathStub, + createEnvironment: () => ({}), + sendAspireCommandToAspireTerminal: async () => { }, + } as unknown as AspireTerminalProvider; + + try { + const stopping = stopExternalAppHost( + terminalProvider, + '/outside/AppHost/AppHost.csproj', + new vscode.CancellationTokenSource().token); + await new Promise(resolve => setImmediate(resolve)); + + assert.ok(getAspireCliExecutablePathStub.calledOnceWith(windowCliPathTarget)); + + childState.exitCode = 0; + child.emit('close', 0); + await stopping; + } + finally { + getWorkspaceFolderStub.restore(); + (nodeChildProcess.spawn as sinon.SinonStub).restore(); + } + }); + test('rejects when aspire stop exits unsuccessfully', async () => { const childState = createTestChildProcess(); const child = childState as unknown as nodeChildProcess.ChildProcessWithoutNullStreams; @@ -139,11 +203,20 @@ suite('AppHostStopper', () => { }); test('cancels and terminates an in-flight aspire stop process', async () => { + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); const platformStub = sinon.stub(process, 'platform').value('linux'); const childState = createTestChildProcess(); const child = childState as unknown as nodeChildProcess.ChildProcessWithoutNullStreams; const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns(child); - const processKillStub = sinon.stub(process, 'kill').returns(true); + let processGroupAlive = true; + const noSuchProcess = Object.assign(new Error('No such process'), { code: 'ESRCH' }); + const processKillStub = sinon.stub(process, 'kill').callsFake((_pid, signal) => { + if (signal === 0 && !processGroupAlive) { + throw noSuchProcess; + } + + return true; + }); const cancellationSource = new vscode.CancellationTokenSource(); let settled = false; @@ -152,15 +225,23 @@ suite('AppHostStopper', () => { createTerminalProvider(), '/repo/AppHost/AppHost.csproj', cancellationSource.token).finally(() => { settled = true; }); - await new Promise(resolve => setImmediate(resolve)); + await clock.tickAsync(0); cancellationSource.cancel(); - await new Promise(resolve => setImmediate(resolve)); + await clock.tickAsync(0); assert.deepStrictEqual(processKillStub.firstCall.args, [-1234, 'SIGTERM']); assert.strictEqual(settled, false); + child.emit('error', new Error('process transport closed during cancellation')); + await clock.tickAsync(0); + assert.strictEqual(settled, false, 'Process errors must not bypass termination confirmation after cancellation.'); childState.signalCode = 'SIGTERM'; child.emit('close', null); + await clock.tickAsync(0); + assert.strictEqual(settled, false, 'Cancellation must wait until descendant cleanup is confirmed.'); + + processGroupAlive = false; + await clock.tickAsync(50); await assert.rejects(stopping, error => error instanceof vscode.CancellationError); assert.strictEqual(settled, true); } @@ -169,10 +250,11 @@ suite('AppHostStopper', () => { processKillStub.restore(); spawnStub.restore(); platformStub.restore(); + clock.restore(); } }); - test('bounds cancellation when the child never reports completion', async () => { + test('reports cleanup failure when cancellation cannot confirm process termination', async () => { const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); const platformStub = sinon.stub(process, 'platform').value('linux'); const childState = createTestChildProcess(); @@ -180,21 +262,21 @@ suite('AppHostStopper', () => { const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns(child); const processKillStub = sinon.stub(process, 'kill').returns(true); const cancellationSource = new vscode.CancellationTokenSource(); - let cancelled = false; + let rejection: unknown; try { const stopping = stopExternalAppHost( createTerminalProvider(), '/repo/AppHost/AppHost.csproj', cancellationSource.token).catch(error => { - cancelled = error instanceof vscode.CancellationError; + rejection = error; }); await clock.tickAsync(0); cancellationSource.cancel(); await clock.tickAsync(11_000); - assert.strictEqual(cancelled, true); + assert.match(String(rejection), /Could not confirm aspire stop process group termination within 5000ms/); await stopping; } finally { diff --git a/extension/src/test/appHostTargetVersion.test.ts b/extension/src/test/appHostTargetVersion.test.ts index a49c5177330..33a8ca1ee5b 100644 --- a/extension/src/test/appHostTargetVersion.test.ts +++ b/extension/src/test/appHostTargetVersion.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { getAppHostTargetVersion, summarizeAppHostTargetVersions } from '../utils/appHostTargetVersion'; import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; +import { removeDirectorySafely } from './testHelpers'; function candidate(path: string, language: string | null): CandidateAppHostDisplayInfo { return { path, language, status: 'buildable' }; } @@ -22,7 +23,7 @@ suite('appHostTargetVersion', () => { teardown(() => { for (const dir of tempDirs) { if (existsSync(dir)) { - rmSync(dir, { recursive: true, force: true }); + removeDirectorySafely(dir); } } tempDirs.length = 0; @@ -30,7 +31,7 @@ suite('appHostTargetVersion', () => { suiteTeardown(() => { if (existsSync(tempParent)) { - rmSync(tempParent, { recursive: true, force: true }); + removeDirectorySafely(tempParent); } }); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 32cd7259ca8..5e306e96e82 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -22,7 +22,9 @@ import { terminalCommandArgumentControlCharacters, appHostPathCopiedToClipboard, import { onDidInvokeCommand, withCommandTelemetry } from '../utils/telemetry'; import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery'; import { lsJsonStreamCapability } from '../types/configInfo'; +import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { removeDirectorySafely } from './testHelpers'; function makeResource(overrides: Partial = {}): ResourceJson { const base: ResourceJson = { name: 'my-service', @@ -57,7 +59,9 @@ function makeAppHost(overrides: Partial = {}): AppHostDispla } function makeLaunchService(): AppHostLaunchService { - return new AppHostLaunchService(); + return new AppHostLaunchService({ + getCapabilityStatus: async () => 'supported', + }); } function makeTerminalProvider(): AspireTerminalProvider { @@ -190,7 +194,7 @@ function createShellProof(): ShellProof { assert.strictEqual(fs.existsSync(resourceMarkerPath), false, 'resource payload should not execute'); }, dispose(): void { - fs.rmSync(directory, { recursive: true, force: true }); + removeDirectorySafely(directory); }, }; } @@ -2634,10 +2638,17 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { }); test('workspace resource commands use the AppHost that owns the resource', async () => { - const commands: AspireSubcommand[] = []; + const commands: Array<{ command: AspireSubcommand; options: unknown }> = []; const runResourceCommandCalls: Array<[string, string | undefined, string, readonly string[]]> = []; const selectedHostPath = '/repo/apps/Store/AppHost.csproj'; const otherHostPath = '/repo/samples/Store/AppHost.csproj'; + const otherFolder = { + uri: vscode.Uri.file('/repo/samples'), + name: 'samples', + index: 1, + }; + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => uri.fsPath.startsWith(`${otherFolder.uri.fsPath}${path.sep}`) ? otherFolder : undefined); + const otherTarget = workspaceFolderCliPathTarget(otherFolder); const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); const repository = { viewMode: 'workspace' as ViewMode, @@ -2660,7 +2671,7 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { const terminalProvider = { getAspireCliExecutablePath: async () => 'aspire', createEnvironment: () => ({}), - sendAspireCommandToAspireTerminal: (command: AspireSubcommand) => commands.push(command), + sendAspireCommandToAspireTerminal: (command: AspireSubcommand, _showTerminal?: boolean, _additionalArgs?: string[], options?: unknown) => commands.push({ command, options }), } as unknown as AspireTerminalProvider; const provider = new AspireAppHostTreeProvider(repository, terminalProvider, makeLaunchService()); @@ -2676,11 +2687,18 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { // Logs and terminal still go through the terminal; restart now runs over the hidden CLI // backchannel and must target the AppHost that owns the resource. assert.deepStrictEqual(commands, [ - ['logs', shellArg('cache'), '--apphost', shellArg(otherHostPath)], - ['terminal', 'attach', shellArg('cache-b'), '--apphost', shellArg(otherHostPath)], + { + command: ['logs', shellArg('cache'), '--apphost', shellArg(otherHostPath)], + options: { target: otherTarget }, + }, + { + command: ['terminal', 'attach', shellArg('cache-b'), '--apphost', shellArg(otherHostPath)], + options: { terminalTarget: 'editor', target: otherTarget }, + }, ]); assert.deepStrictEqual(runResourceCommandCalls, [['cache-b', otherHostPath, 'restart', []]]); provider.dispose(); + getWorkspaceFolderStub.restore(); }); test('workspace resource commands use the running AppHost path when no workspace AppHost is selected', async () => { diff --git a/extension/src/test/aspireCodeLensProvider.test.ts b/extension/src/test/aspireCodeLensProvider.test.ts index a7c275e4820..b4cd60f4a6d 100644 --- a/extension/src/test/aspireCodeLensProvider.test.ts +++ b/extension/src/test/aspireCodeLensProvider.test.ts @@ -4,11 +4,12 @@ import * as assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import { createMockDocument } from './testHelpers'; import { AspireCodeLensProvider } from '../editor/AspireCodeLensProvider'; import { AspireGutterDecorationProvider } from '../editor/AspireGutterDecorationProvider'; import * as AppHostResourceParser from '../editor/parsers/AppHostResourceParser'; import { ParsedResource } from '../editor/parsers/AppHostResourceParser'; -import { codeLensCommand, codeLensResourceValueMissing, codeLensRustAppHostUseAspire } from '../loc/strings'; +import { codeLensCommand, codeLensJavaAppHostAlreadyRunning, codeLensJavaAppHostAlreadyRunningTooltip, codeLensJavaAppHostUseAspire, codeLensJavaAppHostUseAspireTooltip, codeLensResourceValueMissing, codeLensRustAppHostUseAspire, codeLensSpringBootDashboardBypassesAspire, codeLensSpringBootDashboardBypassesAspireTooltip } from '../loc/strings'; import { ResourceState, ResourceType } from '../editor/resourceConstants'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { AppHostDataRepository, AppHostDisplayInfo, ResourceJson } from '../data/AppHostDataRepository'; @@ -26,63 +27,6 @@ function p(...segments: string[]): string { return path.join(path.sep, ...segments); } -function createMockDocument(content: string, filePath: string): vscode.TextDocument { - const lines = content.split('\n'); - return { - uri: vscode.Uri.file(filePath), - fileName: filePath, - isUntitled: false, - languageId: filePath.endsWith('.cs') ? 'csharp' : filePath.endsWith('.ts') ? 'typescript' : filePath.endsWith('.rs') ? 'rust' : 'javascript', - version: 1, - isDirty: false, - isClosed: false, - eol: vscode.EndOfLine.LF, - lineCount: lines.length, - encoding: 'utf-8', - save: () => Promise.resolve(false), - lineAt: (lineOrPos: number | vscode.Position) => { - const lineNum = typeof lineOrPos === 'number' ? lineOrPos : lineOrPos.line; - const text = lines[lineNum] || ''; - return { - lineNumber: lineNum, - text, - range: new vscode.Range(lineNum, 0, lineNum, text.length), - rangeIncludingLineBreak: new vscode.Range(lineNum, 0, lineNum + 1, 0), - firstNonWhitespaceCharacterIndex: text.search(/\S/), - isEmptyOrWhitespace: text.trim().length === 0, - } as vscode.TextLine; - }, - offsetAt: (position: vscode.Position) => { - let offset = 0; - for (let i = 0; i < position.line && i < lines.length; i++) { - offset += lines[i].length + 1; - } - return offset + position.character; - }, - positionAt: (offset: number) => { - let remaining = offset; - for (let i = 0; i < lines.length; i++) { - if (remaining <= lines[i].length) { - return new vscode.Position(i, remaining); - } - remaining -= lines[i].length + 1; - } - return new vscode.Position(lines.length - 1, lines[lines.length - 1].length); - }, - getText: (range?: vscode.Range) => { - if (!range) { - return content; - } - const startOffset = lines.slice(0, range.start.line).reduce((sum, l) => sum + l.length + 1, 0) + range.start.character; - const endOffset = lines.slice(0, range.end.line).reduce((sum, l) => sum + l.length + 1, 0) + range.end.character; - return content.substring(startOffset, endOffset); - }, - getWordRangeAtPosition: () => undefined, - validateRange: (range: vscode.Range) => range, - validatePosition: (position: vscode.Position) => position, - notebook: undefined as any, - } as vscode.TextDocument; -} function makeAppHost(appHostPath: string): AppHostDisplayInfo { return { @@ -122,17 +66,23 @@ function createHarness(opts: { appHosts?: AppHostDisplayInfo[]; workspaceResources?: ResourceJson[]; workspaceAppHostPath?: string; + installedExtensions?: string[]; }): TestHarness { const subs: vscode.Disposable[] = []; const terminalProvider = new AspireTerminalProvider(subs); const repository = new AppHostDataRepository(terminalProvider); - const treeProvider = new AspireAppHostTreeProvider(repository, terminalProvider, new AppHostLaunchService()); + const treeProvider = new AspireAppHostTreeProvider(repository, terminalProvider, new AppHostLaunchService({ + getCapabilityStatus: async () => 'supported', + })); const appHostsStub = sinon.stub(repository, 'appHosts').get(() => opts.appHosts ?? []); const workspaceResourcesStub = sinon.stub(repository, 'workspaceResources').get(() => opts.workspaceResources ?? []); const workspaceAppHostPathStub = sinon.stub(repository, 'workspaceAppHostPath').get(() => opts.workspaceAppHostPath); - const provider = new AspireCodeLensProvider(treeProvider, repository); + const provider = new AspireCodeLensProvider( + treeProvider, + repository, + extensionId => (opts.installedExtensions ?? []).includes(extensionId)); return { provider, @@ -715,6 +665,29 @@ suite('AspireCodeLensProvider resource lens anchoring', () => { harness.dispose(); }); + test('renders resource and builder lenses for a Java AppHost, which previously had none', async () => { + const appHostPath = p('repo', 'AppHost', 'AppHost.java'); + const content = [ + 'import aspire.*;', + 'void main() throws Exception {', + ' var builder = DistributedApplication.CreateBuilder();', + ' var catalog = builder.addSpringBootApp("catalog", "./catalog");', + ' builder.build().run();', + '}', + ].join('\n'); + const harness = createHarness({ + appHosts: [makeAppHost(appHostPath)], + workspaceAppHostPath: appHostPath, + workspaceResources: [makeResource('catalog', { state: 'Running' })], + }); + + const lenses = await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]; + + assert.ok(lenses.some(lens => lens.range.start.line === 3), 'expected at least one lens anchored on the addSpringBootApp line'); + assert.ok(lenses.some(lens => lens.command?.command === 'aspire-vscode.codeLensOpenDashboard'), 'expected the builder statement to carry Open Dashboard'); + harness.dispose(); + }); + test('emits pipeline debug lenses for a stopped Rust AppHost', async () => { const appHostPath = p('repo', 'AppHost', 'apphost.rs'); const content = [ @@ -1194,4 +1167,367 @@ suite('AspireCodeLensProvider resource lens anchoring', () => { assert.strictEqual(reveals[0].command?.title, codeLensResourceValueMissing); harness.dispose(); }); + suite('Java AppHost entry point warning', () => { + const javaAppHostPath = p('repo', 'AppHost', 'AppHost.java'); + + function appHostSource(main: string): string { + return [ + 'import aspire.*;', + main, + ' var builder = DistributedApplication.CreateBuilder();', + ' builder.addSpringBootApp("catalog", "./catalog");', + ' builder.build().run();', + '}', + ].join('\n'); + } + + function entryPointLenses(lenses: vscode.CodeLens[]): vscode.CodeLens[] { + return lenses.filter(lens => + lens.command?.title === codeLensJavaAppHostUseAspire || + lens.command?.title === codeLensJavaAppHostAlreadyRunning); + } + + async function lensesFor(content: string, opts: Parameters[0] = {}): Promise { + const harness = createHarness(opts); + const lenses = entryPointLenses(await harness.provider.provideCodeLenses(createMockDocument(content, javaAppHostPath), cancellationToken) as vscode.CodeLens[]); + harness.dispose(); + return lenses; + } + + test('warns on the implicitly declared instance main the Java AppHost actually ships', async () => { + // JEP 512: a source-launched AppHost.java has no class, no modifiers and no parameters. + const lenses = await lensesFor(appHostSource('void main() throws Exception {')); + + assert.strictEqual(lenses.length, 1); + assert.strictEqual(lenses[0].range.start.line, 1, 'the warning belongs on the declaration the Run/Debug lens sits above'); + assert.strictEqual(lenses[0].command?.title, codeLensJavaAppHostUseAspire); + assert.strictEqual(lenses[0].command?.tooltip, codeLensJavaAppHostUseAspireTooltip); + // Rendered as plain text: a stopped AppHost has nothing in the tree to reveal. + assert.strictEqual(lenses[0].command?.command, ''); + }); + + test('warns on a conventional static main, which is the Maven and Gradle project shape', async () => { + const lenses = await lensesFor(appHostSource('public static void main(String[] args) throws Exception {')); + + assert.strictEqual(lenses.length, 1); + assert.strictEqual(lenses[0].range.start.line, 1); + }); + + test('does not warn on a Java file that is not an AppHost', async () => { + const lenses = await lensesFor('public class Application {\n public static void main(String[] args) {\n }\n}'); + + assert.deepStrictEqual(lenses, [], 'detection is content-based, so an ordinary Java file with a main is left alone'); + }); + + test('points at the running AppHost once it is started under Aspire', async () => { + const lenses = await lensesFor(appHostSource('void main() {'), { + appHosts: [makeAppHost(javaAppHostPath)], + }); + + assert.strictEqual(lenses.length, 1); + assert.strictEqual(lenses[0].command?.title, codeLensJavaAppHostAlreadyRunning); + assert.strictEqual(lenses[0].command?.tooltip, codeLensJavaAppHostAlreadyRunningTooltip); + assert.strictEqual(lenses[0].command?.command, 'aspire-vscode.codeLensRevealAppHost'); + assert.deepStrictEqual(lenses[0].command?.arguments, [javaAppHostPath]); + }); + }); + + suite('Spring Boot Dashboard warning', () => { + const springBootDashboard = 'vscjava.vscode-spring-boot-dashboard'; + + function springBootLenses(lenses: vscode.CodeLens[]): vscode.CodeLens[] { + return lenses.filter(lens => lens.command?.title === codeLensSpringBootDashboardBypassesAspire); + } + + test('warns on the line that launches a Java resource through the Spring Boot Maven plugin', async () => { + const appHostPath = p('repo', 'AppHost', 'AppHost.cs'); + const content = [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'builder.AddJavaApp("api", "../api")', + ' .WithMavenGoal("spring-boot:run");', + '', + 'builder.Build().Run();', + ].join('\n'); + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + + assert.strictEqual(lenses.length, 1); + assert.strictEqual(lenses[0].range.start.line, 3, 'the warning belongs on the goal, not the Add call'); + assert.strictEqual(lenses[0].command?.tooltip, codeLensSpringBootDashboardBypassesAspireTooltip); + // Rendered as plain text: there is no Spring Boot Dashboard command worth invoking here. + assert.strictEqual(lenses[0].command?.command, ''); + harness.dispose(); + }); + + test('warns on the Gradle bootRun task', async () => { + const appHostPath = p('repo', 'AppHost', 'AppHost.cs'); + const content = [ + 'var builder = DistributedApplication.CreateBuilder(args);', + 'builder.AddJavaApp("api", "../api").WithGradleTask("bootRun");', + ].join('\n'); + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + + assert.strictEqual(lenses.length, 1); + assert.strictEqual(lenses[0].range.start.line, 1); + harness.dispose(); + }); + + test('warns on AddSpringBootApp, which configures the same launch internally', async () => { + // The README leads with this form, so matching only the explicit goal would miss the case + // users are most likely to hit. + const cases: ReadonlyArray = [ + [p('repo', 'AppHost', 'apphost.cs'), 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.AddSpringBootApp("catalog", "../catalog");'], + [p('repo', 'AppHost', 'apphost.ts'), "const builder = createBuilder();\nbuilder.addSpringBootApp('catalog', '../catalog');"], + [p('repo', 'AppHost', 'apphost.rs'), 'fn main() {\n let builder = create_builder(None)?;\n let catalog = builder.add_spring_boot_app("catalog", "../catalog")?;\n}'], + [p('repo', 'AppHost', 'apphost.py'), 'builder = create_builder()\nbuilder.add_spring_boot_app("catalog", "../catalog")'], + [p('repo', 'AppHost', 'AppHost.java'), 'public class AppHost {\n public static void main(String[] args) {\n var builder = DistributedApplication.CreateBuilder();\n builder.addSpringBootApp("catalog", "../catalog");\n }\n}'], + [p('repo', 'AppHost', 'apphost.go'), 'func main() {\n\tbuilder.AddSpringBootApp("catalog", "../catalog")\n}'], + ]; + + for (const [appHostPath, content] of cases) { + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + assert.strictEqual(lenses.length, 1, `expected a warning for ${appHostPath}`); + harness.dispose(); + } + }); + + test('stays silent for AddQuarkusApp, which the Spring Boot Dashboard does not offer to run', async () => { + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + const content = 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.AddQuarkusApp("inventory", "../inventory");'; + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, p('repo', 'AppHost', 'apphost.cs')), cancellationToken) as vscode.CodeLens[]); + assert.strictEqual(lenses.length, 0); + harness.dispose(); + }); + + test('warns regardless of the AppHost language', async () => { + const cases: ReadonlyArray = [ + [p('repo', 'AppHost', 'apphost.ts'), "const builder = createBuilder();\nbuilder.addJavaApp('api', '../api').withGradleTask('bootRun');"], + [p('repo', 'AppHost', 'apphost.js'), 'const builder = createBuilder();\nbuilder.addJavaApp("api", "../api").withMavenGoal("spring-boot:run");'], + [p('repo', 'AppHost', 'apphost.rs'), 'fn main() {\n let builder = create_builder(None)?;\n let api = builder.add_java_app("api", "../api")?.with_maven_goal("spring-boot:run")?;\n}'], + ]; + + for (const [appHostPath, content] of cases) { + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + assert.strictEqual(lenses.length, 1, `expected a warning for ${appHostPath}`); + harness.dispose(); + } + }); + + test('warns in AppHost languages that have no resource parser', async () => { + // These languages produce no state or action lenses because nothing parses their resource + // model, but they can still launch a Java resource through Spring Boot's build plugins. + const cases: ReadonlyArray = [ + [p('repo', 'AppHost', 'apphost.py'), 'builder = create_builder()\nbuilder.add_java_app("api", "../api").with_gradle_task("bootRun")'], + [p('repo', 'AppHost', 'apphost.go'), 'func main() {\n\tbuilder.AddJavaApp("api", "../api").WithMavenGoal("spring-boot:run")\n}'], + ]; + + for (const [appHostPath, content] of cases) { + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + assert.strictEqual(lenses.length, 1, `expected a warning for ${appHostPath}`); + harness.dispose(); + } + }); + + test('stays silent for a source file that is not the AppHost', async () => { + // The parserless languages have nothing narrowing the document to an AppHost, so without a + // file-name gate every Python and Go file in the workspace would be scanned - and any + // that happened to contain the call would get an Aspire lens. + const cases: ReadonlyArray = [ + [p('repo', 'src', 'Application.java'), 'class Application {\n void configure() {\n builder.addJavaApp("api", "../api").withMavenGoal("spring-boot:run");\n }\n}'], + [p('repo', 'src', 'helpers.py'), 'builder.add_java_app("api", "../api").with_gradle_task("bootRun")'], + [p('repo', 'src', 'main.go'), 'func run() {\n\tbuilder.AddJavaApp("api", "../api").WithMavenGoal("spring-boot:run")\n}'], + ]; + + for (const [filePath, content] of cases) { + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, filePath), cancellationToken) as vscode.CodeLens[]); + assert.strictEqual(lenses.length, 0, `expected no warning for ${filePath}`); + harness.dispose(); + } + }); + + test('warns in a nested AppHost that keeps the conventional file name', async () => { + // A Maven or Gradle Java AppHost sits at the build tool's source root rather than next to + // the project file, so content-based parser detection has to work no matter how deep the + // AppHost.java source file is nested. + const appHostPath = p('repo', 'AppHost', 'src', 'main', 'java', 'AppHost.java'); + const content = 'public class AppHost {\n public static void main(String[] args) {\n var builder = DistributedApplication.CreateBuilder();\n builder.addJavaApp("api", "../api").withMavenGoal("spring-boot:run");\n }\n}'; + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + + assert.strictEqual(lenses.length, 1); + harness.dispose(); + }); + + test('stays silent for a commented-out launch in Java and languages that have no parser', async () => { + const cases: ReadonlyArray = [ + [p('repo', 'AppHost', 'AppHost.java'), 'public class AppHost {\n public static void main(String[] args) {\n var builder = DistributedApplication.CreateBuilder();\n // builder.addJavaApp("api", "../api").withMavenGoal("spring-boot:run");\n /* builder.addJavaApp("b", "../b").withGradleTask("bootRun"); */\n }\n}'], + [p('repo', 'AppHost', 'apphost.py'), '# builder.add_java_app("api", "../api").with_maven_goal("spring-boot:run")'], + [p('repo', 'AppHost', 'apphost.go'), 'func main() {\n\t// builder.AddJavaApp("api", "../api").WithMavenGoal("spring-boot:run")\n}'], + ]; + + for (const [appHostPath, content] of cases) { + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + assert.strictEqual(lenses.length, 0, `expected no warning for ${appHostPath}`); + harness.dispose(); + } + }); + + test('still warns on a live Java launch that follows a commented-out one', async () => { + const appHostPath = p('repo', 'AppHost', 'AppHost.java'); + const content = [ + 'public class AppHost {', + ' public static void main(String[] args) {', + ' var builder = DistributedApplication.CreateBuilder();', + ' // builder.addJavaApp("old", "../old").withMavenGoal("spring-boot:run");', + ' builder.addJavaApp("api", "../api").withMavenGoal("spring-boot:run");', + ' }', + '}', + ].join('\n'); + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + + assert.strictEqual(lenses.length, 1); + assert.strictEqual(lenses[0].range.start.line, 4); + harness.dispose(); + }); + + test('stays silent for a launch quoted inside a Java text block', async () => { + const appHostPath = p('repo', 'AppHost', 'AppHost.java'); + const content = [ + 'public class AppHost {', + ' public static void main(String[] args) {', + ' var builder = DistributedApplication.CreateBuilder();', + ' }', + ' static final String DOCS = """', + ' builder.addJavaApp("api", "../api").withMavenGoal("spring-boot:run");', + ' """;', + '}', + ].join('\n'); + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + + assert.strictEqual(lenses.length, 0); + harness.dispose(); + }); + + test('warns once per line and once per resource', async () => { + const appHostPath = p('repo', 'AppHost', 'AppHost.cs'); + const content = [ + 'var builder = DistributedApplication.CreateBuilder(args);', + 'builder.AddJavaApp("api", "../api").WithMavenGoal("spring-boot:run");', + 'builder.AddJavaApp("worker", "../worker").WithGradleTask("bootRun");', + ].join('\n'); + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + + assert.deepStrictEqual(lenses.map(lens => lens.range.start.line), [1, 2]); + harness.dispose(); + }); + + test('stays silent when the Spring Boot Dashboard is not installed', async () => { + const appHostPath = p('repo', 'AppHost', 'AppHost.cs'); + const content = [ + 'var builder = DistributedApplication.CreateBuilder(args);', + 'builder.AddJavaApp("api", "../api").WithMavenGoal("spring-boot:run");', + ].join('\n'); + const harness = createHarness({}); + + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + + assert.strictEqual(lenses.length, 0); + harness.dispose(); + }); + + test('stays silent for build-tool launches that are not Spring Boot', async () => { + const appHostPath = p('repo', 'AppHost', 'AppHost.cs'); + const content = [ + 'var builder = DistributedApplication.CreateBuilder(args);', + 'builder.AddJavaApp("api", "../api").WithMavenGoal("exec:java");', + 'builder.AddJavaApp("worker", "../worker").WithGradleTask("run");', + ].join('\n'); + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + + assert.strictEqual(lenses.length, 0); + harness.dispose(); + }); + + test('stays silent for a commented-out launch', async () => { + // Users leave disabled variants in place while iterating; warning about a resource that + // does not exist is misleading. + const cases: ReadonlyArray = [ + [p('repo', 'AppHost', 'AppHost.cs'), [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '// builder.AddJavaApp("api", "../api").WithMavenGoal("spring-boot:run");', + '/* builder.AddJavaApp("worker", "../worker").WithGradleTask("bootRun"); */', + ]], + [p('repo', 'AppHost', 'apphost.ts'), [ + 'const builder = createBuilder();', + "// builder.addJavaApp('api', '../api').withMavenGoal('spring-boot:run');", + "/* builder.addJavaApp('worker', '../worker').withGradleTask('bootRun'); */", + ]], + [p('repo', 'AppHost', 'apphost.rs'), [ + 'fn main() {', + ' let builder = create_builder(None)?;', + ' // builder.add_java_app("api", "../api")?.with_maven_goal("spring-boot:run")?;', + ' /* builder.add_java_app("worker", "../worker")?.with_gradle_task("bootRun")?; */', + '}', + ]], + ]; + + for (const [appHostPath, lines] of cases) { + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(lines.join('\n'), appHostPath), cancellationToken) as vscode.CodeLens[]); + assert.strictEqual(lenses.length, 0, `expected no warning for ${appHostPath}, got ${lenses.length}`); + harness.dispose(); + } + }); + + test('stays silent for a launch quoted inside a string literal', async () => { + const cases: ReadonlyArray = [ + [p('repo', 'AppHost', 'AppHost.cs'), + 'var builder = DistributedApplication.CreateBuilder(args);\nvar docs = "call .WithMavenGoal(\\"spring-boot:run\\") to use the plugin";'], + [p('repo', 'AppHost', 'apphost.ts'), + 'const builder = createBuilder();\nconst docs = `call .withMavenGoal("spring-boot:run") to use the plugin`;'], + [p('repo', 'AppHost', 'apphost.rs'), + 'fn main() {\n let builder = create_builder(None)?;\n let docs = "call .with_maven_goal(\\"spring-boot:run\\") to use the plugin";\n}'], + ]; + + for (const [appHostPath, content] of cases) { + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + assert.strictEqual(lenses.length, 0, `expected no warning for ${appHostPath}, got ${lenses.length}`); + harness.dispose(); + } + }); + + test('still warns on a real launch that shares a document with a commented-out one', async () => { + const appHostPath = p('repo', 'AppHost', 'AppHost.cs'); + const content = [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '// builder.AddJavaApp("old", "../old").WithMavenGoal("spring-boot:run");', + 'builder.AddJavaApp("api", "../api").WithMavenGoal("spring-boot:run");', + ].join('\n'); + const harness = createHarness({ installedExtensions: [springBootDashboard] }); + + const lenses = springBootLenses(await harness.provider.provideCodeLenses(createMockDocument(content, appHostPath), cancellationToken) as vscode.CodeLens[]); + + assert.deepStrictEqual(lenses.map(lens => lens.range.start.line), [2]); + harness.dispose(); + }); + }); }); diff --git a/extension/src/test/aspireDebugConfigurationProvider.test.ts b/extension/src/test/aspireDebugConfigurationProvider.test.ts index 7cd329878ce..29d3a7bc1e3 100644 --- a/extension/src/test/aspireDebugConfigurationProvider.test.ts +++ b/extension/src/test/aspireDebugConfigurationProvider.test.ts @@ -8,18 +8,27 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { AspireDebugConfigurationProvider, type ExternalLaunchReservation } from '../debugger/AspireDebugConfigurationProvider'; import { appHostLaunchReservationIdConfigKey, appHostLaunchTokenConfigKey, appHostSelectionOriginConfigKey } from '../debugger/AspireDebugConfigurationMetadata'; -import { isAspireDebugConfigurationExtensionOwned, markAspireDebugConfigurationAsExtensionOwned, stripAspireDebugConfigurationProviderInternalProperties } from '../debugger/AspireDebugConfigurationProviderInternal'; +import { isAspireDebugConfigurationExtensionOwned, markAspireDebugConfigurationAsExtensionOwned, markAspireDebugConfigurationWithResolvedCliPath, markAspireDebugConfigurationWithResolvedCliPathScope, stripAspireDebugConfigurationProviderInternalProperties } from '../debugger/AspireDebugConfigurationProviderInternal'; import type { AspireExtendedDebugConfiguration } from '../dcp/types'; +import { defaultConfigurationName, defaultConfigurationNameForWorkspaceFolder } from '../loc/strings'; import * as cliPathModule from '../utils/cliPath'; +import { getCliPathTargetKey, windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; +import { removeDirectorySafely } from './testHelpers'; /** Captures the AppHost paths the provider claims for `launch.json`/F5 launches. */ class RecordingLaunchReservation implements ExternalLaunchReservation { readonly reserved: string[] = []; readonly directoryScoped: string[] = []; readonly replacements: { previousAppHostPath: string; previousReservationId: string; appHostPath: string }[] = []; + readonly validations: { appHostPath: string; reservationId: string; isDirectoryScope: boolean }[] = []; + readonly released: { appHostPath: string; reservationId: string }[] = []; + readonly prepared: { appHostPath: string; command: string; args: string[] | undefined; cliPath: string | undefined }[] = []; /** When set, the claim is refused as if a lifecycle-owned launch already held it. */ claimedByLifecycle = false; + validationResult: string | false | undefined; + preparedArgs: string[] | undefined; + preparationError: Error | undefined; tryReserveExternalLaunch(appHostPath: string, isDirectoryScope = false): string | false { this.reserved.push(appHostPath); @@ -33,22 +42,66 @@ class RecordingLaunchReservation implements ExternalLaunchReservation { this.replacements.push({ previousAppHostPath, previousReservationId, appHostPath }); return this.tryReserveExternalLaunch(appHostPath, isDirectoryScope); } + + validateOrReacquireExternalLaunchReservation(appHostPath: string, reservationId: string, isDirectoryScope = false): string | false { + this.validations.push({ appHostPath, reservationId, isDirectoryScope }); + return this.validationResult ?? reservationId; + } + + releaseExternalLaunchReservation(appHostPath: string, reservationId: string): void { + this.released.push({ appHostPath, reservationId }); + } + + async prepareLaunchArguments( + appHostPath: string, + command: string, + args: string[] | undefined, + token: vscode.CancellationToken, + cliPath?: string, + _target?: import('../utils/cliPathVariables').CliPathResolutionTarget, + ): Promise<{ args: string[] | undefined }> { + this.prepared.push({ + appHostPath, + command, + args: args ? [...args] : undefined, + cliPath, + }); + + if (token.isCancellationRequested) { + throw new vscode.CancellationError(); + } + if (this.preparationError) { + throw this.preparationError; + } + + return { args: this.preparedArgs ?? args }; + } } suite('AspireDebugConfigurationProvider', () => { let tempDir: string; let sandbox: sinon.SinonSandbox; let launchReservation: RecordingLaunchReservation; + let resolveCliPathStub: sinon.SinonStub; + let tryExecuteCliStub: sinon.SinonStub; + let workspaceState: TestMemento; setup(() => { sandbox = sinon.createSandbox(); launchReservation = new RecordingLaunchReservation(); + workspaceState = new TestMemento(); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-debug-configuration-provider-')); + resolveCliPathStub = sandbox.stub(cliPathModule, 'resolveCliPath').resolves({ + cliPath: '/resolved/aspire', + available: true, + source: 'configured', + }); + tryExecuteCliStub = sandbox.stub(cliPathModule, 'tryExecuteCli').resolves(true); }); teardown(() => { sandbox.restore(); - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); }); test('resolves launch config SDK-style AppHost Program.cs to containing project file', async () => { @@ -60,7 +113,7 @@ suite('AspireDebugConfigurationProvider', () => { fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();'); fs.writeFileSync(projectPath, ''); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(projectPath), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Debug AppHost', type: 'aspire', @@ -76,7 +129,7 @@ suite('AspireDebugConfigurationProvider', () => { const appHostPath = path.join(tempDir, 'apphost.cs'); fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);'); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Debug AppHost', type: 'aspire', @@ -91,7 +144,7 @@ suite('AspireDebugConfigurationProvider', () => { const appHostPath = path.join(tempDir, 'apphost.ts'); fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";'); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath, appHostPath, 'typescript/nodejs'), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(appHostPath, appHostPath, 'typescript/nodejs'), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Debug AppHost', type: 'aspire', @@ -114,7 +167,7 @@ suite('AspireDebugConfigurationProvider', () => { fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();'); fs.writeFileSync(projectPath, ''); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(projectPath), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Debug AppHost', type: 'aspire', @@ -135,7 +188,7 @@ suite('AspireDebugConfigurationProvider', () => { const appHostPath = path.join(tempDir, 'AppHost.csproj'); fs.writeFileSync(appHostPath, ''); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Publish AppHost', type: 'aspire', @@ -163,7 +216,7 @@ suite('AspireDebugConfigurationProvider', () => { // from the folder itself for the configuration to look like the `${workspaceFolder}` // one VS Code substitutes. const folderPath = folder.uri.fsPath; - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(projectPath), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(folder, { name: 'Debug AppHost', type: 'aspire', @@ -178,7 +231,7 @@ suite('AspireDebugConfigurationProvider', () => { test('reserves the workspace directory while F5 defers AppHost selection to the CLI', async () => { const folder = createWorkspaceFolder(path.join(tempDir, 'workspace')); - const provider = new AspireDebugConfigurationProvider( + const provider = createProvider( createAppHostDiscoveryService(folder.uri.fsPath, null), launchReservation); @@ -209,7 +262,7 @@ suite('AspireDebugConfigurationProvider', () => { ? { path: candidatePath, language: 'csharp', status: 'buildable' } : undefined, } as unknown as AppHostDiscoveryService; - const provider = new AspireDebugConfigurationProvider(discoveryService, launchReservation); + const provider = createProvider(discoveryService, launchReservation); const config: vscode.DebugConfiguration = { name: 'Debug AppHost', type: 'aspire', @@ -242,7 +295,7 @@ suite('AspireDebugConfigurationProvider', () => { launchReservation.claimedByLifecycle = true; const message = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Debug AppHost', type: 'aspire', @@ -260,7 +313,7 @@ suite('AspireDebugConfigurationProvider', () => { launchReservation.claimedByLifecycle = true; const message = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Debug AppHost', type: 'aspire', @@ -278,7 +331,7 @@ suite('AspireDebugConfigurationProvider', () => { const appHostPath = path.join(tempDir, 'AppHost.csproj'); fs.writeFileSync(appHostPath, ''); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Publish AppHost', type: 'aspire', @@ -292,10 +345,103 @@ suite('AspireDebugConfigurationProvider', () => { assert.deepStrictEqual(launchReservation.reserved, []); }); + test('does not preserve a launch.json reservation ID after phase one trusts the resolved CLI', async () => { + const appHostPath = path.join(tempDir, 'AppHost.csproj'); + fs.writeFileSync(appHostPath, ''); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const input: vscode.DebugConfiguration = { + name: 'Publish AppHost', + type: 'aspire', + request: 'launch', + command: 'publish', + program: appHostPath, + [appHostLaunchReservationIdConfigKey]: 'forged-reservation', + }; + + const phaseOne = await provider.resolveDebugConfiguration(undefined, input); + const phaseTwo = phaseOne + ? await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, phaseOne) + : undefined; + + assert.strictEqual(phaseTwo?.[appHostLaunchReservationIdConfigKey], undefined); + assert.strictEqual((phaseTwo as AspireExtendedDebugConfiguration | undefined)?.resolvedCliPath, '/resolved/aspire'); + assert.deepStrictEqual(launchReservation.reserved, []); + }); + + test('uses the resolved CLI path when a launch.json pinned CLI path is untrusted', async () => { + const appHostPath = path.join(tempDir, 'AppHost.csproj'); + fs.writeFileSync(appHostPath, ''); + launchReservation.preparedArgs = ['--isolated']; + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath, + resolvedCliPath: '/forged/aspire', + }); + + assert.strictEqual((config as AspireExtendedDebugConfiguration | undefined)?.resolvedCliPath, '/resolved/aspire'); + assert.deepStrictEqual(config?.args, ['--isolated']); + assert.deepStrictEqual(launchReservation.prepared, [{ + appHostPath, + command: 'run', + args: undefined, + cliPath: '/resolved/aspire', + }]); + }); + + test('preserves the launch service pinned CLI path', async () => { + const appHostPath = path.join(tempDir, 'AppHost.csproj'); + fs.writeFileSync(appHostPath, ''); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const input = { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath, + resolvedCliPath: '/selected/aspire', + }; + markAspireDebugConfigurationAsExtensionOwned(input); + + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, input); + + assert.strictEqual((config as AspireExtendedDebugConfiguration | undefined)?.resolvedCliPath, '/selected/aspire'); + }); + + test('uses the trusted pinned CLI path when preparing run arguments', async () => { + const appHostPath = path.join(tempDir, 'AppHost.csproj'); + fs.writeFileSync(appHostPath, ''); + launchReservation.preparedArgs = ['--isolated']; + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const input = { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath, + resolvedCliPath: '/trusted/aspire', + }; + markAspireDebugConfigurationWithResolvedCliPath(input, '/trusted/aspire'); + markAspireDebugConfigurationWithResolvedCliPathScope(input, getCliPathTargetKey(windowCliPathTarget)); + + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, input); + + assert.strictEqual((config as AspireExtendedDebugConfiguration | undefined)?.resolvedCliPath, '/trusted/aspire'); + assert.deepStrictEqual(config?.args, ['--isolated']); + assert.deepStrictEqual(launchReservation.prepared, [{ + appHostPath, + command: 'run', + args: undefined, + cliPath: '/trusted/aspire', + }]); + assert.strictEqual(resolveCliPathStub.called, false); + }); + test('reuses one reservation across repeated resolver passes for an external launch', async () => { const appHostPath = path.join(tempDir, 'AppHost.csproj'); fs.writeFileSync(appHostPath, ''); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); const config: vscode.DebugConfiguration = { name: 'Debug AppHost', type: 'aspire', @@ -309,10 +455,110 @@ suite('AspireDebugConfigurationProvider', () => { : undefined; assert.deepStrictEqual(launchReservation.reserved, [appHostPath]); + assert.deepStrictEqual(launchReservation.validations, [{ + appHostPath, + reservationId: 'reservation-1', + isDirectoryScope: false, + }]); assert.strictEqual(firstPass?.[appHostLaunchReservationIdConfigKey], 'reservation-1'); assert.strictEqual(secondPass?.[appHostLaunchReservationIdConfigKey], 'reservation-1'); }); + test('uses a reacquired reservation when a repeated resolver pass outlives the original reservation', async () => { + const appHostPath = path.join(tempDir, 'AppHost.csproj'); + fs.writeFileSync(appHostPath, ''); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const firstPass = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath, + }); + assert.ok(firstPass); + launchReservation.validationResult = 'reservation-2'; + + const secondPass = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, firstPass); + + assert.strictEqual(secondPass?.[appHostLaunchReservationIdConfigKey], 'reservation-2'); + assert.deepStrictEqual(launchReservation.validations, [{ + appHostPath, + reservationId: 'reservation-1', + isDirectoryScope: false, + }]); + }); + + test('cancels a stale repeated resolver pass when a newer launch owns the AppHost', async () => { + const appHostPath = path.join(tempDir, 'AppHost.csproj'); + fs.writeFileSync(appHostPath, ''); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const firstPass = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath, + }); + assert.ok(firstPass); + launchReservation.validationResult = false; + const message = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); + + const secondPass = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, firstPass); + + assert.strictEqual(secondPass, undefined); + assert.strictEqual(message.calledOnce, true); + assert.deepStrictEqual(launchReservation.validations, [{ + appHostPath, + reservationId: 'reservation-1', + isDirectoryScope: false, + }]); + }); + + test('releases the repeated-pass reservation when argument preparation fails', async () => { + const appHostPath = path.join(tempDir, 'AppHost.csproj'); + fs.writeFileSync(appHostPath, ''); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const firstPass = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath, + }); + assert.ok(firstPass); + launchReservation.preparationError = new Error('preparation failed'); + + await assert.rejects( + provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, firstPass), + /preparation failed/); + + assert.deepStrictEqual(launchReservation.released, [{ + appHostPath, + reservationId: 'reservation-1', + }]); + }); + + test('releases the repeated-pass reservation when argument preparation is canceled', async () => { + const appHostPath = path.join(tempDir, 'AppHost.csproj'); + fs.writeFileSync(appHostPath, ''); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const firstPass = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: appHostPath, + }); + assert.ok(firstPass); + const cancellation = new vscode.CancellationTokenSource(); + cancellation.cancel(); + + await assert.rejects( + provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, firstPass, cancellation.token), + error => error instanceof vscode.CancellationError); + + assert.deepStrictEqual(launchReservation.released, [{ + appHostPath, + reservationId: 'reservation-1', + }]); + }); + test('reuses one reservation when repeated resolver passes use equivalent AppHost paths', async () => { const appHostDirectory = path.join(tempDir, 'AppHost'); const projectPath = path.join(appHostDirectory, 'AppHost.csproj'); @@ -331,7 +577,7 @@ suite('AspireDebugConfigurationProvider', () => { status: 'buildable', }), } as unknown as AppHostDiscoveryService; - const provider = new AspireDebugConfigurationProvider(discoveryService, launchReservation); + const provider = createProvider(discoveryService, launchReservation); const config: vscode.DebugConfiguration = { name: 'Debug AppHost', type: 'aspire', @@ -372,7 +618,7 @@ suite('AspireDebugConfigurationProvider', () => { status: 'buildable', }), } as unknown as AppHostDiscoveryService; - const provider = new AspireDebugConfigurationProvider(discoveryService, launchReservation); + const provider = createProvider(discoveryService, launchReservation); const config: vscode.DebugConfiguration = { name: 'Debug AppHost', type: 'aspire', @@ -419,7 +665,7 @@ suite('AspireDebugConfigurationProvider', () => { status: 'buildable', }), } as unknown as AppHostDiscoveryService; - const provider = new AspireDebugConfigurationProvider(discoveryService, launchReservation); + const provider = createProvider(discoveryService, launchReservation); const config: vscode.DebugConfiguration = { name: 'Debug AppHost', type: 'aspire', @@ -452,7 +698,7 @@ suite('AspireDebugConfigurationProvider', () => { fs.writeFileSync(appHostPath, ''); launchReservation.claimedByLifecycle = true; - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); const debugConfiguration: vscode.DebugConfiguration = { name: 'Debug AppHost', type: 'aspire', @@ -481,7 +727,7 @@ suite('AspireDebugConfigurationProvider', () => { fs.writeFileSync(appHostPath, ''); launchReservation.claimedByLifecycle = true; - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); const config: vscode.DebugConfiguration = { name: 'Debug AppHost', type: 'aspire', @@ -509,7 +755,7 @@ suite('AspireDebugConfigurationProvider', () => { const appHostPath = path.join(tempDir, 'apphost.ts'); fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";'); - const provider = new AspireDebugConfigurationProvider( + const provider = createProvider( createAppHostDiscoveryService(appHostPath, appHostPath, 'typescript/nodejs'), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { @@ -531,7 +777,7 @@ suite('AspireDebugConfigurationProvider', () => { fs.writeFileSync(programPath, 'Console.WriteLine("Hello");'); fs.writeFileSync(path.join(appDirectory, 'App.csproj'), ''); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(programPath), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Debug AppHost', type: 'aspire', @@ -545,7 +791,7 @@ suite('AspireDebugConfigurationProvider', () => { test('leaves workspace folder launch target unchanged and records AppHost telemetry target', async () => { const folder = createWorkspaceFolder(tempDir); const appHostPath = path.join(tempDir, 'NestedAppHost', 'apphost.ts'); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(appHostPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(appHostPath), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(folder, { name: 'Debug AppHost', @@ -564,7 +810,7 @@ suite('AspireDebugConfigurationProvider', () => { const workspacePath = path.join(tempDir, 'workspace'); const programPath = path.join(tempDir, 'Workspace'); const folder = createWorkspaceFolder(workspacePath); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(programPath), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(folder, { name: 'Debug AppHost', @@ -576,25 +822,84 @@ suite('AspireDebugConfigurationProvider', () => { assert.strictEqual(config?.[appHostSelectionOriginConfigKey], 'explicit-launch-configuration'); }); - test('provides dynamic launch config when active file resolves to AppHost candidate', async () => { + test('keeps the legacy dynamic launch config name in a single-folder workspace', async () => { const folder = createWorkspaceFolder(tempDir); const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); const projectPath = path.join(tempDir, 'AppHost', 'AppHost.csproj'); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(projectPath), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(projectPath), launchReservation); + sandbox.stub(vscode.workspace, 'workspaceFolders').value([folder]); setActiveEditor(programPath, folder); const configs = await provider.provideDebugConfigurations(folder); - assert.strictEqual(configs.length, 1); - assert.strictEqual(configs[0].program, projectPath); - assert.strictEqual(configs[0][appHostSelectionOriginConfigKey], 'default-discovery'); + const config = getOnlyConfiguration(configs); + assert.strictEqual(config.name, defaultConfigurationName); + assert.strictEqual(config.program, projectPath); + assert.strictEqual(config[appHostSelectionOriginConfigKey], 'default-discovery'); + assert.strictEqual(config.presentation, undefined); + }); + + test('keeps the original dynamic launch config owner when another workspace root is added', async () => { + const folder = createWorkspaceFolder(path.join(tempDir, 'repo-with-apphost'), 'src'); + const otherFolder = createWorkspaceFolder(path.join(tempDir, 'repo-docs'), 'docs', 1); + const provider = createProvider(createAppHostDiscoveryService(folder.uri.fsPath, null), launchReservation); + let workspaceFolders = [folder]; + sandbox.stub(vscode.workspace, 'workspaceFolders').get(() => workspaceFolders); + sandbox.stub(vscode.window, 'activeTextEditor').value(undefined); + + const singleRootConfigs = await provider.provideDebugConfigurations(folder); + workspaceFolders = [folder, otherFolder]; + const multiRootConfigs = await provider.provideDebugConfigurations(folder); + + assert.strictEqual(getOnlyConfiguration(singleRootConfigs).name, defaultConfigurationName); + assert.strictEqual(getOnlyConfiguration(multiRootConfigs).name, defaultConfigurationName); + }); + + test('uses a URI-qualified dynamic launch config name for a non-owner workspace folder', async () => { + const ownerFolder = createWorkspaceFolder(path.join(tempDir, 'repo-with-apphost'), 'src'); + const otherFolder = createWorkspaceFolder(path.join(tempDir, 'repo-docs'), 'docs', 1); + const provider = createProvider(createAppHostDiscoveryService(tempDir, null), launchReservation); + sandbox.stub(vscode.workspace, 'workspaceFolders').value([ownerFolder, otherFolder]); + sandbox.stub(vscode.window, 'activeTextEditor').value(undefined); + + await provider.provideDebugConfigurations(ownerFolder); + const configs = await provider.provideDebugConfigurations(otherFolder); + + assert.strictEqual( + getOnlyConfiguration(configs).name, + defaultConfigurationNameForWorkspaceFolder(otherFolder.name, otherFolder.uri.toString())); + }); + + test('does not reassign the legacy dynamic launch config name while its owner is absent', async () => { + const ownerFolder = createWorkspaceFolder(path.join(tempDir, 'repo-with-apphost'), 'src'); + const remainingFolder = createWorkspaceFolder(path.join(tempDir, 'repo-docs'), 'docs', 1); + let workspaceFolders = [ownerFolder, remainingFolder]; + sandbox.stub(vscode.workspace, 'workspaceFolders').get(() => workspaceFolders); + sandbox.stub(vscode.window, 'activeTextEditor').value(undefined); + + const provider = createProvider(createAppHostDiscoveryService(tempDir, null), launchReservation); + const ownerConfig = getOnlyConfiguration(await provider.provideDebugConfigurations(ownerFolder)); + const remainingConfig = getOnlyConfiguration(await provider.provideDebugConfigurations(remainingFolder)); + + workspaceFolders = [remainingFolder]; + const providerAfterReload = createProvider(createAppHostDiscoveryService(tempDir, null), launchReservation); + const remainingConfigAfterOwnerRemoval = getOnlyConfiguration(await providerAfterReload.provideDebugConfigurations(remainingFolder)); + + workspaceFolders = [remainingFolder, ownerFolder]; + const ownerConfigAfterReadd = getOnlyConfiguration(await providerAfterReload.provideDebugConfigurations(ownerFolder)); + + const remainingName = defaultConfigurationNameForWorkspaceFolder(remainingFolder.name, remainingFolder.uri.toString()); + assert.strictEqual(ownerConfig.name, defaultConfigurationName); + assert.strictEqual(remainingConfig.name, remainingName); + assert.strictEqual(remainingConfigAfterOwnerRemoval.name, remainingName); + assert.strictEqual(ownerConfigAfterReadd.name, defaultConfigurationName); }); test('omits selection origin from launch configurations written to launch.json', async () => { const folder = createWorkspaceFolder(tempDir); const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); const projectPath = path.join(tempDir, 'AppHost', 'AppHost.csproj'); - const provider = new AspireDebugConfigurationProvider( + const provider = createProvider( createAppHostDiscoveryService(projectPath), launchReservation, vscode.DebugConfigurationProviderTriggerKind.Initial); @@ -603,48 +908,81 @@ suite('AspireDebugConfigurationProvider', () => { const configs = await provider.provideDebugConfigurations(folder); assert.strictEqual(configs.length, 1); + assert.strictEqual(configs[0].name, defaultConfigurationName); assert.strictEqual(configs[0].program, projectPath); + assert.strictEqual(configs[0].presentation, undefined); assert.ok(!(appHostSelectionOriginConfigKey in configs[0])); + assert.strictEqual(workspaceState.getCallCount, 0); + assert.strictEqual(workspaceState.updateCallCount, 0); + }); + + test('uses unique dynamic launch config names across unique and duplicate workspace folder aliases', async () => { + const firstDuplicateFolder = createWorkspaceFolder(path.join(tempDir, 'repo-docs'), 'src', 1); + const secondDuplicateFolder = createWorkspaceFolder(path.join(tempDir, 'repo-with-apphost'), 'src', 2); + const uniqueFolder = createWorkspaceFolder( + path.join(tempDir, 'repo-unique'), + `src: ${firstDuplicateFolder.uri.toString()}`, + 0); + const provider = createProvider(createAppHostDiscoveryService(tempDir, null), launchReservation); + sandbox.stub(vscode.window, 'activeTextEditor').value(undefined); + sandbox.stub(vscode.workspace, 'workspaceFolders').value([uniqueFolder, firstDuplicateFolder, secondDuplicateFolder]); + + const [uniqueConfigs, firstDuplicateConfigs, secondDuplicateConfigs] = await Promise.all([ + provider.provideDebugConfigurations(uniqueFolder), + provider.provideDebugConfigurations(firstDuplicateFolder), + provider.provideDebugConfigurations(secondDuplicateFolder), + ]); + const configurationNames = [ + getOnlyConfiguration(uniqueConfigs).name, + getOnlyConfiguration(firstDuplicateConfigs).name, + getOnlyConfiguration(secondDuplicateConfigs).name, + ]; + + assert.deepStrictEqual( + configurationNames, + [ + defaultConfigurationName, + defaultConfigurationNameForWorkspaceFolder(firstDuplicateFolder.name, firstDuplicateFolder.uri.toString()), + defaultConfigurationNameForWorkspaceFolder(secondDuplicateFolder.name, secondDuplicateFolder.uri.toString()), + ]); + assert.strictEqual(new Set(configurationNames).size, configurationNames.length); }); test('provides default dynamic launch config when active file is not an AppHost candidate', async () => { const folder = createWorkspaceFolder(tempDir); const programPath = path.join(tempDir, 'Web', 'Program.cs'); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(programPath, null), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(programPath, null), launchReservation); setActiveEditor(programPath, folder); const configs = await provider.provideDebugConfigurations(folder); - assert.strictEqual(configs.length, 1); - assert.strictEqual(configs[0].program, folder.uri.fsPath); + assert.strictEqual(getOnlyConfiguration(configs).program, folder.uri.fsPath); }); test('provides default dynamic launch config when discovery fails', async () => { const folder = createWorkspaceFolder(tempDir); const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); - const provider = new AspireDebugConfigurationProvider(createFailingAppHostDiscoveryService(), launchReservation); + const provider = createProvider(createFailingAppHostDiscoveryService(), launchReservation); setActiveEditor(programPath, folder); const configs = await provider.provideDebugConfigurations(folder); - assert.strictEqual(configs.length, 1); - assert.strictEqual(configs[0].program, folder.uri.fsPath); + assert.strictEqual(getOnlyConfiguration(configs).program, folder.uri.fsPath); }); test('provides default dynamic launch config when there is no active editor', async () => { const folder = createWorkspaceFolder(tempDir); - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService(folder.uri.fsPath, null), launchReservation); + const provider = createProvider(createAppHostDiscoveryService(folder.uri.fsPath, null), launchReservation); sandbox.stub(vscode.window, 'activeTextEditor').value(undefined); const configs = await provider.provideDebugConfigurations(folder); - assert.strictEqual(configs.length, 1); - assert.strictEqual(configs[0].program, folder.uri.fsPath); + assert.strictEqual(getOnlyConfiguration(configs).program, folder.uri.fsPath); }); test('leaves launch config program unchanged when debug target resolution fails', async () => { const programPath = path.join(tempDir, 'AppHost', 'Program.cs'); - const provider = new AspireDebugConfigurationProvider(createFailingAppHostDiscoveryService(), launchReservation); + const provider = createProvider(createFailingAppHostDiscoveryService(), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Debug AppHost', @@ -657,8 +995,8 @@ suite('AspireDebugConfigurationProvider', () => { }); test('resolveDebugConfiguration keeps skip flag through repeated resolver calls after launch service already checked CLI', async () => { - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); - const resolveCliPathStub = sandbox.stub(cliPathModule, 'resolveCliPath').resolves({ cliPath: 'aspire', available: false, source: 'not-found' }); + const provider = createProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); + resolveCliPathStub.resolves({ cliPath: 'aspire', available: false, source: 'not-found' }); const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); const initialConfig = { @@ -681,8 +1019,189 @@ suite('AspireDebugConfigurationProvider', () => { assert.strictEqual(showErrorMessageStub.called, false); }); + test('resolveDebugConfiguration resolves CLI availability with the supplied workspace folder target', async () => { + const provider = createProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); + const folder = createWorkspaceFolder('/repo'); + resolveCliPathStub.resolves({ cliPath: '/repo/bin/aspire', available: true, source: 'configured' }); + + const config = await provider.resolveDebugConfiguration(folder, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: '/repo/AppHost.csproj', + } as AspireExtendedDebugConfiguration) as AspireExtendedDebugConfiguration | undefined; + + assert.ok(config); + assert.ok(resolveCliPathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); + }); + + test('resolveDebugConfiguration resolves CLI availability from a concrete program owner instead of the supplied folder', async () => { + const folderA = createWorkspaceFolder(path.join(tempDir, 'workspace-a')); + const folderB = createWorkspaceFolder(path.join(tempDir, 'workspace-b')); + const programPath = path.join(folderB.uri.fsPath, 'AppHost.csproj'); + const provider = createProvider(createAppHostDiscoveryService(programPath), launchReservation); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => { + if (uri.fsPath.startsWith(folderA.uri.fsPath)) { + return folderA; + } + if (uri.fsPath.startsWith(folderB.uri.fsPath)) { + return folderB; + } + return undefined; + }); + resolveCliPathStub.resolves({ cliPath: '/workspace-b/bin/aspire', available: true, source: 'configured' }); + + const config = await provider.resolveDebugConfiguration(folderA, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: programPath, + } as AspireExtendedDebugConfiguration) as AspireExtendedDebugConfiguration | undefined; + + assert.ok(config); + assert.ok(resolveCliPathStub.calledOnceWith(workspaceFolderCliPathTarget(folderB))); + }); + + test('resolveDebugConfiguration uses the supplied folder while program variables are unresolved', async () => { + const folder = createWorkspaceFolder(path.join(tempDir, 'workspace')); + const provider = createProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); + const getWorkspaceFolderStub = sandbox.stub(vscode.workspace, 'getWorkspaceFolder'); + resolveCliPathStub.resolves({ cliPath: '/workspace/bin/aspire', available: true, source: 'configured' }); + + const config = await provider.resolveDebugConfiguration(folder, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: '${workspaceFolder}/AppHost.csproj', + } as AspireExtendedDebugConfiguration) as AspireExtendedDebugConfiguration | undefined; + + assert.ok(config); + assert.ok(resolveCliPathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); + assert.ok(getWorkspaceFolderStub.notCalled); + }); + + test('resolveDebugConfiguration resolves CLI availability with the window target when no folder is supplied', async () => { + const provider = createProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); + resolveCliPathStub.resolves({ cliPath: 'aspire', available: true, source: 'path' }); + + const config = await provider.resolveDebugConfiguration(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: '/repo/AppHost.csproj', + } as AspireExtendedDebugConfiguration) as AspireExtendedDebugConfiguration | undefined; + + assert.ok(config); + assert.ok(resolveCliPathStub.calledOnceWith(windowCliPathTarget)); + }); + + test('resolveDebugConfiguration validates the exact CLI pinned for restart', async () => { + const provider = createProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); + const config = { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: '/repo/AppHost.csproj', + resolvedCliPath: '/pinned/aspire', + } as AspireExtendedDebugConfiguration; + markAspireDebugConfigurationWithResolvedCliPath(config, '/pinned/aspire'); + markAspireDebugConfigurationWithResolvedCliPathScope(config, getCliPathTargetKey(windowCliPathTarget)); + + const resolved = await provider.resolveDebugConfiguration(undefined, config); + + assert.ok(resolved); + sinon.assert.calledOnceWithExactly(tryExecuteCliStub, '/pinned/aspire'); + assert.strictEqual(resolveCliPathStub.called, false); + }); + + test('resolveDebugConfiguration aborts restart when its pinned CLI is unavailable', async () => { + const provider = createProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); + tryExecuteCliStub.resolves(false); + sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + const config = { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: '/repo/AppHost.csproj', + resolvedCliPath: '/pinned/aspire', + } as AspireExtendedDebugConfiguration; + markAspireDebugConfigurationWithResolvedCliPath(config, '/pinned/aspire'); + markAspireDebugConfigurationWithResolvedCliPathScope(config, getCliPathTargetKey(windowCliPathTarget)); + + const resolved = await provider.resolveDebugConfiguration(undefined, config); + + assert.strictEqual(resolved, undefined); + sinon.assert.calledOnceWithExactly(tryExecuteCliStub, '/pinned/aspire'); + assert.strictEqual(resolveCliPathStub.called, false); + }); + + test('preserves the CLI path selected by a normal launch availability gate', async () => { + const provider = createProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); + resolveCliPathStub.resolves({ + cliPath: '/verified/aspire', + available: true, + source: 'configured', + }); + + const gatedConfig = await provider.resolveDebugConfiguration(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: '/repo/AppHost.csproj', + resolvedCliPath: '/injected/aspire', + } as AspireExtendedDebugConfiguration); + const config = gatedConfig + ? await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, gatedConfig) as AspireExtendedDebugConfiguration | undefined + : undefined; + + assert.ok(config); + assert.strictEqual(config.resolvedCliPath, '/verified/aspire'); + }); + + test('re-resolves the CLI against the substituted program when variables named another folder', async () => { + const folderA = createWorkspaceFolder(path.join(tempDir, 'workspace-a')); + const folderB = createWorkspaceFolder(path.join(tempDir, 'workspace-b')); + const programPath = path.join(folderB.uri.fsPath, 'AppHost.java'); + const provider = createProvider(createAppHostDiscoveryService(programPath), launchReservation); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => { + if (uri.fsPath.startsWith(folderA.uri.fsPath)) { + return folderA; + } + if (uri.fsPath.startsWith(folderB.uri.fsPath)) { + return folderB; + } + return undefined; + }); + resolveCliPathStub.callsFake(async (target: unknown) => { + const isFolderB = JSON.stringify(target) === JSON.stringify(workspaceFolderCliPathTarget(folderB)); + return { cliPath: isFolderB ? '/workspace-b/bin/aspire' : '/workspace-a/bin/aspire', available: true, source: 'configured' } as never; + }); + + // The gate runs before substitution, so ${workspaceFolder:workspace-b} is still literal and the + // only folder available is the initiating one. That first answer must not be final. + const gatedConfig = await provider.resolveDebugConfiguration(folderA, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: '${workspaceFolder:workspace-b}/AppHost.java', + } as AspireExtendedDebugConfiguration); + + assert.ok(gatedConfig); + + // VS Code substitutes variables in place and passes the same configuration to this hook, so + // the owning folder is now knowable and the CLI must be re-resolved against it. + (gatedConfig as AspireExtendedDebugConfiguration).program = programPath; + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables( + folderA, + gatedConfig as AspireExtendedDebugConfiguration) as AspireExtendedDebugConfiguration | undefined; + + assert.ok(config); + assert.strictEqual(config.resolvedCliPath, '/workspace-b/bin/aspire'); + assert.ok(resolveCliPathStub.calledWith(workspaceFolderCliPathTarget(folderB))); + }); + test('resolveDebugConfigurationWithSubstitutedVariables removes internal skip flag before launch', async () => { - const provider = new AspireDebugConfigurationProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); + const provider = createProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { name: 'Debug AppHost', @@ -696,6 +1215,39 @@ suite('AspireDebugConfigurationProvider', () => { assert.strictEqual(config.skipCliAvailabilityCheck, undefined); }); + test('removes a resolved CLI path injected by a launch configuration', async () => { + const provider = createProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); + + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + command: 'publish', + program: '/repo/AppHost.csproj', + resolvedCliPath: '/untrusted/aspire', + } as AspireExtendedDebugConfiguration) as AspireExtendedDebugConfiguration | undefined; + + assert.ok(config); + assert.strictEqual(config.resolvedCliPath, undefined); + }); + + test('preserves the resolved CLI path on an extension-owned launch', async () => { + const provider = createProvider(createAppHostDiscoveryService('/repo/AppHost.csproj'), launchReservation); + const initialConfig = { + name: 'Debug AppHost', + type: 'aspire', + request: 'launch', + program: '/repo/AppHost.csproj', + resolvedCliPath: '/verified/aspire', + } as AspireExtendedDebugConfiguration; + markAspireDebugConfigurationAsExtensionOwned(initialConfig); + + const config = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, initialConfig) as AspireExtendedDebugConfiguration | undefined; + + assert.ok(config); + assert.strictEqual(config.resolvedCliPath, '/verified/aspire'); + }); + function setActiveEditor(filePath: string, folder: vscode.WorkspaceFolder): void { sandbox.stub(vscode.window, 'activeTextEditor').value({ document: { @@ -704,16 +1256,61 @@ suite('AspireDebugConfigurationProvider', () => { }); sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(folder); } + + function createProvider( + appHostDiscoveryService: AppHostDiscoveryService, + externalLaunchReservation: ExternalLaunchReservation, + triggerKind: vscode.DebugConfigurationProviderTriggerKind = vscode.DebugConfigurationProviderTriggerKind.Dynamic): AspireDebugConfigurationProvider { + return new AspireDebugConfigurationProvider(appHostDiscoveryService, externalLaunchReservation, workspaceState, triggerKind); + } }); -function createWorkspaceFolder(folderPath: string): vscode.WorkspaceFolder { +function createWorkspaceFolder(folderPath: string, name = 'workspace', index = 0): vscode.WorkspaceFolder { return { uri: vscode.Uri.file(folderPath), - name: 'workspace', - index: 0, + name, + index, }; } +function getOnlyConfiguration(configs: vscode.DebugConfiguration[]): vscode.DebugConfiguration { + assert.strictEqual(configs.length, 1); + return configs[0]; +} + +class TestMemento implements vscode.Memento { + private readonly values = new Map(); + + getCallCount = 0; + updateCallCount = 0; + + keys(): readonly string[] { + return [...this.values.keys()]; + } + + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: T): T | undefined { + this.getCallCount++; + return this.values.has(key) ? this.values.get(key) as T : defaultValue; + } + + update(key: string, value: unknown): Thenable { + this.updateCallCount++; + if (value === undefined) { + this.values.delete(key); + } + else { + this.values.set(key, value); + } + + return Promise.resolve(); + } + + setKeysForSync(): void { + } +} + function createAppHostDiscoveryService(resolvedPath: string, candidatePath: string | null = resolvedPath, language = 'csharp'): AppHostDiscoveryService { const createCandidate = () => candidatePath ? { path: candidatePath, diff --git a/extension/src/test/aspireDebugSession.test.ts b/extension/src/test/aspireDebugSession.test.ts index 6b5c2e79f44..80abd9fdf06 100644 --- a/extension/src/test/aspireDebugSession.test.ts +++ b/extension/src/test/aspireDebugSession.test.ts @@ -4,18 +4,24 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import type { ChildProcessWithoutNullStreams } from 'node:child_process'; -import { dirname, join } from 'node:path'; +import { delimiter as pathDelimiter, dirname, join } from 'node:path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import { createWorkspaceFolder, fsPathOf, removeDirectorySafely } from './testHelpers'; import * as cliModule from '../utils/process/cliProcess'; +import * as cliPathModule from '../utils/cliPath'; import * as debuggerExtensionsModule from '../debugger/debuggerExtensions'; import { AspireDebugSession, buildAspireCommandArgs, getLoggableDebugConfiguration, markDebugConfigurationEnvironmentSensitive } from '../debugger/AspireDebugSession'; +import { AspireDebugConfigurationProvider } from '../debugger/AspireDebugConfigurationProvider'; import { extensionLogOutputChannel } from '../utils/logging'; -import { appHostTelemetryTargetPathConfigKey } from '../debugger/AspireDebugConfigurationMetadata'; -import { AspireResourceExtendedDebugConfiguration, RustLaunchConfiguration } from '../dcp/types'; +import { appHostLaunchReservationIdConfigKey, appHostTelemetryTargetPathConfigKey } from '../debugger/AspireDebugConfigurationMetadata'; +import { isAspireDebugConfigurationExtensionOwned } from '../debugger/AspireDebugConfigurationProviderInternal'; +import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { AspireExtendedDebugConfiguration, AspireResourceExtendedDebugConfiguration, JavaLaunchConfiguration, RustLaunchConfiguration } from '../dcp/types'; import { __resetCommonPropertiesForTests, __setReporterForTests } from '../utils/telemetry'; import { aspireDashboard, debugSessionStopTimedOut } from '../loc/strings'; import { registerRunCleanup } from '../debugger/runCleanupRegistry'; +import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; interface RecordedEvent { name: string; @@ -64,7 +70,7 @@ suite('AspireDebugSession tests', () => { __resetCommonPropertiesForTests(); for (const dir of tempDirs) { if (existsSync(dir)) { - rmSync(dir, { recursive: true, force: true }); + removeDirectorySafely(dir); } } tempDirs.length = 0; @@ -158,17 +164,177 @@ suite('AspireDebugSession tests', () => { } }); - test('terminateCliProcessTree signals a running CLI process and still collects an exited one', () => { + function createSessionWithConfiguration( + configuration: Record, + getAspireCliExecutablePath: sinon.SinonStub, + ): AspireDebugSession { + const parentDebugSession = { + id: 'aspire-session', + configuration, + } as unknown as vscode.DebugSession; + + return new AspireDebugSession( + parentDebugSession, + { onNewConnection: () => ({ dispose: () => { } }) } as any, + { recordAppHostProcessExit: () => { } } as any, + { getAspireCliExecutablePath, createEnvironment: () => ({}) } as any, + () => { }); + } + + test('spawnAspireCommand resolves the CLI using the target derived from the AppHost path', async () => { + sinon.stub(cliModule, 'spawnCliProcess').returns(createFakeCliProcess(4330)); + const folder = createWorkspaceFolder('workspace', '/workspace'); + sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => + uri.fsPath === fsPathOf('/workspace/AppHost.csproj') ? folder : undefined); + const getAspireCliExecutablePath = sinon.stub().resolves('/workspace/aspire'); + const aspireDebugSession = createSessionWithConfiguration({ program: '/workspace/AppHost.csproj' }, getAspireCliExecutablePath); + + await aspireDebugSession.spawnAspireCommand(['run'], '/workspace', false, 'aspire run'); + + assert.ok(getAspireCliExecutablePath.calledOnceWith(workspaceFolderCliPathTarget(folder))); + }); + + test('spawnAspireCommand uses the CLI path verified by the launch service', async () => { + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').returns(createFakeCliProcess(4334)); + const getAspireCliExecutablePath = sinon.stub().resolves('/second/aspire'); + const aspireDebugSession = createSessionWithConfiguration({ + program: '/workspace/AppHost.csproj', + resolvedCliPath: '/verified/aspire', + }, getAspireCliExecutablePath); + + await aspireDebugSession.spawnAspireCommand(['run'], '/workspace', false, 'aspire run'); + + assert.strictEqual(getAspireCliExecutablePath.called, false); + assert.strictEqual(spawnStub.firstCall.args[1], '/verified/aspire'); + }); + + test('spawnAspireCommand prefers the resolved AppHost path over the configured program', async () => { + sinon.stub(cliModule, 'spawnCliProcess').returns(createFakeCliProcess(4331)); + const folder = createWorkspaceFolder('other', '/other'); + sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => + uri.fsPath === fsPathOf('/other/Program.cs') ? folder : undefined); + const getAspireCliExecutablePath = sinon.stub().resolves('/other/aspire'); + const aspireDebugSession = createSessionWithConfiguration({ + program: '/workspace', + [appHostTelemetryTargetPathConfigKey]: '/other/Program.cs', + }, getAspireCliExecutablePath); + + await aspireDebugSession.spawnAspireCommand(['run'], '/workspace', false, 'aspire run'); + + assert.ok(getAspireCliExecutablePath.calledOnceWith(workspaceFolderCliPathTarget(folder))); + }); + + test('spawnAspireCommand falls back to the working directory target when no AppHost path is configured', async () => { + sinon.stub(cliModule, 'spawnCliProcess').returns(createFakeCliProcess(4332)); + const folder = createWorkspaceFolder('workspace', '/workspace'); + sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => + uri.fsPath === fsPathOf('/workspace') ? folder : undefined); + const getAspireCliExecutablePath = sinon.stub().resolves('/workspace/aspire'); + const aspireDebugSession = createSessionWithConfiguration({}, getAspireCliExecutablePath); + + await aspireDebugSession.spawnAspireCommand(['run'], '/workspace', false, 'aspire run'); + + assert.ok(getAspireCliExecutablePath.calledOnceWith(workspaceFolderCliPathTarget(folder))); + }); + + test('spawnAspireCommand uses the window target when no AppHost path or working directory is available', async () => { + sinon.stub(cliModule, 'spawnCliProcess').returns(createFakeCliProcess(4333)); + const getAspireCliExecutablePath = sinon.stub().resolves('aspire'); + const aspireDebugSession = createSessionWithConfiguration({}, getAspireCliExecutablePath); + + await aspireDebugSession.spawnAspireCommand(['run'], undefined, false, 'aspire run'); + + assert.ok(getAspireCliExecutablePath.calledOnceWith(windowCliPathTarget)); + }); + + test('redacts forwarded AppHost arguments from shutdown logs', async () => { + const cliProcess = createFakeCliProcess(4323); + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').returns(cliProcess); + sinon.stub(cliModule, 'terminateCliProcess').resolves(); + sinon.stub(vscode.debug, 'stopDebugging').resolves(); + const logStub = sinon.stub(extensionLogOutputChannel, 'info'); + const aspireDebugSession = createSessionForSpawn(); + + await aspireDebugSession.spawnAspireCommand( + ['run', '--isolated', '--', '--api-key', 'secret-value'], + '/workspace', + false, + 'aspire run'); + + aspireDebugSession.dispose(); + await aspireDebugSession.stopDebugging(); + + const shutdownMessage = logStub.args + .map(([message]) => message) + .find(message => message.startsWith('Requested Aspire CLI exit with args:')); + assert.strictEqual( + shutdownMessage, + 'Requested Aspire CLI exit with args: run --isolated -- '); + + spawnStub.firstCall.args[3]?.exitCallback?.(0); + }); + + test('logs only the forwarded AppHost argument count when starting the debugger', async () => { + const logStub = sinon.stub(extensionLogOutputChannel, 'info'); + sinon.stub(debuggerExtensionsModule, 'createDebugSessionConfiguration').resolves({ + type: 'coreclr', + request: 'launch', + name: 'AppHost', + runId: '', + debugSessionId: 'aspire-session', + } as AspireResourceExtendedDebugConfiguration); + const aspireDebugSession = createSessionForSpawn(); + sinon.stub(aspireDebugSession, 'createDebugAdapterTrackerCore'); + sinon.stub(aspireDebugSession, 'startAndGetDebugSession').resolves(undefined); + + await aspireDebugSession.startAppHost( + '/workspace/AppHost.csproj', + ['run', '--no-build', '--', '--api-key', 'secret-value'], + [], + true, + { forceBuild: false }); + + const startMessage = logStub.args + .map(([message]) => message) + .find(message => message.startsWith('Starting AppHost for project:')); + assert.strictEqual( + startMessage, + 'Starting AppHost for project: /workspace/AppHost.csproj with argument count: 2'); + }); + + test('does not forward CLI options to the AppHost debugger without a separator', async () => { + const createDebugSessionConfiguration = sinon.stub(debuggerExtensionsModule, 'createDebugSessionConfiguration').resolves({ + type: 'coreclr', + request: 'launch', + name: 'AppHost', + runId: '', + debugSessionId: 'aspire-session', + } as AspireResourceExtendedDebugConfiguration); + const aspireDebugSession = createSessionForSpawn(); + sinon.stub(aspireDebugSession, 'createDebugAdapterTrackerCore'); + sinon.stub(aspireDebugSession, 'startAndGetDebugSession').resolves(undefined); + + await aspireDebugSession.startAppHost( + '/workspace/AppHost.csproj', + ['run', '--isolated'], + [], + true, + { forceBuild: false }); + + assert.deepStrictEqual(createDebugSessionConfiguration.firstCall.args[2], []); + }); + + test('terminateCliProcessTree signals a running CLI process and still collects an exited one', async () => { // `terminateCliProcess` is stubbed rather than executed: on Windows it shells out to // `taskkill /pid /t` instead of calling `child.kill`, so running it for real would // both fail this assertion on the Windows CI agents and signal whatever process happens to // own the made-up PID there. - const terminateStub = sinon.stub(cliModule, 'terminateCliProcess'); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); const running = createFakeCliProcess(4322); const aspireDebugSession = createSessionForSpawn(); (aspireDebugSession as any)._cliProcess = running; - aspireDebugSession.terminateCliProcessTree(); + await aspireDebugSession.terminateCliProcessTree(); // The cooperative `stopCli` RPC cannot terminate the process, so the signal is what // actually ends the CLI and the resource tree beneath it. @@ -179,7 +345,7 @@ suite('AspireDebugSession tests', () => { const exitedAspireDebugSession = createSessionForSpawn(); (exitedAspireDebugSession as any)._cliProcess = exited; - exitedAspireDebugSession.terminateCliProcessTree(); + await exitedAspireDebugSession.terminateCliProcessTree(); // An exited leader is still forwarded: `terminateCliProcess` reaps the surviving members of // its managed process group, which is the only path that collects an AppHost and resource @@ -188,20 +354,43 @@ suite('AspireDebugSession tests', () => { assert.strictEqual(terminateStub.secondCall.args[0], exited); }); - test('terminateCliProcessTree is idempotent after signalling a CLI process', () => { - const terminateStub = sinon.stub(cliModule, 'terminateCliProcess'); + test('terminateCliProcessTree is idempotent after signalling a CLI process', async () => { + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); const cliProcess = createFakeCliProcess(4324); const aspireDebugSession = createSessionForSpawn(); (aspireDebugSession as any)._cliProcess = cliProcess; - aspireDebugSession.terminateCliProcessTree({ force: true }); - aspireDebugSession.terminateCliProcessTree(); + await Promise.all([ + aspireDebugSession.terminateCliProcessTree({ force: true }), + aspireDebugSession.terminateCliProcessTree(), + ]); sinon.assert.calledOnce(terminateStub); assert.strictEqual(terminateStub.firstCall.args[0], cliProcess); assert.deepStrictEqual(terminateStub.firstCall.args[2], { force: true }); }); + test('a disposed session remains owned until CLI process-tree termination settles', async () => { + const termination = createDeferred(); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').returns(termination.promise); + const removeAspireDebugSession = sinon.stub(); + const aspireDebugSession = createSessionForSpawn( + async () => '/usr/local/bin/aspire', + removeAspireDebugSession); + (aspireDebugSession as any)._cliProcess = createFakeCliProcess(4330); + + const terminating = aspireDebugSession.terminateCliProcessTree({ force: true }); + aspireDebugSession.finalizeForExtensionShutdown(); + + sinon.assert.notCalled(removeAspireDebugSession); + + termination.resolve(); + await terminating; + + sinon.assert.calledOnceWithExactly(removeAspireDebugSession, aspireDebugSession); + sinon.assert.calledOnce(terminateStub); + }); + test('a POSIX CLI process that exits on its own still has its process group collected', async () => { // The behaviour under test is selected by `process.platform`, so it has to be pinned rather // than inherited from whichever agent runs the suite. Without this the test asserts POSIX @@ -211,7 +400,7 @@ suite('AspireDebugSession tests', () => { // the state the old early return skipped on. const cliProcess = createFakeCliProcess(4325, 0); const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').returns(cliProcess); - const terminateStub = sinon.stub(cliModule, 'terminateCliProcess'); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); sinon.stub(vscode.debug, 'stopDebugging').resolves(); const aspireDebugSession = createSessionForSpawn(); @@ -238,7 +427,7 @@ suite('AspireDebugSession tests', () => { const platformStub = sinon.stub(process, 'platform').value('win32'); const cliProcess = createFakeCliProcess(4327, 0); const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').returns(cliProcess); - const terminateStub = sinon.stub(cliModule, 'terminateCliProcess'); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); sinon.stub(vscode.debug, 'stopDebugging').resolves(); const aspireDebugSession = createSessionForSpawn(); @@ -265,7 +454,7 @@ suite('AspireDebugSession tests', () => { const platformStub = sinon.stub(process, 'platform').value('win32'); const cliProcess = createFakeCliProcess(4328, 0); const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').returns(cliProcess); - const terminateStub = sinon.stub(cliModule, 'terminateCliProcess'); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); sinon.stub(vscode.debug, 'stopDebugging').resolves(); const aspireDebugSession = createSessionForSpawn(); @@ -292,7 +481,7 @@ suite('AspireDebugSession tests', () => { const cliProcess = createFakeCliProcess(4329, 0); const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').returns(cliProcess); const removeAspireDebugSession = sinon.stub(); - sinon.stub(cliModule, 'terminateCliProcess'); + sinon.stub(cliModule, 'terminateCliProcess').resolves(); sinon.stub(vscode.debug, 'stopDebugging').resolves(); const aspireDebugSession = createSessionForSpawn( async () => '/usr/local/bin/aspire', @@ -318,13 +507,13 @@ suite('AspireDebugSession tests', () => { test('a forced CLI process tree termination is not repeated by the exit callback', async () => { const cliProcess = createFakeCliProcess(4326, 0); const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').returns(cliProcess); - const terminateStub = sinon.stub(cliModule, 'terminateCliProcess'); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); sinon.stub(vscode.debug, 'stopDebugging').resolves(); const aspireDebugSession = createSessionForSpawn(); await aspireDebugSession.spawnAspireCommand(['run'], '/workspace', false, 'aspire run'); - aspireDebugSession.terminateCliProcessTree({ force: true }); + await aspireDebugSession.terminateCliProcessTree({ force: true }); spawnStub.firstCall.args[3]?.exitCallback?.(0); sinon.assert.calledOnceWithExactly( @@ -431,6 +620,43 @@ suite('AspireDebugSession tests', () => { ]); }); + test('suppresses the Aspire CLI first-run banner when AppHost arguments include nologo', async () => { + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: '/workspace/apphost.cs', + command: 'run', + args: ['--', '--nologo'], + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const terminalProvider = { + isCliDebugLoggingEnabled: () => false, + }; + const aspireDebugSession = new AspireDebugSession(parentDebugSession as unknown as vscode.DebugSession, {} as any, {} as any, terminalProvider as any, () => { }); + const spawnStub = sinon.stub(aspireDebugSession, 'spawnAspireCommand').resolves(); + + aspireDebugSession.handleMessage({ command: 'launch', seq: 1, arguments: { noDebug: false } }); + + await waitFor(() => spawnStub.calledOnce); + assert.deepStrictEqual(spawnStub.firstCall.args[0], [ + 'run', + '--start-debug-session', + '--nologo', + '--apphost', + '/workspace/apphost.cs', + '--', + '--nologo', + ]); + }); + test('forwards explicit launch configuration provenance to the Aspire CLI', async () => { const parentDebugSession = { id: 'aspire-session', @@ -463,6 +689,82 @@ suite('AspireDebugSession tests', () => { }]); }); + test('an explicit CLI directory launch passes apphost and selection origin to the Aspire CLI', async () => { + const appHostDirectory = makeTempDir(); + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: appHostDirectory, + command: 'run', + __aspireAppHostSelectionOrigin: 'explicit-cli', + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const terminalProvider = { + isCliDebugLoggingEnabled: () => false, + }; + const aspireDebugSession = new AspireDebugSession(parentDebugSession as unknown as vscode.DebugSession, {} as any, {} as any, terminalProvider as any, () => { }); + const spawnStub = sinon.stub(aspireDebugSession, 'spawnAspireCommand').resolves(); + + aspireDebugSession.handleMessage({ command: 'launch', seq: 1, arguments: { noDebug: false } }); + + await waitFor(() => spawnStub.calledOnce); + assert.deepStrictEqual(spawnStub.firstCall.args[0], [ + 'run', + '--start-debug-session', + '--nologo', + '--apphost', + appHostDirectory, + ]); + assert.strictEqual(spawnStub.firstCall.args[1], appHostDirectory); + assert.deepStrictEqual(spawnStub.firstCall.args[4], [{ + name: 'ASPIRE_CLI_APPHOST_SELECTION_ORIGIN', + value: 'explicit-cli', + }]); + }); + + test('a default-discovery directory launch omits the apphost argument', async () => { + const appHostDirectory = makeTempDir(); + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: appHostDirectory, + command: 'run', + __aspireAppHostSelectionOrigin: 'default-discovery', + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const terminalProvider = { + isCliDebugLoggingEnabled: () => false, + }; + const aspireDebugSession = new AspireDebugSession(parentDebugSession as unknown as vscode.DebugSession, {} as any, {} as any, terminalProvider as any, () => { }); + const spawnStub = sinon.stub(aspireDebugSession, 'spawnAspireCommand').resolves(); + + aspireDebugSession.handleMessage({ command: 'launch', seq: 1, arguments: { noDebug: false } }); + + await waitFor(() => spawnStub.calledOnce); + assert.deepStrictEqual(spawnStub.firstCall.args[0], [ + 'run', + '--start-debug-session', + '--nologo', + ]); + assert.strictEqual(spawnStub.firstCall.args[1], appHostDirectory); + }); + test('describes a no-debug launch as an Aspire run session', async () => { const parentDebugSession = { id: 'aspire-session', @@ -1476,6 +1778,79 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); assert.strictEqual(stopDebuggingStub.callCount, 4); }); + test('stopDebugging reserves budget for the AppHost stop when a resource adapter never acknowledges', async () => { + // A debug adapter suspended at a breakpoint does not acknowledge stopDebugging() until its + // runtime resumes, so it consumes whatever budget it is given. When every phase shared one + // deadline, that left the AppHost stop with 0ms: it timed out immediately, the AppHost + // process kept running, and its resources stayed in the Call Stack pane after the debug + // session disappeared. The reserve exists so the AppHost and parent stops still land. + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: '/workspace/apphost.java', + command: 'run', + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const appHostDebugSession = { + id: 'apphost-session', + type: 'java', + name: 'AppHost', + configuration: { type: 'java', request: 'launch', name: 'AppHost' }, + }; + const suspendedResourceDebugSession = { + id: 'suspended-resource-session', + type: 'java', + name: 'catalog', + configuration: { type: 'java', request: 'attach', name: 'catalog' }, + }; + const terminalProvider = { + isCliDebugLoggingEnabled: () => false, + }; + + const stopDebuggingStub = sinon.stub(vscode.debug, 'stopDebugging').callsFake(async session => { + if (session === (suspendedResourceDebugSession as unknown as vscode.DebugSession)) { + // Never settles, exactly like an adapter stopped at a breakpoint. + return new Promise(() => { }); + } + + if (session === (appHostDebugSession as unknown as vscode.DebugSession)) { + // Deliberately non-instant. A stop that resolves in the same microtask would win the + // race even against a 0ms budget, so the test would pass without the reserve. + await new Promise(resolve => setTimeout(resolve, 50)); + } + }); + + const aspireDebugSession = new AspireDebugSession(parentDebugSession as unknown as vscode.DebugSession, {} as any, {} as any, terminalProvider as any, () => { }); + (aspireDebugSession as any)._appHostDebugSession = { + id: appHostDebugSession.id, + session: appHostDebugSession as unknown as vscode.DebugSession, + stopSession: () => vscode.debug.stopDebugging(appHostDebugSession as unknown as vscode.DebugSession), + }; + (aspireDebugSession as any)._resourceDebugSessions = [ + { + id: suspendedResourceDebugSession.id, + session: suspendedResourceDebugSession as unknown as vscode.DebugSession, + stopSession: () => vscode.debug.stopDebugging(suspendedResourceDebugSession as unknown as vscode.DebugSession), + }, + ]; + + // The suspended resource still times out, and that failure has to reach the caller. + await assert.rejects(() => aspireDebugSession.stopDebugging()); + + assert.strictEqual((aspireDebugSession as any)._appHostStopped, true, 'AppHost stop must be confirmed even when a resource adapter never acknowledges'); + assert.strictEqual((aspireDebugSession as any)._parentStopped, true, 'Aspire parent stop must be confirmed even when a resource adapter never acknowledges'); + assert.ok(stopDebuggingStub.calledWith(appHostDebugSession as unknown as vscode.DebugSession)); + assert.ok(stopDebuggingStub.calledWith(parentDebugSession as unknown as vscode.DebugSession)); + }); + test('stopDebugging still stops the Aspire parent session when AppHost stop fails', async () => { const parentDebugSession = { id: 'aspire-session', @@ -1512,6 +1887,9 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); } }); const aspireDebugSession = new AspireDebugSession(parentDebugSession as unknown as vscode.DebugSession, {} as any, {} as any, terminalProvider as any, () => { }); + const stopCli = sinon.stub().resolves(); + (aspireDebugSession as any)._rpcClient = { stopCli }; + const scheduleCliProcessTermination = sinon.stub(aspireDebugSession as any, 'scheduleCliProcessTermination'); (aspireDebugSession as any)._appHostDebugSession = { id: appHostDebugSession.id, session: appHostDebugSession as unknown as vscode.DebugSession, @@ -1523,6 +1901,8 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); assert.strictEqual(stopDebuggingStub.callCount, 2); assert.strictEqual(stopDebuggingStub.firstCall.args[0], appHostDebugSession); assert.strictEqual(stopDebuggingStub.secondCall.args[0], parentDebugSession); + sinon.assert.calledOnce(stopCli); + sinon.assert.calledOnce(scheduleCliProcessTermination); }); test('stopDebugging reports both resource and AppHost stop failures', async () => { @@ -2006,7 +2386,7 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); const shutdown = aspireDebugSession.stopDebugging(); await clock.tickAsync(10_001); - await assert.rejects(shutdown, /Timed out after 10 seconds waiting for debug session 'Resource' to start/); + await assert.rejects(shutdown, /Timed out after 6 seconds waiting for debug session 'Resource' to start/); resolveStart!(true); startSessionCallback?.(resourceDebugSession as unknown as vscode.DebugSession); @@ -2223,15 +2603,17 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); ]; const stopPromise = aspireDebugSession.stopDebugging(); - // Just short of the budget the shutdown is still waiting on the resource, so nothing else - // has been stopped yet: the ordering is honoured right up to the deadline. - await clock.tickAsync(9_000); + // Just short of the resource budget the shutdown is still waiting on the resource, so nothing + // else has been stopped yet: the ordering is honoured right up to the resource deadline. + await clock.tickAsync(5_000); assert.strictEqual(stopDebuggingStub.callCount, 0, 'The AppHost must not be stopped while a resource stop is still within budget'); await clock.tickAsync(2_000); await assert.rejects(stopPromise, (err: Error) => { - assert.strictEqual(err.message, debugSessionStopTimedOut('Wedged resource', 10)); + // Six, not ten: the resource phase runs against the reserved deadline so the AppHost and + // parent stops still have a usable budget after a wedged resource consumes its own. + assert.strictEqual(err.message, debugSessionStopTimedOut('Wedged resource', 6)); return true; }); // Giving up on the resource must not abandon the rest of the shutdown - the AppHost and the @@ -2280,8 +2662,14 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); const stopPromise = aspireDebugSession.stopDebugging(); await clock.tickAsync(10_001); - await assert.rejects(stopPromise, (err: Error) => { - assert.strictEqual(err.message, debugSessionStopTimedOut('AppHost', 1)); + await assert.rejects(stopPromise, (err: AggregateError) => { + // Both phases time out, and the budgets they report are the point of this test. The + // resource is bounded at the reserved deadline rather than the whole budget, which is + // what leaves the AppHost a real four seconds instead of the one second it used to get + // after a slow resource had taken everything else. + assert.deepStrictEqual( + (err.errors as Error[]).map(error => error.message), + [debugSessionStopTimedOut('Slow resource', 6), debugSessionStopTimedOut('AppHost', 4)]); return true; }); clock.restore(); @@ -2490,7 +2878,7 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); const firstStop = aspireDebugSession.stopDebugging(); await clock.tickAsync(10_001); - await assert.rejects(firstStop, /Timed out after 10 seconds waiting for debug session 'Resource' to stop/); + await assert.rejects(firstStop, /Timed out after 6 seconds waiting for debug session 'Resource' to stop/); const retry = aspireDebugSession.stopDebugging(); await Promise.resolve(); @@ -3310,9 +3698,14 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); assert.strictEqual(debuggerExtension.resourceType, 'rust'); }); - test('an AppHost restart is aborted and forces CLI cleanup when resource shutdown fails', async () => { - let restartHandler: ((debugSessionId: string) => boolean) | undefined; - let terminateSessionCallback: ((session: vscode.DebugSession) => unknown) | undefined; + test('launches a Java AppHost with the Java debugger', async () => { + const appHostPath = join(makeTempDir(), 'AppHost.java'); + writeFileSync(appHostPath, ''); + sinon.stub(vscode.extensions, 'getExtension').callsFake((extensionId: string) => + extensionId === 'vscjava.vscode-java-debug' + ? { id: extensionId } as vscode.Extension + : undefined); + const parentDebugSession = { id: 'aspire-session', type: 'aspire', @@ -3322,7 +3715,363 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); type: 'aspire', request: 'launch', name: 'Aspire', - program: '/workspace/apphost.cs', + program: appHostPath, + command: 'run', + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const createDebugSessionConfiguration = sinon.stub(debuggerExtensionsModule, 'createDebugSessionConfiguration').resolves({ + type: 'java', + request: 'launch', + name: 'Java AppHost', + runId: '', + debugSessionId: 'aspire-session', + }); + const aspireDebugSession = new AspireDebugSession( + parentDebugSession as unknown as vscode.DebugSession, + {} as any, + {} as any, + {} as any, + () => { }); + sinon.stub(aspireDebugSession, 'createDebugAdapterTrackerCore'); + sinon.stub(aspireDebugSession, 'startAndGetDebugSession').resolves(undefined); + + // The CLI sends the launcher it would have run itself, so the classpath the build tool staged is + // already resolved and the adapter never has to reproduce it. Those entries are relative to the + // AppHost directory, because that is the working directory the CLI runs `java` from. + const classPath = ['target/classes', 'target/aspire-deps/*'].join(pathDelimiter); + + await aspireDebugSession.startAppHost( + appHostPath, + ['java', '-Xmx512m', '-cp', classPath, 'AppHost', '--example-argument'], + [], + true, + { forceBuild: false }); + + const launchConfig = createDebugSessionConfiguration.firstCall.args[1] as JavaLaunchConfiguration; + const appHostArgs = createDebugSessionConfiguration.firstCall.args[2]; + const debuggerExtension = createDebugSessionConfiguration.firstCall.args[5]; + + // Absolute, not the relative entries the CLI sent: the adapter does not resolve classPaths + // against the launch configuration's cwd, so relative entries are looked up somewhere else + // entirely and the JVM starts without the AppHost class on its classpath. + assert.deepStrictEqual(launchConfig, { + type: 'java', + main_class: 'AppHost', + class_paths: [ + join(dirname(appHostPath), 'target', 'classes'), + join(dirname(appHostPath), 'target', 'aspire-deps', '*') + ], + working_directory: dirname(appHostPath), + vm_args: ['-Xmx512m'], + }); + assert.deepStrictEqual(appHostArgs, ['--example-argument']); + assert.strictEqual(debuggerExtension.resourceType, 'java'); + }); + + test('omits vm_args from a Java AppHost launch when the command carries none', async () => { + const appHostPath = join(makeTempDir(), 'AppHost.java'); + writeFileSync(appHostPath, ''); + sinon.stub(vscode.extensions, 'getExtension').callsFake((extensionId: string) => + extensionId === 'vscjava.vscode-java-debug' + ? { id: extensionId } as vscode.Extension + : undefined); + + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: appHostPath, + command: 'run', + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const createDebugSessionConfiguration = sinon.stub(debuggerExtensionsModule, 'createDebugSessionConfiguration').resolves({ + type: 'java', + request: 'launch', + name: 'Java AppHost', + runId: '', + debugSessionId: 'aspire-session', + }); + const aspireDebugSession = new AspireDebugSession( + parentDebugSession as unknown as vscode.DebugSession, + {} as any, + {} as any, + {} as any, + () => { }); + sinon.stub(aspireDebugSession, 'createDebugAdapterTrackerCore'); + sinon.stub(aspireDebugSession, 'startAndGetDebugSession').resolves(undefined); + + await aspireDebugSession.startAppHost( + appHostPath, + ['java', '-cp', 'target/classes', 'AppHost'], + [], + true, + { forceBuild: false }); + + const launchConfig = createDebugSessionConfiguration.firstCall.args[1] as JavaLaunchConfiguration; + + // An empty vm_args array is not the same as no vm_args to the Java adapter, and build_tool is + // deliberately absent because the classpath is supplied explicitly. + assert.deepStrictEqual(launchConfig, { + type: 'java', + main_class: 'AppHost', + class_paths: [join(dirname(appHostPath), 'target', 'classes')], + working_directory: dirname(appHostPath), + }); + }); + + test('reports the missing Java debugger extension rather than an unsupported debug type', async () => { + const appHostPath = join(makeTempDir(), 'AppHost.java'); + writeFileSync(appHostPath, ''); + sinon.stub(vscode.extensions, 'getExtension').returns(undefined); + + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: appHostPath, + command: 'run', + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const aspireDebugSession = new AspireDebugSession( + parentDebugSession as unknown as vscode.DebugSession, + {} as any, + {} as any, + {} as any, + () => { }); + sinon.stub(aspireDebugSession, 'createDebugAdapterTrackerCore'); + const startAndGetDebugSession = sinon.stub(aspireDebugSession, 'startAndGetDebugSession').resolves(undefined); + const showErrorMessage = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); + + await aspireDebugSession.startAppHost( + appHostPath, + ['java', '-cp', 'target/classes', 'AppHost'], + [], + true, + { forceBuild: false }); + + assert.strictEqual(startAndGetDebugSession.called, false); + const message = showErrorMessage.firstCall.args[0] as string; + assert.ok( + message.includes('vscjava.vscode-java-debug'), + `expected an actionable install message, got ${message}`); + }); + + test('reports an unrecognised Java AppHost command instead of guessing a launch', async () => { + const appHostPath = join(makeTempDir(), 'AppHost.java'); + writeFileSync(appHostPath, ''); + sinon.stub(vscode.extensions, 'getExtension').callsFake((extensionId: string) => + extensionId === 'vscjava.vscode-java-debug' + ? { id: extensionId } as vscode.Extension + : undefined); + + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: appHostPath, + command: 'run', + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const aspireDebugSession = new AspireDebugSession( + parentDebugSession as unknown as vscode.DebugSession, + {} as any, + {} as any, + {} as any, + () => { }); + sinon.stub(aspireDebugSession, 'createDebugAdapterTrackerCore'); + const startAndGetDebugSession = sinon.stub(aspireDebugSession, 'startAndGetDebugSession').resolves(undefined); + const showErrorMessage = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); + + // A wrapper invocation has no main class to hand the adapter, and guessing one would start a JVM + // with the wrong arguments. + const secret = 'database-password'; + await aspireDebugSession.startAppHost( + appHostPath, + ['./mvnw', `-Dspring.datasource.password=${secret}`, 'exec:java'], + [], + true, + { forceBuild: false }); + + assert.strictEqual(startAndGetDebugSession.called, false); + const message = showErrorMessage.firstCall.args[0] as string; + assert.ok(!message.includes(secret), `expected the rejected command arguments to be omitted, got ${message}`); + }); + + test('an AppHost restart preserves the target-scoped CLI path and argument tokens', async () => { + const pathResolvedCli = await cliPathModule.findCliOnPath({ + platform: 'linux', + pathValue: '/selected/bin', + fileExists: async candidate => candidate === '/selected/bin/aspire', + tryExecute: async candidate => candidate === '/selected/bin/aspire', + }); + assert.strictEqual(pathResolvedCli, '/selected/bin/aspire'); + + let restartHandler: ((debugSessionId: string) => boolean) | undefined; + let terminateSessionCallback: ((session: vscode.DebugSession) => unknown) | undefined; + const forwardedArgs = ['--isolated', '--', '--app-option', 'value with spaces']; + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: '/workspace/apphost.cs', + command: 'run', + args: forwardedArgs, + resolvedCliPath: pathResolvedCli, + }, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const appHostDebugSession = { + id: 'apphost-session', + type: 'coreclr', + name: 'AppHost', + configuration: { + runId: 'apphost-run', + }, + }; + const appHostResourceSession = { + id: appHostDebugSession.id, + session: appHostDebugSession as unknown as vscode.DebugSession, + stopSession: sinon.stub().resolves(), + }; + sinon.stub(debuggerExtensionsModule, 'createDebugSessionConfiguration').resolves({ + runId: 'apphost-run', + debugSessionId: 'debug-1', + type: 'coreclr', + name: 'AppHost', + request: 'launch', + }); + sinon.stub(vscode.debug, 'onDidTerminateDebugSession').callsFake(callback => { + terminateSessionCallback = callback; + return { dispose: sinon.stub() }; + }); + sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns(undefined); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const aspireDebugSession = new AspireDebugSession( + parentDebugSession as unknown as vscode.DebugSession, + {} as any, + {} as any, + { isDebugConfigEnvironmentLoggingEnabled: () => false } as any, + () => { }); + sinon.stub(aspireDebugSession, 'createDebugAdapterTrackerCore').callsFake((_debugAdapter, onRestart) => { + restartHandler = onRestart; + }); + sinon.stub(aspireDebugSession, 'startAndGetDebugSession').resolves(appHostResourceSession); + sinon.stub(aspireDebugSession, 'stopDebugging').resolves(); + + await aspireDebugSession.startAppHost('/workspace/AppHost.csproj', ['run'], [], true, { forceBuild: false }); + assert.strictEqual(restartHandler?.(aspireDebugSession.debugSessionId), true); + await terminateSessionCallback?.(appHostDebugSession as unknown as vscode.DebugSession); + + const restartedConfig = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; + assert.strictEqual(restartedConfig.resolvedCliPath, pathResolvedCli); + assert.deepStrictEqual(restartedConfig.args, forwardedArgs); + assert.strictEqual(isAspireDebugConfigurationExtensionOwned(restartedConfig), false); + + const tryExecuteCliStub = sinon.stub(cliPathModule, 'tryExecuteCli').resolves(true); + const reservedPaths: string[] = []; + const preparedCliPaths: Array = []; + const provider = new AspireDebugConfigurationProvider({ + resolveDebugTarget: async (filePath: string) => filePath, + tryFindWorkspaceDefaultCandidate: async () => undefined, + } as unknown as AppHostDiscoveryService, { + tryReserveExternalLaunch: (appHostPath: string) => { + reservedPaths.push(appHostPath); + return 'restart-reservation'; + }, + validateOrReacquireExternalLaunchReservation: () => { + throw new Error('The restart should acquire a fresh reservation.'); + }, + replaceExternalLaunchReservation: () => { + throw new Error('The restart should acquire a fresh reservation.'); + }, + releaseExternalLaunchReservation: () => { + throw new Error('The successful restart should keep its reservation.'); + }, + prepareLaunchArguments: async (_appHostPath: string, _command: string, args: string[] | undefined, _token: vscode.CancellationToken, cliPath?: string) => { + preparedCliPaths.push(cliPath); + return { args }; + }, + }, { + get: () => undefined, + update: async () => { }, + keys: () => [], + }); + const checkedConfig = await provider.resolveDebugConfiguration(undefined, restartedConfig); + assert.strictEqual(checkedConfig, restartedConfig); + sinon.assert.calledOnceWithExactly(tryExecuteCliStub, pathResolvedCli); + + const resolvedConfig = await provider.resolveDebugConfigurationWithSubstitutedVariables(undefined, restartedConfig) as AspireExtendedDebugConfiguration | undefined; + + assert.deepStrictEqual(reservedPaths, ['/workspace/apphost.cs']); + assert.deepStrictEqual(preparedCliPaths, [pathResolvedCli]); + assert.strictEqual(resolvedConfig?.[appHostLaunchReservationIdConfigKey], 'restart-reservation'); + assert.strictEqual(resolvedConfig?.resolvedCliPath, pathResolvedCli); + assert.deepStrictEqual(resolvedConfig?.args, forwardedArgs); + + const replacementCliProcess = createFakeCliProcess(3391); + const spawnCliStub = sinon.stub(cliModule, 'spawnCliProcess').returns(replacementCliProcess); + const getAspireCliExecutablePath = sinon.stub().rejects(new Error('The trusted restart pin should be used.')); + const replacementSession = new AspireDebugSession( + { + id: 'replacement-aspire-session', + configuration: resolvedConfig, + } as unknown as vscode.DebugSession, + { onNewConnection: () => ({ dispose: () => { } }) } as any, + { recordAppHostProcessExit: () => { } } as any, + { + getAspireCliExecutablePath, + createEnvironment: () => ({}), + } as any, + () => { }); + + await replacementSession.spawnAspireCommand(['run'], '/workspace', false, 'aspire run'); + + assert.strictEqual(spawnCliStub.firstCall.args[1], pathResolvedCli); + assert.strictEqual(getAspireCliExecutablePath.called, false); + }); + + test('an AppHost restart is aborted and forces CLI cleanup when resource shutdown fails', async () => { + let restartHandler: ((debugSessionId: string) => boolean) | undefined; + let terminateSessionCallback: ((session: vscode.DebugSession) => unknown) | undefined; + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: '/workspace/apphost.cs', command: 'run', }, customRequest: sinon.stub(), @@ -3704,6 +4453,50 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); assert.strictEqual(loggableConfig.environmentVariables, ''); }); + test('redacts debug configuration arguments when environment logging is disabled', () => { + const debugConfig = { + runId: 'run-1', + debugSessionId: 'debug-1', + type: 'coreclr', + name: '.NET', + request: 'launch', + args: ['--api-key', 'secret-value'], + runtimeArgs: ['--runtime-secret'], + } as AspireResourceExtendedDebugConfiguration; + + const loggableConfig = getLoggableDebugConfiguration(debugConfig, false); + + assert.strictEqual(loggableConfig.args, ''); + assert.strictEqual(loggableConfig.runtimeArgs, ''); + assert.deepStrictEqual(debugConfig.args, ['--api-key', 'secret-value']); + assert.deepStrictEqual(debugConfig.runtimeArgs, ['--runtime-secret']); + }); + + test('redacts debug configuration arguments without mutating the source when environment logging is enabled', () => { + const debugConfig = { + runId: 'run-1', + debugSessionId: 'debug-1', + type: 'pwa-node', + name: 'Node.js package script', + request: 'launch', + runtimeExecutable: 'npm', + runtimeArgs: ['run', 'start', '--', '--api-key', 'runtime-secret'], + args: ['--api-key', 'app-secret'], + env: { + LOG_LEVEL: 'debug', + }, + } as AspireResourceExtendedDebugConfiguration; + + const loggableConfig = getLoggableDebugConfiguration(debugConfig, true); + + assert.notStrictEqual(loggableConfig, debugConfig); + assert.strictEqual(loggableConfig.args, ''); + assert.strictEqual(loggableConfig.runtimeArgs, ''); + assert.deepStrictEqual(loggableConfig.env, { LOG_LEVEL: 'debug' }); + assert.deepStrictEqual(debugConfig.args, ['--api-key', 'app-secret']); + assert.deepStrictEqual(debugConfig.runtimeArgs, ['run', 'start', '--', '--api-key', 'runtime-secret']); + }); + test('redacts MAUI environmentVariables even when environment logging is enabled', () => { const debugConfig = { runId: 'run-1', @@ -3711,6 +4504,8 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); type: 'maui', name: 'MAUI', request: 'launch', + args: ['--api-key', 'app-secret'], + runtimeArgs: ['--runtime-secret'], env: { SECRET_TOKEN: 'env-secret', }, @@ -3721,6 +4516,47 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); assert.deepStrictEqual(loggableConfig.env, { SECRET_TOKEN: 'env-secret' }); assert.strictEqual(loggableConfig.environmentVariables, ''); + assert.strictEqual(loggableConfig.args, ''); + assert.strictEqual(loggableConfig.runtimeArgs, ''); + }); + + test('redacts Java vmArgs and classPaths by default', () => { + const debugConfig = { + runId: 'run-1', + debugSessionId: 'debug-1', + type: 'java', + name: 'catalog', + request: 'launch', + // A JVM system property is the ordinary way a secret reaches a Java process, and the + // extension log persists to disk. + vmArgs: ['-Xmx512m', '-Dspring.datasource.password=hunter2'], + classPaths: ['/Users/someone/src/private-project/target/classes'], + } as AspireResourceExtendedDebugConfiguration; + + const loggableConfig = getLoggableDebugConfiguration(debugConfig, false); + + assert.strictEqual(loggableConfig.vmArgs, ''); + assert.strictEqual(loggableConfig.classPaths, ''); + const serialized = JSON.stringify(loggableConfig); + assert.ok(!serialized.includes('hunter2')); + assert.ok(!serialized.includes('/Users/someone')); + }); + + test('includes Java vmArgs and classPaths when debug configuration logging is enabled', () => { + const debugConfig = { + runId: 'run-1', + debugSessionId: 'debug-1', + type: 'java', + name: 'catalog', + request: 'launch', + vmArgs: ['-Xmx512m'], + classPaths: ['/Users/someone/src/project/target/classes', '/deps/a.jar'], + } as AspireResourceExtendedDebugConfiguration; + + const loggableConfig = getLoggableDebugConfiguration(debugConfig, true); + + assert.deepStrictEqual(loggableConfig.vmArgs, ['-Xmx512m']); + assert.deepStrictEqual(loggableConfig.classPaths, ['/Users/someone/src/project/target/classes', '/deps/a.jar']); }); test('redacts sensitive debugger environments even when environment logging is enabled', () => { @@ -3730,6 +4566,8 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); type: 'lldb', name: 'Rust', request: 'launch', + args: ['--api-key', 'app-secret'], + runtimeArgs: ['--runtime-secret'], env: { SECRET_TOKEN: 'env-secret', }, @@ -3743,6 +4581,8 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); assert.strictEqual(loggableConfig.env, ''); assert.strictEqual(loggableConfig.environment, ''); + assert.strictEqual(loggableConfig.args, ''); + assert.strictEqual(loggableConfig.runtimeArgs, ''); assert.ok(!JSON.stringify(loggableConfig).includes('secret')); }); @@ -4357,6 +5197,24 @@ var builder = Aspire.Hosting.DistributedApplication.CreateBuilder(args); }); suite('buildAspireCommandArgs', () => { + test('appends a do step before additional command arguments', () => { + const args = buildAspireCommandArgs('do', ['--verbose'], ['--start-debug-session', '--apphost', '/workspace/AppHost.csproj'], 'deploy'); + + assert.deepStrictEqual(args, ['do', 'deploy', '--verbose', '--start-debug-session', '--apphost', '/workspace/AppHost.csproj']); + }); + + test('appends a do step before command arguments and keeps extension arguments before the app argument separator', () => { + const args = buildAspireCommandArgs('do', ['--verbose', '--', '--custom-arg', 'value'], ['--nologo', '--apphost', '/workspace/AppHost.csproj'], 'deploy'); + + assert.deepStrictEqual(args, ['do', 'deploy', '--verbose', '--nologo', '--apphost', '/workspace/AppHost.csproj', '--', '--custom-arg', 'value']); + }); + + test('does not append a step to run arguments', () => { + const args = buildAspireCommandArgs('run', ['--isolated'], ['--start-debug-session', '--apphost', '/workspace/AppHost.csproj'], 'deploy'); + + assert.deepStrictEqual(args, ['run', '--isolated', '--start-debug-session', '--apphost', '/workspace/AppHost.csproj']); + }); + test('appends extension arguments when command has no app argument separator', () => { const args = buildAspireCommandArgs('run', ['--isolated'], ['--start-debug-session', '--apphost', '/workspace/AppHost.csproj']); diff --git a/extension/src/test/aspireEditorCommandProvider.test.ts b/extension/src/test/aspireEditorCommandProvider.test.ts index b66f60e884c..c0a9fe40657 100644 --- a/extension/src/test/aspireEditorCommandProvider.test.ts +++ b/extension/src/test/aspireEditorCommandProvider.test.ts @@ -11,6 +11,7 @@ import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; import { AppHostLaunchService } from '../services/AppHostLaunchService'; import * as cliPathModule from '../utils/cliPath'; +import { removeDirectorySafely } from './testHelpers'; function createEditor(filePath: string): vscode.TextEditor { return { document: { @@ -21,6 +22,12 @@ function createEditor(filePath: string): vscode.TextEditor { } as vscode.TextEditor; } +function createLaunchService(): AppHostLaunchService { + return new AppHostLaunchService({ + getCapabilityStatus: async () => 'supported', + }); +} + suite('AspireEditorCommandProvider', () => { let tempDir: string; let activeEditor: vscode.TextEditor | undefined; @@ -41,7 +48,9 @@ suite('AspireEditorCommandProvider', () => { activeEditorStub = sinon.stub(vscode.window, 'activeTextEditor').get(() => activeEditor); workspaceFoldersStub = sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => { - if (uri.fsPath.startsWith(tempDir)) { + // VS Code lowercases the drive letter in fsPath, so the raw mkdtemp path does not + // prefix-match its own URI on Windows. Normalise both sides through Uri.file. + if (uri.fsPath.startsWith(vscode.Uri.file(tempDir).fsPath)) { return { uri: vscode.Uri.file(tempDir), name: 'test', index: 0 }; } @@ -69,7 +78,7 @@ suite('AspireEditorCommandProvider', () => { getWorkspaceFolderStub.restore(); workspaceFoldersStub.restore(); activeEditorStub.restore(); - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); }); test('returns containing project file when active editor is SDK-style AppHost Program.cs', async () => { @@ -82,7 +91,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(projectPath, ''); activeEditor = createEditor(programPath); - const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(projectPath), new AppHostLaunchService()); + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(projectPath), createLaunchService()); try { assert.strictEqual(await provider.getAppHostPath(), projectPath); } @@ -96,7 +105,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(appHostPath, '#:sdk Aspire.AppHost.Sdk\nvar builder = DistributedApplication.CreateBuilder(args);'); activeEditor = createEditor(appHostPath); - const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath), new AppHostLaunchService()); + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath), createLaunchService()); try { assert.strictEqual(await provider.getAppHostPath(), appHostPath); } @@ -110,7 +119,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(appHostPath, 'import { createBuilder } from "./.aspire/modules/aspire";'); activeEditor = createEditor(appHostPath); - const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath, 'typescript/nodejs'), new AppHostLaunchService()); + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath, 'typescript/nodejs'), createLaunchService()); try { assert.strictEqual(await provider.getAppHostPath(), appHostPath); } @@ -124,7 +133,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(appHostPath, 'fn main() {}'); activeEditor = createEditor(appHostPath); - const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath, 'rust'), new AppHostLaunchService()); + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath, 'rust'), createLaunchService()); try { assert.strictEqual(await provider.getAppHostPath(), appHostPath); } @@ -138,7 +147,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); activeEditor = createEditor(programPath); - const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService(), new AppHostLaunchService()); + const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService(), createLaunchService()); try { await provider.processDocument(activeEditor.document); @@ -155,7 +164,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); activeEditor = createEditor(programPath); - const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService(), new AppHostLaunchService()); + const provider = new AspireEditorCommandProvider(createFailingAppHostDiscoveryService(), createLaunchService()); try { assert.strictEqual(await provider.getAppHostPath(), null); } @@ -174,7 +183,7 @@ suite('AspireEditorCommandProvider', () => { fs.writeFileSync(programPath, 'var builder = DistributedApplication.CreateBuilder(args);'); activeEditor = createEditor(programPath); - const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath), new AppHostLaunchService()); + const provider = new AspireEditorCommandProvider(createAppHostDiscoveryService(appHostPath), createLaunchService()); try { await provider.tryExecuteRunAppHost(true); diff --git a/extension/src/test/aspireGutterDecorationProvider.test.ts b/extension/src/test/aspireGutterDecorationProvider.test.ts index 70c2fb40942..c637eb40f8d 100644 --- a/extension/src/test/aspireGutterDecorationProvider.test.ts +++ b/extension/src/test/aspireGutterDecorationProvider.test.ts @@ -4,6 +4,7 @@ import * as assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import { createMockDocument } from './testHelpers'; import waitForExpect from 'wait-for-expect'; import { AspireGutterDecorationProvider, classifyState } from '../editor/AspireGutterDecorationProvider'; import { ResourceState } from '../editor/resourceConstants'; @@ -14,56 +15,6 @@ function p(...segments: string[]): string { return path.join(path.sep, ...segments); } -function createMockDocument(content: string, filePath: string): vscode.TextDocument { - const lines = content.split('\n'); - return { - uri: vscode.Uri.file(filePath), - fileName: filePath, - isUntitled: false, - languageId: filePath.endsWith('.cs') ? 'csharp' : filePath.endsWith('.ts') ? 'typescript' : filePath.endsWith('.rs') ? 'rust' : 'javascript', - version: 1, - isDirty: false, - isClosed: false, - eol: vscode.EndOfLine.LF, - lineCount: lines.length, - encoding: 'utf-8', - save: () => Promise.resolve(false), - lineAt: (lineOrPos: number | vscode.Position) => { - const lineNum = typeof lineOrPos === 'number' ? lineOrPos : lineOrPos.line; - const text = lines[lineNum] || ''; - return { - lineNumber: lineNum, - text, - range: new vscode.Range(lineNum, 0, lineNum, text.length), - rangeIncludingLineBreak: new vscode.Range(lineNum, 0, lineNum + 1, 0), - firstNonWhitespaceCharacterIndex: text.search(/\S/), - isEmptyOrWhitespace: text.trim().length === 0, - } as vscode.TextLine; - }, - offsetAt: (position: vscode.Position) => { - let offset = 0; - for (let i = 0; i < position.line && i < lines.length; i++) { - offset += lines[i].length + 1; - } - return offset + position.character; - }, - positionAt: (offset: number) => { - let remaining = offset; - for (let i = 0; i < lines.length; i++) { - if (remaining <= lines[i].length) { - return new vscode.Position(i, remaining); - } - remaining -= lines[i].length + 1; - } - return new vscode.Position(lines.length - 1, lines[lines.length - 1].length); - }, - getText: () => content, - getWordRangeAtPosition: () => undefined, - validateRange: (range: vscode.Range) => range, - validatePosition: (position: vscode.Position) => position, - notebook: undefined as any, - } as vscode.TextDocument; -} function makeResource(name: string): ResourceJson { return { diff --git a/extension/src/test/aspirePackageRestoreProvider.test.ts b/extension/src/test/aspirePackageRestoreProvider.test.ts new file mode 100644 index 00000000000..a0137900cb6 --- /dev/null +++ b/extension/src/test/aspirePackageRestoreProvider.test.ts @@ -0,0 +1,87 @@ +import * as assert from 'assert'; +import { ChildProcessWithoutNullStreams } from 'child_process'; +import { EventEmitter } from 'events'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { AspirePackageRestoreProvider } from '../utils/AspirePackageRestoreProvider'; +import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import * as cliProcessModule from '../utils/process/cliProcess'; + +suite('AspirePackageRestoreProvider', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => sandbox.restore()); + + test('resolves the CLI from the workspace folder that owns the config file', async () => { + const folder = createWorkspaceFolder('/repo/workspace'); + const configUri = vscode.Uri.file(path.join(folder.uri.fsPath, 'aspire.config.json')); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').withArgs(configUri).returns(folder); + const getAspireCliExecutablePath = sandbox.stub().resolves('/repo/workspace/bin/aspire'); + const provider = new AspirePackageRestoreProvider({ getAspireCliExecutablePath } as unknown as AspireTerminalProvider); + const childProcess = createChildProcess(); + const spawnStub = sandbox.stub(cliProcessModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + queueMicrotask(() => { + options?.exitCallback?.(0); + childProcess.emit('close', 0); + }); + return childProcess as unknown as ChildProcessWithoutNullStreams; + }); + + try { + await (provider as any)._runRestore(configUri, folder.uri.fsPath, 'aspire.config.json'); + + assert.ok(getAspireCliExecutablePath.calledOnceWith(workspaceFolderCliPathTarget(folder))); + assert.ok(spawnStub.calledOnceWith( + provider['_terminalProvider'], + '/repo/workspace/bin/aspire', + ['restore'], + sinon.match({ workingDirectory: folder.uri.fsPath }))); + } finally { + provider.dispose(); + } + }); + + test('does not spawn restore when disposed during CLI resolution', async () => { + let resolveCliPath!: (cliPath: string) => void; + const cliPath = new Promise(resolve => { + resolveCliPath = resolve; + }); + const getAspireCliExecutablePath = sandbox.stub().returns(cliPath); + const provider = new AspirePackageRestoreProvider({ getAspireCliExecutablePath } as unknown as AspireTerminalProvider); + const childProcess = createChildProcess(); + const spawnStub = sandbox.stub(cliProcessModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + queueMicrotask(() => { + options?.exitCallback?.(0); + childProcess.emit('close', 0); + }); + return childProcess as unknown as ChildProcessWithoutNullStreams; + }); + const configUri = vscode.Uri.file('/repo/workspace/aspire.config.json'); + + const restore = (provider as any)._runRestore(configUri, '/repo/workspace', 'aspire.config.json') as Promise; + assert.ok(getAspireCliExecutablePath.calledOnce); + provider.dispose(); + resolveCliPath('/repo/workspace/bin/aspire'); + await restore; + + assert.ok(spawnStub.notCalled); + }); +}); + +function createWorkspaceFolder(folderPath: string): vscode.WorkspaceFolder { + return { + uri: vscode.Uri.file(folderPath), + name: 'workspace', + index: 0, + }; +} + +function createChildProcess(): EventEmitter & { kill: sinon.SinonStub } { + return Object.assign(new EventEmitter(), { kill: sinon.stub() }); +} \ No newline at end of file diff --git a/extension/src/test/aspireTerminalProvider.test.ts b/extension/src/test/aspireTerminalProvider.test.ts index 833296fa289..f939fbfa726 100644 --- a/extension/src/test/aspireTerminalProvider.test.ts +++ b/extension/src/test/aspireTerminalProvider.test.ts @@ -1,8 +1,13 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { AspireTerminalProvider, quoteShellArg, shellArg } from '../utils/AspireTerminalProvider'; import * as cliPathModule from '../utils/cliPath'; +import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { createWorkspaceFolder, removeDirectorySafely } from './testHelpers'; import { EnvironmentVariables } from '../utils/environment'; import { extensionLogOutputChannel } from '../utils/logging'; import { terminalCommandArgumentControlCharacters, terminalCommandUnsafeLiteral } from '../loc/strings'; @@ -58,6 +63,23 @@ suite('AspireTerminalProvider tests', () => { const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'C:\\Program Files\\Aspire\\aspire.exe'); }); + + test('forwards the workspace folder target to resolveCliPath', async () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + resolveCliPathStub.resolves({ cliPath: '/repo/a/bin/aspire', available: true, source: 'configured' }); + + await terminalProvider.getAspireCliExecutablePath(workspaceFolderCliPathTarget(folder)); + + assert.ok(resolveCliPathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); + }); + + test('defaults to the window target when no target is supplied', async () => { + resolveCliPathStub.resolves({ cliPath: 'aspire', available: true, source: 'path' }); + + await terminalProvider.getAspireCliExecutablePath(); + + assert.ok(resolveCliPathStub.calledOnceWith(windowCliPathTarget)); + }); }); function restoreEnvironmentVariable(name: string, value: string | undefined): void { @@ -233,6 +255,155 @@ suite('AspireTerminalProvider tests', () => { } }); + test('does not reuse a shared Aspire terminal across workspace folders', async () => { + const folderA = createWorkspaceFolder('a', '/repo/a'); + const folderB = createWorkspaceFolder('b', '/repo/b'); + const targetA = workspaceFolderCliPathTarget(folderA); + const targetB = workspaceFolderCliPathTarget(folderB); + resolveCliPathStub.callsFake(async target => ({ + cliPath: target === targetA ? '/repo/a/aspire' : '/repo/b/aspire', + available: true, + source: 'configured', + })); + const createEnvironmentStub = sinon.stub(terminalProvider, 'createEnvironment').returns({}); + const createTerminalStub = sinon.stub(vscode.window, 'createTerminal').callsFake(() => ({ + shellIntegration: { executeCommand: () => ({} as vscode.TerminalShellExecution) }, + show: () => { }, + dispose: () => { }, + } as unknown as vscode.Terminal)); + + try { + await terminalProvider.sendAspireCommandToAspireTerminal('logs', false, undefined, { target: targetA }); + await terminalProvider.sendAspireCommandToAspireTerminal('logs', false, undefined, { target: targetB }); + + assert.strictEqual(createTerminalStub.callCount, 2); + assert.strictEqual((createTerminalStub.firstCall.args[0] as vscode.TerminalOptions).cwd, folderA.uri); + assert.strictEqual((createTerminalStub.secondCall.args[0] as vscode.TerminalOptions).cwd, folderB.uri); + } + finally { + createTerminalStub.restore(); + createEnvironmentStub.restore(); + } + }); + + test('uses the same concrete CLI for the command and terminal environment', async () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + const target = workspaceFolderCliPathTarget(folder); + const cliPath = '/repo/a/bin/aspire'; + resolveCliPathStub.resolves({ cliPath, available: true, source: 'configured' }); + const createEnvironmentStub = sinon.stub(terminalProvider, 'createEnvironment').returns({}); + let executedCommand: string | undefined; + const createTerminalStub = sinon.stub(vscode.window, 'createTerminal').returns({ + shellIntegration: { + executeCommand: (commandLine: string) => { + executedCommand = commandLine; + return {} as vscode.TerminalShellExecution; + } + }, + show: () => { }, + dispose: () => { }, + } as unknown as vscode.Terminal); + + try { + await terminalProvider.sendAspireCommandToAspireTerminal('logs', false, undefined, { target }); + + assert.ok(resolveCliPathStub.calledOnceWith(target)); + // The shell decides how the path is quoted: PowerShell needs the call operator and + // quotes, POSIX shells do not. Asserting containment keeps this about which CLI ran. + assert.ok(executedCommand?.includes(cliPath), executedCommand); + assert.ok(executedCommand?.endsWith(' logs'), executedCommand); + assert.ok(createEnvironmentStub.calledOnceWith(undefined, undefined, undefined, cliPath)); + } + finally { + createTerminalStub.restore(); + createEnvironmentStub.restore(); + } + }); + + test('uses a gate-resolved CLI without resolving the target again', async () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + const target = workspaceFolderCliPathTarget(folder); + const cliPath = '/gate/bin/aspire'; + const createEnvironmentStub = sinon.stub(terminalProvider, 'createEnvironment').returns({}); + let executedCommand: string | undefined; + const createTerminalStub = sinon.stub(vscode.window, 'createTerminal').returns({ + shellIntegration: { + executeCommand: (commandLine: string) => { + executedCommand = commandLine; + return {} as vscode.TerminalShellExecution; + }, + }, + show: () => { }, + dispose: () => { }, + } as unknown as vscode.Terminal); + + try { + await terminalProvider.sendAspireCommandToAspireTerminal('new', false, undefined, { target, cliPath }); + + assert.strictEqual(resolveCliPathStub.called, false); + assert.ok(executedCommand?.includes(cliPath), executedCommand); + assert.strictEqual(createEnvironmentStub.firstCall.args[3], cliPath); + } + finally { + createTerminalStub.restore(); + createEnvironmentStub.restore(); + } + }); + + test('does not reuse a shared terminal whose CLI environment is stale', async () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + const target = workspaceFolderCliPathTarget(folder); + const createEnvironmentStub = sinon.stub(terminalProvider, 'createEnvironment').returns({}); + const createTerminalStub = sinon.stub(vscode.window, 'createTerminal').callsFake(() => ({ + shellIntegration: { executeCommand: () => ({} as vscode.TerminalShellExecution) }, + show: () => { }, + dispose: () => { }, + } as unknown as vscode.Terminal)); + + try { + await terminalProvider.sendAspireCommandToAspireTerminal('new', false, undefined, { target, cliPath: '/repo/a/first/aspire' }); + await terminalProvider.sendAspireCommandToAspireTerminal('new', false, undefined, { target, cliPath: '/repo/a/second/aspire' }); + + assert.strictEqual(createTerminalStub.callCount, 2); + assert.strictEqual(createEnvironmentStub.firstCall.args[3], '/repo/a/first/aspire'); + assert.strictEqual(createEnvironmentStub.secondCall.args[3], '/repo/a/second/aspire'); + } + finally { + createTerminalStub.restore(); + createEnvironmentStub.restore(); + } + }); + + test('targeted invalidation stops reusing only the matching folder terminal', async () => { + const folderA = createWorkspaceFolder('a', '/repo/a'); + const folderB = createWorkspaceFolder('b', '/repo/b'); + const targetA = workspaceFolderCliPathTarget(folderA); + const targetB = workspaceFolderCliPathTarget(folderB); + resolveCliPathStub.resolves({ cliPath: 'aspire', available: true, source: 'path' }); + const createEnvironmentStub = sinon.stub(terminalProvider, 'createEnvironment').returns({}); + const createTerminalStub = sinon.stub(vscode.window, 'createTerminal').callsFake(() => ({ + shellIntegration: { executeCommand: () => ({} as vscode.TerminalShellExecution) }, + show: () => { }, + dispose: () => { }, + } as unknown as vscode.Terminal)); + + try { + await terminalProvider.sendAspireCommandToAspireTerminal('logs', false, undefined, { target: targetA }); + await terminalProvider.sendAspireCommandToAspireTerminal('logs', false, undefined, { target: targetB }); + + (terminalProvider as unknown as { invalidateSharedAspireTerminal(target: typeof targetA): void }).invalidateSharedAspireTerminal(targetA); + + await terminalProvider.sendAspireCommandToAspireTerminal('logs', false, undefined, { target: targetB }); + await terminalProvider.sendAspireCommandToAspireTerminal('logs', false, undefined, { target: targetA }); + + assert.strictEqual(createTerminalStub.callCount, 3); + } + finally { + createTerminalStub.restore(); + createEnvironmentStub.restore(); + } + }); + test('uses shell integration to execute command when available', async () => { resolveCliPathStub.resolves({ cliPath: 'aspire', available: true, source: 'path' }); const events: string[] = []; @@ -292,7 +463,7 @@ suite('AspireTerminalProvider tests', () => { const sharedTerminalStub = sinon.stub(terminalProvider, 'getAspireTerminal'); try { - await terminalProvider.sendAspireCommandToAspireTerminal('logs', true, undefined, { terminalTarget: 'editor' }); + await terminalProvider.sendAspireCommandToAspireTerminal('logs', true, undefined, { terminalTarget: 'editor', target: windowCliPathTarget }); assert.strictEqual(sharedTerminalStub.called, false); assert.strictEqual(createTerminalStub.calledOnce, true); @@ -616,7 +787,7 @@ suite('AspireTerminalProvider tests', () => { '--', '--secret', 'super-secret', - ], { redactAdditionalArgs: true }); + ], { redactAdditionalArgs: true, target: windowCliPathTarget }); assert.ok(executedCommand?.includes('super-secret')); assert.strictEqual(infoStub.calledOnce, true); @@ -880,6 +1051,55 @@ suite('AspireTerminalProvider tests', () => { assert.strictEqual(env[key], undefined, key); } }); + + test('forwards the exact resolved CLI path even when a different configured path is valid', () => { + const otherExistingPath = path.join(__dirname, 'cliSpawn.test.js'); + const getConfiguredCliPathStub = sinon.stub(cliPathModule, 'getConfiguredCliPath').returns(otherExistingPath); + try { + const env = terminalProvider.createEnvironment(undefined, undefined, undefined, __filename); + assert.strictEqual(env.AspireCliPath, __filename); + } finally { + getConfiguredCliPathStub.restore(); + } + }); + + test('forwards the exact resolved CLI path when extension backchannel variables are suppressed', () => { + const otherExistingPath = path.join(__dirname, 'cliSpawn.test.js'); + const getConfiguredCliPathStub = sinon.stub(cliPathModule, 'getConfiguredCliPath').returns(otherExistingPath); + try { + const env = terminalProvider.createEnvironment(undefined, undefined, true, __filename); + assert.strictEqual(env.AspireCliPath, __filename); + } finally { + getConfiguredCliPathStub.restore(); + } + }); + + test('removes an ambient AspireCliPath when the resolved CLI path is a bare command name', () => { + // A bare command isn't forwardable, so any inherited AspireCliPath (e.g. from a + // parent shell) must not leak into this child and point ResolveAspireCliBundle at a + // CLI other than the one that was actually launched. + const previousAmbientCliPath = process.env.AspireCliPath; + process.env.AspireCliPath = '/ambient/stale/aspire'; + try { + const env = terminalProvider.createEnvironment(undefined, undefined, undefined, 'aspire'); + assert.strictEqual(env.AspireCliPath, undefined); + } finally { + restoreEnvironmentVariable('AspireCliPath', previousAmbientCliPath); + } + }); + + test('omits AspireCliPath for a resolved unbundled framework-dependent CLI build', () => { + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-terminal-provider-unbundled-')); + const cliPath = path.join(tempDirectory, 'aspire'); + fs.writeFileSync(cliPath, ''); + fs.writeFileSync(path.join(tempDirectory, 'aspire.dll'), ''); + try { + const env = terminalProvider.createEnvironment(undefined, undefined, undefined, cliPath); + assert.strictEqual(env.AspireCliPath, undefined); + } finally { + removeDirectorySafely(tempDirectory); + } + }); }); // The Windows quoting form targets PowerShell (powershell.exe / pwsh.exe), diff --git a/extension/src/test/cli.test.ts b/extension/src/test/cli.test.ts index 9ec145ca359..fb4d7162fd3 100644 --- a/extension/src/test/cli.test.ts +++ b/extension/src/test/cli.test.ts @@ -55,18 +55,19 @@ suite('CLI process termination', () => { await termination; }); - test('stops tracking immediately when a PID-less child cannot be signaled', async () => { + test('rejects when a PID-less child cannot be signaled', async () => { sinon.stub(process, 'platform').value('linux'); const clock = sinon.useFakeTimers(); const childProcess = createFakeCliProcess(undefined, null); childProcess.kill.returns(false); - let settled = false; - void terminateCliProcess(childProcess, 'Aspire CLI').then(() => { settled = true; }); - await clock.tickAsync(0); + const termination = terminateCliProcess(childProcess, 'Aspire CLI'); - assert.strictEqual(settled, true); + await assert.rejects(termination, /Could not terminate Aspire CLI because no process identifier was available/); sinon.assert.calledOnceWithExactly(childProcess.kill, undefined); + assert.strictEqual(childProcess.listenerCount('close'), 0); + assert.strictEqual(childProcess.listenerCount('exit'), 0); + assert.strictEqual(clock.countTimers(), 0); }); test('forcefully terminates a live process when graceful signaling throws', async () => { @@ -88,18 +89,19 @@ suite('CLI process termination', () => { await termination; }); - test('stops tracking immediately when signaling a PID-less child throws', async () => { + test('rejects when signaling a PID-less child throws', async () => { sinon.stub(process, 'platform').value('linux'); const clock = sinon.useFakeTimers(); const childProcess = createFakeCliProcess(undefined, null); childProcess.kill.throws(new Error('signal failed')); - let settled = false; - void terminateCliProcess(childProcess, 'Aspire CLI').then(() => { settled = true; }); - await clock.tickAsync(0); + const termination = terminateCliProcess(childProcess, 'Aspire CLI'); - assert.strictEqual(settled, true); + await assert.rejects(termination, /Could not terminate Aspire CLI because no process identifier was available/); sinon.assert.calledOnceWithExactly(childProcess.kill, undefined); + assert.strictEqual(childProcess.listenerCount('close'), 0); + assert.strictEqual(childProcess.listenerCount('exit'), 0); + assert.strictEqual(clock.countTimers(), 0); }); }); diff --git a/extension/src/test/cliPath.test.ts b/extension/src/test/cliPath.test.ts index 196ea62e5e6..4d4c1f824d5 100644 --- a/extension/src/test/cliPath.test.ts +++ b/extension/src/test/cliPath.test.ts @@ -3,24 +3,48 @@ import * as sinon from 'sinon'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as vscode from 'vscode'; +import { createWorkspaceFolder, removeDirectorySafely } from './testHelpers'; import { findCliOnPath, + getConfiguredCliPath, getDefaultCliInstallPaths, resolveCliPath, CliPathDependencies, + CliPathResolver, tryExecuteCli, CliProbeExecutor, isConfiguredCliPathRejectedForForwarding, resetRejectedConfiguredCliPathForForwarding, } from '../utils/cliPath'; +import { + getCliExecutableCandidates, + windowCliPathTarget, + workspaceFolderCliPathTarget, +} from '../utils/cliPathVariables'; + +function absolutePathFor(platform: NodeJS.Platform, ...segments: string[]): string { + return platform === 'win32' + ? path.win32.join('C:\\', ...segments) + : path.posix.join('/', ...segments); +} + +function defaultCliPathFor(platform: NodeJS.Platform, ...segments: string[]): string { + return absolutePathFor(platform, ...segments, platform === 'win32' ? 'aspire.exe' : 'aspire'); +} + +function configuredCliPathFor(platform: NodeJS.Platform, ...segments: string[]): string { + return absolutePathFor(platform, ...segments, 'aspire'); +} -const bundlePath = '/home/user/.aspire/bin/aspire'; -const globalToolPath = '/home/user/.dotnet/tools/aspire'; +const bundlePath = defaultCliPathFor(process.platform, 'home', 'user', '.aspire', 'bin'); +const globalToolPath = defaultCliPathFor(process.platform, 'home', 'user', '.dotnet', 'tools'); const defaultPaths = [bundlePath, globalToolPath]; function createMockDeps(overrides: Partial = {}): CliPathDependencies { return { getConfiguredPath: () => '', + getWorkspaceFolders: () => [], getDefaultPaths: () => defaultPaths, isConfiguredPathAutoConfigured: (configuredPath, paths) => paths.some(candidate => process.platform === 'win32' ? path.win32.normalize(candidate).toLowerCase() === path.win32.normalize(configuredPath).toLowerCase() @@ -28,6 +52,7 @@ function createMockDeps(overrides: Partial = {}): CliPathDe findOnPath: async () => undefined, findAtDefaultPath: async () => undefined, tryExecute: async () => false, + getExecutableCandidates: candidate => [candidate], setConfiguredPath: async () => {}, updateResolvedPathForForwarding: () => {}, ...overrides, @@ -129,6 +154,41 @@ suite('utils/cliPath tests', () => { }); suite('findCliOnPath', () => { + test('returns a concrete POSIX executable from PATH', async () => { + const cliPath = '/opt/aspire/bin/aspire'; + const result = await findCliOnPath({ + platform: 'linux', + pathValue: '/missing/bin:/opt/aspire/bin', + fileExists: async candidate => candidate === cliPath, + tryExecute: async candidate => candidate === cliPath, + }); + + assert.strictEqual(result, cliPath); + }); + + test('skips empty and relative POSIX PATH entries before probing candidates', async () => { + const executablePath = '/opt/aspire/bin/aspire'; + const fileExistsCandidates: string[] = []; + const tryExecuteCandidates: string[] = []; + + const result = await findCliOnPath({ + platform: 'linux', + pathValue: ':tools::.:../tools:/opt/missing:/opt/aspire/bin:', + fileExists: async candidate => { + fileExistsCandidates.push(candidate); + return candidate === executablePath; + }, + tryExecute: async candidate => { + tryExecuteCandidates.push(candidate); + return candidate === executablePath; + }, + }); + + assert.strictEqual(result, executablePath); + assert.deepStrictEqual(fileExistsCandidates, ['/opt/missing/aspire', executablePath]); + assert.deepStrictEqual(tryExecuteCandidates, [executablePath]); + }); + test('returns a concrete Windows command shim from PATH', async () => { const commandShim = 'C:\\npm\\aspire.cmd'; const result = await findCliOnPath({ @@ -194,7 +254,7 @@ suite('utils/cliPath tests', () => { commandShim); } finally { - fs.rmSync(tempDirectory, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); + removeDirectorySafely(tempDirectory); } }); }); @@ -351,7 +411,7 @@ suite('utils/cliPath tests', () => { available: true, source: 'default-install', }); - assert.ok(setConfiguredPath.calledOnceWithExactly('')); + assert.ok(setConfiguredPath.calledOnceWithExactly('', windowCliPathTarget)); } finally { platformStub.restore(); @@ -395,7 +455,7 @@ suite('utils/cliPath tests', () => { assert.deepStrictEqual(result, { cliPath: 'aspire', available: true, source: 'path' }); assert.ok(tryExecute.notCalled); - assert.ok(setConfiguredPath.calledOnceWithExactly('')); + assert.ok(setConfiguredPath.calledOnceWithExactly('', windowCliPathTarget)); } finally { platformStub.restore(); @@ -403,7 +463,7 @@ suite('utils/cliPath tests', () => { }); test('keeps a workspace-scoped legacy path as an explicit user pin', async () => { - const legacyGlobalToolPath = '/home/user/.dotnet/tools/aspire'; + const legacyGlobalToolPath = globalToolPath; const findOnPath = sinon.stub().resolves('aspire'); const tryExecute = sinon.stub().resolves(true); const setConfiguredPath = sinon.stub().resolves(); @@ -540,7 +600,7 @@ suite('utils/cliPath tests', () => { }); test('uses custom configured path when valid and not a default', async () => { - const customPath = '/custom/path/aspire'; + const customPath = configuredCliPathFor(process.platform, 'custom', 'path'); const deps = createMockDeps({ getConfiguredPath: () => customPath, @@ -554,6 +614,31 @@ suite('utils/cliPath tests', () => { assert.strictEqual(result.cliPath, customPath); }); + test('resolves a bare configured command name to the concrete PATH executable', async () => { + const configuredPath = 'aspire'; + const resolvedPath = configuredCliPathFor(process.platform, 'somewhere', 'else'); + const tryExecute = sinon.stub().resolves(true); + const findOnPath = sinon.stub().resolves(resolvedPath); + const updateResolvedPathForForwarding = sinon.stub(); + + const result = await resolveCliPath(createMockDeps({ + getConfiguredPath: () => configuredPath, + findOnPath, + tryExecute, + updateResolvedPathForForwarding, + })); + + assert.deepStrictEqual(result, { + cliPath: resolvedPath, + available: true, + source: 'path', + }); + assert.ok(tryExecute.notCalled); + assert.ok(findOnPath.calledOnce); + assert.strictEqual(isConfiguredCliPathRejectedForForwarding(configuredPath), true); + assert.ok(updateResolvedPathForForwarding.calledOnceWithExactly(configuredPath, resolvedPath)); + }); + test('keeps an explicitly configured Windows command shim ahead of PATH', async () => { const configuredShim = 'C:\\Users\\user\\.dotnet\\tools\\aspire.cmd'; const findOnPath = sinon.stub().resolves('aspire'); @@ -576,8 +661,9 @@ suite('utils/cliPath tests', () => { }); test('falls through to PATH check when custom configured path is invalid', async () => { + const configuredPath = configuredCliPathFor(process.platform, 'bad', 'path'); const deps = createMockDeps({ - getConfiguredPath: () => '/bad/path/aspire', + getConfiguredPath: () => configuredPath, tryExecute: async () => false, findOnPath: async () => 'aspire', }); @@ -590,9 +676,10 @@ suite('utils/cliPath tests', () => { test('does not overwrite an invalid explicit setting when falling back to a default path', async () => { const setConfiguredPath = sinon.stub().resolves(); + const configuredPath = configuredCliPathFor(process.platform, 'bad', 'path'); const deps = createMockDeps({ - getConfiguredPath: () => '/bad/path/aspire', + getConfiguredPath: () => configuredPath, tryExecute: async () => false, findOnPath: async () => undefined, findAtDefaultPath: async () => bundlePath, @@ -624,7 +711,7 @@ suite('utils/cliPath tests', () => { }); suite('configured path forwarding suppression', () => { - const configuredPath = '/opt/custom/aspire'; + const configuredPath = configuredCliPathFor(process.platform, 'opt', 'custom'); const discoveredShim = 'C:\\Users\\me\\.dotnet\\tools\\aspire.cmd'; setup(() => { @@ -686,6 +773,156 @@ suite('utils/cliPath tests', () => { assert.ok(isConfiguredCliPathRejectedForForwarding(configuredPath)); }); + test('rejects a configured POSIX relative path-like value and falls through to PATH without probing it', async () => { + const relativeConfiguredPath = './tools/aspire'; + const tryExecute = sinon.stub().resolves(true); + const findOnPath = sinon.stub().resolves('aspire'); + + const result = await resolveCliPath(createMockDeps({ + getConfiguredPath: () => relativeConfiguredPath, + findOnPath, + tryExecute, + })); + + assert.deepStrictEqual(result, { + cliPath: 'aspire', + available: true, + source: 'path', + }); + assert.ok(tryExecute.notCalled); + assert.ok(findOnPath.calledOnce); + assert.ok(isConfiguredCliPathRejectedForForwarding(relativeConfiguredPath)); + }); + + test('rejects a configured Windows relative path-like value and falls through to default discovery without probing it', async () => { + const relativeConfiguredPath = '.\\tools\\aspire.cmd'; + const discoveredCliPath = 'C:\\Users\\me\\.aspire\\bin\\aspire.exe'; + const tryExecute = sinon.stub().resolves(true); + + const result = await resolveCliPath(createMockDeps({ + getConfiguredPath: () => relativeConfiguredPath, + findAtDefaultPath: async () => discoveredCliPath, + tryExecute, + })); + + assert.deepStrictEqual(result, { + cliPath: discoveredCliPath, + available: true, + source: 'default-install', + }); + assert.ok(tryExecute.notCalled); + assert.ok(isConfiguredCliPathRejectedForForwarding(relativeConfiguredPath)); + }); + + test('expands a Windows environment-variable configured path before probing and forwarding it', async () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + const originalAspireHome = process.env.ASPIRE_HOME; + process.env.ASPIRE_HOME = 'C:\\Aspire Home'; + const configuredPath = '%aspire_home%\\aspire.cmd'; + const expandedPath = 'C:\\Aspire Home\\aspire.cmd'; + const tryExecute = sinon.stub().callsFake(async candidate => candidate === expandedPath); + const findOnPath = sinon.stub().resolves('C:\\Other\\aspire.exe'); + const updateResolvedPathForForwarding = sinon.stub(); + + try { + const result = await resolveCliPath(createMockDeps({ + getConfiguredPath: () => configuredPath, + findOnPath, + tryExecute, + updateResolvedPathForForwarding, + })); + + assert.deepStrictEqual(result, { + cliPath: expandedPath, + available: true, + source: 'configured', + }); + assert.ok(tryExecute.calledOnceWithExactly(expandedPath)); + assert.ok(findOnPath.notCalled); + assert.strictEqual(isConfiguredCliPathRejectedForForwarding(configuredPath), false); + assert.ok(updateResolvedPathForForwarding.calledOnceWithExactly(configuredPath, expandedPath)); + } + finally { + platformStub.restore(); + if (originalAspireHome === undefined) { + delete process.env.ASPIRE_HOME; + } + else { + process.env.ASPIRE_HOME = originalAspireHome; + } + } + }); + + test('rejects a configured Windows path whose environment variable cannot be expanded', async () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + const configuredPath = 'C:\\Tools\\%ASPIRE_UNKNOWN_HOME%\\aspire.cmd'; + const resolvedPath = 'C:\\Other\\aspire.exe'; + const tryExecute = sinon.stub().resolves(true); + const findOnPath = sinon.stub().resolves(resolvedPath); + + try { + const result = await resolveCliPath(createMockDeps({ + getConfiguredPath: () => configuredPath, + findOnPath, + tryExecute, + })); + + assert.deepStrictEqual(result, { + cliPath: resolvedPath, + available: true, + source: 'path', + }); + assert.ok(tryExecute.notCalled); + assert.ok(findOnPath.calledOnce); + assert.strictEqual(isConfiguredCliPathRejectedForForwarding(configuredPath), true); + } + finally { + platformStub.restore(); + } + }); + + test('rejects a configured Windows drive-relative path-like value and falls through to the discovered CLI without probing it', async () => { + const driveRelativeConfiguredPath = 'C:tools\\aspire.exe'; + const discoveredCliPath = 'C:\\Users\\me\\.aspire\\bin\\aspire.exe'; + const tryExecute = sinon.stub().resolves(true); + const findAtDefaultPath = sinon.stub().resolves(discoveredCliPath); + + const result = await resolveCliPath(createMockDeps({ + getConfiguredPath: () => driveRelativeConfiguredPath, + findAtDefaultPath, + tryExecute, + })); + + assert.deepStrictEqual(result, { + cliPath: discoveredCliPath, + available: true, + source: 'default-install', + }); + assert.ok(tryExecute.notCalled); + assert.ok(findAtDefaultPath.calledOnce); + assert.ok(isConfiguredCliPathRejectedForForwarding(driveRelativeConfiguredPath)); + }); + + test('rejects a separator-free Windows drive-relative executable and falls through without probing it', async () => { + const driveRelativeConfiguredPath = 'C:aspire.exe'; + const discoveredCliPath = 'C:\\Users\\me\\.aspire\\bin\\aspire.exe'; + const tryExecute = sinon.stub().resolves(true); + + const result = await resolveCliPath(createMockDeps({ + getConfiguredPath: () => driveRelativeConfiguredPath, + findAtDefaultPath: async () => discoveredCliPath, + tryExecute, + })); + + assert.deepStrictEqual(result, { + cliPath: discoveredCliPath, + available: true, + source: 'default-install', + }); + assert.ok(tryExecute.notCalled); + assert.ok(isConfiguredCliPathRejectedForForwarding(driveRelativeConfiguredPath)); + }); + test('does not suppress forwarding when the configured path executes successfully', async () => { const deps = createMockDeps({ getConfiguredPath: () => configuredPath, @@ -731,8 +968,8 @@ suite('utils/cliPath tests', () => { }); test('does not let an older resolution clear a newer configured-path rejection', async () => { - const olderConfiguredPath = '/opt/old/aspire'; - const newerConfiguredPath = '/opt/new/aspire'; + const olderConfiguredPath = configuredCliPathFor(process.platform, 'opt', 'old'); + const newerConfiguredPath = configuredCliPathFor(process.platform, 'opt', 'new'); let configuredPath = olderConfiguredPath; let completeOlderProbe: ((value: boolean) => void) | undefined; const olderProbe = new Promise(resolve => completeOlderProbe = resolve); @@ -761,10 +998,10 @@ suite('utils/cliPath tests', () => { }); test('does not publish a stale fallback when the setting returns to the same snapshot', async () => { - const firstConfiguredPath = '/opt/first/aspire'; - const intermediateConfiguredPath = '/opt/intermediate/aspire'; - const oldFallback = '/opt/old-fallback/aspire'; - const newFallback = '/opt/new-fallback/aspire'; + const firstConfiguredPath = configuredCliPathFor(process.platform, 'opt', 'first'); + const intermediateConfiguredPath = configuredCliPathFor(process.platform, 'opt', 'intermediate'); + const oldFallback = configuredCliPathFor(process.platform, 'opt', 'old-fallback'); + const newFallback = configuredCliPathFor(process.platform, 'opt', 'new-fallback'); let configuredPathValue = firstConfiguredPath; let completeOldFallback: ((value: string) => void) | undefined; let notifyOldFallbackStarted: (() => void) | undefined; @@ -783,7 +1020,7 @@ suite('utils/cliPath tests', () => { } return configuredPathValue === intermediateConfiguredPath - ? '/opt/intermediate-fallback/aspire' + ? configuredCliPathFor(process.platform, 'opt', 'intermediate-fallback') : newFallback; }, updateResolvedPathForForwarding, @@ -807,8 +1044,8 @@ suite('utils/cliPath tests', () => { }); test('does not persist a default path from a stale resolution', async () => { - const intermediateConfiguredPath = '/opt/intermediate/aspire'; - const redirectedFallback = '/opt/redirected/aspire'; + const intermediateConfiguredPath = configuredCliPathFor(process.platform, 'opt', 'intermediate'); + const redirectedFallback = configuredCliPathFor(process.platform, 'opt', 'redirected'); let configuredPathValue = ''; let completeOldFallback: ((value: string) => void) | undefined; let notifyOldFallbackStarted: (() => void) | undefined; @@ -909,8 +1146,8 @@ suite('utils/cliPath tests', () => { }); test('retries a resolution when the configured path changes during its probe', async () => { - const olderConfiguredPath = '/opt/old/aspire'; - const newerConfiguredPath = '/opt/new/aspire'; + const olderConfiguredPath = configuredCliPathFor(process.platform, 'opt', 'old'); + const newerConfiguredPath = configuredCliPathFor(process.platform, 'opt', 'new'); let configuredPathValue = olderConfiguredPath; let completeOlderProbe: ((value: boolean) => void) | undefined; const olderProbe = new Promise(resolve => completeOlderProbe = resolve); @@ -947,7 +1184,7 @@ suite('utils/cliPath tests', () => { assert.strictEqual(await tryExecuteCli(wrapperPath), true); } finally { - fs.rmSync(tempDirectory, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); + removeDirectorySafely(tempDirectory); } }); @@ -969,7 +1206,7 @@ suite('utils/cliPath tests', () => { assert.strictEqual(await tryExecuteCli(wrapperPath), true); } finally { - fs.rmSync(tempDirectory, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); + removeDirectorySafely(tempDirectory); } }); @@ -1043,3 +1280,284 @@ suite('utils/cliPath tests', () => { }); }); }); + +suite('CliPathResolver scoped tests', () => { + // Folder paths for the ${workspaceFolder} cases have to be shaped like the host's, because + // expandConfiguredCliPath normalizes its expansion with the host's path library: a POSIX literal + // becomes a backslash path on Windows and stops matching what the test configured. path.resolve + // supplies the drive letter Windows needs for the result to be fully qualified. + const folderAPath = path.resolve('/repo/a'); + const folderBPath = path.resolve('/repo/b'); + const folderACli = path.join(folderAPath, 'bin', 'aspire'); + const folderBCli = path.join(folderBPath, 'bin', 'aspire'); + const folderA = createWorkspaceFolder('a', folderAPath, 0); + const folderB = createWorkspaceFolder('b', folderBPath, 1); + const targetA = workspaceFolderCliPathTarget(folderA); + const targetB = workspaceFolderCliPathTarget(folderB); + + test('resolves the same tokenized setting independently for two workspace folders without coalescing', async () => { + const attempted: string[] = []; + const resolver = new CliPathResolver(createMockDeps({ + getConfiguredPath: () => '${workspaceFolder}/bin/aspire', + getWorkspaceFolders: () => [folderA, folderB], + tryExecute: async candidate => { + attempted.push(candidate); + return candidate === folderACli || candidate === folderBCli; + }, + })); + + const [resultA, resultB] = await Promise.all([ + resolver.resolve(targetA), + resolver.resolve(targetB), + ]); + + assert.strictEqual(resultA.cliPath, folderACli); + assert.strictEqual(resultA.source, 'configured'); + assert.strictEqual(resultB.cliPath, folderBCli); + assert.strictEqual(resultB.source, 'configured'); + assert.deepStrictEqual(attempted.sort(), [folderACli, folderBCli].sort()); + }); + + test('never passes a tokenized configured value to setConfiguredPath', async () => { + const setConfiguredPath = sinon.stub().resolves(); + const resolver = new CliPathResolver(createMockDeps({ + getConfiguredPath: () => '${workspaceFolder}/bin/aspire', + getWorkspaceFolders: () => [folderA], + tryExecute: async candidate => candidate === folderACli, + setConfiguredPath, + })); + + const result = await resolver.resolve(targetA); + + assert.strictEqual(result.cliPath, folderACli); + assert.strictEqual(result.source, 'configured'); + assert.ok(setConfiguredPath.notCalled); + }); + + test('does not persist the global setting for a folder target with an empty configured path when a default install is found', async () => { + const setConfiguredPath = sinon.stub().resolves(); + const resolver = new CliPathResolver(createMockDeps({ + getConfiguredPath: () => '', + getWorkspaceFolders: () => [folderA], + findOnPath: async () => undefined, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + })); + + const result = await resolver.resolve(targetA); + + assert.strictEqual(result.cliPath, bundlePath); + assert.strictEqual(result.source, 'default-install'); + assert.ok(setConfiguredPath.notCalled, 'a folder target must never write the window-wide setting'); + }); + + test('does not clear the global setting for a folder target inheriting a legacy configured path when PATH is found', async () => { + const setConfiguredPath = sinon.stub().resolves(); + const resolver = new CliPathResolver(createMockDeps({ + getConfiguredPath: () => bundlePath, + isConfiguredPathAutoConfigured: () => true, + getWorkspaceFolders: () => [folderA], + findOnPath: async () => 'aspire', + setConfiguredPath, + })); + + const result = await resolver.resolve(targetA); + + assert.strictEqual(result.cliPath, 'aspire'); + assert.strictEqual(result.source, 'path'); + assert.ok(setConfiguredPath.notCalled, 'a folder target must never clear the window-wide setting'); + }); + + test('does not persist the global setting when a tokenized configured candidate fails and a default install is found', async () => { + const setConfiguredPath = sinon.stub().resolves(); + const resolver = new CliPathResolver(createMockDeps({ + getConfiguredPath: () => '${workspaceFolder}/bin/aspire', + getWorkspaceFolders: () => [folderA], + tryExecute: async () => false, + findOnPath: async () => undefined, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + })); + + const result = await resolver.resolve(targetA); + + assert.strictEqual(result.cliPath, bundlePath); + assert.strictEqual(result.source, 'default-install'); + assert.ok(setConfiguredPath.notCalled, 'a rejected tokenized candidate must never fall back to persisting a default install path'); + }); + + test('scopes rejection state to the folder that produced it', async () => { + const missingFolderACli = path.join(folderAPath, 'missing', 'aspire'); + const resolver = new CliPathResolver(createMockDeps({ + getConfiguredPath: target => target.kind === 'workspaceFolder' && target.workspaceFolder.name === 'a' + ? missingFolderACli + : folderBCli, + getWorkspaceFolders: () => [folderA, folderB], + findOnPath: async () => 'aspire', + tryExecute: async candidate => candidate === folderBCli, + })); + + await resolver.resolve(targetA); + await resolver.resolve(targetB); + + assert.strictEqual(resolver.isConfiguredPathRejectedForForwarding(targetA, missingFolderACli), true); + assert.strictEqual(resolver.isConfiguredPathRejectedForForwarding(targetB, missingFolderACli), false); + assert.strictEqual(resolver.isConfiguredPathRejectedForForwarding(targetB, folderBCli), false); + }); + + test('falls through to PATH/default without probing an unsupported token', async () => { + const attempted: string[] = []; + const findOnPath = sinon.stub().resolves('aspire'); + const resolver = new CliPathResolver(createMockDeps({ + getConfiguredPath: () => '${unsupportedToken}/aspire', + getWorkspaceFolders: () => [folderA], + tryExecute: async candidate => { + attempted.push(candidate); + return false; + }, + findOnPath, + })); + + const result = await resolver.resolve(targetA); + + assert.strictEqual(result.source, 'path'); + assert.strictEqual(result.cliPath, 'aspire'); + assert.deepStrictEqual(attempted, []); + assert.ok(findOnPath.calledOnce); + }); + + test('probes Windows configured candidates in exact, .exe, .cmd, .bat order', async () => { + const attempted: string[] = []; + const configured = 'C:\\repo\\a\\bin\\aspire'; + const resolver = new CliPathResolver(createMockDeps({ + getConfiguredPath: () => configured, + getExecutableCandidates: candidate => getCliExecutableCandidates(candidate, 'win32'), + tryExecute: async candidate => { + attempted.push(candidate); + return candidate === `${configured}.cmd`; + }, + })); + + const result = await resolver.resolve(windowCliPathTarget); + + assert.deepStrictEqual(attempted, [configured, `${configured}.exe`, `${configured}.cmd`]); + assert.strictEqual(result.cliPath, `${configured}.cmd`); + assert.strictEqual(result.source, 'configured'); + }); + + test('reads the scoped setting through vscode.workspace.getConfiguration', () => { + const workspaceConfiguration = { + get: sinon.stub().returns(folderACli), + } as unknown as vscode.WorkspaceConfiguration; + const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration').returns(workspaceConfiguration); + + try { + const result = getConfiguredCliPath(targetA); + + assert.ok(getConfigurationStub.calledOnceWith('aspire', folderA.uri)); + assert.strictEqual(result, folderACli); + } + finally { + getConfigurationStub.restore(); + } + }); + + test('ignores workspace CLI paths in Restricted Mode while preserving the global value', () => { + const trustDescriptor = Object.getOwnPropertyDescriptor(vscode.workspace, 'isTrusted'); + Object.defineProperty(vscode.workspace, 'isTrusted', { value: false, configurable: true }); + const globalCliPath = configuredCliPathFor(process.platform, 'users', 'me'); + const workspaceCliPath = path.join(path.dirname(folderAPath), 'tools', 'aspire'); + const workspaceFolderCli = path.join(folderAPath, 'tools', 'aspire'); + const workspaceConfiguration = { + get: sinon.stub().returns(workspaceFolderCli), + inspect: sinon.stub().returns({ + key: 'aspire.aspireCliExecutablePath', + globalValue: globalCliPath, + workspaceValue: workspaceCliPath, + workspaceFolderValue: workspaceFolderCli, + }), + } as unknown as vscode.WorkspaceConfiguration; + const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration').returns(workspaceConfiguration); + + try { + assert.strictEqual(getConfiguredCliPath(targetA), globalCliPath); + assert.strictEqual(getConfiguredCliPath(windowCliPathTarget), globalCliPath); + } + finally { + getConfigurationStub.restore(); + if (trustDescriptor) { + Object.defineProperty(vscode.workspace, 'isTrusted', trustDescriptor); + } + } + }); + + test('ignores a tokenized global CLI path in Restricted Mode', () => { + const trustDescriptor = Object.getOwnPropertyDescriptor(vscode.workspace, 'isTrusted'); + Object.defineProperty(vscode.workspace, 'isTrusted', { value: false, configurable: true }); + const workspaceConfiguration = { + get: sinon.stub().returns('${workspaceFolder}/tools/aspire'), + inspect: sinon.stub().returns({ + key: 'aspire.aspireCliExecutablePath', + globalValue: '${workspaceFolder}/tools/aspire', + }), + } as unknown as vscode.WorkspaceConfiguration; + const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration').returns(workspaceConfiguration); + + try { + assert.strictEqual(getConfiguredCliPath(targetA), ''); + assert.strictEqual(getConfiguredCliPath(windowCliPathTarget), ''); + } + finally { + getConfigurationStub.restore(); + if (trustDescriptor) { + Object.defineProperty(vscode.workspace, 'isTrusted', trustDescriptor); + } + } + }); + + test('treats a non-string configured CLI path as unset', () => { + const workspaceConfiguration = { + get: sinon.stub().returns({ path: '/repo/a/bin/aspire' }), + inspect: sinon.stub().returns(undefined), + } as unknown as vscode.WorkspaceConfiguration; + const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration').returns(workspaceConfiguration); + + try { + assert.strictEqual(getConfiguredCliPath(targetA), ''); + } + finally { + getConfigurationStub.restore(); + } + }); + + test('keeps plain absolute configured path behavior unchanged', async () => { + const configured = folderACli; + const resolver = new CliPathResolver(createMockDeps({ + getConfiguredPath: () => configured, + tryExecute: async candidate => candidate === configured, + })); + + const result = await resolver.resolve(targetA); + + assert.deepStrictEqual(result, { cliPath: configured, available: true, source: 'configured' }); + }); + + test('keeps a plain relative configured path invalid and falls through', async () => { + const attempted: string[] = []; + const findOnPath = sinon.stub().resolves('aspire'); + const resolver = new CliPathResolver(createMockDeps({ + getConfiguredPath: () => '../artifacts/aspire', + tryExecute: async candidate => { + attempted.push(candidate); + return false; + }, + findOnPath, + })); + + const result = await resolver.resolve(targetA); + + assert.strictEqual(result.source, 'path'); + assert.strictEqual(result.cliPath, 'aspire'); + assert.deepStrictEqual(attempted, []); + }); +}); diff --git a/extension/src/test/cliPathEnvironment.test.ts b/extension/src/test/cliPathEnvironment.test.ts index f7ddb389f7f..aefd78bd575 100644 --- a/extension/src/test/cliPathEnvironment.test.ts +++ b/extension/src/test/cliPathEnvironment.test.ts @@ -1,21 +1,31 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import * as path from 'path'; +import { createWorkspaceFolder } from './testHelpers'; import { ASPIRE_CLI_PATH_ENV_VAR, CliPathEnvironmentCollection, CliPathEnvironmentDependencies, + ResolvedCliPathDependencies, createAspireCliPathProcessEnvironment, + createResolvedAspireCliPathProcessEnvironment, + CliPathEnvironmentSynchronizer, + CliPathEnvironmentSynchronizerDependencies, getForwardableAspireCliPath, + getForwardableResolvedAspireCliPath, initializeCliPathEnvironmentSync, registerCliPathEnvironmentSync, syncAspireCliPathEnvironment, } from '../utils/cliPathEnvironment'; import { + CliPathDependencies, + CliPathResolver, isConfiguredCliPathRejectedForForwarding, resetRejectedConfiguredCliPathForForwarding, resolveCliPath, } from '../utils/cliPath'; +import { CliPathResolutionTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; function createFakeCollection(): CliPathEnvironmentCollection & { entries: Map } { const entries = new Map(); @@ -159,6 +169,62 @@ suite('cliPathEnvironment.getForwardableAspireCliPath tests', () => { }); }); +function makeResolvedDeps(overrides: Partial = {}): ResolvedCliPathDependencies { + return { + isAbsolute: (cliPath: string) => cliPath.startsWith('/') || /^[A-Za-z]:[\\/]/.test(cliPath), + fileExists: (cliPath: string) => cliPath.endsWith('/aspire') || cliPath.endsWith('\\aspire.exe') || cliPath.endsWith('/aspire.exe'), + realpath: (cliPath: string) => cliPath, + ...overrides, + }; +} + +suite('cliPathEnvironment.getForwardableResolvedAspireCliPath tests', () => { + test('returns the supplied concrete path when it is absolute and exists', () => { + assert.strictEqual( + getForwardableResolvedAspireCliPath('/repo/a/bin/aspire', makeResolvedDeps()), + '/repo/a/bin/aspire'); + }); + + test('returns undefined for a bare command name', () => { + assert.strictEqual(getForwardableResolvedAspireCliPath('aspire', makeResolvedDeps()), undefined); + }); + + test('returns undefined for a missing absolute path', () => { + assert.strictEqual( + getForwardableResolvedAspireCliPath('/repo/a/bin/missing-aspire', makeResolvedDeps({ fileExists: () => false })), + undefined); + }); + + test('returns undefined for undefined input', () => { + assert.strictEqual(getForwardableResolvedAspireCliPath(undefined, makeResolvedDeps()), undefined); + }); + + test('ignores configured-path rejection state for a concrete path the resolver already launched', () => { + // The resolver already selected and validated this exact executable, so raw-setting + // rejection state (which describes a different configured/target value) must not + // suppress forwarding it. + const cliPath = '/repo/a/bin/aspire'; + const deps: ResolvedCliPathDependencies & { isRejectedForForwarding: () => boolean } = { + ...makeResolvedDeps(), + isRejectedForForwarding: () => true, + }; + + assert.strictEqual(getForwardableResolvedAspireCliPath(cliPath, deps), cliPath); + }); + + test('returns undefined for a resolved unbundled framework-dependent CLI build', () => { + assert.strictEqual( + getForwardableResolvedAspireCliPath('/work/aspire/artifacts/bin/Aspire.Cli/Debug/net10.0/aspire', makeResolvedDeps({ + fileExists: (candidate) => { + const normalized = normalizeCandidate(candidate); + return normalized === '/work/aspire/artifacts/bin/Aspire.Cli/Debug/net10.0/aspire' + || normalized === '/work/aspire/artifacts/bin/Aspire.Cli/Debug/net10.0/aspire.dll'; + }, + })), + undefined); + }); +}); + suite('cliPathEnvironment.createAspireCliPathProcessEnvironment tests', () => { test('overlays AspireCliPath for direct extension-owned child processes', () => { const env = createAspireCliPathProcessEnvironment( @@ -183,6 +249,54 @@ suite('cliPathEnvironment.createAspireCliPathProcessEnvironment tests', () => { }); }); +suite('cliPathEnvironment.createResolvedAspireCliPathProcessEnvironment tests', () => { + teardown(() => sinon.restore()); + + test('sets AspireCliPath to the supplied valid concrete path', () => { + const env = createResolvedAspireCliPathProcessEnvironment( + '/repo/a/bin/aspire', + { PATH: '/usr/bin', AspireCliPath: '/old/aspire' }, + makeResolvedDeps()); + + assert.deepStrictEqual(env, { + PATH: '/usr/bin', + AspireCliPath: '/repo/a/bin/aspire', + }); + }); + + test('removes a stale AspireCliPath when the resolved command is relative', () => { + const env = createResolvedAspireCliPathProcessEnvironment( + 'aspire', + { PATH: '/usr/bin', AspireCliPath: '/old/aspire' }, + makeResolvedDeps()); + + assert.deepStrictEqual(env, { PATH: '/usr/bin' }); + }); + + test('removes a stale AspireCliPath when the resolved executable is missing', () => { + const env = createResolvedAspireCliPathProcessEnvironment( + '/repo/a/bin/missing-aspire', + { PATH: '/usr/bin', AspireCliPath: '/old/aspire' }, + makeResolvedDeps({ fileExists: () => false })); + + assert.deepStrictEqual(env, { PATH: '/usr/bin' }); + }); + + test('replaces Windows casing variants with the canonical AspireCliPath key', () => { + sinon.stub(process, 'platform').value('win32'); + + const env = createResolvedAspireCliPathProcessEnvironment( + 'C:\\repo\\a\\bin\\aspire.exe', + { PATH: 'C:\\Windows', ASPIRECLIPATH: 'C:\\old\\aspire.exe' }, + makeResolvedDeps()); + + assert.deepStrictEqual(env, { + PATH: 'C:\\Windows', + AspireCliPath: 'C:\\repo\\a\\bin\\aspire.exe', + }); + }); +}); + suite('cliPathEnvironment.syncAspireCliPathEnvironment tests', () => { test('sets AspireCliPath when the configured path is an absolute Unix path', () => { const collection = createFakeCollection(); @@ -325,7 +439,10 @@ suite('cliPathEnvironment.registerCliPathEnvironmentSync tests', () => { test('re-applies the contributed path when CLI resolution rejects and later accepts the setting', async () => { const collection = createFakeCollection(); - const configuredPath = '/abs/aspire'; + const configuredPath = path.join( + path.parse(process.cwd()).root, + 'abs', + process.platform === 'win32' ? 'aspire.exe' : 'aspire'); const onForwardedPathChanged = sinon.stub(); let configuredPathWorks = false; @@ -338,11 +455,13 @@ suite('cliPathEnvironment.registerCliPathEnvironmentSync tests', () => { const resolutionDeps = { getConfiguredPath: () => configuredPath, + getWorkspaceFolders: () => [], getDefaultPaths: () => [], isConfiguredPathAutoConfigured: () => false, findOnPath: async () => 'aspire', findAtDefaultPath: async () => undefined, tryExecute: async () => configuredPathWorks, + getExecutableCandidates: (candidate: string) => [candidate], setConfiguredPath: async () => { }, updateResolvedPathForForwarding: () => { }, }; @@ -415,3 +534,415 @@ suite('cliPathEnvironment.registerCliPathEnvironmentSync tests', () => { assert.strictEqual(initializationCompleted, true); }); }); + +suite('CliPathEnvironmentSynchronizer tests', () => { + // Workspace folder roots have to be fully qualified for the host platform. A driveless + // '/repo/a' becomes '\repo\a' on Windows, which is drive-relative rather than absolute, and + // isAbsoluteCliPath rejects an expanded ${workspaceFolder} candidate built from it -- so the + // resolver would fall through to PATH instead of probing the configured path these tests are about. + const folderAPath = path.resolve('/repo/a'); + const folderBPath = path.resolve('/repo/b'); + const folderA = createWorkspaceFolder('a', folderAPath, 0); + const folderB = createWorkspaceFolder('b', folderBPath, 1); + + // expandConfiguredCliPath normalizes the expanded token with the platform's path library. + const folderACliPath = path.join(folderAPath, 'aspire'); + const folderBCliPath = path.join(folderBPath, 'aspire'); + + test('applies independent AspireCliPath mutations and clears a removed folder', async () => { + const workspaceFoldersEmitter = new vscode.EventEmitter(); + const forwardingEmitter = new vscode.EventEmitter(); + const scopedCollections = new Map>(); + const globalCollection = createFakeGlobalCollection(scopedCollections); + const paths = new Map([ + [folderA.uri.toString(), '/repo/a/aspire'], + [folderB.uri.toString(), '/repo/b/aspire'], + ]); + const resolver = { + resolve: sinon.stub().callsFake(async (target: CliPathResolutionTarget) => ({ + cliPath: target.kind === 'workspaceFolder' ? paths.get(target.workspaceFolder.uri.toString())! : '/window/aspire', + available: true, + source: 'configured', + })), + onDidChangeForwarding: forwardingEmitter.event, + } as unknown as CliPathResolver; + const onForwardedPathChanged = sinon.stub(); + const subscriptions: vscode.Disposable[] = []; + const synchronizer = new CliPathEnvironmentSynchronizer( + globalCollection, + resolver, + subscriptions, + onForwardedPathChanged, + createSynchronizerDependencies([folderA, folderB], workspaceFoldersEmitter.event)); + + await synchronizer.initialize(); + + assert.strictEqual(scopedCollections.get(folderA.uri.toString())?.entries.get(ASPIRE_CLI_PATH_ENV_VAR), '/repo/a/aspire'); + assert.strictEqual(scopedCollections.get(folderB.uri.toString())?.entries.get(ASPIRE_CLI_PATH_ENV_VAR), '/repo/b/aspire'); + assert.strictEqual( + (globalCollection as unknown as ReturnType).entries.has(ASPIRE_CLI_PATH_ENV_VAR), + false, + 'an unscoped mutation would leak into every open workspace folder'); + + workspaceFoldersEmitter.fire({ added: [], removed: [folderA] }); + + assert.strictEqual(scopedCollections.get(folderA.uri.toString())?.entries.has(ASPIRE_CLI_PATH_ENV_VAR), false); + assert.strictEqual(scopedCollections.get(folderB.uri.toString())?.entries.get(ASPIRE_CLI_PATH_ENV_VAR), '/repo/b/aspire'); + assert.ok(onForwardedPathChanged.calledOnce); + assert.strictEqual(onForwardedPathChanged.firstCall.args[0].kind, 'workspaceFolder'); + assert.strictEqual(onForwardedPathChanged.firstCall.args[0].workspaceFolder.uri.toString(), folderA.uri.toString()); + assert.deepStrictEqual(onForwardedPathChanged.firstCall.args.slice(1), ['/repo/a/aspire', undefined]); + + synchronizer.dispose(); + subscriptions.forEach(disposable => disposable.dispose()); + forwardingEmitter.dispose(); + workspaceFoldersEmitter.dispose(); + }); + + test('updates only changed forwarded paths and re-resolves all targets after trust is granted', async () => { + const configurationEmitter = new vscode.EventEmitter(); + const workspaceFoldersEmitter = new vscode.EventEmitter(); + const trustEmitter = new vscode.EventEmitter(); + const forwardingEmitter = new vscode.EventEmitter(); + const scopedCollections = new Map>(); + const paths = new Map([ + [folderA.uri.toString(), '/repo/a/aspire'], + [folderB.uri.toString(), '/repo/b/aspire'], + ]); + const resolver = { + resolve: sinon.stub().callsFake(async (target: CliPathResolutionTarget) => ({ + cliPath: target.kind === 'workspaceFolder' ? paths.get(target.workspaceFolder.uri.toString())! : 'aspire', + available: true, + source: target.kind === 'workspaceFolder' ? 'configured' : 'path', + })), + onDidChangeForwarding: forwardingEmitter.event, + } as unknown as CliPathResolver; + const onForwardedPathChanged = sinon.stub(); + const synchronizer = new CliPathEnvironmentSynchronizer( + createFakeGlobalCollection(scopedCollections), + resolver, + [], + onForwardedPathChanged, + createSynchronizerDependencies( + [folderA, folderB], + workspaceFoldersEmitter.event, + configurationEmitter.event, + trustEmitter.event)); + await synchronizer.initialize(); + + paths.set(folderA.uri.toString(), '/repo/a/next-aspire'); + configurationEmitter.fire({ + affectsConfiguration: (section, scope) => section === 'aspire.aspireCliExecutablePath' + && (scope === undefined || scope.toString() === folderA.uri.toString()), + }); + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual(scopedCollections.get(folderA.uri.toString())?.entries.get(ASPIRE_CLI_PATH_ENV_VAR), '/repo/a/next-aspire'); + assert.strictEqual(scopedCollections.get(folderB.uri.toString())?.entries.get(ASPIRE_CLI_PATH_ENV_VAR), '/repo/b/aspire'); + assert.strictEqual(onForwardedPathChanged.callCount, 1); + assert.strictEqual(onForwardedPathChanged.firstCall.args[0].workspaceFolder.uri.toString(), folderA.uri.toString()); + + paths.set(folderB.uri.toString(), '/repo/b/trusted-aspire'); + trustEmitter.fire(); + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual(scopedCollections.get(folderB.uri.toString())?.entries.get(ASPIRE_CLI_PATH_ENV_VAR), '/repo/b/trusted-aspire'); + assert.strictEqual(onForwardedPathChanged.callCount, 2); + assert.strictEqual(onForwardedPathChanged.secondCall.args[0].workspaceFolder.uri.toString(), folderB.uri.toString()); + + synchronizer.dispose(); + configurationEmitter.dispose(); + forwardingEmitter.dispose(); + trustEmitter.dispose(); + workspaceFoldersEmitter.dispose(); + }); + + test('clears a persisted folder mutation when the folder is removed before resolution completes', async () => { + const workspaceFoldersEmitter = new vscode.EventEmitter(); + const forwardingEmitter = new vscode.EventEmitter(); + const scopedCollections = new Map>(); + const globalCollection = createFakeGlobalCollection(scopedCollections); + const scopedCollection = globalCollection.getScoped({ workspaceFolder: folderA }) as unknown as ReturnType; + scopedCollection.replace(ASPIRE_CLI_PATH_ENV_VAR, '/persisted/aspire'); + let completeFolderResolution: ((result: { cliPath: string; available: boolean; source: 'configured' }) => void) | undefined; + const folderResolution = new Promise<{ cliPath: string; available: boolean; source: 'configured' }>(resolve => { + completeFolderResolution = resolve; + }); + const resolver = { + resolve: sinon.stub().callsFake((target: CliPathResolutionTarget) => target.kind === 'workspaceFolder' + ? folderResolution + : Promise.resolve({ cliPath: 'aspire', available: true, source: 'path' })), + onDidChangeForwarding: forwardingEmitter.event, + } as unknown as CliPathResolver; + const synchronizer = new CliPathEnvironmentSynchronizer( + globalCollection, + resolver, + [], + undefined, + createSynchronizerDependencies([folderA], workspaceFoldersEmitter.event)); + + const initialization = synchronizer.initialize(); + workspaceFoldersEmitter.fire({ added: [], removed: [folderA] }); + + assert.strictEqual(scopedCollection.entries.has(ASPIRE_CLI_PATH_ENV_VAR), false); + + completeFolderResolution!({ cliPath: '/repo/a/aspire', available: true, source: 'configured' }); + await initialization; + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual(scopedCollection.entries.has(ASPIRE_CLI_PATH_ENV_VAR), false); + + synchronizer.dispose(); + forwardingEmitter.dispose(); + workspaceFoldersEmitter.dispose(); + }); + + test('clears a persisted global mutation before resolving open workspace folders', async () => { + const workspaceFoldersEmitter = new vscode.EventEmitter(); + const forwardingEmitter = new vscode.EventEmitter(); + const scopedCollections = new Map>(); + const globalCollection = createFakeGlobalCollection(scopedCollections); + const globalEntries = (globalCollection as unknown as ReturnType).entries; + const scopedCollection = globalCollection.getScoped({ workspaceFolder: folderA }) as unknown as ReturnType; + globalCollection.replace(ASPIRE_CLI_PATH_ENV_VAR, '/persisted/window-aspire'); + let completeResolution: ((result: { cliPath: string; available: boolean; source: 'path' }) => void) | undefined; + const resolution = new Promise<{ cliPath: string; available: boolean; source: 'path' }>(resolve => { + completeResolution = resolve; + }); + const resolver = { + resolve: sinon.stub().returns(resolution), + onDidChangeForwarding: forwardingEmitter.event, + } as unknown as CliPathResolver; + const synchronizer = new CliPathEnvironmentSynchronizer( + globalCollection, + resolver, + [], + undefined, + createSynchronizerDependencies([folderA], workspaceFoldersEmitter.event)); + + const initialization = synchronizer.initialize(); + + assert.strictEqual(globalEntries.has(ASPIRE_CLI_PATH_ENV_VAR), false); + + completeResolution!({ cliPath: '/resolved/aspire', available: true, source: 'path' }); + await initialization; + + assert.strictEqual(globalEntries.has(ASPIRE_CLI_PATH_ENV_VAR), false); + assert.strictEqual(scopedCollection.entries.get(ASPIRE_CLI_PATH_ENV_VAR), '/resolved/aspire'); + + synchronizer.dispose(); + forwardingEmitter.dispose(); + workspaceFoldersEmitter.dispose(); + }); + + test('applies the latest path once when the real resolver emits forwarding during resolution', async () => { + const workspaceFoldersEmitter = new vscode.EventEmitter(); + const scopedCollections = new Map>(); + const globalCollection = createFakeGlobalCollection(scopedCollections); + const globalCollectionState = globalCollection as unknown as ReturnType; + const replaceSpy = sinon.spy(globalCollectionState, 'replace'); + const resolverDependencies: CliPathDependencies = { + getConfiguredPath: () => 'relative-aspire', + getWorkspaceFolders: () => [], + getDefaultPaths: () => [], + isConfiguredPathAutoConfigured: () => false, + findOnPath: async () => '/resolved/aspire', + findAtDefaultPath: async () => undefined, + tryExecute: async () => false, + getExecutableCandidates: candidate => [candidate], + setConfiguredPath: async () => { }, + }; + const resolver = new CliPathResolver(resolverDependencies); + const synchronizer = new CliPathEnvironmentSynchronizer( + globalCollection, + resolver, + [], + undefined, + createSynchronizerDependencies([], workspaceFoldersEmitter.event)); + + await synchronizer.initialize(); + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual(globalCollectionState.entries.get(ASPIRE_CLI_PATH_ENV_VAR), '/resolved/aspire'); + assert.ok(replaceSpy.calledOnceWithExactly(ASPIRE_CLI_PATH_ENV_VAR, '/resolved/aspire')); + + synchronizer.dispose(); + resolver.dispose(); + workspaceFoldersEmitter.dispose(); + }); + + test('applies the latest folder path once when the real resolver emits forwarding during resolution', async () => { + const workspaceFoldersEmitter = new vscode.EventEmitter(); + const scopedCollections = new Map>(); + const globalCollection = createFakeGlobalCollection(scopedCollections); + const scopedCollection = globalCollection.getScoped({ workspaceFolder: folderA }) as unknown as ReturnType; + const replaceSpy = sinon.spy(scopedCollection, 'replace'); + const resolverDependencies: CliPathDependencies = { + getConfiguredPath: target => target.kind === 'workspaceFolder' ? '${workspaceFolder}/aspire' : '', + getWorkspaceFolders: () => [folderA], + getDefaultPaths: () => [], + isConfiguredPathAutoConfigured: () => false, + findOnPath: async () => '/fallback/aspire', + findAtDefaultPath: async () => undefined, + tryExecute: async candidate => candidate === folderACliPath, + getExecutableCandidates: candidate => [candidate], + setConfiguredPath: async () => { }, + }; + const resolver = new CliPathResolver(resolverDependencies); + const synchronizer = new CliPathEnvironmentSynchronizer( + globalCollection, + resolver, + [], + undefined, + createSynchronizerDependencies([folderA], workspaceFoldersEmitter.event)); + + await synchronizer.initialize(); + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual(scopedCollection.entries.get(ASPIRE_CLI_PATH_ENV_VAR), folderACliPath); + assert.ok(replaceSpy.calledOnceWithExactly(ASPIRE_CLI_PATH_ENV_VAR, folderACliPath)); + + synchronizer.dispose(); + resolver.dispose(); + workspaceFoldersEmitter.dispose(); + }); + + test('does not publish a removed folder CLI from an in-flight window resolution', async () => { + const workspaceFoldersEmitter = new vscode.EventEmitter(); + const scopedCollections = new Map>(); + const globalCollection = createFakeGlobalCollection(scopedCollections); + const globalCollectionState = globalCollection as unknown as ReturnType; + let workspaceFolders: readonly vscode.WorkspaceFolder[] = [folderA]; + let completeOldProbe: ((result: boolean) => void) | undefined; + let oldProbeStarted: (() => void) | undefined; + const oldProbeStart = new Promise(resolve => oldProbeStarted = resolve); + const oldProbe = new Promise(resolve => completeOldProbe = resolve); + const resolverDependencies: CliPathDependencies = { + getConfiguredPath: target => target.kind === 'window' ? '${workspaceFolder}/aspire' : '', + getWorkspaceFolders: () => workspaceFolders, + getDefaultPaths: () => [], + isConfiguredPathAutoConfigured: () => false, + findOnPath: async () => '/fallback/aspire', + findAtDefaultPath: async () => undefined, + tryExecute: candidate => { + if (candidate === folderACliPath) { + oldProbeStarted!(); + return oldProbe; + } + return Promise.resolve(false); + }, + getExecutableCandidates: candidate => [candidate], + setConfiguredPath: async () => { }, + }; + const resolver = new CliPathResolver(resolverDependencies); + const synchronizerDependencies = { + ...createSynchronizerDependencies([folderA], workspaceFoldersEmitter.event), + getWorkspaceFolders: () => workspaceFolders, + }; + const synchronizer = new CliPathEnvironmentSynchronizer( + globalCollection, + resolver, + [], + undefined, + synchronizerDependencies); + + const initialization = synchronizer.initialize(); + await oldProbeStart; + workspaceFolders = []; + workspaceFoldersEmitter.fire({ added: [], removed: [folderA] }); + completeOldProbe!(true); + await initialization; + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual(globalCollectionState.entries.get(ASPIRE_CLI_PATH_ENV_VAR), '/fallback/aspire'); + + synchronizer.dispose(); + resolver.dispose(); + workspaceFoldersEmitter.dispose(); + }); + + test('re-resolves surviving folders whose named workspace token changes', async () => { + const workspaceFoldersEmitter = new vscode.EventEmitter(); + const scopedCollections = new Map>(); + const globalCollection = createFakeGlobalCollection(scopedCollections); + let workspaceFolders: readonly vscode.WorkspaceFolder[] = [folderA, folderB]; + const resolverDependencies: CliPathDependencies = { + getConfiguredPath: target => target.kind === 'workspaceFolder' && target.workspaceFolder.uri.toString() === folderA.uri.toString() + ? '${workspaceFolder:b}/aspire' + : '', + getWorkspaceFolders: () => workspaceFolders, + getDefaultPaths: () => [], + isConfiguredPathAutoConfigured: () => false, + findOnPath: async () => 'aspire', + findAtDefaultPath: async () => undefined, + tryExecute: async candidate => candidate === folderBCliPath, + getExecutableCandidates: candidate => [candidate], + setConfiguredPath: async () => { }, + }; + const resolver = new CliPathResolver(resolverDependencies); + const synchronizerDependencies = { + ...createSynchronizerDependencies([folderA, folderB], workspaceFoldersEmitter.event), + getWorkspaceFolders: () => workspaceFolders, + }; + const synchronizer = new CliPathEnvironmentSynchronizer( + globalCollection, + resolver, + [], + undefined, + synchronizerDependencies); + + await synchronizer.initialize(); + assert.strictEqual( + scopedCollections.get(folderA.uri.toString())?.entries.get(ASPIRE_CLI_PATH_ENV_VAR), + folderBCliPath); + + workspaceFolders = [folderA]; + workspaceFoldersEmitter.fire({ added: [], removed: [folderB] }); + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual( + scopedCollections.get(folderA.uri.toString())?.entries.has(ASPIRE_CLI_PATH_ENV_VAR), + false); + + synchronizer.dispose(); + resolver.dispose(); + workspaceFoldersEmitter.dispose(); + }); +}); + +function createFakeGlobalCollection( + scopedCollections: Map>, +): vscode.GlobalEnvironmentVariableCollection { + const globalCollection = createFakeCollection() as unknown as CliPathEnvironmentCollection & { + getScoped(scope: vscode.EnvironmentVariableScope): CliPathEnvironmentCollection; + }; + globalCollection.getScoped = scope => { + const key = scope.workspaceFolder!.uri.toString(); + let collection = scopedCollections.get(key); + if (!collection) { + collection = createFakeCollection(); + scopedCollections.set(key, collection); + } + return collection; + }; + return globalCollection as unknown as vscode.GlobalEnvironmentVariableCollection; +} + +function createSynchronizerDependencies( + workspaceFolders: readonly vscode.WorkspaceFolder[], + onDidChangeWorkspaceFolders: vscode.Event, + onDidChangeConfiguration: vscode.Event = createNoopEvent(), + onDidGrantWorkspaceTrust: vscode.Event = createNoopEvent(), +): CliPathEnvironmentSynchronizerDependencies { + return { + getWorkspaceFolders: () => workspaceFolders, + getForwardablePath: (cliPath: string | undefined) => cliPath === 'aspire' ? undefined : cliPath, + onDidChangeConfiguration, + onDidChangeWorkspaceFolders, + onDidGrantWorkspaceTrust, + }; +} + +function createNoopEvent(): vscode.Event { + return () => ({ dispose: () => { } }); +} diff --git a/extension/src/test/cliPathRejectionNotifier.test.ts b/extension/src/test/cliPathRejectionNotifier.test.ts new file mode 100644 index 00000000000..157f27b77da --- /dev/null +++ b/extension/src/test/cliPathRejectionNotifier.test.ts @@ -0,0 +1,190 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { CliPathRejectionNotifier, CliPathRejectionNotificationSurface, CliPathRejectionState } from '../utils/cliPathRejectionNotifier'; +import { CliPathResolutionTarget, windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { configuredCliPathRejectedOpenSetting } from '../loc/strings'; + +suite('cliPathRejectionNotifier', () => { + + class FakeSurface implements CliPathRejectionNotificationSurface { + readonly warnings: string[] = []; + readonly openedSettings: string[] = []; + selection: string | undefined; + + showWarning(message: string, ..._actions: string[]): Thenable { + this.warnings.push(message); + return Promise.resolve(this.selection); + } + + openSetting(settingId: string): Thenable { + this.openedSettings.push(settingId); + return Promise.resolve(undefined); + } + } + + class FakeState implements CliPathRejectionState { + configuredPath = ''; + rejected = false; + listener: ((target: CliPathResolutionTarget) => void) | undefined; + + getConfiguredPath(): string { + return this.configuredPath; + } + + isRejected(configuredPath: string): boolean { + return this.rejected && configuredPath === this.configuredPath; + } + + onDidChangeRejection(listener: (target: CliPathResolutionTarget) => void): vscode.Disposable { + this.listener = listener; + return { dispose: () => { this.listener = undefined; } }; + } + } + + function createNotifier(): { notifier: CliPathRejectionNotifier; surface: FakeSurface; state: FakeState } { + const surface = new FakeSurface(); + const state = new FakeState(); + const notifier = new CliPathRejectionNotifier(surface, state); + return { notifier, surface, state }; + } + + test('warns with the rejected path when the configured CLI path is rejected', async () => { + const { notifier, surface, state } = createNotifier(); + state.configuredPath = '/repo/artifacts/bin/Aspire.Cli/Debug/net10.0'; + state.rejected = true; + + await notifier.notifyIfRejected(windowCliPathTarget); + + assert.strictEqual(surface.warnings.length, 1); + assert.ok(surface.warnings[0].includes('/repo/artifacts/bin/Aspire.Cli/Debug/net10.0')); + notifier.dispose(); + }); + + test('does not warn when the configured path resolved successfully', async () => { + const { notifier, surface, state } = createNotifier(); + state.configuredPath = '/repo/artifacts/bin/Aspire.Cli/Debug/net10.0/aspire'; + state.rejected = false; + + await notifier.notifyIfRejected(windowCliPathTarget); + + assert.deepStrictEqual(surface.warnings, []); + notifier.dispose(); + }); + + test('does not warn when no path is configured', async () => { + const { notifier, surface, state } = createNotifier(); + state.configuredPath = ''; + state.rejected = true; + + await notifier.notifyIfRejected(windowCliPathTarget); + + assert.deepStrictEqual(surface.warnings, []); + notifier.dispose(); + }); + + test('warns only once for the same rejected path', async () => { + const { notifier, surface, state } = createNotifier(); + state.configuredPath = '/bad/path'; + state.rejected = true; + + await notifier.notifyIfRejected(windowCliPathTarget); + await notifier.notifyIfRejected(windowCliPathTarget); + await notifier.notifyIfRejected(windowCliPathTarget); + + assert.strictEqual(surface.warnings.length, 1); + notifier.dispose(); + }); + + test('warns again when the user changes the setting to a different bad path', async () => { + const { notifier, surface, state } = createNotifier(); + state.configuredPath = '/bad/first'; + state.rejected = true; + await notifier.notifyIfRejected(windowCliPathTarget); + + state.configuredPath = '/bad/second'; + await notifier.notifyIfRejected(windowCliPathTarget); + + assert.strictEqual(surface.warnings.length, 2); + assert.ok(surface.warnings[0].includes('/bad/first')); + assert.ok(surface.warnings[1].includes('/bad/second')); + notifier.dispose(); + }); + + test('warns again for the same path after it recovers and is rejected once more', async () => { + const { notifier, surface, state } = createNotifier(); + state.configuredPath = '/bad/path'; + state.rejected = true; + await notifier.notifyIfRejected(windowCliPathTarget); + + state.rejected = false; + await notifier.notifyIfRejected(windowCliPathTarget); + + state.rejected = true; + await notifier.notifyIfRejected(windowCliPathTarget); + + assert.strictEqual(surface.warnings.length, 2); + notifier.dispose(); + }); + + test('tracks rejections per resolution scope', async () => { + const { notifier, surface, state } = createNotifier(); + const folderTarget = workspaceFolderCliPathTarget({ + uri: vscode.Uri.file('/repo/app'), + name: 'app', + index: 0, + }); + state.configuredPath = '/bad/path'; + state.rejected = true; + + await notifier.notifyIfRejected(windowCliPathTarget); + await notifier.notifyIfRejected(folderTarget); + + assert.strictEqual(surface.warnings.length, 2); + notifier.dispose(); + }); + + test('opens the setting when the user selects the action', async () => { + const { notifier, surface, state } = createNotifier(); + state.configuredPath = '/bad/path'; + state.rejected = true; + surface.selection = configuredCliPathRejectedOpenSetting; + + await notifier.notifyIfRejected(windowCliPathTarget); + + assert.deepStrictEqual(surface.openedSettings, ['aspire.aspireCliExecutablePath']); + notifier.dispose(); + }); + + test('does not open the setting when the warning is dismissed', async () => { + const { notifier, surface, state } = createNotifier(); + state.configuredPath = '/bad/path'; + state.rejected = true; + surface.selection = undefined; + + await notifier.notifyIfRejected(windowCliPathTarget); + + assert.deepStrictEqual(surface.openedSettings, []); + notifier.dispose(); + }); + + test('notifies when the resolver reports a rejection change', async () => { + const { notifier, surface, state } = createNotifier(); + state.configuredPath = '/bad/path'; + state.rejected = true; + + assert.ok(state.listener, 'notifier should subscribe to rejection changes'); + state.listener!(windowCliPathTarget); + await new Promise(resolve => setImmediate(resolve)); + + assert.strictEqual(surface.warnings.length, 1); + notifier.dispose(); + }); + + test('unsubscribes on dispose', () => { + const { notifier, state } = createNotifier(); + + notifier.dispose(); + + assert.strictEqual(state.listener, undefined); + }); +}); diff --git a/extension/src/test/cliPathVariables.test.ts b/extension/src/test/cliPathVariables.test.ts new file mode 100644 index 00000000000..b92ff30d2d3 --- /dev/null +++ b/extension/src/test/cliPathVariables.test.ts @@ -0,0 +1,255 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { createWorkspaceFolder } from './testHelpers'; +import { + ExpandedConfiguredCliPath, + expandConfiguredCliPath, + getCliExecutableCandidates, + getCliPathTargetForUri, + getCliPathTargetKey, + windowCliPathTarget, + workspaceFolderCliPathTarget, +} from '../utils/cliPathVariables'; + +function makeFolder(name: string, fsPath: string, index: number = 0): vscode.WorkspaceFolder { + return createWorkspaceFolder(name, fsPath, index); +} + +suite('cliPathVariables tests', () => { + + suite('CliPathResolutionTarget contract', () => { + + test('windowCliPathTarget has kind === "window"', () => { + assert.strictEqual(windowCliPathTarget.kind, 'window'); + }); + + test('workspaceFolderCliPathTarget has kind === "workspaceFolder" and correct workspaceFolder', () => { + const folder = makeFolder('myRepo', '/repo'); + const target = workspaceFolderCliPathTarget(folder); + + assert.strictEqual(target.kind, 'workspaceFolder'); + if (target.kind === 'workspaceFolder') { + assert.strictEqual(target.workspaceFolder, folder); + } + }); + + }); + + suite('createWorkspaceFolder', () => { + + test('reports fsPath exactly as written, whatever the host would render', () => { + // vscode.Uri.file rewrites this on every platform: POSIX hosts prefix a separator, Windows + // hosts flip them. The fixtures below describe POSIX paths and pass an explicit platform to + // the code under test, so the folder has to keep the path it was given or those cases stop + // exercising the platform argument at all. + const written = 'C:\\repo\\a'; + const folder = createWorkspaceFolder('a', written); + + assert.notStrictEqual(vscode.Uri.file(written).fsPath, written); + assert.strictEqual(folder.uri.fsPath, written); + assert.strictEqual(folder.name, 'a'); + assert.strictEqual(folder.index, 0); + }); + + test('leaves the rest of the Uri intact', () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + + assert.strictEqual(folder.uri.scheme, 'file'); + assert.strictEqual(folder.uri.path, '/repo/a'); + }); + + }); + + suite('expandConfiguredCliPath', () => { + + test('expands ${workspaceFolder} and normalizes path traversal on linux', () => { + const configuredPath = '${workspaceFolder}/../../artifacts/bin/Aspire.Cli/Debug/net10.0/aspire'; + const folder = makeFolder('JavaSpringBoot', '/repo/playground/JavaSpringBoot'); + const target = workspaceFolderCliPathTarget(folder); + + const result = expandConfiguredCliPath(configuredPath, target, [folder], 'linux'); + + assert.strictEqual(result.configuredPath, configuredPath); + assert.strictEqual(result.resolvedPath, '/repo/artifacts/bin/Aspire.Cli/Debug/net10.0/aspire'); + assert.strictEqual(result.error, undefined); + }); + + test('expands ${workspaceFolder:tools} to the uniquely-named folder, independent of operation target', () => { + const configuredPath = '${workspaceFolder:tools}/bin/aspire'; + const toolsFolder = makeFolder('tools', '/repo/tools', 1); + const playgroundFolder = makeFolder('playground', '/repo/playground', 0); + const target = workspaceFolderCliPathTarget(playgroundFolder); + + const result = expandConfiguredCliPath(configuredPath, target, [playgroundFolder, toolsFolder], 'linux'); + + assert.strictEqual(result.resolvedPath, '/repo/tools/bin/aspire'); + assert.strictEqual(result.error, undefined); + }); + + test('window target with two open folders returns undefined resolvedPath and error mentioning multiple workspace folders', () => { + const configuredPath = '${workspaceFolder}/bin/aspire'; + const folder1 = makeFolder('alpha', '/repo/alpha', 0); + const folder2 = makeFolder('beta', '/repo/beta', 1); + + const result = expandConfiguredCliPath(configuredPath, windowCliPathTarget, [folder1, folder2], 'linux'); + + assert.strictEqual(result.resolvedPath, undefined); + assert.ok(result.error, 'expected an error message'); + assert.ok( + result.error.toLowerCase().includes('multiple') || result.error.toLowerCase().includes('ambiguous'), + `error should mention multiple folders; got: ${result.error}` + ); + }); + + test('plain relative path is returned unchanged with no resolvedPath or error', () => { + const configuredPath = '../../artifacts/aspire'; + + const result = expandConfiguredCliPath(configuredPath, windowCliPathTarget, [], 'linux'); + + assert.strictEqual(result.configuredPath, configuredPath); + assert.strictEqual(result.resolvedPath, undefined); + assert.strictEqual(result.error, undefined); + }); + + test('window target in a single-folder workspace expands unqualified token', () => { + const folder = makeFolder('repo', '/repo', 0); + + const result = expandConfiguredCliPath('${workspaceFolder}/bin/aspire', windowCliPathTarget, [folder], 'linux'); + + assert.strictEqual(result.resolvedPath, '/repo/bin/aspire'); + assert.strictEqual(result.error, undefined); + }); + + test('unknown token is rejected with an error', () => { + const result = expandConfiguredCliPath('${customVar}/aspire', windowCliPathTarget, [], 'linux'); + + assert.strictEqual(result.resolvedPath, undefined); + assert.ok(result.error, 'expected an error message'); + }); + + test('missing named folder is rejected with an error', () => { + const folder = makeFolder('root', '/repo', 0); + + const result = expandConfiguredCliPath('${workspaceFolder:nonexistent}/aspire', windowCliPathTarget, [folder], 'linux'); + + assert.strictEqual(result.resolvedPath, undefined); + assert.ok(result.error, 'expected an error message'); + }); + + test('ambiguous named folder (two folders with same name) is rejected with an error', () => { + const folder1 = makeFolder('tools', '/repo/alpha/tools', 0); + const folder2 = makeFolder('tools', '/repo/beta/tools', 1); + + const result = expandConfiguredCliPath('${workspaceFolder:tools}/aspire', windowCliPathTarget, [folder1, folder2], 'linux'); + + assert.strictEqual(result.resolvedPath, undefined); + assert.ok(result.error, 'expected an error message'); + assert.ok( + result.error.toLowerCase().includes('ambiguous') || result.error.toLowerCase().includes('multiple'), + `error should mention ambiguity; got: ${result.error}` + ); + }); + + }); + + suite('getCliExecutableCandidates', () => { + + test('Windows extensionless path returns exact, .exe, .cmd, .bat in order', () => { + const candidates = getCliExecutableCandidates('C:\\tools\\aspire', 'win32'); + + assert.deepStrictEqual(candidates, [ + 'C:\\tools\\aspire', + 'C:\\tools\\aspire.exe', + 'C:\\tools\\aspire.cmd', + 'C:\\tools\\aspire.bat', + 'C:\\tools\\aspire\\aspire', + 'C:\\tools\\aspire\\aspire.exe', + 'C:\\tools\\aspire\\aspire.cmd', + 'C:\\tools\\aspire\\aspire.bat', + ]); + }); + + test('Windows path already ending in .exe returns only itself', () => { + const candidates = getCliExecutableCandidates('C:\\tools\\aspire.exe', 'win32'); + + assert.deepStrictEqual(candidates, ['C:\\tools\\aspire.exe']); + }); + + test('non-Windows path also probes the CLI inside it so a build output directory resolves', () => { + const candidates = getCliExecutableCandidates('/usr/local/bin/aspire', 'linux'); + + assert.deepStrictEqual(candidates, [ + '/usr/local/bin/aspire', + '/usr/local/bin/aspire/aspire', + ]); + }); + + test('directory path resolves the CLI executable inside it', () => { + const candidates = getCliExecutableCandidates('/repo/artifacts/bin/Aspire.Cli/Debug/net10.0', 'darwin'); + + assert.deepStrictEqual(candidates, [ + '/repo/artifacts/bin/Aspire.Cli/Debug/net10.0', + '/repo/artifacts/bin/Aspire.Cli/Debug/net10.0/aspire', + ]); + }); + + test('windows directory whose last segment contains a dot still probes the executable inside it', () => { + // extname('net10.0') is '.0', so treating a dotted final segment as a file name meant a + // locally built CLI at ...\\Debug\\net10.0 was never found on Windows. + const candidates = getCliExecutableCandidates('C:\\repo\\artifacts\\bin\\Aspire.Cli\\Debug\\net10.0', 'win32'); + + assert.ok(candidates.includes('C:\\repo\\artifacts\\bin\\Aspire.Cli\\Debug\\net10.0\\aspire.exe')); + }); + + }); + + suite('getCliPathTargetKey', () => { + + test('window target key is "window"', () => { + assert.strictEqual(getCliPathTargetKey(windowCliPathTarget), 'window'); + }); + + test('workspaceFolder target key is "workspaceFolder:${folder.uri.toString()}"', () => { + const folder = makeFolder('myFolder', '/repo'); + const target = workspaceFolderCliPathTarget(folder); + + assert.strictEqual(getCliPathTargetKey(target), `workspaceFolder:${folder.uri.toString()}`); + }); + + }); + + suite('getCliPathTargetForUri', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('returns the owning workspace folder target when a folder owns the uri', () => { + const folder = makeFolder('java', '/repo/playground/JavaSpringBoot'); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').withArgs(sinon.match.any).returns(folder); + + const target = getCliPathTargetForUri(vscode.Uri.file('/repo/playground/JavaSpringBoot/AppHost.csproj')); + + assert.strictEqual(target.kind, 'workspaceFolder'); + if (target.kind === 'workspaceFolder') { + assert.strictEqual(target.workspaceFolder, folder); + } + }); + + test('returns the window target when no open folder owns the uri', () => { + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(undefined); + + const target = getCliPathTargetForUri(vscode.Uri.file('/outside/AppHost.csproj')); + + assert.deepStrictEqual(target, windowCliPathTarget); + }); + + }); + +}); diff --git a/extension/src/test/cliSpawn.test.ts b/extension/src/test/cliSpawn.test.ts index 818ec10430a..f6394aeb1fd 100644 --- a/extension/src/test/cliSpawn.test.ts +++ b/extension/src/test/cliSpawn.test.ts @@ -13,7 +13,56 @@ import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { getCmdShimSpawnCommandWithoutVerbatimArguments } from '../utils/cmdShim'; import { EnvironmentVariables } from '../utils/environment'; +import { removeDirectorySafely } from './testHelpers'; suite('spawnCliProcess tests', () => { + test('builds the child environment from the exact CLI command being launched', () => { + const childProcess = createTestChildProcess(4801); + const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns(childProcess); + const createEnvironmentStub = sinon.stub().returns({}); + const terminalProvider = { createEnvironment: createEnvironmentStub } as unknown as AspireTerminalProvider; + + try { + spawnCliProcess(terminalProvider, '/repo/a/bin/aspire', ['config', 'info']); + + assert.ok(createEnvironmentStub.calledOnceWith(undefined, undefined, undefined, '/repo/a/bin/aspire')); + } + finally { + spawnStub.restore(); + } + }); + + test('passes the original cmd shim path to createEnvironment on Windows, not cmd.exe', () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + const originalComSpec = process.env.ComSpec; + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + const childProcess = createTestChildProcess(4802); + const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns(childProcess); + const createEnvironmentStub = sinon.stub().returns({}); + const terminalProvider = { createEnvironment: createEnvironmentStub } as unknown as AspireTerminalProvider; + + try { + spawnCliProcess(terminalProvider, 'C:\\repo\\a\\aspire.cmd', ['config', 'info']); + + // createEnvironment must receive the original shim path so the terminal provider + // can compute correct environment variables relative to the CLI install location. + assert.ok(createEnvironmentStub.calledOnceWith(undefined, undefined, undefined, 'C:\\repo\\a\\aspire.cmd')); + // The actual spawn must go through cmd.exe, not the shim directly. + assert.strictEqual(spawnStub.firstCall.args[0], 'C:\\Windows\\System32\\cmd.exe'); + assert.notStrictEqual(spawnStub.firstCall.args[0], 'C:\\repo\\a\\aspire.cmd'); + } + finally { + spawnStub.restore(); + platformStub.restore(); + + if (originalComSpec === undefined) { + delete process.env.ComSpec; + } + else { + process.env.ComSpec = originalComSpec; + } + } + }); + test('creates POSIX process groups only for lifecycle-managed CLI processes', () => { const platformStub = sinon.stub(process, 'platform').value('linux'); const children = [createTestChildProcess(4101), createTestChildProcess(4102)]; @@ -38,14 +87,14 @@ suite('spawnCliProcess tests', () => { test('force kills a POSIX process group after the grace period while its leader is alive', async () => { const platformStub = sinon.stub(process, 'platform').value('linux'); const processKillStub = sinon.stub(process, 'kill').returns(true); - const clock = sinon.useFakeTimers(); + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); const childProcess = createTestChildProcess(4242); const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns(childProcess); const terminalProvider = { createEnvironment: () => ({}) } as AspireTerminalProvider; try { const child = spawnCliProcess(terminalProvider, '/usr/local/bin/aspire', ['ls'], { createProcessGroup: true }); - terminateCliProcess(child, 'test Aspire CLI'); + const termination = terminateCliProcess(child, 'test Aspire CLI'); await clock.tickAsync(5000); assert.deepStrictEqual(processKillStub.args, [ @@ -53,6 +102,16 @@ suite('spawnCliProcess tests', () => { [-4242, 'SIGKILL'], ]); assert.strictEqual(childProcess.kill.called, false); + + processKillStub.callsFake((_pid, signal) => { + if (signal === 0) { + throw Object.assign(new Error('No such process'), { code: 'ESRCH' }); + } + + return true; + }); + await clock.tickAsync(50); + await termination; } finally { spawnStub.restore(); @@ -64,24 +123,74 @@ suite('spawnCliProcess tests', () => { test('force kills surviving POSIX descendants immediately when their leader exits', async () => { const platformStub = sinon.stub(process, 'platform').value('linux'); - const processKillStub = sinon.stub(process, 'kill').returns(true); - const clock = sinon.useFakeTimers(); + let processGroupAlive = true; + const noSuchProcess = Object.assign(new Error('No such process'), { code: 'ESRCH' }); + const processKillStub = sinon.stub(process, 'kill').callsFake((_pid, signal) => { + if (signal === 0 && !processGroupAlive) { + throw noSuchProcess; + } + + return true; + }); + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); const childProcess = createTestChildProcess(4343); const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns(childProcess); const terminalProvider = { createEnvironment: () => ({}) } as AspireTerminalProvider; try { const child = spawnCliProcess(terminalProvider, '/usr/local/bin/aspire', ['ls'], { createProcessGroup: true }); - terminateCliProcess(child, 'test Aspire CLI'); + const closeListenerCount = childProcess.listenerCount('close'); + const exitListenerCount = childProcess.listenerCount('exit'); + let settled = false; + const termination = terminateCliProcess(child, 'test Aspire CLI').then(() => { settled = true; }); childProcess.emit('close', null); + await clock.tickAsync(0); assert.deepStrictEqual(processKillStub.args, [ [-4343, 'SIGTERM'], [-4343, 0], [-4343, 'SIGKILL'], + [-4343, 0], ]); + assert.strictEqual(settled, false, 'Expected termination to wait until the surviving process group exits.'); + + processGroupAlive = false; + await clock.tickAsync(50); + await termination; + assert.strictEqual(settled, true); + assert.strictEqual(childProcess.listenerCount('close'), closeListenerCount); + assert.strictEqual(childProcess.listenerCount('exit'), exitListenerCount); + assert.strictEqual(clock.countTimers(), 0); + } + finally { + spawnStub.restore(); + clock.restore(); + processKillStub.restore(); + platformStub.restore(); + } + }); + + test('rejects when a POSIX process group remains alive after forced termination', async () => { + const platformStub = sinon.stub(process, 'platform').value('linux'); + const processKillStub = sinon.stub(process, 'kill').returns(true); + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + const childProcess = createTestChildProcess(4595); + const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns(childProcess); + const terminalProvider = { createEnvironment: () => ({}) } as AspireTerminalProvider; + + try { + const child = spawnCliProcess(terminalProvider, '/usr/local/bin/aspire', ['run'], { createProcessGroup: true }); + const closeListenerCount = childProcess.listenerCount('close'); + const exitListenerCount = childProcess.listenerCount('exit'); + let rejection: unknown; + void terminateCliProcess(child, 'test Aspire CLI', { force: true }).catch(error => { rejection = error; }); + await clock.tickAsync(5000); - assert.strictEqual(processKillStub.callCount, 3); + + assert.match(String(rejection), /Could not confirm test Aspire CLI process group termination within 5000ms/); + assert.strictEqual(childProcess.listenerCount('close'), closeListenerCount); + assert.strictEqual(childProcess.listenerCount('exit'), exitListenerCount); + assert.strictEqual(clock.countTimers(), 0); } finally { spawnStub.restore(); @@ -97,7 +206,7 @@ suite('spawnCliProcess tests', () => { const processKillStub = sinon.stub(process, 'kill'); processKillStub.onFirstCall().returns(true); processKillStub.onSecondCall().throws(noSuchProcess); - const clock = sinon.useFakeTimers(); + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); const childProcess = createTestChildProcess(4444); const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns(childProcess); const terminalProvider = { createEnvironment: () => ({}) } as AspireTerminalProvider; @@ -121,24 +230,42 @@ suite('spawnCliProcess tests', () => { } }); - test('force kills surviving POSIX descendants when termination starts after leader exit', () => { + test('force kills surviving POSIX descendants when termination starts after leader exit', async () => { const platformStub = sinon.stub(process, 'platform').value('linux'); - const processKillStub = sinon.stub(process, 'kill').returns(true); + let processGroupAlive = true; + const noSuchProcess = Object.assign(new Error('No such process'), { code: 'ESRCH' }); + const processKillStub = sinon.stub(process, 'kill').callsFake((_pid, signal) => { + if (signal === 0 && !processGroupAlive) { + throw noSuchProcess; + } + + return true; + }); + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); const childProcess = createTestChildProcess(4545, 0); const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns(childProcess); const terminalProvider = { createEnvironment: () => ({}) } as AspireTerminalProvider; try { const child = spawnCliProcess(terminalProvider, '/usr/local/bin/aspire', ['ls'], { createProcessGroup: true }); - terminateCliProcess(child, 'test Aspire CLI'); + let settled = false; + const termination = terminateCliProcess(child, 'test Aspire CLI').then(() => { settled = true; }); assert.deepStrictEqual(processKillStub.args, [ [-4545, 0], [-4545, 'SIGKILL'], + [-4545, 0], ]); + assert.strictEqual(settled, false, 'Expected termination to wait until the surviving process group exits.'); + + processGroupAlive = false; + await clock.tickAsync(50); + await termination; + assert.strictEqual(settled, true); } finally { spawnStub.restore(); + clock.restore(); processKillStub.restore(); platformStub.restore(); } @@ -205,28 +332,25 @@ suite('spawnCliProcess tests', () => { } }); - test('does not rewrite percent sequences that cmd command lines cannot escape', () => { + test('rejects percent sequences that cmd command lines would expand', () => { const platformStub = sinon.stub(process, 'platform').value('win32'); try { - const result = getCliSpawnCommand( - 'C:\\Tools\\Aspire CLI\\aspire.cmd', - ['resource', 'api', 'echo', '--', '--path=%PATH%'], - ); - - assert.strictEqual( - result.args[4], - '""C:\\Tools\\Aspire CLI\\aspire.cmd" "resource" "api" "echo" "--" "--path=%PATH%""'); + assert.throws( + () => getCliSpawnCommand( + 'C:\\Tools\\Aspire CLI\\aspire.cmd', + ['resource', 'api', 'echo', '--', '--path=%PATH%']), + /Arguments containing %NAME% cannot be forwarded through a Windows \.cmd or \.bat shim/); } finally { platformStub.restore(); } }); - test('does not rewrite percent sequences in non-verbatim cmd wrappers', () => { + test('allows percent expansion in a command shim path but not its forwarded arguments', () => { const result = getCmdShimSpawnCommandWithoutVerbatimArguments( 'C:\\tools\\%ASPIRE_HOME%\\aspire.cmd', - ['--path=%PATH%'], + ['agent', 'mcp'], ); assert.deepStrictEqual(result.args, [ @@ -234,7 +358,8 @@ suite('spawnCliProcess tests', () => { '/v:off', '/c', 'C:\\tools\\%ASPIRE_HOME%\\aspire.cmd', - '--path^=%PATH%', + 'agent', + 'mcp', ]); }); @@ -285,7 +410,7 @@ suite('spawnCliProcess tests', () => { assert.strictEqual(result.stdout.trim(), 'mcp-started'); } finally { - fs.rmSync(tempDirectory, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); + removeDirectorySafely(tempDirectory); } }); @@ -474,23 +599,201 @@ suite('spawnCliProcess tests', () => { platformStub.restore(); } }); - test('terminates the process tree with taskkill on Windows rather than signalling the child', () => { + + test('rejects percent expansion in Windows command shim arguments', () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + + try { + assert.throws( + () => getCliSpawnCommand('C:\\Tools\\aspire.cmd', ['run', '--', '%ASPIRE_SECRET%']), + /Arguments containing %NAME% cannot be forwarded through a Windows \.cmd or \.bat shim/); + } + finally { + platformStub.restore(); + } + }); + + test('allows a single unmatched percent in Windows command shim arguments', () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + + try { + assert.doesNotThrow( + () => getCliSpawnCommand('C:\\Tools\\aspire.cmd', ['run', '--', '100%'])); + } + finally { + platformStub.restore(); + } + }); + + test('rejects percent expansion sequences that span Windows command shim arguments', () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + + try { + assert.throws( + () => getCliSpawnCommand('C:\\Tools\\aspire.cmd', ['run', '--', 'before%', '%after']), + /Arguments containing %NAME% cannot be forwarded through a Windows \.cmd or \.bat shim/); + } + finally { + platformStub.restore(); + } + }); + + test('waits for taskkill and child close when terminating a Windows process tree', async () => { // Regression coverage for the Windows CI break: `terminateCliProcess` deliberately never // calls `child.kill` on Windows, because killing the leader there orphans its descendants. // A test that asserts on `child.kill` therefore passes on POSIX and fails on Windows. const platformStub = sinon.stub(process, 'platform').value('win32'); const spawned: Array<{ command: string; args: readonly string[] }> = []; + const taskkillUnref = sinon.stub(); + const taskkillProcess = Object.assign(new EventEmitter(), { unref: taskkillUnref }); const spawnStub = sinon.stub(nodeChildProcess, 'spawn').callsFake(((command: string, args: readonly string[]) => { spawned.push({ command, args }); - return Object.assign(new EventEmitter(), { unref: () => { } }) as unknown as nodeChildProcess.ChildProcessWithoutNullStreams; + return taskkillProcess as unknown as nodeChildProcess.ChildProcessWithoutNullStreams; }) as unknown as typeof nodeChildProcess.spawn); const child = createTestChildProcess(4747); try { - terminateCliProcess(child, 'test Aspire CLI'); + let settled = false; + const termination = terminateCliProcess(child, 'test Aspire CLI').then(() => { settled = true; }); assert.deepStrictEqual(spawned, [{ command: 'taskkill.exe', args: ['/pid', '4747', '/t'] }]); assert.strictEqual(child.kill.callCount, 0); + sinon.assert.notCalled(taskkillUnref); + + child.emit('close', null); + await Promise.resolve(); + assert.strictEqual(settled, false, 'Expected termination to await taskkill completion after the child closes.'); + + taskkillProcess.emit('close', 0); + await termination; + assert.strictEqual(settled, true); + assert.strictEqual(child.listenerCount('close'), 0); + assert.strictEqual(child.listenerCount('exit'), 0); + assert.strictEqual(taskkillProcess.listenerCount('close'), 0); + assert.strictEqual(taskkillProcess.listenerCount('error'), 0); + } + finally { + spawnStub.restore(); + platformStub.restore(); + } + }); + + test('rejects when taskkill does not complete within the bounded deadline', async () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + const taskkillKill = sinon.stub().returns(true); + const taskkillProcess = Object.assign(new EventEmitter(), { kill: taskkillKill }); + const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns( + taskkillProcess as unknown as nodeChildProcess.ChildProcessWithoutNullStreams); + const child = createTestChildProcess(4848); + + try { + let rejection: unknown; + void terminateCliProcess(child, 'test Aspire CLI', { force: true }).catch(error => { rejection = error; }); + + await clock.tickAsync(5000); + + assert.match(String(rejection), /test Aspire CLI did not report exit within 5000ms after taskkill failed and the leader was killed/); + assert.deepStrictEqual(spawnStub.firstCall.args[1], ['/pid', '4848', '/t', '/f']); + sinon.assert.calledOnceWithExactly(taskkillKill, 'SIGKILL'); + sinon.assert.calledOnceWithExactly(child.kill, 'SIGKILL'); + assert.strictEqual(child.listenerCount('close'), 0); + assert.strictEqual(child.listenerCount('exit'), 0); + assert.strictEqual(taskkillProcess.listenerCount('close'), 0); + assert.strictEqual(taskkillProcess.listenerCount('error'), 0); + assert.strictEqual(clock.countTimers(), 0); + } + finally { + spawnStub.restore(); + clock.restore(); + platformStub.restore(); + } + }); + + test('best-effort kills the leader but reports failure when taskkill cannot start', async () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + const taskkillProcess = Object.assign(new EventEmitter(), { kill: sinon.stub().returns(true) }); + const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns( + taskkillProcess as unknown as nodeChildProcess.ChildProcessWithoutNullStreams); + const child = createTestChildProcess(4898); + + try { + const termination = terminateCliProcess(child, 'test Aspire CLI', { force: true }); + taskkillProcess.emit('error', new Error('spawn failed')); + await Promise.resolve(); + sinon.assert.calledOnceWithExactly(child.kill, 'SIGKILL'); + (child as unknown as { exitCode: number | null }).exitCode = 1; + child.emit('close', 1); + + await assert.rejects( + termination, + /Failed to run taskkill for test Aspire CLI \(PID 4898\): spawn failed/); + assert.strictEqual(child.listenerCount('close'), 0); + assert.strictEqual(child.listenerCount('exit'), 0); + assert.strictEqual(taskkillProcess.listenerCount('close'), 0); + assert.strictEqual(taskkillProcess.listenerCount('error'), 0); + assert.strictEqual(clock.countTimers(), 0); + } + finally { + spawnStub.restore(); + clock.restore(); + platformStub.restore(); + } + }); + + test('escalates a nonzero graceful taskkill result to forced tree termination', async () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + const gracefulTaskkill = Object.assign(new EventEmitter(), { kill: sinon.stub().returns(true) }); + const forcedTaskkill = Object.assign(new EventEmitter(), { kill: sinon.stub().returns(true) }); + const spawnStub = sinon.stub(nodeChildProcess, 'spawn'); + spawnStub.onFirstCall().returns(gracefulTaskkill as unknown as nodeChildProcess.ChildProcessWithoutNullStreams); + spawnStub.onSecondCall().returns(forcedTaskkill as unknown as nodeChildProcess.ChildProcessWithoutNullStreams); + const child = createTestChildProcess(4949); + + try { + const termination = terminateCliProcess(child, 'test Aspire CLI'); + gracefulTaskkill.emit('close', 5); + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(spawnStub.secondCall.args[1], ['/pid', '4949', '/t', '/f']); + + (child as unknown as { exitCode: number | null }).exitCode = 1; + child.emit('close', 1); + forcedTaskkill.emit('close', 0); + await termination; + sinon.assert.notCalled(child.kill); + assert.strictEqual(child.listenerCount('close'), 0); + assert.strictEqual(child.listenerCount('exit'), 0); + assert.strictEqual(gracefulTaskkill.listenerCount('close'), 0); + assert.strictEqual(gracefulTaskkill.listenerCount('error'), 0); + assert.strictEqual(forcedTaskkill.listenerCount('close'), 0); + assert.strictEqual(forcedTaskkill.listenerCount('error'), 0); + } + finally { + spawnStub.restore(); + platformStub.restore(); + } + }); + + test('does not re-target a PID after taskkill reports that it no longer exists', async () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + const taskkillProcess = Object.assign(new EventEmitter(), { kill: sinon.stub().returns(true) }); + const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns( + taskkillProcess as unknown as nodeChildProcess.ChildProcessWithoutNullStreams); + const child = createTestChildProcess(4999); + + try { + const termination = terminateCliProcess(child, 'test Aspire CLI', { force: true }); + taskkillProcess.emit('close', 128); + await new Promise(resolve => setImmediate(resolve)); + + sinon.assert.calledOnce(spawnStub); + assert.deepStrictEqual(spawnStub.firstCall.args[1], ['/pid', '4999', '/t', '/f']); + sinon.assert.notCalled(child.kill); + + (child as unknown as { exitCode: number | null }).exitCode = 0; + child.emit('close', 0); + await termination; } finally { spawnStub.restore(); @@ -498,24 +801,131 @@ suite('spawnCliProcess tests', () => { } }); + test('does not re-target a PID when forced taskkill escalation reports that it no longer exists', async () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + const gracefulTaskkill = Object.assign(new EventEmitter(), { kill: sinon.stub().returns(true) }); + const forcedTaskkill = Object.assign(new EventEmitter(), { kill: sinon.stub().returns(true) }); + const spawnStub = sinon.stub(nodeChildProcess, 'spawn'); + spawnStub.onFirstCall().returns(gracefulTaskkill as unknown as nodeChildProcess.ChildProcessWithoutNullStreams); + spawnStub.onSecondCall().returns(forcedTaskkill as unknown as nodeChildProcess.ChildProcessWithoutNullStreams); + const child = createTestChildProcess(5049); + + try { + const termination = terminateCliProcess(child, 'test Aspire CLI'); + gracefulTaskkill.emit('close', 5); + await new Promise(resolve => setImmediate(resolve)); + forcedTaskkill.emit('close', 128); + await new Promise(resolve => setImmediate(resolve)); + + sinon.assert.calledTwice(spawnStub); + assert.deepStrictEqual(spawnStub.secondCall.args[1], ['/pid', '5049', '/t', '/f']); + sinon.assert.notCalled(child.kill); + + (child as unknown as { exitCode: number | null }).exitCode = 0; + child.emit('close', 0); + await termination; + } + finally { + spawnStub.restore(); + platformStub.restore(); + } + }); + + test('accepts a nonzero taskkill result when the target process already exited', async () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + const taskkillProcess = Object.assign(new EventEmitter(), { kill: sinon.stub().returns(true) }); + const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns( + taskkillProcess as unknown as nodeChildProcess.ChildProcessWithoutNullStreams); + const child = createTestChildProcess(5050); + + try { + const termination = terminateCliProcess(child, 'test Aspire CLI', { force: true }); + (child as unknown as { exitCode: number | null }).exitCode = 0; + child.emit('close', 0); + taskkillProcess.emit('close', 128); + + await termination; + sinon.assert.notCalled(child.kill); + assert.strictEqual(child.listenerCount('close'), 0); + assert.strictEqual(child.listenerCount('exit'), 0); + assert.strictEqual(taskkillProcess.listenerCount('close'), 0); + assert.strictEqual(taskkillProcess.listenerCount('error'), 0); + } + finally { + spawnStub.restore(); + platformStub.restore(); + } + }); + + test('rejects when a Windows child does not close after successful taskkill', async () => { + const platformStub = sinon.stub(process, 'platform').value('win32'); + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + const taskkillProcess = Object.assign(new EventEmitter(), { kill: sinon.stub().returns(true) }); + const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns( + taskkillProcess as unknown as nodeChildProcess.ChildProcessWithoutNullStreams); + const child = createTestChildProcess(5151); + + try { + let rejection: unknown; + void terminateCliProcess(child, 'test Aspire CLI', { force: true }).catch(error => { rejection = error; }); + taskkillProcess.emit('close', 0); + + await clock.tickAsync(5000); + + assert.match(String(rejection), /test Aspire CLI did not report exit within 5000ms after taskkill succeeded/); + assert.deepStrictEqual(spawnStub.firstCall.args[1], ['/pid', '5151', '/t', '/f']); + sinon.assert.notCalled(child.kill); + assert.strictEqual(child.listenerCount('close'), 0); + assert.strictEqual(child.listenerCount('exit'), 0); + assert.strictEqual(taskkillProcess.listenerCount('close'), 0); + assert.strictEqual(taskkillProcess.listenerCount('error'), 0); + assert.strictEqual(clock.countTimers(), 0); + } + finally { + spawnStub.restore(); + clock.restore(); + platformStub.restore(); + } + }); + test('force terminates a POSIX process group immediately without waiting for the grace period', async () => { const platformStub = sinon.stub(process, 'platform').value('linux'); - const processKillStub = sinon.stub(process, 'kill').returns(true); - const clock = sinon.useFakeTimers(); + let processGroupAlive = true; + const noSuchProcess = Object.assign(new Error('No such process'), { code: 'ESRCH' }); + const processKillStub = sinon.stub(process, 'kill').callsFake((_pid, signal) => { + if (signal === 0 && !processGroupAlive) { + throw noSuchProcess; + } + + return true; + }); + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); const childProcess = createTestChildProcess(4646); const spawnStub = sinon.stub(nodeChildProcess, 'spawn').returns(childProcess); const terminalProvider = { createEnvironment: () => ({}) } as AspireTerminalProvider; try { const child = spawnCliProcess(terminalProvider, '/usr/local/bin/aspire', ['run'], { createProcessGroup: true }); - terminateCliProcess(child, 'test Aspire CLI', { force: true }); + let settled = false; + const termination = terminateCliProcess(child, 'test Aspire CLI', { force: true }).then(() => { settled = true; }); // No SIGTERM and no escalation timer: a caller that is itself shutting down cannot rely // on an `unref`'d timer still being there five seconds later. - assert.deepStrictEqual(processKillStub.args, [[-4646, 'SIGKILL']]); + assert.deepStrictEqual(processKillStub.args, [ + [-4646, 'SIGKILL'], + ]); + await clock.tickAsync(50); + assert.strictEqual(settled, false, 'Expected forced termination to confirm that the process group exited.'); - await clock.tickAsync(5000); - assert.strictEqual(processKillStub.callCount, 1); + processGroupAlive = false; + await clock.tickAsync(50); + await termination; + assert.strictEqual(settled, true); + assert.deepStrictEqual(processKillStub.args, [ + [-4646, 'SIGKILL'], + [-4646, 0], + [-4646, 0], + ]); } finally { spawnStub.restore(); diff --git a/extension/src/test/configInfoProvider.test.ts b/extension/src/test/configInfoProvider.test.ts index 53a8d89c895..7a0b09014c4 100644 --- a/extension/src/test/configInfoProvider.test.ts +++ b/extension/src/test/configInfoProvider.test.ts @@ -9,7 +9,20 @@ import type { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import * as cliModule from '../utils/process/cliProcess'; import { AppHostDiscoveryService } from '../utils/appHostDiscovery'; import { AppHostDataRepository } from '../data/AppHostDataRepository'; -import { describeIncludeDisabledCommandsCapability, lsJsonStreamCapability } from '../types/configInfo'; +import { describeIncludeDisabledCommandsCapability, isolatedLaunchCapability, lsJsonStreamCapability } from '../types/configInfo'; +import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; + +function emitConfigInfo(options: cliModule.SpawnProcessOptions | undefined, capabilities: readonly string[] = []): void { + options?.stdoutCallback?.(JSON.stringify({ + localSettingsPath: '/workspace/aspire.config.json', + globalSettingsPath: '/home/user/.aspire/aspire.config.json', + availableFeatures: [], + localSettingsSchema: { properties: [] }, + globalSettingsSchema: { properties: [] }, + capabilities, + })); + options?.exitCallback?.(0); +} suite('configInfoProvider tests', () => { teardown(() => sinon.restore()); @@ -129,6 +142,101 @@ suite('configInfoProvider tests', () => { assert.strictEqual(spawnStub.firstCall.args[3]?.noExtensionVariables, true); }); + test('getConfigInfo runs in the targeted folder rather than the first one', async () => { + const folderA: vscode.WorkspaceFolder = { uri: vscode.Uri.file('/repo/a'), name: 'a', index: 0 }; + const folderB: vscode.WorkspaceFolder = { uri: vscode.Uri.file('/repo/b'), name: 'b', index: 1 }; + sinon.stub(vscode.workspace, 'workspaceFolders').value([folderA, folderB]); + const terminalProvider = { + getAspireCliExecutablePath: async () => '/usr/bin/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + let workingDirectory: string | undefined; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + workingDirectory = options?.workingDirectory; + options?.stdoutCallback?.(JSON.stringify({ + localSettingsPath: '/repo/b/aspire.config.json', + globalSettingsPath: '/home/user/.aspire/aspire.config.json', + availableFeatures: [], + localSettingsSchema: { properties: [] }, + globalSettingsSchema: { properties: [] }, + })); + options?.exitCallback?.(0); + return {} as ChildProcessWithoutNullStreams; + }); + + // `aspire config info` reports the local settings file it discovers from its working + // directory, so running in folder A answers questions about folder A no matter which folder + // the caller named. "Open Local Settings" would then open, or create, the wrong file. + const configInfo = await new ConfigInfoProvider(terminalProvider).getConfigInfo({ + target: workspaceFolderCliPathTarget(folderB), + }); + + assert.ok(configInfo); + assert.strictEqual(workingDirectory, folderB.uri.fsPath); + assert.strictEqual(configInfo.localSettingsPath, '/repo/b/aspire.config.json'); + }); + + test('getConfigInfo does not serve one folder result to another folder from cache', async () => { + const folderA: vscode.WorkspaceFolder = { uri: vscode.Uri.file('/repo/a'), name: 'a', index: 0 }; + const folderB: vscode.WorkspaceFolder = { uri: vscode.Uri.file('/repo/b'), name: 'b', index: 1 }; + sinon.stub(vscode.workspace, 'workspaceFolders').value([folderA, folderB]); + const terminalProvider = { + // One CLI serves both folders, which is the common case and the reason a cache keyed only + // by CLI path conflates them. + getAspireCliExecutablePath: async () => '/usr/bin/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.(JSON.stringify({ + localSettingsPath: `${options?.workingDirectory}/aspire.config.json`, + globalSettingsPath: '/home/user/.aspire/aspire.config.json', + availableFeatures: [], + localSettingsSchema: { properties: [] }, + globalSettingsSchema: { properties: [] }, + })); + options?.exitCallback?.(0); + return {} as ChildProcessWithoutNullStreams; + }); + + const provider = new ConfigInfoProvider(terminalProvider); + const a = await provider.getConfigInfo({ target: workspaceFolderCliPathTarget(folderA) }); + const b = await provider.getConfigInfo({ target: workspaceFolderCliPathTarget(folderB) }); + + assert.strictEqual(a?.localSettingsPath, `${folderA.uri.fsPath}/aspire.config.json`); + assert.strictEqual(b?.localSettingsPath, `${folderB.uri.fsPath}/aspire.config.json`); + }); + + test('getConfigInfo forwards options.target to terminalProvider.getAspireCliExecutablePath', async () => { + const workspaceFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file('/repo/a'), + name: 'a', + index: 0, + }; + const target = workspaceFolderCliPathTarget(workspaceFolder); + const getAspireCliExecutablePathStub = sinon.stub().resolves('/repo/a/bin/aspire'); + const terminalProvider = { + getAspireCliExecutablePath: getAspireCliExecutablePathStub, + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + options?.stdoutCallback?.(JSON.stringify({ + localSettingsPath: '/repo/a/aspire.config.json', + globalSettingsPath: '/home/user/.aspire/aspire.config.json', + availableFeatures: [], + localSettingsSchema: { properties: [] }, + globalSettingsSchema: { properties: [] }, + })); + options?.exitCallback?.(0); + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + const configInfo = await provider.getConfigInfo({ target }); + + assert.ok(configInfo); + assert.ok(getAspireCliExecutablePathStub.calledOnceWith(target)); + }); + test('getConfigInfo retries without nologo when an older CLI rejects it', async () => { const terminalProvider = { getAspireCliExecutablePath: async () => '/usr/bin/aspire', @@ -159,6 +267,316 @@ suite('configInfoProvider tests', () => { assert.deepStrictEqual(spawnStub.secondCall.args[2], ['config', 'info', '--json']); }); + test('getCapabilityStatus uses advertised capabilities before the minimum-version fallback', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { + assert.deepStrictEqual(args, ['config', 'info', '--json', '--nologo']); + emitConfigInfo(options, [isolatedLaunchCapability]); + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + const status = await provider.getCapabilityStatus(isolatedLaunchCapability, { + cliPath: '/exact/aspire', + forceRefresh: true, + minimumVersion: '13.2.0', + }); + + assert.strictEqual(status, 'supported'); + assert.strictEqual(spawnStub.callCount, 1); + }); + + test('getCapabilityStatus accepts the stable minimum and higher numeric cores but rejects minimum-core prereleases', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const versions = [ + ['13.2.0', 'supported'], + ['13.2.4+abcdef', 'supported'], + ['13.3.0-preview.1.12345.6', 'supported'], + ['13.3.0-dev.123+abcdef', 'supported'], + ['13.2.0-preview.1.12345.6', 'unsupported'], + ['13.2.0-dev.123+abcdef', 'unsupported'], + ['13.1.99', 'unsupported'], + ] as const; + let versionIndex = 0; + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { + assert.strictEqual(command, '/exact/aspire'); + if (args?.[0] === 'config') { + emitConfigInfo(options); + } else { + assert.deepStrictEqual(args, ['--version']); + options?.stdoutCallback?.(`${versions[versionIndex++][0]}\n`); + options?.exitCallback?.(0); + } + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + for (const [, expectedStatus] of versions) { + assert.strictEqual( + await provider.getCapabilityStatus(isolatedLaunchCapability, { + cliPath: '/exact/aspire', + forceRefresh: true, + minimumVersion: '13.2.0', + }), + expectedStatus); + } + + assert.strictEqual(spawnStub.callCount, versions.length * 2); + for (const call of spawnStub.getCalls().filter(call => call.args[2]?.[0] === '--version')) { + assert.strictEqual(call.args[3]?.createProcessGroup, true); + assert.strictEqual(call.args[3]?.noExtensionVariables, true); + } + }); + + test('getCapabilityStatus reports malformed or unbounded version output as unavailable', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const invalidVersions = [ + '', + 'Aspire CLI 13.2.0', + '13.2', + '13.2.0\n13.2.1', + '100000.2.0', + '13.2.0-preview!', + ]; + let versionIndex = 0; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { + if (args?.[0] === 'config') { + emitConfigInfo(options); + } else { + options?.stdoutCallback?.(invalidVersions[versionIndex++]); + options?.exitCallback?.(0); + } + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + for (const _invalidVersion of invalidVersions) { + assert.strictEqual( + await provider.getCapabilityStatus(isolatedLaunchCapability, { + cliPath: '/exact/aspire', + forceRefresh: true, + minimumVersion: '13.2.0', + }), + 'unavailable'); + } + }); + + test('getCapabilityStatus falls back to exact CLI version when config info is unavailable', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, command, args, options) => { + assert.strictEqual(command, '/exact/aspire'); + if (args?.[0] === 'config') { + options?.exitCallback?.(1); + } else { + assert.deepStrictEqual(args, ['--version']); + options?.stdoutCallback?.('13.2.4'); + options?.exitCallback?.(0); + } + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + const status = await provider.getCapabilityStatus(isolatedLaunchCapability, { + cliPath: '/exact/aspire', + forceRefresh: true, + suppressErrors: true, + minimumVersion: '13.2.0', + }); + + assert.strictEqual(status, 'supported'); + assert.deepStrictEqual(spawnStub.getCalls().map(call => call.args[2]), [ + ['config', 'info', '--json', '--nologo'], + ['--version'], + ]); + }); + + test('getCapabilityStatus reports version spawn and exit failures as unavailable', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + let versionAttempt = 0; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { + if (args?.[0] === 'config') { + emitConfigInfo(options); + } else if (versionAttempt++ === 0) { + options?.errorCallback?.(new Error('spawn failed')); + } else { + options?.stdoutCallback?.('13.2.0'); + options?.exitCallback?.(2); + } + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + + for (let attempt = 0; attempt < 2; attempt++) { + assert.strictEqual( + await provider.getCapabilityStatus(isolatedLaunchCapability, { + cliPath: '/exact/aspire', + forceRefresh: true, + minimumVersion: '13.2.0', + }), + 'unavailable'); + } + }); + + test('getCapabilityStatus reports version timeouts as unavailable and terminates the process', async () => { + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const childProcess = { kill: () => true } as unknown as ChildProcessWithoutNullStreams; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { + if (args?.[0] === 'config') { + emitConfigInfo(options); + return {} as ChildProcessWithoutNullStreams; + } + + return childProcess; + }); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); + const provider = new ConfigInfoProvider(terminalProvider); + + try { + const probe = provider.getCapabilityStatus(isolatedLaunchCapability, { + cliPath: '/exact/aspire', + forceRefresh: true, + minimumVersion: '13.2.0', + }); + await clock.tickAsync(30_000); + + assert.strictEqual(await probe, 'unavailable'); + sinon.assert.calledOnceWithExactly( + terminateStub, + childProcess, + 'timed-out Aspire CLI version probe'); + } + finally { + clock.restore(); + } + }); + + test('getCapabilityStatus shares one timeout budget across config and version probes', async () => { + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const versionProcess = { kill: () => true } as unknown as ChildProcessWithoutNullStreams; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { + if (args?.[0] === 'config') { + setTimeout(() => emitConfigInfo(options), 5_000); + return {} as ChildProcessWithoutNullStreams; + } + + return versionProcess; + }); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); + const provider = new ConfigInfoProvider(terminalProvider); + + try { + const probe = provider.getCapabilityStatus(isolatedLaunchCapability, { + cliPath: '/exact/aspire', + forceRefresh: true, + minimumVersion: '13.2.0', + timeoutMs: 10_000, + }); + await clock.tickAsync(10_000); + + sinon.assert.calledOnceWithExactly( + terminateStub, + versionProcess, + 'timed-out Aspire CLI version probe'); + assert.strictEqual(await probe, 'unavailable'); + } + finally { + clock.restore(); + } + }); + + test('getCapabilityStatus reports cancellation as unavailable and terminates the version process', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const childProcess = { kill: () => true } as unknown as ChildProcessWithoutNullStreams; + let signalVersionStarted!: () => void; + const versionStarted = new Promise(resolve => signalVersionStarted = resolve); + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { + if (args?.[0] === 'config') { + emitConfigInfo(options); + return {} as ChildProcessWithoutNullStreams; + } + + signalVersionStarted(); + return childProcess; + }); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); + const provider = new ConfigInfoProvider(terminalProvider); + const cancellation = new vscode.CancellationTokenSource(); + + const probe = provider.getCapabilityStatus(isolatedLaunchCapability, { + cliPath: '/exact/aspire', + forceRefresh: true, + minimumVersion: '13.2.0', + cancellationToken: cancellation.token, + }); + await versionStarted; + cancellation.cancel(); + + assert.strictEqual(await probe, 'unavailable'); + sinon.assert.calledOnceWithExactly( + terminateStub, + childProcess, + 'cancelled Aspire CLI version probe'); + }); + + test('getCapabilityStatus refreshes version fallback when a CLI changes in place', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + let version = '13.1.3'; + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, args, options) => { + if (args?.[0] === 'config') { + emitConfigInfo(options); + } else { + options?.stdoutCallback?.(version); + options?.exitCallback?.(0); + } + return {} as ChildProcessWithoutNullStreams; + }); + const provider = new ConfigInfoProvider(terminalProvider); + const options = { + cliPath: '/exact/aspire', + forceRefresh: true, + minimumVersion: '13.2.0', + }; + + assert.strictEqual(await provider.getCapabilityStatus(isolatedLaunchCapability, options), 'unsupported'); + version = '13.2.0'; + assert.strictEqual(await provider.getCapabilityStatus(isolatedLaunchCapability, options), 'supported'); + assert.deepStrictEqual(spawnStub.getCalls().map(call => call.args[2]), [ + ['config', 'info', '--json', '--nologo'], + ['--version'], + ['config', 'info', '--json', '--nologo'], + ['--version'], + ]); + }); + test('shared provider deduplicates eager capability probes', async () => { sinon.stub(vscode.workspace, 'workspaceFolders').value(undefined); const terminalProvider = { @@ -287,6 +705,173 @@ suite('configInfoProvider tests', () => { assert.strictEqual(spawnStub.callCount, 2); }); + test('force refresh cancels and terminates its capability probe', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const childProcess = { kill: () => true } as unknown as ChildProcessWithoutNullStreams; + sinon.stub(cliModule, 'spawnCliProcess').returns(childProcess); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); + const provider = new ConfigInfoProvider(terminalProvider); + const cancellation = new vscode.CancellationTokenSource(); + + const probe = provider.getConfigInfo({ + cliPath: '/usr/bin/aspire', + suppressErrors: true, + forceRefresh: true, + cancellationToken: cancellation.token, + }); + cancellation.cancel(); + + assert.strictEqual(await probe, null); + sinon.assert.calledOnceWithExactly(terminateStub, childProcess, 'cancelled aspire config info command'); + }); + + test('a token-bound force refresh is not shared with unrelated callers', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const childProcesses: ChildProcessWithoutNullStreams[] = []; + const probeOptions: cliModule.SpawnProcessOptions[] = []; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + const childProcess = { kill: () => true } as unknown as ChildProcessWithoutNullStreams; + childProcesses.push(childProcess); + if (options) { + probeOptions.push(options); + } + return childProcess; + }); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); + const provider = new ConfigInfoProvider(terminalProvider); + const cancellation = new vscode.CancellationTokenSource(); + + const refresh = provider.getConfigInfo({ + cliPath: '/usr/bin/aspire', + suppressErrors: true, + forceRefresh: true, + cancellationToken: cancellation.token, + }); + const shared = provider.getConfigInfo({ + cliPath: '/usr/bin/aspire', + suppressErrors: true, + }); + + assert.strictEqual(probeOptions.length, 2); + cancellation.cancel(); + assert.strictEqual(await refresh, null); + sinon.assert.calledOnceWithExactly(terminateStub, childProcesses[0], 'cancelled aspire config info command'); + + probeOptions[1].stdoutCallback?.(JSON.stringify({ + localSettingsPath: '/workspace/aspire.config.json', + globalSettingsPath: '/home/user/.aspire/aspire.config.json', + availableFeatures: [], + localSettingsSchema: { properties: [] }, + globalSettingsSchema: { properties: [] }, + capabilities: [lsJsonStreamCapability], + })); + probeOptions[1].exitCallback?.(0); + + assert.deepStrictEqual((await shared)?.capabilities, [lsJsonStreamCapability]); + }); + + test('cancelling one shared caller does not terminate the shared probe', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + let probeOptions: cliModule.SpawnProcessOptions | undefined; + const childProcess = { kill: () => true } as unknown as ChildProcessWithoutNullStreams; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + probeOptions = options; + return childProcess; + }); + const terminateStub = sinon.stub(cliModule, 'terminateCliProcess').resolves(); + const provider = new ConfigInfoProvider(terminalProvider); + const cancellation = new vscode.CancellationTokenSource(); + + const cancelledCaller = provider.getConfigInfo({ + cliPath: '/usr/bin/aspire', + suppressErrors: true, + cancellationToken: cancellation.token, + }); + const unrelatedCaller = provider.getConfigInfo({ + cliPath: '/usr/bin/aspire', + suppressErrors: true, + }); + + cancellation.cancel(); + assert.strictEqual(await cancelledCaller, null); + assert.strictEqual(terminateStub.called, false); + + probeOptions?.stdoutCallback?.(JSON.stringify({ + localSettingsPath: '/workspace/aspire.config.json', + globalSettingsPath: '/home/user/.aspire/aspire.config.json', + availableFeatures: [], + localSettingsSchema: { properties: [] }, + globalSettingsSchema: { properties: [] }, + capabilities: [lsJsonStreamCapability], + })); + probeOptions?.exitCallback?.(0); + + assert.deepStrictEqual((await unrelatedCaller)?.capabilities, [lsJsonStreamCapability]); + }); + + test('a cancelled force refresh preserves the last successful cached result', async () => { + const terminalProvider = { + getAspireCliExecutablePath: async () => '/unused/aspire', + createEnvironment: () => ({}), + } as unknown as AspireTerminalProvider; + const probeOptions: cliModule.SpawnProcessOptions[] = []; + sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + if (options) { + probeOptions.push(options); + } + return { kill: () => true } as unknown as ChildProcessWithoutNullStreams; + }); + sinon.stub(cliModule, 'terminateCliProcess').resolves(); + const provider = new ConfigInfoProvider(terminalProvider); + + const initial = provider.getConfigInfo({ cliPath: '/usr/bin/aspire', suppressErrors: true }); + probeOptions[0].stdoutCallback?.(JSON.stringify({ + localSettingsPath: '/workspace/aspire.config.json', + globalSettingsPath: '/home/user/.aspire/aspire.config.json', + availableFeatures: [], + localSettingsSchema: { properties: [] }, + globalSettingsSchema: { properties: [] }, + capabilities: [lsJsonStreamCapability], + })); + probeOptions[0].exitCallback?.(0); + await initial; + + const cancellation = new vscode.CancellationTokenSource(); + const refresh = provider.getConfigInfo({ + cliPath: '/usr/bin/aspire', + suppressErrors: true, + forceRefresh: true, + cancellationToken: cancellation.token, + }); + cancellation.cancel(); + assert.strictEqual(await refresh, null); + + const cached = provider.getConfigInfo({ cliPath: '/usr/bin/aspire', suppressErrors: true }); + if (probeOptions[2]) { + probeOptions[2].stdoutCallback?.(JSON.stringify({ + localSettingsPath: '/workspace/aspire.config.json', + globalSettingsPath: '/home/user/.aspire/aspire.config.json', + availableFeatures: [], + localSettingsSchema: { properties: [] }, + globalSettingsSchema: { properties: [] }, + capabilities: [], + })); + probeOptions[2].exitCallback?.(0); + } + + assert.deepStrictEqual((await cached)?.capabilities, [lsJsonStreamCapability]); + assert.strictEqual(probeOptions.length, 2); + }); + test('caller timeout does not cancel a newer shared probe after delayed path resolution', async () => { const clock = sinon.useFakeTimers(); let resolveCliPath: ((cliPath: string) => void) | undefined; diff --git a/extension/src/test/dotnetDebugger.test.ts b/extension/src/test/dotnetDebugger.test.ts index 064cf35c86b..3870fea8663 100644 --- a/extension/src/test/dotnetDebugger.test.ts +++ b/extension/src/test/dotnetDebugger.test.ts @@ -4,13 +4,17 @@ import { EventEmitter } from 'events'; import * as nodePath from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import { createProjectDebuggerExtension, projectDebuggerExtension, quoteCommandLineArgument } from '../debugger/languages/dotnet'; +import { createProjectDebuggerExtension, DotNetService, projectDebuggerExtension, quoteCommandLineArgument } from '../debugger/languages/dotnet'; import { AspireExtendedDebugConfiguration, AspireResourceExtendedDebugConfiguration, ExecutableLaunchConfiguration, ProjectLaunchConfiguration } from '../dcp/types'; import * as io from '../utils/io'; import { createDebugSessionConfiguration, ResourceDebuggerExtension } from '../debugger/debuggerExtensions'; import { AppHostParentOutputFilter, AspireDebugSession } from '../debugger/AspireDebugSession'; import * as hotReload from '../debugger/hotReload'; +import * as cliPathModule from '../utils/cliPath'; +import * as cliPathEnvironmentModule from '../utils/cliPathEnvironment'; +import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { removeDirectorySafely } from './testHelpers'; class TestDotNetService { private _getDotNetTargetPathStub: sinon.SinonStub; private _hasDevKit: boolean; @@ -433,8 +437,102 @@ suite('Dotnet Debugger Extension Tests', () => { { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig); - assert.strictEqual(execFileStub.firstCall.args[2]?.cwd, projectDirectory); - assert.strictEqual(spawnStub.firstCall.args[2]?.cwd, projectDirectory); + assert.strictEqual(msbuildCallFor(execFileStub, projectPath).args[2]?.cwd, projectDirectory); + assert.strictEqual(buildCallFor(spawnStub, projectPath).args[2]?.cwd, projectDirectory); + }); + + test('project-scoped dotnet commands resolve the CLI using the target derived from the project path and forward only that resolved CLI', async () => { + const projectPath = nodePath.join(process.cwd(), '.test-temp', `dotnet-cli-target-${process.pid}-${Date.now()}`, 'TestProject.csproj'); + const outputPath = nodePath.join(nodePath.dirname(projectPath), 'bin', 'Debug', 'net10.0', 'TestProject.dll'); + const projectDirectory = nodePath.dirname(projectPath); + const buildProcess = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter() + }); + + const execFileStub = sinon.stub(childProcess, 'execFile').yields(null, { stdout: outputPath, stderr: '' }); + const spawnStub = sinon.stub(childProcess, 'spawn').callsFake(() => { + setImmediate(() => buildProcess.emit('close', 0)); + return buildProcess as unknown as childProcess.ChildProcessWithoutNullStreams; + }); + const folder = { name: 'workspace', index: 0, uri: vscode.Uri.file(projectDirectory) } as vscode.WorkspaceFolder; + // Compare against the folder's own fsPath rather than projectDirectory: VS Code lowercases + // the drive letter, so 'd:\...'.startsWith('D:\...') is false on Windows and the stub would + // report no owning folder instead of the one this test is about. + sinon.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri: vscode.Uri) => + uri.fsPath.startsWith(folder.uri.fsPath) ? folder : undefined); + const resolveCliPathStub = sinon.stub(cliPathModule, 'resolveCliPath').resolves({ cliPath: '/resolved/aspire', available: true, source: 'configured' }); + const resolvedEnv = { MARKER: 'resolved-env' } as unknown as NodeJS.ProcessEnv; + const createResolvedEnvStub = sinon.stub(cliPathEnvironmentModule, 'createResolvedAspireCliPathProcessEnvironment').returns(resolvedEnv); + + const launchConfig: ProjectLaunchConfiguration = { + type: 'project', + project_path: projectPath + }; + + const debugConfig: AspireResourceExtendedDebugConfiguration = { + runId: '1', + debugSessionId: '1', + type: 'coreclr', + name: 'Test Debug Config', + request: 'launch' + }; + + const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession); + + await projectDebuggerExtension.createDebugSessionConfigurationCallback!( + launchConfig, + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.ok(resolveCliPathStub.calledWith(workspaceFolderCliPathTarget(folder))); + assert.ok(createResolvedEnvStub.calledWith('/resolved/aspire')); + assert.strictEqual(msbuildCallFor(execFileStub, projectPath).args[2]?.env, resolvedEnv); + assert.strictEqual(buildCallFor(spawnStub, projectPath).args[2]?.env, resolvedEnv); + }); + + test('dotnet run-api preserves CLI resolution errors without spawning', async () => { + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + const resolutionError = new Error('CLI resolution failed'); + sinon.stub(cliPathModule, 'resolveCliPath').rejects(resolutionError); + const spawnStub = sinon.stub(childProcess, 'spawn'); + const service = new DotNetService({} as AspireDebugSession); + + await assert.rejects( + service.getDotNetRunApiOutput('/workspace/apphost.cs'), + error => error === resolutionError); + + assert.ok(spawnStub.notCalled); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('dotnet run-api does not time out or spawn while CLI resolution is pending', async () => { + const clock = sinon.useFakeTimers({ shouldClearNativeTimers: true }); + let rejectResolution!: (reason?: unknown) => void; + const pendingResolution = new Promise>>((_, reject) => { + rejectResolution = reject; + }); + sinon.stub(cliPathModule, 'resolveCliPath').returns(pendingResolution); + const spawnStub = sinon.stub(childProcess, 'spawn'); + const service = new DotNetService({} as AspireDebugSession); + let outcome: { value?: string; error?: unknown } | undefined; + const observed = service.getDotNetRunApiOutput('/workspace/apphost.cs').then( + value => { outcome = { value }; }, + error => { outcome = { error }; }); + + await clock.tickAsync(10_001); + const outcomeAfterTimeout = outcome; + const spawnCalledAfterTimeout = spawnStub.called; + const resolutionError = new Error('CLI resolution failed'); + rejectResolution(resolutionError); + await observed; + + assert.strictEqual(outcomeAfterTimeout, undefined); + assert.strictEqual(spawnCalledAfterTimeout, false); + assert.strictEqual(outcome?.error, resolutionError); + assert.ok(spawnStub.notCalled); }); test('project is not built when C# dev kit is installed and executable found', async () => { @@ -1153,7 +1251,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(debugConfig.program, 'dotnet'); assert.strictEqual(debugConfig.args, '--info'); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -1192,7 +1290,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(extension.debugAdapter, 'coreclr'); assert.strictEqual(debugConfig.program, executablePath); - assert.strictEqual(debugConfig.args, undefined); + assert.deepStrictEqual(debugConfig.args, []); // cwd defaults to the file's directory; run-api's WorkingDirectory (empty here) is not consumed. assert.strictEqual(debugConfig.cwd, '/tmp'); // run-api's profile-derived EnvironmentVariables are dropped (only DOTNET_ROOT* would be kept), so @@ -1249,20 +1347,18 @@ suite('Dotnet Debugger Extension Tests', () => { }); }); - test('file-based .cs project launched via the dotnet launcher keeps the run-api host DLL and user args', async () => { + test('file-based .cs project launched via the dotnet launcher keeps the run-api host args and user args', async () => { // When the SDK resolves a file-based app to the `dotnet` launcher, run-api returns ExecutablePath=dotnet - // and CommandLineArguments carrying the built app DLL. The extension must launch that program with the - // DLL host argument first, then the user application arguments supplied by DCP. Dropping CommandLineArguments - // would launch the 'dotnet' launcher with nothing to run. The working directory and environment come from - // the launch profile (here, its defaults) — not from run-api, whose values reflect the SDK default profile. - const dllPath = '/tmp/obj/Debug/net10.0/app.dll'; + // and SDK-serialized CommandLineArguments such as `exec ""` for UseAppHost=false. + // The extension must deserialize those host tokens before prepending user arguments supplied by DCP. + const dllPath = '/tmp/obj/Debug/net10.0/app with spaces.dll'; const workingDirectory = '/tmp/obj/Debug/net10.0'; const { extension, dotNetService } = createDebuggerExtension('unused-build-output', null, true, true); dotNetService.runApiOutput = JSON.stringify({ $type: 'RunCommand', Version: 1, ExecutablePath: 'dotnet', - CommandLineArguments: dllPath, + CommandLineArguments: `exec "${dllPath}"`, WorkingDirectory: workingDirectory, EnvironmentVariables: { RUNAPI_ENV: 'from-run-api' } }); @@ -1285,7 +1381,7 @@ suite('Dotnet Debugger Extension Tests', () => { await extension.createDebugSessionConfigurationCallback!(launchConfig, ['--message', 'hello'], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig); assert.strictEqual(debugConfig.program, 'dotnet'); - assert.strictEqual(debugConfig.args, `${dllPath} --message hello`); + assert.deepStrictEqual(debugConfig.args, ['exec', dllPath, '--message', 'hello']); // cwd comes from the launch profile default (the file's directory), not run-api's WorkingDirectory. assert.strictEqual(debugConfig.cwd, '/tmp'); // run-api's profile-derived EnvironmentVariables are dropped (only DOTNET_ROOT* would be kept), so @@ -1294,6 +1390,49 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.buildDotNetProjectStub.called, true); }); + test('file-based .cs AppHost keeps run-api host arguments and forwarded arg tokens separate', async () => { + // The AppHost forwards run-session arguments as discrete tokens. When run-api reports the file-based + // host program as `dotnet exec ""`, the extension must deserialize and prepend those + // SDK-authored host tokens without joining the forwarded tokens into command-line text. + const path = require('path'); + + const projectPath = path.join(process.cwd(), '.test-temp', `dotnet-apphost-forwarded-args-${process.pid}-${Date.now()}`, 'apphost.cs'); + const projectDirectory = path.dirname(projectPath); + const dllPath = path.join(projectDirectory, 'obj', 'Debug', 'net10.0', 'output with spaces', 'apphost.dll'); + const forwardedArgs = ['--custom', 'value with spaces', '', 'literal "quote"', String.raw`C:\tools\backslash\path`]; + const { extension, dotNetService } = createDebuggerExtension('unused-build-output', null, true, true); + dotNetService.runApiOutput = JSON.stringify({ + $type: 'RunCommand', + Version: 1, + ExecutablePath: 'dotnet', + CommandLineArguments: `exec "${dllPath}"`, + WorkingDirectory: path.join(projectDirectory, 'from-run-api'), + EnvironmentVariables: {} + }); + + const launchConfig: ProjectLaunchConfiguration = { + type: 'project', + project_path: projectPath + }; + + const debugConfig: AspireResourceExtendedDebugConfiguration = { + runId: '1', + debugSessionId: '1', + type: 'coreclr', + name: 'Test Debug Config', + request: 'launch' + }; + + const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession); + + await extension.createDebugSessionConfigurationCallback!(launchConfig, forwardedArgs, [], { debug: true, forceBuild: false, runId: '1', debugSessionId: '1', isApphost: true, debugSession: fakeAspireDebugSession }, debugConfig); + + assert.strictEqual(debugConfig.program, 'dotnet'); + assert.deepStrictEqual(debugConfig.args, ['exec', dllPath, '--custom', 'value with spaces', '', 'literal "quote"', String.raw`C:\tools\backslash\path`]); + assert.strictEqual(debugConfig.cwd, projectDirectory); + assert.strictEqual(dotNetService.buildDotNetProjectStub.notCalled, true); + }); + test('file-based .cs applies the launch profile working directory', async () => { // A file-based app can carry a `.run.json` launch profile. When that profile sets an explicit // workingDirectory the extension must resolve and apply it, mirroring how launch profiles set the @@ -1346,7 +1485,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(debugConfig.cwd, profileWorkingDirectory); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -1405,14 +1544,15 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(debugConfig.program, executablePath); assert.strictEqual(debugConfig.args, '--from-profile'); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); test('file-based .cs with disable_launch_profile ignores run-api profile arguments, working directory, and profile environment but keeps DOTNET_ROOT', async () => { // With disable_launch_profile the extension selects no launch profile, but `dotnet run-api` still applies // the SDK default profile and returns its arguments, working directory, and environment. The profile - // values may not leak into the debug configuration: args come only from the run session, cwd defaults to + // values may not leak into the debug configuration: args come only from the run session (an explicit empty + // array here), cwd defaults to // the file's directory, and no profile env is applied. The runtime host-resolution variables (DOTNET_ROOT*) // are NOT profile-derived, so they must still be preserved even when the profile is disabled. const fs = require('fs'); @@ -1467,12 +1607,12 @@ suite('Dotnet Debugger Extension Tests', () => { await extension.createDebugSessionConfigurationCallback!(launchConfig, [], [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig); assert.strictEqual(debugConfig.program, executablePath); - assert.strictEqual(debugConfig.args, undefined); + assert.deepStrictEqual(debugConfig.args, []); assert.strictEqual(debugConfig.cwd, tempRoot); // The profile env (DISABLED_PROFILE_ENV) is dropped; the runtime host variable DOTNET_ROOT is preserved. assert.deepStrictEqual(debugConfig.env, { DOTNET_ROOT: '/usr/share/dotnet' }); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -1536,7 +1676,7 @@ suite('Dotnet Debugger Extension Tests', () => { // arguments appear exactly once and are not prefixed with run-api's CommandLineArguments. assert.strictEqual(debugConfig.args, '--from-profile'); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -1603,7 +1743,7 @@ suite('Dotnet Debugger Extension Tests', () => { // DOTNET_ROOT_X64 survives. assert.deepStrictEqual(debugConfig.env, { DOTNET_ROOT_X64: '/usr/share/dotnet/x64' }); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -1697,7 +1837,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.deepStrictEqual(appHostDebugConfig.args, ['run', '--file', projectPath, '--no-build', '--no-launch-profile', '--property:_AspireSuppressCliRunHook=true']); assert.strictEqual(appHostDebugConfig.noDebug, true); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -1763,7 +1903,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(debugConfig.noDebug, true); assert.strictEqual(debugConfig.cwd, tempRoot); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -1826,7 +1966,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(debugConfig.cwd, tempRoot); assert.deepStrictEqual(debugConfig.env, { APP_ENV: 'from-project-profile' }); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -1912,7 +2052,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(appHostDebugConfig.args, `run --file "${projectPath}" --no-build --no-launch-profile --property:_AspireSuppressCliRunHook=true --property:RunWorkingDirectory="${path.join(tempRoot, 'custom')}" -- --from-profile`); assert.strictEqual(appHostDebugConfig.cwd, tempRoot); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -1982,7 +2122,7 @@ suite('Dotnet Debugger Extension Tests', () => { // (SDK-injected, defined by no profile) is preserved. assert.deepStrictEqual(debugConfig.env, { DOTNET_ROOT: '/selected/dotnet', DOTNET_ROOT_X64: '/usr/share/dotnet/x64' }); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -2055,7 +2195,7 @@ suite('Dotnet Debugger Extension Tests', () => { // genuine SDK-injected DOTNET_ROOT_X64 survives. assert.deepStrictEqual(debugConfig.env, { DOTNET_ROOT_X64: '/usr/share/dotnet/x64' }); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -2130,6 +2270,63 @@ suite('Dotnet Debugger Extension Tests', () => { assert.deepStrictEqual(debugConfig.args, ['run', '--project', projectPath, '--no-launch-profile', '--', '--message', 'hello world']); assert.strictEqual(debugConfig.noDebug, true); } finally { + removeDirectorySafely(tempRoot); + } + }); + + test('project Executable profile preserves forwarded array args without expanding tokens', async () => { + const fs = require('fs'); + const path = require('path'); + + const tempRoot = path.join(process.cwd(), '.test-temp', `dotnet-debugger-exec-args-${process.pid}-${Date.now()}`); + const projectDir = path.join(tempRoot, 'ToolProject'); + const propertiesDir = path.join(projectDir, 'Properties'); + fs.mkdirSync(propertiesDir, { recursive: true }); + + const spacesEnvVarName = 'ASPIRE_TEST_DEBUGGER_ARG_SPACES'; + const pathEnvVarName = 'ASPIRE_TEST_DEBUGGER_ARG_PATH'; + const backslashPath = String.raw`C:\tools\backslash\path`; + process.env[spacesEnvVarName] = 'value with spaces'; + process.env[pathEnvVarName] = backslashPath; + + try { + const projectPath = path.join(projectDir, 'ToolProject.csproj'); + fs.writeFileSync(projectPath, ''); + fs.writeFileSync(path.join(propertiesDir, 'launchSettings.json'), JSON.stringify({ + profiles: { + Tool: { + commandName: 'Executable', + executablePath: 'dotnet' + } + } + })); + + const outputPath = path.join(projectDir, 'bin', 'Debug', 'net10.0', 'ToolProject.dll'); + const { extension } = createDebuggerExtension(outputPath, null, true, true); + const launchConfig: ProjectLaunchConfiguration = { + type: 'project', + project_path: projectPath, + launch_profile: 'Tool' + }; + + const debugConfig: AspireResourceExtendedDebugConfiguration = { + runId: '1', + debugSessionId: '1', + type: 'coreclr', + name: 'Test Debug Config', + request: 'launch' + }; + + const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession); + const forwardedArgs = ['--custom', `$(${spacesEnvVarName})`, '', 'literal "quote"', `$(${pathEnvVarName})`]; + + await extension.createDebugSessionConfigurationCallback!(launchConfig, forwardedArgs, [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig); + + assert.strictEqual(debugConfig.program, 'dotnet'); + assert.deepStrictEqual(debugConfig.args, forwardedArgs); + } finally { + delete process.env[spacesEnvVarName]; + delete process.env[pathEnvVarName]; fs.rmSync(tempRoot, { recursive: true, force: true }); } }); @@ -2184,7 +2381,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(debugConfig.args, `run --project "${projectPath}" --no-launch-profile -- --arg "value with spaces" --message "say \\"hi\\"" --path "C:\\Temp\\file.txt"`); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -2243,7 +2440,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(debugConfig.cwd, path.join(projectDir, 'custom')); assert.strictEqual(debugConfig.executablePath, undefined); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -2283,7 +2480,7 @@ suite('Dotnet Debugger Extension Tests', () => { extension.createDebugSessionConfigurationCallback!(launchConfig, undefined, [], { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, debugConfig), /Failed to inspect runtimeconfig/); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -2329,7 +2526,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(showInformationMessageStub.calledOnce, true); assert.match(showInformationMessageStub.firstCall.args[0], /breakpoints/i); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -2408,12 +2605,14 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(debugConfig.executablePath, 'exePath'); assert.strictEqual(debugConfig.checkForDevCert, true); - // serverReadyAction should be present with the applicationUrl - assert.notStrictEqual(debugConfig.serverReadyAction, undefined); - assert.strictEqual(debugConfig.serverReadyAction.uriFormat, 'https://localhost:5001'); + // launchSettings.json sets launchBrowser with applicationUrl https://localhost:5001, but the app + // host owns this resource's endpoints and can bind it elsewhere, so that address is not where the + // resource actually listens. `aspire run` never opens it either — Aspire.Hosting parses + // launchBrowser and never reads it — so the extension must not open a URL the CLI would not. + assert.strictEqual(debugConfig.serverReadyAction, undefined); // cleanup - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); }); test('preserves serverReadyAction from project debugger settings', async () => { @@ -2472,7 +2671,62 @@ suite('Dotnet Debugger Extension Tests', () => { assert.deepStrictEqual(debugConfig.serverReadyAction, serverReadyAction); - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); + }); + + test('does not open the launch profile URL for the orchestrated dashboard resource', async () => { + // Bug: running an AppHost from VS Code opened http://localhost:15888 — the address in the Aspire + // dashboard project's own launchSettings.json — instead of the dashboard the app host actually + // started, which listens on a dynamic port and needs a login token. The app host reassigns this + // resource's URLs, so the on-disk profile is stale, and the run-session payload carries no + // endpoint data for the extension to correct it with. + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-test-')); + const projectDir = path.join(tempDir, 'Aspire.Dashboard'); + const propertiesDir = path.join(projectDir, 'Properties'); + fs.mkdirSync(propertiesDir, { recursive: true }); + + const projectPath = path.join(projectDir, 'Aspire.Dashboard.csproj'); + fs.writeFileSync(projectPath, ''); + fs.writeFileSync(path.join(propertiesDir, 'launchSettings.json'), JSON.stringify({ + profiles: { + Development: { + commandName: 'Project', + launchBrowser: true, + applicationUrl: 'http://localhost:15888' + } + } + }, null, 2)); + + const outputPath = path.join(projectDir, 'bin', 'Debug', 'net10.0', 'Aspire.Dashboard.dll'); + const { extension } = createDebuggerExtension(outputPath, null, true, true); + const launchConfig: ProjectLaunchConfiguration = { + type: 'project', + project_path: projectPath, + launch_profile: 'Development' + }; + const debugConfig: AspireResourceExtendedDebugConfiguration = { + runId: '1', + debugSessionId: '1', + type: 'coreclr', + name: 'Aspire.Dashboard', + request: 'launch' + }; + const fakeAspireDebugSession = sinon.createStubInstance(AspireDebugSession); + + await extension.createDebugSessionConfigurationCallback!( + launchConfig, + undefined, + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.strictEqual(debugConfig.serverReadyAction, undefined); + + removeDirectorySafely(tempDir); }); test('uses executable path for Executable command launch profiles instead of project output', async () => { @@ -2545,7 +2799,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.buildDotNetProjectStub.calledOnce, true); // cleanup - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); }); test('fails project launch when the selected Executable launch profile has no executablePath', async () => { @@ -2601,7 +2855,7 @@ suite('Dotnet Debugger Extension Tests', () => { // The invalid profile must be rejected before any build/launch work happens. assert.strictEqual(dotNetService.buildDotNetProjectStub.called, false); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2656,7 +2910,7 @@ suite('Dotnet Debugger Extension Tests', () => { assert.strictEqual(dotNetService.buildDotNetProjectStub.called, false); } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); @@ -2723,6 +2977,30 @@ suite('Dotnet Debugger Extension Tests', () => { // cleanup delete process.env[envVarName]; - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); }); }); + +/** + * All unit tests share one extension host, so background extension work - an editor event that + * queues AppHost discovery, for example - can reach a freshly installed stub before the call the + * test itself makes. Selecting the process by the project it was started for keeps these assertions + * about the dotnet command under test rather than about whichever process happened to start first. + */ +function msbuildCallFor(stub: sinon.SinonStub, projectPath: string): sinon.SinonSpyCall { + return dotnetCallFor(stub, projectPath, 'dotnet msbuild'); +} + +function buildCallFor(stub: sinon.SinonStub, projectPath: string): sinon.SinonSpyCall { + return dotnetCallFor(stub, projectPath, 'dotnet build'); +} + +function dotnetCallFor(stub: sinon.SinonStub, projectPath: string, description: string): sinon.SinonSpyCall { + const call = stub.getCalls().find(candidate => { + const args = candidate.args[1]; + return Array.isArray(args) && args.includes(projectPath); + }); + + assert.ok(call, `${description} was not started for '${projectPath}'. Started: ${JSON.stringify(stub.getCalls().map(candidate => candidate.args[1]))}`); + return call; +} diff --git a/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts b/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts new file mode 100644 index 00000000000..33feb7d6a09 --- /dev/null +++ b/extension/src/test/e2eAddWorkspaceFolderGuard.test.ts @@ -0,0 +1,77 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getE2eAddableWorkspaceFolderPath } from '../testing/e2eStateFileBridge'; + +// `addWorkspaceFolder` exists to build a multi-root workspace during an E2E run, so the folder it +// receives is by definition not part of the workspace yet. Routing it through the guard that +// requires containment in an already-open folder made every workspace-target-proof shard fail with +// "workspace path arguments must stay inside the opened workspace" before a single assertion ran. +suite('E2E addWorkspaceFolder guard', () => { + let runRoot: string; + let workspaceRoot: string; + let originalRunRoot: string | undefined; + let originalWorkspaceRoot: string | undefined; + + setup(() => { + runRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-addfolder-run-')); + workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-addfolder-ws-')); + originalRunRoot = process.env.ASPIRE_EXTENSION_E2E_RUN_ROOT; + originalWorkspaceRoot = process.env.ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT; + process.env.ASPIRE_EXTENSION_E2E_RUN_ROOT = runRoot; + process.env.ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT = workspaceRoot; + }); + + teardown(() => { + restoreEnvironmentVariable('ASPIRE_EXTENSION_E2E_RUN_ROOT', originalRunRoot); + restoreEnvironmentVariable('ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT', originalWorkspaceRoot); + fs.rmSync(runRoot, { recursive: true, force: true }); + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + test('accepts a folder inside the workspace root that is not an open workspace folder', () => { + const folderPath = path.join(workspaceRoot, 'folder-a'); + fs.mkdirSync(folderPath, { recursive: true }); + + assert.strictEqual(getE2eAddableWorkspaceFolderPath(folderPath), folderPath); + }); + + test('accepts a folder inside the run root', () => { + const folderPath = path.join(runRoot, 'folder-b'); + fs.mkdirSync(folderPath, { recursive: true }); + + assert.strictEqual(getE2eAddableWorkspaceFolderPath(folderPath), folderPath); + }); + + test('rejects a folder outside every configured root', () => { + const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-addfolder-outside-')); + + try { + assert.throws( + () => getE2eAddableWorkspaceFolderPath(outsideRoot), + /can only add folders inside the configured E2E run root or workspace root/); + } finally { + fs.rmSync(outsideRoot, { recursive: true, force: true }); + } + }); + + test('rejects a path that is a file rather than a directory', () => { + const filePath = path.join(workspaceRoot, 'not-a-folder.txt'); + fs.writeFileSync(filePath, 'contents'); + + assert.throws(() => getE2eAddableWorkspaceFolderPath(filePath), /requires an existing folder/); + }); + + test('rejects a relative path', () => { + assert.throws(() => getE2eAddableWorkspaceFolderPath('folder-a'), /requires an absolute folder path/); + }); +}); + +function restoreEnvironmentVariable(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} diff --git a/extension/src/test/e2eDiagnosticsProbe.test.ts b/extension/src/test/e2eDiagnosticsProbe.test.ts new file mode 100644 index 00000000000..c7454770990 --- /dev/null +++ b/extension/src/test/e2eDiagnosticsProbe.test.ts @@ -0,0 +1,96 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { getDiagnosticsForFile } from '../testing/e2eStateFileBridge'; + +import { removeDirectorySafely } from './testHelpers'; +/** + * The Java AppHost E2E spec probes diagnostics for every generated Aspire Java SDK source, which is + * more than a hundred files. Each probe has to show the document so the language server publishes + * diagnostics for it, and the tabs that leaves behind are then closed one at a time over WebDriver + * by whichever suite tears down next - which is how a passing suite still failed the shard on a + * five minute `after all` timeout. + */ +suite('E2E diagnostics probe', () => { + let temporaryDirectory: string | undefined; + let restoreEnablePreview: (() => Promise) | undefined; + + teardown(async () => { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + + if (restoreEnablePreview) { + await restoreEnablePreview(); + restoreEnablePreview = undefined; + } + + if (temporaryDirectory) { + const probedDirectory = temporaryDirectory; + temporaryDirectory = undefined; + removeDirectorySafely(probedDirectory); + } + }); + + test('probing many files does not accumulate editor tabs', async () => { + temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-diagnostics-probe-')); + + // `workbench.editor.enablePreview` is a user setting, so a probe that relies on the default + // preview reuse keeps a tab per file on any profile where it is off. Turning it off here is + // what reproduces the tab pile seen in the real E2E VS Code instance. + const editorConfiguration = vscode.workspace.getConfiguration('workbench.editor'); + await editorConfiguration.update('enablePreview', false, vscode.ConfigurationTarget.Global); + restoreEnablePreview = async () => { + await vscode.workspace.getConfiguration('workbench.editor').update('enablePreview', undefined, vscode.ConfigurationTarget.Global); + }; + + const probedFiles: string[] = []; + for (let index = 0; index < 12; index++) { + const filePath = path.join(temporaryDirectory, `Probe${index}.java`); + fs.writeFileSync(filePath, `class Probe${index} { }\n`); + probedFiles.push(filePath); + } + + for (const filePath of probedFiles) { + await getDiagnosticsForFile(filePath); + } + + const openTabs = vscode.window.tabGroups.all.reduce((total, group) => total + group.tabs.length, 0); + assert.ok( + openTabs <= 1, + `Probing ${probedFiles.length} files left ${openTabs} editor tabs open. Diagnostics probes must reuse a single preview tab.`); + }); + + test('probing a file the caller already opened leaves it open', async () => { + temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-diagnostics-probe-')); + + const filePath = path.join(temporaryDirectory, 'AlreadyOpen.java'); + fs.writeFileSync(filePath, 'class AlreadyOpen { }\n'); + + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)); + await vscode.window.showTextDocument(document, { preview: false }); + + await getDiagnosticsForFile(filePath); + + const openPaths = vscode.window.tabGroups.all + .flatMap(group => group.tabs) + .map(tab => tab.input instanceof vscode.TabInputText ? tab.input.uri.fsPath : undefined); + + // Compare through Uri.file the way the probe itself does. On Windows a path from os.tmpdir() + // keeps the drive letter VS Code lowercases, so `C:\Users\RUNNER~1\...` never string-matches + // the `c:\Users\RUNNER~1\...` a tab reports. + const expectedPath = vscode.Uri.file(filePath).fsPath; + + assert.ok( + openPaths.includes(expectedPath), + `Probing must not close an editor the caller already had open. Open tabs: ${JSON.stringify(openPaths)}`); + }); +}); + +/** + * Windows releases the handle behind a closed editor asynchronously, so removing the probe directory + * races that release and fails with `EPERM`. Node retries recursive removals for exactly that code, + * and a directory stranded in the OS temp folder is not worth failing the suite over - the assertion + * here is about editor tabs - so a removal that still loses the race is only reported. + * See https://nodejs.org/api/fs.html#fsrmsyncpath-options. + */ diff --git a/extension/src/test/e2eDownloadCache.test.ts b/extension/src/test/e2eDownloadCache.test.ts index 92d28aafd1d..93e9c5dd9f5 100644 --- a/extension/src/test/e2eDownloadCache.test.ts +++ b/extension/src/test/e2eDownloadCache.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import { execFileSync, spawn } from 'child_process'; import * as path from 'path'; +import { removeDirectorySafely } from './testHelpers'; const extensionRoot = path.resolve(__dirname, '..', '..'); const repoRoot = path.resolve(extensionRoot, '..'); const testArtifactsRoot = path.join(extensionRoot, '.test-artifacts', 'e2e-download-cache-tests'); @@ -422,6 +423,36 @@ function withLockedDirectoryRemoval(directoryPath: string, failureCount: number, return attempts; } +/** + * Drops a new file into `directoryPath` right after it is listed, reproducing the cleanup race + * where a Java language server or build daemon is still writing while the run root is torn down. + * + * Hooking the listing rather than racing a real writer keeps this deterministic - a timing-based + * version of this test would only reproduce the bug intermittently. + */ +function withLateWriterDuringRemoval(directoryPath: string, writeCount: number, body: () => void): number { + const nodeFs = require('fs') as typeof fs; + const realReaddirSync = nodeFs.readdirSync; + let writes = 0; + + try { + nodeFs.readdirSync = ((target: fs.PathLike, ...rest: unknown[]) => { + const entries = (realReaddirSync as (...args: unknown[]) => unknown)(target, ...rest); + if (target === directoryPath && writes < writeCount) { + nodeFs.writeFileSync(path.join(directoryPath, `late-writer-${writes++}.log`), 'written after listing'); + } + + return entries; + }) as typeof fs.readdirSync; + + body(); + } finally { + nodeFs.readdirSync = realReaddirSync; + } + + return writes; +} + async function waitForPaths(pathsToCheck: string[], timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; @@ -582,7 +613,7 @@ suite('E2E download cache', () => { while (createdTestRoots.length > 0) { const root = createdTestRoots.pop(); if (root) { - fs.rmSync(root, { recursive: true, force: true }); + removeDirectorySafely(root); } } }); @@ -2093,6 +2124,39 @@ suite('E2E download cache', () => { assert.strictEqual(attempts, 3); }); + test('re-sweeps a directory that gains an entry between listing and removal', () => { + const root = createTestRoot('link-safe-removal-late-writer'); + const refilled = path.join(root, 'refilled'); + writeFile(path.join(refilled, 'existing.txt'), 'existing'); + + // A retry of the same rmdir can never clear this: the entry that appeared is still on disk, + // so only a fresh listing removes it. maxRetries is 0 here, matching Linux CI, to show the + // recovery comes from re-sweeping rather than from the lock retry budget. + const writes = withLateWriterDuringRemoval(refilled, 1, () => { + cache.removePathWithoutFollowingLinks(refilled, { maxRetries: 0, retryDelay: 1 }); + }); + + assert.strictEqual(writes, 1); + assert.strictEqual(fs.existsSync(refilled), false); + }); + + test('gives up on a directory that keeps being refilled during removal', () => { + const root = createTestRoot('link-safe-removal-sweep-budget'); + const refilled = path.join(root, 'refilled'); + writeFile(path.join(refilled, 'existing.txt'), 'existing'); + + // A process that never stops writing has to surface as a cleanup failure rather than hang + // the shard, so the sweeps are bounded. + const writes = withLateWriterDuringRemoval(refilled, Number.POSITIVE_INFINITY, () => { + assert.throws( + () => cache.removePathWithoutFollowingLinks(refilled, { maxRetries: 0, retryDelay: 1 }), + /ENOTEMPTY/); + }); + + assert.strictEqual(writes, 6); + assert.ok(fs.existsSync(refilled)); + }); + test('replaces stale projected entries so a reused storage directory tracks the cache', () => { const root = createTestRoot('replaces-stale-projection'); const result = cache.ensureDownloadCache(getDefaultCacheOptions(path.join(root, 'cache'), { populate(stagingDirectory) { diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index de79d4738c3..b1e10b31ff9 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import { spawnSync } from 'child_process'; import * as ts from 'typescript'; +import { removeDirectorySafely } from './testHelpers'; function readSourcePattern(source: string, name: string): RegExp { const declaration = new RegExp(`const ${name} = /(.+)/;`).exec(source); assert.ok(declaration, `run-e2e.js must define ${name}`); @@ -233,7 +234,7 @@ suite('E2E launch profile', () => { assert.deepStrictEqual(fs.readdirSync(tempRoot), []); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -256,7 +257,7 @@ suite('E2E launch profile', () => { assert.deepStrictEqual(fs.readdirSync(tempRoot), []); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } }); @@ -282,7 +283,7 @@ suite('E2E launch profile', () => { assert.deepStrictEqual(fs.readdirSync(tempRoot), []); } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); + removeDirectorySafely(tempRoot); } } }); @@ -973,6 +974,7 @@ suite('E2E launch profile', () => { const extensionRoot = path.resolve(__dirname, '..', '..'); const fixtures = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'helpers', 'fixtures.ts'), 'utf8'); const zeroToRunning = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'zeroToRunning.e2e.test.ts'), 'utf8'); + const dynamicDebugConfiguration = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'dynamicDebugConfiguration.e2e.test.ts'), 'utf8'); const commandPalette = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'commandPalette.e2e.test.ts'), 'utf8'); const discoveryConfiguration = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'discoveryConfiguration.e2e.test.ts'), 'utf8'); const stopAppHostStart = fixtures.indexOf('export async function stopAppHostIfRunning'); @@ -1014,6 +1016,13 @@ suite('E2E launch profile', () => { assert.ok(zeroToRunning.indexOf('() => appHostPidBeforeStop ??= getRunningAppHostPid(appHostPath)') > zeroToRunning.indexOf('await runE2eTeardown([')); assert.ok(zeroToRunning.indexOf('appHostPidBeforeStop = await waitForRunningAppHostPid(appHostPath, 30000);') < zeroToRunning.lastIndexOf("executeE2eControlCommand({ name: 'stopDebugging' })")); assert.ok(zeroToRunning.includes('removeGeneratedProject(projectName, appHostPidBeforeStop)')); + assert.ok(dynamicDebugConfiguration.includes('let appHostPidBeforeStop: number | undefined;')); + assert.ok(dynamicDebugConfiguration.includes('() => appHostPidBeforeStop ??= getRunningAppHostPid(appHostPath)')); + assert.ok(dynamicDebugConfiguration.includes('() => appHostPidBeforeStop ??= getRunningAppHostPid(firstAppHostPath)')); + assert.ok(dynamicDebugConfiguration.includes('() => stopAppHostIfRunning(appHostPath)')); + assert.ok(dynamicDebugConfiguration.includes('() => stopAppHostIfRunning(firstAppHostPath)')); + assert.ok(dynamicDebugConfiguration.includes("waitForKnownProcessExit(appHostPidBeforeStop, 'the dynamic debug configuration AppHost process', 30000)")); + assert.ok(dynamicDebugConfiguration.indexOf("waitForKnownProcessExit(appHostPidBeforeStop, 'the dynamic debug configuration AppHost process', 30000)") < dynamicDebugConfiguration.indexOf('removePath(fixtureRoot, { recursive: true, force: true })')); assert.ok(commandPalette.includes('runE2eTeardown')); assert.ok(discoveryConfiguration.includes('runE2eTeardown')); assert.ok(!commandPalette.includes('throw new AggregateError')); diff --git a/extension/src/test/e2eMochaReporter.test.ts b/extension/src/test/e2eMochaReporter.test.ts index 0839a18f6c4..7473e0ea09c 100644 --- a/extension/src/test/e2eMochaReporter.test.ts +++ b/extension/src/test/e2eMochaReporter.test.ts @@ -4,6 +4,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { removeDirectorySafely } from './testHelpers'; suite('E2E Mocha reporter', () => { test('prints spec progress and writes JSON results', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-e2e-reporter-')); @@ -47,7 +48,7 @@ suite('E2E Mocha reporter', () => { } finally { Base.consoleLog = previousConsoleLog; - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); diff --git a/extension/src/test/e2eShardMatrix.test.ts b/extension/src/test/e2eShardMatrix.test.ts index 881f01ffec4..2b236fedb92 100644 --- a/extension/src/test/e2eShardMatrix.test.ts +++ b/extension/src/test/e2eShardMatrix.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { load } from 'js-yaml'; +import { removeDirectorySafely } from './testHelpers'; /** * The E2E suite runs one spec per workflow matrix row. This unit test is the signal for a spec that * has no row, because an E2E shard cannot report that a different shard was never scheduled. @@ -192,7 +193,7 @@ suite('E2E shard matrix', () => { assert.AssertionError); } finally { - fs.rmSync(fixtureRoot, { recursive: true, force: true }); + removeDirectorySafely(fixtureRoot); } }); diff --git a/extension/src/test/fixtures/e2e-notification-extester-stub.ts b/extension/src/test/fixtures/e2e-notification-extester-stub.ts index f87fbf53e48..799e05ce888 100644 --- a/extension/src/test/fixtures/e2e-notification-extester-stub.ts +++ b/extension/src/test/fixtures/e2e-notification-extester-stub.ts @@ -5,6 +5,8 @@ interface NotificationLike { const state: { editorPolls: Array; + codeLensPolls: Array; + lastCodeLensTexts: string[]; notificationPolls: Array; terminalPolls: Array; pollResults: Array; @@ -12,6 +14,8 @@ const state: { notificationPollCount: number; } = { editorPolls: [], + codeLensPolls: [], + lastCodeLensTexts: [], notificationPolls: [], terminalPolls: [], pollResults: [], @@ -38,8 +42,20 @@ export function setEditorPolls(editorPolls: Array): void { state.waitMessages = []; } +/** + * Each entry is one `getCodeLenses()` result. An `Error` entry stands for the tab not being open yet, + * which is what `openEditor` throws before VS Code has created it. + */ +export function setCodeLensPolls(codeLensPolls: Array): void { + state.codeLensPolls = [...codeLensPolls]; + state.lastCodeLensTexts = []; + state.pollResults = []; + state.waitMessages = []; +} + export function resetNotificationWaitState(): void { setEditorPolls([]); + setCodeLensPolls([]); setNotificationPolls([]); setTerminalPolls([]); } @@ -82,7 +98,7 @@ export const VSBrowser = { driver: { wait: async (condition: () => Promise, _timeout: number | undefined, message?: string): Promise => { state.waitMessages.push(message ?? ''); - const maxAttempts = Math.max(state.editorPolls.length, state.notificationPolls.length, state.terminalPolls.length, 1) + 1; + const maxAttempts = Math.max(state.editorPolls.length, state.codeLensPolls.length, state.notificationPolls.length, state.terminalPolls.length, 1) + 1; for (let attempt = 0; attempt < maxAttempts; attempt++) { const result = await condition(); @@ -95,7 +111,23 @@ export const VSBrowser = { throw new Error(message ?? 'Timed out waiting for notification.'); }, - executeScript: async (): Promise => '', + executeScript: async (): Promise => { + // The only script the helpers run reads CodeLens widget text, so drain the queued + // code lens polls here. Once the queue is exhausted the last result keeps being + // returned, which is how a real editor behaves when its lenses stop changing, and + // it lets a wait time out with the lenses it actually saw. + const nextPoll = state.codeLensPolls.shift(); + if (nextPoll === undefined) { + return state.lastCodeLensTexts; + } + + if (nextPoll instanceof Error) { + throw nextPoll; + } + + state.lastCodeLensTexts = nextPoll; + return nextPoll; + }, actions: () => ({ sendKeys: () => ({ perform: async (): Promise => { }, @@ -134,6 +166,24 @@ export class EditorView { return nextPoll; } + + /** + * Simulates opening the tab. An `Error` at the head of the queue stands for the tab not + * existing yet, so it is consumed and thrown. A successful open does not consume the entry: + * the lens text it holds is read by `executeScript`, which is how the helper reads lenses. + */ + async openEditor(_title: string): Promise<{ getCodeLenses(): Promise }>> }> { + const nextPoll = state.codeLensPolls[0]; + if (nextPoll instanceof Error) { + state.codeLensPolls.shift(); + throw nextPoll; + } + + const texts = nextPoll ?? []; + return { + getCodeLenses: async () => texts.map(text => ({ getText: async () => text })), + }; + } } export class InputBox { diff --git a/extension/src/test/gitWorktree.test.ts b/extension/src/test/gitWorktree.test.ts new file mode 100644 index 00000000000..9ba6f3fc40a --- /dev/null +++ b/extension/src/test/gitWorktree.test.ts @@ -0,0 +1,222 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { ensureIsolatedCliArg, isLinkedGitWorktree, resolveIsolated, tryGetLinkedWorktreeRoot } from '../utils/gitWorktree'; +import { writeGitDirFile, writeLinkedWorktreeMetadata } from './testGitWorktree'; + +suite('gitWorktree', () => { + let root: string; + + setup(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-git-worktree-')); + }); + + teardown(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + test('primary checkout is not a linked worktree', () => { + fs.mkdirSync(path.join(root, '.git')); + const appHostPath = path.join(root, 'AppHost', 'AppHost.csproj'); + + assert.strictEqual(tryGetLinkedWorktreeRoot(appHostPath), undefined); + assert.strictEqual(isLinkedGitWorktree(root), false); + assert.strictEqual(resolveIsolated(undefined, appHostPath), false); + assert.strictEqual(resolveIsolated(true, appHostPath), true); + }); + + test('standard common Git directory is detected', () => { + const worktreeRoot = path.join(root, 'worktree'); + writeLinkedWorktreeMetadata(worktreeRoot, path.join(root, 'primary', '.git')); + const appHostPath = path.join(worktreeRoot, 'AppHost', 'AppHost.csproj'); + + assert.strictEqual(tryGetLinkedWorktreeRoot(appHostPath), fs.realpathSync.native(worktreeRoot)); + assert.strictEqual(isLinkedGitWorktree(appHostPath), true); + assert.strictEqual(resolveIsolated(undefined, appHostPath), true); + assert.strictEqual(resolveIsolated(false, appHostPath), false); + }); + + for (const commonGitDirectoryName of ['repo.git', 'separate-git']) { + test(`${commonGitDirectoryName} common Git directory is detected`, () => { + const worktreeRoot = path.join(root, 'worktree'); + writeLinkedWorktreeMetadata(worktreeRoot, path.join(root, commonGitDirectoryName)); + + assert.strictEqual(tryGetLinkedWorktreeRoot(worktreeRoot), fs.realpathSync.native(worktreeRoot)); + }); + } + + test('relative gitdir worktree is detected', () => { + writeLinkedWorktreeMetadata(root, path.join(root, 'primary', '.git'), 'feature', true); + + assert.strictEqual(tryGetLinkedWorktreeRoot(root), fs.realpathSync.native(root)); + }); + + test('checkout alias resolves relative metadata from the canonical checkout', function () { + const worktreeRoot = path.join(root, 'physical', 'checkouts', 'feature'); + writeLinkedWorktreeMetadata( + worktreeRoot, + path.join(root, 'physical', 'primary', '.git'), + 'feature', + true); + const aliasRoot = path.join(root, 'aliases', 'feature'); + fs.mkdirSync(path.dirname(aliasRoot), { recursive: true }); + try { + fs.symlinkSync(worktreeRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir'); + } + catch { + this.skip(); + return; + } + + assert.strictEqual(tryGetLinkedWorktreeRoot(aliasRoot), fs.realpathSync.native(worktreeRoot)); + }); + + test('admin alias resolves relative back-pointer from the canonical admin directory', function () { + const worktreeRoot = path.join(root, 'checkout'); + const adminDirectory = path.join(root, 'physical', 'primary', '.git', 'worktrees', 'feature'); + fs.mkdirSync(adminDirectory, { recursive: true }); + const aliasDirectory = path.join(root, 'aliases', 'admin'); + fs.mkdirSync(path.dirname(aliasDirectory), { recursive: true }); + try { + fs.symlinkSync(adminDirectory, aliasDirectory, process.platform === 'win32' ? 'junction' : 'dir'); + } + catch { + this.skip(); + return; + } + + const gitFilePath = writeGitDirFile(worktreeRoot, aliasDirectory); + fs.writeFileSync( + path.join(adminDirectory, 'gitdir'), + `${path.relative(adminDirectory, gitFilePath)}\n`); + + assert.strictEqual(tryGetLinkedWorktreeRoot(worktreeRoot), fs.realpathSync.native(worktreeRoot)); + }); + + test('gitdir with a trailing directory separator is detected', () => { + const adminDirectory = writeLinkedWorktreeMetadata(root, path.join(root, 'primary', '.git')); + fs.writeFileSync(path.join(root, '.git'), `gitdir: ${adminDirectory}${path.sep}\n`); + + assert.strictEqual(tryGetLinkedWorktreeRoot(root), fs.realpathSync.native(root)); + }); + + test('uppercase WORKTREES admin directory uses platform casing', () => { + const worktreeRoot = path.join(root, 'worktree'); + const adminDirectory = path.join(root, 'primary', '.git', 'WORKTREES', 'feature'); + const gitFilePath = writeGitDirFile(worktreeRoot, adminDirectory); + fs.writeFileSync(path.join(adminDirectory, 'gitdir'), `${gitFilePath}\n`); + + assert.strictEqual( + tryGetLinkedWorktreeRoot(worktreeRoot), + process.platform === 'win32' ? fs.realpathSync.native(worktreeRoot) : undefined); + }); + + test('case-variant back-pointer uses filesystem identity', () => { + const adminDirectory = writeLinkedWorktreeMetadata(root, path.join(root, 'primary', '.git')); + const gitFilePath = path.join(root, '.git'); + const caseVariantGitFile = path.join(root, '.GIT'); + fs.writeFileSync(path.join(adminDirectory, 'gitdir'), `${caseVariantGitFile}\n`); + const caseVariantIsGitFile = fs.existsSync(caseVariantGitFile) + && fs.realpathSync.native(caseVariantGitFile) === fs.realpathSync.native(gitFilePath); + + assert.strictEqual( + tryGetLinkedWorktreeRoot(root), + caseVariantIsGitFile ? fs.realpathSync.native(root) : undefined); + }); + + test('symlink alias back-pointer is detected', function () { + const worktreeRoot = path.join(root, 'worktree'); + const adminDirectory = writeLinkedWorktreeMetadata(worktreeRoot, path.join(root, 'primary', '.git')); + const aliasRoot = path.join(root, 'worktree-alias'); + try { + fs.symlinkSync(worktreeRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir'); + } + catch { + this.skip(); + return; + } + + fs.writeFileSync(path.join(adminDirectory, 'gitdir'), `${path.join(aliasRoot, '.git')}\n`); + + assert.strictEqual(tryGetLinkedWorktreeRoot(worktreeRoot), fs.realpathSync.native(worktreeRoot)); + }); + + test('submodule inside a linked worktree is not detected', () => { + const worktreeRoot = path.join(root, 'worktree'); + const adminDirectory = writeLinkedWorktreeMetadata(worktreeRoot, path.join(root, 'primary', '.git')); + const submoduleRoot = path.join(worktreeRoot, 'extern', 'dep'); + writeGitDirFile(submoduleRoot, path.join(adminDirectory, 'modules', 'dep')); + + assert.strictEqual(tryGetLinkedWorktreeRoot(path.join(submoduleRoot, 'AppHost.csproj')), undefined); + }); + + test('reciprocal back-pointer outside worktrees is not detected', () => { + const worktreeRoot = path.join(root, 'worktree'); + const adminDirectory = path.join(root, 'primary', '.git', 'modules', 'dep'); + const gitFilePath = writeGitDirFile(worktreeRoot, adminDirectory); + fs.writeFileSync(path.join(adminDirectory, 'gitdir'), `${gitFilePath}\n`); + + assert.strictEqual(tryGetLinkedWorktreeRoot(worktreeRoot), undefined); + }); + + test('back-pointer to a different checkout is not detected', () => { + const commonGitDirectory = path.join(root, 'primary', '.git'); + const worktreeRoot = path.join(root, 'worktree'); + const adminDirectory = writeLinkedWorktreeMetadata(worktreeRoot, commonGitDirectory); + const otherWorktreeRoot = path.join(root, 'other-worktree'); + const otherGitFile = writeGitDirFile( + otherWorktreeRoot, + path.join(commonGitDirectory, 'worktrees', 'other')); + fs.writeFileSync(path.join(adminDirectory, 'gitdir'), `${otherGitFile}\n`); + + assert.strictEqual(tryGetLinkedWorktreeRoot(worktreeRoot), undefined); + }); + + test('decoy worktree pointer without a back-pointer is not detected', () => { + const worktreeRoot = path.join(root, 'worktree'); + writeGitDirFile(worktreeRoot, path.join(root, 'primary', '.git', 'worktrees', 'stale')); + + assert.strictEqual(tryGetLinkedWorktreeRoot(worktreeRoot), undefined); + }); + + test('submodule .git file is not a linked worktree', () => { + fs.mkdirSync(path.join(root, '.git')); + const submoduleRoot = path.join(root, 'extern', 'dep'); + fs.mkdirSync(submoduleRoot, { recursive: true }); + writeGitDirFile(submoduleRoot, path.join(root, '.git', 'modules', 'dep')); + + assert.strictEqual(tryGetLinkedWorktreeRoot(path.join(submoduleRoot, 'AppHost.csproj')), undefined); + assert.strictEqual(resolveIsolated(undefined, submoduleRoot), false); + }); + + test('ensureIsolatedCliArg leaves args unchanged when isolation is unspecified', () => { + assert.deepStrictEqual(ensureIsolatedCliArg(undefined, undefined), undefined); + assert.deepStrictEqual(ensureIsolatedCliArg(['--no-build'], undefined), ['--no-build']); + }); + + test('ensureIsolatedCliArg inserts the isolation value before --', () => { + assert.deepStrictEqual(ensureIsolatedCliArg(undefined, false), ['--isolated', 'false']); + assert.deepStrictEqual(ensureIsolatedCliArg(undefined, true), ['--isolated']); + assert.deepStrictEqual(ensureIsolatedCliArg(['--no-build'], true), ['--no-build', '--isolated']); + assert.deepStrictEqual( + ensureIsolatedCliArg(['--no-build', '--', '--custom'], true), + ['--no-build', '--isolated', '--', '--custom']); + assert.deepStrictEqual( + ensureIsolatedCliArg(['--no-build', '--', '--custom'], false), + ['--no-build', '--isolated', 'false', '--', '--custom']); + }); + + test('ensureIsolatedCliArg does not duplicate an existing isolation option', () => { + assert.deepStrictEqual( + ensureIsolatedCliArg(['--isolated', '--no-build'], true), + ['--isolated', '--no-build']); + assert.deepStrictEqual( + ensureIsolatedCliArg(['--isolated', 'false', '--no-build'], true), + ['--isolated', 'false', '--no-build']); + assert.deepStrictEqual( + ensureIsolatedCliArg(['--isolated=false', '--no-build'], true), + ['--isolated=false', '--no-build']); + }); +}); diff --git a/extension/src/test/helpers/processArguments.ts b/extension/src/test/helpers/processArguments.ts new file mode 100644 index 00000000000..199cf157cba --- /dev/null +++ b/extension/src/test/helpers/processArguments.ts @@ -0,0 +1,89 @@ +import * as assert from 'assert'; +import { getCmdShimSpawnCommand, shouldWrapWithCmd } from '../../utils/cmdShimCommand'; + +export function commandLineArgumentEquals(actual: string, expected: string, platform = process.platform): boolean { + return platform === 'win32' + ? actual.toLowerCase() === expected.toLowerCase() + : actual === expected; +} + +export function assertLinkedAppHostCliLaunch( + argumentsList: readonly string[], + appHostPath: string, + cliPath: string, + platform = process.platform +): void { + const formattedArguments = JSON.stringify(argumentsList); + assert.ok( + argumentsList.length > 0 && commandLineArgumentEquals(argumentsList[0], cliPath, platform), + `Expected the current E2E CLI '${cliPath}' as argv[0] in: ${formattedArguments}`); + + const runIndex = argumentsList.indexOf('run', 1); + assert.ok(runIndex > 0, `Expected exact 'run' after the CLI path in: ${formattedArguments}`); + const separatorIndex = argumentsList.indexOf('--', runIndex + 1); + const rootArgumentsEnd = separatorIndex >= 0 ? separatorIndex : argumentsList.length; + + const isolatedIndex = argumentsList.indexOf('--isolated', runIndex + 1); + assert.ok(isolatedIndex > runIndex && isolatedIndex < rootArgumentsEnd, `Expected exact '--isolated' root option after 'run' in: ${formattedArguments}`); + assert.strictEqual( + argumentsList.slice(runIndex + 1, rootArgumentsEnd).some(argument => argument === '--isolated=false') || + argumentsList[isolatedIndex + 1]?.toLowerCase() === 'false', + false, + `Expected inferred isolation to use only the true-form --isolated switch: ${formattedArguments}`); + + const startDebugSessionIndex = argumentsList.indexOf('--start-debug-session', isolatedIndex + 1); + assert.ok(startDebugSessionIndex > isolatedIndex && startDebugSessionIndex < rootArgumentsEnd, `Expected exact '--start-debug-session' root option after '--isolated' in: ${formattedArguments}`); + + const appHostIndex = argumentsList.indexOf('--apphost', startDebugSessionIndex + 1); + assert.ok(appHostIndex > startDebugSessionIndex && appHostIndex < rootArgumentsEnd, `Expected exact '--apphost' root option after '--start-debug-session' in: ${formattedArguments}`); + assert.ok( + appHostIndex + 1 < argumentsList.length && + commandLineArgumentEquals(argumentsList[appHostIndex + 1], appHostPath, platform), + `Expected exact --apphost path '${appHostPath}' immediately after '--apphost' in: ${formattedArguments}`); +} + +export function assertExactLinkedAppHostCliLaunch( + argumentsList: readonly string[], + appHostPath: string, + cliPath: string, + appHostArguments: readonly string[], + platform = process.platform +): void { + const expectedArguments = getExpectedLinkedAppHostCliProcessArguments(cliPath, appHostPath, appHostArguments); + const pathArgumentIndexes = shouldWrapWithCmd(cliPath) + ? [0] + : [0, 6]; + const argumentsMatch = argumentsList.length === expectedArguments.length && + argumentsList.every((argument, index) => + pathArgumentIndexes.includes(index) + ? commandLineArgumentEquals(argument, expectedArguments[index], platform) + : argument === expectedArguments[index]); + + assert.ok( + argumentsMatch, + `Expected exact Aspire CLI argv ${JSON.stringify(expectedArguments)}, got ${JSON.stringify(argumentsList)}.`); +} + +export function getExpectedLinkedAppHostCliProcessArguments( + cliPath: string, + appHostPath: string, + appHostArguments: readonly string[] +): string[] { + const cliArguments = [ + 'run', + '--isolated', + '--start-debug-session', + '--nologo', + '--apphost', + appHostPath, + '--', + ...appHostArguments, + ]; + + if (shouldWrapWithCmd(cliPath)) { + const spawnCommand = getCmdShimSpawnCommand(cliPath, cliArguments); + return [spawnCommand.command, ...spawnCommand.args]; + } + + return [cliPath, ...cliArguments]; +} diff --git a/extension/src/test/javaAppHostParser.test.ts b/extension/src/test/javaAppHostParser.test.ts new file mode 100644 index 00000000000..2b0ad3f1de1 --- /dev/null +++ b/extension/src/test/javaAppHostParser.test.ts @@ -0,0 +1,207 @@ +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { getParserForDocument } from '../editor/parsers/AppHostResourceParser'; +import '../editor/parsers/javaAppHostParser'; +import { createMockDocument } from './testHelpers'; + +// Both playground AppHosts are JEP 512 implicitly declared classes, but they differ in ways the +// parser has to absorb: `var` vs explicit types, and `CreateBuilder()` vs `CreateBuilder(args)`. +const implicitClassAppHost = [ + 'import aspire.*;', + '', + 'void main() throws Exception {', + ' var builder = DistributedApplication.CreateBuilder();', + '', + ' var catalog = builder.addSpringBootApp("catalog", "./catalog")', + ' .withOtelAgentDefaultPath()', + ' .withExternalHttpEndpoints();', + '', + ' builder.addJavaAppWithJar("worker", "./worker", "target/worker.jar");', + '', + ' builder.build().run();', + '}', +].join('\n'); + +const explicitClassAppHost = [ + 'import aspire.*;', + '', + 'public class AppHost {', + ' public static void main(String[] args) throws Exception {', + ' IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);', + ' NodeAppResource app = builder.addNodeApp("app", "./api", "src/index.ts");', + ' builder.build().run();', + ' }', + '}', +].join('\n'); + +function javaDoc(content: string): vscode.TextDocument { + return createMockDocument(content, '/repo/AppHost.java'); +} + +// Tests run from out/test, so the repository root is four levels up (out/test -> out -> extension -> repo). +const repositoryRoot = path.resolve(__dirname, '..', '..', '..'); + +const playgroundAppHostPaths = [ + path.join(repositoryRoot, 'playground', 'JavaAppHost', 'AppHost.java'), + path.join(repositoryRoot, 'playground', 'JavaSpringBoot', 'JavaSpringBoot.AppHost.Java', 'AppHost.java'), +]; + +suite('Java AppHost parser', () => { + test('recognises an implicitly declared AppHost', async () => { + const parser = await getParserForDocument(javaDoc(implicitClassAppHost)); + + assert.ok(parser, 'expected a parser for a Java AppHost'); + assert.deepStrictEqual(parser.getSupportedExtensions(), ['.java']); + }); + + test('recognises a conventional class AppHost, which is the Maven and Gradle project shape', async () => { + const parser = await getParserForDocument(javaDoc(explicitClassAppHost)); + + assert.ok(parser, 'expected a parser for a class-based Java AppHost'); + }); + + test('does not claim a Java file that never builds an application', async () => { + const parser = await getParserForDocument(javaDoc('class Helper {\n void main() {\n }\n}')); + + assert.strictEqual(parser, undefined); + }); + + test('recognises a fully qualified CreateBuilder call, which needs no import', async () => { + // Writing the type out in full is ordinary Java, and the single-file AppHost shape invites it + // because it avoids the wildcard import entirely. The C# parser already matches on the trailing + // segment for the same reason. + const parser = await getParserForDocument(javaDoc([ + 'void main(String[] args) throws Exception {', + ' var builder = aspire.DistributedApplication.CreateBuilder(args);', + ' builder.addSpringBootApp("catalog", "./catalog");', + ' builder.build().run();', + '}', + ].join('\n'))); + + assert.ok(parser, 'expected a parser for a fully qualified Java AppHost'); + }); + + test('still refuses an unrelated CreateBuilder on a different type', async () => { + // Matching the trailing segment must not degrade into matching the method name alone. + const parser = await getParserForDocument(javaDoc([ + 'void main() {', + ' var builder = ReportBuilder.CreateBuilder();', + ' builder.addSpringBootApp("catalog", "./catalog");', + '}', + ].join('\n'))); + + assert.strictEqual(parser, undefined); + }); + + test('extracts every resource with its name, method and anchor line', async () => { + const document = javaDoc(implicitClassAppHost); + const parser = await getParserForDocument(document); + const resources = await parser!.parseResources(document); + + assert.deepStrictEqual(resources.map(r => [r.name, r.methodName, r.kind, r.statementStartLine]), [ + ['catalog', 'addSpringBootApp', 'resource', 5], + ['worker', 'addJavaAppWithJar', 'resource', 9], + ]); + }); + + test('anchors a multi-line fluent chain on the declaration, not the chained call', async () => { + const document = javaDoc(implicitClassAppHost); + const parser = await getParserForDocument(document); + const resources = await parser!.parseResources(document); + + assert.strictEqual(resources[0].range.start.line, 5, 'the range starts on the addSpringBootApp line'); + assert.strictEqual(resources[0].statementStartLine, 5, 'and the statement starts on the var declaration'); + }); + + test('extracts resources declared with an explicit type', async () => { + const document = javaDoc(explicitClassAppHost); + const parser = await getParserForDocument(document); + const resources = await parser!.parseResources(document); + + assert.deepStrictEqual(resources.map(r => r.name), ['app']); + }); + + test('classifies addStep as a pipeline step', async () => { + const document = javaDoc('void main() {\n var builder = DistributedApplication.CreateBuilder();\n builder.addStep("deploy");\n}'); + const parser = await getParserForDocument(document); + const resources = await parser!.parseResources(document); + + assert.deepStrictEqual(resources.map(r => [r.name, r.kind]), [['deploy', 'pipelineStep']]); + }); + + test('ignores commented-out and quoted resource calls', async () => { + const document = javaDoc([ + 'void main() {', + ' var builder = DistributedApplication.CreateBuilder();', + ' // builder.addRedis("commented");', + ' /* builder.addPostgres("blocked"); */', + ' var sample = "builder.addRedis(\\"quoted\\")";', + ' builder.addRedis("real");', + '}', + ].join('\n')); + const parser = await getParserForDocument(document); + const resources = await parser!.parseResources(document); + + assert.deepStrictEqual(resources.map(r => r.name), ['real']); + }); + + test('ignores an add call whose first argument is not a literal name', async () => { + const document = javaDoc('void main() {\n var builder = DistributedApplication.CreateBuilder();\n builder.addRedis(nameVariable);\n}'); + const parser = await getParserForDocument(document); + const resources = await parser!.parseResources(document); + + assert.deepStrictEqual(resources, []); + }); + + test('finds the builder statement line for both AppHost shapes', async () => { + const implicitDoc = javaDoc(implicitClassAppHost); + const explicitDoc = javaDoc(explicitClassAppHost); + + assert.strictEqual(await (await getParserForDocument(implicitDoc))!.findBuilderStatementLine!(implicitDoc), 3); + assert.strictEqual(await (await getParserForDocument(explicitDoc))!.findBuilderStatementLine!(explicitDoc), 4); + }); + + test('finds the entry point line for both AppHost shapes', async () => { + const implicitDoc = javaDoc(implicitClassAppHost); + const explicitDoc = javaDoc(explicitClassAppHost); + + assert.strictEqual(await (await getParserForDocument(implicitDoc))!.findAppHostEntryPointLine!(implicitDoc), 2); + assert.strictEqual(await (await getParserForDocument(explicitDoc))!.findAppHostEntryPointLine!(explicitDoc), 3); + }); + + // The tests above parse hand-written approximations of the playground AppHosts. Those stay + // readable, but they drift: they do not exercise array initializers, `new Options().path(...)` + // arguments, or comments interleaved through a builder chain. When the real files stop being + // recognised, every editor feature keyed off the parser disappears silently — the entry point + // warning, the resource lenses, and the dashboard links — because a missing parser is + // indistinguishable from an ordinary Java file. So parse the files we actually ship. + for (const appHostPath of playgroundAppHostPaths) { + test(`recognises the ${path.basename(path.dirname(appHostPath))} playground AppHost`, async () => { + const document = createMockDocument(await fs.readFile(appHostPath, 'utf8'), appHostPath); + const parser = await getParserForDocument(document); + + assert.ok(parser, `expected ${appHostPath} to be recognised as a Java AppHost`); + assert.notStrictEqual( + await parser.findBuilderStatementLine!(document), + undefined, + 'the builder statement anchors the dashboard and log lenses'); + assert.notStrictEqual( + await parser.findAppHostEntryPointLine!(document), + undefined, + 'the entry point anchors the warning that the Java Run and Debug actions bypass Aspire'); + }); + } + + test('filters offsets that fall inside comments and strings', async () => { + const content = 'void main() {\n var builder = DistributedApplication.CreateBuilder();\n // addRedis\n builder.addRedis("real");\n}'; + const document = javaDoc(content); + const parser = await getParserForDocument(document); + + const commentOffset = content.indexOf('addRedis'); + const codeOffset = content.indexOf('addRedis("real")'); + + assert.deepStrictEqual(await parser!.filterActiveOffsets!(document, [commentOffset, codeOffset]), [codeOffset]); + }); +}); diff --git a/extension/src/test/javaDebugger.test.ts b/extension/src/test/javaDebugger.test.ts new file mode 100644 index 00000000000..1d14f786f77 --- /dev/null +++ b/extension/src/test/javaDebugger.test.ts @@ -0,0 +1,635 @@ +import * as path from 'path'; +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { getSupportedCapabilities } from '../capabilities'; +import { AspireDebugSession, getLoggableDebugConfiguration } from '../debugger/AspireDebugSession'; +import * as debuggerExtensionsModule from '../debugger/debuggerExtensions'; +import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions'; +import { javaDebuggerExtension, parseJavaAppHostCommand, resolveJavaClassPaths } from '../debugger/languages/java'; +import { javaAppHostCommandNotRecognized, javaDebuggerExtensionNotInstalled } from '../loc/strings'; +import { AspireResourceExtendedDebugConfiguration, GoLaunchConfiguration, JavaLaunchConfiguration } from '../dcp/types'; + +suite('Java Debugger Extension Tests', () => { + const fakeAspireDebugSession = {} as AspireDebugSession; + + teardown(() => sinon.restore()); + + test('advertises Java support when both Java extensions are installed', () => { + stubInstalledExtensions(['redhat.java', 'vscjava.vscode-java-debug']); + + const capabilities = getSupportedCapabilities(); + assert.ok(capabilities.includes('java')); + assert.ok(capabilities.includes('vscjava.vscode-java-debug')); + assert.ok(getResourceDebuggerExtensions().some(extension => extension.resourceType === 'java')); + }); + + test('does not advertise Java support when only the debug adapter is installed', () => { + stubInstalledExtensions(['vscjava.vscode-java-debug']); + + const capabilities = getSupportedCapabilities(); + assert.ok(!capabilities.includes('java')); + assert.ok(!capabilities.includes('vscjava.vscode-java-debug')); + assert.ok(!getResourceDebuggerExtensions().some(extension => extension.resourceType === 'java')); + }); + + test('does not advertise Java support when only the language server is installed', () => { + stubInstalledExtensions(['redhat.java']); + + const capabilities = getSupportedCapabilities(); + assert.ok(!capabilities.includes('java')); + assert.ok(!getResourceDebuggerExtensions().some(extension => extension.resourceType === 'java')); + }); + + test('configures the VS Code Java debugger from the launch configuration', async () => { + const launchConfig: JavaLaunchConfiguration = { + type: 'java', + request: 'launch', + working_directory: '/workspace/api', + main_class: 'com.example.api.Application', + build_tool: 'maven' + }; + const debugConfig = createDebugConfig(); + stubInstalledExtensions([]); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + launchConfig, + ['--server.port=8080'], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.strictEqual(debugConfig.type, 'java'); + assert.strictEqual(debugConfig.request, 'launch'); + assert.strictEqual(debugConfig.cwd, '/workspace/api'); + assert.strictEqual(debugConfig.mainClass, 'com.example.api.Application'); + assert.deepStrictEqual(debugConfig.args, ['--server.port=8080']); + assert.strictEqual(debugConfig.noDebug, false); + }); + + test('sets noDebug when launch option disables debugging', async () => { + const debugConfig = createDebugConfig(); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig(), + [], + [], + { debug: false, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.strictEqual(debugConfig.noDebug, true); + }); + + test('rejects an attach request instead of sending the adapter a configuration it cannot use', async () => { + // An attach session needs a host and a port, and nothing in the wire schema carries them, so + // an attach configuration built here would always be rejected by vscjava.vscode-java-debug + // with a message that points at the adapter rather than at the app host. Failing here instead + // names the real problem. + const debugConfig = createDebugConfig(); + + await assert.rejects( + () => javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ request: 'attach' }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig), + /attach/i); + }); + + test('defaults to a launch request when the launch configuration omits one', async () => { + const debugConfig = createDebugConfig({ request: 'attach' }); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ request: undefined }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.strictEqual(debugConfig.request, 'launch'); + }); + + test('puts a prebuilt JAR on the classpath rather than passing it as the main class', async () => { + const debugConfig = createDebugConfig(); + + // The adapter documents mainClass as a fully qualified class name or a .java path, so it + // never opens an archive. The app host therefore reads Main-Class from the manifest itself + // and sends the JAR as a classpath entry; passing the archive as mainClass left the adapter + // unable to resolve an entry point at all. + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ + main_class: 'com.example.api.Application', + class_paths: ['/workspace/api/target/api-1.0.0.jar'] + }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.strictEqual(debugConfig.mainClass, 'com.example.api.Application'); + assert.deepStrictEqual(debugConfig.classPaths, ['/workspace/api/target/api-1.0.0.jar']); + }); + + test('omits classPaths so the adapter resolves them from the project when none are reported', async () => { + const debugConfig = createDebugConfig(); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ class_paths: undefined }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.ok(!('classPaths' in debugConfig), 'classPaths must stay unset so the Java debugger resolves the project classpath.'); + }); + + test('omits mainClass so the adapter resolves it when the app host does not report one', async () => { + const debugConfig = createDebugConfig(); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ main_class: undefined }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.ok(!('mainClass' in debugConfig), 'mainClass must stay unset so the Java debugger resolves it from the project.'); + }); + + test('scopes entry point resolution to the reported project when no main class is known', async () => { + // Without projectName the adapter searches every project in the workspace, so a solution with + // several Java resources makes it prompt the user to pick a main class on every launch. + const debugConfig = createDebugConfig(); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ main_class: undefined, project_name: 'catalog' }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.strictEqual(debugConfig.projectName, 'catalog'); + assert.ok(!('mainClass' in debugConfig), 'mainClass must stay unset so the adapter resolves it within the named project.'); + }); + + test('scopes a known main class to the reported project so it cannot resolve ambiguously', async () => { + // Regression: with mainClass alone the adapter searches every project in the workspace and + // fails with "Main class 'com.example.catalog.CatalogApplication' isn't unique in the + // workspace" as soon as the class is visible through two projects, which happens whenever a + // directory is covered both by its own build file and by another project's source root. + const debugConfig = createDebugConfig(); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ main_class: 'com.example.api.Application', project_name: 'catalog' }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.strictEqual(debugConfig.mainClass, 'com.example.api.Application'); + assert.strictEqual(debugConfig.projectName, 'catalog'); + }); + + test('forwards application arguments and defaults them to an empty array', async () => { + const withArgs = createDebugConfig(); + const withoutArgs = createDebugConfig(); + const launchOptions = { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }; + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig(), ['--spring.profiles.active=dev', 'extra'], [], launchOptions, withArgs); + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig(), undefined, [], launchOptions, withoutArgs); + + assert.deepStrictEqual(withArgs.args, ['--spring.profiles.active=dev', 'extra']); + assert.deepStrictEqual(withoutArgs.args, []); + }); + + test('preserves the merged environment instead of replacing it with the resource variables', async () => { + // prepareDebugSession already merged the inherited process environment with the resource's + // variables. Overwriting env here would drop PATH and JAVA_HOME, so the JVM could not start. + const debugConfig = createDebugConfig({ + env: { PATH: '/usr/bin', JAVA_HOME: '/opt/jdk-21', OTEL_SERVICE_NAME: 'api' } + }); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig(), + [], + [{ name: 'OTEL_SERVICE_NAME', value: 'api' }, { name: 'ASPIRE_RESOURCE', value: 'api' }], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.deepStrictEqual(debugConfig.env, { PATH: '/usr/bin', JAVA_HOME: '/opt/jdk-21', OTEL_SERVICE_NAME: 'api' }); + }); + + test('launches when the Java language server is not installed', async () => { + stubInstalledExtensions([]); + const debugConfig = createDebugConfig(); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ build_tool: 'gradle' }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.strictEqual(debugConfig.type, 'java'); + assert.strictEqual(debugConfig.mainClass, 'com.example.api.Application'); + }); + + test('launches when the project configuration refresh command rejects', async () => { + stubJavaLanguageServer(true); + const executeCommand = sinon.stub(vscode.commands, 'executeCommand').rejects(new Error("command 'java.execute.workspaceCommand' not found")); + const debugConfig = createDebugConfig(); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ build_tool: 'maven' }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.ok(executeCommand.called, 'The refresh should be attempted when the language server reports ready.'); + assert.strictEqual(debugConfig.type, 'java'); + assert.strictEqual(debugConfig.cwd, '/workspace/api'); + }); + + test('skips the project configuration refresh when the language server is not ready', async () => { + stubJavaLanguageServer(false); + const executeCommand = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); + const debugConfig = createDebugConfig(); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ build_tool: 'maven' }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.strictEqual(executeCommand.called, false); + assert.strictEqual(debugConfig.type, 'java'); + }); + + test('skips the project configuration refresh when the resource has no build tool', async () => { + stubJavaLanguageServer(true); + const executeCommand = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); + const debugConfig = createDebugConfig(); + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ build_tool: undefined }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + assert.strictEqual(executeCommand.called, false); + assert.strictEqual(debugConfig.type, 'java'); + }); + + test('stops waiting for the language server when the debug session is already stopping', async () => { + // The readiness wait is the only slow step on the launch path. Without an abort it runs for the + // full timeout, so a stop issued while a Java resource is starting is held up for ~30 seconds. + sinon.stub(vscode.extensions, 'getExtension').callsFake((extensionId: string) => { + if (extensionId !== 'redhat.java') { + return undefined; + } + + return { + id: extensionId, + isActive: true, + exports: { serverMode: 'Standard', serverReady: () => new Promise(() => { }) } + } as unknown as vscode.Extension; + }); + + const executeCommand = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); + const debugConfig = createDebugConfig(); + const stoppingSession = { isStopAttemptInProgress: true } as AspireDebugSession; + + const startedAt = Date.now(); + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig({ build_tool: 'maven' }), + [], + [], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: stoppingSession }, + debugConfig); + + const elapsed = Date.now() - startedAt; + assert.ok(elapsed < 5000, `expected the readiness wait to be abandoned promptly, but it took ${elapsed}ms`); + assert.strictEqual(executeCommand.called, false); + assert.strictEqual(debugConfig.type, 'java'); + }); + + test('throws for a launch configuration that is not java', () => { + const goLaunchConfig: GoLaunchConfiguration = { type: 'go', program: '/workspace/api' }; + + assert.throws( + () => javaDebuggerExtension.getProjectFile(goLaunchConfig), + /Invalid launch configuration/); + }); + + test('returns the working directory as the project file', () => { + assert.strictEqual(javaDebuggerExtension.getProjectFile(createJavaLaunchConfig()), '/workspace/api'); + }); + + test('names the session after the main class when it is a class name', () => { + assert.strictEqual(javaDebuggerExtension.getDisplayName(createJavaLaunchConfig()), 'Java: com.example.api.Application'); + }); + + test('names the session with a workspace relative path rather than a file URI', () => { + sinon.stub(vscode.workspace, 'asRelativePath').callsFake(() => 'api'); + + const fromSourceFile = javaDebuggerExtension.getDisplayName(createJavaLaunchConfig({ main_class: '/workspace/api/src/main/java/Application.java' })); + const withoutMainClass = javaDebuggerExtension.getDisplayName(createJavaLaunchConfig({ main_class: undefined })); + + assert.strictEqual(fromSourceFile, 'Java: api'); + assert.strictEqual(withoutMainClass, 'Java: api'); + }); + + test('falls back to the Java label when there is nothing to name the session after', () => { + const emptyLaunchConfig: JavaLaunchConfiguration = { type: 'java' }; + + assert.strictEqual(javaDebuggerExtension.getDisplayName(emptyLaunchConfig), 'Java'); + }); + + test('supports Java source files', () => { + assert.deepStrictEqual(javaDebuggerExtension.getSupportedFileTypes(), ['.java']); + }); + + test('stops a Java AppHost launch with install guidance when the debug adapter is not installed', async () => { + // startAppHost builds the debugger descriptor directly instead of going through + // getResourceDebuggerExtensions, so without its own gate VS Code fails the session with the raw + // "configured debug type is not supported" rather than naming the extension to install. + stubInstalledExtensions([]); + const showErrorMessage = sinon.stub(vscode.window, 'showErrorMessage'); + sinon.stub(vscode.debug, 'stopDebugging').resolves(); + const createDebugSessionConfiguration = sinon.stub(debuggerExtensionsModule, 'createDebugSessionConfiguration'); + + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: '/workspace/AppHost.java', + command: 'run', + }, + }; + + const debugSession = new AspireDebugSession( + parentDebugSession as unknown as vscode.DebugSession, + {} as any, + {} as any, + {} as any, + () => { }); + sinon.stub(debugSession, 'createDebugAdapterTrackerCore'); + + await debugSession.startAppHost( + '/workspace/AppHost.java', + ['java', '-cp', 'target/classes', 'AppHost'], + [], + true, + { forceBuild: false }); + + sinon.assert.notCalled(createDebugSessionConfiguration); + sinon.assert.calledOnce(showErrorMessage); + assert.strictEqual( + showErrorMessage.firstCall.args[0], + javaDebuggerExtensionNotInstalled(javaDebuggerExtension.extensionId!)); + }); + + test('stops a Java AppHost launch when the command cannot be parsed into a launch configuration', async () => { + // Guessing here would start a JVM with the wrong arguments, so an unrecognised command has to + // fail loudly rather than be approximated. + stubInstalledExtensions(['redhat.java', 'vscjava.vscode-java-debug']); + const showErrorMessage = sinon.stub(vscode.window, 'showErrorMessage'); + sinon.stub(vscode.debug, 'stopDebugging').resolves(); + const createDebugSessionConfiguration = sinon.stub(debuggerExtensionsModule, 'createDebugSessionConfiguration'); + + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + configuration: { + type: 'aspire', + request: 'launch', + name: 'Aspire', + program: '/workspace/AppHost.java', + command: 'run', + }, + }; + + const debugSession = new AspireDebugSession( + parentDebugSession as unknown as vscode.DebugSession, + {} as any, + {} as any, + {} as any, + () => { }); + sinon.stub(debugSession, 'createDebugAdapterTrackerCore'); + + await debugSession.startAppHost('/workspace/AppHost.java', ['java', '-Xmx512m'], [], true, { forceBuild: false }); + + sinon.assert.notCalled(createDebugSessionConfiguration); + sinon.assert.calledOnce(showErrorMessage); + assert.strictEqual( + showErrorMessage.firstCall.args[0], + javaAppHostCommandNotRecognized()); + }); + + test('always redacts resolved Java environments from persistent configuration logs', async () => { + const credential = 'resolved-environment-credential'; + const debugConfig = createDebugConfig(); + debugConfig.env = { + PRIVATE_TOKEN: credential, + OTEL_EXPORTER_OTLP_HEADERS: `x-otlp-api-key=${credential}`, + }; + + await javaDebuggerExtension.createDebugSessionConfigurationCallback!( + createJavaLaunchConfig(), + [], + [{ name: 'PRIVATE_TOKEN', value: credential }], + { debug: true, runId: '1', debugSessionId: '1', isApphost: false, debugSession: fakeAspireDebugSession }, + debugConfig); + + // `aspire.enableDebugConfigEnvironmentLogging` defaults to true, so `true` here is the default + // path rather than an opt-in, and the resolved environment must still be withheld. + const loggableConfig = getLoggableDebugConfiguration(debugConfig, true); + assert.strictEqual(loggableConfig.env, ''); + assert.ok(!JSON.stringify(loggableConfig).includes(credential)); + }); +}); + +function createJavaLaunchConfig(overrides: Partial = {}): JavaLaunchConfiguration { + return { + type: 'java', + request: 'launch', + working_directory: '/workspace/api', + main_class: 'com.example.api.Application', + build_tool: 'maven', + ...overrides + }; +} + +function createDebugConfig(overrides: Partial = {}): AspireResourceExtendedDebugConfiguration { + return { + runId: '1', + debugSessionId: '1', + type: 'java', + name: 'Java', + request: 'launch', + program: '/workspace/api', + args: [], + ...overrides + }; +} + +function stubInstalledExtensions(extensionIds: string[]): void { + sinon.stub(vscode.extensions, 'getExtension').callsFake((extensionId: string) => { + return extensionIds.includes(extensionId) ? { id: extensionId } as vscode.Extension : undefined; + }); +} + +// Stands in for the redhat.java extension API that java.ts waits on before refreshing the classpath. +function stubJavaLanguageServer(serverReady: boolean): void { + sinon.stub(vscode.extensions, 'getExtension').callsFake((extensionId: string) => { + if (extensionId !== 'redhat.java') { + return undefined; + } + + return { + id: extensionId, + isActive: true, + exports: { serverMode: 'Standard', serverReady: async () => serverReady } + } as unknown as vscode.Extension; + }); +} + +suite('Java AppHost Command Parsing Tests', () => { + // The CLI launches every Java AppHost toolchain as a plain JVM invocation, so the extension can + // recover the main class and classpath from the command line rather than re-deriving the layout. + test('parses the javac toolchain command', () => { + const parsed = parseJavaAppHostCommand(['java', '-cp', '.java-build', 'AppHost', '--operation', 'run']); + + assert.deepStrictEqual(parsed, { + mainClass: 'AppHost', + classPaths: ['.java-build'], + vmArgs: [], + appHostArgs: ['--operation', 'run'] + }); + }); + + test('splits a multi-entry classpath on the platform delimiter', () => { + const classPath = ['target/classes', 'target/aspire-deps/*'].join(path.delimiter); + const parsed = parseJavaAppHostCommand(['java', '-cp', classPath, 'AppHost']); + + assert.deepStrictEqual(parsed?.classPaths, ['target/classes', 'target/aspire-deps/*']); + assert.deepStrictEqual(parsed?.appHostArgs, []); + }); + + test('accepts the -classpath and --class-path aliases', () => { + for (const option of ['-classpath', '--class-path']) { + const parsed = parseJavaAppHostCommand(['java', option, 'build/classes/java/main', 'AppHost']); + assert.deepStrictEqual(parsed?.classPaths, ['build/classes/java/main'], option); + } + }); + + test('keeps JVM options separate from the AppHost arguments', () => { + const parsed = parseJavaAppHostCommand(['java', '-Xmx512m', '-cp', 'out', 'AppHost', '-Dnot.a.vm.arg']); + + assert.deepStrictEqual(parsed?.vmArgs, ['-Xmx512m']); + assert.deepStrictEqual(parsed?.appHostArgs, ['-Dnot.a.vm.arg']); + }); + + test('does not mistake a separated option value for the main class', () => { + // These options take their value as the *next* argument. Treating the value as a bare token + // makes it the main class, and the adapter then launches something the user never asked for + // without reporting anything wrong. + const cases: [string[], string][] = [ + [['java', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-cp', 'out', 'AppHost'], '--add-opens'], + [['java', '--add-exports', 'java.base/sun.nio.ch=ALL-UNNAMED', '-cp', 'out', 'AppHost'], '--add-exports'], + [['java', '--patch-module', 'java.base=patches', '-cp', 'out', 'AppHost'], '--patch-module'] + ]; + + for (const [args, option] of cases) { + assert.strictEqual(parseJavaAppHostCommand(args)?.mainClass, 'AppHost', option); + } + }); + + test('keeps a separated option and its value together in the JVM arguments', () => { + const parsed = parseJavaAppHostCommand(['java', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-cp', 'out', 'AppHost']); + + assert.deepStrictEqual(parsed?.vmArgs, ['--add-opens', 'java.base/java.lang=ALL-UNNAMED']); + }); + + test('returns null for a JAR launch, whose entry point lives in the archive manifest', () => { + // `java -jar app.jar` resolves Main-Class from the manifest, so no class appears on the + // command line. The adapter documents mainClass as a class name or .java path and never opens + // an archive, so handing it "app.jar" fails the launch with ClassNotFoundException. + assert.strictEqual(parseJavaAppHostCommand(['java', '-jar', 'app.jar', '--operation', 'run']), null); + assert.strictEqual(parseJavaAppHostCommand(['java', '-cp', 'out', '-jar', 'app.jar']), null); + }); + + test('returns null when a separated option is missing its value', () => { + // The option consumes the token that would otherwise be the main class, leaving nothing to + // launch rather than a class named "--add-opens". + assert.strictEqual(parseJavaAppHostCommand(['java', '--add-opens']), null); + }); + + test('returns null when the command is not a recognizable JVM launch', () => { + assert.strictEqual(parseJavaAppHostCommand([]), null); + assert.strictEqual(parseJavaAppHostCommand(['java']), null); + // Only options, so there is no main class to attach the debugger to. + assert.strictEqual(parseJavaAppHostCommand(['java', '-Xmx512m']), null); + // A classpath option with no value would otherwise consume the main class. + assert.strictEqual(parseJavaAppHostCommand(['java', '-cp']), null); + }); + + test('returns null for a build-tool wrapper invocation', () => { + // The wrapper forks its own JVM, so "exec:java" is a Maven goal rather than a main class. + assert.strictEqual(parseJavaAppHostCommand(['./mvnw', 'exec:java']), null); + assert.strictEqual(parseJavaAppHostCommand(['./gradlew', 'run']), null); + assert.strictEqual(parseJavaAppHostCommand(['cmd.exe', '/c', 'mvnw.cmd', 'exec:java']), null); + }); + + test('accepts a launcher referenced by an absolute path', () => { + const parsed = parseJavaAppHostCommand([path.join('/opt', 'jdk', 'bin', 'java'), '-cp', 'out', 'AppHost']); + + assert.strictEqual(parsed?.mainClass, 'AppHost'); + }); + + // The CLI writes the classpath relative to the AppHost directory it runs `java` from, and the + // debug adapter resolves a relative entry somewhere else, so the JVM it starts has no AppHost + // class on its classpath and dies with ClassNotFoundException before Aspire connects to it. + test('resolves a relative AppHost classpath against the AppHost directory', () => { + const appHostDirectory = absoluteTestPath('repo', 'JavaSpringBoot.AppHost.Java'); + + assert.deepStrictEqual( + resolveJavaClassPaths(['build/classes/java/main', 'build/aspire-deps/*'], appHostDirectory), + [ + path.join(appHostDirectory, 'build', 'classes', 'java', 'main'), + path.join(appHostDirectory, 'build', 'aspire-deps', '*') + ]); + }); + + test('leaves an absolute AppHost classpath entry alone', () => { + const appHostDirectory = absoluteTestPath('repo', 'JavaSpringBoot.AppHost.Java'); + const absoluteEntry = absoluteTestPath('elsewhere', 'classes'); + + assert.deepStrictEqual(resolveJavaClassPaths([absoluteEntry], appHostDirectory), [absoluteEntry]); + }); +}); + +/** + * Builds a fully qualified path for a test fixture. + * + * `path.join(path.sep, 'repo')` looks absolute and even satisfies `path.isAbsolute`, but on Windows it + * produces the drive-relative `\repo` rather than a fully qualified path. `path.resolve` — which is what + * the production code under test uses — completes that against the current drive and returns `D:\repo`, + * so a fixture built with `join` and an expectation built from the same fixture disagree by a drive + * letter and the test only fails on Windows. `path.resolve` fully qualifies on both platforms, which + * keeps the fixture and the code under test in the same shape. + */ +function absoluteTestPath(...segments: string[]): string { + return path.resolve(path.sep, ...segments); +} diff --git a/extension/src/test/launchProfiles.test.ts b/extension/src/test/launchProfiles.test.ts index 6989d843d03..ae8d65163fc 100644 --- a/extension/src/test/launchProfiles.test.ts +++ b/extension/src/test/launchProfiles.test.ts @@ -2,20 +2,22 @@ import * as assert from 'assert'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; +import * as sinon from 'sinon'; import { determineBaseLaunchProfile, determineDefaultLaunchProfile, mergeEnvironmentVariables, determineArguments, determineWorkingDirectory, - determineServerReadyAction, readLaunchSettings, expandEnvironmentVariables, LaunchSettings, LaunchProfile } from '../debugger/launchProfiles'; import { ExecutableLaunchConfiguration, EnvVar, ProjectLaunchConfiguration } from '../dcp/types'; +import { extensionLogOutputChannel } from '../utils/logging'; +import { removeDirectorySafely } from './testHelpers'; suite('Launch Profile Tests', () => { suite('determineBaseLaunchProfile', () => { const sampleLaunchSettings: LaunchSettings = { @@ -515,13 +517,28 @@ suite('Launch Profile Tests', () => { }); suite('determineArguments', () => { - test('uses run session args when provided', () => { + test('clones run session args when provided so token boundaries are preserved', () => { const baseProfileArgs = '--base-arg value'; - const runSessionArgs = ['--session-arg', 'value']; + const runSessionArgs = ['--custom', 'value with spaces', '', 'literal "quote"', String.raw`C:\tools\backslash\path`]; const result = determineArguments(baseProfileArgs, runSessionArgs); - assert.deepStrictEqual(result, '--session-arg value'); + assert.deepStrictEqual(result, runSessionArgs); + assert.notStrictEqual(result, runSessionArgs); + }); + + test('logs only the run session argument count', () => { + const debugStub = sinon.stub(extensionLogOutputChannel, 'debug'); + + try { + const result = determineArguments(undefined, ['--api-key', 'secret-value']); + + assert.deepStrictEqual(result, ['--api-key', 'secret-value']); + assert.strictEqual(debugStub.callCount, 1); + assert.strictEqual(debugStub.firstCall.args[0], 'Using run session arguments (count: 2)'); + } finally { + debugStub.restore(); + } }); test('uses empty run session args when explicitly provided', () => { @@ -530,7 +547,8 @@ suite('Launch Profile Tests', () => { const result = determineArguments(baseProfileArgs, runSessionArgs); - assert.deepStrictEqual(result, ''); + assert.deepStrictEqual(result, []); + assert.notStrictEqual(result, runSessionArgs); }); test('uses base profile args when run session args are null', () => { @@ -543,12 +561,13 @@ suite('Launch Profile Tests', () => { }); test('uses base profile args when run session args are undefined', () => { - const baseProfileArgs = '--base-arg value --flag'; + const baseProfileArgs = '--custom "value with spaces" "" "literal \\"quote\\"" "C:\\tools\\backslash\\path"'; const runSessionArgs = undefined; const result = determineArguments(baseProfileArgs, runSessionArgs); - assert.deepStrictEqual(result, baseProfileArgs); + assert.strictEqual(result, baseProfileArgs); + assert.strictEqual(typeof result, 'string'); }); test('returns undefined when no args available', () => { @@ -630,95 +649,6 @@ suite('Launch Profile Tests', () => { }); }); - suite('determineServerReadyAction', () => { - test('returns undefined when launchBrowser is false', () => { - const result = determineServerReadyAction(false, 'https://localhost:5001', undefined); - assert.strictEqual(result, undefined); - }); - - test('returns undefined when applicationUrl is undefined', () => { - const result = determineServerReadyAction(true, undefined, undefined); - assert.strictEqual(result, undefined); - }); - - test('returns serverReadyAction when launchBrowser true and applicationUrl provided', () => { - const applicationUrl = 'https://localhost:5001'; - const result = determineServerReadyAction(true, applicationUrl, undefined); - - assert.notStrictEqual(result, undefined); - assert.strictEqual(result?.action, 'openExternally'); - assert.strictEqual(result?.uriFormat, applicationUrl); - assert.strictEqual(result?.pattern, '\\bNow listening on:\\s+https?://\\S+'); - }); - - test('returns serverReadyAction with first URL when multiple URLs separated by semicolon', () => { - const applicationUrl = 'https://localhost:5001;http://localhost:5000'; - const result = determineServerReadyAction(true, applicationUrl, undefined); - - assert.notStrictEqual(result, undefined); - assert.strictEqual(result?.action, 'openExternally'); - assert.strictEqual(result?.uriFormat, 'https://localhost:5001'); - assert.strictEqual(result?.pattern, '\\bNow listening on:\\s+https?://\\S+'); - }); - - test('returns serverReadyAction with absolute launchUrl', () => { - const applicationUrl = 'https://localhost:5001;http://localhost:5000'; - const launchUrl = 'https://localhost:5001/some/path'; - const result = determineServerReadyAction(true, applicationUrl, launchUrl); - - assert.notStrictEqual(result, undefined); - assert.strictEqual(result?.action, 'openExternally'); - assert.strictEqual(result?.uriFormat, 'https://localhost:5001/some/path'); - assert.strictEqual(result?.pattern, '\\bNow listening on:\\s+https?://\\S+'); - }); - - test('returns serverReadyAction with relative launchUrl', () => { - const applicationUrl = 'https://localhost:5001;http://localhost:5000'; - const launchUrl = '/some/path'; - const result = determineServerReadyAction(true, applicationUrl, launchUrl); - - assert.notStrictEqual(result, undefined); - assert.strictEqual(result?.action, 'openExternally'); - assert.strictEqual(result?.uriFormat, 'https://localhost:5001/some/path'); - assert.strictEqual(result?.pattern, '\\bNow listening on:\\s+https?://\\S+'); - }); - - test('absolute launchUrl overrides wildcard-host applicationUrl', () => { - const result = determineServerReadyAction( - true, - 'http://*:80/;https://*:443/', - 'https://mywebsite.localhost'); - - assert.strictEqual(result?.uriFormat, 'https://mywebsite.localhost/'); - }); - - test('falls back to applicationUrl when relative launchUrl resolves against wildcard-host applicationUrl', () => { - const applicationUrl = 'http://*:80/'; - const result = determineServerReadyAction(true, applicationUrl, '/some/path'); - - assert.strictEqual(result?.uriFormat, applicationUrl); - }); - - test('falls back to applicationUrl when relative launchUrl cannot be resolved', () => { - const result = determineServerReadyAction(true, 'localhost:5001', '/some/path'); - - assert.strictEqual(result?.uriFormat, 'localhost:5001'); - }); - - test('falls back to applicationUrl when applicationUrl is empty', () => { - const result = determineServerReadyAction(true, ';http://localhost:5000', '/some/path'); - - assert.strictEqual(result?.uriFormat, ''); - }); - - test('falls back to applicationUrl when absolute launchUrl is not http or https', () => { - const applicationUrl = 'https://localhost:5001'; - const result = determineServerReadyAction(true, applicationUrl, 'javascript:alert(1)'); - - assert.strictEqual(result?.uriFormat, applicationUrl); - }); - }); - suite('readLaunchSettings', () => { let tempDir: string; let projectPath: string; @@ -741,7 +671,7 @@ suite('Launch Profile Tests', () => { teardown(() => { if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); + removeDirectorySafely(tempDir); } }); diff --git a/extension/src/test/mcpServerDefinition.test.ts b/extension/src/test/mcpServerDefinition.test.ts index 9a90c4ed112..9ecd698833c 100644 --- a/extension/src/test/mcpServerDefinition.test.ts +++ b/extension/src/test/mcpServerDefinition.test.ts @@ -1,8 +1,10 @@ import * as assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as cliPath from '../utils/cliPath'; import { AspireMcpServerDefinitionProvider, createAspireMcpServerDefinition } from '../mcp/AspireMcpServerDefinitionProvider'; +import { CliPathResolutionTarget } from '../utils/cliPathVariables'; suite('AspireMcpServerDefinitionProvider definition tests', () => { test('wraps Windows command shims for VS Code MCP launchers', () => { @@ -38,10 +40,41 @@ suite('AspireMcpServerDefinitionProvider definition tests', () => { }); test('passes native executables through to the VS Code MCP launcher', () => { - const definition = createAspireMcpServerDefinition('C:\\Program Files\\Aspire\\aspire.exe'); + const cliPath = 'C:\\Program Files\\Aspire\\aspire.exe'; + const definition = createAspireMcpServerDefinition(cliPath, undefined, undefined, { + isAbsolute: () => true, + fileExists: candidate => candidate === cliPath, + realpath: () => undefined, + }); - assert.strictEqual(definition.command, 'C:\\Program Files\\Aspire\\aspire.exe'); + assert.strictEqual(definition.command, cliPath); assert.deepStrictEqual(definition.args, ['agent', 'mcp']); + assert.deepStrictEqual(definition.env, { AspireCliPath: cliPath }); + }); + + // `aspire agent mcp` can build an AppHost, and that build inherits this environment. Forwarding + // an unbundled framework-dependent CLI path makes ResolveAspireCliBundle stamp bundle assets + // from a CLI that has no bundle layout, so the MCP server must apply the same forwardability + // guard every other AspireCliPath producer applies. + test('does not forward an unbundled framework-dependent CLI path to the MCP server', () => { + // Build the paths with `path` rather than literals: the production guard derives the + // adjacent assembly with path.dirname/path.join, which emits backslashes on Windows, so a + // hardcoded POSIX literal would never match there and the test would silently pass. + const cliPath = path.join(path.sep, 'repo', 'artifacts', 'bin', 'Aspire.Cli', 'Debug', 'aspire'); + const cliAssemblyPath = path.join(path.dirname(cliPath), 'aspire.dll'); + const definition = createAspireMcpServerDefinition(cliPath, undefined, undefined, { + isAbsolute: () => true, + // An inner-loop `dotnet build` output: the apphost sits next to aspire.dll with no + // install sidecar and no adjacent bundle layout. + fileExists: candidate => candidate === cliPath || candidate === cliAssemblyPath, + realpath: () => undefined, + }); + + assert.strictEqual(definition.command, cliPath); + assert.deepStrictEqual(definition.args, ['agent', 'mcp']); + // VS Code normalizes an omitted env to an empty record, so asserting the whole value both + // proves AspireCliPath is absent and pins that nothing else is forwarded in its place. + assert.deepStrictEqual(definition.env, {}); }); }); @@ -50,6 +83,8 @@ suite('AspireMcpServerDefinitionProvider refresh tests', () => { let configurationStub: sinon.SinonStub; let getConfigurationStub: sinon.SinonStub; let workspaceFoldersStub: sinon.SinonStub; + let trustGrantHandler: (() => void) | undefined; + let trustGrantStub: sinon.SinonStub; setup(() => { configurationStub = sinon.stub(vscode.workspace, 'onDidChangeConfiguration').callsFake(handler => { @@ -57,6 +92,10 @@ suite('AspireMcpServerDefinitionProvider refresh tests', () => { return { dispose: () => { } }; }); workspaceFoldersStub = sinon.stub(vscode.workspace, 'onDidChangeWorkspaceFolders').returns({ dispose: () => { } }); + trustGrantStub = sinon.stub(vscode.workspace, 'onDidGrantWorkspaceTrust').callsFake(handler => { + trustGrantHandler = handler; + return { dispose: () => { } }; + }); const workspaceConfiguration: vscode.WorkspaceConfiguration = { get: sinon.stub().returns(true), has: sinon.stub().returns(true), @@ -69,6 +108,7 @@ suite('AspireMcpServerDefinitionProvider refresh tests', () => { teardown(() => { configurationStub.restore(); getConfigurationStub.restore(); + trustGrantStub.restore(); workspaceFoldersStub.restore(); }); @@ -84,6 +124,40 @@ suite('AspireMcpServerDefinitionProvider refresh tests', () => { provider.dispose(); }); + test('refreshes when workspace trust is granted', () => { + const provider = new AspireMcpServerDefinitionProvider(); + const refresh = sinon.stub(provider, 'refresh').resolves(); + + trustGrantHandler!(); + + assert.ok(refresh.calledOnce); + provider.dispose(); + }); + + test('does not provide MCP definitions in an untrusted workspace', async () => { + const folder = { index: 0, name: 'app', uri: vscode.Uri.file('/repo/app') }; + const workspaceFoldersValueStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([folder]); + const trustDescriptor = Object.getOwnPropertyDescriptor(vscode.workspace, 'isTrusted'); + Object.defineProperty(vscode.workspace, 'isTrusted', { value: false, configurable: true }); + const forwardingEmitter = new vscode.EventEmitter(); + const resolve = sinon.stub().resolves({ available: true, cliPath: '/repo/app/aspire', source: 'configured' }); + const resolver = { resolve, onDidChangeForwarding: forwardingEmitter.event } as unknown as cliPath.CliPathResolver; + const provider = new AspireMcpServerDefinitionProvider(resolver); + + try { + await provider.refresh(); + + assert.deepStrictEqual(provider.provideMcpServerDefinitions(new vscode.CancellationTokenSource().token), []); + assert.ok(resolve.notCalled); + } + finally { + provider.dispose(); + forwardingEmitter.dispose(); + Object.defineProperty(vscode.workspace, 'isTrusted', trustDescriptor!); + workspaceFoldersValueStub.restore(); + } + }); + test('refreshes when CLI resolution rejects a configured path', async () => { cliPath.resetRejectedConfiguredCliPathForForwarding(); const provider = new AspireMcpServerDefinitionProvider(); @@ -92,11 +166,13 @@ suite('AspireMcpServerDefinitionProvider refresh tests', () => { try { await cliPath.resolveCliPath({ getConfiguredPath: () => '/invalid/aspire', + getWorkspaceFolders: () => [], getDefaultPaths: () => [], isConfiguredPathAutoConfigured: () => false, findOnPath: async () => 'aspire', findAtDefaultPath: async () => undefined, tryExecute: async () => false, + getExecutableCandidates: (candidate: string) => [candidate], setConfiguredPath: async () => { }, updateResolvedPathForForwarding: () => { }, }); @@ -109,6 +185,175 @@ suite('AspireMcpServerDefinitionProvider refresh tests', () => { } }); + test('provides one folder-scoped MCP definition per workspace folder', async () => { + const folderA = { index: 0, name: 'a', uri: vscode.Uri.file('/repo/a') }; + const folderB = { index: 1, name: 'b', uri: vscode.Uri.file('/repo/b') }; + const workspaceFoldersValueStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([folderA, folderB]); + const forwardingEmitter = new vscode.EventEmitter(); + const resolver = { + resolve: sinon.stub().callsFake(async (target: CliPathResolutionTarget) => ({ + available: true, + cliPath: target.kind === 'workspaceFolder' && target.workspaceFolder.name === 'a' + ? '/repo/a/aspire' + : '/repo/b/aspire', + source: 'configured', + })), + onDidChangeForwarding: forwardingEmitter.event, + } as unknown as cliPath.CliPathResolver; + const provider = new AspireMcpServerDefinitionProvider(resolver); + const cancellationSource = new vscode.CancellationTokenSource(); + + try { + await provider.refresh(); + const definitions = await Promise.resolve(provider.provideMcpServerDefinitions(cancellationSource.token)) ?? []; + + assert.deepStrictEqual(definitions.map(definition => ({ + label: definition.label, + command: definition.command, + cwd: definition.cwd?.fsPath, + })), [ + { label: 'Aspire (a)', command: '/repo/a/aspire', cwd: folderA.uri.fsPath }, + { label: 'Aspire (b)', command: '/repo/b/aspire', cwd: folderB.uri.fsPath }, + ]); + } + finally { + cancellationSource.dispose(); + forwardingEmitter.dispose(); + provider.dispose(); + workspaceFoldersValueStub.restore(); + } + }); + + test('keeps the Aspire label for a single workspace folder', async () => { + const folder = { index: 0, name: 'app', uri: vscode.Uri.file('/repo/app') }; + const workspaceFoldersValueStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([folder]); + const forwardingEmitter = new vscode.EventEmitter(); + const resolver = { + resolve: sinon.stub().resolves({ + available: true, + cliPath: '/repo/app/aspire', + source: 'configured', + }), + onDidChangeForwarding: forwardingEmitter.event, + } as unknown as cliPath.CliPathResolver; + const provider = new AspireMcpServerDefinitionProvider(resolver); + const cancellationSource = new vscode.CancellationTokenSource(); + + try { + await provider.refresh(); + const definitions = await Promise.resolve(provider.provideMcpServerDefinitions(cancellationSource.token)) ?? []; + + assert.strictEqual(definitions.length, 1); + assert.strictEqual(definitions[0].label, 'Aspire'); + assert.strictEqual(definitions[0].cwd?.fsPath, folder.uri.fsPath); + } + finally { + cancellationSource.dispose(); + forwardingEmitter.dispose(); + provider.dispose(); + workspaceFoldersValueStub.restore(); + } + }); + + test('disambiguates MCP labels when workspace folders share a name', async () => { + const folderA = { index: 0, name: 'api', uri: vscode.Uri.file('/repo/a/api') }; + const folderB = { index: 1, name: 'api', uri: vscode.Uri.file('/repo/b/api') }; + const workspaceFoldersValueStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([folderA, folderB]); + const forwardingEmitter = new vscode.EventEmitter(); + const resolver = { + resolve: sinon.stub().callsFake(async (target: CliPathResolutionTarget) => ({ + available: true, + cliPath: target.kind === 'workspaceFolder' && target.workspaceFolder.index === 0 + ? '/repo/a/aspire' + : '/repo/b/aspire', + source: 'configured', + })), + onDidChangeForwarding: forwardingEmitter.event, + } as unknown as cliPath.CliPathResolver; + const provider = new AspireMcpServerDefinitionProvider(resolver); + const cancellationSource = new vscode.CancellationTokenSource(); + + try { + await provider.refresh(); + const definitions = await Promise.resolve(provider.provideMcpServerDefinitions(cancellationSource.token)) ?? []; + + assert.deepStrictEqual(definitions.map(definition => definition.label), [ + 'Aspire (api 1)', + 'Aspire (api 2)', + ]); + } + finally { + cancellationSource.dispose(); + forwardingEmitter.dispose(); + provider.dispose(); + workspaceFoldersValueStub.restore(); + } + }); + + test('keeps duplicate-folder labels stable when one CLI is unavailable', async () => { + const folderA = { index: 0, name: 'api', uri: vscode.Uri.file('/repo/a/api') }; + const folderB = { index: 1, name: 'api', uri: vscode.Uri.file('/repo/b/api') }; + const workspaceFoldersValueStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([folderA, folderB]); + const forwardingEmitter = new vscode.EventEmitter(); + const resolver = { + resolve: sinon.stub().callsFake(async (target: CliPathResolutionTarget) => ({ + available: target.kind === 'workspaceFolder' && target.workspaceFolder.index === 1, + cliPath: '/repo/b/aspire', + source: 'configured', + })), + onDidChangeForwarding: forwardingEmitter.event, + } as unknown as cliPath.CliPathResolver; + const provider = new AspireMcpServerDefinitionProvider(resolver); + + try { + await provider.refresh(); + const definitions = await Promise.resolve( + provider.provideMcpServerDefinitions(new vscode.CancellationTokenSource().token)) ?? []; + + assert.deepStrictEqual(definitions.map(definition => definition.label), ['Aspire (api 2)']); + } + finally { + forwardingEmitter.dispose(); + provider.dispose(); + workspaceFoldersValueStub.restore(); + } + }); + + test('does not collide a generated MCP label with a real folder name', async () => { + const folderA = { index: 0, name: 'api', uri: vscode.Uri.file('/repo/a/api') }; + const folderB = { index: 1, name: 'api', uri: vscode.Uri.file('/repo/b/api') }; + const folderC = { index: 2, name: 'api 1', uri: vscode.Uri.file('/repo/c/api') }; + const workspaceFoldersValueStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([folderA, folderB, folderC]); + const forwardingEmitter = new vscode.EventEmitter(); + const resolver = { + resolve: sinon.stub().callsFake(async (target: CliPathResolutionTarget) => ({ + available: true, + cliPath: `/repo/${target.kind === 'workspaceFolder' ? target.workspaceFolder.index : 'window'}/aspire`, + source: 'configured', + })), + onDidChangeForwarding: forwardingEmitter.event, + } as unknown as cliPath.CliPathResolver; + const provider = new AspireMcpServerDefinitionProvider(resolver); + const cancellationSource = new vscode.CancellationTokenSource(); + + try { + await provider.refresh(); + const definitions = await Promise.resolve(provider.provideMcpServerDefinitions(cancellationSource.token)) ?? []; + + assert.deepStrictEqual(definitions.map(definition => definition.label), [ + 'Aspire (api 2)', + 'Aspire (api 3)', + 'Aspire (api 1)', + ]); + } + finally { + cancellationSource.dispose(); + forwardingEmitter.dispose(); + provider.dispose(); + workspaceFoldersValueStub.restore(); + } + }); + test('ignores an older refresh that completes after a newer result', async () => { const workspaceFoldersValueStub = sinon.stub(vscode.workspace, 'workspaceFolders').value([{ index: 0, @@ -117,14 +362,16 @@ suite('AspireMcpServerDefinitionProvider refresh tests', () => { }]); let completeOlderRefresh: ((result: cliPath.CliPathResolutionResult) => void) | undefined; const olderResult = new Promise(resolve => completeOlderRefresh = resolve); - const resolveCliPathStub = sinon.stub(cliPath, 'resolveCliPath'); - resolveCliPathStub.onFirstCall().returns(olderResult); - resolveCliPathStub.onSecondCall().resolves({ + const forwardingEmitter = new vscode.EventEmitter(); + const resolve = sinon.stub(); + resolve.onFirstCall().returns(olderResult); + resolve.onSecondCall().resolves({ available: false, cliPath: 'aspire', source: 'not-found', }); - const provider = new AspireMcpServerDefinitionProvider(); + const resolver = { resolve, onDidChangeForwarding: forwardingEmitter.event } as unknown as cliPath.CliPathResolver; + const provider = new AspireMcpServerDefinitionProvider(resolver); const cancellationSource = new vscode.CancellationTokenSource(); try { @@ -145,8 +392,8 @@ suite('AspireMcpServerDefinitionProvider refresh tests', () => { } finally { cancellationSource.dispose(); + forwardingEmitter.dispose(); provider.dispose(); - resolveCliPathStub.restore(); workspaceFoldersValueStub.restore(); } }); diff --git a/extension/src/test/meaningfulEngagement.test.ts b/extension/src/test/meaningfulEngagement.test.ts index 8363c02877d..027784198b8 100644 --- a/extension/src/test/meaningfulEngagement.test.ts +++ b/extension/src/test/meaningfulEngagement.test.ts @@ -8,6 +8,7 @@ import type { CandidateAppHostDisplayInfo, AppHostDiscoveryService } from '../ut import { MeaningfulEngagementReporter } from '../utils/meaningfulEngagement'; import { __resetCommonPropertiesForTests, __setReporterForTests, getCommonTelemetryProperties } from '../utils/telemetry'; +import { removeDirectorySafely } from './testHelpers'; interface RecordedEvent { name: string; properties?: Record; @@ -62,7 +63,7 @@ suite('MeaningfulEngagementReporter', () => { sinon.restore(); for (const dir of tempDirs) { if (existsSync(dir)) { - rmSync(dir, { recursive: true, force: true }); + removeDirectorySafely(dir); } } tempDirs.length = 0; @@ -72,7 +73,7 @@ suite('MeaningfulEngagementReporter', () => { suiteTeardown(() => { if (existsSync(tempParent)) { - rmSync(tempParent, { recursive: true, force: true }); + removeDirectorySafely(tempParent); } }); diff --git a/extension/src/test/notificationWaitForMessage.test.ts b/extension/src/test/notificationWaitForMessage.test.ts index 081bd896278..45c3fb961d0 100644 --- a/extension/src/test/notificationWaitForMessage.test.ts +++ b/extension/src/test/notificationWaitForMessage.test.ts @@ -15,11 +15,13 @@ interface NotificationExtesterStubModule { }; resetNotificationWaitState(): void; setEditorPolls(editorPolls: Array): void; + setCodeLensPolls(codeLensPolls: Array): void; setNotificationPolls(notificationPolls: Array): void; setTerminalPolls(terminalPolls: Array): void; } interface VscodeHelpersModule { + waitForCodeLensText(fileName: string, expectedText: string, timeoutMs?: number): Promise; waitForEditorTitle(expectedText: string, timeoutMs?: number): Promise; waitForNotificationMessage(expectedText: string, timeoutMs?: number): Promise; waitForTerminalChannel(expectedText: string, timeoutMs?: number): Promise; @@ -134,8 +136,40 @@ suite('waitForNotificationMessage', () => { error.message.includes('Open editor titles:'), ); }); + + test('waits for the tab to exist and for the provider to produce lenses', async () => { + const { stub, vscode } = loadNotificationWaitModules(); + + // openResources returns before VS Code creates the tab, so openEditor throws; once it exists the + // provider has usually not produced anything yet, so the first read is empty. + stub.setCodeLensPolls([ + new Error('No editor with title AppHost.java'), + [], + ['Run | Debug — these bypass Aspire'], + ]); + + const texts = await vscode.waitForCodeLensText('AppHost.java', 'bypass Aspire', 5000); + + assert.deepStrictEqual(texts, ['Run | Debug — these bypass Aspire']); + }); + + test('reports the lenses it did see when none ever match', async () => { + const { stub, vscode } = loadNotificationWaitModules(); + + stub.setCodeLensPolls([['Run', 'Debug']]); + + await assert.rejects( + vscode.waitForCodeLensText('AppHost.java', 'bypass Aspire', 5000), + error => error instanceof Error + && error.message.includes("Timed out waiting for a CodeLens containing 'bypass Aspire'") + && error.message.includes('CodeLenses:') + && error.message.includes('Run') + && error.message.includes('Debug'), + ); + }); }); + function loadNotificationWaitModules(): { stub: NotificationExtesterStubModule; vscode: VscodeHelpersModule; diff --git a/extension/src/test/packageManifest.test.ts b/extension/src/test/packageManifest.test.ts index faa9bc35e08..6020ecc2681 100644 --- a/extension/src/test/packageManifest.test.ts +++ b/extension/src/test/packageManifest.test.ts @@ -185,6 +185,22 @@ suite('extension/package.json', () => { assert.ok(activationEvents.includes('workspaceContains:**/apphost.rs')); }); + // A Java-only workspace contains no project file the other activation events match: no .csproj, + // and `.aspire/` only exists once a restore has already run. Without an explicit Java event the + // extension never activates on a fresh clone, so the AppHost never appears in the Aspire view + // and none of the editor features register. Both casings are listed because `workspaceContains` + // globs are case-sensitive on Linux and macOS, and Java ties the file name to the class name: + // `aspire init` writes `AppHost.java` while the lowercase spelling matches the other languages. + test('Java AppHost files activate the extension', () => { + const manifest = readManifest(); + const activationEvents = manifest.activationEvents ?? []; + + assert.ok(activationEvents.includes('workspaceContains:**/AppHost.java'), 'aspire init writes AppHost.java'); + assert.ok(activationEvents.includes('workspaceContains:**/apphost.java'), 'lowercase matches the other languages'); + // Maven and Gradle AppHosts nest the source under the standard layout rather than the root. + assert.ok(activationEvents.includes('workspaceContains:**/src/main/java/AppHost.java')); + }); + test('FSharp and Visual Basic AppHost projects activate the extension', () => { const manifest = readManifest(); const activationEvents = manifest.activationEvents ?? []; diff --git a/extension/src/test/parsers.test.ts b/extension/src/test/parsers.test.ts index 19c7c972fa6..810969518d8 100644 --- a/extension/src/test/parsers.test.ts +++ b/extension/src/test/parsers.test.ts @@ -1,75 +1,19 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import { createMockDocument } from './testHelpers'; import { Parser } from 'web-tree-sitter'; import { AppHostResourceParser, getParserForDocument, getSupportedLanguageIds, getAllParsers, ParsedResource } from '../editor/parsers/AppHostResourceParser'; import { __resetTreeSitterForTests, initializeTreeSitter } from '../editor/parsers/treeSitter'; // Import parsers so they self-register import '../editor/parsers/csharpAppHostParser'; +import '../editor/parsers/javaAppHostParser'; import '../editor/parsers/jsTsAppHostParser'; import '../editor/parsers/rustAppHostParser'; /** * Creates a minimal mock TextDocument for parser testing. */ -function createMockDocument(content: string, filePath: string): vscode.TextDocument { - const lines = content.split('\n'); - return { - uri: vscode.Uri.file(filePath), - fileName: filePath, - isUntitled: false, - languageId: filePath.endsWith('.cs') ? 'csharp' : filePath.endsWith('.ts') ? 'typescript' : filePath.endsWith('.rs') ? 'rust' : 'javascript', - version: 1, - isDirty: false, - isClosed: false, - eol: vscode.EndOfLine.LF, - lineCount: lines.length, - encoding: 'utf-8', - save: () => Promise.resolve(false), - lineAt: (lineOrPos: number | vscode.Position) => { - const lineNum = typeof lineOrPos === 'number' ? lineOrPos : lineOrPos.line; - const text = lines[lineNum] || ''; - return { - lineNumber: lineNum, - text, - range: new vscode.Range(lineNum, 0, lineNum, text.length), - rangeIncludingLineBreak: new vscode.Range(lineNum, 0, lineNum + 1, 0), - firstNonWhitespaceCharacterIndex: text.search(/\S/), - isEmptyOrWhitespace: text.trim().length === 0, - } as vscode.TextLine; - }, - offsetAt: (position: vscode.Position) => { - let offset = 0; - for (let i = 0; i < position.line && i < lines.length; i++) { - offset += lines[i].length + 1; // +1 for \n - } - offset += position.character; - return offset; - }, - positionAt: (offset: number) => { - let remaining = offset; - for (let i = 0; i < lines.length; i++) { - if (remaining <= lines[i].length) { - return new vscode.Position(i, remaining); - } - remaining -= lines[i].length + 1; // +1 for \n - } - return new vscode.Position(lines.length - 1, lines[lines.length - 1].length); - }, - getText: (range?: vscode.Range) => { - if (!range) { - return content; - } - const startOffset = lines.slice(0, range.start.line).reduce((sum, l) => sum + l.length + 1, 0) + range.start.character; - const endOffset = lines.slice(0, range.end.line).reduce((sum, l) => sum + l.length + 1, 0) + range.end.character; - return content.substring(startOffset, endOffset); - }, - getWordRangeAtPosition: () => undefined, - validateRange: (range: vscode.Range) => range, - validatePosition: (position: vscode.Position) => position, - notebook: undefined as any, - } as vscode.TextDocument; -} // ============================================================ // Parser Registry Tests @@ -86,6 +30,7 @@ suite('AppHostResourceParser registry', () => { assert.ok(ids.includes('typescript'), 'Should support typescript'); assert.ok(ids.includes('javascript'), 'Should support javascript'); assert.ok(ids.includes('rust'), 'Should support rust'); + assert.ok(ids.includes('java'), 'Should support java'); }); test('initializes Tree-sitter once for concurrent callers', async () => { diff --git a/extension/src/test/processArguments.test.ts b/extension/src/test/processArguments.test.ts new file mode 100644 index 00000000000..a9c8110c103 --- /dev/null +++ b/extension/src/test/processArguments.test.ts @@ -0,0 +1,201 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { assertExactLinkedAppHostCliLaunch, assertLinkedAppHostCliLaunch, getExpectedLinkedAppHostCliProcessArguments } from './helpers/processArguments'; + +suite('process argument parsing', () => { + const cliPath = '/tools/aspire'; + const appHostPath = '/workspace/Linked Worktree/LinkedAppHost.csproj'; + + teardown(() => { + sinon.restore(); + }); + + test('accepts exact Windows launch arguments and compares paths case-insensitively', () => { + assert.doesNotThrow(() => assertLinkedAppHostCliLaunch( + ['C:\\Tools\\aspire.exe', 'run', '--isolated', '--start-debug-session', '--apphost', 'c:\\Users\\runner\\workspace with spaces\\AppHost.csproj'], + 'C:\\Users\\runner\\workspace with spaces\\AppHost.csproj', + 'C:\\Tools\\ASPIRE.EXE', + 'win32')); + }); + + test('rejects --isolated=false as evidence of inferred isolation', () => { + assert.throws( + () => assertLinkedAppHostCliLaunch( + [cliPath, 'run', '--isolated=false', '--start-debug-session', '--apphost', appHostPath], + appHostPath, + cliPath, + 'linux'), + /Expected exact '--isolated'/); + }); + + test('rejects false immediately after --isolated', () => { + assert.throws( + () => assertLinkedAppHostCliLaunch( + [cliPath, 'run', '--isolated', 'false', '--start-debug-session', '--apphost', appHostPath], + appHostPath, + cliPath, + 'linux'), + /Expected inferred isolation to use only the true-form --isolated switch/); + }); + + test('ignores option-shaped AppHost arguments after the separator', () => { + assert.doesNotThrow(() => assertLinkedAppHostCliLaunch( + [ + cliPath, + 'run', + '--isolated', + '--start-debug-session', + '--apphost', + appHostPath, + '--', + '--isolated=false', + '--start-debug-session', + '--apphost', + ], + appHostPath, + cliPath, + 'linux')); + }); + + test('rejects --start-debug-session embedded in another argument', () => { + assert.throws( + () => assertLinkedAppHostCliLaunch( + [cliPath, 'run', '--isolated', '--prefix--start-debug-session', '--apphost', appHostPath], + appHostPath, + cliPath, + 'linux'), + /Expected exact '--start-debug-session'/); + }); + + test('rejects --apphost embedded in another argument', () => { + assert.throws( + () => assertLinkedAppHostCliLaunch( + [cliPath, 'run', '--isolated', '--start-debug-session', '--prefix--apphost', appHostPath], + appHostPath, + cliPath, + 'linux'), + /Expected exact '--apphost'/); + }); + + test('rejects an AppHost path embedded in another argument', () => { + assert.throws( + () => assertLinkedAppHostCliLaunch( + [cliPath, 'run', '--isolated', '--start-debug-session', '--apphost', `${appHostPath}.backup`], + appHostPath, + cliPath, + 'linux'), + /Expected exact --apphost path/); + }); + + test('requires the AppHost path immediately after --apphost', () => { + assert.throws( + () => assertLinkedAppHostCliLaunch( + [cliPath, 'run', '--isolated', '--start-debug-session', '--apphost', '--other', appHostPath], + appHostPath, + cliPath, + 'linux'), + /Expected exact --apphost path/); + }); + + test('accepts the exact linked AppHost CLI argv including application argument boundaries', () => { + const appHostArguments = ['--custom', 'value with spaces', '', 'literal "quote"', String.raw`C:\tools\backslash\path`]; + + assert.doesNotThrow(() => assertExactLinkedAppHostCliLaunch( + [ + 'C:\\Tools\\aspire.exe', + 'run', + '--isolated', + '--start-debug-session', + '--nologo', + '--apphost', + 'c:\\Users\\runner\\workspace with spaces\\AppHost.csproj', + '--', + ...appHostArguments, + ], + 'C:\\Users\\runner\\workspace with spaces\\AppHost.csproj', + 'C:\\Tools\\ASPIRE.EXE', + appHostArguments, + 'win32')); + }); + + test('builds the exact cmd shim process argv for a linked AppHost launch', () => { + sinon.stub(process, 'platform').value('win32'); + const originalComSpec = process.env.ComSpec; + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + + try { + const result = getExpectedLinkedAppHostCliProcessArguments( + 'C:\\Tools\\Aspire CLI\\aspire.cmd', + 'C:\\worktrees\\linked apphost\\AppHost.csproj', + ['--custom', 'value with spaces', '', 'literal "quote"'], + ); + + assert.deepStrictEqual(result, [ + 'C:\\Windows\\System32\\cmd.exe', + '/d', + '/v:off', + '/s', + '/c', + '""C:\\Tools\\Aspire CLI\\aspire.cmd" "run" "--isolated" "--start-debug-session" "--nologo" "--apphost" "C:\\worktrees\\linked apphost\\AppHost.csproj" "--" "--custom" "value with spaces" "" "literal ""quote""""', + ]); + assert.doesNotThrow(() => assertExactLinkedAppHostCliLaunch( + result, + 'C:\\worktrees\\linked apphost\\AppHost.csproj', + 'C:\\Tools\\Aspire CLI\\aspire.cmd', + ['--custom', 'value with spaces', '', 'literal "quote"'])); + } + finally { + if (originalComSpec === undefined) { + delete process.env.ComSpec; + } + else { + process.env.ComSpec = originalComSpec; + } + } + }); + + test('rejects a repeated resolver result that duplicates the root isolation switch', () => { + assert.throws( + () => assertExactLinkedAppHostCliLaunch( + [ + cliPath, + 'run', + '--isolated', + '--isolated', + '--start-debug-session', + '--nologo', + '--apphost', + appHostPath, + '--', + '--custom', + ], + appHostPath, + cliPath, + ['--custom'], + 'linux'), + /Expected exact Aspire CLI argv/); + }); + + test('rejects flattened or changed AppHost argument tokens', () => { + assert.throws( + () => assertExactLinkedAppHostCliLaunch( + [ + cliPath, + 'run', + '--isolated', + '--start-debug-session', + '--nologo', + '--apphost', + appHostPath, + '--', + '--custom', + 'value with spaces literal "quote"', + String.raw`C:\tools\backslash\path`, + ], + appHostPath, + cliPath, + ['--custom', 'value with spaces', '', 'literal "quote"', String.raw`C:\tools\backslash\path`], + 'linux'), + /Expected exact Aspire CLI argv/); + }); +}); diff --git a/extension/src/test/registerCliCommands.test.ts b/extension/src/test/registerCliCommands.test.ts new file mode 100644 index 00000000000..102f183a65e --- /dev/null +++ b/extension/src/test/registerCliCommands.test.ts @@ -0,0 +1,224 @@ +/// + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { registerCliCommands } from '../activation/registerCliCommands'; +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; +import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import * as cliPathModule from '../utils/cliPath'; +import { ConfigInfoProvider } from '../utils/configInfoProvider'; +import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { createWorkspaceFolder, removeDirectorySafely } from './testHelpers'; +suite('registerCliCommands', () => { + let sandbox: sinon.SinonSandbox; + let callbacks: Map Promise>; + let terminalProvider: AspireTerminalProvider; + let sendCommandStub: sinon.SinonStub; + let getTerminalStub: sinon.SinonStub; + let resolveCliPathStub: sinon.SinonStub; + let showWorkspaceFolderPickStub: sinon.SinonStub; + let workspaceFoldersStub: sinon.SinonStub; + let activeTextEditorStub: sinon.SinonStub; + let getWorkspaceFolderStub: sinon.SinonStub; + let getAppHostPathStub: sinon.SinonStub; + let tryExecuteDoAppHostStub: sinon.SinonStub; + let editorCommandProvider: AspireEditorCommandProvider; + let tempDir: string; + + setup(() => { + sandbox = sinon.createSandbox(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-cli-commands-')); + callbacks = new Map(); + sandbox.stub(vscode.commands, 'registerCommand').callsFake((command, callback) => { + callbacks.set(command, callback as (...args: unknown[]) => Promise); + return { dispose: () => { } }; + }); + sendCommandStub = sinon.stub().resolves(); + getTerminalStub = sinon.stub().returns({ + terminal: { show: sinon.stub() }, + dispose: () => { }, + }); + terminalProvider = { + sendAspireCommandToAspireTerminal: sendCommandStub, + getAspireTerminal: getTerminalStub, + } as unknown as AspireTerminalProvider; + resolveCliPathStub = sandbox.stub(cliPathModule, 'resolveCliPath').resolves({ + cliPath: '/resolved/aspire', + available: true, + source: 'configured', + }); + showWorkspaceFolderPickStub = sandbox.stub(vscode.window, 'showWorkspaceFolderPick'); + workspaceFoldersStub = sandbox.stub(vscode.workspace, 'workspaceFolders').value(undefined); + activeTextEditorStub = sandbox.stub(vscode.window, 'activeTextEditor').value(undefined); + getWorkspaceFolderStub = sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(undefined); + getAppHostPathStub = sinon.stub().resolves(null); + tryExecuteDoAppHostStub = sinon.stub().resolves(); + editorCommandProvider = { + getAppHostPath: getAppHostPathStub, + tryExecuteDoAppHost: tryExecuteDoAppHostStub, + } as unknown as AspireEditorCommandProvider; + + registerCliCommands(terminalProvider, editorCommandProvider); + }); + + teardown(() => { + sandbox.restore(); + removeDirectorySafely(tempDir); + }); + + test('init uses the active editor workspace folder without prompting', async () => { + const folderA = createWorkspaceFolder('a', '/repo/a'); + const folderB = createWorkspaceFolder('b', '/repo/b'); + const target = workspaceFolderCliPathTarget(folderB); + workspaceFoldersStub.value([folderA, folderB]); + activeTextEditorStub.value({ document: { uri: vscode.Uri.file('/repo/b/Program.cs') } }); + getWorkspaceFolderStub.returns(folderB); + + await callbacks.get('aspire-vscode.init')!(); + + assert.strictEqual(showWorkspaceFolderPickStub.called, false); + assert.ok(resolveCliPathStub.calledOnceWith(target)); + assert.ok(sendCommandStub.calledOnceWith('init', true, undefined, { target, cliPath: '/resolved/aspire' })); + }); + + test('new prompts once in a multi-root window and reuses the selected target', async () => { + const folderA = createWorkspaceFolder('a', '/repo/a'); + const folderB = createWorkspaceFolder('b', '/repo/b'); + const target = workspaceFolderCliPathTarget(folderB); + workspaceFoldersStub.value([folderA, folderB]); + showWorkspaceFolderPickStub.resolves(folderB); + + await callbacks.get('aspire-vscode.new')!(); + + assert.strictEqual(showWorkspaceFolderPickStub.calledOnce, true); + assert.ok(resolveCliPathStub.calledOnceWith(target)); + assert.ok(sendCommandStub.calledOnceWith('new', true, undefined, { target, cliPath: '/resolved/aspire' })); + }); + + test('workspace folder selection cancellation prevents the gate and command body', async () => { + workspaceFoldersStub.value([ + createWorkspaceFolder('a', '/repo/a'), + createWorkspaceFolder('b', '/repo/b'), + ]); + showWorkspaceFolderPickStub.resolves(undefined); + + await callbacks.get('aspire-vscode.init')!(); + + assert.strictEqual(showWorkspaceFolderPickStub.calledOnce, true); + assert.strictEqual(resolveCliPathStub.called, false); + assert.strictEqual(sendCommandStub.called, false); + }); + + test('open terminal uses the only workspace folder without prompting', async () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + const target = workspaceFolderCliPathTarget(folder); + workspaceFoldersStub.value([folder]); + + await callbacks.get('aspire-vscode.openTerminal')!(); + + assert.strictEqual(showWorkspaceFolderPickStub.called, false); + assert.ok(resolveCliPathStub.calledOnceWith(target)); + assert.ok(getTerminalStub.calledOnceWith(false, target, '/resolved/aspire')); + }); + + test('init uses the window target when no workspace folders are open', async () => { + await callbacks.get('aspire-vscode.init')!(); + + assert.strictEqual(showWorkspaceFolderPickStub.called, false); + assert.ok(resolveCliPathStub.calledOnceWith(windowCliPathTarget)); + assert.ok(sendCommandStub.calledOnceWith('init', true, undefined, { target: windowCliPathTarget, cliPath: '/resolved/aspire' })); + }); + + test('add resolves the AppHost once and uses its workspace target for gate and terminal', async () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + const appHostPath = '/repo/a/AppHost/AppHost.csproj'; + const target = workspaceFolderCliPathTarget(folder); + getAppHostPathStub.resolves(appHostPath); + getWorkspaceFolderStub.returns(folder); + + await callbacks.get('aspire-vscode.add')!(); + + assert.strictEqual(getAppHostPathStub.calledOnce, true); + assert.ok(resolveCliPathStub.calledOnceWith(target)); + assert.ok(sendCommandStub.calledOnceWith('add', true, ['--apphost', appHostPath], { target, cliPath: '/resolved/aspire' })); + }); + + test('do uses the resolved AppHost target for capability probing and execution', async () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + const appHostPath = '/repo/a/AppHost/AppHost.csproj'; + const target = workspaceFolderCliPathTarget(folder); + getAppHostPathStub.resolves(appHostPath); + getWorkspaceFolderStub.returns(folder); + const hasCapabilityStub = sandbox.stub(ConfigInfoProvider.prototype, 'hasCapability').resolves(true); + + await callbacks.get('aspire-vscode.do')!(); + + assert.strictEqual(getAppHostPathStub.calledOnce, true); + assert.ok(resolveCliPathStub.calledOnceWith(target)); + assert.ok(hasCapabilityStub.calledOnceWith('pipelines', { target, cliPath: '/resolved/aspire' })); + assert.ok(tryExecuteDoAppHostStub.calledOnceWith(false, undefined, appHostPath, target, '/resolved/aspire')); + }); + + test('do rejects a missing AppHost before probing the CLI', async () => { + const hasCapabilityStub = sandbox.stub(ConfigInfoProvider.prototype, 'hasCapability').resolves(true); + const showErrorMessageStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + + await callbacks.get('aspire-vscode.do')!(); + + assert.strictEqual(getAppHostPathStub.calledOnce, true); + assert.strictEqual(resolveCliPathStub.called, false); + assert.strictEqual(hasCapabilityStub.called, false); + assert.strictEqual(tryExecuteDoAppHostStub.called, false); + assert.strictEqual(showErrorMessageStub.calledOnce, true); + }); + + test('add without an AppHost selects one workspace folder for the gate and terminal', async () => { + const folderA = createWorkspaceFolder('a', '/repo/a'); + const folderB = createWorkspaceFolder('b', '/repo/b'); + const target = workspaceFolderCliPathTarget(folderB); + workspaceFoldersStub.value([folderA, folderB]); + showWorkspaceFolderPickStub.resolves(folderB); + + await callbacks.get('aspire-vscode.add')!(); + + assert.strictEqual(getAppHostPathStub.calledOnce, true); + assert.strictEqual(showWorkspaceFolderPickStub.calledOnce, true); + assert.ok(resolveCliPathStub.calledOnceWith(target)); + assert.ok(sendCommandStub.calledOnceWith('add', true, undefined, { target, cliPath: '/resolved/aspire' })); + }); + + test('local settings use the selected folder while global settings stay window scoped', async () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + const target = workspaceFolderCliPathTarget(folder); + workspaceFoldersStub.value([folder]); + const localSettingsPath = path.join(tempDir, 'local.json'); + const globalSettingsPath = path.join(tempDir, 'global.json'); + fs.writeFileSync(localSettingsPath, '{}'); + fs.writeFileSync(globalSettingsPath, '{}'); + const getConfigInfoStub = sandbox.stub(ConfigInfoProvider.prototype, 'getConfigInfo').resolves({ + localSettingsPath, + globalSettingsPath, + } as any); + sandbox.stub(vscode.workspace, 'openTextDocument').resolves({} as vscode.TextDocument); + sandbox.stub(vscode.window, 'showTextDocument').resolves({} as vscode.TextEditor); + + await callbacks.get('aspire-vscode.openLocalSettings')!(); + await callbacks.get('aspire-vscode.openGlobalSettings')!(); + + assert.ok(getConfigInfoStub.firstCall.calledWith({ target, cliPath: '/resolved/aspire' })); + assert.ok(getConfigInfoStub.secondCall.calledWith({ target: windowCliPathTarget })); + }); + + test('update self remains window scoped without a CLI availability gate', async () => { + workspaceFoldersStub.value([createWorkspaceFolder('a', '/repo/a')]); + + await callbacks.get('aspire-vscode.updateSelf')!(); + + assert.strictEqual(resolveCliPathStub.called, false); + assert.ok(sendCommandStub.calledOnceWith('update --self', true, undefined, { target: windowCliPathTarget })); + }); +}); \ No newline at end of file diff --git a/extension/src/test/registerCodeLensCommands.test.ts b/extension/src/test/registerCodeLensCommands.test.ts new file mode 100644 index 00000000000..2e093ce59be --- /dev/null +++ b/extension/src/test/registerCodeLensCommands.test.ts @@ -0,0 +1,55 @@ +/// + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { registerCodeLensCommands } from '../activation/registerCodeLensCommands'; +import { AppHostDataRepository } from '../data/AppHostDataRepository'; +import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; +import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; +import { createWorkspaceFolder } from './testHelpers'; + +suite('registerCodeLensCommands', () => { + test('log commands use the workspace folder that owns the AppHost path', async () => { + const sandbox = sinon.createSandbox(); + const callbacks = new Map Promise>(); + sandbox.stub(vscode.commands, 'registerCommand').callsFake((command, callback) => { + callbacks.set(command, callback as (...args: unknown[]) => Promise); + return { dispose: () => { } }; + }); + sandbox.stub(vscode.languages, 'registerCodeLensProvider').returns({ dispose: () => { } }); + const folder = createWorkspaceFolder('a', '/repo/a'); + const target = workspaceFolderCliPathTarget(folder); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(folder); + const sendCommandStub = sinon.stub().resolves(); + const terminalProvider = { + sendAspireCommandToAspireTerminal: sendCommandStub, + } as unknown as AspireTerminalProvider; + const treeProvider = { + onDidChangeTreeData: () => ({ dispose: () => { } }), + } as unknown as AspireAppHostTreeProvider; + const registrations = registerCodeLensCommands( + treeProvider, + {} as vscode.TreeView, + { viewMode: 'global' } as AppHostDataRepository, + terminalProvider, + {} as AspireEditorCommandProvider, + { get: () => undefined, update: async () => { } } as unknown as vscode.Memento, + ); + const appHostPath = '/repo/a/AppHost/AppHost.csproj'; + + try { + await callbacks.get('aspire-vscode.codeLensViewLogs')!('api', appHostPath); + await callbacks.get('aspire-vscode.codeLensViewAppHostLogs')!(appHostPath); + + assert.deepStrictEqual(sendCommandStub.firstCall.args[3], { target }); + assert.deepStrictEqual(sendCommandStub.secondCall.args[3], { target }); + } + finally { + registrations.forEach(registration => registration.dispose()); + sandbox.restore(); + } + }); +}); \ No newline at end of file diff --git a/extension/src/test/resourceCommandArguments.test.ts b/extension/src/test/resourceCommandArguments.test.ts index ec9bf7179f7..d6d758c59b6 100644 --- a/extension/src/test/resourceCommandArguments.test.ts +++ b/extension/src/test/resourceCommandArguments.test.ts @@ -15,6 +15,7 @@ import { import { createResourceCommandArgumentLoader } from '../views/ResourceCommandArgumentsLoader'; import { ResourceCommandArgumentInputJson } from '../data/AppHostDataRepository'; import { extensionLogOutputChannel } from '../utils/logging'; +import { workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; function makeInput(overrides: Partial = {}): ResourceCommandArgumentInputJson { return { @@ -440,6 +441,42 @@ suite('ResourceCommandArguments', () => { } }); + test('shared dynamic argument loader resolves the CLI using the target derived from the appHost path', async () => { + const withProgressStub = sinon.stub(vscode.window, 'withProgress').callsFake((_options: any, task: any) => task(undefined, undefined)); + const folder = { name: 'a', index: 0, uri: vscode.Uri.file('/repo') } as vscode.WorkspaceFolder; + const getWorkspaceFolderStub = sinon.stub(vscode.workspace, 'getWorkspaceFolder').returns(folder); + const getAspireCliExecutablePathStub = sinon.stub().resolves('/repo/bin/aspire'); + const terminalProvider = { + getAspireCliExecutablePath: getAspireCliExecutablePathStub, + } as unknown as AspireTerminalProvider; + const spawnStub = sinon.stub(cliModule, 'spawnCliProcess').callsFake((_terminalProvider, _command, _args, options) => { + queueMicrotask(() => { + options?.stdoutCallback?.('[]'); + options?.exitCallback?.(0); + }); + + return { stdin: { end: () => { } } } as any; + }); + + try { + const loader = createResourceCommandArgumentLoader({ + cliExecutionProvider: terminalProvider, + resourceName: 'argument-commands', + commandName: 'dependent-arguments', + appHostPath: '/repo/AppHost.csproj', + }); + + await loader([]); + + assert.ok(getAspireCliExecutablePathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); + } + finally { + spawnStub.restore(); + withProgressStub.restore(); + getWorkspaceFolderStub.restore(); + } + }); + test('shared dynamic argument loader retries without nologo when an older CLI rejects it', async () => { const withProgressStub = sinon.stub(vscode.window, 'withProgress').callsFake((_options: any, task: any) => task(undefined, undefined)); const warningStub = sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined); diff --git a/extension/src/test/resourceStateUtils.test.ts b/extension/src/test/resourceStateUtils.test.ts index 795e26232f3..07ea7fcd3f3 100644 --- a/extension/src/test/resourceStateUtils.test.ts +++ b/extension/src/test/resourceStateUtils.test.ts @@ -6,6 +6,7 @@ import { findResourceState, findWorkspaceResourceState, matchesAppHostPathOrDire import type { ResourceJson, AppHostDisplayInfo } from '../data/AppHostDataRepository'; import { ResourceState } from '../editor/resourceConstants'; +import { removeDirectorySafely } from './testHelpers'; function makeResource(overrides: Partial = {}): ResourceJson { return { name: 'my-service', @@ -49,7 +50,7 @@ function createSymlinkedWorkspace(): { canonicalDirectory, linkedDirectory, dispose() { - fs.rmSync(tempDirectory, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); + removeDirectorySafely(tempDirectory); }, }; } @@ -232,7 +233,7 @@ suite('matchesAppHostPathOrDirectory', () => { if (loopCreated) { fs.unlinkSync(loopPath); } - fs.rmSync(tempDirectory, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); + removeDirectorySafely(tempDirectory); } }); diff --git a/extension/src/test/rpc/interactionServiceTests.test.ts b/extension/src/test/rpc/interactionServiceTests.test.ts index 069b96d8ec7..8b02021f7d1 100644 --- a/extension/src/test/rpc/interactionServiceTests.test.ts +++ b/extension/src/test/rpc/interactionServiceTests.test.ts @@ -13,6 +13,11 @@ import { AspireDebugSession } from '../../debugger/AspireDebugSession'; import { dashboardDefaultChangedNotificationKey } from '../../utils/dashboardNotificationState'; import { AspireExtensionContext } from '../../AspireExtensionContext'; import { debugSessionStopTimedOut } from '../../loc/strings'; +import { appHostSelectionOriginConfigKey } from '../../debugger/AspireDebugConfigurationMetadata'; +import { AspireDebugConfigurationProvider } from '../../debugger/AspireDebugConfigurationProvider'; +import type { AspireExtendedDebugConfiguration } from '../../dcp/types'; +import type { AppHostDiscoveryService } from '../../utils/appHostDiscovery'; +import * as cliPathModule from '../../utils/cliPath'; suite('InteractionService endpoints', () => { let statusBarItem: vscode.StatusBarItem; @@ -171,6 +176,7 @@ suite('InteractionService endpoints', () => { }, }); + assert.ok(startDebuggingStub.calledOnce); const debugConfiguration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; assert.deepStrictEqual(debugConfiguration.env, { ASPIRE_HOME: '/isolated/aspire-home', @@ -181,6 +187,199 @@ suite('InteractionService endpoints', () => { } }); + test('startDebugSession carries explicit CLI AppHost selection origin', async () => { + const testInfo = await createTestRpcServer(); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const options = { + command: 'run', + appHostSelectionOrigin: 'explicit-cli' as const, + }; + + try { + await testInfo.interactionService.startDebugSession( + '/workspace', + '/workspace/apphost.cs', + false, + options); + + assert.ok(startDebuggingStub.calledOnce); + const debugConfiguration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(debugConfiguration[appHostSelectionOriginConfigKey], 'explicit-cli'); + } + finally { + startDebuggingStub.restore(); + } + }); + + test('explicit CLI selection origin survives both resolver stages and reaches the spawned CLI', async () => { + // `aspire run --apphost ` typed in an Aspire terminal only preserves the recorded + // default when `explicit-cli` reaches the spawned CLI: AspireDebugSession omits `--apphost` + // for a directory target under every other origin, so the child CLI falls back to discovery + // and rewrites the default. VS Code resolves a configuration in two stages and can hand a + // cloned object to each, and `ensureAppHostSelectionOrigin` backfills an absent origin with + // `explicit-launch-configuration`. A dropped value is therefore silent: the session still + // starts, just against the wrong AppHost. The test above and the AspireDebugSession tests + // cover the producer and the consumer, but both stub the resolver stages out, so this test + // runs the real InteractionService, both real resolver stages, and a real AspireDebugSession + // to protect the whole handoff. + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aspire-explicit-cli-origin-')); + const appHostDirectory = path.join(workspaceRoot, 'Alternate'); + await fs.mkdir(appHostDirectory); + await fs.writeFile( + path.join(appHostDirectory, 'apphost.cs'), + 'var builder = DistributedApplication.CreateBuilder(args);\nbuilder.Build().Run();'); + + const testInfo = await createTestRpcServer(); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const resolveCliPathStub = sinon.stub(cliPathModule, 'resolveCliPath') + .resolves({ cliPath: 'aspire', available: true, source: 'path' }); + + try { + await testInfo.interactionService.startDebugSession( + workspaceRoot, + appHostDirectory, + false, + { command: 'run', appHostSelectionOrigin: 'explicit-cli' }); + + assert.ok(startDebuggingStub.calledOnce); + const launchedConfiguration = startDebuggingStub.firstCall.args[1] as AspireExtendedDebugConfiguration; + + const provider = new AspireDebugConfigurationProvider( + createDirectoryAppHostDiscoveryService(), + { + tryReserveExternalLaunch: () => 'reservation-1', + validateOrReacquireExternalLaunchReservation: (_appHostPath, reservationId) => reservationId, + replaceExternalLaunchReservation: () => 'reservation-1', + releaseExternalLaunchReservation: () => { }, + prepareLaunchArguments: async (_appHostPath, _command, args) => ({ args }), + }, + createTestMemento()); + const workspaceFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file(workspaceRoot), + name: 'workspace', + index: 0, + }; + + // Clone between the stages the way VS Code does when it carries a configuration across + // the resolver boundary. Passing one shared object straight through would let an origin + // that only survives by reference identity look like it survived the real handoff. + const firstStage = await provider.resolveDebugConfiguration( + workspaceFolder, + cloneDebugConfigurationAcrossResolverStage(launchedConfiguration)) as AspireExtendedDebugConfiguration | undefined; + assert.ok(firstStage); + + const secondStage = await provider.resolveDebugConfigurationWithSubstitutedVariables( + workspaceFolder, + cloneDebugConfigurationAcrossResolverStage(firstStage)) as AspireExtendedDebugConfiguration | undefined; + assert.ok(secondStage); + assert.strictEqual(secondStage[appHostSelectionOriginConfigKey], 'explicit-cli'); + assert.strictEqual(secondStage.program, appHostDirectory); + + const parentDebugSession = { + id: 'aspire-session', + type: 'aspire', + name: 'Aspire', + workspaceFolder: undefined, + configuration: secondStage, + customRequest: sinon.stub(), + getDebugProtocolBreakpoint: sinon.stub(), + }; + const aspireDebugSession = new AspireDebugSession( + parentDebugSession as unknown as vscode.DebugSession, + {} as any, + {} as any, + { isCliDebugLoggingEnabled: () => false } as any, + () => { }); + const spawnStub = sinon.stub(aspireDebugSession, 'spawnAspireCommand').resolves(); + + try { + aspireDebugSession.handleMessage({ command: 'launch', seq: 1, arguments: { noDebug: true } }); + await waitForStub(spawnStub); + + assert.deepStrictEqual(spawnStub.firstCall.args[0], [ + 'run', + '--nologo', + '--apphost', + appHostDirectory, + ]); + assert.deepStrictEqual(spawnStub.firstCall.args[4], [{ + name: 'ASPIRE_CLI_APPHOST_SELECTION_ORIGIN', + value: 'explicit-cli', + }]); + } + finally { + spawnStub.restore(); + } + } + finally { + resolveCliPathStub.restore(); + startDebuggingStub.restore(); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + test('startDebugSession preserves user selection fallback for older CLIs', async () => { + const testInfo = await createTestRpcServer(); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + + try { + await testInfo.interactionService.startDebugSession( + '/workspace', + '/workspace/apphost.cs', + false); + + assert.ok(startDebuggingStub.calledOnce); + const debugConfiguration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(debugConfiguration[appHostSelectionOriginConfigKey], 'user-selection'); + } + finally { + startDebuggingStub.restore(); + } + }); + + test('startDebugSession preserves default discovery fallback for older CLIs', async () => { + const testInfo = await createTestRpcServer(); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + + try { + await testInfo.interactionService.startDebugSession( + '/workspace', + null, + false); + + assert.ok(startDebuggingStub.calledOnce); + const debugConfiguration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(debugConfiguration[appHostSelectionOriginConfigKey], 'default-discovery'); + } + finally { + startDebuggingStub.restore(); + } + }); + + test('startDebugSession carries explicit default discovery origin', async () => { + const testInfo = await createTestRpcServer(); + const startDebuggingStub = sinon.stub(vscode.debug, 'startDebugging').resolves(true); + const options = { + command: 'run', + appHostSelectionOrigin: 'default-discovery' as const, + }; + + try { + await testInfo.interactionService.startDebugSession( + '/workspace', + '/workspace/apphost.cs', + false, + options); + + assert.ok(startDebuggingStub.calledOnce); + const debugConfiguration = startDebuggingStub.firstCall.args[1] as vscode.DebugConfiguration; + assert.strictEqual(debugConfiguration[appHostSelectionOriginConfigKey], 'default-discovery'); + } + finally { + startDebuggingStub.restore(); + } + }); + test('displayError endpoint', async () => { const testInfo = await createTestRpcServer(); const showErrorMessageSpy = sinon.spy(vscode.window, 'showErrorMessage'); @@ -1264,3 +1463,35 @@ async function createTestRpcServer(debugSessionId?: string | null, getAspireDebu interactionService: rpcClient.interactionService }; } + +/** + * Round-trips a debug configuration through JSON the way VS Code does when it carries one across + * the two resolver stages. Every property the provider relies on is a plain string-keyed value, so + * this preserves exactly what a real cross-stage handoff preserves and drops what it drops. + */ +function cloneDebugConfigurationAcrossResolverStage(configuration: vscode.DebugConfiguration): vscode.DebugConfiguration { + return JSON.parse(JSON.stringify(configuration)) as vscode.DebugConfiguration; +} + +/** + * Discovery that leaves a directory launch target alone, matching a workspace where the CLI already + * picked the AppHost directory and the extension has no better concrete candidate to substitute. + */ +function createDirectoryAppHostDiscoveryService(): AppHostDiscoveryService { + return { + resolveDebugTarget: async (filePath: string) => filePath, + tryFindWorkspaceDefaultCandidate: async () => undefined, + tryFindCandidateForEditorFile: async () => undefined, + } as unknown as AppHostDiscoveryService; +} + +async function waitForStub(stub: sinon.SinonStub, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (!stub.called) { + if (Date.now() > deadline) { + throw new Error('Timed out waiting for the Aspire CLI to be spawned.'); + } + + await new Promise(resolve => setTimeout(resolve, 10)); + } +} diff --git a/extension/src/test/strings.test.ts b/extension/src/test/strings.test.ts index aef58449b60..cdaf657b2d2 100644 --- a/extension/src/test/strings.test.ts +++ b/extension/src/test/strings.test.ts @@ -1,7 +1,8 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; -import { launchingWithAppHost, launchingWithDirectory } from '../loc/strings'; +import * as locStrings from '../loc/strings'; +import { defaultConfigurationNameForWorkspaceFolder, launchingWithAppHost, launchingWithDirectory } from '../loc/strings'; import { collapseWhitespace, escapeCodicons, formatText } from '../utils/strings'; suite('utils/strings tests', () => { @@ -61,6 +62,30 @@ suite('utils/strings tests', () => { } }); + test('AppHost isolation compatibility loc strings are present in package.nls.json and the generated XLF catalog', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const stringsSource = fs.readFileSync(path.join(extensionRoot, 'src', 'loc', 'strings.ts'), 'utf8'); + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + const xlf = fs.readFileSync(path.join(extensionRoot, 'loc', 'xlf', 'aspire-vscode.xlf'), 'utf8'); + const runtimeStrings = locStrings as Record; + + const expectedStrings = { + appHostLifecycleIsolationModeNotSupported: 'The selected Aspire CLI does not support the requested isolation mode.', + appHostLifecycleIsolationCapabilityCouldNotBeVerified: 'The selected Aspire CLI isolation capability could not be verified.', + }; + + for (const [name, value] of Object.entries(expectedStrings)) { + assert.strictEqual(runtimeStrings[name], value, `Expected ${name} to be exported from strings.ts.`); + + const escapedValue = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const declaration = new RegExp( + `export\\s+const\\s+${name}\\s*=\\s*vscode\\.l10n\\.t\\(\\s*(['"\`])${escapedValue}\\1\\s*\\)`); + assert.match(stringsSource, declaration, `Expected ${name} to be registered in strings.ts with the value "${value}".`); + assert.strictEqual(packageNls[`aspire-vscode.strings.${name}`], value); + assert.ok(xlf.includes(``), `Regenerate loc/xlf/aspire-vscode.xlf with "yarn run localize" after adding ${name}.`); + } + }); + test('Rust loc strings are present in package.nls.json and the generated XLF catalog', () => { // package.nls.json is the only input to the XLF catalog (see gulpfile.js), so a string that // only exists in strings.ts is shipped untranslated. Guard the Rust debugger strings, which @@ -80,9 +105,47 @@ suite('utils/strings tests', () => { const missingFromXlf = names.filter(name => !xlf.includes(``)); assert.deepStrictEqual(missingFromXlf, [], 'Regenerate loc/xlf/aspire-vscode.xlf with "yarn run localize" after adding package.nls.json entries.'); }); + + test('Java loc strings are present in package.nls.json and the generated XLF catalog', () => { + // Same guard as the Rust strings above: package.nls.json is the only input to the XLF + // catalog (see gulpfile.js), so a Java string that only exists in strings.ts ships + // untranslated. + const extensionRoot = path.resolve(__dirname, '..', '..'); + const stringsSource = fs.readFileSync(path.join(extensionRoot, 'src', 'loc', 'strings.ts'), 'utf8'); + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + const xlf = fs.readFileSync(path.join(extensionRoot, 'loc', 'xlf', 'aspire-vscode.xlf'), 'utf8'); + + // The Spring Boot Dashboard lens strings are matched explicitly because they belong to the Java + // experience but are named after the code lens they render in, so a java* prefix scan misses them. + const declarationPattern = /export\s+const\s+(java[A-Za-z0-9_]*|codeLensSpringBoot[A-Za-z0-9_]*)\s*=\s*(?:\([^)]*\)\s*=>\s*)?vscode\.l10n\.t\(/g; + const names = [...stringsSource.matchAll(declarationPattern)].map(match => match[1]); + assert.ok(names.includes('javaDisplayName'), 'Expected javaDisplayName to be declared in strings.ts.'); + assert.ok(names.includes('javaLabel'), 'Expected javaLabel to be declared in strings.ts.'); + assert.ok( + names.includes('codeLensSpringBootDashboardBypassesAspire'), + 'Expected codeLensSpringBootDashboardBypassesAspire to be declared in strings.ts.'); + + const missingFromNls = names.filter(name => packageNls[`aspire-vscode.strings.${name}`] === undefined); + assert.deepStrictEqual(missingFromNls, [], 'Every Java loc string needs an aspire-vscode.strings.* entry in package.nls.json.'); + + const missingFromXlf = names.filter(name => !xlf.includes(``)); + assert.deepStrictEqual(missingFromXlf, [], 'Regenerate loc/xlf/aspire-vscode.xlf with "yarn run localize" after adding package.nls.json entries.'); + }); }); suite('loc/strings tests', () => { + test('formats and registers workspace-specific default configuration names', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + + assert.strictEqual( + defaultConfigurationNameForWorkspaceFolder('src', 'file:///workspace/repo-with-apphost'), + 'Aspire: Launch default AppHost (src: file:///workspace/repo-with-apphost)'); + assert.strictEqual( + packageNls['aspire-vscode.strings.defaultConfigurationNameForWorkspaceFolder'], + 'Aspire: Launch default AppHost ({0}: {1})'); + }); + test('formats launch messages with the session type', () => { assert.deepStrictEqual( [ @@ -117,4 +180,85 @@ suite('loc/strings tests', () => { launchingRunWithAppHost: 'Launching Aspire run session for AppHost {0}...', }); }); + + test('no new loc string ships without a package.nls.json entry', () => { + // The two guards above only cover the rust*/java* prefixes, so the configuredCliPath* strings + // were added, shipped English-only, and passed CI - package.nls.json is the only input to the + // XLF catalog (see gulpfile.js), so a string that exists only in strings.ts never reaches + // translators. This guard covers every vscode.l10n.t export instead of a prefix, so the next + // omission fails here rather than in a localization drop. + // + // The allowlist is the set that predates the guard. It is deliberately an explicit list rather + // than a count: entries may be removed as strings are localized, but adding one means shipping + // a user-visible string untranslated and should be a conscious decision in review. + const knownUnlocalized = new Set([ + 'appHostStoppingDescription', + 'aspireDashboard', + 'aspireDebugSessionNotInitialized', + 'browserDisplayName', + 'browserLabel', + 'codeLensCommand', + 'codeLensDebugPipelineStep', + 'codeLensOpenDashboard', + 'codeLensResourceFailedToStart', + 'codeLensResourceFailedToStartError', + 'codeLensResourceNotStarted', + 'codeLensResourceRunning', + 'codeLensResourceRunningError', + 'codeLensResourceRunningWarning', + 'codeLensResourceRuntimeUnhealthy', + 'codeLensResourceStarting', + 'codeLensResourceStopped', + 'codeLensResourceStoppedError', + 'codeLensResourceStoppedErrorWithExitCode', + 'codeLensResourceStoppedWithExitCode', + 'codeLensResourceStopping', + 'codeLensResourceValueMissing', + 'codeLensResourceWaiting', + 'codeLensRestart', + 'codeLensRustAppHostAlreadyRunning', + 'codeLensRustAppHostAlreadyRunningTooltip', + 'codeLensRustAppHostUseAspire', + 'codeLensRustAppHostUseAspireTooltip', + 'codeLensStart', + 'codeLensStop', + 'codeLensViewAppHostLogs', + 'codeLensViewLogs', + 'dashboardLabel', + 'defaultConfigurationName', + 'errorFetchingAppHosts', + 'errorMessage', + 'healthCheckDescription', + 'healthChecksLabel', + 'logFileLabel', + 'pidDescription', + 'resourceCommandLogOpenFailed', + 'resourceCommandOpenAppHostLog', + 'resourceCommandOpenCliLog', + 'resourceDescriptionExitCode', + 'resourceDescriptionHealth', + 'rpcServerAddressError', + 'settingsLabel', + 'tooltipEndpoints', + 'tooltipHealth', + 'tooltipState', + 'tooltipType', + ]); + + const extensionRoot = path.resolve(__dirname, '..', '..'); + const stringsSource = fs.readFileSync(path.join(extensionRoot, 'src', 'loc', 'strings.ts'), 'utf8'); + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + + const declarationPattern = /export\s+const\s+([A-Za-z0-9_]+)\s*=\s*(?:\([^)]*\)\s*=>\s*)?vscode\.l10n\.t\(/g; + const names = [...stringsSource.matchAll(declarationPattern)].map(match => match[1]); + assert.ok(names.length > 200, `Expected the declaration scan to find the loc strings, found ${names.length}.`); + + const newlyMissing = names + .filter(name => packageNls[`aspire-vscode.strings.${name}`] === undefined) + .filter(name => !knownUnlocalized.has(name)); + assert.deepStrictEqual(newlyMissing, [], 'Add an aspire-vscode.strings.* entry to package.nls.json for these, then run "yarn run localize".'); + + const localizedButAllowlisted = [...knownUnlocalized].filter(name => packageNls[`aspire-vscode.strings.${name}`] !== undefined); + assert.deepStrictEqual(localizedButAllowlisted, [], 'These strings are localized now - remove them from knownUnlocalized.'); + }); }); diff --git a/extension/src/test/testGitWorktree.ts b/extension/src/test/testGitWorktree.ts new file mode 100644 index 00000000000..d49d5c4dd27 --- /dev/null +++ b/extension/src/test/testGitWorktree.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export function writeLinkedWorktreeMetadata( + worktreeRoot: string, + commonGitDirectory: string, + worktreeName = 'feature', + useRelativePaths = false): string { + const fullWorktreeRoot = path.resolve(worktreeRoot); + const adminDirectory = path.join(path.resolve(commonGitDirectory), 'worktrees', worktreeName); + fs.mkdirSync(adminDirectory, { recursive: true }); + + const gitFilePath = writeGitDirFile(fullWorktreeRoot, adminDirectory, useRelativePaths); + const backPointer = useRelativePaths + ? path.relative(adminDirectory, gitFilePath) + : gitFilePath; + fs.writeFileSync(path.join(adminDirectory, 'gitdir'), `${backPointer}\n`); + + return adminDirectory; +} + +export function writeGitDirFile( + checkoutRoot: string, + gitDirectory: string, + useRelativePath = false): string { + const fullCheckoutRoot = path.resolve(checkoutRoot); + const fullGitDirectory = path.resolve(gitDirectory); + fs.mkdirSync(fullCheckoutRoot, { recursive: true }); + fs.mkdirSync(fullGitDirectory, { recursive: true }); + + const gitFilePath = path.join(fullCheckoutRoot, '.git'); + const pointer = useRelativePath + ? path.relative(fullCheckoutRoot, fullGitDirectory) + : fullGitDirectory; + fs.writeFileSync(gitFilePath, `gitdir: ${pointer}\n`); + + return gitFilePath; +} diff --git a/extension/src/test/testHelpers.ts b/extension/src/test/testHelpers.ts new file mode 100644 index 00000000000..c3a618e641a --- /dev/null +++ b/extension/src/test/testHelpers.ts @@ -0,0 +1,126 @@ +import * as fs from 'node:fs'; +import * as vscode from 'vscode'; + +export function languageIdForPath(filePath: string): string { + if (filePath.endsWith('.cs')) { return 'csharp'; } + if (filePath.endsWith('.ts')) { return 'typescript'; } + if (filePath.endsWith('.rs')) { return 'rust'; } + if (filePath.endsWith('.java')) { return 'java'; } + if (filePath.endsWith('.py')) { return 'python'; } + if (filePath.endsWith('.go')) { return 'go'; } + return 'javascript'; +} + +export function createMockDocument(content: string, filePath: string): vscode.TextDocument { + const lines = content.split('\n'); + return { + uri: vscode.Uri.file(filePath), + fileName: filePath, + isUntitled: false, + languageId: languageIdForPath(filePath), + version: 1, + isDirty: false, + isClosed: false, + eol: vscode.EndOfLine.LF, + lineCount: lines.length, + encoding: 'utf-8', + save: () => Promise.resolve(false), + lineAt: (lineOrPos: number | vscode.Position) => { + const lineNum = typeof lineOrPos === 'number' ? lineOrPos : lineOrPos.line; + const text = lines[lineNum] || ''; + return { + lineNumber: lineNum, + text, + range: new vscode.Range(lineNum, 0, lineNum, text.length), + rangeIncludingLineBreak: new vscode.Range(lineNum, 0, lineNum + 1, 0), + firstNonWhitespaceCharacterIndex: text.search(/\S/), + isEmptyOrWhitespace: text.trim().length === 0, + } as vscode.TextLine; + }, + offsetAt: (position: vscode.Position) => { + let offset = 0; + for (let i = 0; i < position.line && i < lines.length; i++) { + offset += lines[i].length + 1; + } + return offset + position.character; + }, + positionAt: (offset: number) => { + let remaining = offset; + for (let i = 0; i < lines.length; i++) { + if (remaining <= lines[i].length) { + return new vscode.Position(i, remaining); + } + remaining -= lines[i].length + 1; + } + return new vscode.Position(lines.length - 1, lines[lines.length - 1].length); + }, + getText: (range?: vscode.Range) => { + if (!range) { + return content; + } + const startOffset = lines.slice(0, range.start.line).reduce((sum, line) => sum + line.length + 1, 0) + range.start.character; + const endOffset = lines.slice(0, range.end.line).reduce((sum, line) => sum + line.length + 1, 0) + range.end.character; + return content.substring(startOffset, endOffset); + }, + getWordRangeAtPosition: () => undefined, + validateRange: (range: vscode.Range) => range, + validatePosition: (position: vscode.Position) => position, + notebook: undefined as any, + } as vscode.TextDocument; +} + +/** + * Returns the platform-native path VS Code produces for a POSIX-style fixture path. + * + * Fixtures spell paths as '/workspace/AppHost.csproj' because they read well, but the code under + * test hands `getWorkspaceFolder` a `vscode.Uri`, whose `fsPath` is '\\workspace\\AppHost.csproj' + * on Windows. Comparing that against the raw literal never matches there, so the stub returns + * undefined and the code silently takes its no-owning-folder fallback instead of failing on the + * comparison itself. Normalising the expected side the same way keeps the two comparable. + */ +export function fsPathOf(posixPath: string): string { + return vscode.Uri.file(posixPath).fsPath; +} + +/** + * Builds a workspace folder whose `fsPath` is exactly `fsPath`, whatever host the tests run on. + * + * `vscode.Uri.file('/repo/a').fsPath` renders with the host's separators, so on Windows it comes back + * as `\\repo\\a`. Any test that hands the result to code taking an explicit platform argument then + * stops testing that argument: the path is already Windows-shaped before the POSIX branch sees it. + */ +export function createWorkspaceFolder(name: string, fsPath: string, index: number = 0): vscode.WorkspaceFolder { + const uri = vscode.Uri.file(fsPath); + + return { + // Shadows the Uri prototype's fsPath getter and leaves every other member intact. + uri: Object.create(uri, { fsPath: { value: fsPath, enumerable: true } }) as vscode.Uri, + name, + index, + }; +} + +/** + * Removes a directory created by a test, tolerating the handle-release races that make plain + * `rmSync` flaky on Windows CI. + * + * Windows releases the handle behind a closed editor or an exited child process asynchronously, so a + * teardown that runs immediately after the test body can still see the directory as in use and throw + * `EPERM, Permission denied`. Mocha fails the hook, which fails the whole run - CI has been broken by + * exactly this in `e2eDiagnosticsProbe` and `aspireEditorCommandProvider`, both times on Windows only + * and neither reproducible on macOS or Linux. + * + * Two things are needed, and `force: true` supplies neither: it only suppresses `ENOENT`. + * - `maxRetries`/`retryDelay`, which Node applies to EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM but only for + * recursive removals (https://nodejs.org/api/fs.html#fsrmsyncpath-options). + * - Swallowing whatever survives the retries. Leaving a temp directory behind is a non-event - the OS + * reclaims it - whereas failing the hook loses the entire test run's signal. + */ +export function removeDirectorySafely(directory: string): void { + try { + fs.rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + catch (error) { + console.warn(`Failed to remove test directory '${directory}': ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/extension/src/test/versionInfo.test.ts b/extension/src/test/versionInfo.test.ts index ac585988653..c416935927f 100644 --- a/extension/src/test/versionInfo.test.ts +++ b/extension/src/test/versionInfo.test.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import * as os from 'os'; import { readVersionFile } from '../utils/versionInfo'; +import { removeDirectorySafely } from './testHelpers'; suite('utils/versionInfo tests', () => { test('readVersionFile returns SHA when .version file exists', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'versioninfo-test-')); @@ -13,7 +14,7 @@ suite('utils/versionInfo tests', () => { const result = readVersionFile(tmpDir); assert.strictEqual(result, sha); } finally { - fs.rmSync(tmpDir, { recursive: true }); + removeDirectorySafely(tmpDir); } }); @@ -25,7 +26,7 @@ suite('utils/versionInfo tests', () => { const result = readVersionFile(tmpDir); assert.strictEqual(result, sha); } finally { - fs.rmSync(tmpDir, { recursive: true }); + removeDirectorySafely(tmpDir); } }); @@ -35,7 +36,7 @@ suite('utils/versionInfo tests', () => { const result = readVersionFile(tmpDir); assert.strictEqual(result, 'unknown'); } finally { - fs.rmSync(tmpDir, { recursive: true }); + removeDirectorySafely(tmpDir); } }); }); diff --git a/extension/src/test/workspace.test.ts b/extension/src/test/workspace.test.ts index 352eb3f29d9..69a797a25ba 100644 --- a/extension/src/test/workspace.test.ts +++ b/extension/src/test/workspace.test.ts @@ -5,10 +5,12 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { yesLabel } from '../loc/strings'; -import { checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace'; +import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace, getCommonExcludeGlob, findAspireSettingsFiles } from '../utils/workspace'; import { AppHostDiscoveryService, getWorkspaceAppHostProjectSearchResult } from '../utils/appHostDiscovery'; import { getAppHostDiscoveryExcludeGlob } from '../utils/workspaceFileSearch'; - +import * as cliPathModule from '../utils/cliPath'; +import { windowCliPathTarget, workspaceFolderCliPathTarget } from '../utils/cliPathVariables'; +import { createWorkspaceFolder, removeDirectorySafely } from './testHelpers'; suite('utils/workspace tests', () => { let sandbox: sinon.SinonSandbox; @@ -20,6 +22,27 @@ suite('utils/workspace tests', () => { sandbox.restore(); }); + suite('checkCliAvailableOrRedirect', () => { + test('forwards the supplied window target to resolveCliPath', async () => { + const resolveCliPathStub = sandbox.stub(cliPathModule, 'resolveCliPath').resolves({ cliPath: 'aspire', available: true, source: 'path' }); + + const result = await checkCliAvailableOrRedirect('command_gate', windowCliPathTarget); + + assert.strictEqual(result.available, true); + assert.ok(resolveCliPathStub.calledOnceWith(windowCliPathTarget)); + }); + + test('forwards the supplied workspace folder target to resolveCliPath', async () => { + const folder = createWorkspaceFolder('a', '/repo/a'); + const resolveCliPathStub = sandbox.stub(cliPathModule, 'resolveCliPath').resolves({ cliPath: '/repo/a/bin/aspire', available: true, source: 'configured' }); + + const result = await checkCliAvailableOrRedirect('debug_gate', workspaceFolderCliPathTarget(folder)); + + assert.strictEqual(result.cliPath, '/repo/a/bin/aspire'); + assert.ok(resolveCliPathStub.calledOnceWith(workspaceFolderCliPathTarget(folder))); + }); + }); + test('getCommonExcludeGlob returns valid glob pattern', () => { const glob = getCommonExcludeGlob(); @@ -208,7 +231,7 @@ suite('utils/workspace tests', () => { status: 'buildable', }); } finally { - fs.rmSync(workspaceRoot, { recursive: true, force: true }); + removeDirectorySafely(workspaceRoot); } }); }); diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index b2f3e345b2f..e416383260b 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -6,7 +6,7 @@ import { AspireExtensionContext } from '../AspireExtensionContext'; import { getLoggableDebugConfiguration, type AspireDebugSession } from '../debugger/AspireDebugSession'; import { createDebugSessionConfiguration, getResourceDebuggerExtensions } from '../debugger/debuggerExtensions'; import { projectDebuggerExtension } from '../debugger/languages/dotnet'; -import { spawnCliProcess } from '../utils/process/cliProcess'; +import { redactCliArgsForLogging, spawnCliProcess, terminateCliProcess } from '../utils/process/cliProcess'; import { cleanupRun } from '../debugger/runCleanupRegistry'; import type { AspireResourceExtendedDebugConfiguration, EnvVar, ExecutableLaunchConfiguration } from '../dcp/types'; import { createStateSnapshot, getSensitiveDashboardUrl, isSamePath } from '../extensionState'; @@ -19,7 +19,10 @@ import { dashboardDefaultChangedNotificationKey } from '../utils/dashboardNotifi import { extensionLogOutputChannel } from '../utils/logging'; import { onDidInvokeCommand } from '../utils/telemetry'; import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; +import { ResourceItem } from '../views/treeItems/resourceItems'; +import { ResourceJson } from '../data/appHostCliContracts'; import { AppHostDataRepository } from '../data/AppHostDataRepository'; +import { getSupportedCapabilities, javaLanguageExtensionId } from '../capabilities'; let atomicWriteSequence = 0; @@ -35,6 +38,9 @@ export function createE2eStateFileBridge( ): vscode.Disposable { const stateFile = process.env.ASPIRE_EXTENSION_E2E_STATE_FILE; const controlFile = process.env.ASPIRE_EXTENSION_E2E_CONTROL_FILE; + // Identifies this run so a host left behind by an earlier run cannot service this run's control + // commands or overwrite its state file — both files live at a stable per-shard path. + const runId = process.env.ASPIRE_EXTENSION_E2E_RUN_ID; if (!isE2eBridgeEnabled() || !stateFile || !controlFile) { return new vscode.Disposable(() => undefined); } @@ -69,6 +75,7 @@ export function createE2eStateFileBridge( writeJsonFileAtomic(stateFile, { updatedAt: new Date().toISOString(), + runId, state, dashboardUrl: getSensitiveDashboardUrl(dataRepository), commandInvocations, @@ -202,7 +209,7 @@ export function createE2eStateFileBridge( return; } - controlProcessing = processE2eControlFile(controlFile, lastControlRevision, async (payload) => { + controlProcessing = processE2eControlFile(controlFile, lastControlRevision, runId, async (payload) => { const revision = payload.revision; lastControlRevision = revision; try { @@ -327,6 +334,7 @@ function sleepSynchronously(milliseconds: number): void { async function processE2eControlFile( controlFile: string, lastControlRevision: number, + runId: string | undefined, applyControl: (payload: AspireExtensionE2EControlPayload) => Promise, ): Promise { let payload: AspireExtensionE2EControlPayload; @@ -346,6 +354,12 @@ async function processE2eControlFile( return; } + // Ignore commands addressed to a different run. Revisions restart at 0 in every test process, so + // without this an extension host from an earlier run would answer — and race the intended host. + if (runId !== undefined && payload.runId !== undefined && payload.runId !== runId) { + return; + } + await applyControl(payload); } @@ -565,6 +579,16 @@ async function executeE2eControlCommand( markStarted(); return getE2eBreakpoints(); } + case 'startDebugging': { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + throw new Error('Aspire extension E2E startDebugging requires an open workspace folder.'); + } + + const commandPromise = vscode.debug.startDebugging(workspaceFolder, command.configurationName); + markStarted(); + return await commandPromise; + } case 'stopDebugging': { markStarted(); await stopDebuggingForE2E(aspireContext, dataRepository, appHostLaunchService, appHostTreeProvider); @@ -644,6 +668,24 @@ async function executeE2eControlCommand( supportedFileTypes: extension.getSupportedFileTypes(), })); } + case 'getSupportedCapabilities': { + markStarted(); + // The capability list is what the CLI asks for before it hands an AppHost to the extension to + // launch, so a spec that needs the extension to debug an AppHost has to be able to see it. + return getSupportedCapabilities(); + } + case 'getVisibleExtensionIds': { + markStarted(); + // Capabilities are derived from vscode.extensions.getExtension, so the only list that explains a + // missing capability is the one the extension host itself can see. The runner already checks the + // extensions directory and extensions.json, but both can be correct while the host still loads + // nothing - a copied extension directory is only scanned while extensions.json is absent. + return vscode.extensions.all.map(extension => extension.id); + } + case 'waitForJavaLanguageServer': { + markStarted(); + return await waitForJavaLanguageServer(command.timeoutMs ?? 900000); + } case 'createResourceDebugConfiguration': { markStarted(); const launchConfig = getE2eLaunchConfiguration(command.launchConfig); @@ -758,6 +800,11 @@ async function executeE2eControlCommand( await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(folderPath), false); return undefined; } + case 'setWorkspaceFolders': { + const folders = getE2eWorkspaceFolderEntries(command.folders); + markStarted(); + return await setE2eWorkspaceFolders(folders); + } case 'stopOwnedDebugSessionProcesses': { markStarted(); const appHostPath = command.appHostPath; @@ -768,9 +815,7 @@ async function executeE2eControlCommand( Promise.allSettled(debugSessions.map(session => session.requestCliStopForExtensionShutdown())), delay(5000), ]); - for (const session of debugSessions) { - session.terminateCliProcessTree({ force: true }); - } + await Promise.allSettled(debugSessions.map(session => session.terminateCliProcessTree({ force: true }))); return undefined; } @@ -782,10 +827,30 @@ async function executeE2eControlCommand( fileName: folder.uri.fsPath, })) ?? []; } + case 'addWorkspaceFolder': { + markStarted(); + return await addWorkspaceFolderForE2E(getE2eAddableWorkspaceFolderPath(command.folderPath)); + } case 'getActiveEditor': { markStarted(); return getActiveEditorInfo(); } + case 'runAspireCli': { + if (!Array.isArray(command.args) || !command.args.every(argument => typeof argument === 'string')) { + throw new Error('Aspire extension E2E runAspireCli args must be an array of strings.'); + } + + const workingDirectory = getE2eRunAspireCliWorkingDirectory(command.workingDirectory); + const timeoutMs = getE2ePositiveInteger(command.timeoutMs, 300000, 'timeoutMs'); + const commandPromise = runAspireCliForE2E( + terminalProvider, + [...command.args], + workingDirectory, + timeoutMs, + terminalProvider.createEnvironment()); + markStarted(); + return await commandPromise; + } default: throw new Error(`Unsupported Aspire extension E2E control command: ${getUnknownCommandName(command)}`); } @@ -923,6 +988,7 @@ async function proveAppHostAndResourceDebugging(command: AppHostAndResourceDebug const resourceName = getE2eRequiredString(command.resourceName, 'Aspire extension E2E debug proof requires resourceName.'); const appHostBreakpointLine = getE2eBreakpointLine(command.appHostBreakpointLine); const resourceBreakpointLine = getE2eBreakpointLine(command.resourceBreakpointLine); + const resourceRequestPath = command.resourceRequestPath ?? '/'; const timeoutMs = getE2ePositiveInteger(command.timeoutMs, 300000, 'timeoutMs'); const debugSessions: DebugSessionSnapshot[] = []; @@ -1048,7 +1114,23 @@ async function proveAppHostAndResourceDebugging(command: AppHostAndResourceDebug } await appHostHit.session.customRequest('continue', { threadId: appHostHit.stoppedEvent.threadId }); - const resourceHit = await waitForBreakpoint(resourceSourcePath, resourceBreakpointLine); + // A breakpoint inside a request handler only runs when a request arrives, and nothing else in the + // run issues one: the health check probes /actuator/health rather than the controller. Without + // driving the traffic here the wait below can only ever time out, which is what it did - the + // resource launched under the debugger and sat idle for the full 15 minutes. + // + // The endpoint wait gets the caller's whole budget rather than a shorter cap of its own. The + // resource is a Spring Boot app that the run still has to compile and start under a debugger, on + // a runner that is already hosting the Java language server, so capping this at five minutes + // reported a timeout while the resource was legitimately still coming up - and reported it as + // "300000ms" even though the spec had asked for fifteen minutes. + const resourceHit = await withResourceTraffic( + appHostTreeProvider, + appHostPath, + resourceName, + resourceRequestPath, + timeoutMs, + () => waitForBreakpoint(resourceSourcePath, resourceBreakpointLine)); if (resourceHit.matchingFrame.line !== resourceBreakpointLine + 1) { throw new Error(`Expected resource breakpoint line ${resourceBreakpointLine + 1}, got ${resourceHit.matchingFrame.line}.`); } @@ -1095,6 +1177,10 @@ async function proveAppHostAndResourceDebugging(command: AppHostAndResourceDebug throw new Error(`${error instanceof Error ? error.message : String(error)} Diagnostics: ${JSON.stringify({ + // The CLI only delegates the AppHost launch to the extension when it advertises the language's + // capability, so a missing entry here is the difference between "the debugger failed" and "the + // debugger was never asked", which the session list alone cannot distinguish. + supportedCapabilities: getSupportedCapabilities(), debugSessions, launchRequests, debugAdapterResponses, @@ -1227,7 +1313,7 @@ async function proveMauiResourceDebugging(command: MauiResourceDebugProofCommand ['resource', resourceName, 'start', '--apphost', appHostPath, '--non-interactive', '--nologo'], path.dirname(appHostPath), resourceStartTimeoutMs, - aspireDebugSession.debugSessionId); + terminalProvider.createDcpRunSessionEnvironment(aspireDebugSession.debugSessionId, false)); let stoppedEvent: { stoppedEvent: DebugAdapterStoppedEvent; stackTrace: { stackFrames?: Array<{ source?: { path?: string }; line?: number }> }; matchingFrame: { source?: { path?: string }; line?: number } }; try { @@ -1352,8 +1438,15 @@ function redactDebugAdapterArguments(value: unknown): unknown { return copy; } -async function runAspireCliForE2E(terminalProvider: AspireTerminalProvider, args: string[], workingDirectory: string, timeoutMs: number, debugSessionId: string): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { +async function runAspireCliForE2E( + terminalProvider: AspireTerminalProvider, + args: string[], + workingDirectory: string, + timeoutMs: number, + environment: Record +): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { const cliPath = await terminalProvider.getAspireCliExecutablePath(); + const diagnosticCommand = [cliPath, ...redactCliArgsForLogging(args)].join(' '); return await new Promise((resolve, reject) => { const stdout: string[] = []; const stderr: string[] = []; @@ -1364,8 +1457,10 @@ async function runAspireCliForE2E(terminalProvider: AspireTerminalProvider, args } completed = true; - child.kill('SIGTERM'); - reject(new Error(`${cliPath} ${args.join(' ')} timed out after ${timeoutMs}ms.\nstdout:\n${stdout.join('')}\nstderr:\n${stderr.join('')}`)); + void terminateCliProcess(child, 'Aspire extension E2E CLI command', { force: true, suppressTimeoutWarning: true }) + .then( + () => reject(new Error(`${diagnosticCommand} timed out after ${timeoutMs}ms.`)), + reject); }, timeoutMs); const child = spawnCliProcess(terminalProvider, cliPath, args, { @@ -1383,7 +1478,7 @@ async function runAspireCliForE2E(terminalProvider: AspireTerminalProvider, args if (code === 0) { resolve(result); } else { - reject(new Error(`${cliPath} ${args.join(' ')} exited with code ${code}.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`)); + reject(new Error(`${diagnosticCommand} exited with code ${code}.`)); } }, errorCallback: error => { @@ -1396,14 +1491,71 @@ async function runAspireCliForE2E(terminalProvider: AspireTerminalProvider, args reject(error); }, noExtensionVariables: true, - env: Object.entries(terminalProvider.createDcpRunSessionEnvironment(debugSessionId, false)) + createProcessGroup: true, + env: Object.entries(environment) .map(([name, value]) => ({ name, value: String(value) })) }); }); } -async function waitForE2eValue(description: string, timeoutMs: number, getValue: () => T | undefined | Promise): Promise { - const started = Date.now(); +/** + * Sends requests to a resource's HTTP endpoint for as long as runs. + * + * A breakpoint in a request handler is only reachable while a request is in flight, so a proof that + * merely waits for one is waiting on a line that nothing will execute. Aspire's own health check is + * not enough: it probes /actuator/health, which is Spring's endpoint rather than the application's. + * + * Requests are issued rather than awaited. The first one that reaches the handler parks on the + * breakpoint and never gets a response, so awaiting it would deadlock against the wait it is meant + * to satisfy; each attempt is abandoned after a short timeout and another is sent behind it. + */ +async function withResourceTraffic( + appHostTreeProvider: AspireAppHostTreeProvider, + appHostPath: string, + resourceName: string, + requestPath: string, + endpointTimeoutMs: number, + waitForHit: () => Promise +): Promise { + const baseUrl = await waitForE2eValue( + `an HTTP endpoint for resource '${resourceName}'`, + endpointTimeoutMs, + () => { + const element = appHostTreeProvider.findEndpointElement({ appHostPath, resourceName }); + return element && hasEndpointUrl(element) ? element.url : undefined; + }, + () => describeResourcesForE2E(appHostTreeProvider, appHostPath, resourceName)); + + // A relative path resolves against the endpoint only when the base ends in '/'; without it the + // last segment of the endpoint would be replaced instead. + const requestUrl = new URL(requestPath.replace(/^\//, ''), baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); + + let driving = true; + const driver = (async () => { + while (driving) { + try { + await fetch(requestUrl, { signal: AbortSignal.timeout(2000) }); + } + catch { + // Connection refused until the server is listening, and aborted once a request parks on the + // breakpoint. Neither says anything about whether the breakpoint bound, so both are ignored + // and the wait below is left to decide. + } + + await delay(500); + } + })(); + + try { + return await waitForHit(); + } + finally { + driving = false; + await driver; + } +} + +async function waitForE2eValue(description: string, timeoutMs: number, getValue: () => T | undefined | Promise, describeState?: () => string): Promise { const started = Date.now(); let lastError: string | undefined; while (Date.now() - started < timeoutMs) { try { @@ -1419,7 +1571,11 @@ async function waitForE2eValue(description: string, timeoutMs: number, getVal await delay(500); } - throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}. Last error: ${lastError ?? ''}`); + // A poll that returns undefined never sets lastError, so waits that are simply never satisfied + // report "Last error: " and say nothing about why. `describeState` lets those callers attach + // what they were looking at, which is the difference between an actionable failure and a rerun. + const state = describeState ? ` State: ${describeState()}` : ''; + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}. Last error: ${lastError ?? ''}.${state}`); } async function stopDebuggingForE2E( @@ -1538,6 +1694,81 @@ function getE2eWorkspaceFolderPath(folderPath: unknown): string { return folderPath; } +function getE2eWorkspaceFolderEntries(folders: unknown): Array<{ uri: vscode.Uri; name?: string }> { + if (!Array.isArray(folders) || folders.length === 0) { + throw new Error('Aspire extension E2E setWorkspaceFolders requires at least one workspace folder.'); + } + + const expectedWorkspaceRoot = process.env.ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT; + if (typeof expectedWorkspaceRoot !== 'string' || expectedWorkspaceRoot.length === 0) { + throw new Error('Aspire extension E2E setWorkspaceFolders requires ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT.'); + } + + return folders.map((folder, index) => { + if (!folder || typeof folder !== 'object') { + throw new Error(`Aspire extension E2E workspace folder ${index} must be an object.`); + } + + const { folderPath, name } = folder as { folderPath?: unknown; name?: unknown }; + if (typeof folderPath !== 'string' || folderPath.length === 0 || !path.isAbsolute(folderPath)) { + throw new Error(`Aspire extension E2E workspace folder ${index} requires an absolute folderPath.`); + } + if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) { + throw new Error(`Aspire extension E2E workspace folder ${index} requires an existing directory: ${folderPath}`); + } + if (!isPathWithinDirectory(folderPath, expectedWorkspaceRoot)) { + throw new Error(`Aspire extension E2E workspace folder ${index} must stay inside the configured E2E workspace root.`); + } + if (name !== undefined && (typeof name !== 'string' || name.length === 0)) { + throw new Error(`Aspire extension E2E workspace folder ${index} name must be a non-empty string when provided.`); + } + + return { + uri: vscode.Uri.file(folderPath), + ...(typeof name === 'string' ? { name } : {}), + }; + }); +} + +async function setE2eWorkspaceFolders(folders: Array<{ uri: vscode.Uri; name?: string }>): Promise> { + const currentFolders = vscode.workspace.workspaceFolders ?? []; + const matchesCurrentFolders = currentFolders.length === folders.length && currentFolders.every((folder, index) => + folder.uri.toString() === folders[index].uri.toString() + && folder.name === (folders[index].name ?? path.basename(folders[index].uri.fsPath))); + if (matchesCurrentFolders) { + return getWorkspaceFolderInfo(); + } + + let workspaceFoldersChanged: (() => void) | undefined; + const changed = new Promise(resolve => workspaceFoldersChanged = resolve); + const subscription = vscode.workspace.onDidChangeWorkspaceFolders(() => workspaceFoldersChanged?.()); + try { + if (!vscode.workspace.updateWorkspaceFolders(0, currentFolders.length, ...folders)) { + throw new Error('VS Code declined the E2E workspace folder update.'); + } + + await Promise.race([ + changed, + delay(10_000).then(() => { + throw new Error('Timed out waiting for VS Code to apply the E2E workspace folder update.'); + }), + ]); + } + finally { + subscription.dispose(); + } + + return getWorkspaceFolderInfo(); +} + +function getWorkspaceFolderInfo(): Array<{ name: string; uri: string; fileName: string }> { + return vscode.workspace.workspaceFolders?.map(folder => ({ + name: folder.name, + uri: folder.uri.toString(), + fileName: folder.uri.fsPath, + })) ?? []; +} + function getE2eRunPath(filePath: unknown): string { if (typeof filePath !== 'string' || filePath.length === 0 || !path.isAbsolute(filePath)) { throw new Error('Aspire extension E2E openFile requires an absolute file path.'); @@ -1547,14 +1778,66 @@ function getE2eRunPath(filePath: unknown): string { throw new Error(`Aspire extension E2E openFile requires an existing file: ${filePath}`); } - const runRoot = process.env.ASPIRE_EXTENSION_E2E_RUN_ROOT; - if (typeof runRoot !== 'string' || runRoot.length === 0 || !isPathWithinDirectory(filePath, runRoot)) { - throw new Error('Aspire extension E2E openFile can only open files inside the configured E2E run root.'); + // The workspace root is normally inside the run root, but a run whose workspace has to live + // elsewhere (the Java run keeps it in the repository so the CLI resolves packages correctly) + // still needs to open its own sources. Both roots are harness-configured, so accept either. + const allowedRoots = [ + process.env.ASPIRE_EXTENSION_E2E_RUN_ROOT, + process.env.ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT, + ].filter((root): root is string => typeof root === 'string' && root.length > 0); + + if (!allowedRoots.some(root => isPathWithinDirectory(filePath, root))) { + throw new Error('Aspire extension E2E openFile can only open files inside the configured E2E run root or workspace root.'); } return filePath; } +function getE2eRunAspireCliWorkingDirectory(directoryPath: unknown): string { + if (typeof directoryPath !== 'string' || directoryPath.length === 0 || path.isAbsolute(directoryPath)) { + throw new Error('Aspire extension E2E runAspireCli workingDirectory must be workspace-relative.'); + } + + const workspaceRoot = process.env.ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT; + if (typeof workspaceRoot !== 'string' || workspaceRoot.length === 0) { + throw new Error('Aspire extension E2E runAspireCli requires the configured E2E workspace root.'); + } + + const resolvedDirectory = path.resolve(workspaceRoot, directoryPath); + if (!isPathWithinDirectory(resolvedDirectory, workspaceRoot)) { + throw new Error('Aspire extension E2E runAspireCli workingDirectory must stay inside the configured E2E workspace root.'); + } + + if (!fs.existsSync(resolvedDirectory) || !fs.statSync(resolvedDirectory).isDirectory()) { + throw new Error(`Aspire extension E2E runAspireCli requires an existing workingDirectory: ${directoryPath}`); + } + + return resolvedDirectory; +} + +// `addWorkspaceFolder` targets a folder that is not part of the workspace yet, so validate it +// against the harness roots rather than requiring containment in an already-open workspace folder. +export function getE2eAddableWorkspaceFolderPath(folderPath: unknown): string { + if (typeof folderPath !== 'string' || folderPath.length === 0 || !path.isAbsolute(folderPath)) { + throw new Error('Aspire extension E2E addWorkspaceFolder requires an absolute folder path.'); + } + + if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) { + throw new Error(`Aspire extension E2E addWorkspaceFolder requires an existing folder: ${folderPath}`); + } + + const allowedRoots = [ + process.env.ASPIRE_EXTENSION_E2E_RUN_ROOT, + process.env.ASPIRE_EXTENSION_E2E_WORKSPACE_ROOT, + ].filter((root): root is string => typeof root === 'string' && root.length > 0); + + if (!allowedRoots.some(root => isPathWithinDirectory(folderPath, root))) { + throw new Error('Aspire extension E2E addWorkspaceFolder can only add folders inside the configured E2E run root or workspace root.'); + } + + return folderPath; +} + function getE2eBreakpointLine(line: unknown): number { if (typeof line !== 'number' || !Number.isInteger(line) || line < 0) { throw new Error('Aspire extension E2E setSourceBreakpoint requires a zero-based non-negative integer line.'); @@ -1598,19 +1881,45 @@ function getExtensionFileStatus(context: vscode.ExtensionContext, relativePaths: ])); } -async function getDiagnosticsForFile(filePath: string): Promise<{ message: string; severity: vscode.DiagnosticSeverity; code?: string | number }[]> { +export async function getDiagnosticsForFile(filePath: string): Promise<{ message: string; severity: vscode.DiagnosticSeverity; code?: string | number }[]> { if (typeof filePath !== 'string' || filePath.length === 0) { throw new Error('Aspire extension E2E getDiagnostics requires filePath.'); } const uri = vscode.Uri.file(filePath); + const wasAlreadyOpen = isFileOpenInAnyTab(uri); const document = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(document); - return vscode.languages.getDiagnostics(uri).map(diagnostic => ({ + + // The document has to be shown for a language server to publish diagnostics for it, but the Java + // AppHost spec probes every generated SDK source - more than a hundred files. `preview` alone is + // not enough to keep that to one tab because VS Code ignores it when the user has + // `workbench.editor.enablePreview` off, so any tab this opened is closed again below. Otherwise + // whichever suite tears down next closes them one at a time over WebDriver and exceeds its + // timeout. + await vscode.window.showTextDocument(document, { preview: true, preserveFocus: true }); + + const diagnostics = vscode.languages.getDiagnostics(uri).map(diagnostic => ({ message: diagnostic.message, severity: diagnostic.severity, code: typeof diagnostic.code === 'string' || typeof diagnostic.code === 'number' ? diagnostic.code : undefined, })); + + if (!wasAlreadyOpen) { + const openedTabs = vscode.window.tabGroups.all + .flatMap(group => group.tabs) + .filter(tab => tab.input instanceof vscode.TabInputText && tab.input.uri.fsPath === uri.fsPath); + + if (openedTabs.length > 0) { + await vscode.window.tabGroups.close(openedTabs, true); + } + } + + return diagnostics; +} + +function isFileOpenInAnyTab(uri: vscode.Uri): boolean { + return vscode.window.tabGroups.all.some(group => group.tabs.some(tab => + tab.input instanceof vscode.TabInputText && tab.input.uri.fsPath === uri.fsPath)); } function getAppHostElement(appHostTreeProvider: AspireAppHostTreeProvider, appHostPath: string | undefined): unknown { @@ -1832,6 +2141,139 @@ function cloneDebugConsoleOutputEvent(event: AspireDebugConsoleOutputEvent, sequ }; } +/** + * Java language server API surface the E2E bridge depends on. + * + * redhat.java's own typings are not a dependency of this extension, so only the two members that + * describe readiness are declared here. + * https://github.com/redhat-developer/vscode-java/blob/master/src/extension.api.ts + */ +interface JavaLanguageServerApi { + serverMode?: string; + serverReady?: () => Promise; +} + +/** + * Waits until the Java language server has finished importing the workspace. + * + * redhat.java reports no diagnostics both before it has looked at a file and after it has declared + * that file clean, so a spec that reads diagnostics without waiting cannot tell a healthy workspace + * from a language server that was never installed. That is precisely how the Java specs reported + * green while no Java extension was present in the run at all. + */ +async function waitForJavaLanguageServer(timeoutMs: number): Promise<{ serverMode?: string }> { + const extension = vscode.extensions.getExtension(javaLanguageExtensionId); + if (!extension) { + throw new Error(`${javaLanguageExtensionId} is not installed, so nothing will import the Java workspace. Installed extensions: ${vscode.extensions.all.map(candidate => candidate.id).join(', ')}`); + } + + const api = await extension.activate(); + if (typeof api?.serverReady !== 'function') { + throw new Error(`${javaLanguageExtensionId} did not export serverReady(), so language server readiness cannot be observed.`); + } + + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + api.serverReady(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`The Java language server was not ready within ${timeoutMs}ms. Server mode: ${api.serverMode ?? ''}.`)), timeoutMs); + }), + ]); + } + finally { + if (timer) { + clearTimeout(timer); + } + } + + // serverReady() resolves in LightWeight mode, which only serves syntax and answers no + // project-aware request. That is not enough for the callers here: VS Code merges the CodeLens sets + // of every registered provider, so while redhat.java is still importing, a .java file renders + // `CodeLenses: (none)` - including the Aspire lens, which was ready the whole time. Waiting for + // Standard mode is what makes "the workspace is imported" true rather than "the extension started". + // + // Server modes are LightWeight, Hybrid and Standard; only Standard means the project model exists. + // See https://github.com/redhat-developer/vscode-java/blob/master/src/settings.ts (ServerMode). + const deadline = Date.now() + timeoutMs; + while (api.serverMode !== 'Standard' && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + + if (api.serverMode !== 'Standard') { + throw new Error(`The Java language server did not reach Standard mode within ${timeoutMs}ms, so the workspace was never imported. Server mode: ${api.serverMode ?? ''}.`); + } + + return { serverMode: api.serverMode }; +} + +/** + * Adds a folder to the running window's workspace and resolves once the extension host observes it. + * + * The spec used to drive `Workspaces: Add Folder to Workspace...` and its quick-open input. Adding the + * first folder converts a single-folder window into an untitled multi-root workspace, which reloads + * the window and restarts the extension host, and after that reload the second add never took - the + * command ran, the input was confirmed, and `workspaceFolders` still never listed the folder, so the + * spec burned its whole retry budget and failed on the confirmation poll. + * + * What the spec proves is that CLI commands target the right workspace folder. How the folder gets + * added is incidental, so it goes through the API that VS Code itself calls rather than through the + * UI, which removes the reload race without weakening the proof. + * + * `updateWorkspaceFolders` returns false when the edit could not be applied at all, and returning true + * only means it was accepted - the folder appears asynchronously, so the caller still has to observe + * `onDidChangeWorkspaceFolders`. Both are handled here so callers get one settled answer. + */ +async function addWorkspaceFolderForE2E(folderPath: string): Promise<{ added: boolean; folders: string[] }> { + const uri = vscode.Uri.file(folderPath); + const alreadyPresent = vscode.workspace.workspaceFolders?.some(folder => folder.uri.fsPath === uri.fsPath) ?? false; + if (!alreadyPresent) { + const accepted = vscode.workspace.updateWorkspaceFolders(vscode.workspace.workspaceFolders?.length ?? 0, null, { uri }); + if (!accepted) { + throw new Error(`VS Code rejected adding '${folderPath}' to the workspace.`); + } + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + subscription.dispose(); + reject(new Error(`'${folderPath}' was accepted but never appeared in workspaceFolders.`)); + }, 30000); + const subscription = vscode.workspace.onDidChangeWorkspaceFolders(() => { + if (vscode.workspace.workspaceFolders?.some(folder => folder.uri.fsPath === uri.fsPath)) { + clearTimeout(timer); + subscription.dispose(); + resolve(); + } + }); + }); + } + + return { + added: !alreadyPresent, + folders: vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath) ?? [], + }; +} + +/** + * Renders every resource the tree knows about, so an endpoint that never appears says why. + * + * The endpoint wait polls for a URL and returns undefined until one exists, so it never records an + * error and its timeout reported only "Last error: " - which cannot distinguish a resource that + * failed to start from one still building from one that was never in the model at all. + */ +function describeResourcesForE2E(appHostTreeProvider: AspireAppHostTreeProvider, appHostPath: string, resourceName: string): string { + const element = appHostTreeProvider.findResourceElement(resourceName, appHostPath); + if (!(element instanceof ResourceItem)) { + return `resource '${resourceName}' is not in the tree for '${appHostPath}'.`; + } + + const describe = (resource: ResourceJson) => + `${resource.name} [type=${resource.resourceType}, state=${resource.state ?? ''}, health=${resource.healthStatus ?? ''}, exitCode=${resource.exitCode ?? ''}, urls=${(resource.urls ?? []).map(url => url.url).join(',') || ''}]`; + + const siblings = element.allResources ?? [element.resource]; + return `${describe(element.resource)}; all resources: ${siblings.map(describe).join(' | ')}`; +} + function getUnknownCommandName(command: unknown): string { if (command && typeof command === 'object' && 'name' in command) { return String(command.name); diff --git a/extension/src/types/configInfo.ts b/extension/src/types/configInfo.ts index 6fa43097d37..bd87c853e7e 100644 --- a/extension/src/types/configInfo.ts +++ b/extension/src/types/configInfo.ts @@ -36,6 +36,8 @@ export interface ConfigInfo { capabilities?: string[]; } +export type CapabilityStatus = 'supported' | 'unsupported' | 'unavailable'; + /** * Capability advertised by the CLI when `aspire describe` supports the hidden * `--include-disabled-commands` flag. Tooling uses this to avoid passing the flag to older CLIs @@ -51,3 +53,15 @@ export const describeIncludeDisabledCommandsCapability = 'describe-include-disab * Keep in sync with `KnownCapabilities.LsJsonStream` in src/Aspire.Cli/Utils/ExtensionHelper.cs. */ export const lsJsonStreamCapability = 'ls-json-stream.v1'; + +/** + * Capability advertised by the CLI when `aspire run` accepts the `--isolated` option. + * Tooling uses this to avoid passing the option to older CLIs that reject it. + * Keep in sync with `KnownCapabilities.IsolatedLaunch` in src/Aspire.Cli/Utils/ExtensionHelper.cs. + */ +export const isolatedLaunchCapability = 'isolated-launch.v1'; + +/** + * First Aspire CLI version that accepts `aspire run --isolated`. + */ +export const isolatedLaunchMinimumVersion = '13.2.0'; diff --git a/extension/src/types/extensionApi.ts b/extension/src/types/extensionApi.ts index ece797823d6..e00e7cbc4c0 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -98,6 +98,12 @@ export type AspireExtensionApi = AspireExtensionApiV2; export interface AspireExtensionE2EStateFile { updatedAt: string; + /** + * Identifies the E2E run whose extension host produced this file. The state and control files + * live at a stable per-shard path, so an extension host left behind by an earlier run can still + * be polling them. Readers compare this against their own run id to ignore a foreign writer. + */ + runId?: string; state: AspireExtensionStateSnapshot; dashboardUrl?: string; commandInvocations: readonly AspireExtensionE2ECommandInvocation[]; @@ -167,6 +173,12 @@ export interface AspireExtensionE2EControlStatus { export interface AspireExtensionE2EControlPayload { revision: number; + /** + * Addresses this payload to a single E2E run. `revision` alone cannot do that: it restarts at 0 + * in every test process while a freshly launched extension host starts at -1, so a host left + * behind by an earlier run would accept another run's commands. + */ + runId?: string; aspireCliExecutablePath?: string; e2eCliExecutablePath?: string | null; forceCliUnavailable?: boolean; @@ -207,6 +219,7 @@ export type AspireExtensionE2EControlCommand = | { name: 'setSourceBreakpoint'; filePath: string; line: number; clearExisting?: boolean } | { name: 'clearBreakpoints' } | { name: 'getBreakpoints' } + | { name: 'startDebugging'; configurationName: string } | { name: 'stopDebugging' } | { name: 'closeAllEditors' } | { name: 'getRegisteredAspireCommands' } @@ -223,10 +236,16 @@ export type AspireExtensionE2EControlCommand = | { name: 'assertClipboardMatchesLastExpectation' } | { name: 'openFile'; filePath: string } | { name: 'openWorkspaceFolder'; folderPath: string } + | { name: 'setWorkspaceFolders'; folders: readonly { folderPath: string; name?: string }[] } | { name: 'stopOwnedDebugSessionProcesses'; appHostPath?: string } | { name: 'getWorkspaceFolders' } + | { name: 'addWorkspaceFolder'; folderPath: string } | { name: 'getActiveEditor' } + | { name: 'runAspireCli'; args: readonly string[]; workingDirectory: string; timeoutMs?: number } | { name: 'getResourceDebuggerExtensions' } + | { name: 'getSupportedCapabilities' } + | { name: 'getVisibleExtensionIds' } + | { name: 'waitForJavaLanguageServer'; timeoutMs?: number } | { name: 'createResourceDebugConfiguration'; launchConfig: ExecutableLaunchConfiguration; @@ -237,5 +256,5 @@ export type AspireExtensionE2EControlCommand = debuggers?: Readonly>; environmentKeys?: readonly string[]; } - | { name: 'proveAppHostAndResourceDebugging'; appHostPath: string; resourceName: string; appHostSourcePath: string; appHostBreakpointLine: number; resourceSourcePath: string; resourceBreakpointLine: number; timeoutMs?: number } + | { name: 'proveAppHostAndResourceDebugging'; appHostPath: string; resourceName: string; appHostSourcePath: string; appHostBreakpointLine: number; resourceSourcePath: string; resourceBreakpointLine: number; resourceRequestPath?: string; timeoutMs?: number } | { name: 'proveMauiResourceDebugging'; appHostPath: string; resourceName: string; sourcePath: string; breakpointLine: number; timeoutMs?: number; pauseOnBreakpointMs?: number }; diff --git a/extension/src/utils/AspirePackageRestoreProvider.ts b/extension/src/utils/AspirePackageRestoreProvider.ts index 1c841011c54..6ce30e3e9f7 100644 --- a/extension/src/utils/AspirePackageRestoreProvider.ts +++ b/extension/src/utils/AspirePackageRestoreProvider.ts @@ -5,6 +5,7 @@ import { findAspireSettingsFiles } from './workspace'; import { ChildProcessWithoutNullStreams } from 'child_process'; import { spawnCliProcess } from './process/cliProcess'; import { AspireTerminalProvider } from './AspireTerminalProvider'; +import { getCliPathTargetForUri } from './cliPathVariables'; import { extensionLogOutputChannel } from './logging'; import { getEnableAutoRestore } from './settings'; import { runningAspireRestore, runningAspireRestoreProgress, aspireRestoreCompleted, aspireRestoreAllCompleted, aspireRestoreFailed, aspireRestoreFailedStatusBar } from '../loc/strings'; @@ -30,6 +31,7 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { private _total = 0; private _completed = 0; private _hideTimeout: ReturnType | undefined; + private _disposed = false; constructor(terminalProvider: AspireTerminalProvider) { this._terminalProvider = terminalProvider; @@ -68,7 +70,7 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { } private async _restoreAll(force = false): Promise { - if (!force && !getEnableAutoRestore()) { + if (this._disposed || (!force && !getEnableAutoRestore())) { extensionLogOutputChannel.info('Auto-restore is disabled, skipping restore'); return; } @@ -108,7 +110,7 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { } private async _onChanged(uri: vscode.Uri): Promise { - if (!getEnableAutoRestore()) { + if (this._disposed || !getEnableAutoRestore()) { return; } const configDir = path.dirname(uri.fsPath); @@ -126,7 +128,7 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { } private async _restoreIfChanged(uri: vscode.Uri, isInitial: boolean): Promise { - if (!getEnableAutoRestore()) { + if (this._disposed || !getEnableAutoRestore()) { return; } @@ -160,30 +162,43 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { } try { - await this._runRestore(configDir, relativePath); + await this._runRestore(uri, configDir, relativePath); + if (this._disposed) { + return; + } // Only update baseline after successful restore so a retry is attempted on next change this._lastContent.set(uri.fsPath, content); this._failedDirs.delete(configDir); this._showProgress(); this._scheduleHide(); } catch (error) { + if (this._disposed) { + return; + } this._failedDirs.add(configDir); this._showProgress(); extensionLogOutputChannel.warn(`Restore failed for ${relativePath}: ${error}`); } // If a change arrived while we were restoring, re-read and restore again - while (this._pendingRestore.delete(configDir)) { + while (!this._disposed && this._pendingRestore.delete(configDir)) { await this._restoreIfChanged(uri, false); } } - private async _runRestore(configDir: string, relativePath: string): Promise { + private async _runRestore(uri: vscode.Uri, configDir: string, relativePath: string): Promise { + if (this._disposed) { + return; + } + this._active.set(configDir, relativePath); this._showProgress(); try { - const cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(getCliPathTargetForUri(uri)); + if (this._disposed) { + return; + } await new Promise((resolve, reject) => { let settled = false; const proc = spawnCliProcess(this._terminalProvider, cliPath, ['restore'], { @@ -224,11 +239,17 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { } finally { this._active.delete(configDir); this._completed++; - this._showProgress(); + if (!this._disposed) { + this._showProgress(); + } } } private _scheduleHide(): void { + if (this._disposed) { + return; + } + if (this._hideTimeout) { clearTimeout(this._hideTimeout); this._timeouts.delete(this._hideTimeout); @@ -245,6 +266,10 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { } private _showProgress(): void { + if (this._disposed) { + return; + } + if (this._active.size === 0 && this._failedDirs.size > 0) { this._statusBarItem.text = `$(error) ${aspireRestoreFailedStatusBar}`; this._statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); @@ -262,6 +287,11 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { } dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + for (const proc of this._childProcesses) { try { proc.kill(); } catch { /* ignore */ } } diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts index 2a4e1a8b1bb..194051253d5 100644 --- a/extension/src/utils/AspireTerminalProvider.ts +++ b/extension/src/utils/AspireTerminalProvider.ts @@ -6,8 +6,9 @@ import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; import { DcpServerConnectionInfo } from '../dcp/types'; import { getRunSessionInfo, getSupportedCapabilities } from '../capabilities'; import { EnvironmentVariables, getEnvironmentWithoutE2EBridgeVariables } from './environment'; -import { resolveCliPath } from './cliPath'; -import { ASPIRE_CLI_PATH_ENV_VAR, getForwardableAspireCliPath } from './cliPathEnvironment'; +import { CliPathResolver, resolveCliPath } from './cliPath'; +import { ASPIRE_CLI_PATH_ENV_VAR, getForwardableAspireCliPath, getForwardableResolvedAspireCliPath } from './cliPathEnvironment'; +import { CliPathResolutionTarget, getCliPathTargetKey, windowCliPathTarget } from './cliPathVariables'; import path from 'path'; import { assertNoTerminalControlCharacters } from './cmdShim'; @@ -23,11 +24,14 @@ export const enum AnsiColors { export interface AspireTerminal { terminal: vscode.Terminal; dispose: () => void; + resolvedCliPath?: string; } export interface SendAspireCommandOptions { redactAdditionalArgs?: boolean; terminalTarget?: 'shared' | 'editor'; + target: CliPathResolutionTarget; + cliPath?: string; } // String parts are fixed CLI syntax and are validated before interpolation. @@ -112,7 +116,7 @@ export function shellArg(value: string): ShellArg { } export class AspireTerminalProvider implements vscode.Disposable { - private _terminalByDebugSessionId: Map = new Map(); + private _terminalByDebugSessionId = new Map(); private _invalidatedSharedTerminals = new Set(); private _rpcServerConnectionInfo?: RpcServerConnectionInfo; private _dcpServerConnectionInfo?: DcpServerConnectionInfo; @@ -124,6 +128,7 @@ export class AspireTerminalProvider implements vscode.Disposable { constructor( subscriptions: vscode.Disposable[], private readonly _isPowerShell7Available = isPowerShell7Available, + private readonly _cliPathResolver?: CliPathResolver, ) { subscriptions.push(vscode.window.onDidCloseTerminal(closedTerminal => { this._invalidatedSharedTerminals.delete(closedTerminal); @@ -161,7 +166,8 @@ export class AspireTerminalProvider implements vscode.Disposable { } async sendAspireCommandToAspireTerminal(subcommand: AspireSubcommand, showTerminal: boolean = true, additionalArgs?: string[], options?: SendAspireCommandOptions) { - const cliPath = await this.getAspireCliExecutablePath(); + const target = options?.target ?? windowCliPathTarget; + const cliPath = options?.cliPath ?? await this.getAspireCliExecutablePath(target); const subcommandLine = formatSubcommand(subcommand); assertNoTerminalControlCharacters(cliPath); @@ -211,8 +217,8 @@ export class AspireTerminalProvider implements vscode.Disposable { } else { aspireTerminal = terminalTarget === 'editor' - ? this.createAspireEditorTerminal() - : this.getAspireTerminal(); + ? this.createAspireEditorTerminal(target, cliPath) + : this.getAspireTerminal(false, target, cliPath); executionMode = aspireTerminal.terminal.shellIntegration ? 'shellIntegration' : 'sendText'; } this._onDidSendAspireCommand.fire({ @@ -250,8 +256,17 @@ export class AspireTerminalProvider implements vscode.Disposable { } - getAspireTerminal(forceCreate?: boolean): AspireTerminal { - const existingTerminal = this._terminalByDebugSessionId.get(null); + getAspireTerminal( + forceCreate: boolean = false, + target: CliPathResolutionTarget = windowCliPathTarget, + resolvedCliPath?: string, + ): AspireTerminal { + const terminalKey = this.getTerminalKey(undefined, target); + let existingTerminal = this._terminalByDebugSessionId.get(terminalKey); + if (existingTerminal && resolvedCliPath !== undefined && !areResolvedCliPathsEqual(existingTerminal.resolvedCliPath, resolvedCliPath)) { + this.invalidateSharedAspireTerminal(target); + existingTerminal = undefined; + } if (existingTerminal) { if (!forceCreate) { return existingTerminal; @@ -262,49 +277,64 @@ export class AspireTerminalProvider implements vscode.Disposable { } extensionLogOutputChannel.info(`Creating new Aspire terminal`); - const terminal = this.createTerminal(); + const terminal = this.createTerminal(undefined, target, resolvedCliPath); const aspireTerminal: AspireTerminal = { terminal, + resolvedCliPath, dispose: () => { terminal.dispose(); - this._terminalByDebugSessionId.delete(null); + this._terminalByDebugSessionId.delete(terminalKey); } }; - this._terminalByDebugSessionId.set(null, aspireTerminal); + this._terminalByDebugSessionId.set(terminalKey, aspireTerminal); return aspireTerminal; } - invalidateSharedAspireTerminal(): void { - const existingTerminal = this._terminalByDebugSessionId.get(null); - if (!existingTerminal) { - return; - } + invalidateSharedAspireTerminal(target?: CliPathResolutionTarget): void { + const terminalKeys = target + ? [this.getTerminalKey(undefined, target)] + : [...this._terminalByDebugSessionId.keys()].filter(key => key.startsWith('shared:')); + + for (const terminalKey of terminalKeys) { + const existingTerminal = this._terminalByDebugSessionId.get(terminalKey); + if (!existingTerminal) { + continue; + } - // The terminal may be running a long-lived command, so leave it open. Stop reusing it - // so the next Aspire command gets a new terminal with the current environment. - extensionLogOutputChannel.info('Invalidating shared Aspire terminal environment'); - this._terminalByDebugSessionId.delete(null); - this._invalidatedSharedTerminals.add(existingTerminal.terminal); + // The terminal may be running a long-lived command, so leave it open. Stop reusing it + // so the next Aspire command gets a new terminal with the current environment. + extensionLogOutputChannel.info('Invalidating shared Aspire terminal environment'); + this._terminalByDebugSessionId.delete(terminalKey); + this._invalidatedSharedTerminals.add(existingTerminal.terminal); + } } - private createAspireEditorTerminal(): AspireTerminal { + private createAspireEditorTerminal(target: CliPathResolutionTarget, resolvedCliPath: string): AspireTerminal { extensionLogOutputChannel.info('Creating Aspire editor terminal'); - const terminal = this.createTerminal(vscode.TerminalLocation.Editor); + const terminal = this.createTerminal(vscode.TerminalLocation.Editor, target, resolvedCliPath); return { terminal, + resolvedCliPath, dispose: () => terminal.dispose(), }; } - private createTerminal(location?: vscode.TerminalLocation): vscode.Terminal { + private createTerminal( + location?: vscode.TerminalLocation, + target: CliPathResolutionTarget = windowCliPathTarget, + resolvedCliPath?: string, + ): vscode.Terminal { const terminalOptions: vscode.TerminalOptions = { name: aspireTerminalName, - env: this.createEnvironment(), + env: this.createEnvironment(undefined, undefined, undefined, resolvedCliPath), location, }; + if (target.kind === 'workspaceFolder') { + terminalOptions.cwd = target.workspaceFolder.uri; + } if (process.platform === 'win32') { // quoteShellArg uses PowerShell escaping on Windows. Do not rely on the // user's default terminal profile because cmd.exe treats backticks as @@ -315,7 +345,11 @@ export class AspireTerminalProvider implements vscode.Disposable { return vscode.window.createTerminal(terminalOptions); } - createEnvironment(debugSessionId?: string, noDebug?: boolean, noExtensionVariables?: boolean): any { + private getTerminalKey(debugSessionId: string | undefined, target: CliPathResolutionTarget): string { + return `${debugSessionId ?? 'shared'}:${getCliPathTargetKey(target)}`; + } + + createEnvironment(debugSessionId?: string, noDebug?: boolean, noExtensionVariables?: boolean, resolvedCliPath?: string): any { if (noExtensionVariables) { const env: any = { ...getEnvironmentWithoutE2EBridgeVariables(), @@ -326,7 +360,7 @@ export class AspireTerminalProvider implements vscode.Disposable { ASPIRE_LOCALE_OVERRIDE: vscode.env.language, }; - addForwardableAspireCliPath(env); + addForwardableAspireCliPath(env, resolvedCliPath); scrubNoExtensionVariablesEnvironment(env); return env; @@ -336,7 +370,7 @@ export class AspireTerminalProvider implements vscode.Disposable { ...getEnvironmentWithoutE2EBridgeVariables(), }; - addForwardableAspireCliPath(env); + addForwardableAspireCliPath(env, resolvedCliPath); Object.assign(env, { // Extension connection information @@ -455,8 +489,10 @@ export class AspireTerminalProvider implements vscode.Disposable { } - async getAspireCliExecutablePath(): Promise { - const result = await resolveCliPath(); + async getAspireCliExecutablePath(target: CliPathResolutionTarget = windowCliPathTarget): Promise { + const result = this._cliPathResolver + ? await this._cliPathResolver.resolve(target) + : await resolveCliPath(target); return result.cliPath; } @@ -481,6 +517,16 @@ export class AspireTerminalProvider implements vscode.Disposable { } } +function areResolvedCliPathsEqual(left: string | undefined, right: string): boolean { + if (left === undefined) { + return false; + } + + return process.platform === 'win32' + ? path.win32.normalize(left).toLowerCase() === path.win32.normalize(right).toLowerCase() + : left === right; +} + function isPowerShell7Available(): boolean { const result = childProcess.spawnSync('pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'], { stdio: 'ignore', @@ -490,7 +536,7 @@ function isPowerShell7Available(): boolean { return result.status === 0 && result.error === undefined; } -function addForwardableAspireCliPath(env: Record): void { +function addForwardableAspireCliPath(env: Record, resolvedCliPath?: string): void { // Forward aspire.aspireCliExecutablePath as AspireCliPath so MSBuild's // ResolveAspireCliBundle task — which `dotnet build` evaluates whenever // the AppHost is built (including from this CLI process and from VS @@ -502,6 +548,20 @@ function addForwardableAspireCliPath(env: Record): v // Only forward values that pass the task's File.Exists guard; stale // absolute paths make the task produce no bundle outputs instead of // falling back, and the AppHost targets can then fail with ASPIRE009. + if (resolvedCliPath !== undefined) { + // A concrete resolved path (e.g. from spawnCliProcess) is the exact executable this + // process launches, so it is the only candidate considered; delete any inherited + // AspireCliPath first so an unforwardable resolvedCliPath can't fall back to stale + // ambient metadata from a different CLI. + deleteEnvironmentVariable(env, ASPIRE_CLI_PATH_ENV_VAR); + const forwardableResolvedPath = getForwardableResolvedAspireCliPath(resolvedCliPath); + if (forwardableResolvedPath) { + env[ASPIRE_CLI_PATH_ENV_VAR] = forwardableResolvedPath; + } + + return; + } + const configuredCliPath = getForwardableAspireCliPath(); if (configuredCliPath) { env[ASPIRE_CLI_PATH_ENV_VAR] = configuredCliPath; @@ -536,8 +596,12 @@ function scrubNoExtensionVariablesEnvironment(env: Record, name: string): void { if (process.platform === 'win32') { + // Windows environment variable names are case-insensitive; compare uppercased so callers + // can pass a mixed-case canonical name (e.g. `AspireCliPath`) as well as already-uppercase + // constants. + const upperName = name.toUpperCase(); for (const key of Object.keys(env)) { - if (key.toUpperCase() === name) { + if (key.toUpperCase() === upperName) { delete env[key]; } } diff --git a/extension/src/utils/appHostArgs.ts b/extension/src/utils/appHostArgs.ts index 54fdfe2c04d..7e6bd283823 100644 --- a/extension/src/utils/appHostArgs.ts +++ b/extension/src/utils/appHostArgs.ts @@ -1,14 +1,18 @@ import { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider'; +export interface AppHostCommandTarget { + readonly appHostPath?: string; + readonly args?: string[]; +} + /** - * Returns CLI arguments to pass the resolved AppHost project path via --apphost, - * or undefined if no AppHost is currently available. + * Resolves the AppHost once and returns both its path and CLI arguments. */ -export async function getAppHostArgs(editorCommandProvider: AspireEditorCommandProvider): Promise { +export async function getAppHostArgs(editorCommandProvider: AspireEditorCommandProvider): Promise { const appHostPath = await editorCommandProvider.getAppHostPath(); if (!appHostPath) { - return undefined; + return {}; } - return ['--apphost', appHostPath]; + return { appHostPath, args: ['--apphost', appHostPath] }; } diff --git a/extension/src/utils/appHostDiscovery.ts b/extension/src/utils/appHostDiscovery.ts index 47147f66c45..27ebaa7718a 100644 --- a/extension/src/utils/appHostDiscovery.ts +++ b/extension/src/utils/appHostDiscovery.ts @@ -13,6 +13,7 @@ import { sendTelemetryEvent } from './telemetry'; import { isExcludedDiscoveryCandidate, isExcludedDiscoveryUri } from './workspaceFileSearch'; import { ConfigInfoProvider } from './configInfoProvider'; import { lsJsonStreamCapability } from '../types/configInfo'; +import { workspaceFolderCliPathTarget } from './cliPathVariables'; import { isSamePath } from './paths/comparison'; import { isSameFileSystemEntry } from './paths/fileSystemIdentity'; import { createLsStreamCandidateHandler, parseCandidateOutput, parseLegacyGetAppHostsOutput, toCandidatesFromLegacySearchResult } from './appHostCandidateParsing'; @@ -277,8 +278,8 @@ export class AppHostDiscoveryService implements vscode.Disposable { private async _discoverCore(workspaceFolder: vscode.WorkspaceFolder, reportCandidateProgress: IncrementalCandidateCallback, cancellationToken: vscode.CancellationToken, forceRefresh: boolean): Promise { let cliPath: string | undefined; try { - cliPath = await this._getAspireCliExecutablePath(cancellationToken); - const lsJsonStreamSupported = await this._resolveLsStreamCapability(cliPath, forceRefresh); + cliPath = await this._getAspireCliExecutablePath(workspaceFolder, cancellationToken); + const lsJsonStreamSupported = await this._resolveLsStreamCapability(cliPath, workspaceFolder, forceRefresh); let appHosts: CandidateAppHostDisplayInfo[]; if (lsJsonStreamSupported) { try { @@ -374,10 +375,11 @@ export class AppHostDiscoveryService implements vscode.Disposable { return toCandidatesFromLegacySearchResult(parsed); } - private _getAspireCliExecutablePath(cancellationToken: vscode.CancellationToken): Promise { + private _getAspireCliExecutablePath(workspaceFolder: vscode.WorkspaceFolder, cancellationToken: vscode.CancellationToken): Promise { this._throwIfDisposed(); throwIfCancellationRequested(cancellationToken); + const target = workspaceFolderCliPathTarget(workspaceFolder); return new Promise((resolve, reject) => { let settled = false; let cancellationDisposable: vscode.Disposable | undefined; @@ -407,7 +409,7 @@ export class AppHostDiscoveryService implements vscode.Disposable { } try { - this._terminalProvider.getAspireCliExecutablePath().then( + this._terminalProvider.getAspireCliExecutablePath(target).then( cliPath => settle(() => resolve(cliPath)), error => settle(() => reject(error instanceof Error ? error : new Error(String(error))))); } @@ -417,11 +419,12 @@ export class AppHostDiscoveryService implements vscode.Disposable { }); } - private async _resolveLsStreamCapability(cliPath: string, forceRefresh: boolean): Promise { + private async _resolveLsStreamCapability(cliPath: string, workspaceFolder: vscode.WorkspaceFolder, forceRefresh: boolean): Promise { const configInfo = await this._configInfoProvider.getConfigInfo({ suppressErrors: true, forceRefresh, cliPath, + target: workspaceFolderCliPathTarget(workspaceFolder), }); const supported = configInfo?.capabilities?.includes(lsJsonStreamCapability) ?? false; extensionLogOutputChannel.info(`CLI capability '${lsJsonStreamCapability}' ${supported ? 'advertised' : 'not advertised'}; aspire ls --stream ${supported ? 'enabled' : 'disabled'}.`); @@ -476,6 +479,10 @@ export class AppHostDiscoveryService implements vscode.Disposable { '**/apphost.mjs', '**/apphost.cjs', '**/apphost.rs', + // Java requires the file name to match the public class name, so this is always + // AppHost.java. Watcher globs are case-sensitive on Linux, so the pattern has to + // carry the real casing rather than the lowercase form used by the other entries. + '**/AppHost.java', `**/${aspireConfigFileName}`, '**/.aspire/settings.json', ]; @@ -630,7 +637,9 @@ export class AppHostDiscoveryService implements vscode.Disposable { const cancel = (error: Error) => { settle(() => reject(error)); if (childProcess) { - terminateCliProcess(childProcess, `AppHost discovery command: aspire ${cliArgs.join(' ')}`); + void terminateCliProcess(childProcess, `AppHost discovery command: aspire ${cliArgs.join(' ')}`).catch(terminationError => { + extensionLogOutputChannel.error(`Failed to terminate AppHost discovery command: ${String(terminationError)}`); + }); } }; const cleanup = () => { diff --git a/extension/src/utils/appHostLanguage.ts b/extension/src/utils/appHostLanguage.ts index 38b31bd1c63..8a4dba746a2 100644 --- a/extension/src/utils/appHostLanguage.ts +++ b/extension/src/utils/appHostLanguage.ts @@ -18,7 +18,7 @@ import { CandidateAppHostDisplayInfo } from './appHostCandidateTypes'; * - `unknown` : we found AppHosts but couldn't classify any of them. * - `none` : no AppHosts were detected at all. */ -export type AppHostLanguage = 'csharp' | 'typescript' | 'rust' | 'unknown'; +export type AppHostLanguage = 'csharp' | 'typescript' | 'rust' | 'java' | 'unknown'; export type AppHostLanguageSummary = Exclude | 'polyglot' | 'unknown' | 'none'; export function formatAppHostLanguage(language: string): string | undefined { @@ -57,6 +57,9 @@ function languageFamily(raw: string | null | undefined): AppHostLanguage | 'othe if (value === 'rust' || value.startsWith('rust/')) { return 'rust'; } + if (value === 'java' || value.startsWith('java/')) { + return 'java'; + } return 'other'; } @@ -68,6 +71,7 @@ export function summarizeAppHostLanguages(candidates: readonly CandidateAppHostD let sawCsharp = false; let sawTypescript = false; let sawRust = false; + let sawJava = false; let sawOther = false; for (const candidate of candidates) { @@ -81,12 +85,15 @@ export function summarizeAppHostLanguages(candidates: readonly CandidateAppHostD else if (family === 'rust') { sawRust = true; } + else if (family === 'java') { + sawJava = true; + } else { sawOther = true; } } - const distinctFamilies = Number(sawCsharp) + Number(sawTypescript) + Number(sawRust) + Number(sawOther); + const distinctFamilies = Number(sawCsharp) + Number(sawTypescript) + Number(sawRust) + Number(sawJava) + Number(sawOther); if (distinctFamilies > 1) { return 'polyglot'; } @@ -99,6 +106,9 @@ export function summarizeAppHostLanguages(candidates: readonly CandidateAppHostD if (sawRust) { return 'rust'; } + if (sawJava) { + return 'java'; + } return 'unknown'; } @@ -127,6 +137,9 @@ export function classifyAppHostPath(appHostPath: string | undefined): AppHostLan if (lower.endsWith('.rs')) { return 'rust'; } + if (lower.endsWith('.java')) { + return 'java'; + } return 'unknown'; } @@ -155,6 +168,7 @@ export async function classifyAppHostDirectory(directoryPath: string | undefined let sawCsharp = false; let sawTypescript = false; let sawRust = false; + let sawJava = false; for (const entry of entries) { if (await isCsharpAppHostMarker(directoryPath, entry)) { sawCsharp = true; @@ -165,6 +179,9 @@ export async function classifyAppHostDirectory(directoryPath: string | undefined else if (entry.toLowerCase() === 'apphost.rs') { sawRust = true; } + else if (await isJavaAppHostMarker(directoryPath, entry)) { + sawJava = true; + } } if (sawCsharp && sawTypescript) { // Highly unusual; prefer csharp because Aspire's reference AppHost @@ -181,9 +198,39 @@ export async function classifyAppHostDirectory(directoryPath: string | undefined if (sawRust) { return 'rust'; } + if (sawJava) { + return 'java'; + } return 'unknown'; } +/** + * A Java AppHost has two supported layouts: a flat `AppHost.java` sitting directly in the + * directory, or a Maven/Gradle project whose AppHost lives at `src/main/java/AppHost.java`. + * + * The build file alone is not a marker — that would classify every Maven or Gradle project in + * the workspace as an AppHost — so the nested layout is only accepted once the AppHost source + * file is confirmed to exist. + */ +async function isJavaAppHostMarker(directoryPath: string, entry: string): Promise { + const lower = entry.toLowerCase(); + if (lower === 'apphost.java') { + return true; + } + + if (lower !== 'pom.xml' && lower !== 'build.gradle' && lower !== 'build.gradle.kts') { + return false; + } + + try { + await fs.access(join(directoryPath, 'src', 'main', 'java', 'AppHost.java')); + return true; + } + catch { + return false; + } +} + async function isCsharpAppHostMarker(directoryPath: string, entry: string): Promise { const lower = entry.toLowerCase(); if (lower === 'apphost.cs') { diff --git a/extension/src/utils/cliPath.ts b/extension/src/utils/cliPath.ts index 8f5600b908c..e3f25fb3457 100644 --- a/extension/src/utils/cliPath.ts +++ b/extension/src/utils/cliPath.ts @@ -6,6 +6,13 @@ import * as path from 'path'; import * as os from 'os'; import { extensionLogOutputChannel } from './logging'; import { getCmdShimSpawnCommand, isCommandShimPath, shouldWrapWithCmd } from './cmdShim'; +import { + expandConfiguredCliPath, + getCliExecutableCandidates, + getCliPathTargetKey, + windowCliPathTarget, + CliPathResolutionTarget, +} from './cliPathVariables'; const execFileAsync = promisify(execFile); const fsAccessAsync = promisify(fs.access); @@ -66,7 +73,30 @@ export function isFullyQualifiedWindowsPath(cliPath: string): boolean { } function isAbsoluteCliPath(cliPath: string): boolean { - return path.posix.isAbsolute(cliPath) || isFullyQualifiedWindowsPath(cliPath); + if (isFullyQualifiedWindowsPath(cliPath)) { + return true; + } + + // A POSIX-rooted path is not absolute on Windows. Windows paths remain recognizable on + // other hosts so tests and Settings Sync can preserve an explicit cross-platform pin. + return process.platform !== 'win32' && path.posix.isAbsolute(cliPath); +} + +function expandWindowsEnvironmentVariables( + value: string, + environment: NodeJS.ProcessEnv = process.env, +): string { + if (process.platform !== 'win32') { + return value; + } + + const environmentVariables = new Map( + Object.entries(environment).map(([name, environmentValue]) => [name.toLowerCase(), environmentValue]), + ); + + return value.replace(/%([^%]+)%/g, (match, name: string) => { + return environmentVariables.get(name.toLowerCase()) ?? match; + }); } function containsCliPath(paths: readonly string[], candidate: string): boolean { @@ -138,84 +168,11 @@ export async function tryExecuteCli(cliPath: string, execute: CliProbeExecutor = * `AspireCliPath` after resolution fell back to a different CLI. `ResolveAspireCliBundle` * stops at an explicit `AspireCliPath` instead of probing PATH, so the AppHost would be * stamped with bundle paths belonging to a CLI that never ran. + * + * This state now lives per-target inside `CliPathResolver` below; the exports here are + * compatibility wrappers over the shared `cliPathResolver` singleton for window-scoped + * consumers that have not migrated to an explicit `CliPathResolutionTarget`. */ -let rejectedConfiguredCliPath: string | undefined; -let resolvedCliPathForForwarding: { configuredPath: string; resolvedPath: string } | undefined; -let cliPathResolutionGeneration = 0; -const rejectedConfiguredCliPathChangedEmitter = new vscode.EventEmitter(); -const resolvedCliPathForForwardingChangedEmitter = new vscode.EventEmitter(); - -/** - * Fires when CLI resolution changes whether the configured path may be forwarded - * through `AspireCliPath`. - */ -export const onDidChangeConfiguredCliPathRejection = rejectedConfiguredCliPathChangedEmitter.event; -export const onDidChangeResolvedCliPathForForwarding = resolvedCliPathForForwardingChangedEmitter.event; - -function updateRejectedConfiguredCliPath( - expectedConfiguredPath: string, - rejectedPath: string | undefined, - getCurrentConfiguredPath: () => string, - resolutionGeneration: number, -): void { - // CLI probes are asynchronous and can overlap. Only the latest resolution for - // the setting that is still current may change forwarding state. - if (resolutionGeneration !== cliPathResolutionGeneration - || getCurrentConfiguredPath() !== expectedConfiguredPath) { - return; - } - - if (rejectedConfiguredCliPath === rejectedPath) { - return; - } - - rejectedConfiguredCliPath = rejectedPath; - rejectedConfiguredCliPathChangedEmitter.fire(); -} - -/** - * Reports whether CLI resolution rejected this configured path and fell back to a - * different CLI. Such a path must not be forwarded as `AspireCliPath`. - */ -export function isConfiguredCliPathRejectedForForwarding(configuredPath: string): boolean { - return rejectedConfiguredCliPath !== undefined && rejectedConfiguredCliPath === configuredPath; -} - -/** - * Returns the effective absolute discovery path resolved for the current setting - * snapshot without persisting it as an explicit user configuration value. - */ -export function getResolvedCliPathForForwarding(configuredPath: string): string | undefined { - return resolvedCliPathForForwarding?.configuredPath === configuredPath - ? resolvedCliPathForForwarding.resolvedPath - : undefined; -} - -function updateResolvedCliPathForForwarding(configuredPath: string, resolvedPath: string | undefined): void { - const nextValue = resolvedPath === undefined - ? undefined - : { configuredPath, resolvedPath }; - if (resolvedCliPathForForwarding?.configuredPath === nextValue?.configuredPath - && resolvedCliPathForForwarding?.resolvedPath === nextValue?.resolvedPath) { - return; - } - - resolvedCliPathForForwarding = nextValue; - resolvedCliPathForForwardingChangedEmitter.fire(); -} - -/** Test seam that clears the rejected-configured-path state between cases. */ -export function resetRejectedConfiguredCliPathForForwarding(): void { - cliPathResolutionGeneration++; - if (rejectedConfiguredCliPath !== undefined) { - rejectedConfiguredCliPath = undefined; - rejectedConfiguredCliPathChangedEmitter.fire(); - } - if (resolvedCliPathForForwarding !== undefined) { - resolvedCliPathForForwarding = undefined; - resolvedCliPathForForwardingChangedEmitter.fire(); - } -} interface CliPathLookupOptions { platform?: NodeJS.Platform; @@ -227,23 +184,38 @@ interface CliPathLookupOptions { /** * Finds an executable Aspire CLI on PATH. * - * Windows command shims must be resolved to their concrete path so downstream - * process launches can route them through cmd.exe instead of passing the bare - * command name to Node's executable-only spawn path. + * PATH lookups return a concrete path so capability negotiation, launch, and + * restart all target the same executable. Windows command shims additionally + * need their concrete path so downstream process launches can route them through + * cmd.exe instead of passing the bare command name to Node's executable-only spawn path. */ export async function findCliOnPath(options: CliPathLookupOptions = {}): Promise { const platform = options.platform ?? process.platform; const tryExecute = options.tryExecute ?? tryExecuteCli; - if (platform !== 'win32') { - return await tryExecute('aspire') ? 'aspire' : undefined; - } - const pathValue = options.pathValue ?? process.env.PATH; if (!pathValue) { return undefined; } const candidateExists = options.fileExists ?? fileExists; + if (platform !== 'win32') { + for (const pathEntry of pathValue.split(path.posix.delimiter)) { + // The extension probes candidates with `--version` before workspace trust is checked. + // Ignore entries that could resolve through the Extension Host's workspace-controlled cwd. + const directory = pathEntry.trim(); + if (!directory || !path.posix.isAbsolute(directory)) { + continue; + } + + const candidate = path.posix.join(directory, 'aspire'); + if (await candidateExists(candidate) && await tryExecute(candidate)) { + return candidate; + } + } + + return undefined; + } + const executableNames = ['aspire.exe', 'aspire.cmd', 'aspire.bat', 'aspire']; for (const pathEntry of pathValue.split(path.win32.delimiter)) { const directory = pathEntry.trim().replace(/^"(.*)"$/, '$1'); @@ -279,9 +251,32 @@ export async function findCliAtDefaultPath(): Promise { /** * Gets the VS Code configuration setting for the Aspire CLI path. + * + * A workspace-folder target reads the setting scoped to that folder, so a folder-level + * override (or a committed `.vscode/settings.json` value) takes effect for that folder + * independently of the window-wide value. + */ +export function getConfiguredCliPath(target: CliPathResolutionTarget = windowCliPathTarget): string { + const resource = target.kind === 'workspaceFolder' ? target.workspaceFolder.uri : undefined; + const configuration = vscode.workspace.getConfiguration('aspire', resource); + if (!vscode.workspace.isTrusted) { + // Repository settings are untrusted input. Never execute a workspace-provided path + // until the user trusts the workspace. A user-level value containing a workspace + // token is also repository-directed after expansion, so only preserve a concrete pin. + const globalValue = asConfiguredPath(configuration.inspect('aspireCliExecutablePath')?.globalValue); + return /\$\{workspaceFolder(?::[^}]*)?\}/.test(globalValue) ? '' : globalValue; + } + + return asConfiguredPath(configuration.get('aspireCliExecutablePath')); +} + +/** + * Settings files are hand-edited, so the value is whatever JSON the user typed. Anything + * that is not a string is treated as unset rather than allowed to throw from inside CLI + * resolution, which every command depends on. */ -export function getConfiguredCliPath(): string { - return vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', '').trim(); +function asConfiguredPath(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; } /** @@ -310,28 +305,43 @@ export interface CliPathResolutionResult { } /** - * Dependencies for resolveCliPath that can be overridden for testing. + * Dependencies for `CliPathResolver.resolve` that can be overridden for testing. + * + * `getConfiguredPath`, `isConfiguredPathAutoConfigured`, and `setConfiguredPath` receive the + * resolution target so a workspace-folder scope can read/write its own configuration slice + * instead of always reading the window-wide setting. */ export interface CliPathDependencies { - getConfiguredPath: () => string; + getConfiguredPath: (target: CliPathResolutionTarget) => string; + getWorkspaceFolders: () => readonly vscode.WorkspaceFolder[]; getDefaultPaths: () => string[]; - isConfiguredPathAutoConfigured: (configuredPath: string, defaultPaths: readonly string[]) => boolean; + isConfiguredPathAutoConfigured: (configuredPath: string, defaultPaths: readonly string[], target: CliPathResolutionTarget) => boolean; findOnPath: () => Promise; findAtDefaultPath: () => Promise; tryExecute: (cliPath: string) => Promise; - setConfiguredPath: (cliPath: string) => Promise; - updateResolvedPathForForwarding: (configuredPath: string, resolvedPath: string | undefined) => void; + getExecutableCandidates: (cliPath: string) => string[]; + setConfiguredPath: (cliPath: string, target: CliPathResolutionTarget) => Promise; + /** + * Temporary test-observation seam kept alongside resolver state so existing tests can + * assert on forwarding-fallback updates directly. `CliPathResolver`'s own per-target state + * (`getResolvedCliPathForForwarding`) remains the authoritative source for consumers. + */ + updateResolvedPathForForwarding?: (configuredPath: string, resolvedPath: string | undefined) => void; } const defaultDependencies: CliPathDependencies = { getConfiguredPath: getConfiguredCliPath, + getWorkspaceFolders: () => vscode.workspace.workspaceFolders ?? [], // Only paths that older extension versions could have written to the // setting are safe to treat as automatically configured. getDefaultPaths: getLegacyAutoConfiguredCliPaths, - isConfiguredPathAutoConfigured: (configuredPath, defaultPaths) => { - const inspection = vscode.workspace.getConfiguration('aspire').inspect('aspireCliExecutablePath'); - const hasWorkspaceOverride = inspection?.workspaceValue !== undefined - || inspection?.workspaceFolderValue !== undefined; + isConfiguredPathAutoConfigured: (configuredPath, defaultPaths, target) => { + const resource = target.kind === 'workspaceFolder' ? target.workspaceFolder.uri : undefined; + const inspection = vscode.workspace.getConfiguration('aspire', resource).inspect('aspireCliExecutablePath'); + // Restricted Mode ignores repository-owned values when choosing the configured path, + // so those same values cannot change the provenance of the trusted global setting. + const hasWorkspaceOverride = vscode.workspace.isTrusted + && (inspection?.workspaceValue !== undefined || inspection?.workspaceFolderValue !== undefined); // Older versions only wrote this setting globally. A workspace-scoped value is // therefore an explicit user pin even when it happens to equal a legacy default. @@ -340,40 +350,45 @@ const defaultDependencies: CliPathDependencies = { findOnPath: findCliOnPath, findAtDefaultPath: findCliAtDefaultPath, tryExecute: tryExecuteCli, - setConfiguredPath: setConfiguredCliPath, - updateResolvedPathForForwarding: updateResolvedCliPathForForwarding, + getExecutableCandidates: getCliExecutableCandidates, + // Legacy discovery only ever wrote the window-wide (Global) setting; scoped writes are + // not part of current behavior, so the target is intentionally unused here. + setConfiguredPath: cliPath => setConfiguredCliPath(cliPath), }; -interface InFlightCliPathResolution { +interface ConfiguredCliPathSnapshot { configuredPath: string; configuredPathIsLegacyDefault: boolean; - e2eCliPath: string | undefined; - promise: Promise; + defaultPaths: string[]; + workspaceFolders: readonly vscode.WorkspaceFolder[]; + workspaceFoldersKey: string; } -const inFlightCliPathResolutions = new WeakMap(); - -interface ConfiguredCliPathSnapshot { +interface InFlightCliPathResolution { + deps: CliPathDependencies; configuredPath: string; configuredPathIsLegacyDefault: boolean; - defaultPaths: string[]; + workspaceFoldersKey: string; + e2eCliPath: string | undefined; + promise: Promise; } -function getConfiguredCliPathSnapshot(deps: CliPathDependencies): ConfiguredCliPathSnapshot { - const configuredPath = deps.getConfiguredPath(); - const defaultPaths = deps.getDefaultPaths(); - return { - configuredPath, - configuredPathIsLegacyDefault: configuredPath !== '' - && deps.isConfiguredPathAutoConfigured(configuredPath, defaultPaths), - defaultPaths, - }; +interface CliPathScopeState { + generation: number; + rejectedConfiguredPath?: string; + resolvedPathForForwarding?: { configuredPath: string; resolvedPath: string }; + inFlight?: InFlightCliPathResolution; } /** - * Resolves the Aspire CLI path, checking multiple locations in order: + * Resolves the Aspire CLI path for a given target, keeping mutable resolution state + * (in-flight probes, configured-path rejection, and the forwarding-fallback path) + * scoped per-target so a workspace folder's resolution never coalesces with, or + * suppresses forwarding for, another folder's or the window's resolution. + * + * Resolution checks locations in order: * 1. E2E runner-provided CLI path - * 2. User-configured path in VS Code settings + * 2. User-configured path in VS Code settings (expanding `${workspaceFolder}` tokens) * 3. System PATH * 4. Default installation directories (bundle and .NET global-tool locations) * 5. A still-valid legacy auto-configured path @@ -388,175 +403,416 @@ function getConfiguredCliPathSnapshot(deps: CliPathDependencies): ConfiguredCliP * If the CLI is on PATH and a setting was previously auto-configured to a default path, * the setting is cleared to prefer PATH. */ -export function resolveCliPath(deps: CliPathDependencies = defaultDependencies): Promise { - const configuredPathSnapshot = getConfiguredCliPathSnapshot(deps); - const e2eCliPath = process.env.ASPIRE_EXTENSION_E2E_CLI_PATH?.trim(); - const inFlightResolution = inFlightCliPathResolutions.get(deps); - if (inFlightResolution?.configuredPath === configuredPathSnapshot.configuredPath - && inFlightResolution.configuredPathIsLegacyDefault === configuredPathSnapshot.configuredPathIsLegacyDefault - && inFlightResolution.e2eCliPath === e2eCliPath) { - return inFlightResolution.promise; +export class CliPathResolver implements vscode.Disposable { + private readonly _scopeStates = new Map(); + private readonly _onDidChangeConfiguredCliPathRejectionEmitter = new vscode.EventEmitter(); + private readonly _onDidChangeResolvedCliPathForForwardingEmitter = new vscode.EventEmitter(); + private readonly _onDidChangeForwardingEmitter = new vscode.EventEmitter(); + + readonly onDidChangeConfiguredCliPathRejection = this._onDidChangeConfiguredCliPathRejectionEmitter.event; + readonly onDidChangeResolvedCliPathForForwarding = this._onDidChangeResolvedCliPathForForwardingEmitter.event; + /** Fires whenever either forwarding-relevant event above fires, for consumers that don't care which. */ + readonly onDidChangeForwarding = this._onDidChangeForwardingEmitter.event; + + constructor(private readonly _deps: CliPathDependencies = defaultDependencies) { } - const resolutionGeneration = ++cliPathResolutionGeneration; - let writtenConfiguredPathSnapshot: ConfiguredCliPathSnapshot | undefined; - let promise!: Promise; - promise = resolveCliPathCore( - deps, - configuredPathSnapshot, - e2eCliPath, - resolutionGeneration, - snapshot => writtenConfiguredPathSnapshot = snapshot) - .then(result => { - // A setting edit or scope change can race a CLI probe. Do not return a result - // for a configuration snapshot that is no longer current. - const currentConfiguredPathSnapshot = getConfiguredCliPathSnapshot(deps); - if (resolutionGeneration !== cliPathResolutionGeneration - || (!areConfiguredCliPathSnapshotsEqual(currentConfiguredPathSnapshot, configuredPathSnapshot) - && !areConfiguredCliPathSnapshotsEqual(currentConfiguredPathSnapshot, writtenConfiguredPathSnapshot)) - || process.env.ASPIRE_EXTENSION_E2E_CLI_PATH?.trim() !== e2eCliPath) { - return resolveCliPath(deps); - } + resolve( + target: CliPathResolutionTarget = windowCliPathTarget, + depsOverrideForTests?: CliPathDependencies, + ): Promise { + const deps = depsOverrideForTests ?? this._deps; + const scopeKey = getCliPathTargetKey(target); + let scopeState = this._scopeStates.get(scopeKey); + if (!scopeState) { + scopeState = { generation: 0 }; + this._scopeStates.set(scopeKey, scopeState); + } + const state = scopeState; + + const configuredPathSnapshot = this.getConfiguredCliPathSnapshot(deps, target); + const e2eCliPath = process.env.ASPIRE_EXTENSION_E2E_CLI_PATH?.trim(); + + // Different test-provided `deps` objects targeting the same scope must never + // coalesce with each other's in-flight probe, even when their snapshots match. + if (state.inFlight?.deps === deps + && state.inFlight.configuredPath === configuredPathSnapshot.configuredPath + && state.inFlight.configuredPathIsLegacyDefault === configuredPathSnapshot.configuredPathIsLegacyDefault + && state.inFlight.workspaceFoldersKey === configuredPathSnapshot.workspaceFoldersKey + && state.inFlight.e2eCliPath === e2eCliPath) { + return state.inFlight.promise; + } - deps.updateResolvedPathForForwarding( - currentConfiguredPathSnapshot.configuredPath, - (result.source === 'default-install' || result.source === 'path') + const generation = ++state.generation; + let writtenConfiguredPathSnapshot: ConfiguredCliPathSnapshot | undefined; + let promise!: Promise; + // resolveCore can synchronously publish a configured-path rejection before its first + // await. Publish state.inFlight first so forwarding listeners that re-enter resolve() + // coalesce with this probe instead of replacing it and creating a promise cycle. + promise = Promise.resolve() + .then(() => this.resolveCore( + target, + state, + deps, + configuredPathSnapshot, + e2eCliPath, + generation, + snapshot => writtenConfiguredPathSnapshot = snapshot)) + .then(result => { + // A setting edit or scope change can race a CLI probe. Do not return a result + // for a configuration snapshot that is no longer current. + const currentConfiguredPathSnapshot = this.getConfiguredCliPathSnapshot(deps, target); + if (generation !== state.generation + || (!this.areConfiguredCliPathSnapshotsEqual(currentConfiguredPathSnapshot, configuredPathSnapshot) + && !this.areConfiguredCliPathSnapshotsEqual(currentConfiguredPathSnapshot, writtenConfiguredPathSnapshot)) + || process.env.ASPIRE_EXTENSION_E2E_CLI_PATH?.trim() !== e2eCliPath) { + return this.resolve(target, depsOverrideForTests); + } + + // A raw configured path equal to the concrete result (no token/suffix expansion) + // need not be republished; the setting itself is already forwardable. + const forwardingCandidate = (result.source === 'default-install' || result.source === 'path' || result.source === 'configured') && isAbsoluteCliPath(result.cliPath) && !areCliPathsEqual(currentConfiguredPathSnapshot.configuredPath, result.cliPath) ? result.cliPath - : undefined); - return result; - }) - .finally(() => { - if (inFlightCliPathResolutions.get(deps)?.promise === promise) { - inFlightCliPathResolutions.delete(deps); - } - }); + : undefined; + + deps.updateResolvedPathForForwarding?.(currentConfiguredPathSnapshot.configuredPath, forwardingCandidate); + this.updateResolvedCliPathForForwarding(state, target, currentConfiguredPathSnapshot.configuredPath, forwardingCandidate); + return result; + }) + .finally(() => { + if (state.inFlight?.promise === promise) { + state.inFlight = undefined; + } + }); + + state.inFlight = { + deps, + configuredPath: configuredPathSnapshot.configuredPath, + configuredPathIsLegacyDefault: configuredPathSnapshot.configuredPathIsLegacyDefault, + workspaceFoldersKey: configuredPathSnapshot.workspaceFoldersKey, + e2eCliPath, + promise, + }; + return promise; + } - inFlightCliPathResolutions.set(deps, { - configuredPath: configuredPathSnapshot.configuredPath, - configuredPathIsLegacyDefault: configuredPathSnapshot.configuredPathIsLegacyDefault, - e2eCliPath, - promise, - }); - return promise; -} + /** + * Reports whether CLI resolution for `target` rejected `rawConfiguredPath` and fell back + * to a different CLI. Such a path must not be forwarded as `AspireCliPath`. + */ + isConfiguredPathRejectedForForwarding(target: CliPathResolutionTarget, rawConfiguredPath: string): boolean { + return this._scopeStates.get(getCliPathTargetKey(target))?.rejectedConfiguredPath === rawConfiguredPath; + } -function areConfiguredCliPathSnapshotsEqual( - left: ConfiguredCliPathSnapshot, - right: ConfiguredCliPathSnapshot | undefined, -): boolean { - return right !== undefined - && left.configuredPath === right.configuredPath - && left.configuredPathIsLegacyDefault === right.configuredPathIsLegacyDefault; -} + /** + * Returns the effective absolute discovery path resolved for `target`'s current setting + * snapshot without persisting it as an explicit user configuration value. + */ + getResolvedCliPathForForwarding(target: CliPathResolutionTarget, rawConfiguredPath: string): string | undefined { + const resolved = this._scopeStates.get(getCliPathTargetKey(target))?.resolvedPathForForwarding; + return resolved?.configuredPath === rawConfiguredPath ? resolved.resolvedPath : undefined; + } -async function resolveCliPathCore( - deps: CliPathDependencies, - configuredPathSnapshot: ConfiguredCliPathSnapshot, - e2eCliPath: string | undefined, - resolutionGeneration: number, - configuredPathWritten: (snapshot: ConfiguredCliPathSnapshot) => void, -): Promise { - const { configuredPath, configuredPathIsLegacyDefault, defaultPaths } = configuredPathSnapshot; - let expectedConfiguredPathSnapshot = configuredPathSnapshot; - - const updateConfiguredPath = async (value: string): Promise => { - // Do not overwrite a setting whose value or scope changed while the CLI probe - // was running. A workspace pin can have the same effective value as the global - // auto-configured setting, but must still block a stale global write. - if (resolutionGeneration !== cliPathResolutionGeneration - || !areConfiguredCliPathSnapshotsEqual( - getConfiguredCliPathSnapshot(deps), - expectedConfiguredPathSnapshot)) { - return; - } + /** Test seam that clears all per-target resolution state between cases. */ + resetForTests(): void { + this._scopeStates.clear(); + } - const writtenConfiguredPathSnapshot = { - configuredPath: value, - configuredPathIsLegacyDefault: value !== '' && containsCliPath(defaultPaths, value), + dispose(): void { + this._scopeStates.clear(); + this._onDidChangeConfiguredCliPathRejectionEmitter.dispose(); + this._onDidChangeResolvedCliPathForForwardingEmitter.dispose(); + this._onDidChangeForwardingEmitter.dispose(); + } + + private getConfiguredCliPathSnapshot(deps: CliPathDependencies, target: CliPathResolutionTarget): ConfiguredCliPathSnapshot { + const configuredPath = deps.getConfiguredPath(target); + const defaultPaths = deps.getDefaultPaths(); + const workspaceFolders = configuredPath.includes('${workspaceFolder') + ? [...deps.getWorkspaceFolders()] + : []; + return { + configuredPath, + configuredPathIsLegacyDefault: configuredPath !== '' + && deps.isConfiguredPathAutoConfigured(configuredPath, defaultPaths, target), defaultPaths, + workspaceFolders, + workspaceFoldersKey: JSON.stringify(workspaceFolders.map(folder => [folder.name, folder.uri.toString()])), }; - await deps.setConfiguredPath(value); - expectedConfiguredPathSnapshot = writtenConfiguredPathSnapshot; - configuredPathWritten(writtenConfiguredPathSnapshot); - }; - - if (e2eCliPath) { - const isValid = await deps.tryExecute(e2eCliPath); - if (isValid) { - return { cliPath: e2eCliPath, available: true, source: 'configured' }; - } + } - extensionLogOutputChannel.warn(`E2E CLI path is invalid: ${e2eCliPath}`); + private areConfiguredCliPathSnapshotsEqual( + left: ConfiguredCliPathSnapshot, + right: ConfiguredCliPathSnapshot | undefined, + ): boolean { + return right !== undefined + && left.configuredPath === right.configuredPath + && left.configuredPathIsLegacyDefault === right.configuredPathIsLegacyDefault + && left.workspaceFoldersKey === right.workspaceFoldersKey; } - // Check if user has configured a custom path (not one of the defaults) - if (configuredPath && (!configuredPathIsLegacyDefault || isCommandShimPath(configuredPath))) { - const isValid = await deps.tryExecute(configuredPath); - if (isValid) { - updateRejectedConfiguredCliPath(configuredPath, undefined, deps.getConfiguredPath, resolutionGeneration); - return { cliPath: configuredPath, available: true, source: 'configured' }; + private updateRejectedConfiguredCliPath( + state: CliPathScopeState, + target: CliPathResolutionTarget, + expectedConfiguredPath: string, + rejectedPath: string | undefined, + generation: number, + deps: CliPathDependencies, + ): void { + // CLI probes are asynchronous and can overlap. Only the latest resolution for + // the setting that is still current may change forwarding state. + if (generation !== state.generation || deps.getConfiguredPath(target) !== expectedConfiguredPath) { + return; } - extensionLogOutputChannel.warn(`Configured CLI path is invalid: ${configuredPath}`); - // Everything below this point resolves a different CLI. The setting is kept so an - // explicit user pin is not silently erased, but it must stop being forwarded as - // AspireCliPath, otherwise MSBuild resolves bundle assets from the CLI that failed. - extensionLogOutputChannel.warn('Suppressing AspireCliPath forwarding for the rejected configured CLI path'); - updateRejectedConfiguredCliPath(configuredPath, configuredPath, deps.getConfiguredPath, resolutionGeneration); - // Continue to check other locations - } - else { - updateRejectedConfiguredCliPath(configuredPath, undefined, deps.getConfiguredPath, resolutionGeneration); + if (state.rejectedConfiguredPath === rejectedPath) { + return; + } + + state.rejectedConfiguredPath = rejectedPath; + this._onDidChangeConfiguredCliPathRejectionEmitter.fire(target); + this._onDidChangeForwardingEmitter.fire(target); } - // 2. Check if CLI is on PATH - const cliOnPath = await deps.findOnPath(); - if (cliOnPath) { - // If we previously auto-set the path to a default install location, clear it - // since PATH is now working - if (configuredPathIsLegacyDefault) { - extensionLogOutputChannel.info('Clearing aspireCliExecutablePath setting since CLI is on PATH'); - await updateConfiguredPath(''); + private updateResolvedCliPathForForwarding( + state: CliPathScopeState, + target: CliPathResolutionTarget, + configuredPath: string, + resolvedPath: string | undefined, + ): void { + const nextValue = resolvedPath === undefined + ? undefined + : { configuredPath, resolvedPath }; + if (state.resolvedPathForForwarding?.configuredPath === nextValue?.configuredPath + && state.resolvedPathForForwarding?.resolvedPath === nextValue?.resolvedPath) { + return; } - return { cliPath: cliOnPath, available: true, source: 'path' }; + state.resolvedPathForForwarding = nextValue; + this._onDidChangeResolvedCliPathForForwardingEmitter.fire(target); + this._onDidChangeForwardingEmitter.fire(target); } - // 3. Check default installation paths (~/.aspire/bin first, then ~/.dotnet/tools) - const foundPath = await deps.findAtDefaultPath(); - if (foundPath) { - // The setting does not record who wrote it, so persist only paths that - // older versions already recognized as automatic defaults. Newly added - // discovery locations remain distinguishable from explicit user pins. - if (!areCliPathsEqual(configuredPath, foundPath)) { - if ((configuredPath === '' || configuredPathIsLegacyDefault) - && containsCliPath(defaultPaths, foundPath) - && !isCommandShimPath(foundPath)) { - extensionLogOutputChannel.info('Updating aspireCliExecutablePath setting to use default install location'); - await updateConfiguredPath(foundPath); + private async resolveCore( + target: CliPathResolutionTarget, + state: CliPathScopeState, + deps: CliPathDependencies, + configuredPathSnapshot: ConfiguredCliPathSnapshot, + e2eCliPath: string | undefined, + generation: number, + configuredPathWritten: (snapshot: ConfiguredCliPathSnapshot) => void, + ): Promise { + const { configuredPath, configuredPathIsLegacyDefault, defaultPaths, workspaceFolders } = configuredPathSnapshot; + let expectedConfiguredPathSnapshot = configuredPathSnapshot; + + const updateConfiguredPath = async (value: string): Promise => { + // Only the window-wide setting has auto-configuration provenance; a workspace-folder + // target must never write or clear it on another scope's behalf. + if (target.kind !== 'window') { + return; + } + + // Defensive: a tokenized value should never reach here as a legacy/auto-configured + // snapshot, but never persist or clear a `${...}` variable reference regardless. + if (configuredPath.includes('${') || expectedConfiguredPathSnapshot.configuredPath.includes('${')) { + return; + } + + // Do not overwrite a setting whose value or scope changed while the CLI probe + // was running. A workspace pin can have the same effective value as the global + // auto-configured setting, but must still block a stale global write. + if (generation !== state.generation + || !this.areConfiguredCliPathSnapshotsEqual( + this.getConfiguredCliPathSnapshot(deps, target), + expectedConfiguredPathSnapshot)) { + return; + } + + const writtenConfiguredPathSnapshot = { + configuredPath: value, + configuredPathIsLegacyDefault: value !== '' && containsCliPath(defaultPaths, value), + defaultPaths, + workspaceFolders, + workspaceFoldersKey: configuredPathSnapshot.workspaceFoldersKey, + }; + await deps.setConfiguredPath(value, target); + expectedConfiguredPathSnapshot = writtenConfiguredPathSnapshot; + configuredPathWritten(writtenConfiguredPathSnapshot); + }; + + if (e2eCliPath) { + const isValid = await deps.tryExecute(e2eCliPath); + if (isValid) { + return { cliPath: e2eCliPath, available: true, source: 'configured' }; + } + + extensionLogOutputChannel.warn(`E2E CLI path is invalid: ${e2eCliPath}`); + } + + // Check if user has configured a custom path (not one of the defaults) + if (configuredPath && (!configuredPathIsLegacyDefault || isCommandShimPath(configuredPath))) { + const expanded = expandConfiguredCliPath(configuredPath, target, workspaceFolders); + + if (expanded.error) { + // An unresolvable token (unknown workspace folder, ambiguous window scope, or an + // unsupported variable) has no concrete candidate to probe; do not call tryExecute. + extensionLogOutputChannel.warn(`Configured CLI path could not be resolved: ${expanded.error}`); + this.updateRejectedConfiguredCliPath(state, target, configuredPath, configuredPath, generation, deps); + } + else { + // No-token paths return `resolvedPath === undefined`; use the raw value unchanged. + const workspaceExpandedCandidate = expanded.resolvedPath ?? configuredPath; + const effectiveCandidate = expandWindowsEnvironmentVariables(workspaceExpandedCandidate); + if (process.platform === 'win32' && /%[^%]+%/.test(effectiveCandidate)) { + // cmd.exe removes unknown %NAME% references before invoking a command shim, so + // probing an unresolved token would validate a different path than the configured pin. + extensionLogOutputChannel.warn(`Configured CLI path contains an unresolved environment variable and will not be probed: ${configuredPath}`); + this.updateRejectedConfiguredCliPath(state, target, configuredPath, configuredPath, generation, deps); + } + else if (isAbsoluteCliPath(effectiveCandidate)) { + let matchedCandidate: string | undefined; + for (const candidate of deps.getExecutableCandidates(effectiveCandidate)) { + if (await deps.tryExecute(candidate)) { + matchedCandidate = candidate; + break; + } + } + + if (matchedCandidate !== undefined) { + this.updateRejectedConfiguredCliPath(state, target, configuredPath, undefined, generation, deps); + return { cliPath: matchedCandidate, available: true, source: 'configured' }; + } + + extensionLogOutputChannel.warn(`Configured CLI path is invalid: ${configuredPath}`); + // Everything below this point resolves a different CLI. The setting is kept so an + // explicit user pin is not silently erased, but it must stop being forwarded as + // AspireCliPath, otherwise MSBuild resolves bundle assets from the CLI that failed. + extensionLogOutputChannel.warn('Suppressing AspireCliPath forwarding for the rejected configured CLI path'); + this.updateRejectedConfiguredCliPath(state, target, configuredPath, configuredPath, generation, deps); + // Continue to check other locations + } + else { + // A plain relative path or bare command name is not a valid configured CLI + // candidate; only PATH lookup resolves bare command names. + extensionLogOutputChannel.warn(`Configured CLI path must be absolute: ${configuredPath}`); + this.updateRejectedConfiguredCliPath(state, target, configuredPath, configuredPath, generation, deps); + } } - else if (configuredPathIsLegacyDefault) { - // The extension will execute foundPath, while the configured setting is independently - // forwarded as AspireCliPath for MSBuild bundle resolution. Leaving a legacy setting here - // could therefore run one CLI while stamping AppHosts with another CLI's bundle paths. - extensionLogOutputChannel.info('Clearing superseded auto-configured aspireCliExecutablePath setting'); + } + else { + this.updateRejectedConfiguredCliPath(state, target, configuredPath, undefined, generation, deps); + } + + // 2. Check if CLI is on PATH + const cliOnPath = await deps.findOnPath(); + if (cliOnPath) { + // If we previously auto-set the path to a default install location, clear it + // since PATH is now working + if (configuredPathIsLegacyDefault) { + extensionLogOutputChannel.info('Clearing aspireCliExecutablePath setting since CLI is on PATH'); await updateConfiguredPath(''); } + + return { cliPath: cliOnPath, available: true, source: 'path' }; + } + + // 3. Check default installation paths (~/.aspire/bin first, then ~/.dotnet/tools) + const foundPath = await deps.findAtDefaultPath(); + if (foundPath) { + // The setting does not record who wrote it, so persist only paths that + // older versions already recognized as automatic defaults. Newly added + // discovery locations remain distinguishable from explicit user pins. + if (!areCliPathsEqual(configuredPath, foundPath)) { + if ((configuredPath === '' || configuredPathIsLegacyDefault) + && containsCliPath(defaultPaths, foundPath) + && !isCommandShimPath(foundPath)) { + extensionLogOutputChannel.info('Updating aspireCliExecutablePath setting to use default install location'); + await updateConfiguredPath(foundPath); + } + else if (configuredPathIsLegacyDefault) { + // The extension will execute foundPath, while the configured setting is independently + // forwarded as AspireCliPath for MSBuild bundle resolution. Leaving a legacy setting here + // could therefore run one CLI while stamping AppHosts with another CLI's bundle paths. + extensionLogOutputChannel.info('Clearing superseded auto-configured aspireCliExecutablePath setting'); + await updateConfiguredPath(''); + } + } + + return { cliPath: foundPath, available: true, source: 'default-install' }; } - return { cliPath: foundPath, available: true, source: 'default-install' }; + // A legacy extension version may have persisted a default path that is no + // longer part of current discovery (for example after DOTNET_CLI_HOME is + // redirected). Keep it as the final fallback without letting it outrank a + // working PATH or current install location. + if (configuredPathIsLegacyDefault && await deps.tryExecute(configuredPath)) { + return { cliPath: configuredPath, available: true, source: 'default-install' }; + } + + // CLI not found anywhere + return { cliPath: 'aspire', available: false, source: 'not-found' }; } +} + +/** Shared long-lived resolver instance used by all window-scoped compatibility exports below. */ +export const cliPathResolver = new CliPathResolver(); - // A legacy extension version may have persisted a default path that is no - // longer part of current discovery (for example after DOTNET_CLI_HOME is - // redirected). Keep it as the final fallback without letting it outrank a - // working PATH or current install location. - if (configuredPathIsLegacyDefault && await deps.tryExecute(configuredPath)) { - return { cliPath: configuredPath, available: true, source: 'default-install' }; +const legacyConfiguredCliPathRejectionEmitter = new vscode.EventEmitter(); +const legacyResolvedCliPathForForwardingEmitter = new vscode.EventEmitter(); +cliPathResolver.onDidChangeConfiguredCliPathRejection(() => legacyConfiguredCliPathRejectionEmitter.fire()); +cliPathResolver.onDidChangeResolvedCliPathForForwarding(() => legacyResolvedCliPathForForwardingEmitter.fire()); + +/** + * Fires when CLI resolution changes whether the configured path may be forwarded + * through `AspireCliPath`, for any target. Window-scoped consumers that have not + * migrated to an explicit `CliPathResolutionTarget` can keep using this event. + */ +export const onDidChangeConfiguredCliPathRejection = legacyConfiguredCliPathRejectionEmitter.event; +export const onDidChangeResolvedCliPathForForwarding = legacyResolvedCliPathForForwardingEmitter.event; + +/** + * Resolves the Aspire CLI path for the given resolution target using the shared + * `cliPathResolver`. + * + * The single-argument `deps` overload is a temporary test seam preserved for existing + * callers; it resolves against `windowCliPathTarget` with the supplied dependencies. The two + * shapes are discriminated by the `kind` property, which only a `CliPathResolutionTarget` has. + */ +export function resolveCliPath( + targetOrDeps?: CliPathResolutionTarget | CliPathDependencies, +): Promise { + if (targetOrDeps !== undefined && 'kind' in targetOrDeps) { + return cliPathResolver.resolve(targetOrDeps); } - // CLI not found anywhere - return { cliPath: 'aspire', available: false, source: 'not-found' }; + return cliPathResolver.resolve(windowCliPathTarget, targetOrDeps); +} + +/** + * Reports whether CLI resolution rejected this configured path and fell back to a + * different CLI. Such a path must not be forwarded as `AspireCliPath`. + */ +export function isConfiguredCliPathRejectedForForwarding( + configuredPath: string, + target: CliPathResolutionTarget = windowCliPathTarget, +): boolean { + return cliPathResolver.isConfiguredPathRejectedForForwarding(target, configuredPath); +} + +/** + * Returns the effective absolute discovery path resolved for the current setting + * snapshot without persisting it as an explicit user configuration value. + */ +export function getResolvedCliPathForForwarding( + configuredPath: string, + target: CliPathResolutionTarget = windowCliPathTarget, +): string | undefined { + return cliPathResolver.getResolvedCliPathForForwarding(target, configuredPath); +} + +/** Test seam that clears the rejected-configured-path and forwarding state between cases. */ +export function resetRejectedConfiguredCliPathForForwarding(): void { + cliPathResolver.resetForTests(); } diff --git a/extension/src/utils/cliPathEnvironment.ts b/extension/src/utils/cliPathEnvironment.ts index 88dfcdb581f..06520365e6f 100644 --- a/extension/src/utils/cliPathEnvironment.ts +++ b/extension/src/utils/cliPathEnvironment.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as vscode from 'vscode'; import { + CliPathResolver, getConfiguredCliPath, getResolvedCliPathForForwarding, isConfiguredCliPathRejectedForForwarding, @@ -10,6 +11,12 @@ import { onDidChangeResolvedCliPathForForwarding, resolveCliPath, } from './cliPath'; +import { + CliPathResolutionTarget, + getCliPathTargetKey, + windowCliPathTarget, + workspaceFolderCliPathTarget, +} from './cliPathVariables'; import { extensionLogOutputChannel } from './logging'; import { aspireCliPathEnvironmentDescription } from '../loc/strings'; @@ -40,10 +47,13 @@ export interface CliPathEnvironmentCollection { delete(variable: string): void; } -export interface ForwardableCliPathDependencies { +export interface ResolvedCliPathDependencies { isAbsolute: (cliPath: string) => boolean; fileExists: (cliPath: string) => boolean; realpath: (cliPath: string) => string | undefined; +} + +export interface ForwardableCliPathDependencies extends ResolvedCliPathDependencies { isRejectedForForwarding: (cliPath: string) => boolean; } @@ -57,12 +67,16 @@ export interface CliPathEnvironmentDependencies extends ForwardableCliPathDepend log?: (message: string) => void; } -const defaultForwardableCliPathDeps: ForwardableCliPathDependencies = { +const defaultResolvedCliPathDeps: ResolvedCliPathDependencies = { isAbsolute: cliPath => process.platform === 'win32' ? isFullyQualifiedWindowsPath(cliPath) : path.isAbsolute(cliPath), fileExists: fileExists, realpath: realpath, +}; + +const defaultForwardableCliPathDeps: ForwardableCliPathDependencies = { + ...defaultResolvedCliPathDeps, isRejectedForForwarding: isConfiguredCliPathRejectedForForwarding, }; @@ -73,18 +87,22 @@ const defaultDeps: CliPathEnvironmentDependencies = { log: (message) => extensionLogOutputChannel.info(message), }; +function isForwardableResolvedCliPath(cliPath: string, deps: ResolvedCliPathDependencies): boolean { + return cliPath.length > 0 + && deps.isAbsolute(cliPath) + && deps.fileExists(cliPath) + && !isUnbundledFrameworkDependentCliPath(cliPath, deps) + && !isResolvedUnbundledFrameworkDependentCliPath(cliPath, deps); +} + export function isForwardableAspireCliPath( configuredPath: string, deps: ForwardableCliPathDependencies = defaultForwardableCliPathDeps, ): boolean { - return configuredPath.length > 0 - && deps.isAbsolute(configuredPath) - && deps.fileExists(configuredPath) - // CLI resolution rejected this path and is running a different CLI, so forwarding it - // would make ResolveAspireCliBundle stamp bundle paths from a CLI that never ran. - && !deps.isRejectedForForwarding(configuredPath) - && !isUnbundledFrameworkDependentCliPath(configuredPath, deps) - && !isResolvedUnbundledFrameworkDependentCliPath(configuredPath, deps); + // CLI resolution rejected this path and is running a different CLI, so forwarding it + // would make ResolveAspireCliBundle stamp bundle paths from a CLI that never ran. + return isForwardableResolvedCliPath(configuredPath, deps) + && !deps.isRejectedForForwarding(configuredPath); } export function getForwardableAspireCliPath(deps: CliPathEnvironmentDependencies = defaultDeps): string | undefined { @@ -99,6 +117,23 @@ export function getForwardableAspireCliPath(deps: CliPathEnvironmentDependencies : undefined; } +/** + * Validates a concrete CLI path that has already been chosen and launched (for example, the + * exact executable `spawnCliProcess` invokes) so it can be forwarded to the child's environment. + * Unlike `getForwardableAspireCliPath`, this intentionally does not consult configured-path + * rejection state: that state describes a raw setting/target combination, but the caller here + * already selected and ran this exact executable, so rejection of a *different* configured value + * must not suppress it. + */ +export function getForwardableResolvedAspireCliPath( + cliPath: string | undefined, + deps: ResolvedCliPathDependencies = defaultResolvedCliPathDeps, +): string | undefined { + return cliPath !== undefined && isForwardableResolvedCliPath(cliPath, deps) + ? cliPath + : undefined; +} + export function createAspireCliPathProcessEnvironment( baseEnv: NodeJS.ProcessEnv = process.env, deps: CliPathEnvironmentDependencies = defaultDeps, @@ -114,6 +149,52 @@ export function createAspireCliPathProcessEnvironment( }; } +/** + * Builds a child process environment that forwards exactly the concrete, already-resolved CLI + * path a caller is about to invoke (or run against a project via `dotnet build`/`msbuild`/ + * `dotnet run-api`), rather than re-reading the configured setting. + * + * Unlike {@link createAspireCliPathProcessEnvironment}, this does not consult configured-path + * rejection state: the caller already resolved and is about to run this exact executable, so a + * *different* configured value being rejected must not suppress it. When the resolved path is + * not forwardable (relative, missing, or an unbundled framework-dependent build), any stale + * `AspireCliPath` already present in `baseEnv` is removed instead of being left to point at a + * different CLI than the one actually running. + */ +export function createResolvedAspireCliPathProcessEnvironment( + resolvedCliPath: string | undefined, + baseEnv: NodeJS.ProcessEnv = process.env, + deps: ResolvedCliPathDependencies = defaultResolvedCliPathDeps, +): NodeJS.ProcessEnv { + const env = { ...baseEnv }; + const forwardablePath = getForwardableResolvedAspireCliPath(resolvedCliPath, deps); + if (forwardablePath === undefined) { + deleteEnvVarCaseInsensitive(env, ASPIRE_CLI_PATH_ENV_VAR); + return env; + } + + deleteEnvVarCaseInsensitive(env, ASPIRE_CLI_PATH_ENV_VAR); + env[ASPIRE_CLI_PATH_ENV_VAR] = forwardablePath; + return env; +} + +// Windows environment variable names are case-insensitive, so a stale `AspireCliPath` (or +// `ASPIRECLIPATH`, etc.) already in `baseEnv` must be removed regardless of casing, matching the +// case-insensitive override semantics `mergeCliSpawnEnvironment` applies to spawned CLI processes. +function deleteEnvVarCaseInsensitive(env: NodeJS.ProcessEnv, name: string): void { + if (process.platform !== 'win32') { + delete env[name]; + return; + } + + const lowerName = name.toLowerCase(); + for (const key of Object.keys(env)) { + if (key.toLowerCase() === lowerName) { + delete env[key]; + } + } +} + function fileExists(filePath: string): boolean { try { return fs.statSync(filePath).isFile(); @@ -132,7 +213,7 @@ function realpath(filePath: string): string | undefined { } } -function isUnbundledFrameworkDependentCliPath(configuredPath: string, deps: ForwardableCliPathDependencies): boolean { +function isUnbundledFrameworkDependentCliPath(configuredPath: string, deps: ResolvedCliPathDependencies): boolean { const cliDirectory = path.dirname(configuredPath); const cliAssemblyPath = path.join(cliDirectory, 'aspire.dll'); @@ -149,7 +230,7 @@ function isUnbundledFrameworkDependentCliPath(configuredPath: string, deps: Forw return !hasInstallSidecar(cliDirectory, deps) && !hasAdjacentBundleLayout(cliDirectory, deps); } -function isResolvedUnbundledFrameworkDependentCliPath(configuredPath: string, deps: ForwardableCliPathDependencies): boolean { +function isResolvedUnbundledFrameworkDependentCliPath(configuredPath: string, deps: ResolvedCliPathDependencies): boolean { const resolvedPath = deps.realpath(configuredPath); if (resolvedPath === undefined || resolvedPath === configuredPath || !deps.isAbsolute(resolvedPath) || !deps.fileExists(resolvedPath)) { return false; @@ -158,20 +239,231 @@ function isResolvedUnbundledFrameworkDependentCliPath(configuredPath: string, de return isUnbundledFrameworkDependentCliPath(resolvedPath, deps); } -function hasInstallSidecar(cliDirectory: string, deps: ForwardableCliPathDependencies): boolean { +function hasInstallSidecar(cliDirectory: string, deps: ResolvedCliPathDependencies): boolean { return deps.fileExists(path.join(cliDirectory, '.aspire-install.json')); } -function hasAdjacentBundleLayout(cliDirectory: string, deps: ForwardableCliPathDependencies): boolean { +function hasAdjacentBundleLayout(cliDirectory: string, deps: ResolvedCliPathDependencies): boolean { return hasBundleRoot(cliDirectory, deps) || hasBundleRoot(path.join(cliDirectory, 'bundle'), deps); } -function hasBundleRoot(bundleRoot: string, deps: ForwardableCliPathDependencies): boolean { +function hasBundleRoot(bundleRoot: string, deps: ResolvedCliPathDependencies): boolean { return (deps.fileExists(path.join(bundleRoot, 'dcp', 'dcp')) || deps.fileExists(path.join(bundleRoot, 'dcp', 'dcp.exe'))) && (deps.fileExists(path.join(bundleRoot, 'managed', 'aspire-managed')) || deps.fileExists(path.join(bundleRoot, 'managed', 'aspire-managed.exe'))); } +export interface CliPathEnvironmentSynchronizerDependencies { + getWorkspaceFolders: () => readonly vscode.WorkspaceFolder[]; + getForwardablePath: (cliPath: string | undefined) => string | undefined; + onDidChangeConfiguration: vscode.Event; + onDidChangeWorkspaceFolders: vscode.Event; + onDidGrantWorkspaceTrust: vscode.Event; +} + +const defaultSynchronizerDependencies: CliPathEnvironmentSynchronizerDependencies = { + getWorkspaceFolders: () => vscode.workspace.workspaceFolders ?? [], + getForwardablePath: getForwardableResolvedAspireCliPath, + onDidChangeConfiguration: vscode.workspace.onDidChangeConfiguration, + onDidChangeWorkspaceFolders: vscode.workspace.onDidChangeWorkspaceFolders, + onDidGrantWorkspaceTrust: vscode.workspace.onDidGrantWorkspaceTrust, +}; + +/** + * Keeps VS Code's window and workspace-folder environment collections aligned with the + * exact CLI each target resolves. Folder collections are retained until their folder is + * removed so stale persisted mutations can be explicitly cleared. + */ +export class CliPathEnvironmentSynchronizer implements vscode.Disposable { + private readonly _scopedCollections = new Map(); + private readonly _forwardedPaths = new Map(); + private readonly _activeFolderTargets = new Map(); + private readonly _syncGenerations = new Map(); + private readonly _disposable: vscode.Disposable; + private _disposed = false; + + constructor( + private readonly _globalCollection: vscode.GlobalEnvironmentVariableCollection, + private readonly _resolver: CliPathResolver, + subscriptions: vscode.Disposable[], + private readonly _onForwardedPathChanged?: ( + target: CliPathResolutionTarget, + previousPath: string | undefined, + currentPath: string | undefined, + ) => void, + private readonly _deps: CliPathEnvironmentSynchronizerDependencies = defaultSynchronizerDependencies, + ) { + this._disposable = vscode.Disposable.from( + this._deps.onDidChangeConfiguration(event => this.handleConfigurationChange(event)), + this._resolver.onDidChangeForwarding(target => this.syncTargetInBackground(target)), + this._deps.onDidChangeWorkspaceFolders(event => this.handleWorkspaceFoldersChange(event)), + this._deps.onDidGrantWorkspaceTrust(() => this.initializeInBackground())); + subscriptions.push(this._disposable); + } + + async initialize(workspaceFolders: readonly vscode.WorkspaceFolder[] = this._deps.getWorkspaceFolders()): Promise { + const nextFolderKeys = new Set(workspaceFolders.map(folder => getCliPathTargetKey(workspaceFolderCliPathTarget(folder)))); + for (const [key, target] of this._activeFolderTargets) { + if (!nextFolderKeys.has(key)) { + this.removeFolderTarget(target); + } + } + + const folderTargets = workspaceFolders.map(folder => workspaceFolderCliPathTarget(folder)); + for (const target of folderTargets) { + this._activeFolderTargets.set(getCliPathTargetKey(target), target); + } + this.clearGlobalPathForFolders(); + + await Promise.all([ + this.syncTarget(windowCliPathTarget), + ...folderTargets.map(target => this.syncTarget(target)), + ]); + } + + async syncTarget(target: CliPathResolutionTarget): Promise { + const key = getCliPathTargetKey(target); + if (this._disposed || (target.kind === 'workspaceFolder' && !this._activeFolderTargets.has(key))) { + return; + } + + const generation = (this._syncGenerations.get(key) ?? 0) + 1; + this._syncGenerations.set(key, generation); + const result = await this._resolver.resolve(target); + if (this._disposed + || this._syncGenerations.get(key) !== generation + || (target.kind === 'workspaceFolder' && !this._activeFolderTargets.has(key))) { + return; + } + + const collection = target.kind === 'window' + ? this._globalCollection + : this._globalCollection.getScoped({ workspaceFolder: target.workspaceFolder }); + if (target.kind === 'workspaceFolder') { + this._scopedCollections.set(key, collection); + } + + const resolvedPath = this._deps.getForwardablePath(result.available ? result.cliPath : undefined); + // An unscoped mutation is inherited by every workspace folder and cannot be masked by + // deleting a folder-scoped mutator. Use it only when no folder scopes exist. + const nextPath = target.kind === 'window' && this._activeFolderTargets.size > 0 + ? undefined + : resolvedPath; + const hadPreviousPath = this._forwardedPaths.has(key); + const previousPath = this._forwardedPaths.get(key); + if (nextPath === undefined) { + collection.description = undefined; + collection.delete(ASPIRE_CLI_PATH_ENV_VAR); + } + else { + collection.description = aspireCliPathEnvironmentDescription; + collection.replace(ASPIRE_CLI_PATH_ENV_VAR, nextPath); + } + this._forwardedPaths.set(key, nextPath); + + if (hadPreviousPath && previousPath !== nextPath) { + this._onForwardedPathChanged?.(target, previousPath, nextPath); + } + } + + dispose(): void { + this._disposed = true; + this._disposable.dispose(); + this._scopedCollections.clear(); + this._forwardedPaths.clear(); + this._activeFolderTargets.clear(); + this._syncGenerations.clear(); + } + + private handleConfigurationChange(event: vscode.ConfigurationChangeEvent): void { + const section = `aspire.${ASPIRE_CLI_EXECUTABLE_PATH_SETTING}`; + if (!event.affectsConfiguration(section)) { + return; + } + + this.syncTargetInBackground(windowCliPathTarget); + for (const target of this._activeFolderTargets.values()) { + if (target.kind === 'workspaceFolder' && event.affectsConfiguration(section, target.workspaceFolder.uri)) { + this.syncTargetInBackground(target); + } + } + } + + private handleWorkspaceFoldersChange(event: vscode.WorkspaceFoldersChangeEvent): void { + for (const folder of event.removed) { + this.removeFolderTarget(workspaceFolderCliPathTarget(folder)); + } + const addedTargets = event.added.map(folder => workspaceFolderCliPathTarget(folder)); + for (const target of addedTargets) { + this._activeFolderTargets.set(getCliPathTargetKey(target), target); + } + this.clearGlobalPathForFolders(); + + // A folder setting can reference any other folder through ${workspaceFolder:name}, + // so additions and removals can change every active folder's effective executable. + for (const target of this._activeFolderTargets.values()) { + this.syncTargetInBackground(target); + } + + // An unqualified ${workspaceFolder} window setting changes meaning as folders are + // added or removed, even when no configuration value itself changed. + this.syncTargetInBackground(windowCliPathTarget); + } + + private clearGlobalPathForFolders(): void { + if (this._activeFolderTargets.size === 0) { + return; + } + + this._globalCollection.description = undefined; + this._globalCollection.delete(ASPIRE_CLI_PATH_ENV_VAR); + + const key = getCliPathTargetKey(windowCliPathTarget); + const hadPreviousPath = this._forwardedPaths.has(key); + const previousPath = this._forwardedPaths.get(key); + this._forwardedPaths.set(key, undefined); + if (hadPreviousPath && previousPath !== undefined) { + this._onForwardedPathChanged?.(windowCliPathTarget, previousPath, undefined); + } + } + + private removeFolderTarget(target: CliPathResolutionTarget): void { + if (target.kind !== 'workspaceFolder') { + return; + } + + const key = getCliPathTargetKey(target); + this._activeFolderTargets.delete(key); + this._syncGenerations.set(key, (this._syncGenerations.get(key) ?? 0) + 1); + + // getScoped is stable for a folder scope and also reaches a mutation restored by VS Code + // before this activation's first asynchronous resolution completed. + const collection = this._globalCollection.getScoped({ workspaceFolder: target.workspaceFolder }); + collection.description = undefined; + collection.delete(ASPIRE_CLI_PATH_ENV_VAR); + this._scopedCollections.delete(key); + + const hadPreviousPath = this._forwardedPaths.has(key); + const previousPath = this._forwardedPaths.get(key); + this._forwardedPaths.delete(key); + if (hadPreviousPath && previousPath !== undefined) { + this._onForwardedPathChanged?.(target, previousPath, undefined); + } + } + + private syncTargetInBackground(target: CliPathResolutionTarget): void { + void this.syncTarget(target).catch(error => { + extensionLogOutputChannel.warn(`Aspire CLI path environment synchronization failed: ${String(error)}`); + }); + } + + private initializeInBackground(): void { + void this.initialize().catch(error => { + extensionLogOutputChannel.warn(`Aspire CLI path environment initialization failed: ${String(error)}`); + }); + } +} + /** * Applies the configured CLI path, or the effective unpersisted fallback, to * the supplied environment variable collection. Called at activation and when diff --git a/extension/src/utils/cliPathRejectionNotifier.ts b/extension/src/utils/cliPathRejectionNotifier.ts new file mode 100644 index 00000000000..d74152f2cd1 --- /dev/null +++ b/extension/src/utils/cliPathRejectionNotifier.ts @@ -0,0 +1,92 @@ +import * as vscode from 'vscode'; +import { CliPathResolutionTarget, getCliPathTargetKey } from './cliPathVariables'; +import { cliPathResolver, getConfiguredCliPath, isConfiguredCliPathRejectedForForwarding } from './cliPath'; +import { configuredCliPathRejected, configuredCliPathRejectedOpenSetting } from '../loc/strings'; + +/** The setting a rejected configured CLI path comes from. */ +const cliPathSettingId = 'aspire.aspireCliExecutablePath'; + +/** + * Test seam for the notification surface, so unit tests can observe what the user would see + * without a real window. + */ +export interface CliPathRejectionNotificationSurface { + showWarning(message: string, ...actions: string[]): Thenable; + openSetting(settingId: string): Thenable; +} + +const defaultSurface: CliPathRejectionNotificationSurface = { + showWarning: (message, ...actions) => vscode.window.showWarningMessage(message, ...actions), + openSetting: settingId => vscode.commands.executeCommand('workbench.action.openSettings', settingId), +}; + +/** Test seam for the CLI path resolution state the notifier reads. */ +export interface CliPathRejectionState { + getConfiguredPath(target: CliPathResolutionTarget): string; + isRejected(configuredPath: string, target: CliPathResolutionTarget): boolean; + onDidChangeRejection(listener: (target: CliPathResolutionTarget) => void): vscode.Disposable; +} + +const defaultState: CliPathRejectionState = { + getConfiguredPath: target => getConfiguredCliPath(target), + isRejected: (configuredPath, target) => isConfiguredCliPathRejectedForForwarding(configuredPath, target), + onDidChangeRejection: listener => cliPathResolver.onDidChangeConfiguredCliPathRejection(listener), +}; + +/** + * Surfaces a user-visible warning when an explicitly configured Aspire CLI path is rejected and + * resolution silently falls back to a different CLI. + * + * Without this, the only signal is a line in the extension output channel, so a mistyped path (or + * one that names the build output directory rather than the executable) looks like it took effect + * while every command actually runs an unrelated CLI version. That mismatch surfaces much later as + * confusing package-downgrade or assembly-load errors. + */ +export class CliPathRejectionNotifier implements vscode.Disposable { + private readonly _disposable: vscode.Disposable; + + // Keyed by resolution scope so a rejection in one workspace folder does not suppress the + // warning for another. The stored value is the path already reported for that scope, so a + // user who edits the setting to a different bad value is told again. + private readonly _notifiedPathByScope = new Map(); + + constructor( + private readonly _surface: CliPathRejectionNotificationSurface = defaultSurface, + private readonly _state: CliPathRejectionState = defaultState, + ) { + this._disposable = this._state.onDidChangeRejection(target => { + void this.notifyIfRejected(target); + }); + } + + async notifyIfRejected(target: CliPathResolutionTarget): Promise { + const scopeKey = getCliPathTargetKey(target); + const configuredPath = this._state.getConfiguredPath(target); + + if (!configuredPath || !this._state.isRejected(configuredPath, target)) { + // Resolution recovered (or the setting was cleared), so allow the same path to be + // reported again if it is rejected once more later in the session. + this._notifiedPathByScope.delete(scopeKey); + return; + } + + if (this._notifiedPathByScope.get(scopeKey) === configuredPath) { + return; + } + + this._notifiedPathByScope.set(scopeKey, configuredPath); + + const selection = await this._surface.showWarning( + configuredCliPathRejected(configuredPath), + configuredCliPathRejectedOpenSetting); + + if (selection === configuredCliPathRejectedOpenSetting) { + await this._surface.openSetting(cliPathSettingId); + } + } + + dispose(): void { + this._disposable.dispose(); + this._notifiedPathByScope.clear(); + } +} diff --git a/extension/src/utils/cliPathVariables.ts b/extension/src/utils/cliPathVariables.ts new file mode 100644 index 00000000000..4bd98b1c6b8 --- /dev/null +++ b/extension/src/utils/cliPathVariables.ts @@ -0,0 +1,196 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; + +export type CliPathResolutionTarget = + | { readonly kind: 'window' } + | { readonly kind: 'workspaceFolder'; readonly workspaceFolder: vscode.WorkspaceFolder }; + +export const windowCliPathTarget: CliPathResolutionTarget = { kind: 'window' }; + +export function workspaceFolderCliPathTarget(folder: vscode.WorkspaceFolder): CliPathResolutionTarget { + return { kind: 'workspaceFolder', workspaceFolder: folder }; +} + +/** + * Returns the configuration scope key for a resolution target. + * Window scope uses the literal string 'window'; workspace-folder scope + * uses the folder URI so that each folder has its own stored value. + */ +export function getCliPathTargetKey(target: CliPathResolutionTarget): string { + if (target.kind === 'window') { + return 'window'; + } + return `workspaceFolder:${target.workspaceFolder.uri.toString()}`; +} + +export interface ExpandedConfiguredCliPath { + /** The raw configured path before any variable substitution. */ + configuredPath: string; + /** The fully resolved path after token expansion and normalization, if successful. */ + resolvedPath?: string; + /** Human-readable description of why expansion failed, if it did. */ + error?: string; +} + +// Matches any ${...} variable token in a configured path string. +const tokenRegex = /\$\{([^}]+)\}/g; + +/** + * Expands `${workspaceFolder}` and `${workspaceFolder:name}` tokens in a + * configured CLI path and returns both the original and the resolved form. + * + * Rules: + * - `${workspaceFolder}` uses the operation folder when the target is a + * workspace folder, or the single open folder when the target is window. + * - `${workspaceFolder:name}` requires exactly one open folder with that name. + * - Unknown `${...}` tokens are rejected with an error. + * - Paths with no tokens are returned unchanged (no resolvedPath). + * - Normalization is applied only when at least one token was expanded. + * + * @param configuredPath - The raw string from configuration. + * @param target - The resolution target (window or a specific workspace folder). + * @param workspaceFolders - The open workspace folders; defaults to VS Code's live list. + * @param platform - The OS platform; defaults to `process.platform`. + */ +export function expandConfiguredCliPath( + configuredPath: string, + target: CliPathResolutionTarget, + workspaceFolders: readonly vscode.WorkspaceFolder[] = vscode.workspace.workspaceFolders ?? [], + platform: NodeJS.Platform = process.platform +): ExpandedConfiguredCliPath { + // Fast path: no tokens — absent resolvedPath and error signals the caller to use configuredPath unchanged. + if (!configuredPath.includes('${')) { + return { configuredPath }; + } + + const pathLib = platform === 'win32' ? path.win32 : path.posix; + + let error: string | undefined; + let hasExpandedToken = false; + + const resolved = configuredPath.replace(tokenRegex, (fullToken, tokenContent: string) => { + // Once an error is recorded stop substituting further tokens. + if (error !== undefined) { + return fullToken; + } + + if (tokenContent === 'workspaceFolder') { + if (target.kind === 'workspaceFolder') { + hasExpandedToken = true; + return target.workspaceFolder.uri.fsPath; + } + + // Window target: only valid when exactly one folder is open. + if (workspaceFolders.length === 1) { + hasExpandedToken = true; + return workspaceFolders[0].uri.fsPath; + } + if (workspaceFolders.length === 0) { + error = `${fullToken} cannot be expanded: no workspace folders are open`; + } else { + error = `${fullToken} is ambiguous: multiple workspace folders are open`; + } + return fullToken; + } + + if (tokenContent.startsWith('workspaceFolder:')) { + const name = tokenContent.slice('workspaceFolder:'.length); + const matches = workspaceFolders.filter(f => f.name === name); + + if (matches.length === 0) { + error = `${fullToken} cannot be expanded: no workspace folder named '${name}' is open`; + return fullToken; + } + if (matches.length > 1) { + error = `${fullToken} is ambiguous: multiple workspace folders named '${name}' are open`; + return fullToken; + } + + hasExpandedToken = true; + return matches[0].uri.fsPath; + } + + error = `Unknown variable ${fullToken} in configured CLI path`; + return fullToken; + }); + + if (error !== undefined) { + return { configuredPath, error }; + } + + if (hasExpandedToken) { + return { configuredPath, resolvedPath: pathLib.normalize(resolved) }; + } + + return { configuredPath }; +} + +/** + * Returns the resolution target that owns `uri`: the workspace folder containing it, or the + * window scope when no open folder owns it (for example, a path outside every open folder). + * + * AppHost/project/config URIs must always resolve their scope this way rather than from + * process cwd, so a hidden CLI operation always resolves and forwards the same CLI its + * owning folder configured. + */ +export function getCliPathTargetForUri(uri: vscode.Uri): CliPathResolutionTarget { + const folder = vscode.workspace.getWorkspaceFolder(uri); + return folder ? workspaceFolderCliPathTarget(folder) : windowCliPathTarget; +} + +/** File name of the Aspire CLI executable, without any platform-specific extension. */ +const cliExecutableFileName = 'aspire'; + +/** + * Extensions Windows treats as directly executable, used both to complete an extensionless path and + * to recognise a path that already names the executable. + */ +const windowsExecutableExtensions = ['.exe', '.cmd', '.bat']; + +/** + * Returns the set of filesystem paths to probe when looking for the CLI + * executable at `cliPath`. + * + * On non-Windows platforms, or when the path already carries an extension, + * only the path itself is returned. On Windows, an extensionless path yields + * four candidates so that both native executables and command shims are found. + * + * A directory containing the CLI is also probed, because pointing the setting at a build + * output folder such as `artifacts/bin/Aspire.Cli/Debug/net10.0` is a natural mistake that + * otherwise fails silently. Probing is a process launch that fails fast when the joined path + * does not exist, so the extra candidates cost nothing once an earlier candidate matches. + */ +export function getCliExecutableCandidates( + cliPath: string, + platform: NodeJS.Platform = process.platform +): string[] { + const withExtensions = (candidate: string): string[] => { + if (platform !== 'win32') { + return [candidate]; + } + + // An existing extension (e.g. .exe, .cmd) means the caller already knows + // the exact form; do not append additional extensions. + if (path.win32.extname(candidate) !== '') { + return [candidate]; + } + + return [candidate, ...windowsExecutableExtensions.map(extension => `${candidate}${extension}`)]; + }; + const joiner = platform === 'win32' ? path.win32 : path.posix; + + // Only an executable extension means the path names the CLI itself. extname() reports the text + // after the last dot, so testing for "has any extension" classified a perfectly ordinary + // directory - `...\bin\Debug\net10.0`, which is where a locally built CLI lives - as a file with + // extension `.0`, and `aspire.exe` inside it was never probed. + const namesFile = platform === 'win32' + && windowsExecutableExtensions.includes(path.win32.extname(cliPath).toLowerCase()); + if (namesFile) { + return withExtensions(cliPath); + } + + return [ + ...withExtensions(cliPath), + ...withExtensions(joiner.join(cliPath, cliExecutableFileName)), + ]; +} diff --git a/extension/src/utils/cmdShim.ts b/extension/src/utils/cmdShim.ts index d7723bd7108..2caef8bc6ba 100644 --- a/extension/src/utils/cmdShim.ts +++ b/extension/src/utils/cmdShim.ts @@ -1,16 +1,8 @@ -import { terminalCommandArgumentControlCharacters } from '../loc/strings'; - -/** - * Shape describing how to launch a command, mirroring the subset of Node's - * `child_process` options the extension needs to run Windows command shims. - */ -export interface CmdShimSpawnCommand { - command: string; - args: string[]; - /** Diagnostic-friendly argument list; the wrapped form is hard to read in logs. */ - diagnosticArgs?: string[]; - windowsVerbatimArguments?: boolean; -} +import { cmdShimPercentArgument, terminalCommandArgumentControlCharacters } from '../loc/strings'; +import { getCmdShimSpawnCommand as buildCmdShimSpawnCommand, getCmdShimCommandInterpreter, quoteCmdArgument } from './cmdShimCommand'; +import type { CmdShimSpawnCommand } from './cmdShimCommand'; +export { isCommandShimPath, quoteCmdArgument, shouldWrapWithCmd } from './cmdShimCommand'; +export type { CmdShimSpawnCommand } from './cmdShimCommand'; export function assertNoTerminalControlCharacters(value: string): void { // Shell quoting protects shell metacharacters after the command reaches the @@ -23,27 +15,16 @@ export function assertNoTerminalControlCharacters(value: string): void { } } -/** - * Windows `.cmd`/`.bat` shims are batch scripts, not executables. Node refuses to - * spawn them without a shell since the CVE-2024-27980 fix - * (https://github.com/nodejs/node/issues/52681), so they must go through cmd.exe. - */ -export function isCommandShimPath(command: string): boolean { - return /\.(?:cmd|bat)$/i.test(command); -} - -export function shouldWrapWithCmd(command: string): boolean { - return process.platform === 'win32' && isCommandShimPath(command); -} - -function getComSpec(): string { - return process.env.ComSpec ?? 'cmd.exe'; -} - -function assertNoCmdWrapperControlCharacters(values: readonly string[]): void { +function assertCmdWrapperValues(values: readonly string[]): void { for (const value of values) { assertNoTerminalControlCharacters(value); } + + // cmd.exe expands `%NAME%` before the batch shim receives `%*`. A single unmatched percent is + // literal, but a pair can span quoted argv tokens because cmd.exe parses the full command. + if (/%[^%]*%/.test(values.join(' '))) { + throw new Error(cmdShimPercentArgument); + } } /** @@ -57,22 +38,20 @@ function assertNoCmdWrapperControlCharacters(values: readonly string[]): void { * directory such as `C:\tools\a^b`. Verified on Windows CI across `&`, `^`, `()` and * space directories. * - * Quoting makes `&`, `^`, `|`, `<`, `>` and parentheses literal. Percent expansion is - * an unavoidable limitation of routing a batch shim through a `cmd /c` command string. + * Quoting makes `&`, `^`, `|`, `<`, `>` and parentheses literal. Percent-delimited values are + * rejected because cmd.exe expands them before the shim receives its arguments. */ export function getCmdShimSpawnCommand(command: string, args: readonly string[]): CmdShimSpawnCommand { const commandArgs = [...args]; // cmd.exe receives this path as one `/c` command string, not an argv array. // Reject terminal controls before quoting so CR/LF and ETX cannot split the wrapper // invocation or cancel the command before cmd parsing reaches the quotes. - assertNoCmdWrapperControlCharacters([command, ...commandArgs]); + // Percent expansion in the command path remains intentional so settings such as + // `%ASPIRE_HOME%\aspire.cmd` keep working; only forwarded argv must remain byte-for-byte exact. + assertNoTerminalControlCharacters(command); + assertCmdWrapperValues(commandArgs); - return { - command: getComSpec(), - args: ['/d', '/v:off', '/s', '/c', buildCmdWrapperCommand(command, commandArgs)], - diagnosticArgs: [command, ...commandArgs], - windowsVerbatimArguments: true, - }; + return buildCmdShimSpawnCommand(command, commandArgs); } /** @@ -83,18 +62,19 @@ export function getCmdShimSpawnCommand(command: string, args: readonly string[]) * * The argv shape survives libuv's quoting pass by caret-escaping both whitespace * and metacharacters. The caret forces cmd.exe's quote-stripping branch, then makes - * the resulting unquoted token parse as one literal value. Percent expansion remains - * an unavoidable limitation of routing a batch shim through a cmd.exe command string. + * the resulting unquoted token parse as one literal value. Percent-delimited values are rejected + * because cmd.exe expands them before the shim receives its arguments. */ export function getCmdShimSpawnCommandWithoutVerbatimArguments(command: string, args: readonly string[]): CmdShimSpawnCommand { const commandArgs = [...args]; - assertNoCmdWrapperControlCharacters([command, ...commandArgs]); + assertNoTerminalControlCharacters(command); + assertCmdWrapperValues(commandArgs); if (commandArgs.some(argument => argument.length === 0 || /[ \t"]/.test(argument))) { throw new Error('The non-verbatim cmd.exe wrapper cannot safely quote arguments containing whitespace or quotes.'); } return { - command: getComSpec(), + command: getCmdShimCommandInterpreter(), // `/s` is omitted because there is no outer quote pair to strip here. `call` // is omitted because its second parse consumes carets and breaks parenthesized paths. args: ['/d', '/v:off', '/c', ...[command, ...commandArgs].map(escapeCmdArgumentForLibuvQuoting)], @@ -111,44 +91,3 @@ function escapeCmdArgumentForLibuvQuoting(value: string): string { // prevent `%NAME%` expansion in a cmd /c command string. return value.replace(/[ \t()[\]{}!^`<>&|;,+'=@~]/g, match => `^${match}`); } - -function buildCmdWrapperCommand(command: string, args: string[]): string { - // The outer quote pair is consumed by `/s`, leaving the inner per-argument quoting - // intact for cmd.exe. See `cmd /?` for the `/s` first/last quote stripping rule. - return `"${[quoteCmdArgument(command), ...args.map(quoteCmdArgument)].join(' ')}"`; -} - -export function quoteCmdArgument(value: string): string { - // The wrapper command is executed as: - // cmd.exe /d /v:off /s /c ""aspire.cmd" "" ..." - // Many .cmd shims then forward arguments to a native executable with `%*`, for example: - // "node.exe" "aspire.js" %* - // Percent signs cannot be escaped in a `cmd /c` command string. `%%` only collapses - // inside a batch file; using it here corrupts the path or argument before the shim runs. - // Trailing backslashes must be doubled before our closing quote - // (`"--path=C:\temp\\" "next"`), and backslashes before embedded quotes must be doubled - // before cmd's doubled-quote escape. - let quotedValue = ''; - let backslashCount = 0; - - for (const character of value) { - if (character === '\\') { - backslashCount++; - continue; - } - - if (character === '"') { - quotedValue += '\\'.repeat(backslashCount * 2); - backslashCount = 0; - quotedValue += '""'; - continue; - } - - quotedValue += '\\'.repeat(backslashCount); - backslashCount = 0; - quotedValue += character; - } - - quotedValue += '\\'.repeat(backslashCount * 2); - return `"${quotedValue}"`; -} diff --git a/extension/src/utils/cmdShimCommand.ts b/extension/src/utils/cmdShimCommand.ts new file mode 100644 index 00000000000..9c378423fe0 --- /dev/null +++ b/extension/src/utils/cmdShimCommand.ts @@ -0,0 +1,82 @@ +/** + * Shape describing how to launch a command, mirroring the subset of Node's + * `child_process` options the extension needs to run Windows command shims. + */ +export interface CmdShimSpawnCommand { + command: string; + args: string[]; + /** Diagnostic-friendly argument list; the wrapped form is hard to read in logs. */ + diagnosticArgs?: string[]; + windowsVerbatimArguments?: boolean; +} + +/** + * Windows `.cmd`/`.bat` shims are batch scripts, not executables. Node refuses to + * spawn them without a shell since the CVE-2024-27980 fix + * (https://github.com/nodejs/node/issues/52681), so they must go through cmd.exe. + */ +export function isCommandShimPath(command: string): boolean { + return /\.(?:cmd|bat)$/i.test(command); +} + +export function shouldWrapWithCmd(command: string): boolean { + return process.platform === 'win32' && isCommandShimPath(command); +} + +export function getCmdShimCommandInterpreter(): string { + return process.env.ComSpec ?? 'cmd.exe'; +} + +// Validation remains in cmdShim.ts so user-facing failures use the extension's localized +// message. Keeping the construction itself dependency-free lets E2E inspect the exact +// process argv using the same implementation that production launches. +export function getCmdShimSpawnCommand(command: string, args: readonly string[]): CmdShimSpawnCommand { + const commandArgs = [...args]; + return { + command: getCmdShimCommandInterpreter(), + args: ['/d', '/v:off', '/s', '/c', buildCmdWrapperCommand(command, commandArgs)], + diagnosticArgs: [command, ...commandArgs], + windowsVerbatimArguments: true, + }; +} + +function buildCmdWrapperCommand(command: string, args: string[]): string { + // The outer quote pair is consumed by `/s`, leaving the inner per-argument quoting + // intact for cmd.exe. See `cmd /?` for the `/s` first/last quote stripping rule. + return `"${[quoteCmdArgument(command), ...args.map(quoteCmdArgument)].join(' ')}"`; +} + +export function quoteCmdArgument(value: string): string { + // The wrapper command is executed as: + // cmd.exe /d /v:off /s /c ""aspire.cmd" "" ..." + // Many .cmd shims then forward arguments to a native executable with `%*`, for example: + // "node.exe" "aspire.js" %* + // Percent signs cannot be escaped in a `cmd /c` command string. `%%` only collapses + // inside a batch file; using it here corrupts the path or argument before the shim runs. + // Trailing backslashes must be doubled before our closing quote + // (`"--path=C:\temp\\" "next"`), and backslashes before embedded quotes must be doubled + // before cmd's doubled-quote escape. + let quotedValue = ''; + let backslashCount = 0; + + for (const character of value) { + if (character === '\\') { + backslashCount++; + continue; + } + + if (character === '"') { + quotedValue += '\\'.repeat(backslashCount * 2); + backslashCount = 0; + quotedValue += '""'; + continue; + } + + quotedValue += '\\'.repeat(backslashCount); + backslashCount = 0; + quotedValue += character; + } + + quotedValue += '\\'.repeat(backslashCount * 2); + return `"${quotedValue}"`; +} diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index bccbd1516f8..eac36631bc7 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -3,11 +3,15 @@ import type { ChildProcessWithoutNullStreams } from 'child_process'; import { AspireTerminalProvider } from './AspireTerminalProvider'; import { spawnCliProcess, terminateCliProcess } from './process/cliProcess'; import { extensionLogOutputChannel } from './logging'; -import { ConfigInfo, FeatureInfo, PropertyInfo, SettingsSchema } from '../types/configInfo'; +import { CapabilityStatus, ConfigInfo, FeatureInfo, PropertyInfo, SettingsSchema } from '../types/configInfo'; import * as strings from '../loc/strings'; import { isNoLogoUnsupportedOutput, noLogoOption, removeRootNoLogoOption } from './cliCompatibility'; +import { CliPathResolutionTarget, windowCliPathTarget } from './cliPathVariables'; const configInfoTimeoutMs = 30_000; +const cliVersionProbeTimeoutMs = 30_000; +const maxCliVersionOutputLength = 128; +const cliVersionPattern = /^(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,4})\.(0|[1-9]\d{0,4})(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; type RawFeatureInfo = Partial & { Name?: unknown; @@ -42,10 +46,39 @@ export async function getConfigInfo(terminalProvider: AspireTerminalProvider): P return new ConfigInfoProvider(terminalProvider).getConfigInfo(); } -interface ConfigInfoOptions { +export interface ConfigInfoOptions { suppressErrors?: boolean; forceRefresh?: boolean; cliPath?: string; + cancellationToken?: vscode.CancellationToken; + minimumVersion?: string; + /** The resolution scope to use when `cliPath` is not already known. Defaults to the window scope. */ + target?: CliPathResolutionTarget; + /** Internal timeout budget for this config-info invocation. */ + timeoutMs?: number; +} + +interface CliVersion { + major: number; + minor: number; + patch: number; + isPrerelease: boolean; +} + +/** + * Working directory for `aspire config info`, chosen from the resolution target. + * + * The CLI discovers `aspire.config.json` by walking up from its working directory, so the folder it + * runs in decides which local settings file the answer describes. Window-scoped callers have no + * folder of their own and fall back to the first one, which is the best available guess and matches + * how other window-scoped commands behave. + */ +function resolveConfigInfoWorkingDirectory(target: CliPathResolutionTarget): string | undefined { + if (target.kind === 'workspaceFolder') { + return target.workspaceFolder.uri.fsPath; + } + + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; } /** @@ -54,14 +87,19 @@ interface ConfigInfoOptions { * CLI supports: features and capabilities are reported as structured data rather than parsed from * (potentially localized) command output. * - * Successful reads and concurrent probes are cached by CLI executable path. This lets callers share - * one invocation while ensuring that changing `aspire.aspireCliExecutablePath` cannot reuse - * capabilities from a different CLI. Failures are intentionally NOT cached: an older CLI that can't - * answer, or a transient spawn error, should be retried on the next call. + * Successful reads and concurrent probes are cached by CLI executable path and working directory. + * This lets callers share one invocation without reusing another workspace folder's local settings + * or capabilities from a different CLI. Failures are intentionally NOT cached: an older CLI that + * can't answer, or a transient spawn error, should be retried on the next call. + * + * Capability checks can fall back to a minimum CLI version when the token predates structured + * capability reporting. Version probes are intentionally not cached: an exact launch must observe + * an executable replaced in place rather than reusing stale support data. */ export class ConfigInfoProvider { private readonly _cachedConfigInfoByCliPath = new Map(); private readonly _inFlightByCliPath = new Map>(); + private readonly _probeGenerationByCliPath = new Map(); constructor(private readonly _terminalProvider: AspireTerminalProvider) { } @@ -73,56 +111,73 @@ export class ConfigInfoProvider { * @param options.suppressErrors When true, failures are logged but not surfaced to the user via * error notifications. Use this for background/best-effort probes (e.g. capability detection) * where a missing or older CLI should degrade silently rather than nag the user. - * @param options.forceRefresh When true, bypasses cached and in-flight results for the selected - * CLI path so the executable is queried again. + * @param options.forceRefresh When true, runs an invocation-owned probe that bypasses cached + * and shared in-flight work. A failed refresh leaves the last successful cache entry intact. * @param options.cliPath The already-resolved CLI executable path. Supplying this guarantees the * probe describes the same executable the caller is about to invoke. + * @param options.cancellationToken Cancels this caller's wait. For a force refresh it also + * terminates that invocation-owned CLI process; shared probes continue for other callers. + * @param options.target The workspace scope that selects both the CLI and config-info working + * directory. Defaults to the window scope. */ async getConfigInfo(options?: ConfigInfoOptions): Promise { const suppressErrors = options?.suppressErrors ?? false; - const startTime = Date.now(); - const cliPath = options?.cliPath ?? await this._resolveCliPath(suppressErrors); - if (!cliPath) { + if (options?.cancellationToken?.isCancellationRequested) { return null; } - if (options?.forceRefresh) { - this._cachedConfigInfoByCliPath.delete(cliPath); + const startTime = Date.now(); + const target = options?.target ?? windowCliPathTarget; + const cliPath = options?.cliPath ?? await this._resolveCliPath(suppressErrors, target, options?.cancellationToken); + if (!cliPath || options?.cancellationToken?.isCancellationRequested) { + return null; } - else { - const cachedConfigInfo = this._cachedConfigInfoByCliPath.get(cliPath); + + // `aspire config info` reports the local settings file it discovers from its working + // directory, so the answer is per-folder, not per-CLI. Keying the caches by CLI path alone + // let one folder's result be served for another in a multi-root workspace - and callers such + // as "Open Local Settings" act on `localSettingsPath`, so that opens or creates the wrong file. + const workingDirectory = resolveConfigInfoWorkingDirectory(target); + const cacheKey = `${cliPath}\u0000${workingDirectory ?? ''}`; + + if (!options?.forceRefresh) { + const cachedConfigInfo = this._cachedConfigInfoByCliPath.get(cacheKey); if (cachedConfigInfo) { return cachedConfigInfo; } } - const remainingTimeoutMs = configInfoTimeoutMs - (Date.now() - startTime); + const remainingTimeoutMs = (options?.timeoutMs ?? configInfoTimeoutMs) - (Date.now() - startTime); if (remainingTimeoutMs <= 0) { this._reportTimeout(suppressErrors); return null; } - if (!options?.forceRefresh) { - const existingProbe = this._inFlightByCliPath.get(cliPath); - if (existingProbe) { - return await this._awaitProbe(existingProbe, remainingTimeoutMs, suppressErrors); - } - } - - const probe = this._fetchConfigInfo(cliPath, suppressErrors, remainingTimeoutMs); - this._inFlightByCliPath.set(cliPath, probe); - try { - const result = await probe; - if (result && this._inFlightByCliPath.get(cliPath) === probe) { - this._cachedConfigInfoByCliPath.set(cliPath, result); + if (options?.forceRefresh) { + const generation = this._beginProbe(cacheKey); + const result = await this._fetchConfigInfo( + cliPath, + workingDirectory, + suppressErrors, + remainingTimeoutMs, + options.cancellationToken); + if (result && this._probeGenerationByCliPath.get(cacheKey) === generation) { + this._cachedConfigInfoByCliPath.set(cacheKey, result); } return result; } - finally { - if (this._inFlightByCliPath.get(cliPath) === probe) { - this._inFlightByCliPath.delete(cliPath); - } + + const existingProbe = this._inFlightByCliPath.get(cacheKey); + if (existingProbe) { + return await this._awaitProbe( + existingProbe, + remainingTimeoutMs, + suppressErrors, + options?.cancellationToken); } + + const probe = this._startSharedProbe(cacheKey, cliPath, workingDirectory, suppressErrors, remainingTimeoutMs); + return await this._awaitProbe(probe, undefined, suppressErrors, options?.cancellationToken); } /** @@ -130,35 +185,227 @@ export class ConfigInfoProvider { * tokens are stable, locale-independent identifiers (see {@link ConfigInfo.capabilities}). */ async hasCapability(capability: string, options?: ConfigInfoOptions): Promise { - const configInfo = await this.getConfigInfo(options); - return configInfo?.capabilities?.includes(capability) ?? false; + return await this.getCapabilityStatus(capability, options) === 'supported'; } - private async _awaitProbe(probe: Promise, timeoutMs: number, suppressErrors: boolean): Promise { - let timeout: ReturnType | undefined; - const timeoutPromise = new Promise(resolve => { + /** + * Distinguishes a successful probe of an older CLI from a probe that could not complete. + * Callers that must honor an explicit capability-dependent choice cannot safely treat both + * cases as unsupported. + */ + async getCapabilityStatus(capability: string, options?: ConfigInfoOptions): Promise { + if (!options?.minimumVersion) { + const configInfo = await this.getConfigInfo(options); + if (!configInfo) { + return 'unavailable'; + } + + return configInfo.capabilities?.includes(capability) ? 'supported' : 'unsupported'; + } + + const minimumVersion = parseCliVersion(options.minimumVersion); + if (!minimumVersion) { + extensionLogOutputChannel.warn(`Unable to probe Aspire CLI capability '${capability}': invalid minimum version '${options.minimumVersion}'.`); + return 'unavailable'; + } + + const suppressErrors = options.suppressErrors ?? false; + const target = options.target ?? windowCliPathTarget; + const cliPath = options.cliPath ?? await this._resolveCliPath(suppressErrors, target, options.cancellationToken); + if (!cliPath || options.cancellationToken?.isCancellationRequested) { + return 'unavailable'; + } + + const probeStartTime = Date.now(); + const probeTimeoutMs = Math.min(options.timeoutMs ?? configInfoTimeoutMs, configInfoTimeoutMs); + const configInfo = await this.getConfigInfo({ ...options, cliPath, timeoutMs: probeTimeoutMs }); + if (configInfo?.capabilities?.includes(capability)) { + return 'supported'; + } + + if (options.cancellationToken?.isCancellationRequested) { + return 'unavailable'; + } + + const remainingTimeoutMs = probeTimeoutMs - (Date.now() - probeStartTime); + if (remainingTimeoutMs <= 0) { + return 'unavailable'; + } + + return await this._getCliMinimumVersionStatus(cliPath, minimumVersion, remainingTimeoutMs, options.cancellationToken); + } + + private _getCliMinimumVersionStatus( + cliPath: string, + minimumVersion: CliVersion, + timeoutMs: number, + cancellationToken?: vscode.CancellationToken, + ): Promise { + return new Promise((resolve) => { + let childProcess: ChildProcessWithoutNullStreams | undefined; + let output = ''; + let outputTooLong = false; + let settled = false; + let timeout: ReturnType | undefined; + let cancellation: vscode.Disposable | undefined; + const settle = (result: CapabilityStatus) => { + if (settled) { + return; + } + + settled = true; + if (timeout) { + clearTimeout(timeout); + } + cancellation?.dispose(); + resolve(result); + }; + const reportUnavailable = (error: unknown) => { + if (settled) { + return; + } + + extensionLogOutputChannel.warn(`Unable to probe Aspire CLI version: ${String(error)}`); + settle('unavailable'); + }; + if (cancellationToken?.isCancellationRequested) { + settle('unavailable'); + return; + } + timeout = setTimeout(() => { - this._reportTimeout(suppressErrors); - resolve(null); - }, timeoutMs); + settle('unavailable'); + if (childProcess) { + void terminateCliProcess(childProcess, 'timed-out Aspire CLI version probe').catch(error => { + extensionLogOutputChannel.error(`Failed to terminate timed-out Aspire CLI version probe: ${String(error)}`); + }); + } + }, Math.min(timeoutMs, cliVersionProbeTimeoutMs)); + cancellation = cancellationToken?.onCancellationRequested(() => { + settle('unavailable'); + if (childProcess) { + void terminateCliProcess(childProcess, 'cancelled Aspire CLI version probe').catch(error => { + extensionLogOutputChannel.error(`Failed to terminate cancelled Aspire CLI version probe: ${String(error)}`); + }); + } + }); + + try { + childProcess = spawnCliProcess(this._terminalProvider, cliPath, ['--version'], { + createProcessGroup: true, + stdoutCallback: value => { + if (output.length + value.length > maxCliVersionOutputLength) { + outputTooLong = true; + return; + } + + output += value; + }, + exitCallback: code => { + if (code !== 0 || outputTooLong) { + settle('unavailable'); + return; + } + + const version = parseCliVersion(output); + if (!version) { + settle('unavailable'); + return; + } + + const comparison = compareCliVersions(version, minimumVersion); + // A prerelease at the exact minimum core predates the stable version that + // introduced the option, so treat it as known unsupported. Prerelease/dev + // builds with a higher numeric core remain supported because the feature + // already exists on that later release line. + settle(comparison > 0 || (comparison === 0 && (!version.isPrerelease || minimumVersion.isPrerelease)) + ? 'supported' + : 'unsupported'); + }, + errorCallback: reportUnavailable, + noExtensionVariables: true, + }); + } + catch (error) { + reportUnavailable(error); + } + }); + } + + private _startSharedProbe( + cacheKey: string, + cliPath: string, + workingDirectory: string | undefined, + suppressErrors: boolean, + timeoutMs: number, + ): Promise { + const generation = this._beginProbe(cacheKey); + let probe!: Promise; + probe = this._fetchConfigInfo(cliPath, workingDirectory, suppressErrors, timeoutMs) + .then(result => { + if (result && this._probeGenerationByCliPath.get(cacheKey) === generation) { + this._cachedConfigInfoByCliPath.set(cacheKey, result); + } + return result; + }) + .finally(() => { + if (this._inFlightByCliPath.get(cacheKey) === probe) { + this._inFlightByCliPath.delete(cacheKey); + } + }); + this._inFlightByCliPath.set(cacheKey, probe); + return probe; + } + + private _beginProbe(cacheKey: string): number { + const generation = (this._probeGenerationByCliPath.get(cacheKey) ?? 0) + 1; + this._probeGenerationByCliPath.set(cacheKey, generation); + return generation; + } + + private async _awaitProbe( + probe: Promise, + timeoutMs: number | undefined, + suppressErrors: boolean, + cancellationToken?: vscode.CancellationToken, + ): Promise { + if (cancellationToken?.isCancellationRequested) { + return null; + } + + let timeout: ReturnType | undefined; + let cancellation: vscode.Disposable | undefined; + const callerCompletion = new Promise(resolve => { + if (timeoutMs !== undefined) { + timeout = setTimeout(() => { + this._reportTimeout(suppressErrors); + resolve(null); + }, timeoutMs); + } + cancellation = cancellationToken?.onCancellationRequested(() => resolve(null)); }); try { - // This timeout belongs to the caller, not the shared process. A caller that spent most of - // its budget resolving the CLI path must be allowed to leave without cancelling the probe - // for subscribers that joined later with a fresh budget. - return await Promise.race([probe, timeoutPromise]); + // Timeout and cancellation belong to this caller, not the shared process. A caller + // may leave without terminating work that other subscribers still need. + return await Promise.race([probe, callerCompletion]); } finally { if (timeout) { clearTimeout(timeout); } + cancellation?.dispose(); } } - private _resolveCliPath(suppressErrors: boolean): Promise { + private _resolveCliPath( + suppressErrors: boolean, + target: CliPathResolutionTarget, + cancellationToken?: vscode.CancellationToken, + ): Promise { return new Promise((resolve) => { let settled = false; + let cancellation: vscode.Disposable | undefined; const settle = (result: string | null) => { if (settled) { return; @@ -166,6 +413,7 @@ export class ConfigInfoProvider { settled = true; clearTimeout(timeout); + cancellation?.dispose(); resolve(result); }; const reportError = (error: unknown) => { @@ -180,9 +428,10 @@ export class ConfigInfoProvider { this._reportTimeout(suppressErrors); settle(null); }, configInfoTimeoutMs); + cancellation = cancellationToken?.onCancellationRequested(() => settle(null)); try { - this._terminalProvider.getAspireCliExecutablePath().then( + this._terminalProvider.getAspireCliExecutablePath(target).then( cliPath => settle(cliPath), reportError); } @@ -192,11 +441,18 @@ export class ConfigInfoProvider { }); } - private _fetchConfigInfo(cliPath: string, suppressErrors: boolean, timeoutMs: number): Promise { + private _fetchConfigInfo( + cliPath: string, + workingDirectory: string | undefined, + suppressErrors: boolean, + timeoutMs: number, + cancellationToken?: vscode.CancellationToken, + ): Promise { return new Promise((resolve) => { let childProcess: ChildProcessWithoutNullStreams | undefined; let settled = false; let timeout: ReturnType | undefined; + let cancellation: vscode.Disposable | undefined; const settle = (result: ConfigInfo | null) => { if (settled) { return; @@ -206,6 +462,7 @@ export class ConfigInfoProvider { if (timeout) { clearTimeout(timeout); } + cancellation?.dispose(); resolve(result); }; const reportError = (error: unknown) => { @@ -216,6 +473,10 @@ export class ConfigInfoProvider { this._reportError(error, suppressErrors); settle(null); }; + if (cancellationToken?.isCancellationRequested) { + settle(null); + return; + } // The timeout passed here is the remainder of the same 30-second budget that covered // executable-path lookup, so a wedged startup probe cannot block callers indefinitely. @@ -224,11 +485,20 @@ export class ConfigInfoProvider { settle(null); if (childProcess) { - terminateCliProcess(childProcess, 'timed-out aspire config info command'); + void terminateCliProcess(childProcess, 'timed-out aspire config info command').catch(error => { + extensionLogOutputChannel.error(`Failed to terminate timed-out aspire config info command: ${String(error)}`); + }); } }, timeoutMs); + cancellation = cancellationToken?.onCancellationRequested(() => { + settle(null); + if (childProcess) { + void terminateCliProcess(childProcess, 'cancelled aspire config info command').catch(error => { + extensionLogOutputChannel.error(`Failed to terminate cancelled aspire config info command: ${String(error)}`); + }); + } + }); - const workingDirectory = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const runConfigInfo = (args: string[], allowNoLogoRetry: boolean) => { if (settled) { return; @@ -315,6 +585,35 @@ export class ConfigInfoProvider { } } +function parseCliVersion(value: string): CliVersion | undefined { + // `aspire --version` emits a bare semver-like value, for example: + // 13.2.0 + // 13.2.0-preview.1.12345.6+abcdef + // Only the bounded numeric core participates in comparison; any other output is unavailable. + const normalized = value.trim(); + if (normalized.length === 0 || normalized.length > maxCliVersionOutputLength) { + return undefined; + } + + const match = cliVersionPattern.exec(normalized); + if (!match) { + return undefined; + } + + return { + major: Number.parseInt(match[1], 10), + minor: Number.parseInt(match[2], 10), + patch: Number.parseInt(match[3], 10), + isPrerelease: match[4] !== undefined, + }; +} + +function compareCliVersions(left: CliVersion, right: CliVersion): number { + return left.major - right.major || + left.minor - right.minor || + left.patch - right.patch; +} + export function parseConfigInfoOutput(output: string): ConfigInfo { const configInfo = JSON.parse(output.trim()) as RawConfigInfo; diff --git a/extension/src/utils/gitWorktree.ts b/extension/src/utils/gitWorktree.ts new file mode 100644 index 00000000000..93463a87a7f --- /dev/null +++ b/extension/src/utils/gitWorktree.ts @@ -0,0 +1,260 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const maxAncestorWalks = 64; +const gitDirPrefix = 'gitdir:'; +const gitDirectoryName = '.git'; +const worktreesSegment = 'worktrees'; + +/** + * Returns the linked worktree root that contains `startPath`, or `undefined` when the + * path is in the primary checkout, a submodule, or not a git repository. + * + * Git stores linked-worktree metadata in these shapes: + * Standard: /repo/.git/worktrees/feature + * Bare: /repo.git/worktrees/feature + * Separate: /separate-git/worktrees/feature + * Submodule: /repo/.git/worktrees/feature/modules/dependency + * + * /checkout/.git: + * gitdir: /repo/.git/worktrees/feature + * + * /repo/.git/worktrees/feature/gitdir: + * /checkout/.git + * + * The admin `gitdir` back-pointer rejects stale metadata, while requiring the admin + * directory's direct parent to be `worktrees` excludes linked-worktree submodules. + * See https://git-scm.com/docs/git-worktree + */ +export function tryGetLinkedWorktreeRoot(startPath: string | undefined): string | undefined { + if (!startPath) { + return undefined; + } + + let current = getWalkStartDirectory(startPath); + for (let i = 0; i < maxAncestorWalks; i++) { + const gitPath = path.join(current, gitDirectoryName); + if (isDirectory(gitPath)) { + return undefined; + } + + if (isFile(gitPath)) { + return isLinkedWorktreeGitFile(gitPath) ? canonicalizePath(current) : undefined; + } + + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + + current = parent; + } + + return undefined; +} + +export function isLinkedGitWorktree(startPath: string | undefined): boolean { + return tryGetLinkedWorktreeRoot(startPath) !== undefined; +} + +export function resolveIsolated(explicit: boolean | undefined, startPath: string | undefined): boolean { + return explicit ?? isLinkedGitWorktree(startPath); +} + +/** + * Reads the root Aspire CLI `--isolated` option, ignoring any AppHost-owned args after + * the first `--` separator. `aspire run -- --isolated false` forwards `--isolated false` + * to the AppHost itself, so counting those entries as root options would stop the + * extension from adding the launch isolation the linked-worktree path still needs. + */ +export function getRootIsolatedCliArg(args: readonly string[] | undefined): boolean | undefined { + if (!args) { + return undefined; + } + + const beforeSeparator = getArgsBeforeAppSeparator(args); + for (let i = 0; i < beforeSeparator.length; i++) { + const current = beforeSeparator[i]; + if (current === '--isolated') { + return beforeSeparator[i + 1]?.toLowerCase() === 'false' ? false : true; + } + + if (current.startsWith('--isolated=')) { + return current.slice('--isolated='.length).toLowerCase() === 'false' ? false : true; + } + } + + return undefined; +} + +export function ensureIsolatedCliArg(args: string[] | undefined, isolated: boolean | undefined): string[] | undefined { + if (isolated === undefined) { + return removeRootIsolatedCliArgs(args); + } + + const existing = args ?? []; + const separatorIndex = existing.indexOf('--'); + const beforeSeparator = getArgsBeforeAppSeparator(existing); + if (beforeSeparator.some(arg => arg === '--isolated' || arg.startsWith('--isolated='))) { + return args; + } + + const isolationArgs = isolated ? ['--isolated'] : ['--isolated', 'false']; + if (separatorIndex === -1) { + return [...existing, ...isolationArgs]; + } + + return [...beforeSeparator, ...isolationArgs, ...existing.slice(separatorIndex)]; +} + +function removeRootIsolatedCliArgs(args: string[] | undefined): string[] | undefined { + if (!args) { + return undefined; + } + + const separatorIndex = args.indexOf('--'); + const beforeSeparator = getArgsBeforeAppSeparator(args); + const filtered: string[] = []; + let changed = false; + for (let i = 0; i < beforeSeparator.length; i++) { + const current = beforeSeparator[i]; + if (current === '--isolated') { + changed = true; + const value = beforeSeparator[i + 1]?.toLowerCase(); + if (value === 'true' || value === 'false') { + i++; + } + } + else if (current.startsWith('--isolated=')) { + changed = true; + } + else { + filtered.push(current); + } + } + + if (!changed) { + return args; + } + + return separatorIndex === -1 + ? filtered + : [...filtered, ...args.slice(separatorIndex)]; +} + +function getArgsBeforeAppSeparator(args: readonly string[]): readonly string[] { + const separatorIndex = args.indexOf('--'); + return separatorIndex === -1 ? args : args.slice(0, separatorIndex); +} + +function getWalkStartDirectory(startPath: string): string { + try { + if (fs.statSync(startPath).isDirectory()) { + return startPath; + } + } + catch { + // The path may not exist yet (launch tests use placeholder AppHost paths). + } + + const directory = path.dirname(startPath); + return directory || startPath; +} + +function isLinkedWorktreeGitFile(gitFilePath: string): boolean { + const adminDirectory = tryReadGitDirTarget(gitFilePath); + if (!adminDirectory || !isDirectory(adminDirectory)) { + return false; + } + + const canonicalAdminDirectory = canonicalizePath(adminDirectory); + const adminParentName = path.basename(path.dirname(canonicalAdminDirectory)); + const isWorktreesParent = process.platform === 'win32' + ? adminParentName.toLowerCase() === worktreesSegment + : adminParentName === worktreesSegment; + if (!isWorktreesParent) { + return false; + } + + // Git resolves this back-pointer from the physical admin directory, even when + // the checkout's .git file reached that directory through an alias. + const checkoutGitFile = tryReadPath( + path.join(canonicalAdminDirectory, 'gitdir'), + canonicalAdminDirectory); + return checkoutGitFile !== undefined && pathsEqual(checkoutGitFile, gitFilePath); +} + +function tryReadGitDirTarget(gitFilePath: string): string | undefined { + // Relative gitdir values are based on the physical directory containing this + // metadata file, not the lexical checkout alias used to discover it. + const canonicalGitFilePath = canonicalizePath(gitFilePath); + let contents: string; + try { + contents = fs.readFileSync(canonicalGitFilePath, 'utf8'); + } + catch { + return undefined; + } + + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line.toLowerCase().startsWith(gitDirPrefix)) { + continue; + } + + const gitDir = line.slice(gitDirPrefix.length).trim(); + if (gitDir.length === 0) { + return undefined; + } + + return path.resolve(path.dirname(canonicalGitFilePath), gitDir); + } + + return undefined; +} + +function tryReadPath(filePath: string, relativeTo: string): string | undefined { + try { + const value = fs.readFileSync(filePath, 'utf8').trim(); + return value.length > 0 ? path.resolve(relativeTo, value) : undefined; + } + catch { + return undefined; + } +} + +function pathsEqual(left: string, right: string): boolean { + const canonicalLeft = canonicalizePath(left); + const canonicalRight = canonicalizePath(right); + return process.platform === 'win32' + ? canonicalLeft.toLowerCase() === canonicalRight.toLowerCase() + : canonicalLeft === canonicalRight; +} + +function canonicalizePath(value: string): string { + const resolved = path.resolve(value); + try { + return fs.realpathSync.native(resolved); + } + catch { + return resolved; + } +} + +function isDirectory(target: string): boolean { + try { + return fs.statSync(target).isDirectory(); + } + catch { + return false; + } +} + +function isFile(target: string): boolean { + try { + return fs.statSync(target).isFile(); + } + catch { + return false; + } +} diff --git a/extension/src/utils/process/cliProcess.ts b/extension/src/utils/process/cliProcess.ts index 89bf1f83553..817f00835dc 100644 --- a/extension/src/utils/process/cliProcess.ts +++ b/extension/src/utils/process/cliProcess.ts @@ -8,6 +8,10 @@ import * as vscode from 'vscode'; import { EnvironmentVariables } from "../../utils/environment"; const processShutdownGracePeriodMs = 5_000; +const processShutdownConfirmationIntervalMs = 50; +const windowsForcedTaskkillCloseReserveMs = 250; +const windowsForcedTaskkillTotalReserveMs = 1_250; +const windowsTaskkillProcessNotFoundExitCode = 128; const managedPosixProcessGroups = new WeakSet(); export interface SpawnProcessOptions { @@ -36,7 +40,7 @@ export function getCliSpawnCommand(command: string, args?: string[]): CliSpawnCo export function getCliSpawnDiagnostics(command: string, args: string[] | undefined, workingDirectory: string, noDebug: boolean | undefined, debugSessionId: string | undefined, env: Record): string { const startupTimeout = getEnvironmentValue(env, EnvironmentVariables.ASPIRE_CLI_START_TIMEOUT); - return `Spawning Aspire CLI process: ${[command, ...redactCliSpawnArgs(args)].join(' ')}; cwd=${workingDirectory}; noDebug=${noDebug}; debugSessionId=${debugSessionId}; ${EnvironmentVariables.ASPIRE_CLI_START_TIMEOUT}=${startupTimeout}`; + return `Spawning Aspire CLI process: ${[command, ...redactCliArgsForLogging(args)].join(' ')}; cwd=${workingDirectory}; noDebug=${noDebug}; debugSessionId=${debugSessionId}; ${EnvironmentVariables.ASPIRE_CLI_START_TIMEOUT}=${startupTimeout}`; } export function mergeCliSpawnEnvironment(env: Record, envVars?: EnvVar[]): void { @@ -62,7 +66,7 @@ export function spawnCliProcess(terminalProvider: AspireTerminalProvider, comman const env: Record = {}; const spawnCommand = getCliSpawnCommand(command, args); - Object.assign(env, terminalProvider.createEnvironment(options?.debugSessionId, options?.noDebug, options?.noExtensionVariables)); + Object.assign(env, terminalProvider.createEnvironment(options?.debugSessionId, options?.noDebug, options?.noExtensionVariables, command)); mergeCliSpawnEnvironment(env, options?.env); extensionLogOutputChannel.info(getCliSpawnDiagnostics(spawnCommand.command, spawnCommand.diagnosticArgs ?? spawnCommand.args, workingDirectory, options?.noDebug, options?.debugSessionId, env)); @@ -110,14 +114,18 @@ export function spawnCliProcess(terminalProvider: AspireTerminalProvider, comman } export function terminateCliProcess(childProcess: ChildProcessWithoutNullStreams, description: string, options?: { suppressTimeoutWarning?: boolean; force?: boolean }): Promise { - return new Promise(resolve => { - const isWindows = process.platform === 'win32'; - const processGroupPid = !isWindows && managedPosixProcessGroups.has(childProcess) + if (process.platform === 'win32') { + return terminateWindowsCliProcess(childProcess, description, options); + } + + return new Promise((resolve, reject) => { + const processGroupPid = managedPosixProcessGroups.has(childProcess) ? childProcess.pid : undefined; let exited = childProcess.exitCode !== null || childProcess.signalCode !== null; let forceKillTimer: ReturnType | undefined; let confirmationTimer: ReturnType | undefined; + let confirmationDeadline: number | undefined; let forceSignalSent = false; let settled = false; const hasLiveProcessGroup = () => processGroupPid !== undefined && isPosixProcessGroupAlive(processGroupPid); @@ -137,7 +145,7 @@ export function terminateCliProcess(childProcess: ChildProcessWithoutNullStreams return forceSignalSent; }; - const stopTracking = () => { + const settle = (error?: Error) => { if (settled) { return; } @@ -154,20 +162,83 @@ export function terminateCliProcess(childProcess: ChildProcessWithoutNullStreams clearTimeout(confirmationTimer); confirmationTimer = undefined; } - resolve(); + managedPosixProcessGroups.delete(childProcess); + if (error) { + reject(error); + } else { + resolve(); + } + }; + const rejectUnconfirmedTermination = (message: string) => { + const error = new Error(message); + if (!options?.suppressTimeoutWarning) { + extensionLogOutputChannel.warn(error.message); + } + settle(error); + }; + const scheduleProcessGroupConfirmation = () => { + if (settled || confirmationTimer) { + return; + } + + confirmationDeadline ??= Date.now() + processShutdownGracePeriodMs; + confirmationTimer = setTimeout(confirmProcessGroupExit, processShutdownConfirmationIntervalMs); + confirmationTimer.unref(); + }; + const confirmProcessGroupExit = () => { + confirmationTimer = undefined; + if (settled) { + return; + } + + if (!hasLiveProcessGroup()) { + settle(); + return; + } + + if (confirmationDeadline !== undefined && Date.now() >= confirmationDeadline) { + rejectUnconfirmedTermination(`Could not confirm ${description} process group termination within ${processShutdownGracePeriodMs}ms.`); + return; + } + + scheduleProcessGroupConfirmation(); + }; + const scheduleChildExitConfirmation = () => { + confirmationTimer = setTimeout(() => { + confirmationTimer = undefined; + rejectUnconfirmedTermination(`Could not confirm ${description} termination within ${processShutdownGracePeriodMs}ms after forced termination.`); + }, processShutdownGracePeriodMs); + confirmationTimer.unref(); }; const onExit = () => { if (settled) { return; } - if (processGroupPid !== undefined && !forceSignalSent && hasLiveProcessGroup()) { - // Once the leader exits, force any remaining descendants immediately. Delaying another - // negative-PID signal would allow the operating system to recycle the process-group ID. - forceTermination(); + exited = true; + if (forceKillTimer) { + clearTimeout(forceKillTimer); + forceKillTimer = undefined; + } + if (processGroupPid !== undefined) { + let processGroupAlive = hasLiveProcessGroup(); + if (!forceSignalSent && processGroupAlive) { + // Once the leader exits, force any remaining descendants immediately. Delaying another + // negative-PID signal would allow the operating system to recycle the process-group ID. + if (!forceTermination()) { + settle(new Error(`Could not forcefully terminate ${description}.`)); + return; + } + processGroupAlive = hasLiveProcessGroup(); + } + + if (processGroupAlive) { + scheduleProcessGroupConfirmation(); + return; + } } - managedPosixProcessGroups.delete(childProcess); - stopTracking(); + + settle(); }; if (!exited) { @@ -176,29 +247,30 @@ export function terminateCliProcess(childProcess: ChildProcessWithoutNullStreams } else { if (processGroupPid !== undefined) { if (hasLiveProcessGroup()) { - forceTermination(); + if (!forceTermination()) { + settle(new Error(`Could not forcefully terminate ${description}.`)); + return; + } + } + if (hasLiveProcessGroup()) { + scheduleProcessGroupConfirmation(); + return; } - managedPosixProcessGroups.delete(childProcess); } - stopTracking(); + settle(); return; } if (options?.force) { if (!forceTermination()) { - stopTracking(); + settle(new Error(`Could not forcefully terminate ${description}.`)); return; } - - confirmationTimer = setTimeout(() => { - confirmationTimer = undefined; - if (!options.suppressTimeoutWarning) { - extensionLogOutputChannel.warn(`${description} did not report exit after forced termination; stopping process tracking.`); - } - managedPosixProcessGroups.delete(childProcess); - stopTracking(); - }, processShutdownGracePeriodMs); - confirmationTimer.unref(); + if (processGroupPid !== undefined) { + scheduleProcessGroupConfirmation(); + } else { + scheduleChildExitConfirmation(); + } return; } @@ -208,7 +280,7 @@ export function terminateCliProcess(childProcess: ChildProcessWithoutNullStreams if (!signalSent) { extensionLogOutputChannel.warn(`Failed to terminate ${description}.`); if (childProcess.pid === undefined) { - stopTracking(); + settle(new Error(`Could not terminate ${description} because no process identifier was available.`)); return; } } @@ -216,7 +288,7 @@ export function terminateCliProcess(childProcess: ChildProcessWithoutNullStreams } catch (error) { extensionLogOutputChannel.error(`Failed to terminate ${description}: ${String(error)}`); if (childProcess.pid === undefined) { - stopTracking(); + settle(new Error(`Could not terminate ${description} because no process identifier was available.`)); return; } } @@ -237,60 +309,215 @@ export function terminateCliProcess(childProcess: ChildProcessWithoutNullStreams } if (!forceTermination()) { - stopTracking(); + settle(new Error(`Could not forcefully terminate ${description}.`)); return; } - - confirmationTimer = setTimeout(() => { - confirmationTimer = undefined; - if (!options?.suppressTimeoutWarning) { - extensionLogOutputChannel.warn(`${description} did not report exit after forced termination; stopping process tracking.`); - } - managedPosixProcessGroups.delete(childProcess); - stopTracking(); - }, processShutdownGracePeriodMs); - confirmationTimer.unref(); + if (processGroupPid !== undefined) { + scheduleProcessGroupConfirmation(); + } else { + scheduleChildExitConfirmation(); + } }, processShutdownGracePeriodMs); forceKillTimer.unref(); }); } function terminateCliProcessTree(childProcess: ChildProcessWithoutNullStreams, force: boolean): boolean { - if (process.platform !== 'win32') { - if (managedPosixProcessGroups.has(childProcess) && childProcess.pid !== undefined) { - try { - // A detached POSIX child is a process-group leader. Signaling its negative PID - // terminates Aspire and its descendants together. - // https://nodejs.org/api/child_process.html#optionsdetached - return process.kill(-childProcess.pid, force ? 'SIGKILL' : 'SIGTERM'); - } catch { - // The group may have exited between the liveness check and signal delivery. + if (managedPosixProcessGroups.has(childProcess) && childProcess.pid !== undefined) { + try { + // A detached POSIX child is a process-group leader. Signaling its negative PID + // terminates Aspire and its descendants together. + // https://nodejs.org/api/child_process.html#optionsdetached + return process.kill(-childProcess.pid, force ? 'SIGKILL' : 'SIGTERM'); + } catch (error) { + if (isNoSuchProcessError(error)) { + return true; } - } - return childProcess.kill(force ? 'SIGKILL' : undefined); + throw error; + } } - if (childProcess.pid === undefined) { - return childProcess.kill(force ? 'SIGKILL' : undefined); + return childProcess.kill(force ? 'SIGKILL' : undefined); +} + +async function terminateWindowsCliProcess( + childProcess: ChildProcessWithoutNullStreams, + description: string, + options?: { suppressTimeoutWarning?: boolean; force?: boolean } +): Promise { + if (childProcess.exitCode !== null || childProcess.signalCode !== null) { + return; } - const args = ['/pid', String(childProcess.pid), '/t']; - if (force) { - args.push('/f'); + const childClose = observeChildProcessClose(childProcess); + const deadline = Date.now() + processShutdownGracePeriodMs; + const remainingTime = () => Math.max(0, deadline - Date.now()); + const waitForClose = () => childClose.wait(remainingTime()); + const killLeaderAsFallback = async (taskkillError: unknown): Promise => { + if (childClose.hasClosed() || childProcess.exitCode !== null || childProcess.signalCode !== null) { + return; + } + + extensionLogOutputChannel.warn( + `Failed to terminate ${description} with taskkill; falling back to the CLI leader process: ${String(taskkillError)}`); + if (!childProcess.kill('SIGKILL')) { + throw taskkillError; + } + if (remainingTime() === 0) { + throw taskkillError; + } + if (!await waitForClose()) { + throw new Error(`${description} did not report exit within ${processShutdownGracePeriodMs}ms after taskkill failed and the leader was killed.`); + } + // TerminateProcess only confirms the leader exited. Descendants may still be orphaned, so + // preserve the taskkill failure instead of reporting successful process-tree cleanup. + throw taskkillError; + }; + try { + if (childProcess.pid === undefined) { + if (!childProcess.kill(options?.force ? 'SIGKILL' : undefined)) { + throw new Error(`Could not terminate ${description} because no process identifier was available.`); + } + if (!await waitForClose()) { + throw new Error(`${description} did not report exit within ${processShutdownGracePeriodMs}ms after termination.`); + } + return; + } + + const runTaskkillWithinDeadline = async (force: boolean): Promise => { + try { + const closeReserveMs = force ? windowsForcedTaskkillCloseReserveMs : 0; + return await runTaskkill( + childProcess, + description, + force, + Math.max(0, remainingTime() - closeReserveMs)); + } + catch (error) { + await killLeaderAsFallback(error); + throw error; + } + }; + + const forceRequested = options?.force === true; + const firstExitCode = await runTaskkillWithinDeadline(forceRequested); + if (childClose.hasClosed()) { + return; + } + + if (firstExitCode === windowsTaskkillProcessNotFoundExitCode) { + if (!await waitForClose()) { + throw new Error(`taskkill could not find ${description} PID ${childProcess.pid}, but the process did not report exit within ${processShutdownGracePeriodMs}ms.`); + } + return; + } + + if (!forceRequested) { + // A nonzero graceful result means the tree was not terminated. Escalate immediately + // instead of rejecting before `/f` gets a chance to clean up descendants. + if (firstExitCode === 0) { + const forcedTaskkillReserveMs = Math.min(windowsForcedTaskkillTotalReserveMs, remainingTime()); + if (await childClose.wait(Math.max(0, remainingTime() - forcedTaskkillReserveMs))) { + return; + } + } + + if (!options?.suppressTimeoutWarning) { + extensionLogOutputChannel.warn(`${description} did not exit within ${processShutdownGracePeriodMs}ms; forcing termination.`); + } + + const forcedExitCode = await runTaskkillWithinDeadline(true); + if (childClose.hasClosed()) { + return; + } + if (forcedExitCode === windowsTaskkillProcessNotFoundExitCode) { + if (!await waitForClose()) { + throw new Error(`taskkill could not find ${description} PID ${childProcess.pid}, but the process did not report exit within ${processShutdownGracePeriodMs}ms.`); + } + return; + } + if (forcedExitCode !== 0) { + await killLeaderAsFallback(new Error( + `taskkill for ${description} exited with code ${forcedExitCode} while PID ${childProcess.pid} remained live.`)); + return; + } + } + else if (firstExitCode !== 0) { + await killLeaderAsFallback(new Error( + `taskkill for ${description} exited with code ${firstExitCode} while PID ${childProcess.pid} remained live.`)); + return; + } + + if (!await waitForClose()) { + throw new Error(`${description} did not report exit within ${processShutdownGracePeriodMs}ms after taskkill succeeded.`); + } } + finally { + childClose.dispose(); + } +} - const taskkill = spawn('taskkill.exe', args, { - stdio: 'ignore', - windowsHide: true, - }); - taskkill.on('error', error => { - extensionLogOutputChannel.warn(`Failed to stop process tree for PID ${childProcess.pid}: ${error}`); - childProcess.kill(); - }); - taskkill.unref(); +function runTaskkill( + childProcess: ChildProcessWithoutNullStreams, + description: string, + force: boolean, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const args = ['/pid', String(childProcess.pid), '/t']; + if (force) { + args.push('/f'); + } - return true; + const taskkill = spawn('taskkill.exe', args, { + stdio: 'ignore', + windowsHide: true, + }); + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + + settled = true; + taskkill.off('error', onError); + taskkill.off('close', onClose); + try { + taskkill.kill('SIGKILL'); + } catch { + // The bounded failure remains authoritative even if the helper exits during cleanup. + } + reject(new Error(`taskkill for ${description} did not exit within ${timeoutMs}ms.`)); + }, timeoutMs); + timer.unref(); + const cleanup = () => { + clearTimeout(timer); + taskkill.off('error', onError); + taskkill.off('close', onClose); + }; + const onError = (error: Error) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + reject(new Error(`Failed to run taskkill for ${description} (PID ${childProcess.pid}): ${error.message}`)); + }; + const onClose = (code: number | null) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + resolve(code); + }; + + taskkill.once('error', onError); + taskkill.once('close', onClose); + }); } function isPosixProcessGroupAlive(pid: number): boolean { @@ -301,7 +528,63 @@ function isPosixProcessGroupAlive(pid: number): boolean { } } -function redactCliSpawnArgs(args: string[] | undefined): string[] { +function isNoSuchProcessError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ESRCH'; +} + +function observeChildProcessClose(childProcess: ChildProcessWithoutNullStreams): { + hasClosed(): boolean; + wait(timeoutMs: number): Promise; + dispose(): void; +} { + let closed = false; + let resolveClose: (() => void) | undefined; + const closePromise = new Promise(resolve => { + resolveClose = resolve; + }); + const onClose = () => { + if (closed) { + return; + } + + closed = true; + childProcess.off('close', onClose); + resolveClose?.(); + }; + childProcess.once('close', onClose); + + return { + hasClosed(): boolean { + return closed; + }, + async wait(timeoutMs: number): Promise { + if (closed) { + return true; + } + + return await new Promise(resolve => { + let settled = false; + const complete = (result: boolean) => { + if (settled) { + return; + } + + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => complete(false), timeoutMs); + timer.unref(); + void closePromise.then(() => complete(true)); + }); + }, + dispose(): void { + childProcess.off('close', onClose); + }, + }; +} + +export function redactCliArgsForLogging(args: string[] | undefined): string[] { if (!args) { return []; } diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index d181dd14328..b4c9e8cd416 100644 --- a/extension/src/utils/workspace.ts +++ b/extension/src/utils/workspace.ts @@ -3,7 +3,8 @@ import { appHostCandidateDescription, cliNotAvailable, cliFoundAtDefaultPath, di import path from 'path'; import { AspireConfigFile, aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from './cliTypes'; import { extensionLogOutputChannel } from './logging'; -import { resolveCliPath } from './cliPath'; +import { resolveCliPath, tryExecuteCli, type CliPathResolutionResult } from './cliPath'; +import { CliPathResolutionTarget } from './cliPathVariables'; import { AppHostDiscoveryService, AppHostProjectSearchResult, formatAppHostLanguage, getWorkspaceAppHostProjectSearchResult } from './appHostDiscovery'; import { sendTelemetryEvent } from './telemetry'; import { getCommonExcludeGlob } from './workspaceFileSearch'; @@ -249,12 +250,26 @@ async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearch * installation directory and updates the VS Code setting accordingly. * * If not available, shows a message prompting to open Aspire CLI installation steps. + * @param target The resolution scope to check availability for: the workspace folder that + * owns the operation, or the window scope for operations with no single owning folder. + * @param pinnedCliPath The exact CLI previously selected for a restart or resumed operation. * @returns An object containing the CLI path to use and whether CLI is available */ -export async function checkCliAvailableOrRedirect(operation: 'command_gate' | 'debug_gate'): Promise<{ cliPath: string; available: boolean }> { - // Resolve CLI path fresh each time — settings or PATH may have changed +export async function checkCliAvailableOrRedirect( + operation: 'command_gate' | 'debug_gate', + target: CliPathResolutionTarget, + pinnedCliPath?: string, +): Promise<{ cliPath: string; available: boolean }> { + // A restart must validate the executable that its already-negotiated arguments target. + // Ordinary launches still resolve fresh because settings or PATH may have changed. const startTime = Date.now(); - const result = await resolveCliPath(); + const result: CliPathResolutionResult = pinnedCliPath === undefined + ? await resolveCliPath(target) + : { + cliPath: pinnedCliPath, + available: await tryExecuteCli(pinnedCliPath), + source: 'configured', + }; sendTelemetryEvent('aspire/vscode/cli/availability', { available: result.available ? 'true' : 'false', source: result.source, diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 69f192fb70a..d5dd0be3479 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { AspireTerminalProvider, ShellArg, shellArg } from '../utils/AspireTerminalProvider'; +import { getCliPathTargetForUri, windowCliPathTarget } from '../utils/cliPathVariables'; import { compareResourceCommands } from '../utils/resourceDisplay'; import { pidDescription, @@ -971,14 +972,18 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { @@ -993,7 +998,10 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { diff --git a/extension/src/views/ResourceCommandArgumentsLoader.ts b/extension/src/views/ResourceCommandArgumentsLoader.ts index 7ae0d5f0d5f..2b035e9b3ee 100644 --- a/extension/src/views/ResourceCommandArgumentsLoader.ts +++ b/extension/src/views/ResourceCommandArgumentsLoader.ts @@ -14,6 +14,7 @@ import { ResourceCommandArgumentValue, } from './ResourceCommandArguments'; import { nonInteractiveCliEnvironment } from '../utils/environment'; +import { getCliPathTargetForUri } from '../utils/cliPathVariables'; export interface ResourceCommandArgumentLoaderContext { cliExecutionProvider: AspireTerminalProvider; @@ -46,7 +47,7 @@ async function loadResourceCommandArgumentInputs( { location: vscode.ProgressLocation.Window, title: resourceCommandLoadingDynamicInputs }, async () => { try { - const cliPath = await context.cliExecutionProvider.getAspireCliExecutablePath(); + const cliPath = await context.cliExecutionProvider.getAspireCliExecutablePath(getCliPathTargetForUri(vscode.Uri.file(context.appHostPath!))); const args = ['resource', context.resourceName, context.commandName, '--load-arguments', '--non-interactive', '--apphost', context.appHostPath!, noLogoOption]; args.push(...buildResourceCommandCliArgs(values)); diff --git a/extension/yarn.lock b/extension/yarn.lock index 02ecc9a65cf..51e26882096 100644 --- a/extension/yarn.lock +++ b/extension/yarn.lock @@ -4572,7 +4572,7 @@ node-forge@1.4.0: resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/node-forge/-/node-forge-1.4.0.tgz" integrity sha1-HHt9i9wtB4c59YKH1YnZA6EbL8I= -node-gyp-build@^4.8.4: +node-gyp-build@^4.8.2, node-gyp-build@^4.8.4: version "4.8.4" resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/node-gyp-build/-/node-gyp-build-4.8.4.tgz" integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== @@ -6168,6 +6168,14 @@ tree-sitter-c-sharp@0.23.5: node-addon-api "^8.2.2" node-gyp-build "^4.8.4" +tree-sitter-java@0.23.5: + version "0.23.5" + resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/tree-sitter-java/-/tree-sitter-java-0.23.5.tgz#fb150fdaa9c852b3d71ba3144109008ac6a47ac2" + integrity sha512-Yju7oQ0Xx7GcUT01mUglPP+bYfvqjNCGdxqigTnew9nLGoII42PNVP3bHrYeMxswiCRM0yubWmN5qk+zsg0zMA== + dependencies: + node-addon-api "^8.2.2" + node-gyp-build "^4.8.2" + tree-sitter-rust@0.24.0: version "0.24.0" resolved "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/tree-sitter-rust/-/tree-sitter-rust-0.24.0.tgz#efe9104052ae022e98d2cd30018587bd05ce01f3" diff --git a/global.json b/global.json index e43170bf880..4a17eae503f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.400", + "version": "11.0.100-rc.1.26413.103", "rollForward": "major", "allowPrerelease": true, "paths": [ @@ -10,23 +10,27 @@ "errorMessage": "The .NET SDK could not be found. Run ./restore.sh (Linux/macOS) or ./restore.cmd (Windows) to install the local SDK." }, "tools": { - "dotnet": "10.0.400", + "dotnet": "11.0.100-rc.1.26413.103", "runtimes": { "dotnet/x64": [ "$(DotNetRuntimePreviousVersionForTesting)", - "$(DotNetRuntimeCurrentVersionForTesting)" + "$(DotNetRuntimeCurrentVersionForTesting)", + "$(DotNetRuntimeNet10VersionForTesting)" ], "dotnet/arm64": [ "$(DotNetRuntimePreviousVersionForTesting)", - "$(DotNetRuntimeCurrentVersionForTesting)" + "$(DotNetRuntimeCurrentVersionForTesting)", + "$(DotNetRuntimeNet10VersionForTesting)" ], "aspnetcore/x64": [ "$(DotNetRuntimePreviousVersionForTesting)", - "$(DotNetRuntimeCurrentVersionForTesting)" + "$(DotNetRuntimeCurrentVersionForTesting)", + "$(DotNetRuntimeNet10VersionForTesting)" ], "aspnetcore/arm64": [ "$(DotNetRuntimePreviousVersionForTesting)", - "$(DotNetRuntimeCurrentVersionForTesting)" + "$(DotNetRuntimeCurrentVersionForTesting)", + "$(DotNetRuntimeNet10VersionForTesting)" ] } }, diff --git a/playground/Directory.Build.targets b/playground/Directory.Build.targets index ca72857121c..15b52ceef29 100644 --- a/playground/Directory.Build.targets +++ b/playground/Directory.Build.targets @@ -10,7 +10,13 @@ - + + diff --git a/playground/JavaAppHost/.gitattributes b/playground/JavaAppHost/.gitattributes new file mode 100644 index 00000000000..554b18266de --- /dev/null +++ b/playground/JavaAppHost/.gitattributes @@ -0,0 +1,4 @@ +# The Maven wrapper is a shell script. Committing it with CRLF makes it unrunnable on Linux and macOS, +# which is where CI builds this sample. +/forecast/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/playground/JavaAppHost/.vscode/launch.json b/playground/JavaAppHost/.vscode/launch.json new file mode 100644 index 00000000000..105524aaa1d --- /dev/null +++ b/playground/JavaAppHost/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Java AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}/AppHost.java" + } + ] +} diff --git a/playground/JavaAppHost/.vscode/settings.json b/playground/JavaAppHost/.vscode/settings.json new file mode 100644 index 00000000000..31b3815021e --- /dev/null +++ b/playground/JavaAppHost/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "java.project.sourcePaths": [ + ".", + ".aspire/modules" + ] +} diff --git a/playground/JavaAppHost/AppHost.java b/playground/JavaAppHost/AppHost.java index 26858b1ecb7..1a46bd71c31 100644 --- a/playground/JavaAppHost/AppHost.java +++ b/playground/JavaAppHost/AppHost.java @@ -3,9 +3,25 @@ void main(String[] args) throws Exception { IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + // A plain JAR with no web framework, built by its own checked-in Maven wrapper before it runs. + // The JAR path is repeated in withJarArtifact because publishing has to be told which artifact is + // the application; the container build otherwise has to guess among everything under target/. + JavaAppResource forecast = builder.addJavaAppWithJar("forecast", "./forecast", + "target/forecast-1.0.0-SNAPSHOT.jar", new String[0]); + forecast.withMavenBuild(new String[] { "-B", "-ntp", "-DskipTests", "package" }); + forecast.withJarArtifact("target/forecast-1.0.0-SNAPSHOT.jar"); + // Reads target/agent/opentelemetry-javaagent.jar, which the POM copies there during the build. + forecast.withOtelAgentDefaultPath(); + forecast.withHttpEndpoint(new WithHttpEndpointOptions().env("PORT")); + forecast.withHttpHealthCheck(new WithHttpHealthCheckOptions().path("/health")); + NodeAppResource app = builder.addNodeApp("app", "./api", "src/index.ts"); app.withHttpEndpoint(new WithHttpEndpointOptions().env("PORT")); app.withExternalHttpEndpoints(); + // Projects the Java endpoint as services__forecast__http__0, which the API reads to call it, and + // holds the API back until the JVM reports healthy so its first request cannot race the build. + app.withReference(forecast); + app.waitFor(forecast); ViteAppResource frontend = builder.addViteApp("frontend", "./frontend"); frontend.withReference(app); diff --git a/playground/JavaAppHost/api/package-lock.json b/playground/JavaAppHost/api/package-lock.json new file mode 100644 index 00000000000..ba9676d592e --- /dev/null +++ b/playground/JavaAppHost/api/package-lock.json @@ -0,0 +1,4675 @@ +{ + "name": "java-apphost-api", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "java-apphost-api", + "dependencies": { + "@opentelemetry/auto-instrumentations-node": "^0.76.0", + "@opentelemetry/exporter-logs-otlp-grpc": "^0.218.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.218.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.218.0", + "@opentelemetry/sdk-logs": "^0.218.0", + "@opentelemetry/sdk-metrics": "^2.7.1", + "@opentelemetry/sdk-node": "^0.218.0", + "express": "^5.1.0" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "tsx": "^4.21.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha1-egGo0uwvuy2seK2tCbD6eB5Agr4=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha1-cEvSl95tdi3lTqu+r79V9nVqvi8=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha1-tUCifRTkr9BYSWpNvsTT9BTbEQo=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha1-0csWbTSw+/D+irRgpVlPJKN4cB4=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha1-EDSyZFf8iGNo/mG70J9lP2r6jlQ=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha1-ZVVqQyoeTXIDLYIYwZMvzKGkl3I=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha1-LmHgWS+QMNfj2uGO4l68U1kYrvY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha1-yV7CiZWe+AecTcqBeh4sS+Zrm9M=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha1-wJoPZ5F1kqwN6JKpvk04FN69Kmw=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha1-QLIhdd2gYYLz7oFBGGxf8wTEpxc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha1-pYD5xnZ5eDOJHlGfx6EzfIr9jbM=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha1-RkUs8yHcf56Rwvp4Cla7Vuec1os=", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha1-QhGzGE3WYI9T3LIuOfXTTuCIUsg=", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha1-aXhXwqYcubC2u2ZS5AwdxeHKjl0=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha1-0ZKUPrFGpArExkl9DPe+NbmGvwg=", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha1-rOoDVtoODrwI+Xz3ucLkAeHmSNw=", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha1-bww84MtkxTS3DExF7LLBbTTjXf0=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha1-i813B3oNzjN4tXT+2ybSolO3PTY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha1-5/sqAemcgwyU5mI82f77TI+1g0c=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha1-xSkJNy24uG4sVeBaiUADO1Zgo7I=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha1-xCe5vlpkwmL/mn63C1+7qt9EbGw=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha1-3JsUe6yi5sSzyFVxdB70hgpIkJc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha1-zoZtEt8TwV5MmfBzo9Rm9uBkmzo=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha1-dGjjaS0B1inVlB5dg4F7uA+eObQ=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha1-pbwAY/sryrbQ7WPyoVN5WLwmnsY=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha1-EAZO5E9DR7kMmgK0Rrv4CpFjKxI=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha1-5z/1fZeALwY5mVRfQ+uyseymXZ0=", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha1-WmspDMv7GuL2d1r7dOmJi9jF1Og=", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha1-s3Znt7wYHBaHgiWbq0JHT79StVA=", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha1-kpn4KHS6ueTH+cSNhlvsv+jWkHw=", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha1-wbA0beM2ulWvLVp5cIggN7rt7AU=", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.218.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", + "integrity": "sha1-e5gY6N/fHT3KuIv+TWck8vgx9+w=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-node": { + "version": "0.76.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.76.0.tgz", + "integrity": "sha1-649kbe4dfp1EVaavJHBFw4h1EBg=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/instrumentation-amqplib": "^0.65.0", + "@opentelemetry/instrumentation-aws-lambda": "^0.70.0", + "@opentelemetry/instrumentation-aws-sdk": "^0.73.0", + "@opentelemetry/instrumentation-bunyan": "^0.63.0", + "@opentelemetry/instrumentation-cassandra-driver": "^0.63.0", + "@opentelemetry/instrumentation-connect": "^0.61.0", + "@opentelemetry/instrumentation-cucumber": "^0.34.0", + "@opentelemetry/instrumentation-dataloader": "^0.35.0", + "@opentelemetry/instrumentation-dns": "^0.61.0", + "@opentelemetry/instrumentation-express": "^0.66.0", + "@opentelemetry/instrumentation-fs": "^0.37.0", + "@opentelemetry/instrumentation-generic-pool": "^0.61.0", + "@opentelemetry/instrumentation-graphql": "^0.66.0", + "@opentelemetry/instrumentation-grpc": "^0.218.0", + "@opentelemetry/instrumentation-hapi": "^0.64.0", + "@opentelemetry/instrumentation-http": "^0.218.0", + "@opentelemetry/instrumentation-ioredis": "^0.66.0", + "@opentelemetry/instrumentation-kafkajs": "^0.27.0", + "@opentelemetry/instrumentation-knex": "^0.62.0", + "@opentelemetry/instrumentation-koa": "^0.66.0", + "@opentelemetry/instrumentation-lru-memoizer": "^0.62.0", + "@opentelemetry/instrumentation-memcached": "^0.61.0", + "@opentelemetry/instrumentation-mongodb": "^0.71.0", + "@opentelemetry/instrumentation-mongoose": "^0.64.0", + "@opentelemetry/instrumentation-mysql": "^0.64.0", + "@opentelemetry/instrumentation-mysql2": "^0.64.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.64.0", + "@opentelemetry/instrumentation-net": "^0.62.0", + "@opentelemetry/instrumentation-openai": "^0.16.0", + "@opentelemetry/instrumentation-oracledb": "^0.43.0", + "@opentelemetry/instrumentation-pg": "^0.70.0", + "@opentelemetry/instrumentation-pino": "^0.64.0", + "@opentelemetry/instrumentation-redis": "^0.66.0", + "@opentelemetry/instrumentation-restify": "^0.63.0", + "@opentelemetry/instrumentation-router": "^0.62.0", + "@opentelemetry/instrumentation-runtime-node": "^0.31.0", + "@opentelemetry/instrumentation-socket.io": "^0.65.0", + "@opentelemetry/instrumentation-tedious": "^0.37.0", + "@opentelemetry/instrumentation-undici": "^0.28.0", + "@opentelemetry/instrumentation-winston": "^0.62.0", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.33.8", + "@opentelemetry/resource-detector-aws": "^2.18.0", + "@opentelemetry/resource-detector-azure": "^0.26.0", + "@opentelemetry/resource-detector-container": "^0.8.9", + "@opentelemetry/resource-detector-gcp": "^0.53.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-node": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.4.1", + "@opentelemetry/core": "^2.0.0" + } + }, + "node_modules/@opentelemetry/configuration": { + "version": "0.218.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/configuration/-/configuration-0.218.0.tgz", + "integrity": "sha1-pf3VDsnPoK2zu0EgLLOfecX059k=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "yaml": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/configuration/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", + "integrity": "sha1-FVWm+yaVlkFtjGJv0CDD8sOOBx8=", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.10.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha1-qnf3E450ulOZ1fO7DereDBaPALI=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.218.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.218.0.tgz", + "integrity": "sha1-flp+YkB01kSVkMhR1Y7+YAljFLM=", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/sdk-logs": "0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.218.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.218.0.tgz", + "integrity": "sha1-CZbLReDrxsdGVEVDCgGO3KyYcbQ=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/sdk-logs": "0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.218.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.218.0.tgz", + "integrity": "sha1-5TlyCy4UXoWnpJAaHquwsud5kPg=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { + "version": "0.218.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.218.0.tgz", + "integrity": "sha1-bWbCY4cCSJ0GO4hXdB7unOodIdY=", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha1-txP2ndZ5M+zJxhNX8dRSzcn04oE=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.218.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.218.0.tgz", + "integrity": "sha1-wgVYFYXQTFEG0cp2biNjIAbkaiw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha1-txP2ndZ5M+zJxhNX8dRSzcn04oE=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { + "version": "0.218.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.218.0.tgz", + "integrity": "sha1-ZmQri7XmVMp6WUSk8LSQgU/Idf0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha1-txP2ndZ5M+zJxhNX8dRSzcn04oE=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus": { + "version": "0.218.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.218.0.tgz", + "integrity": "sha1-Qb3cl9NM2dmZM4K5wQ/tGajeY/Q=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha1-txP2ndZ5M+zJxhNX8dRSzcn04oE=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.218.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.218.0.tgz", + "integrity": "sha1-XVUyq5TVUUvsiu3BnOMXC2OB7tE=", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.218.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.218.0.tgz", + "integrity": "sha1-Ntar9tY5ueqGFgPGH0NHUcCxoOo=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.218.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.218.0.tgz", + "integrity": "sha1-3gsrNUUUna/t0rlI+4kfm/lilAw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin": { + "version": "2.7.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.7.1.tgz", + "integrity": "sha1-O3nSI63IwJe6MyPk3k7Yq7g8eJ4=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.218.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation/-/instrumentation-0.218.0.tgz", + "integrity": "sha1-/M60/7RfmcDSkmAHaRUPxZRNw+k=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.65.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.65.0.tgz", + "integrity": "sha1-moNxED+QANsjB01+PxQwxA0kek4=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-aws-lambda": { + "version": "0.70.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.70.0.tgz", + "integrity": "sha1-SuMVokwFTGR740fV0/34fKIlouk=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/aws-lambda": "^8.10.155" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-aws-sdk": { + "version": "0.73.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.73.0.tgz", + "integrity": "sha1-LO3WcPNs5njW4AnNZZGxeuwdEws=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.34.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-bunyan": { + "version": "0.63.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.63.0.tgz", + "integrity": "sha1-Ak74rH8bu+mc1E2Xnp0TLkuH33g=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.218.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@types/bunyan": "1.8.11" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-cassandra-driver": { + "version": "0.63.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.63.0.tgz", + "integrity": "sha1-EmDpzdubMaEJNB/oYVGOYmY6QUs=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.37.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.61.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.61.0.tgz", + "integrity": "sha1-t/Edo6m9gPwiR0fkTPV5oarZTZM=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-cucumber": { + "version": "0.34.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.34.0.tgz", + "integrity": "sha1-5WGlMHSefnX8UFMT1HwojvPvSq8=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.35.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.35.0.tgz", + "integrity": "sha1-0PABG9+tRh5b1j7rie3+DFj7k8g=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dns": { + "version": "0.61.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.61.0.tgz", + "integrity": "sha1-V7rS8rgfaoRWP6zZ8zErrJS1AWY=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.66.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-express/-/instrumentation-express-0.66.0.tgz", + "integrity": "sha1-QfEvZXESogSaegB9suvbFTOLAdo=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.37.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.37.0.tgz", + "integrity": "sha1-x5gqMwpMmN6osavDcsmAov0szKg=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.61.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.61.0.tgz", + "integrity": "sha1-s9s5/Qzp3pZ6FE280AG2F6k4nM4=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.66.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.66.0.tgz", + "integrity": "sha1-qsU+5nnttlTbvRLpLri8Lr8iExI=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-grpc": { + "version": "0.218.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.218.0.tgz", + "integrity": "sha1-yI7bIJhq/DduLy8schzg0F/TonM=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "0.218.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.64.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.64.0.tgz", + "integrity": "sha1-DKkXh8owRsMRSRRcxRbpddisHas=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.218.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-http/-/instrumentation-http-0.218.0.tgz", + "integrity": "sha1-Cya3AqYoj6Eb3qSP9KNjU4Wn1Yc=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/instrumentation": "0.218.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.66.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.66.0.tgz", + "integrity": "sha1-8DBqWg/4DutxMS4trBnRvszJGOw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/redis-common": "^0.38.3", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.27.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.27.0.tgz", + "integrity": "sha1-XmCMAg0XoZIqoGdEFO0nGOGExIM=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.62.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.62.0.tgz", + "integrity": "sha1-ds/+aGdYMSszc53QYN3DArt0olM=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.66.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.66.0.tgz", + "integrity": "sha1-a6Xi+XQAvNqkCFu66F/pU3gNKwo=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.62.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.62.0.tgz", + "integrity": "sha1-e6NK4SxJMwIPhx7W5DCeD3YxHGI=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-memcached": { + "version": "0.61.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.61.0.tgz", + "integrity": "sha1-+kk9BImUNyGXpiYuwSof5xmAKiI=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/memcached": "^2.2.6" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.71.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.71.0.tgz", + "integrity": "sha1-0BE5PMsNBJ/BOhPKqC4//HnhZS4=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.64.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.64.0.tgz", + "integrity": "sha1-NLQ6fm8yrgcTQ2sWl1FN5Jt4Pdg=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.64.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.64.0.tgz", + "integrity": "sha1-5rWJRbYYLFl7/EER8HYHiQl+700=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/mysql": "2.15.27" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.64.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.64.0.tgz", + "integrity": "sha1-XAe/T+7cbAcWAbVeXQd9Qj6yYok=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@opentelemetry/sql-common": "^0.41.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-nestjs-core": { + "version": "0.64.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.64.0.tgz", + "integrity": "sha1-pXOH26qi8SLK85pD/E5XtxWVpAA=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-net": { + "version": "0.62.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-net/-/instrumentation-net-0.62.0.tgz", + "integrity": "sha1-gzOfYWY0QcRbYyejxqJk8k8ttE8=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-openai": { + "version": "0.16.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-openai/-/instrumentation-openai-0.16.0.tgz", + "integrity": "sha1-iD1OpMKS4e+HeVqvG1fVL/jCm4M=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.218.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-oracledb": { + "version": "0.43.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-oracledb/-/instrumentation-oracledb-0.43.0.tgz", + "integrity": "sha1-NRruc/7KjH2YVsrvLXoW0klfWpc=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@types/oracledb": "6.5.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.70.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.70.0.tgz", + "integrity": "sha1-9eFqeORDLFKgeXHOGs8arCoZ6rQ=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sql-common": "^0.41.2", + "@types/pg": "8.15.6", + "@types/pg-pool": "2.0.7" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pino": { + "version": "0.64.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.64.0.tgz", + "integrity": "sha1-MQfetKwpR0yk4MSgRLuhvhdAv4Y=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.218.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.66.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.66.0.tgz", + "integrity": "sha1-YEhs9FE80KFB42IDzl/CKN0rGIo=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/redis-common": "^0.38.3", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-restify": { + "version": "0.63.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.63.0.tgz", + "integrity": "sha1-TXzCtFHONmZFws2DQnsmuelbuGU=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-router": { + "version": "0.62.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-router/-/instrumentation-router-0.62.0.tgz", + "integrity": "sha1-VzohByktDLvzQqJb8wKzG2yFxQ4=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-runtime-node": { + "version": "0.31.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-runtime-node/-/instrumentation-runtime-node-0.31.0.tgz", + "integrity": "sha1-Ck5g831RoH3ErMHyfdn0Fr9y9i4=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.218.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-socket.io": { + "version": "0.65.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.65.0.tgz", + "integrity": "sha1-NAcOYF7khUmkcdFGtC8krmsl1bA=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.37.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.37.0.tgz", + "integrity": "sha1-Xj7rM2XcWIEKK0uJnEBiLgVCkbo=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.28.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.28.0.tgz", + "integrity": "sha1-zI+bZ9PbmJnPkn/btX+zOVppkF8=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/instrumentation-winston": { + "version": "0.62.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.62.0.tgz", + "integrity": "sha1-QcErVZbpcJoJ9GlLYzjrQCTB3v8=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.218.0", + "@opentelemetry/instrumentation": "^0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.218.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", + "integrity": "sha1-8z4yF85Wh1a6oTL6vjDwyTRdsIY=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-transformer": "0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.218.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.218.0.tgz", + "integrity": "sha1-3Fof7HFiRdhNxBZ96bHGB+B/tqc=", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.218.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", + "integrity": "sha1-Z4TQ3dE4A8Y6GyQHJgbAL8IbkHE=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha1-txP2ndZ5M+zJxhNX8dRSzcn04oE=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-b3": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/propagator-b3/-/propagator-b3-2.7.1.tgz", + "integrity": "sha1-EH/j4W0HKMSJ7brSIcQC7hl1FKQ=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.7.1.tgz", + "integrity": "sha1-6Ovz9sDpqlJc8EGJNCWInPPmkSU=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/redis-common/-/redis-common-0.38.3.tgz", + "integrity": "sha1-MaBGSkipkcKUCGFONyXZTbfBGu4=", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/resource-detector-alibaba-cloud": { + "version": "0.33.8", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.33.8.tgz", + "integrity": "sha1-tjmh2A5YB/wK1x7vKZaXkbF423g=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-aws": { + "version": "2.21.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resource-detector-aws/-/resource-detector-aws-2.21.0.tgz", + "integrity": "sha1-hS7/4J5I+v+N72AYnReP+0XVc2s=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-azure": { + "version": "0.26.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.26.0.tgz", + "integrity": "sha1-b97TpvTHSLiZ8wo3BTZvjfQZ3wk=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.37.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-container": { + "version": "0.8.12", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resource-detector-container/-/resource-detector-container-0.8.12.tgz", + "integrity": "sha1-C4w/nmdGMQLVUYlviZ2eGZUoEPQ=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resource-detector-gcp": { + "version": "0.53.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.53.0.tgz", + "integrity": "sha1-ZGn5dyKMIitACmCSpc1Rby0pct0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0", + "gcp-metadata": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.10.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.10.0.tgz", + "integrity": "sha1-znbBwbqvzkx3vCmJCWW6H1ZnGsw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.218.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", + "integrity": "sha1-eIhv4wC4KALO6ZYyCLKvMmuSivU=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.10.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-metrics/-/sdk-metrics-2.10.0.tgz", + "integrity": "sha1-Go841FFkuj77s1Bb62jK9KPs8tk=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-node": { + "version": "0.218.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-node/-/sdk-node-0.218.0.tgz", + "integrity": "sha1-z4vcDJkzhxicfEdGEuD4AVBf65c=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/configuration": "0.218.0", + "@opentelemetry/context-async-hooks": "2.7.1", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/exporter-logs-otlp-grpc": "0.218.0", + "@opentelemetry/exporter-logs-otlp-http": "0.218.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.218.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "0.218.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", + "@opentelemetry/exporter-metrics-otlp-proto": "0.218.0", + "@opentelemetry/exporter-prometheus": "0.218.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.218.0", + "@opentelemetry/exporter-trace-otlp-http": "0.218.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.218.0", + "@opentelemetry/exporter-zipkin": "2.7.1", + "@opentelemetry/instrumentation": "0.218.0", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/propagator-b3": "2.7.1", + "@opentelemetry/propagator-jaeger": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1", + "@opentelemetry/sdk-trace-node": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha1-txP2ndZ5M+zJxhNX8dRSzcn04oE=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha1-kWDDr57yIZwmVjq9E24i+30Zs08=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha1-OyqRefYRm7Hyzd7+QbqbKFVQSl0=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.7.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz", + "integrity": "sha1-VN7bjnf6UabQL8IZIJdzkmbIIWg=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.7.1", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha1-Fiv6tG1v9Nob7yQOpS4jqSaw/bw=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha1-8/Rn42wnMy8Oc17IbNzXjdbyeGU=", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", + "integrity": "sha1-f0oUFmz9bJ/+iQltscx16vZEOxk=", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha1-p36nQvqyV3UUVDTrHSMoz1ATrDM=", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha1-TIVzDlm5ofHzSQR9vyQpYDS7JzU=", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha1-2TFa188/MKrHC9o8BoRD3G8UNlk=", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha1-1RLLJsCuAmCR7iwRZ/G+b69chCo=", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha1-TW/ADI+2QBalyBtGnVSQRjUPEGU=", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha1-eNR2Mz2F1bHHkuJXvKdLoIDaSaQ=", + "license": "BSD-3-Clause" + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.162", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/aws-lambda/-/aws-lambda-8.10.162.tgz", + "integrity": "sha1-WJz+zdo6mW6b3yCnOiaggRyvtUk=", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha1-GFm+u4/X2smRikXVTBlxq4ta9HQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bunyan": { + "version": "1.8.11", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/bunyan/-/bunyan-1.8.11.tgz", + "integrity": "sha1-C551eKWqI5D68SpGCCcVSQIpljg=", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha1-W6fzvE+73q/43e2VLl/yzFP42Fg=", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/express/-/express-5.0.6.tgz", + "integrity": "sha1-LXJLLJkNy4yERAY/NYCpA/bVAMw=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha1-nTTIjAye5iuabk2firjX4paI5rQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha1-W3SasrFroRNCP+saZKldzTA5hHI=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/memcached": { + "version": "2.2.10", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/memcached/-/memcached-2.2.10.tgz", + "integrity": "sha1-ET+eOkUda14KOCLgbZ/rUuY+lUo=", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha1-+xOw6GFNOdQvQPOBIX7DIVkV8ek=", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-26.2.0.tgz", + "integrity": "sha1-Wkh1qGL9qP3Ffej6pXm7gey6FoU=", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/oracledb": { + "version": "6.5.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/oracledb/-/oracledb-6.5.2.tgz", + "integrity": "sha1-GUzQ8TQ2+eHnRKbl4FanykAFNtU=", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha1-TfdZC5rFV8vlR54AdOwVQMvdrZs=", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-pool": { + "version": "2.0.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/pg-pool/-/pg-pool-2.0.7.tgz", + "integrity": "sha1-wXlFp0Ry2aO+r45m1apvyTgyhzQ=", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha1-hgaIQnLGPw25aYa9NUhlDYqTiL8=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha1-UK5DU+qt3AQEQnmBL1LIxlhX28s=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/send/-/send-1.2.1.tgz", + "integrity": "sha1-anhORVQ8GMd0wEm/9tPbrwRcnHQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha1-1KRHUD6tDRZxEy0atr1YuAXY3mo=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha1-hoEY56Z4CCWMBRWOnK2JyliirsE=", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha1-u89LpQdUZ/PyEx6rPP/HPC9deJU=", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha1-48121MVI7oldPD/Y3B9sW5Ay56g=", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha1-YCFu6kZNhkWXzigyAAc4oFiWUME=", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha1-6D46fj8wCzTLnYf2FfoMvzV2kO4=", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha1-dZxard8v/cTxVPe0k+HIdw+IxNc=", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha1-bYZi9NjDNgKLismqJCUbDKZLpDc=", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha1-L7Pt5p3/oK94ynxM51iWgGOLVt8=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha1-WJ2rEcABjQNmvmTNi/Esjb7MgyY=", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha1-iwvuuYYFrfGxKPpDhkA8AJ4CIaU=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha1-S1QowiK+mF15w9gmV0edvgtZstY=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha1-I43pNdKippKSjFOMfM+pEGf9Bio=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha1-s8pRAYQziSWa3n2Ix3vQbOVYSco=", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha1-DASwddsCy/5g3I5s8vVIaxo2CKo=", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha1-7dgDYornHATIWuegkG7a00tkiTc=", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM=", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha1-89t4nHUtRVZMx+nh4LMXkNSjjhc=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha1-i3cxYmVtHRCGeEyPI6VM5tc9eRg=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha1-VWNpxHKiupEPKXmJG1JrNDYjftc=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha1-V8f8PMKTrKuf7FTXPhVpDr5KF5M=", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha1-ilj+ePANzXDDcEUXWd+/rwPo7p8=", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha1-2P6ysogeak9YwuCKz9Dig04mIi4=", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", + "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/depd/-/depd-2.0.0.tgz", + "integrity": "sha1-tpYWPMdXVg0JzyLMj60Vcbeedt8=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha1-165mfh3INIL4tw/Q9u78UNow9Yo=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha1-aWzi7Aqg5uqTo5f/zySqeEDIJ8s=", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha1-hAyIA7DYBH9P8M+WMXazLU7z7XI=", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha1-e46omAd9fkCdOsRUdOo46vCFelg=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha1-mD6y+aZyTpMD9hrd8BHHLgngsPo=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha1-BfdaJdq5jk+x3NXhRywFRtUFfI8=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha1-W/LfBpmdu+XwBqX0ahH7n1t7ORs=", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha1-otCzcyBXJN+lJdI7DD4bHKWCyZs=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha1-70W0Y0ycnZeilq6kEUpfmED5VXg=", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha1-ARo/aYVroYnf+n3I/M6Z0qh5A+U=", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/express/-/express-5.2.1.tgz", + "integrity": "sha1-jyHRW20yf5K0eU7PjLCKcvlWrAQ=", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/extend/-/extend-3.0.2.tgz", + "integrity": "sha1-+LETa0Bx+9jrFAr/hYsQGewpFfo=", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha1-8JuNS71Frcbwwgt+eH55PjCdzOk=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha1-osUXplWYUrzbBtH4vX9Rto+tgJk=", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha1-Mujp7Rtoo0l777msK2rfkqY4V28=", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha1-JIB8McnUAuACqz2McgFEzriEhCM=", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha1-ImmTZCiq1MFcfr6XeahL8LKoGBE=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha1-CFEe3aqi3f1WuhETju598RegkyU=", + "license": "MIT" + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha1-jdffahs6Gzpc8YbAWl3SZ2ImNaQ=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha1-LALYZNl/PqbIgwxGTL0Rq26rehw=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha1-xTEvQlSrwbirU67zDCLFIpuAseE=", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha1-B/mHJnQxoDrpT3czy9CIv5L60Lk=", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha1-T5RBKoLbMvNuOwuXQfipf+sDH34=", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha1-dD8OO2lkqTpUke0b/6rgVNf5jQE=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha1-FQs/J0OGnvPoUewMSdFbHRTQDuE=", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob/-/glob-10.5.0.tgz", + "integrity": "sha1-jsA1WRnNMzjChCiiPU8k7MX+c4w=", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha1-F7cfH5XSZtLd01a48AF4Qz8EGxc=", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha1-ifVrghe9vIgCvSmd9tfxCB1+UaE=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha1-/JxqeDoISVHQuXH+EBjegTcHozg=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha1-jGLYy5C+sqrV0KW2dYGtmFTD8AM=", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha1-NtL2W8kJyHkAGN02+02T2myq4Gs=", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha1-2o3+rH2hMLBcK6S1nJts1mYRprk=", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha1-hO4S+WPn3lC8AaE+FgoHizsPQV8=", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/import-in-the-middle": { + "version": "3.3.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/import-in-the-middle/-/import-in-the-middle-3.3.3.tgz", + "integrity": "sha1-cUIO1erCSjWGlb/6zXQ1tIbHbWE=", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha1-v/OFQ+64mEglB5/zoqjmy9RngbM=", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha1-8Rb4Bk/pCz94RKOJl8C3UFEmnx0=", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha1-Qv+fhCBsGZHSbev1IN1cAQQt0vM=", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha1-iDOp2Jq0rN5hiJQr0cU7Y5DtWoo=", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha1-rlR4I6wMrYOYZn+M2e9HMPWwH/E=", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/long/-/long-5.3.2.tgz", + "integrity": "sha1-HYRGMJWZkmLX17f4v9SozFUWf4M=", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha1-QQ/IoXtw5ZgBPfJXwkRrfzOD8Rk=", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha1-oN10voHiqlwvJ+Zc4oNgXuTit/k=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha1-bwNUAN/jq51WB7x3VGzjDML5xrg=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha1-6pIvZgY1oiSe5WXgRJ+VHmtgOAg=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha1-zds+5PnGRTDf9kAjZmHULLajFPU=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha1-OQAtQYJXXVrwNv+hGBAPJSSy4qs=", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha1-mwy5/LeAh/b9fqur4lEcTT1gV04=", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha1-eTibTrG7LQA6m7qH1JLyvTe9xls=", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha1-tmL9zZP2yD0/JSidoM6ByNloW5Q=", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", + "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha1-tskbtHFy1p+Tz9fDV7u1KQGbX2o=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha1-aIjbRqH3HAt2s/dVUBa2P+ZHZuU=", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha1-0eiJus33M7T/OyskPrehKGagt4s=", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha1-g3UmXiG8IND6WCwi4bE0hdbgAhM=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha1-WMjEQRblSEWtV/FKsQsDUzGErD8=", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha1-TxRxoBCCeob5TP2bByfjbSZ95QU=", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha1-naGee+6NEt/wUT7Vt2lXeTvC6NQ=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha1-WB9q3mWMu6ZaDTOA3ndTKVBU83U=", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha1-eWCmaIiFlKByCxKpEdGnQqufEdI=", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha1-eVxCDE98pFxbiHNm9iLuDJhSzM0=", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha1-lDvUY79bcbQXARX4D478mgwOt4w=", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha1-dY9sBnnMC79JOGA7dZdwPzMxgMA=", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha1-LQJQ1jZFT3z6O2rgOC/fqAYyVKM=", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha1-SPj84FT7xpZxmZMpuINLdyZS2C4=", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha1-xAs9oCIsUA/x5RxdcBS2C3lpfHo=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha1-UbwIYAYAXlBhxZHO5yfyUxv2Qag=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha1-tGDILLFYdQd4iBmgaqD//bNURpU=", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha1-e5JQza9KBhOanw/kaKQNfU/rynE=", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha1-8Z/mnOqzEe65S0LnDowgcPm6ECU=", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/qs/-/qs-6.15.3.tgz", + "integrity": "sha1-doUhMqWO1cfA72fkRBubtdYGGzs=", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha1-1/Gb6BK7YnIUcrRdO+IZ7wlXK0c=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha1-PjraWuVWj5CV2EN2/TpJuPsAClE=", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha1-294lh/ZpOYYm1WsgyGirh78BzOQ=", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha1-I7mEPT3JLbcfluGizpLjn9KoIhw=", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/router/-/router-2.2.0.tgz", + "integrity": "sha1-AZvmILcRyHZBFnzHm5kJDwCxRu8=", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha1-RPoWGwGHuVSd2Eu5GAL5vYOFzWo=", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/send/-/send-1.2.1.tgz", + "integrity": "sha1-nqt0O4dPNVD0CiaGe/KGrWDT8+0=", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha1-fxhqSk5fW2Y616QpT/G/N88OmKk=", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha1-ZsmiSnP5/CjL5msJ/tPTPcrxtCQ=", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha1-zNCvT4g1+9wmW4JGGq8MNmY/NOo=", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI=", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha1-6gLGLgXcS+pn1EQvD7ce4ZL44Ks=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha1-wuC1oUpUCuvuO7xsP4ZmzJtQkSc=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha1-1rtrN5Asb+9RdOX1M/q0xzKib0I=", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha1-Ed2hnVNo5Azp7CvcH7DsvAeQ7Oo=", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha1-lSGIwcvVRgcOLdIND0HArgUwywQ=", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha1-j3XuzvdlteHPzcCA2llAntQk44I=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha1-FPja7G2B5yIdKjV+Zoyrc728p5Q=", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha1-O+NDIaiKgg7RvYDfqjPkefu43TU=", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.11", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tsx/-/tsx-4.23.11.tgz", + "integrity": "sha1-gqfLzOFntXUGDbA+94moTGh8f5c=", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha1-cdGnBTKTWC4WrJ8+uvGrmqSeVXA=", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha1-L7Pt5p3/oK94ynxM51iWgGOLVt8=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha1-ROn8nzJEZIzeo15Pm7LWgelBCAk=", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha1-IHO5Gi/bH7+9QB594KyfghTOy0s=", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz", + "integrity": "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha1-VtwiNo7lcPrOG0mBmXXZuaXq0hQ=", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha1-7dgDYornHATIWuegkG7a00tkiTc=", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha1-u3J3n1+kZRhrH0OPZ0+jR/2121Q=", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha1-f0k00PfKjFb5UxSTndzS3ZHOHVU=", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha1-eCdK/ZNZih391hMN9qVm3vy/mqQ=", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha1-d53/5ryv7FlqcXLpgyiaWIZH+qo=", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha1-kJa87r+ZDSG7MfqVFuDt4pSnfTU=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/playground/JavaAppHost/api/src/index.ts b/playground/JavaAppHost/api/src/index.ts index 4d2c25855e1..ffcc4296224 100644 --- a/playground/JavaAppHost/api/src/index.ts +++ b/playground/JavaAppHost/api/src/index.ts @@ -11,19 +11,29 @@ import { join } from "path"; const app = express(); const port = process.env.PORT || 5000; -/** Returns a random 5-day weather forecast as JSON. */ -app.get("/api/weatherforecast", (_req, res) => { - const summaries = ["Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"]; - const forecasts = Array.from({ length: 5 }, (_, i) => { - const temperatureC = Math.floor(Math.random() * 75) - 20; - return { - date: new Date(Date.now() + (i + 1) * 86400000).toISOString(), - temperatureC, - temperatureF: 32 + Math.trunc(temperatureC / 0.5556), - summary: summaries[Math.floor(Math.random() * summaries.length)], - }; - }); - res.json(forecasts); +// Injected by the AppHost's withReference(forecast). Service discovery names the variable after the +// resource and endpoint, so the API never has to know which port Aspire handed the Java service. +const forecastServiceUrl = process.env.services__forecast__http__0; + +/** Proxies the 5-day forecast produced by the Java service. */ +app.get("/api/weatherforecast", async (_req, res) => { + if (!forecastServiceUrl) { + res.status(503).json({ error: "The forecast service is not configured. Run this API through the AppHost." }); + return; + } + + try { + const response = await fetch(new URL("/forecast", forecastServiceUrl)); + + if (!response.ok) { + res.status(502).json({ error: `Forecast service returned ${response.status}` }); + return; + } + + res.json(await response.json()); + } catch (error) { + res.status(502).json({ error: `Forecast service is unreachable: ${error}` }); + } }); app.get("/health", (_req, res) => { diff --git a/playground/JavaAppHost/api/src/instrumentation.ts b/playground/JavaAppHost/api/src/instrumentation.ts index e9282489cdd..737d016a290 100644 --- a/playground/JavaAppHost/api/src/instrumentation.ts +++ b/playground/JavaAppHost/api/src/instrumentation.ts @@ -31,9 +31,9 @@ if (env.OTEL_EXPORTER_OTLP_ENDPOINT) { instrumentations: [getNodeAutoInstrumentations()], }); - sdk.start().catch((error) => { - console.error('Failed to start OpenTelemetry NodeSDK:', error); - }); + // NodeSDK.start() returns void, so there is no promise to attach a rejection handler to; + // calling .catch() on its result throws a TypeError before the server ever starts. + sdk.start(); process.on('SIGTERM', () => { sdk.shutdown().finally(() => process.exit(0)); diff --git a/playground/JavaAppHost/aspire.config.json b/playground/JavaAppHost/aspire.config.json index 4ff2c311107..571019b44b3 100644 --- a/playground/JavaAppHost/aspire.config.json +++ b/playground/JavaAppHost/aspire.config.json @@ -7,6 +7,7 @@ "experimentalPolyglot:java": true }, "packages": { + "Aspire.Hosting.Java": "", "Aspire.Hosting.JavaScript": "" }, "profiles": { diff --git a/playground/JavaAppHost/forecast/.mvn/wrapper/maven-wrapper.properties b/playground/JavaAppHost/forecast/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000000..d58dfb70bab --- /dev/null +++ b/playground/JavaAppHost/forecast/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/playground/JavaAppHost/forecast/mvnw b/playground/JavaAppHost/forecast/mvnw new file mode 100755 index 00000000000..bd8896bf221 --- /dev/null +++ b/playground/JavaAppHost/forecast/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/playground/JavaAppHost/forecast/mvnw.cmd b/playground/JavaAppHost/forecast/mvnw.cmd new file mode 100644 index 00000000000..92450f93273 --- /dev/null +++ b/playground/JavaAppHost/forecast/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/playground/JavaAppHost/forecast/pom.xml b/playground/JavaAppHost/forecast/pom.xml new file mode 100644 index 00000000000..77f6e990843 --- /dev/null +++ b/playground/JavaAppHost/forecast/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + com.example + forecast + 1.0.0-SNAPSHOT + jar + + + 21 + UTF-8 + 2.24.0 + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + com.example.forecast.ForecastService + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-opentelemetry-javaagent + initialize + + copy + + + + + io.opentelemetry.javaagent + opentelemetry-javaagent + ${opentelemetry-javaagent.version} + ${project.build.directory}/agent + opentelemetry-javaagent.jar + + + true + + + + + + + diff --git a/playground/JavaAppHost/forecast/src/main/java/com/example/forecast/ForecastService.java b/playground/JavaAppHost/forecast/src/main/java/com/example/forecast/ForecastService.java new file mode 100644 index 00000000000..877fd6c6784 --- /dev/null +++ b/playground/JavaAppHost/forecast/src/main/java/com/example/forecast/ForecastService.java @@ -0,0 +1,101 @@ +package com.example.forecast; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.random.RandomGenerator; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/// The weather data behind this playground's UI, served by a plain JAR with no web framework and +/// launched as `java -jar target/forecast-1.0.0-SNAPSHOT.jar`. +/// +/// The Node API in front of it fetches `/forecast` through the `services__forecast__http__0` variable +/// that `withReference` projects, so the sample exercises a Java service participating in service +/// discovery rather than sitting next to the other resources doing nothing. +public final class ForecastService { + + private static final int SHUTDOWN_DRAIN_SECONDS = 2; + + private static final List SUMMARIES = List.of( + "Freezing", "Bracing", "Chilly", "Cool", "Mild", + "Warm", "Balmy", "Hot", "Sweltering", "Scorching"); + + public static void main(String[] args) throws IOException, InterruptedException { + var port = port(); + var server = HttpServer.create(new InetSocketAddress(port), 0); + + // Virtual threads because the JDK server is otherwise single-threaded, which would let one slow + // request stall the health probe Aspire uses to decide the resource is running. + server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); + server.createContext("/forecast", ForecastService::forecast); + server.createContext("/health", exchange -> respond(exchange, 200, "text/plain", "Healthy")); + server.start(); + + System.out.printf("forecast service listening on port %d%n", port); + + // SIGTERM is how `aspire stop` and container runtimes ask the process to go away. Draining the + // server first lets in-flight requests finish instead of failing at the socket. The latch keeps + // main alive rather than depending on the server's internal threads being non-daemon. + var stopped = new CountDownLatch(1); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("forecast service stopping"); + server.stop(SHUTDOWN_DRAIN_SECONDS); + stopped.countDown(); + })); + + stopped.await(); + } + + private static void forecast(HttpExchange exchange) throws IOException { + if (!"GET".equals(exchange.getRequestMethod())) { + respond(exchange, 405, "text/plain", "Method not allowed"); + return; + } + + var random = RandomGenerator.getDefault(); + var today = Instant.now().truncatedTo(ChronoUnit.DAYS); + + var json = IntStream.rangeClosed(1, 5) + .mapToObj(day -> { + var temperatureC = random.nextInt(-20, 55); + return """ + {"date":"%s","temperatureC":%d,"temperatureF":%d,"summary":"%s"}""" + .formatted( + today.plus(day, ChronoUnit.DAYS), + temperatureC, + 32 + (int) (temperatureC / 0.5556), + SUMMARIES.get(random.nextInt(SUMMARIES.size()))); + }) + .collect(Collectors.joining(",", "[", "]")); + + respond(exchange, 200, "application/json", json); + } + + private static void respond(HttpExchange exchange, int status, String contentType, String body) throws IOException { + var bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(status, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + private static int port() { + // Aspire assigns the port and hands it over as PORT, which is the variable the AppHost names in + // withHttpEndpoint. Binding a fixed port instead would collide with whatever else is running. + var value = System.getenv("PORT"); + return value == null || value.isBlank() ? 8080 : Integer.parseInt(value); + } + + private ForecastService() { + } +} diff --git a/playground/JavaQuarkus/.gitattributes b/playground/JavaQuarkus/.gitattributes new file mode 100644 index 00000000000..3459e4179c1 --- /dev/null +++ b/playground/JavaQuarkus/.gitattributes @@ -0,0 +1,6 @@ +# The Maven and Gradle wrappers are shell scripts. Committing them with CRLF makes them unrunnable on +# Linux and macOS, which is where CI builds these samples. +/inventory/mvnw text eol=lf +/pricing/gradlew text eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/playground/JavaQuarkus/.vscode/launch.json b/playground/JavaQuarkus/.vscode/launch.json new file mode 100644 index 00000000000..25b7df2d118 --- /dev/null +++ b/playground/JavaQuarkus/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}/JavaQuarkus.AppHost/JavaQuarkus.AppHost.csproj" + } + ] +} diff --git a/playground/JavaQuarkus/JavaQuarkus.AppHost/AppHost.cs b/playground/JavaQuarkus/JavaQuarkus.AppHost/AppHost.cs new file mode 100644 index 00000000000..8408568b9fe --- /dev/null +++ b/playground/JavaQuarkus/JavaQuarkus.AppHost/AppHost.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +// Gives `aspire publish` somewhere to publish to. Each Quarkus resource turns into a container image +// built from a Dockerfile that Aspire generates, which stages the fast-JAR layout the Quarkus build +// produces under target/quarkus-app. +builder.AddDockerComposeEnvironment("compose"); + +// Gradle, detected from build.gradle in the directory. AddQuarkusApp builds the application, runs it in +// dev mode so live coding works, and declares an HTTP endpoint through QUARKUS_HTTP_PORT. +// +// There is no WithOtelAgent call anywhere in this file: the quarkus-opentelemetry extension is compiled +// into both applications, and AddQuarkusApp points that extension at the Aspire dashboard. +var pricing = builder.AddQuarkusApp("pricing", "../pricing") + // /q/health is served by the smallrye-health extension, which both services depend on. + .WithHttpHealthCheck("/q/health") + .WithExternalHttpEndpoints(); + +// Maven, detected from the pom.xml in the directory. Identical AppHost code to the Gradle service above, +// which is the point: the build tool is a property of the project, not something the AppHost restates. +builder.AddQuarkusApp("inventory", "../inventory") + .WithHttpHealthCheck("/q/health") + .WithExternalHttpEndpoints() + // Projects the pricing endpoint as services__pricing__http__0, which inventory's + // application.properties feeds into the MicroProfile REST client's base URL. + .WithReference(pricing) + .WaitFor(pricing); + +#if !SKIP_DASHBOARD_REFERENCE +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); diff --git a/playground/JavaQuarkus/JavaQuarkus.AppHost/JavaQuarkus.AppHost.csproj b/playground/JavaQuarkus/JavaQuarkus.AppHost/JavaQuarkus.AppHost.csproj new file mode 100644 index 00000000000..323dbd4e02d --- /dev/null +++ b/playground/JavaQuarkus/JavaQuarkus.AppHost/JavaQuarkus.AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + + diff --git a/playground/JavaQuarkus/JavaQuarkus.AppHost/Properties/launchSettings.json b/playground/JavaQuarkus/JavaQuarkus.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..f12922a361c --- /dev/null +++ b/playground/JavaQuarkus/JavaQuarkus.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "https://javaquarkus.dev.localhost:17005;http://javaquarkus.dev.localhost:15005", + "environmentVariables": { + "ASPIRE_ENVIRONMENT": "Development", + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23005", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://javaquarkus.dev.localhost:15005", + "environmentVariables": { + "ASPIRE_ENVIRONMENT": "Development", + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18005", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} diff --git a/playground/JavaQuarkus/JavaQuarkus.AppHost/aspire.config.json b/playground/JavaQuarkus/JavaQuarkus.AppHost/aspire.config.json new file mode 100644 index 00000000000..cd77bff4041 --- /dev/null +++ b/playground/JavaQuarkus/JavaQuarkus.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "JavaQuarkus.AppHost.csproj" + } +} diff --git a/playground/JavaQuarkus/inventory/.mvn/wrapper/maven-wrapper.properties b/playground/JavaQuarkus/inventory/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000000..a3c407ffc27 --- /dev/null +++ b/playground/JavaQuarkus/inventory/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,4 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip +distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce diff --git a/playground/JavaQuarkus/inventory/mvnw b/playground/JavaQuarkus/inventory/mvnw new file mode 100755 index 00000000000..bd8896bf221 --- /dev/null +++ b/playground/JavaQuarkus/inventory/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/playground/JavaQuarkus/inventory/mvnw.cmd b/playground/JavaQuarkus/inventory/mvnw.cmd new file mode 100644 index 00000000000..92450f93273 --- /dev/null +++ b/playground/JavaQuarkus/inventory/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/playground/JavaQuarkus/inventory/pom.xml b/playground/JavaQuarkus/inventory/pom.xml new file mode 100644 index 00000000000..555fdb63bd5 --- /dev/null +++ b/playground/JavaQuarkus/inventory/pom.xml @@ -0,0 +1,141 @@ + + + 4.0.0 + com.example + inventory + 1.0.0-SNAPSHOT + + + 3.15.0 + 21 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.38.2 + true + 3.5.6 + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-rest-client-jackson + + + io.quarkus + quarkus-opentelemetry + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + native-image-agent + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + true + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native + + + native + + + + false + false + true + + + + diff --git a/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/InventoryItem.java b/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/InventoryItem.java new file mode 100644 index 00000000000..87d04ba5413 --- /dev/null +++ b/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/InventoryItem.java @@ -0,0 +1,4 @@ +package com.example.inventory; + +public record InventoryItem(String sku, String name, int quantity, Price price) { +} diff --git a/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/InventoryResource.java b/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/InventoryResource.java new file mode 100644 index 00000000000..929bf98a67d --- /dev/null +++ b/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/InventoryResource.java @@ -0,0 +1,48 @@ +package com.example.inventory; + +import java.util.List; +import java.util.Map; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.rest.client.inject.RestClient; + +@Path("/") +public class InventoryResource { + + private static final Map CATALOG = Map.of( + "ASP-1000", "Aspire mug", + "ASP-1001", "Aspire hoodie", + "ASP-1002", "Aspire stickers"); + + private final PricingClient pricing; + + public InventoryResource(@RestClient PricingClient pricing) { + this.pricing = pricing; + } + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String index() { + return "Inventory service (Maven + quarkus:dev). Try /inventory."; + } + + @GET + @Path("/inventory") + @Produces(MediaType.APPLICATION_JSON) + public List inventory() { + // Calling the other service is what produces a trace spanning both of them, which is the point of + // running two resources rather than one. + return CATALOG.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> new InventoryItem( + entry.getKey(), + entry.getValue(), + 10 + Math.abs(entry.getKey().hashCode() % 40), + pricing.price(entry.getKey()))) + .toList(); + } +} diff --git a/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/Price.java b/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/Price.java new file mode 100644 index 00000000000..893773e4c46 --- /dev/null +++ b/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/Price.java @@ -0,0 +1,4 @@ +package com.example.inventory; + +public record Price(String sku, double amount, String currency) { +} diff --git a/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/PricingClient.java b/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/PricingClient.java new file mode 100644 index 00000000000..8f63fa54872 --- /dev/null +++ b/playground/JavaQuarkus/inventory/src/main/java/com/example/inventory/PricingClient.java @@ -0,0 +1,22 @@ +package com.example.inventory; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +// The MicroProfile REST client is used rather than a plain HTTP client because quarkus-opentelemetry +// instruments it, so the call to pricing becomes a child span of the incoming inventory request instead +// of an untraced hole in the middle of the trace. +@RegisterRestClient(configKey = "pricing-api") +@Path("/pricing") +public interface PricingClient { + + @GET + @Path("/{sku}") + @Produces(MediaType.APPLICATION_JSON) + Price price(@PathParam("sku") String sku); +} diff --git a/playground/JavaQuarkus/inventory/src/main/resources/application.properties b/playground/JavaQuarkus/inventory/src/main/resources/application.properties new file mode 100644 index 00000000000..dfc1988d43e --- /dev/null +++ b/playground/JavaQuarkus/inventory/src/main/resources/application.properties @@ -0,0 +1,23 @@ +# WithReference(pricing) in the AppHost projects the resolved endpoint as the environment variable +# services__pricing__http__0. SmallRye Config matches an environment variable to a property name +# ignoring case and punctuation, so the Aspire name resolves here as written. The default keeps the +# service runnable with `mvnw quarkus:dev` outside the AppHost. +quarkus.rest-client.pricing-api.url=${services__pricing__http__0:http://localhost:8081} + +# quarkus-opentelemetry is compiled into the application, which is why AddQuarkusApp needs no +# WithOtelAgent call: no Java agent is involved. +quarkus.otel.enabled=true + +# quarkus-opentelemetry reads its own quarkus.otel.* configuration rather than the standard OTEL_* +# environment variables, so the endpoint has to be named here under the property it does read. +# `aspire run` does not need this line — AddQuarkusApp mirrors the values Aspire resolved onto the +# QUARKUS_OTEL_* environment variables SmallRye Config maps to these properties. A published container +# does, because there the compute environment supplies OTEL_* after every AppHost callback has run, and +# the mirroring finds nothing to copy. The value is a SmallRye expression, expanded inside the JVM at +# startup, so it does not matter when the variable was set. It cannot be passed as an environment +# variable instead: Docker Compose interpolates '${...}' in its own file and rejects this form. +# The defaults match the extension's own, so the service still runs outside Aspire. +# See https://smallrye.io/smallrye-config/Main/config/expressions/. +quarkus.otel.exporter.otlp.endpoint=${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317} +quarkus.otel.exporter.otlp.protocol=${OTEL_EXPORTER_OTLP_PROTOCOL:grpc} +quarkus.otel.service.name=${OTEL_SERVICE_NAME:inventory} diff --git a/playground/JavaQuarkus/pricing/build.gradle b/playground/JavaQuarkus/pricing/build.gradle new file mode 100644 index 00000000000..2d1be122949 --- /dev/null +++ b/playground/JavaQuarkus/pricing/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'java' + id 'io.quarkus' +} + +repositories { + mavenCentral() + mavenLocal() +} + +dependencies { + implementation enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}") + implementation 'io.quarkus:quarkus-rest-jackson' + implementation 'io.quarkus:quarkus-smallrye-health' + implementation 'io.quarkus:quarkus-opentelemetry' + implementation 'io.quarkus:quarkus-arc' + implementation 'io.quarkus:quarkus-rest' + testImplementation 'io.quarkus:quarkus-junit5' + testImplementation 'io.rest-assured:rest-assured' +} + +group = 'com.example' +version = '1.0.0-SNAPSHOT' + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +test { + systemProperty "java.util.logging.manager", "org.jboss.logmanager.LogManager" +} +compileJava { + options.encoding = 'UTF-8' + options.compilerArgs << '-parameters' +} + +compileTestJava { + options.encoding = 'UTF-8' +} diff --git a/playground/JavaQuarkus/pricing/gradle.properties b/playground/JavaQuarkus/pricing/gradle.properties new file mode 100644 index 00000000000..df7715dc6a2 --- /dev/null +++ b/playground/JavaQuarkus/pricing/gradle.properties @@ -0,0 +1,7 @@ +# Gradle properties + +quarkusPluginId=io.quarkus +quarkusPluginVersion=3.38.2 +quarkusPlatformGroupId=io.quarkus.platform +quarkusPlatformArtifactId=quarkus-bom +quarkusPlatformVersion=3.38.2 diff --git a/playground/JavaQuarkus/pricing/gradle/wrapper/gradle-wrapper.jar b/playground/JavaQuarkus/pricing/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..b1b8ef56b44 Binary files /dev/null and b/playground/JavaQuarkus/pricing/gradle/wrapper/gradle-wrapper.jar differ diff --git a/playground/JavaQuarkus/pricing/gradle/wrapper/gradle-wrapper.properties b/playground/JavaQuarkus/pricing/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..6eb5d4e9cde --- /dev/null +++ b/playground/JavaQuarkus/pricing/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=87a2216cc1f9122192d4e0fe905ffdf1b4c72cff797e9f733b174e157cadd396 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-all.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/playground/JavaQuarkus/pricing/gradlew b/playground/JavaQuarkus/pricing/gradlew new file mode 100755 index 00000000000..249efbb032c --- /dev/null +++ b/playground/JavaQuarkus/pricing/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/playground/JavaQuarkus/pricing/gradlew.bat b/playground/JavaQuarkus/pricing/gradlew.bat new file mode 100644 index 00000000000..a51ec4f5886 --- /dev/null +++ b/playground/JavaQuarkus/pricing/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/playground/JavaQuarkus/pricing/settings.gradle b/playground/JavaQuarkus/pricing/settings.gradle new file mode 100644 index 00000000000..3f8feb33315 --- /dev/null +++ b/playground/JavaQuarkus/pricing/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + mavenLocal() + } + plugins { + id "${quarkusPluginId}" version "${quarkusPluginVersion}" + } +} +rootProject.name='pricing' diff --git a/playground/JavaQuarkus/pricing/src/main/java/com/example/pricing/Price.java b/playground/JavaQuarkus/pricing/src/main/java/com/example/pricing/Price.java new file mode 100644 index 00000000000..9678eb623be --- /dev/null +++ b/playground/JavaQuarkus/pricing/src/main/java/com/example/pricing/Price.java @@ -0,0 +1,4 @@ +package com.example.pricing; + +public record Price(String sku, double amount, String currency) { +} diff --git a/playground/JavaQuarkus/pricing/src/main/java/com/example/pricing/PricingResource.java b/playground/JavaQuarkus/pricing/src/main/java/com/example/pricing/PricingResource.java new file mode 100644 index 00000000000..b9b90795002 --- /dev/null +++ b/playground/JavaQuarkus/pricing/src/main/java/com/example/pricing/PricingResource.java @@ -0,0 +1,28 @@ +package com.example.pricing; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/") +public class PricingResource { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String index() { + return "Pricing service (Gradle + quarkusDev). Try /pricing/ASP-1000."; + } + + @GET + @Path("/pricing/{sku}") + @Produces(MediaType.APPLICATION_JSON) + public Price price(@PathParam("sku") String sku) { + // Derived from the SKU so the sample returns the same answer on every run, which keeps the + // trace and log output comparable between runs. + var amount = 10 + Math.abs(sku.hashCode() % 9000) / 100.0; + + return new Price(sku, Math.round(amount * 100) / 100.0, "USD"); + } +} diff --git a/playground/JavaQuarkus/pricing/src/main/resources/application.properties b/playground/JavaQuarkus/pricing/src/main/resources/application.properties new file mode 100644 index 00000000000..e5186162104 --- /dev/null +++ b/playground/JavaQuarkus/pricing/src/main/resources/application.properties @@ -0,0 +1,16 @@ +# quarkus-opentelemetry is compiled into the application, so no Java agent is involved. +quarkus.otel.enabled=true + +# quarkus-opentelemetry reads its own quarkus.otel.* configuration rather than the standard OTEL_* +# environment variables, so the endpoint has to be named here under the property it does read. +# `aspire run` does not need this line — AddQuarkusApp mirrors the values Aspire resolved onto the +# QUARKUS_OTEL_* environment variables SmallRye Config maps to these properties. A published container +# does, because there the compute environment supplies OTEL_* after every AppHost callback has run, and +# the mirroring finds nothing to copy. The value is a SmallRye expression, expanded inside the JVM at +# startup, so it does not matter when the variable was set. It cannot be passed as an environment +# variable instead: Docker Compose interpolates '${...}' in its own file and rejects this form. +# The defaults match the extension's own, so the service still runs outside Aspire. +# See https://smallrye.io/smallrye-config/Main/config/expressions/. +quarkus.otel.exporter.otlp.endpoint=${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317} +quarkus.otel.exporter.otlp.protocol=${OTEL_EXPORTER_OTLP_PROTOCOL:grpc} +quarkus.otel.service.name=${OTEL_SERVICE_NAME:pricing} diff --git a/playground/JavaSpringBoot/.gitattributes b/playground/JavaSpringBoot/.gitattributes new file mode 100644 index 00000000000..88b1e998ed4 --- /dev/null +++ b/playground/JavaSpringBoot/.gitattributes @@ -0,0 +1,8 @@ +# The Maven and Gradle wrappers are shell scripts. Committing them with CRLF makes them unrunnable on +# Linux and macOS, which is where CI builds these samples. +/catalog/mvnw text eol=lf +/worker/mvnw text eol=lf +/JavaSpringBoot.AppHost.Java/gradlew text eol=lf +/orders/gradlew text eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/playground/JavaSpringBoot/.vscode/launch.json b/playground/JavaSpringBoot/.vscode/launch.json new file mode 100644 index 00000000000..5f1c11d4dde --- /dev/null +++ b/playground/JavaSpringBoot/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Java AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}/JavaSpringBoot.AppHost.Java/AppHost.java" + }, + { + "name": "Run C# AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}/JavaSpringBoot.AppHost/JavaSpringBoot.AppHost.csproj" + } + ] +} diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/AppHost.java b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/AppHost.java new file mode 100644 index 00000000000..f634098dd91 --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/AppHost.java @@ -0,0 +1,42 @@ +import aspire.*; + +// The same three services the C# AppHost next door orchestrates, written in Java. Running either one +// against this directory produces the same application, which is the point: the AppHost language is a +// choice about what the team already writes, not about what Aspire can express. +void main() throws Exception { + var builder = DistributedApplication.CreateBuilder(); + + // Matches the C# AppHost next door, so `aspire publish` emits the same Docker Compose artifacts + // from either language. Without a compute environment the publish pipeline has nothing to write. + builder.addDockerComposeEnvironment("compose"); + + // Maven, detected from the pom.xml in the directory. addSpringBootApp builds the application, + // launches it through spring-boot:run, and declares an HTTP endpoint through SERVER_PORT. + var catalog = builder.addSpringBootApp("catalog", "../catalog") + // Reads target/agent/opentelemetry-javaagent.jar, which the POM copies there during the build. + .withOtelAgentDefaultPath() + .withHttpHealthCheck(new WithHttpHealthCheckOptions().path("/actuator/health")) + .withExternalHttpEndpoints(); + + // Gradle, detected from build.gradle. Identical AppHost code to the Maven service above, because + // the build tool is a property of the project rather than something the AppHost restates. + builder.addSpringBootApp("orders", "../orders") + .withOtelAgentDefaultPath() + .withHttpHealthCheck(new WithHttpHealthCheckOptions().path("/actuator/health")) + .withExternalHttpEndpoints() + // Projects the catalog endpoint as services__catalog__http__0 and holds orders back until + // catalog reports healthy, so its first request cannot race the other service's startup. + .withReference(catalog) + .waitFor(catalog); + + // A plain JAR with no framework, built by Maven before it runs. + builder.addJavaAppWithJar("worker", "../worker", "target/worker-0.0.1-SNAPSHOT.jar", + new String[] { "--interval-seconds", "10" }) + .withMavenBuild(new String[] { "-B", "-ntp", "-DskipTests", "package" }) + // Publishing has to know which JAR is the application, or the container build finds both + // worker-0.0.1-SNAPSHOT.jar and any classifier artifacts and refuses to guess. + .withJarArtifact("target/worker-0.0.1-SNAPSHOT.jar") + .withJvmArgs(new String[] { "-Xmx128m" }); + + builder.build().run(); +} diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/aspire.config.json b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/aspire.config.json new file mode 100644 index 00000000000..2edb5de80a4 --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/aspire.config.json @@ -0,0 +1,26 @@ +{ + "appHost": { + "path": "AppHost.java", + "language": "java" + }, + "features": { + "experimentalPolyglot:java": true + }, + "packages": { + "Aspire.Hosting.Java": "../../../src/Aspire.Hosting.Java/Aspire.Hosting.Java.csproj", + "Aspire.Hosting.CodeGeneration.Java": "../../../src/Aspire.Hosting.CodeGeneration.Java/Aspire.Hosting.CodeGeneration.Java.csproj", + "Aspire.Hosting.Docker": "../../../src/Aspire.Hosting.Docker/Aspire.Hosting.Docker.csproj" + }, + "profiles": { + "https": { + "applicationUrl": "https://javaspringboot-java.dev.localhost:17014;http://javaspringboot-java.dev.localhost:15014", + "environmentVariables": {} + }, + "http": { + "applicationUrl": "http://javaspringboot-java.dev.localhost:15014", + "environmentVariables": { + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/build.gradle b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/build.gradle new file mode 100644 index 00000000000..5d700f54429 --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/build.gradle @@ -0,0 +1,81 @@ +plugins { + id 'java' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + // Must match the release the Aspire CLI compiles the AppHost with, which is Java 25. The + // scaffolded AppHost uses an implicitly declared class with an instance main method, finalized + // in Java 25 by JEP 512 (https://openjdk.org/jeps/512). Targeting anything lower makes the + // editor report the AppHost's own entry point as a syntax error. + languageVersion = JavaLanguageVersion.of(25) + } + + // The toolchain alone is not enough for the IDE. Buildship reads sourceCompatibility/ + // targetCompatibility to pick the Eclipse compliance level it hands the Java language server, and + // it does not derive them from the toolchain. Without these, the language server compiles at its + // own default and reports "'Compact Source Files and Instance Main Methods' is only available with + // source level 25 and above" against AppHost.java, even though `gradle build` succeeds. + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +repositories { + mavenCentral() +} + +sourceSets { + main { + java { + // Two source roots, and both are needed. + // + // '.' holds AppHost.java. It sits beside this build file rather than under src/main/java so + // that its paths to the sibling services stay identical to the C# AppHost's ("../catalog"). + // + // '.aspire/modules' holds the Aspire Java SDK, which the CLI generates as source laid out by + // package: .aspire/modules/aspire/AspireBuilder.java declares `package aspire;`. A source root + // has to be the directory a package path is relative to, so the SDK needs a root of its own; + // covering it from '.' instead makes `import aspire.*` resolve to nothing, because that root + // expects those files under ./aspire. + // + // Gradle is what makes this expressible. Maven allows a single , and neither + // build-helper-maven-plugin's add-source goal nor is honoured by + // the Maven importer inside the Java language server, so a Maven AppHost laid out this way + // opens with unresolved Aspire imports. + srcDirs = ['.', '.aspire/modules'] + + // Eclipse, and so the Java language server, and so VS Code, refuses to build a project whose + // source folders nest without an explicit exclusion. '.aspire/modules' nests inside '.', so + // exclude it from that root; the excludes are relative to each source root, which is why this + // does not also exclude the SDK from its own root. + exclude 'src/**', 'build/**', 'bin/**', '.gradle/**', '.aspire/**' + + // Clearing the resources source set below stops Gradle copying this directory into build + // output, but it does not stop Eclipse, and so the Java language server. Eclipse copies every + // non-excluded file in a source folder to that folder's output directory, so rooting the java + // source set at '.' also lands gradlew, the wrapper, build.gradle and aspire.config.json in + // bin/main. Restricting the source folder to .java files is what makes Eclipse's own copy a + // no-op; the pattern changes nothing for Gradle, which only ever compiles .java from here. + include '**/*.java' + } + + // Rooting the java source set at '.' also points the resources source set at '.', because the + // java plugin defaults both to the same convention. processResources would then copy every + // non-.java file in this directory — .gradle/, .aspire/, gradlew, the wrapper jar — into + // build/resources/main and bin/main. The Java language server digests those copies, and throws + // "Problem refreshing workspace ... NoSuchFileException: .../bin/main/.gradle" as soon as Gradle + // deletes the originals. The AppHost ships no resources, so give the set no roots at all. + resources { + srcDirs = [] + } + } +} + +// The AppHost's only dependency is the Aspire SDK above, which is generated as source rather than +// resolved as an artifact. Real AppHosts add third-party dependencies here, which is the main reason to +// adopt a build tool: the CLI stages whatever is declared onto the AppHost's classpath before compiling. +dependencies { +} diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradle/wrapper/gradle-wrapper.jar b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..eddabd2eef8 Binary files /dev/null and b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradle/wrapper/gradle-wrapper.jar differ diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradle/wrapper/gradle-wrapper.properties b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..84c5a9c661a --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradlew b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradlew new file mode 100755 index 00000000000..249efbb032c --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradlew.bat b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradlew.bat new file mode 100644 index 00000000000..a51ec4f5886 --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/settings.gradle b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/settings.gradle new file mode 100644 index 00000000000..214a3baf2b7 --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/settings.gradle @@ -0,0 +1,9 @@ +plugins { + // Lets Gradle provision the JDK the toolchain asks for instead of failing when the machine only has + // a different release installed. Without it, this build only works on a host that already has a + // JDK 25 on disk. + // https://docs.gradle.org/current/userguide/toolchains.html#sub:download_repositories + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + +rootProject.name = 'javaspringboot-apphost' diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost/AppHost.cs b/playground/JavaSpringBoot/JavaSpringBoot.AppHost/AppHost.cs new file mode 100644 index 00000000000..908fcf64b0b --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost/AppHost.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +// Gives `aspire publish` somewhere to publish to. Each Java resource turns into a container image built +// from a Dockerfile that Aspire generates from the resource's build tool and target Java release. +builder.AddDockerComposeEnvironment("compose"); + +// Maven, detected from the pom.xml in the directory. AddSpringBootApp builds the application, launches it +// through spring-boot:run, and declares an HTTP endpoint through SERVER_PORT. +var catalog = builder.AddSpringBootApp("catalog", "../catalog") + // Reads target/agent/opentelemetry-javaagent.jar, which the POM copies there during the build. The + // agent is ~25 MB and is not committed, so it does not exist on a fresh clone. Asking for a + // build-produced agent is therefore what makes Aspire add the catalog-maven-build resource this + // waits for: JAVA_TOOL_OPTIONS reaches the wrapper's own JVM, so without a build that has already + // written the agent, that JVM dies at VM initialization before spring-boot:run ever starts. + .WithOtelAgent() + .WithHttpHealthCheck("/actuator/health") + .WithExternalHttpEndpoints(); + +// Gradle, detected from build.gradle in the directory. Identical AppHost code to the Maven service above, +// which is the point: the build tool is a property of the project, not something the AppHost restates. +var orders = builder.AddSpringBootApp("orders", "../orders") + .WithOtelAgent() + .WithHttpHealthCheck("/actuator/health") + .WithExternalHttpEndpoints() + // Projects the catalog endpoint as services__catalog__http__0 and holds orders back until catalog + // reports healthy, so its first request cannot race the other service's startup. + .WithReference(catalog) + .WaitFor(catalog); + +// A plain JAR with no framework, built by Maven before it runs. Its wrapper lives in the module rather +// than being borrowed from a sibling: publishing uploads only the application directory to the daemon, +// so a wrapper outside it would exist on the host and not in the image. +builder.AddJavaApp("worker", "../worker", "target/worker-0.0.1-SNAPSHOT.jar", ["--interval-seconds", "10"]) + .WithMavenBuild("-B", "-ntp", "-DskipTests", "package") + // Publishing has to know which JAR is the application. Without this the container build would find + // both worker-0.0.1-SNAPSHOT.jar and any classifier artifacts and refuse to guess. + .WithJarArtifact("target/worker-0.0.1-SNAPSHOT.jar") + .WithJvmArgs("-Xmx128m"); + +#if !SKIP_DASHBOARD_REFERENCE +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost/JavaSpringBoot.AppHost.csproj b/playground/JavaSpringBoot/JavaSpringBoot.AppHost/JavaSpringBoot.AppHost.csproj new file mode 100644 index 00000000000..323dbd4e02d --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost/JavaSpringBoot.AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + + diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost/Properties/launchSettings.json b/playground/JavaSpringBoot/JavaSpringBoot.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..8c9f5af4919 --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "https://javaspringboot.dev.localhost:17004;http://javaspringboot.dev.localhost:15004", + "environmentVariables": { + "ASPIRE_ENVIRONMENT": "Development", + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23004", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://javaspringboot.dev.localhost:15004", + "environmentVariables": { + "ASPIRE_ENVIRONMENT": "Development", + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18004", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} diff --git a/playground/JavaSpringBoot/JavaSpringBoot.AppHost/aspire.config.json b/playground/JavaSpringBoot/JavaSpringBoot.AppHost/aspire.config.json new file mode 100644 index 00000000000..8e95da768aa --- /dev/null +++ b/playground/JavaSpringBoot/JavaSpringBoot.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "JavaSpringBoot.AppHost.csproj" + } +} diff --git a/playground/JavaSpringBoot/catalog/.mvn/wrapper/maven-wrapper.properties b/playground/JavaSpringBoot/catalog/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000000..d58dfb70bab --- /dev/null +++ b/playground/JavaSpringBoot/catalog/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/playground/JavaSpringBoot/catalog/mvnw b/playground/JavaSpringBoot/catalog/mvnw new file mode 100755 index 00000000000..bd8896bf221 --- /dev/null +++ b/playground/JavaSpringBoot/catalog/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/playground/JavaSpringBoot/catalog/mvnw.cmd b/playground/JavaSpringBoot/catalog/mvnw.cmd new file mode 100644 index 00000000000..92450f93273 --- /dev/null +++ b/playground/JavaSpringBoot/catalog/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/playground/JavaSpringBoot/catalog/pom.xml b/playground/JavaSpringBoot/catalog/pom.xml new file mode 100644 index 00000000000..1dd69e20e2d --- /dev/null +++ b/playground/JavaSpringBoot/catalog/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.14 + + + + com.example + catalog + 0.0.1-SNAPSHOT + + + 21 + 2.24.0 + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-web + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-opentelemetry-javaagent + initialize + + copy + + + + + io.opentelemetry.javaagent + opentelemetry-javaagent + ${opentelemetry-javaagent.version} + ${project.build.directory}/agent + opentelemetry-javaagent.jar + + + true + + + + + + + diff --git a/playground/JavaSpringBoot/catalog/src/main/java/com/example/catalog/CatalogApplication.java b/playground/JavaSpringBoot/catalog/src/main/java/com/example/catalog/CatalogApplication.java new file mode 100644 index 00000000000..3787bea5c0c --- /dev/null +++ b/playground/JavaSpringBoot/catalog/src/main/java/com/example/catalog/CatalogApplication.java @@ -0,0 +1,12 @@ +package com.example.catalog; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CatalogApplication { + + public static void main(String[] args) { + SpringApplication.run(CatalogApplication.class, args); + } +} diff --git a/playground/JavaSpringBoot/catalog/src/main/java/com/example/catalog/CatalogController.java b/playground/JavaSpringBoot/catalog/src/main/java/com/example/catalog/CatalogController.java new file mode 100644 index 00000000000..8fb34150e40 --- /dev/null +++ b/playground/JavaSpringBoot/catalog/src/main/java/com/example/catalog/CatalogController.java @@ -0,0 +1,26 @@ +package com.example.catalog; + +import java.util.List; +import java.util.Map; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class CatalogController { + + private static final List> Products = List.of( + Map.of("id", 1, "name", "Aspire Mug", "price", 12.50), + Map.of("id", 2, "name", "Aspire Hoodie", "price", 48.00), + Map.of("id", 3, "name", "Aspire Stickers", "price", 4.25)); + + @GetMapping("/") + public String index() { + return "Catalog service (Maven + spring-boot:run)"; + } + + @GetMapping("/products") + public List> products() { + return Products; + } +} diff --git a/playground/JavaSpringBoot/orders/build.gradle b/playground/JavaSpringBoot/orders/build.gradle new file mode 100644 index 00000000000..9d228f0c125 --- /dev/null +++ b/playground/JavaSpringBoot/orders/build.gradle @@ -0,0 +1,58 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.14' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +configurations { + // Resolved separately from the runtime classpath: the agent instruments the JVM from the command + // line and must never end up on the application's own classpath. The roles are stated explicitly + // because Gradle 9 removed the legacy behaviour where a bare configuration was both resolvable and + // consumable. + opentelemetryAgent { + canBeConsumed = false + canBeResolved = true + } +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-web' + + opentelemetryAgent 'io.opentelemetry.javaagent:opentelemetry-javaagent:2.24.0' +} + +tasks.named('test') { + useJUnitPlatform() +} + +// The AppHost points -javaagent at build/agent/opentelemetry-javaagent.jar. Fetching it here keeps a +// ~25 MB binary out of the repository. +// https://opentelemetry.io/docs/zero-code/java/agent/ +tasks.register('copyOpenTelemetryAgent', Copy) { + from configurations.opentelemetryAgent + into layout.buildDirectory.dir('agent') + rename '.*', 'opentelemetry-javaagent.jar' +} + +// bootRun and bootJar both need the agent on disk before the application JVM starts. +tasks.named('bootRun') { + dependsOn 'copyOpenTelemetryAgent' +} + +tasks.named('classes') { + dependsOn 'copyOpenTelemetryAgent' +} diff --git a/playground/JavaSpringBoot/orders/gradle/wrapper/gradle-wrapper.jar b/playground/JavaSpringBoot/orders/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..eddabd2eef8 Binary files /dev/null and b/playground/JavaSpringBoot/orders/gradle/wrapper/gradle-wrapper.jar differ diff --git a/playground/JavaSpringBoot/orders/gradle/wrapper/gradle-wrapper.properties b/playground/JavaSpringBoot/orders/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..84c5a9c661a --- /dev/null +++ b/playground/JavaSpringBoot/orders/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/playground/JavaSpringBoot/orders/gradlew b/playground/JavaSpringBoot/orders/gradlew new file mode 100755 index 00000000000..249efbb032c --- /dev/null +++ b/playground/JavaSpringBoot/orders/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/playground/JavaSpringBoot/orders/gradlew.bat b/playground/JavaSpringBoot/orders/gradlew.bat new file mode 100644 index 00000000000..a51ec4f5886 --- /dev/null +++ b/playground/JavaSpringBoot/orders/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/playground/JavaSpringBoot/orders/settings.gradle b/playground/JavaSpringBoot/orders/settings.gradle new file mode 100644 index 00000000000..9d80af91b79 --- /dev/null +++ b/playground/JavaSpringBoot/orders/settings.gradle @@ -0,0 +1,9 @@ +plugins { + // Lets Gradle provision the JDK the toolchain asks for instead of failing when the machine only has + // a different release installed. Without it, this build only works on a host that already has a + // JDK 21 on disk. + // https://docs.gradle.org/current/userguide/toolchains.html#sub:download_repositories + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + +rootProject.name = 'orders' diff --git a/playground/JavaSpringBoot/orders/src/main/java/com/example/orders/OrdersApplication.java b/playground/JavaSpringBoot/orders/src/main/java/com/example/orders/OrdersApplication.java new file mode 100644 index 00000000000..ecd0a99561f --- /dev/null +++ b/playground/JavaSpringBoot/orders/src/main/java/com/example/orders/OrdersApplication.java @@ -0,0 +1,23 @@ +package com.example.orders; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestTemplate; + +@SpringBootApplication +public class OrdersApplication { + + public static void main(String[] args) { + SpringApplication.run(OrdersApplication.class, args); + } + + // Declared here rather than on the controller. A @Configuration class that both declares a bean and + // injects it into its own constructor is a self-referential cycle, which Spring Boot rejects at + // startup with "Relying upon circular references is discouraged". + @Bean + RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } +} diff --git a/playground/JavaSpringBoot/orders/src/main/java/com/example/orders/OrdersController.java b/playground/JavaSpringBoot/orders/src/main/java/com/example/orders/OrdersController.java new file mode 100644 index 00000000000..15b71f75db3 --- /dev/null +++ b/playground/JavaSpringBoot/orders/src/main/java/com/example/orders/OrdersController.java @@ -0,0 +1,50 @@ +package com.example.orders; + +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +@RestController +public class OrdersController { + + private final RestTemplate restTemplate; + private final String catalogUrl; + + public OrdersController( + RestTemplate restTemplate, + // WithReference(catalog) projects the resolved endpoint as the environment variable + // services__catalog__http__0. Spring's SystemEnvironmentPropertySource checks the literal name + // before it tries any relaxed-binding transformation, so the Aspire name resolves as-is and + // stays bindable from any other Spring configuration source. The default keeps the service + // runnable outside the AppHost. + @Value("${services__catalog__http__0:http://localhost:8080}") String catalogUrl) { + this.restTemplate = restTemplate; + this.catalogUrl = catalogUrl; + } + + @GetMapping("/") + public String index() { + return "Orders service (Gradle + bootRun), catalog at " + catalogUrl; + } + + @GetMapping("/orders") + public Map orders() { + // Calling across resources is what produces a distributed trace spanning both services, which is + // the whole point of running the OpenTelemetry agent on each of them. + // + // RestTemplate can only be handed a raw Class token, so there is no way to ask it for a + // List> directly; the suppression covers the unchecked conversion that + // assigning its raw List result to a parameterized type performs. + @SuppressWarnings("unchecked") + List> products = + restTemplate.getForObject(catalogUrl + "/products", List.class); + + return Map.of( + "orderId", "ORD-1001", + "catalog", products == null ? List.of() : products); + } +} diff --git a/playground/JavaSpringBoot/worker/.mvn/wrapper/maven-wrapper.properties b/playground/JavaSpringBoot/worker/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000000..d58dfb70bab --- /dev/null +++ b/playground/JavaSpringBoot/worker/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/playground/JavaSpringBoot/worker/mvnw b/playground/JavaSpringBoot/worker/mvnw new file mode 100755 index 00000000000..bd8896bf221 --- /dev/null +++ b/playground/JavaSpringBoot/worker/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/playground/JavaSpringBoot/worker/mvnw.cmd b/playground/JavaSpringBoot/worker/mvnw.cmd new file mode 100644 index 00000000000..92450f93273 --- /dev/null +++ b/playground/JavaSpringBoot/worker/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/playground/JavaSpringBoot/worker/pom.xml b/playground/JavaSpringBoot/worker/pom.xml new file mode 100644 index 00000000000..a236578616d --- /dev/null +++ b/playground/JavaSpringBoot/worker/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + com.example + worker + 0.0.1-SNAPSHOT + jar + + + 21 + UTF-8 + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + com.example.worker.Worker + + + + + + + diff --git a/playground/JavaSpringBoot/worker/src/main/java/com/example/worker/Worker.java b/playground/JavaSpringBoot/worker/src/main/java/com/example/worker/Worker.java new file mode 100644 index 00000000000..7691715c50e --- /dev/null +++ b/playground/JavaSpringBoot/worker/src/main/java/com/example/worker/Worker.java @@ -0,0 +1,44 @@ +package com.example.worker; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CountDownLatch; + +/// A background worker with no web framework, launched as `java -jar target/worker.jar`. +/// +/// Aspire treats it as a long-running resource with no endpoints, so the only signals it produces are +/// its console logs and its lifetime. +public final class Worker { + + public static void main(String[] args) throws InterruptedException { + var interval = Duration.ofSeconds(intervalSeconds(args)); + + System.out.printf("worker starting, interval=%ds%n", interval.toSeconds()); + + // A shutdown hook plus a latch, rather than a bare loop, so SIGTERM from `aspire stop` or from a + // container runtime drains cleanly instead of killing the JVM mid-iteration. + var stopped = new CountDownLatch(1); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("worker stopping"); + stopped.countDown(); + })); + + while (!stopped.await(interval.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)) { + System.out.printf("worker tick at %s%n", Instant.now()); + } + } + + private static long intervalSeconds(String[] args) { + // Arguments arrive after the JAR path, which is how AddJavaApp's jarPath overload passes them. + for (var i = 0; i < args.length - 1; i++) { + if ("--interval-seconds".equals(args[i])) { + return Long.parseLong(args[i + 1]); + } + } + + return 5; + } + + private Worker() { + } +} diff --git a/playground/JavaSwing/.vscode/launch.json b/playground/JavaSwing/.vscode/launch.json new file mode 100644 index 00000000000..c59ae63e4bb --- /dev/null +++ b/playground/JavaSwing/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}/JavaSwing.AppHost/JavaSwing.AppHost.csproj" + } + ] +} diff --git a/playground/JavaSwing/JavaSwing.AppHost/AppHost.cs b/playground/JavaSwing/JavaSwing.AppHost/AppHost.cs new file mode 100644 index 00000000000..13c3ed23fe0 --- /dev/null +++ b/playground/JavaSwing/JavaSwing.AppHost/AppHost.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddJavaApp("swing", "../desktop", "target/swing-desktop-1.0.0.jar") + .WithMavenBuild("-B", "-ntp", "-DskipTests", "package") + .WithMainClass("com.example.swing.SwingLauncher"); + +#if !SKIP_DASHBOARD_REFERENCE +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); diff --git a/playground/JavaSwing/JavaSwing.AppHost/JavaSwing.AppHost.csproj b/playground/JavaSwing/JavaSwing.AppHost/JavaSwing.AppHost.csproj new file mode 100644 index 00000000000..7ce720d4f95 --- /dev/null +++ b/playground/JavaSwing/JavaSwing.AppHost/JavaSwing.AppHost.csproj @@ -0,0 +1,20 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + + diff --git a/playground/JavaSwing/JavaSwing.AppHost/Properties/launchSettings.json b/playground/JavaSwing/JavaSwing.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..a7bd9c6ee3f --- /dev/null +++ b/playground/JavaSwing/JavaSwing.AppHost/Properties/launchSettings.json @@ -0,0 +1,7 @@ +{ + "profiles": { + "JavaSwing.AppHost": { + "commandName": "Project" + } + } +} diff --git a/playground/JavaSwing/JavaSwing.AppHost/aspire.config.json b/playground/JavaSwing/JavaSwing.AppHost/aspire.config.json new file mode 100644 index 00000000000..56fb32124b2 --- /dev/null +++ b/playground/JavaSwing/JavaSwing.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "JavaSwing.AppHost.csproj" + } +} diff --git a/playground/JavaSwing/desktop/.mvn/wrapper/maven-wrapper.properties b/playground/JavaSwing/desktop/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000000..a3c407ffc27 --- /dev/null +++ b/playground/JavaSwing/desktop/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,4 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip +distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce diff --git a/playground/JavaSwing/desktop/mvnw b/playground/JavaSwing/desktop/mvnw new file mode 100755 index 00000000000..bd8896bf221 --- /dev/null +++ b/playground/JavaSwing/desktop/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/playground/JavaSwing/desktop/mvnw.cmd b/playground/JavaSwing/desktop/mvnw.cmd new file mode 100644 index 00000000000..92450f93273 --- /dev/null +++ b/playground/JavaSwing/desktop/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/playground/JavaSwing/desktop/pom.xml b/playground/JavaSwing/desktop/pom.xml new file mode 100644 index 00000000000..1ef62191b3c --- /dev/null +++ b/playground/JavaSwing/desktop/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + com.example + swing-desktop + 1.0.0 + + + 21 + UTF-8 + + + + + + maven-compiler-plugin + 3.15.0 + + + maven-jar-plugin + 3.4.2 + + + + com.example.swing.SwingLauncher + + + + + + + diff --git a/playground/JavaSwing/desktop/src/main/java/com/example/swing/SwingApp.java b/playground/JavaSwing/desktop/src/main/java/com/example/swing/SwingApp.java new file mode 100644 index 00000000000..eb554807120 --- /dev/null +++ b/playground/JavaSwing/desktop/src/main/java/com/example/swing/SwingApp.java @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +package com.example.swing; + +import java.awt.BorderLayout; +import java.awt.event.ActionEvent; +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.SwingUtilities; +import javax.swing.Timer; + +public final class SwingApp { + private SwingApp() { + } + + public static void show() { + JFrame frame = new JFrame("Aspire Swing"); + JLabel label = new JLabel("Swing is running under Aspire"); + JButton button = new JButton("Prove breakpoint"); + button.addActionListener(SwingApp::handleButtonClick); + + frame.setLayout(new BorderLayout()); + frame.add(label, BorderLayout.CENTER); + frame.add(button, BorderLayout.SOUTH); + frame.setSize(360, 140); + frame.setLocationByPlatform(true); + frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + frame.setVisible(true); + + System.out.println("SWING_SAMPLE_STARTED " + label.getText()); + + Timer clickTimer = new Timer(1000, ignored -> button.doClick()); + clickTimer.setRepeats(false); + clickTimer.start(); + + Timer exitTimer = new Timer(60000, ignored -> { + System.out.println("SWING_SAMPLE_EXITING"); + frame.dispose(); + }); + exitTimer.setRepeats(false); + exitTimer.start(); + } + + static void handleButtonClick(ActionEvent event) { + JButton source = (JButton) event.getSource(); + source.setText("Breakpoint reached"); + System.out.println("SWING_BUTTON_CLICKED on " + Thread.currentThread().getName()); + } +} diff --git a/playground/JavaSwing/desktop/src/main/java/com/example/swing/SwingLauncher.java b/playground/JavaSwing/desktop/src/main/java/com/example/swing/SwingLauncher.java new file mode 100644 index 00000000000..b3cb897c753 --- /dev/null +++ b/playground/JavaSwing/desktop/src/main/java/com/example/swing/SwingLauncher.java @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +package com.example.swing; + +import javax.swing.SwingUtilities; + +public final class SwingLauncher { + private SwingLauncher() { + } + + public static void main(String[] args) { + SwingUtilities.invokeLater(SwingApp::show); + } +} diff --git a/playground/Qdrant/Qdrant.ApiService/Program.cs b/playground/Qdrant/Qdrant.ApiService/Program.cs index ceae735c73b..12487cc64ad 100644 --- a/playground/Qdrant/Qdrant.ApiService/Program.cs +++ b/playground/Qdrant/Qdrant.ApiService/Program.cs @@ -84,7 +84,7 @@ app.MapGet("/search", async (QdrantClient client) => { - var results = await client.SearchAsync("movie_collection", new[] { 0.12217915f, -0.034832448f }, limit: 3); + var results = await client.QueryAsync("movie_collection", new[] { 0.12217915f, -0.034832448f }, limit: 3); return results.Select(titles => titles.Payload["title"].StringValue); }); diff --git a/playground/Stress/Stress.AppHost/AppHost.cs b/playground/Stress/Stress.AppHost/AppHost.cs index d64551bf923..2b9d155a926 100644 --- a/playground/Stress/Stress.AppHost/AppHost.cs +++ b/playground/Stress/Stress.AppHost/AppHost.cs @@ -129,5 +129,6 @@ builder.AddNoStatusResource("no-status-resource"); builder.AddPropertyStressResource("property-stress-resource"); +builder.AddReplicaStateStressResource("replica-state-stress"); builder.Build().Run(); diff --git a/playground/Stress/Stress.AppHost/ReplicaStateStressResource.cs b/playground/Stress/Stress.AppHost/ReplicaStateStressResource.cs new file mode 100644 index 00000000000..f583da415f4 --- /dev/null +++ b/playground/Stress/Stress.AppHost/ReplicaStateStressResource.cs @@ -0,0 +1,329 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using Aspire.Dashboard.Model; +using Microsoft.Extensions.DependencyInjection; +using HealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus; + +static class ReplicaStateStressResourceExtensions +{ + [AspireExportIgnore(Reason = "Stress playground helper; not part of the supported ATS surface.")] + public static IResourceBuilder AddReplicaStateStressResource(this IDistributedApplicationBuilder builder, string name) + { + var scenario = new ReplicaStateStressScenario(); + var resource = new ReplicaStateStressResource(name); + var controls = new ReplicaStateStressControlsResource($"{name}-controls", resource); + + var resourceBuilder = builder.AddResource(resource) + .WithInitialState(ReplicaStateStressScenario.CreateParentInitialSnapshot()) + .ExcludeFromManifest() + .OnInitializeResource((r, @event, cancellationToken) => scenario.InitializeAsync(r, @event.Notifications, cancellationToken)); + + builder.AddResource(controls) + .WithInitialState(ReplicaStateStressScenario.CreateControlsInitialSnapshot()) + .WithCommand("reset", "reset", context => scenario.ResetAsync(resource, controls, context)) + .WithCommand("replica-1-running", "replica-1-running", context => scenario.Replica1RunningAsync(resource, controls, context)) + .WithCommand("replica-2-running-unhealthy", "replica-2-running-unhealthy", context => scenario.Replica2RunningUnhealthyAsync(resource, controls, context)) + .WithCommand("running-wins", "running-wins", context => scenario.RunningWinsAsync(resource, controls, context)) + .WithCommand("replica-1-exited", "replica-1-exited", context => scenario.Replica1ExitedAsync(resource, controls, context)) + .WithCommand("replica-1-exit-code-updated", "replica-1-exit-code-updated", context => scenario.Replica1ExitCodeUpdatedAsync(resource, context)) + .WithCommand("ordinary-child-running", "ordinary-child-running", context => scenario.OrdinaryChildRunningAsync(resource, controls, context)) + .ExcludeFromManifest(); + + return resourceBuilder; + } +} + +sealed class ReplicaStateStressResource(string name) : Resource(name) +{ +} + +sealed class ReplicaStateStressControlsResource(string name, IResource parent) : Resource(name), IResourceWithParent +{ + public IResource Parent { get; } = parent; +} + +sealed class ReplicaStateStressScenario +{ + private const string Source = "Stress playground"; + private const string HealthCheckName = "replica-state"; + private static readonly ResourceStateSnapshot s_scaledToZeroState = new("Scaled to zero", KnownResourceStateStyles.Info); + private static readonly ResourceStateSnapshot s_runningState = new(KnownResourceStates.Running, KnownResourceStateStyles.Success); + private static readonly ResourceStateSnapshot s_startingState = new(KnownResourceStates.Starting, KnownResourceStateStyles.Info); + private static readonly ResourceStateSnapshot s_exitedState = new(KnownResourceStates.Exited, KnownResourceStateStyles.Error); + private static readonly DateTime s_creationTimestamp = new(2026, 8, 17, 20, 23, 36, DateTimeKind.Utc); + private static readonly DateTime s_replica1RunningStartTimestamp = new(2026, 8, 17, 20, 24, 0, DateTimeKind.Utc); + private static readonly DateTime s_sharedReplicaRunningStartTimestamp = new(2026, 8, 17, 20, 24, 30, DateTimeKind.Utc); + private static readonly DateTime s_runningWinsStartTimestamp = new(2026, 8, 17, 20, 25, 0, DateTimeKind.Utc); + private static readonly DateTime s_replica1ExitedStartTimestamp = new(2026, 8, 17, 20, 25, 30, DateTimeKind.Utc); + private static readonly DateTime s_replica1ExitedStopTimestamp = new(2026, 8, 17, 20, 25, 45, DateTimeKind.Utc); + private static readonly DateTime s_replica1ExitCodeUpdatedStopTimestamp = new(2026, 8, 17, 20, 25, 55, DateTimeKind.Utc); + private static readonly DateTime s_controlsRunningStartTimestamp = new(2026, 8, 17, 20, 26, 0, DateTimeKind.Utc); + private readonly SemaphoreSlim _gate = new(1, 1); + + public static CustomResourceSnapshot CreateParentInitialSnapshot() => new() + { + ResourceType = "Replica state stress", + CreationTimeStamp = s_creationTimestamp, + State = s_scaledToZeroState, + Properties = CreateSourceProperties() + }; + + public static CustomResourceSnapshot CreateControlsInitialSnapshot() => new() + { + ResourceType = "Replica state controls", + CreationTimeStamp = s_creationTimestamp, + State = KnownResourceStates.Active, + Properties = CreateSourceProperties() + }; + + public Task InitializeAsync(ReplicaStateStressResource resource, ResourceNotificationService notifications, CancellationToken cancellationToken) => + ExecuteAsync(cancellationToken, () => ResetReplicasAsync(resource, notifications)); + + public Task ResetAsync(ReplicaStateStressResource resource, ReplicaStateStressControlsResource controls, ExecuteCommandContext context) => + ExecuteCommandAsync(context.CancellationToken, async () => + { + await ResetScenarioAsync(resource, controls, context.Services.GetRequiredService()).ConfigureAwait(false); + return CommandResults.Success(); + }); + + public Task Replica1RunningAsync(ReplicaStateStressResource resource, ReplicaStateStressControlsResource controls, ExecuteCommandContext context) => + ExecuteCommandAsync(context.CancellationToken, async () => + { + var notifications = context.Services.GetRequiredService(); + + await ResetScenarioAsync(resource, controls, notifications).ConfigureAwait(false); + await PublishReplicaAsync(notifications, resource, GetReplicaResourceId(resource, 1), snapshot => + snapshot.WithHealthReports(CreateHealthReports(HealthStatus.Healthy)) with + { + Properties = CreateReplicaProperties(resource.Name), + State = s_runningState, + StartTimeStamp = s_replica1RunningStartTimestamp, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + + return CommandResults.Success(); + }); + + public Task Replica2RunningUnhealthyAsync(ReplicaStateStressResource resource, ReplicaStateStressControlsResource controls, ExecuteCommandContext context) => + ExecuteCommandAsync(context.CancellationToken, async () => + { + var notifications = context.Services.GetRequiredService(); + + await ResetScenarioAsync(resource, controls, notifications).ConfigureAwait(false); + await PublishReplicaAsync(notifications, resource, GetReplicaResourceId(resource, 1), snapshot => + snapshot.WithHealthReports(CreateHealthReports(HealthStatus.Healthy)) with + { + Properties = CreateReplicaProperties(resource.Name), + State = s_runningState, + StartTimeStamp = s_sharedReplicaRunningStartTimestamp, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + await PublishReplicaAsync(notifications, resource, GetReplicaResourceId(resource, 2), snapshot => + snapshot.WithHealthReports(CreateHealthReports(HealthStatus.Unhealthy)) with + { + Properties = CreateReplicaProperties(resource.Name), + State = s_runningState, + StartTimeStamp = s_sharedReplicaRunningStartTimestamp, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + + return CommandResults.Success(); + }); + + public Task RunningWinsAsync(ReplicaStateStressResource resource, ReplicaStateStressControlsResource controls, ExecuteCommandContext context) => + ExecuteCommandAsync(context.CancellationToken, async () => + { + var notifications = context.Services.GetRequiredService(); + + await ResetScenarioAsync(resource, controls, notifications).ConfigureAwait(false); + await PublishReplicaAsync(notifications, resource, GetReplicaResourceId(resource, 1), snapshot => + snapshot.WithHealthReports([]) with + { + Properties = CreateReplicaProperties(resource.Name), + State = s_startingState, + StartTimeStamp = s_runningWinsStartTimestamp, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + await PublishReplicaAsync(notifications, resource, GetReplicaResourceId(resource, 2), snapshot => + snapshot.WithHealthReports(CreateHealthReports(HealthStatus.Healthy)) with + { + Properties = CreateReplicaProperties(resource.Name), + State = s_runningState, + StartTimeStamp = s_runningWinsStartTimestamp, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + + return CommandResults.Success(); + }); + + public Task Replica1ExitedAsync(ReplicaStateStressResource resource, ReplicaStateStressControlsResource controls, ExecuteCommandContext context) => + ExecuteCommandAsync(context.CancellationToken, async () => + { + var notifications = context.Services.GetRequiredService(); + + await ResetScenarioAsync(resource, controls, notifications).ConfigureAwait(false); + await PublishReplicaAsync(notifications, resource, GetReplicaResourceId(resource, 1), snapshot => + snapshot.WithHealthReports([]) with + { + Properties = CreateReplicaProperties(resource.Name), + State = s_exitedState, + StartTimeStamp = s_replica1ExitedStartTimestamp, + StopTimeStamp = s_replica1ExitedStopTimestamp, + ExitCode = 137 + }).ConfigureAwait(false); + await PublishReplicaAsync(notifications, resource, GetReplicaResourceId(resource, 2), snapshot => + snapshot.WithHealthReports([]) with + { + Properties = CreateReplicaProperties(resource.Name), + State = null, + StartTimeStamp = null, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + + return CommandResults.Success(); + }); + + public Task Replica1ExitCodeUpdatedAsync(ReplicaStateStressResource resource, ExecuteCommandContext context) => + ExecuteCommandAsync(context.CancellationToken, async () => + { + var notifications = context.Services.GetRequiredService(); + + await PublishReplicaAsync(notifications, resource, GetReplicaResourceId(resource, 1), snapshot => snapshot with + { + StopTimeStamp = s_replica1ExitCodeUpdatedStopTimestamp, + ExitCode = 143 + }).ConfigureAwait(false); + + return CommandResults.Success(); + }); + + public Task OrdinaryChildRunningAsync(ReplicaStateStressResource resource, ReplicaStateStressControlsResource controls, ExecuteCommandContext context) => + ExecuteCommandAsync(context.CancellationToken, async () => + { + var notifications = context.Services.GetRequiredService(); + + await ResetScenarioAsync(resource, controls, notifications).ConfigureAwait(false); + await notifications.PublishUpdateAsync(controls, snapshot => + snapshot.WithHealthReports([]) with + { + State = s_runningState, + StartTimeStamp = s_controlsRunningStartTimestamp, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + + return CommandResults.Success(); + }); + + private Task ExecuteAsync(CancellationToken cancellationToken, Func action) => + ExecuteAsync(cancellationToken, async () => + { + await action().ConfigureAwait(false); + return null; + }); + + private Task ExecuteCommandAsync(CancellationToken cancellationToken, Func> action) => + ExecuteAsync(cancellationToken, action); + + private async Task ExecuteAsync(CancellationToken cancellationToken, Func> action) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await action().ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + private static async Task ResetScenarioAsync(ReplicaStateStressResource resource, ReplicaStateStressControlsResource controls, ResourceNotificationService notifications) + { + await ResetReplicasAsync(resource, notifications).ConfigureAwait(false); + await notifications.PublishUpdateAsync(controls, snapshot => + snapshot.WithHealthReports([]) with + { + State = KnownResourceStates.Active, + StartTimeStamp = null, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + } + + private static async Task ResetReplicasAsync(ReplicaStateStressResource resource, ResourceNotificationService notifications) + { + await PublishParentAsync(notifications, resource, snapshot => + snapshot.WithHealthReports([]) with + { + Properties = CreateSourceProperties(), + State = s_scaledToZeroState, + StartTimeStamp = null, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + await PublishReplicaAsync(notifications, resource, GetReplicaResourceId(resource, 1), snapshot => + snapshot.WithHealthReports([]) with + { + Properties = CreateReplicaProperties(resource.Name), + State = null, + StartTimeStamp = null, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + await PublishReplicaAsync(notifications, resource, GetReplicaResourceId(resource, 2), snapshot => + snapshot.WithHealthReports([]) with + { + Properties = CreateReplicaProperties(resource.Name), + State = null, + StartTimeStamp = null, + StopTimeStamp = null, + ExitCode = null + }).ConfigureAwait(false); + } + + private static Task PublishParentAsync( + ResourceNotificationService notifications, + ReplicaStateStressResource resource, + Func stateFactory) => + notifications.PublishUpdateAsync(resource, resource.Name, stateFactory); + + private static Task PublishReplicaAsync( + ResourceNotificationService notifications, + ReplicaStateStressResource resource, + string resourceId, + Func stateFactory) + { + // Publish multiple resource IDs from the same model resource so the dashboard keeps the + // parent row and both replica rows on one display name while still treating the replicas + // as distinct nested children. + return notifications.PublishUpdateAsync(resource, resourceId, stateFactory); + } + + private static string GetReplicaResourceId(ReplicaStateStressResource resource, int replicaIndex) => + $"{resource.Name}-replica-{replicaIndex}"; + + private static ImmutableArray CreateSourceProperties() => + [ + new(CustomResourceKnownProperties.Source, Source) + ]; + + private static ImmutableArray CreateReplicaProperties(string parentName) => + [ + new(CustomResourceKnownProperties.Source, Source), + new(KnownProperties.Resource.ParentName, parentName) + ]; + + private static ImmutableArray CreateHealthReports(HealthStatus status) => + [ + new(HealthCheckName, status, null, null) + ]; +} diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs index a1ff3fd636d..943b956ca13 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs @@ -2,124 +2,56 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; -using System.Security.Cryptography; -using Aspire.Cli.Utils; -using Semver; namespace Aspire.Cli.Agents.AspireSkills; /// -/// Represents a validated Aspire skills bundle on disk. +/// A validated Aspire skills bundle. /// internal sealed class AspireSkillsBundle { - private const string ManifestFileName = "skill-manifest.json"; - private const string SkillsDirectoryName = "skills"; - private const string SkillFileName = "SKILL.md"; - private const int MaxSkillDescriptionLength = 1024; + private readonly string _version; + private readonly IReadOnlyList _skills; - private readonly DirectoryInfo _bundleDirectory; - private readonly SkillBundleManifest _manifest; - - private AspireSkillsBundle(DirectoryInfo bundleDirectory, SkillBundleManifest manifest) + internal AspireSkillsBundle(string version, IReadOnlyList skills) { - _bundleDirectory = bundleDirectory; - _manifest = manifest; + _version = version; + _skills = skills; } /// - /// Gets the bundle version from the manifest. - /// - public string Version => _manifest.Version ?? string.Empty; - - /// - /// Loads and validates a bundle from disk. + /// Gets the bundle version. /// - public static async Task LoadAsync(DirectoryInfo bundleDirectory, CancellationToken cancellationToken) - { - // physical-binary-version-by-design (see docs/specs/cli-identity-sidecar.md): - // this parameterless convenience overload is only used by tests, which assert against - // the running test-host assembly version. Production paths (AspireSkillsInstaller) flow - // CliExecutionContext.IdentitySdkVersion through the explicit-version overload instead. - return await LoadAsync( - bundleDirectory, - VersionHelper.GetDefaultSdkVersion(), - VersionHelper.GetDefaultSdkVersion(), - skipCompatibilityCheck: false, - cancellationToken).ConfigureAwait(false); - } - - internal static Task LoadAsync( - DirectoryInfo bundleDirectory, - string currentCliVersion, - string currentSdkVersion, - CancellationToken cancellationToken) - { - return LoadAsync(bundleDirectory, currentCliVersion, currentSdkVersion, skipCompatibilityCheck: false, cancellationToken); - } - - internal static async Task LoadAsync( - DirectoryInfo bundleDirectory, - string currentCliVersion, - string currentSdkVersion, - bool skipCompatibilityCheck, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(bundleDirectory); - ArgumentException.ThrowIfNullOrWhiteSpace(currentCliVersion); - ArgumentException.ThrowIfNullOrWhiteSpace(currentSdkVersion); - - var manifestPath = Path.Combine(bundleDirectory.FullName, ManifestFileName); - if (!File.Exists(manifestPath)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle manifest was not found at '{0}'.", manifestPath)); - } - - await using var manifestStream = File.OpenRead(manifestPath); - var manifest = await System.Text.Json.JsonSerializer.DeserializeAsync( - manifestStream, - AspireSkillsJsonSerializerContext.Default.SkillBundleManifest, - cancellationToken).ConfigureAwait(false); - - if (manifest is null) - { - throw new InvalidOperationException("Aspire skills bundle manifest is empty or invalid."); - } - - ValidateManifest(bundleDirectory, manifest, currentCliVersion, currentSdkVersion, skipCompatibilityCheck); - - return new AspireSkillsBundle(bundleDirectory, manifest); - } + public string Version => _version; /// /// Gets installable files for the specified skill. /// - public async Task> GetSkillFilesAsync(SkillDefinition skill, CancellationToken cancellationToken) + public Task> GetSkillFilesAsync(SkillDefinition skill, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(skill); + cancellationToken.ThrowIfCancellationRequested(); - var manifestSkill = _manifest.Skills.FirstOrDefault(s => string.Equals(s.Name, skill.Name, StringComparison.Ordinal)); - if (manifestSkill is null) + var bundledSkill = _skills.FirstOrDefault(s => string.Equals(s.Definition.Name, skill.Name, StringComparison.Ordinal)); + if (bundledSkill is null) { throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle does not contain skill '{0}'.", skill.Name)); } List files = []; - var manifestFiles = manifestSkill.Files - ?? throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' does not contain any files.", skill.Name)); - foreach (var manifestFile in manifestFiles.OrderBy(f => f.RelativePath, StringComparer.Ordinal)) + foreach (var bundledFile in bundledSkill.Files.OrderBy(f => f.RelativePath, StringComparer.Ordinal)) { - var relativePath = NormalizeRelativePath(manifestFile.RelativePath!); - if (!skill.ShouldInstallFile(relativePath) || !ShouldInstallFile(manifestSkill, relativePath)) + var relativePath = bundledFile.RelativePath; + if (!skill.ShouldInstallFile(relativePath) || + !bundledSkill.Definition.ShouldInstallFile(relativePath)) { continue; } - var fullPath = Path.Combine(_bundleDirectory.FullName, SkillsDirectoryName, skill.Name, relativePath); - files.Add(new SkillAssetFile(relativePath, await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false))); + files.Add(bundledFile); } - return files; + return Task.FromResult>(files); } /// @@ -127,362 +59,12 @@ public async Task> GetSkillFilesAsync(SkillDefinit /// public IReadOnlyList GetSkillDefinitions() { - return _manifest.Skills - .Select(static skill => SkillDefinition.CreateAspireSkillsBundle( - skill.Name!, - skill.Description!, - (skill.InstallExcludedRelativePaths ?? []).Select(NormalizeRelativePath).ToArray(), - skill.ApplicableLanguages ?? [])) + return _skills + .Select(static skill => skill.Definition) .ToList(); } - - private static void ValidateManifest( - DirectoryInfo bundleDirectory, - SkillBundleManifest manifest, - string currentCliVersion, - string currentSdkVersion, - bool skipCompatibilityCheck) - { - if (string.IsNullOrWhiteSpace(manifest.Version)) - { - throw new InvalidOperationException("Aspire skills bundle manifest must specify a version."); - } - - // The bundle's `supports` range gates whether a bundle pulled fresh from GitHub - // is allowed at runtime. For bundles we already trust locally — the snapshot - // embedded in the CLI binary, and bundles already written to our own cache — - // we skip the range check because the CLI's effective version may have moved - // past the snapshot's stamped range (e.g., a dogfood build of 13.5.x using a - // bundle whose supports declares ">=13.4.0 <13.5.0"). The bundle's `version` - // field plus the version-keyed cache directory still gate matching content. - if (!skipCompatibilityCheck) - { - ValidateCompatibility(manifest.Supports, currentCliVersion, currentSdkVersion); - } - - var skills = manifest.Skills; - if (skills is not { Length: > 0 }) - { - throw new InvalidOperationException("Aspire skills bundle manifest must contain at least one skill."); - } - - var skillNames = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var skill in skills) - { - if (string.IsNullOrWhiteSpace(skill.Name)) - { - throw new InvalidOperationException("Aspire skills bundle manifest contains a skill without a name."); - } - - if (!skillNames.Add(skill.Name)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle manifest contains duplicate skill '{0}'.", skill.Name)); - } - - if (string.IsNullOrWhiteSpace(skill.Description)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must specify a description.", skill.Name)); - } - - if (skill.Files is not { Length: > 0 }) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' does not contain any files.", skill.Name)); - } - - foreach (var excludedPath in skill.InstallExcludedRelativePaths ?? []) - { - _ = NormalizeRelativePath(excludedPath); - } - - foreach (var file in skill.Files) - { - ValidateFile(bundleDirectory, skill.Name, file); - } - } - } - - private static void ValidateFile(DirectoryInfo bundleDirectory, string skillName, SkillBundleFile file) - { - var relativePath = NormalizeRelativePath(file.RelativePath); - - var fullPath = Path.Combine(bundleDirectory.FullName, SkillsDirectoryName, skillName, relativePath); - if (!File.Exists(fullPath)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' was not found.", relativePath, skillName)); - } - - // Prefer SHA-512, which current microsoft/aspire-skills builds emit. SHA-256 remains accepted for - // bundles published before the switch — notably the attestation-verified v0.0.1 snapshot embedded in - // the CLI, whose exact bytes (and therefore its SHA-256 per-file manifest) cannot change without - // invalidating its published attestation. Once a signed SHA-512 release is re-embedded, that bundle - // carries `sha512` instead and this fallback is no longer exercised. - if (!string.IsNullOrWhiteSpace(file.Sha512)) - { - VerifyFileHash(fullPath, skillName, relativePath, NormalizeSha512(file.Sha512), static stream => SHA512.HashData(stream), "SHA-512"); - } - else if (!string.IsNullOrWhiteSpace(file.Sha256)) - { - VerifyFileHash(fullPath, skillName, relativePath, NormalizeSha256(file.Sha256), static stream => SHA256.HashData(stream), "SHA-256"); - } - else - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' does not specify a SHA-512 or SHA-256 hash.", relativePath, skillName)); - } - - if (string.Equals(relativePath, SkillFileName, StringComparison.Ordinal)) - { - ValidateSkillFileFrontmatter(skillName, fullPath); - } - } - - private static void VerifyFileHash(string fullPath, string skillName, string relativePath, string expectedHash, Func computeHash, string algorithmName) - { - string actualHash; - using (var stream = File.OpenRead(fullPath)) - { - actualHash = Convert.ToHexString(computeHash(stream)).ToLowerInvariant(); - } - - if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' failed {2} verification.", relativePath, skillName, algorithmName)); - } - } - - private static void ValidateSkillFileFrontmatter(string skillName, string skillFilePath) - { - var content = File.ReadAllText(skillFilePath); - var description = GetFrontmatterValue(content, "description"); - if (string.IsNullOrWhiteSpace(description)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must define a frontmatter description in SKILL.md.", skillName)); - } - - if (description.Length > MaxSkillDescriptionLength) - { - throw new InvalidOperationException(string.Format( - CultureInfo.InvariantCulture, - "Aspire skills bundle skill '{0}' SKILL.md description is {1} characters; agent hosts accept at most {2}.", - skillName, - description.Length, - MaxSkillDescriptionLength)); - } - } - - private static string? GetFrontmatterValue(string content, string key) - { - var normalizedContent = content.ReplaceLineEndings("\n"); - if (!normalizedContent.StartsWith("---\n", StringComparison.Ordinal)) - { - return null; - } - - var frontmatterEndIndex = normalizedContent.IndexOf("\n---\n", 4, StringComparison.Ordinal); - if (frontmatterEndIndex < 0) - { - return null; - } - - // Skill files use simple YAML frontmatter: - // --- - // name: aspire - // description: "Use when working with an Aspire distributed application" - // --- - // The agent hosts read this field directly and reject descriptions longer - // than 1024 characters, so validate the bundled SKILL.md before caching it. - var frontmatter = normalizedContent[4..frontmatterEndIndex]; - var keyPrefix = $"{key}:"; - foreach (var line in frontmatter.Split('\n')) - { - if (!line.StartsWith(keyPrefix, StringComparison.Ordinal)) - { - continue; - } - - var value = line[keyPrefix.Length..].Trim(); - return value.Length >= 2 && - ((value[0] == '"' && value[^1] == '"') || (value[0] == '\'' && value[^1] == '\'')) - ? value[1..^1] - : value; - } - - return null; - } - - private static bool ShouldInstallFile(SkillBundleSkill skill, string relativePath) - { - foreach (var excludedPath in skill.InstallExcludedRelativePaths ?? []) - { - var normalizedExcludedPath = NormalizeRelativePath(excludedPath); - if (PathMatchesOrIsUnder(relativePath, normalizedExcludedPath)) - { - return false; - } - } - - return true; - } - - private static bool PathMatchesOrIsUnder(string relativePath, string excludedPath) - { - if (string.Equals(relativePath, excludedPath, StringComparison.Ordinal)) - { - return true; - } - - if (!relativePath.StartsWith(excludedPath, StringComparison.Ordinal)) - { - return false; - } - - return relativePath.Length > excludedPath.Length && relativePath[excludedPath.Length] == Path.DirectorySeparatorChar; - } - - internal static string NormalizeRelativePath(string? relativePath) - { - if (string.IsNullOrWhiteSpace(relativePath)) - { - throw new InvalidOperationException("Aspire skills bundle contains an empty relative path."); - } - - var normalizedPath = relativePath - .Replace('/', Path.DirectorySeparatorChar) - .Replace('\\', Path.DirectorySeparatorChar); - - if (Path.IsPathRooted(normalizedPath)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle path '{0}' must be relative.", relativePath)); - } - - var segments = normalizedPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0 || segments.Any(segment => segment is "." or "..")) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle path '{0}' is not safe.", relativePath)); - } - - return Path.Combine(segments); - } - - internal static string NormalizeSha512(string sha512) - { - const string prefix = "sha512-"; - return sha512.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - ? sha512[prefix.Length..] - : sha512; - } - - internal static string NormalizeSha256(string sha256) - { - const string prefix = "sha256-"; - return sha256.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - ? sha256[prefix.Length..] - : sha256; - } - - private static void ValidateCompatibility(SkillBundleSupports? supports, string currentCliVersion, string currentSdkVersion) - { - if (supports is null) - { - throw new InvalidOperationException("Aspire skills bundle manifest must specify supported Aspire versions."); - } - - if (string.IsNullOrWhiteSpace(supports.AspireCli)) - { - throw new InvalidOperationException("Aspire skills bundle manifest must specify supports.aspireCli."); - } - - if (!IsVersionInRange(currentCliVersion, supports.AspireCli)) - { - throw new InvalidOperationException(string.Format( - CultureInfo.InvariantCulture, - "Aspire skills bundle supports Aspire CLI versions '{0}', but the current CLI version is '{1}'.", - supports.AspireCli, - currentCliVersion)); - } - - if (!string.IsNullOrWhiteSpace(supports.AspireSdk) && - !IsVersionInRange(currentSdkVersion, supports.AspireSdk)) - { - throw new InvalidOperationException(string.Format( - CultureInfo.InvariantCulture, - "Aspire skills bundle supports Aspire SDK versions '{0}', but the current SDK version is '{1}'.", - supports.AspireSdk, - currentSdkVersion)); - } - } - - private static bool IsVersionInRange(string version, string range) - { - var normalizedVersion = ParseCompatibilityVersion(version); - var comparators = range.Replace(',', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (comparators.Length == 0) - { - throw new InvalidOperationException("Aspire skills bundle contains an empty version range."); - } - - foreach (var comparator in comparators) - { - if (comparator is "*" or "x" or "X") - { - continue; - } - - if (!SatisfiesComparator(normalizedVersion, comparator)) - { - return false; - } - } - - return true; - } - - private static bool SatisfiesComparator(SemVersion version, string comparator) - { - var (op, operandText) = ParseComparator(comparator); - var operand = ParseCompatibilityVersion(operandText); - var comparison = SemVersion.ComparePrecedence(version, operand); - - return op switch - { - ">" => comparison > 0, - ">=" => comparison >= 0, - "<" => comparison < 0, - "<=" => comparison <= 0, - "=" or "==" => comparison == 0, - _ => throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Unsupported Aspire skills bundle version comparator '{0}'.", op)) - }; - } - - private static (string Operator, string Operand) ParseComparator(string comparator) - { - foreach (var op in new[] { ">=", "<=", "==", ">", "<", "=" }) - { - if (comparator.StartsWith(op, StringComparison.Ordinal)) - { - var operand = comparator[op.Length..]; - if (string.IsNullOrWhiteSpace(operand)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle contains an invalid version comparator '{0}'.", comparator)); - } - - return (op, operand); - } - } - - return ("=", comparator); - } - - private static SemVersion ParseCompatibilityVersion(string version) - { - if (!SemVersion.TryParse(version, SemVersionStyles.Any, out var parsedVersion)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle contains an invalid version value '{0}'.", version)); - } - - return SemVersion.Parse( - string.Create( - CultureInfo.InvariantCulture, - $"{parsedVersion.Major}.{parsedVersion.Minor}.{parsedVersion.Patch}"), - SemVersionStyles.Strict); - } } + +internal sealed record ValidatedAspireSkill( + SkillDefinition Definition, + IReadOnlyList Files); diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundleProvider.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundleProvider.cs new file mode 100644 index 00000000000..b37294fa6c7 --- /dev/null +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundleProvider.cs @@ -0,0 +1,732 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Formats.Tar; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Aspire.Cli.Resources; +using Aspire.Cli.Utils; +using Semver; + +namespace Aspire.Cli.Agents.AspireSkills; + +/// +/// Creates and loads validated Aspire skills bundles. +/// +internal interface IAspireSkillsBundleProvider +{ + /// + /// Creates an Aspire skills bundle from an archive and materializes its validated files + /// in a dedicated staging directory. + /// + Task CreateAsync( + FileInfo archive, + DirectoryInfo bundleDirectory, + string expectedArchiveSha512, + CancellationToken cancellationToken, + bool skipCompatibilityCheck = false); + + /// + /// Loads an Aspire skills bundle from disk. + /// + Task LoadAsync(DirectoryInfo bundleDirectory, CancellationToken cancellationToken, bool skipCompatibilityCheck = false); +} + +internal sealed class AspireSkillsBundleProvider : IAspireSkillsBundleProvider +{ + private const string ManifestFileName = "skill-manifest.json"; + private const string SkillsDirectoryName = "skills"; + private const string SkillFileName = "SKILL.md"; + private const int MaxSkillNameLength = 64; + private const int MaxSkillDescriptionLength = 1024; + + private readonly string _currentCliVersion; + private readonly string _currentSdkVersion; + + public AspireSkillsBundleProvider(CliExecutionContext executionContext) + { + ArgumentNullException.ThrowIfNull(executionContext); + + // The CLI and SDK share one resolved identity version. IdentitySdkVersion removes + // build metadata so both values can be compared with manifest SemVer ranges. + _currentCliVersion = executionContext.IdentitySdkVersion; + _currentSdkVersion = executionContext.IdentitySdkVersion; + } + + internal AspireSkillsBundleProvider() + : this(VersionHelper.GetDefaultSdkVersion(), VersionHelper.GetDefaultSdkVersion()) + { + // physical-binary-version-by-design (see docs/specs/cli-identity-sidecar.md): + // this convenience constructor is only used by tests. Production resolves the + // effective CLI identity through CliExecutionContext. + } + + internal AspireSkillsBundleProvider(string currentCliVersion, string currentSdkVersion) + { + ArgumentException.ThrowIfNullOrWhiteSpace(currentCliVersion); + ArgumentException.ThrowIfNullOrWhiteSpace(currentSdkVersion); + + _currentCliVersion = currentCliVersion; + _currentSdkVersion = currentSdkVersion; + } + + public async Task CreateAsync( + FileInfo archive, + DirectoryInfo bundleDirectory, + string expectedArchiveSha512, + CancellationToken cancellationToken, + bool skipCompatibilityCheck = false) + { + ArgumentNullException.ThrowIfNull(archive); + ArgumentNullException.ThrowIfNull(bundleDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedArchiveSha512); + + cancellationToken.ThrowIfCancellationRequested(); + ValidateArchiveSha512(archive.FullName, expectedArchiveSha512); + + Directory.CreateDirectory(bundleDirectory.FullName); + var temporaryDirectoryRoot = bundleDirectory.Parent + ?? throw new InvalidOperationException("The Aspire skills bundle staging directory must have a parent directory."); + // Keep extraction beside the staging directory rather than inside it. If Windows AV or + // indexing holds an extracted file open, best-effort cleanup must not block the later + // atomic move that publishes the validated staging directory. + using var extractionDirectory = TemporaryCacheDirectory.Create( + temporaryDirectoryRoot.FullName, + "extract", + path => FileDeleteHelper.TryDeleteDirectory(path), + path => FileDeleteHelper.TryDeleteFile(path)); + + ExtractArchive(archive.FullName, extractionDirectory.FullName); + cancellationToken.ThrowIfCancellationRequested(); + + var bundleRoot = FindBundleRoot(extractionDirectory.FullName); + var bundle = await LoadAsync(bundleRoot, cancellationToken, skipCompatibilityCheck).ConfigureAwait(false); + + CopyDirectory(bundleRoot.FullName, bundleDirectory.FullName); + return bundle; + } + + public async Task LoadAsync( + DirectoryInfo bundleDirectory, + CancellationToken cancellationToken, + bool skipCompatibilityCheck = false) + { + ArgumentNullException.ThrowIfNull(bundleDirectory); + + var manifestPath = Path.Combine(bundleDirectory.FullName, ManifestFileName); + if (!File.Exists(manifestPath)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle manifest was not found at '{0}'.", manifestPath)); + } + + SkillBundleManifest? manifest; + try + { + await using var manifestStream = File.OpenRead(manifestPath); + manifest = await JsonSerializer.DeserializeAsync( + manifestStream, + AspireSkillsJsonSerializerContext.Default.SkillBundleManifest, + cancellationToken).ConfigureAwait(false); + } + catch (JsonException ex) + { + throw new InvalidOperationException("Aspire skills bundle manifest is invalid.", ex); + } + + if (manifest is null) + { + throw new InvalidOperationException("Aspire skills bundle manifest is empty or invalid."); + } + + cancellationToken.ThrowIfCancellationRequested(); + return CreateBundle(bundleDirectory, manifest, _currentCliVersion, _currentSdkVersion, skipCompatibilityCheck); + } + + private static AspireSkillsBundle CreateBundle( + DirectoryInfo bundleDirectory, + SkillBundleManifest manifest, + string currentCliVersion, + string currentSdkVersion, + bool skipCompatibilityCheck) + { + var version = manifest.Version; + if (string.IsNullOrWhiteSpace(version)) + { + throw new InvalidOperationException("Aspire skills bundle manifest must specify a version."); + } + + // The bundle's `supports` range gates remotely acquired bundles, including cache + // entries that another CLI version may have written. The exact snapshot embedded + // in the current CLI may skip this check because its stamped range can lag the + // binary version (e.g., a dogfood build of 13.5.x using a snapshot stamped + // ">=13.4.0 <13.5.0"). + if (!skipCompatibilityCheck) + { + ValidateCompatibility(manifest.Supports, currentCliVersion, currentSdkVersion); + } + + var skills = manifest.Skills; + if (skills is not { Length: > 0 }) + { + throw new InvalidOperationException("Aspire skills bundle manifest must contain at least one skill."); + } + + var skillNames = new HashSet(StringComparer.OrdinalIgnoreCase); + List validatedSkills = []; + foreach (var skill in skills) + { + if (skill is null) + { + throw new InvalidOperationException("Aspire skills bundle manifest contains an empty skill entry."); + } + + var skillName = skill.Name; + if (string.IsNullOrWhiteSpace(skillName)) + { + throw new InvalidOperationException("Aspire skills bundle manifest contains a skill without a name."); + } + + ValidateSkillName(skillName); + if (!skillNames.Add(skillName)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle manifest contains duplicate skill '{0}'.", skillName)); + } + + if (string.IsNullOrWhiteSpace(skill.Description)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must specify a description.", skillName)); + } + + var skillFiles = skill.Files; + if (skillFiles is not { Length: > 0 }) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' does not contain any files.", skillName)); + } + + var installExcludedRelativePaths = (skill.InstallExcludedRelativePaths ?? []) + .Select(NormalizeRelativePath) + .ToArray(); + if (installExcludedRelativePaths.Contains(SkillFileName, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' cannot exclude SKILL.md from installation.", skillName)); + } + + var definition = SkillDefinition.CreateAspireSkillsBundle( + skillName, + skill.Description, + installExcludedRelativePaths, + skill.ApplicableLanguages ?? []); + + var filePaths = new HashSet(StringComparer.OrdinalIgnoreCase); + var hasSkillFile = false; + List files = []; + foreach (var file in skillFiles) + { + if (file is null) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' contains an empty file entry.", skillName)); + } + + var validatedFile = ValidateFile(bundleDirectory, skillName, file); + if (!filePaths.Add(validatedFile.RelativePath)) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Aspire skills bundle skill '{0}' contains duplicate file '{1}'.", + skillName, + validatedFile.RelativePath)); + } + + files.Add(validatedFile); + hasSkillFile |= string.Equals(validatedFile.RelativePath, SkillFileName, StringComparison.Ordinal); + } + + if (!hasSkillFile) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must contain SKILL.md.", skillName)); + } + + validatedSkills.Add(new ValidatedAspireSkill(definition, files)); + } + + return new AspireSkillsBundle(version, validatedSkills); + } + + private static void ValidateSkillName(string skillName) + { + // Agent hosts use this grammar to discover skills consistently. + // See https://agentskills.io/specification. + if (skillName.Length > MaxSkillNameLength || + skillName[0] == '-' || + skillName[^1] == '-' || + skillName.Contains("--", StringComparison.Ordinal) || + skillName.Any(static character => !char.IsAsciiLetterLower(character) && !char.IsAsciiDigit(character) && character is not '-')) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Aspire skills bundle skill name '{0}' must be 1-{1} characters, use only lowercase ASCII letters, digits, and hyphens, and must not start or end with a hyphen or contain consecutive hyphens.", + skillName, + MaxSkillNameLength)); + } + } + + private static SkillAssetFile ValidateFile(DirectoryInfo bundleDirectory, string skillName, SkillBundleFile file) + { + var relativePath = NormalizeRelativePath(file.RelativePath); + var fullPath = Path.Combine(bundleDirectory.FullName, SkillsDirectoryName, skillName, relativePath); + if (!File.Exists(fullPath)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' was not found.", relativePath, skillName)); + } + + // Hash and decode the same bytes so a concurrent filesystem change cannot + // produce validated content that differs from the content retained in memory. + var bytes = File.ReadAllBytes(fullPath); + string expectedHash; + string actualHash; + string algorithmName; + // The attestation-verified v0.0.1 archive predates the SHA-512 switch and cannot + // be rebuilt without changing its signed subject digest. Prefer SHA-512 for current + // bundles while continuing to validate that embedded archive's per-file SHA-256 hashes. + if (!string.IsNullOrWhiteSpace(file.Sha512)) + { + expectedHash = NormalizeSha512(file.Sha512); + actualHash = Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant(); + algorithmName = "SHA-512"; + } + else if (!string.IsNullOrWhiteSpace(file.Sha256)) + { + expectedHash = NormalizeSha256(file.Sha256); + actualHash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + algorithmName = "SHA-256"; + } + else + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' does not specify a SHA-512 or SHA-256 hash.", relativePath, skillName)); + } + + if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' failed {2} verification.", relativePath, skillName, algorithmName)); + } + + using var stream = new MemoryStream(bytes, writable: false); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + var content = reader.ReadToEnd(); + if (string.Equals(relativePath, SkillFileName, StringComparison.Ordinal)) + { + ValidateSkillFileFrontmatter(skillName, content); + } + + return new SkillAssetFile(relativePath, content); + } + + private static void ValidateSkillFileFrontmatter(string skillName, string content) + { + var frontmatterName = GetFrontmatterValue(content, "name"); + if (string.IsNullOrWhiteSpace(frontmatterName)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must define a frontmatter name in SKILL.md.", skillName)); + } + + if (!string.Equals(frontmatterName, skillName, StringComparison.Ordinal)) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Aspire skills bundle skill '{0}' SKILL.md frontmatter name '{1}' must match its manifest and directory name.", + skillName, + frontmatterName)); + } + + var description = GetFrontmatterValue(content, "description"); + if (string.IsNullOrWhiteSpace(description)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle skill '{0}' must define a frontmatter description in SKILL.md.", skillName)); + } + + if (description.Length > MaxSkillDescriptionLength) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Aspire skills bundle skill '{0}' SKILL.md description is {1} characters; agent hosts accept at most {2}.", + skillName, + description.Length, + MaxSkillDescriptionLength)); + } + } + + private static string? GetFrontmatterValue(string content, string key) + { + var normalizedContent = content.ReplaceLineEndings("\n"); + if (!normalizedContent.StartsWith("---\n", StringComparison.Ordinal)) + { + return null; + } + + var frontmatterEndIndex = normalizedContent.IndexOf("\n---\n", 4, StringComparison.Ordinal); + if (frontmatterEndIndex < 0) + { + return null; + } + + // Skill files use simple YAML frontmatter: + // --- + // name: aspire + // description: "Use when working with an Aspire distributed application" + // --- + // Agent hosts read these fields directly, so validate the bundled SKILL.md + // before caching content that they would reject or ignore. + var frontmatter = normalizedContent[4..frontmatterEndIndex]; + var keyPrefix = $"{key}:"; + foreach (var line in frontmatter.Split('\n')) + { + if (!line.StartsWith(keyPrefix, StringComparison.Ordinal)) + { + continue; + } + + var value = line[keyPrefix.Length..].Trim(); + return value.Length >= 2 && + ((value[0] == '"' && value[^1] == '"') || (value[0] == '\'' && value[^1] == '\'')) + ? value[1..^1] + : value; + } + + return null; + } + + internal static string NormalizeRelativePath(string? relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + throw new InvalidOperationException("Aspire skills bundle contains an empty relative path."); + } + + var normalizedPath = relativePath + .Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar); + + if (Path.IsPathRooted(normalizedPath)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle path '{0}' must be relative.", relativePath)); + } + + var segments = normalizedPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0 || segments.Any(static segment => !IsPortablePathSegment(segment))) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle path '{0}' is not safe.", relativePath)); + } + + return Path.Combine(segments); + } + + private static bool IsPortablePathSegment(string segment) + { + // Bundle paths can be validated on one platform and installed on another. Reject the + // Windows-invalid character set everywhere so ':' cannot create an NTFS alternate data + // stream and other invalid filenames cannot enter a cached bundle. + // See https://learn.microsoft.com/windows/win32/fileio/naming-a-file. + return segment is not "." and not ".." && + !segment.Any(static character => char.IsControl(character) || character is '<' or '>' or ':' or '"' or '|' or '?' or '*'); + } + + internal static string NormalizeSha512(string sha512) + { + return sha512.StartsWith("sha512-", StringComparison.OrdinalIgnoreCase) || + sha512.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase) + ? sha512[7..] + : sha512; + } + + internal static string NormalizeSha256(string sha256) + { + return sha256.StartsWith("sha256-", StringComparison.OrdinalIgnoreCase) || + sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) + ? sha256[7..] + : sha256; + } + + private static void ValidateArchiveSha512(string archivePath, string expectedSha512) + { + var expectedHash = NormalizeSha512(expectedSha512); + using var stream = File.OpenRead(archivePath); + var actualHash = Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); + + if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(string.Format( + CultureInfo.CurrentCulture, + AgentCommandStrings.AspireSkillsInstaller_ArchiveHashVerificationFailed, + expectedHash, + actualHash)); + } + } + + private static void ExtractArchive(string archivePath, string destinationDirectory) + { + if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + ExtractZipArchive(archivePath, destinationDirectory); + return; + } + + ExtractTarball(archivePath, destinationDirectory); + } + + private static void ExtractTarball(string tarballPath, string destinationDirectory) + { + var destinationRoot = Path.GetFullPath(destinationDirectory); + Directory.CreateDirectory(destinationRoot); + + using var fileStream = File.OpenRead(tarballPath); + using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); + using var tarReader = new TarReader(gzipStream); + + while (tarReader.GetNextEntry() is { } entry) + { + if (string.IsNullOrWhiteSpace(entry.Name)) + { + continue; + } + + var destinationPath = GetSafeArchiveDestinationPath(destinationRoot, entry.Name); + + switch (entry.EntryType) + { + case TarEntryType.Directory: + Directory.CreateDirectory(destinationPath); + break; + + case TarEntryType.RegularFile: + case TarEntryType.V7RegularFile: + var destinationFileDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationFileDirectory)) + { + Directory.CreateDirectory(destinationFileDirectory); + } + + entry.ExtractToFile(destinationPath, overwrite: false); + break; + + case TarEntryType.GlobalExtendedAttributes: + case TarEntryType.ExtendedAttributes: + break; + + default: + throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, "Aspire skills archive entry '{0}' has unsupported type '{1}'.", entry.Name, entry.EntryType)); + } + } + } + + private static void ExtractZipArchive(string archivePath, string destinationDirectory) + { + var destinationRoot = Path.GetFullPath(destinationDirectory); + Directory.CreateDirectory(destinationRoot); + + using var archive = ZipFile.OpenRead(archivePath); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrWhiteSpace(entry.FullName)) + { + continue; + } + + var destinationPath = GetSafeArchiveDestinationPath(destinationRoot, entry.FullName); + if (entry.FullName.EndsWith("/", StringComparison.Ordinal) || entry.FullName.EndsWith("\\", StringComparison.Ordinal)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + var destinationFileDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationFileDirectory)) + { + Directory.CreateDirectory(destinationFileDirectory); + } + + entry.ExtractToFile(destinationPath, overwrite: false); + } + } + + private static string GetSafeArchiveDestinationPath(string destinationRoot, string entryName) + { + var normalizedEntryName = entryName.Replace('\\', '/'); + var segments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (Path.IsPathRooted(normalizedEntryName) || + segments.Length == 0 || + segments.Any(static segment => !IsPortablePathSegment(segment))) + { + throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, "Aspire skills archive entry '{0}' is not safe.", entryName)); + } + + var destinationPath = Path.GetFullPath(Path.Combine(destinationRoot, normalizedEntryName.Replace('/', Path.DirectorySeparatorChar))); + if (!destinationPath.StartsWith(destinationRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !string.Equals(destinationPath, destinationRoot, StringComparison.Ordinal)) + { + throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, "Aspire skills archive entry '{0}' escapes the extraction directory.", entryName)); + } + + return destinationPath; + } + + private static DirectoryInfo FindBundleRoot(string extractionDirectory) + { + var rootManifestPath = Path.Combine(extractionDirectory, ManifestFileName); + if (File.Exists(rootManifestPath)) + { + return new DirectoryInfo(extractionDirectory); + } + + var packageDirectory = Path.Combine(extractionDirectory, "package"); + var packageManifestPath = Path.Combine(packageDirectory, ManifestFileName); + if (File.Exists(packageManifestPath)) + { + return new DirectoryInfo(packageDirectory); + } + + var topLevelBundleDirectories = Directory + .EnumerateDirectories(extractionDirectory) + .Where(directory => File.Exists(Path.Combine(directory, ManifestFileName))) + .ToArray(); + + if (topLevelBundleDirectories.Length == 1) + { + return new DirectoryInfo(topLevelBundleDirectories[0]); + } + + if (topLevelBundleDirectories.Length > 1) + { + throw new InvalidOperationException("Downloaded Aspire skills package contains multiple skill-manifest.json files."); + } + + throw new InvalidOperationException("Downloaded Aspire skills package does not contain skill-manifest.json."); + } + + private static void CopyDirectory(string sourceDirectory, string targetDirectory) + { + Directory.CreateDirectory(targetDirectory); + + foreach (var sourceFile in Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDirectory, sourceFile); + var targetFile = Path.Combine(targetDirectory, relativePath); + var targetFileDirectory = Path.GetDirectoryName(targetFile); + if (!string.IsNullOrEmpty(targetFileDirectory)) + { + Directory.CreateDirectory(targetFileDirectory); + } + + File.Copy(sourceFile, targetFile, overwrite: true); + } + } + + private static void ValidateCompatibility(SkillBundleSupports? supports, string currentCliVersion, string currentSdkVersion) + { + if (supports is null) + { + throw new InvalidOperationException("Aspire skills bundle manifest must specify supported Aspire versions."); + } + + if (string.IsNullOrWhiteSpace(supports.AspireCli)) + { + throw new InvalidOperationException("Aspire skills bundle manifest must specify supports.aspireCli."); + } + + if (!IsVersionInRange(currentCliVersion, supports.AspireCli)) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Aspire skills bundle supports Aspire CLI versions '{0}', but the current CLI version is '{1}'.", + supports.AspireCli, + currentCliVersion)); + } + + if (!string.IsNullOrWhiteSpace(supports.AspireSdk) && + !IsVersionInRange(currentSdkVersion, supports.AspireSdk)) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Aspire skills bundle supports Aspire SDK versions '{0}', but the current SDK version is '{1}'.", + supports.AspireSdk, + currentSdkVersion)); + } + } + + private static bool IsVersionInRange(string version, string range) + { + var normalizedVersion = ParseCompatibilityVersion(version); + var comparators = range.Replace(',', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (comparators.Length == 0) + { + throw new InvalidOperationException("Aspire skills bundle contains an empty version range."); + } + + foreach (var comparator in comparators) + { + if (comparator is "*" or "x" or "X") + { + continue; + } + + if (!SatisfiesComparator(normalizedVersion, comparator)) + { + return false; + } + } + + return true; + } + + private static bool SatisfiesComparator(SemVersion version, string comparator) + { + var (op, operandText) = ParseComparator(comparator); + var operand = ParseCompatibilityVersion(operandText); + var comparison = SemVersion.ComparePrecedence(version, operand); + + return op switch + { + ">" => comparison > 0, + ">=" => comparison >= 0, + "<" => comparison < 0, + "<=" => comparison <= 0, + "=" or "==" => comparison == 0, + _ => throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Unsupported Aspire skills bundle version comparator '{0}'.", op)) + }; + } + + private static (string Operator, string Operand) ParseComparator(string comparator) + { + foreach (var op in new[] { ">=", "<=", "==", ">", "<", "=" }) + { + if (comparator.StartsWith(op, StringComparison.Ordinal)) + { + var operand = comparator[op.Length..]; + if (string.IsNullOrWhiteSpace(operand)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle contains an invalid version comparator '{0}'.", comparator)); + } + + return (op, operand); + } + } + + return ("=", comparator); + } + + private static SemVersion ParseCompatibilityVersion(string version) + { + if (!SemVersion.TryParse(version, SemVersionStyles.Any, out var parsedVersion)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle contains an invalid version value '{0}'.", version)); + } + + return SemVersion.Parse( + string.Create( + CultureInfo.InvariantCulture, + $"{parsedVersion.Major}.{parsedVersion.Minor}.{parsedVersion.Patch}"), + SemVersionStyles.Strict); + } +} diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs index 1f1d64d7246..c6749860f24 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs @@ -2,9 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Formats.Tar; using System.Globalization; -using System.IO.Compression; using System.Net; using System.Security.Cryptography; using System.Text.Json; @@ -23,6 +21,7 @@ namespace Aspire.Cli.Agents.AspireSkills; internal sealed class AspireSkillsInstaller( IGitHubArtifactAttestationVerifier githubArtifactAttestationVerifier, IHttpClientFactory httpClientFactory, + IAspireSkillsBundleProvider bundleProvider, IEmbeddedAspireSkillsBundleProvider embeddedBundleProvider, IInteractionService interactionService, CliExecutionContext executionContext, @@ -41,7 +40,19 @@ internal sealed class AspireSkillsInstaller( internal const string MaxCacheAgeKey = "AspireSkillsMaxCacheAgeSeconds"; private const string GitHubApiBaseUrl = "https://api.github.com"; - + internal const string ArchiveSha512FileName = ".archive-sha512"; + internal const string GitHubArchiveSha256FileName = ".github-archive-sha256"; + internal const string GitHubAttestationVerifiedFileName = ".github-attestation-verified"; + private const string LastUsedFileName = ".lastused"; + + private const int CacheLockMaxAttempts = 4; + private const int WindowsSharingViolationHResult = unchecked((int)0x80070020); + private const int WindowsLockViolationHResult = unchecked((int)0x80070021); + private const int LinuxWouldBlockHResult = 11; + private const int MacOsWouldBlockHResult = 35; + + private static readonly TimeSpan s_cacheLockInitialRetryDelay = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan s_cacheLockMaxRetryDelay = TimeSpan.FromMilliseconds(400); private static readonly TimeSpan s_defaultMaxCacheAge = TimeSpan.FromDays(7); public Task InstallAsync(CancellationToken cancellationToken) @@ -50,6 +61,7 @@ public Task InstallAsync(CancellationToken cancellati AgentCommandStrings.AspireSkillsInstaller_InstallingStatus, () => InstallCoreAsync(cancellationToken)); } + private async Task InstallCoreAsync(CancellationToken cancellationToken) { using var activity = telemetry.StartReportedActivity("AspireSkillsInstaller.Install"); @@ -65,15 +77,19 @@ private async Task InstallCoreAsync(CancellationToken var cacheRoot = GetCacheRoot(); Directory.CreateDirectory(cacheRoot); - var cachedBundle = await TryLoadCachedBundleAsync(cacheRoot, effectiveVersion, activity, cancellationToken).ConfigureAwait(false); - if (cachedBundle is not null) + var validationDisabled = string.Equals(configuration[DisablePackageValidationKey], "true", StringComparison.OrdinalIgnoreCase); + var embeddedMetadata = embeddedBundleProvider.Metadata; + + async Task CompleteInstallationAsync(AspireSkillsBundle bundle, string archiveSha512) { - CleanupStaleCacheEntries(cacheRoot, effectiveVersion); - return AspireSkillsInstallResult.Installed(cachedBundle); + await CleanupStaleCacheEntriesAsync( + cacheRoot, + effectiveVersion, + archiveSha512, + cancellationToken).ConfigureAwait(false); + return AspireSkillsInstallResult.Installed(bundle); } - var validationDisabled = string.Equals(configuration[DisablePackageValidationKey], "true", StringComparison.OrdinalIgnoreCase); - // The remote fetch path is opt-in. Ship 13.4 with this disabled so users only // get the embedded snapshot (no unattended network call out to GitHub on every // `aspire agent init`). Toggle the feature on to opt in to the GitHub release path, @@ -89,25 +105,48 @@ private async Task InstallCoreAsync(CancellationToken githubResult = await InstallFromGitHubAsync(cacheRoot, effectiveVersion, validationDisabled, activity, cancellationToken).ConfigureAwait(false); if (githubResult.Status == AcquisitionStatus.Installed) { - CleanupStaleCacheEntries(cacheRoot, effectiveVersion); - return AspireSkillsInstallResult.Installed(githubResult.Bundle!); + return await CompleteInstallationAsync(githubResult.Bundle!, githubResult.ArchiveSha512!).ConfigureAwait(false); } if (githubResult.Status == AcquisitionStatus.Failed) { logger.LogDebug("Aspire skills GitHub acquisition failed for version {Version}; falling back to embedded snapshot. Failure: {Failure}", effectiveVersion, githubResult.Message); } + else if (!githubResult.GitHubReleaseMetadataAvailable || + githubResult.KnownGitHubArchiveSha256 is not null) + { + // A digest from current release metadata can select an exact cache leaf. Only + // use an unpinned verified leaf when release metadata itself was unavailable; + // otherwise an unidentifiable current asset could revive stale same-version content. + var offlineCachedResult = await TryLoadCachedBundleAsync( + cacheRoot, + effectiveVersion, + expectedArchiveSha512: null, + expectedGitHubArchiveSha256: githubResult.KnownGitHubArchiveSha256, + requireVerifiedGitHubSource: true, + skipCompatibilityCheck: false, + activity, + cancellationToken).ConfigureAwait(false); + if (offlineCachedResult is not null) + { + logger.LogDebug( + "Using a previously verified GitHub Aspire skills bundle for version {Version} because GitHub is unavailable.", + effectiveVersion); + return await CompleteInstallationAsync( + offlineCachedResult.Bundle!, + offlineCachedResult.ArchiveSha512!).ConfigureAwait(false); + } + } } else { logger.LogDebug("Aspire skills remote fetch feature '{Feature}' is disabled; using the embedded snapshot.", KnownFeatures.AspireSkillsRemoteFetchEnabled); } - var embeddedResult = await InstallFromEmbeddedAsync(cacheRoot, effectiveVersion, activity, cancellationToken).ConfigureAwait(false); + var embeddedResult = await InstallFromEmbeddedAsync(cacheRoot, effectiveVersion, embeddedMetadata, activity, cancellationToken).ConfigureAwait(false); if (embeddedResult.Status == AcquisitionStatus.Installed) { - CleanupStaleCacheEntries(cacheRoot, effectiveVersion); - return AspireSkillsInstallResult.Installed(embeddedResult.Bundle!); + return await CompleteInstallationAsync(embeddedResult.Bundle!, embeddedResult.ArchiveSha512!).ConfigureAwait(false); } var failureMessage = embeddedResult.Status == AcquisitionStatus.Failed @@ -127,8 +166,9 @@ private async Task InstallFromGitHubAsync( Activity? activity, CancellationToken cancellationToken) { - var tempDir = Path.Combine(cacheRoot, $".github-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); + using var tempDirectory = CreateTemporaryCacheDirectory(cacheRoot, "github"); + string? knownGitHubArchiveSha256 = null; + var githubReleaseMetadataAvailable = false; try { @@ -140,47 +180,84 @@ private async Task InstallFromGitHubAsync( return AcquisitionResult.Unavailable(); } + githubReleaseMetadataAvailable = true; var asset = FindGitHubReleaseAsset(release, version); if (asset is null) { logger.LogDebug("Aspire skills GitHub release {TagName} does not contain a supported bundle asset for version {Version}.", release.TagName, version); - return AcquisitionResult.Unavailable(); + return AcquisitionResult.Unavailable(githubReleaseMetadataAvailable: true); + } + + knownGitHubArchiveSha256 = TryNormalizeArchiveSha256(asset.Digest); + if (knownGitHubArchiveSha256 is not null) + { + var cachedResult = await TryLoadCachedBundleAsync( + cacheRoot, + version, + expectedArchiveSha512: null, + expectedGitHubArchiveSha256: knownGitHubArchiveSha256, + requireVerifiedGitHubSource: !validationDisabled, + skipCompatibilityCheck: false, + activity, + cancellationToken).ConfigureAwait(false); + if (cachedResult is not null) + { + return cachedResult; + } } - var archivePath = Path.Combine(tempDir, GetSafeFileName(asset.Name)); + var archivePath = Path.Combine(tempDirectory.FullName, GetSafeFileName(asset.Name)); if (!await TryDownloadGitHubAssetAsync(httpClient, asset.DownloadUrl, archivePath, cancellationToken).ConfigureAwait(false)) { logger.LogDebug("Aspire skills GitHub release asset {AssetName} was unavailable for version {Version}.", asset.Name, version); - return AcquisitionResult.Unavailable(); + return AcquisitionResult.Unavailable(knownGitHubArchiveSha256, githubReleaseMetadataAvailable); } - if (!validationDisabled) + try { - var provenanceResult = await githubArtifactAttestationVerifier.VerifyAsync( - GitHubRepository, - archivePath, - ExpectedSourceRepository, - ExpectedWorkflowPath, - ExpectedBuildType, - version, - cancellationToken).ConfigureAwait(false); + var githubArchiveSha256 = ComputeArchiveSha256(archivePath); + if (knownGitHubArchiveSha256 is not null && + !string.Equals(githubArchiveSha256, knownGitHubArchiveSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException(string.Format( + CultureInfo.InvariantCulture, + "Aspire skills GitHub release asset '{0}' failed SHA-256 verification.", + asset.Name)); + } - if (!provenanceResult.IsVerified) + if (!validationDisabled) { - return AcquisitionResult.Failed(string.Format( - CultureInfo.CurrentCulture, - AgentCommandStrings.PlaywrightCliInstaller_ProvenanceVerificationFailed, - $"GitHub release asset '{asset.Name}'", - provenanceResult.Outcome)); + var provenanceResult = await githubArtifactAttestationVerifier.VerifyAsync( + GitHubRepository, + archivePath, + ExpectedSourceRepository, + ExpectedWorkflowPath, + ExpectedBuildType, + version, + cancellationToken).ConfigureAwait(false); + + if (!provenanceResult.IsVerified) + { + return AcquisitionResult.Failed(string.Format( + CultureInfo.CurrentCulture, + AgentCommandStrings.PlaywrightCliInstaller_ProvenanceVerificationFailed, + $"GitHub release asset '{asset.Name}'", + provenanceResult.Outcome)); + } } - } - try - { - var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, skipCompatibilityCheck: false, cancellationToken).ConfigureAwait(false); + var archiveSha512 = ComputeArchiveSha512(archivePath); + var bundle = await CacheArchiveAsync( + cacheRoot, + archivePath, + version, + archiveSha512, + githubArchiveSha256, + validationDisabled ? BundleArchiveSource.UnverifiedGitHub : BundleArchiveSource.VerifiedGitHub, + cancellationToken).ConfigureAwait(false); activity?.SetTag("aspire.skills.source", "github"); activity?.SetTag("aspire.skills.cache_hit", false); - return AcquisitionResult.Installed(bundle); + return AcquisitionResult.Installed(bundle, archiveSha512, githubArchiveSha256); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) { @@ -189,24 +266,29 @@ private async Task InstallFromGitHubAsync( return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); } } - catch (Exception ex) when (ex is HttpRequestException or JsonException) + // HttpClient.Timeout uses an internal cancellation token, so distinguish it from caller + // cancellation before treating the remote source as unavailable. + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { - logger.LogDebug(ex, "Aspire skills GitHub release acquisition failed for version {Version}.", version); - return AcquisitionResult.Unavailable(); + logger.LogDebug(ex, "Aspire skills GitHub release acquisition timed out for version {Version}.", version); + return AcquisitionResult.Unavailable(knownGitHubArchiveSha256, githubReleaseMetadataAvailable); } - finally + // A truncated response body throws HttpIOException rather than HttpRequestException. + // Catch it explicitly so local cache and archive I/O failures still propagate. + catch (Exception ex) when (ex is HttpRequestException or HttpIOException or JsonException) { - TryDeleteDirectory(tempDir); + logger.LogDebug(ex, "Aspire skills GitHub release acquisition failed for version {Version}.", version); + return AcquisitionResult.Unavailable(knownGitHubArchiveSha256, githubReleaseMetadataAvailable); } } private async Task InstallFromEmbeddedAsync( string cacheRoot, string version, + EmbeddedAspireSkillsBundleMetadata? metadata, Activity? activity, CancellationToken cancellationToken) { - var metadata = embeddedBundleProvider.Metadata; if (metadata is null) { logger.LogDebug("No embedded Aspire skills bundle metadata is available."); @@ -230,55 +312,56 @@ private async Task InstallFromEmbeddedAsync( return AcquisitionResult.Unavailable(); } - var tempDir = Path.Combine(cacheRoot, $".embedded-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); + var expectedArchiveSha512 = AspireSkillsBundleProvider.NormalizeSha512(metadata.Sha512!); + var cachedResult = await TryLoadCachedBundleAsync( + cacheRoot, + version, + expectedArchiveSha512, + expectedGitHubArchiveSha256: null, + requireVerifiedGitHubSource: false, + skipCompatibilityCheck: true, + activity, + cancellationToken).ConfigureAwait(false); + if (cachedResult is not null) + { + return cachedResult; + } + + using var stageDirectory = CreateTemporaryCacheDirectory(cacheRoot, "stage"); try { - var archivePath = Path.Combine(tempDir, GetSafeFileName(metadata.AssetName!)); - var archiveStream = embeddedBundleProvider.OpenArchive(); - if (archiveStream is null) + // The embedded snapshot ships inside the CLI binary as the trusted last-resort + // fallback. Its `supports` range is stamped at the time the snapshot was built, + // which can lag the actual CLI version (especially for prerelease/dogfood builds) + // and would otherwise reject a perfectly usable local copy. + var bundle = await embeddedBundleProvider.CreateBundleAsync( + new DirectoryInfo(stageDirectory.FullName), + cancellationToken).ConfigureAwait(false); + if (bundle is null) { logger.LogDebug("Embedded Aspire skills archive is unavailable for version {Version}.", version); return AcquisitionResult.Unavailable(); } - await using (archiveStream) - { - await using var fileStream = File.Create(archivePath); - await archiveStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - } - - ValidateArchiveSha512(archivePath, metadata.Sha512!); - - try - { - // The embedded snapshot ships inside the CLI binary as the trusted last-resort - // fallback. Its `supports` range is stamped at the time the snapshot was built, - // which can lag the actual CLI version (especially for prerelease/dogfood builds) - // and would otherwise reject a perfectly usable local copy. Skip the bundle's - // CLI/SDK compatibility check here so the embedded skills are always offered when - // the network path is unavailable. - var bundle = await CacheArchiveAsync(cacheRoot, archivePath, version, skipCompatibilityCheck: true, cancellationToken).ConfigureAwait(false); - activity?.SetTag("aspire.skills.source", "embedded"); - activity?.SetTag("aspire.skills.cache_hit", false); - return AcquisitionResult.Installed(bundle); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) - { - logger.LogDebug(ex, "Embedded Aspire skills bundle {AssetName} is invalid.", metadata.AssetName); - return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); - } + bundle = await CacheStagedBundleAsync( + cacheRoot, + stageDirectory, + bundle, + expectedArchiveSha512, + githubArchiveSha256: null, + version: version, + source: BundleArchiveSource.Embedded, + cancellationToken: cancellationToken).ConfigureAwait(false); + activity?.SetTag("aspire.skills.source", "embedded"); + activity?.SetTag("aspire.skills.cache_hit", false); + return AcquisitionResult.Installed(bundle, expectedArchiveSha512); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) { - logger.LogDebug(ex, "Embedded Aspire skills bundle could not be staged for version {Version}.", version); + logger.LogDebug(ex, "Embedded Aspire skills bundle {AssetName} is invalid.", metadata.AssetName); return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message)); } - finally - { - TryDeleteDirectory(tempDir); - } } private static string? ValidateEmbeddedMetadata(EmbeddedAspireSkillsBundleMetadata metadata) @@ -315,23 +398,30 @@ private async Task InstallFromEmbeddedAsync( return null; } - private static void ValidateArchiveSha512(string archivePath, string expectedSha512) + private static string? TryNormalizeArchiveSha256(string? digest) { - var expectedHash = AspireSkillsBundle.NormalizeSha512(expectedSha512); - string actualHash; - using (var stream = File.OpenRead(archivePath)) + if (string.IsNullOrWhiteSpace(digest)) { - actualHash = Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); + return null; } - if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase)) + var sha256 = AspireSkillsBundleProvider.NormalizeSha256(digest); + return sha256.Length == 64 && sha256.All(Uri.IsHexDigit) + ? sha256.ToLowerInvariant() + : null; + } + + private static string? TryNormalizeArchiveSha512(string? digest) + { + if (string.IsNullOrWhiteSpace(digest)) { - throw new InvalidOperationException(string.Format( - CultureInfo.CurrentCulture, - AgentCommandStrings.AspireSkillsInstaller_ArchiveHashVerificationFailed, - expectedHash, - actualHash)); + return null; } + + var sha512 = AspireSkillsBundleProvider.NormalizeSha512(digest); + return sha512.Length == 128 && sha512.All(Uri.IsHexDigit) + ? sha512.ToLowerInvariant() + : null; } private async Task TryGetGitHubReleaseAsync(HttpClient httpClient, string version, CancellationToken cancellationToken) @@ -386,7 +476,10 @@ private static GitHubReleaseInfo ParseGitHubReleaseInfo(string json) var downloadUrl = downloadUrlElement.GetString(); if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(downloadUrl)) { - assets.Add(new GitHubReleaseAsset(name, downloadUrl)); + var digest = assetElement.TryGetProperty("digest", out var digestElement) && digestElement.ValueKind == JsonValueKind.String + ? digestElement.GetString() + : null; + assets.Add(new GitHubReleaseAsset(name, downloadUrl, digest)); } } } @@ -456,136 +549,374 @@ private static HttpRequestMessage CreateGitHubRequest(string url) return request; } - private async Task TryLoadCachedBundleAsync(string cacheRoot, string version, Activity? activity, CancellationToken cancellationToken) + private async Task TryLoadCachedBundleAsync( + string cacheRoot, + string version, + string? expectedArchiveSha512, + string? expectedGitHubArchiveSha256, + bool requireVerifiedGitHubSource, + bool skipCompatibilityCheck, + Activity? activity, + CancellationToken cancellationToken) + { + activity?.SetTag("aspire.skills.cache_hit", false); + if (expectedArchiveSha512 is null && + expectedGitHubArchiveSha256 is null && + !requireVerifiedGitHubSource) + { + return null; + } + + await using var cacheLock = await AcquireCacheLockAsync(cacheRoot, version, cancellationToken).ConfigureAwait(false); + return await TryLoadCachedBundleCoreAsync( + cacheRoot, + version, + expectedArchiveSha512, + expectedGitHubArchiveSha256, + requireVerifiedGitHubSource, + skipCompatibilityCheck, + activity, + cancellationToken).ConfigureAwait(false); + } + + private async Task TryLoadCachedBundleCoreAsync( + string cacheRoot, + string version, + string? expectedArchiveSha512, + string? expectedGitHubArchiveSha256, + bool requireVerifiedGitHubSource, + bool skipCompatibilityCheck, + Activity? activity, + CancellationToken cancellationToken) + { + var versionCacheDirectory = GetVersionCacheDirectory(cacheRoot, version); + if (!Directory.Exists(versionCacheDirectory)) + { + return null; + } + + if (expectedArchiveSha512 is not null) + { + return await TryLoadCachedBundleDirectoryAsync( + GetBundleCacheDirectory(versionCacheDirectory, expectedArchiveSha512), + version, + expectedArchiveSha512, + expectedGitHubArchiveSha256, + requireVerifiedGitHubSource, + skipCompatibilityCheck, + activity, + cancellationToken).ConfigureAwait(false); + } + + // GitHub release metadata exposes the attestation subject's SHA-256, while cache leaves + // are keyed by the bundle-integrity SHA-512. Enumerate SHA-512 leaves so the persisted + // GitHub SHA-256 mapping can select the current asset without downloading it again. + // When release metadata is unavailable, prefer the most recently used bundle whose + // GitHub provenance was previously verified. + List<(string Directory, string ArchiveSha512, DateTimeOffset LastUsed)> candidates = []; + try + { + foreach (var directory in Directory.GetDirectories(versionCacheDirectory)) + { + var archiveSha512 = TryNormalizeArchiveSha512(Path.GetFileName(directory)); + if (archiveSha512 is not null) + { + candidates.Add((directory, archiveSha512, GetLastUsed(directory))); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogDebug(ex, "Failed to enumerate cached Aspire skills bundles for version {Version}.", version); + return null; + } + + foreach (var candidate in candidates.OrderByDescending(static candidate => candidate.LastUsed)) + { + var cachedResult = await TryLoadCachedBundleDirectoryAsync( + candidate.Directory, + version, + candidate.ArchiveSha512, + expectedGitHubArchiveSha256, + requireVerifiedGitHubSource, + skipCompatibilityCheck, + activity, + cancellationToken).ConfigureAwait(false); + if (cachedResult is not null) + { + return cachedResult; + } + } + + return null; + } + + private async Task TryLoadCachedBundleDirectoryAsync( + string cacheDirectory, + string version, + string expectedArchiveSha512, + string? expectedGitHubArchiveSha256, + bool requireVerifiedGitHubSource, + bool skipCompatibilityCheck, + Activity? activity, + CancellationToken cancellationToken) { - var cacheDirectory = GetVersionCacheDirectory(cacheRoot, version); if (!Directory.Exists(cacheDirectory)) { - activity?.SetTag("aspire.skills.cache_hit", false); + return null; + } + + if (requireVerifiedGitHubSource && + !File.Exists(Path.Combine(cacheDirectory, GitHubAttestationVerifiedFileName))) + { + logger.LogDebug( + "Ignoring cached Aspire skills bundle at {CacheDirectory} because GitHub attestation verification was not recorded.", + cacheDirectory); return null; } try { - // Cached bundles are written by this installer (from GitHub or embedded sources). - // The cache directory is keyed by bundle version, which is the right invalidation - // signal, so skip the `supports` range check here — a previously-embedded snapshot - // whose range no longer covers the current CLI is still the local artifact we - // chose to use and should not be re-evicted on every invocation. - var bundle = await LoadCachedBundleAsync(cacheDirectory, cancellationToken).ConfigureAwait(false); + var cachedArchiveSha512Path = Path.Combine(cacheDirectory, ArchiveSha512FileName); + var cachedArchiveSha512 = File.Exists(cachedArchiveSha512Path) + ? TryNormalizeArchiveSha512(File.ReadAllText(cachedArchiveSha512Path).Trim()) + : null; + if (cachedArchiveSha512 is null || + !string.Equals(cachedArchiveSha512, expectedArchiveSha512, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug( + "Ignoring cached Aspire skills bundle at {CacheDirectory} because its archive SHA-512 does not match its cache identity.", + cacheDirectory); + return null; + } + + var cachedGitHubArchiveSha256Path = Path.Combine(cacheDirectory, GitHubArchiveSha256FileName); + var cachedGitHubArchiveSha256 = File.Exists(cachedGitHubArchiveSha256Path) + ? TryNormalizeArchiveSha256(File.ReadAllText(cachedGitHubArchiveSha256Path).Trim()) + : null; + if (expectedGitHubArchiveSha256 is not null && + !string.Equals(cachedGitHubArchiveSha256, expectedGitHubArchiveSha256, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug( + "Ignoring cached Aspire skills bundle at {CacheDirectory} because its GitHub archive SHA-256 does not match the current release asset.", + cacheDirectory); + return null; + } + + var bundle = await bundleProvider.LoadAsync( + new DirectoryInfo(cacheDirectory), + cancellationToken, + skipCompatibilityCheck).ConfigureAwait(false); ValidateBundleVersion(bundle, version); TouchLastUsed(cacheDirectory); activity?.SetTag("aspire.skills.cache_hit", true); logger.LogDebug("Using cached Aspire skills bundle from {CacheDirectory}.", cacheDirectory); - return bundle; + return AcquisitionResult.Installed(bundle, cachedArchiveSha512, cachedGitHubArchiveSha256); } - catch (InvalidOperationException ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { logger.LogDebug(ex, "Ignoring invalid cached Aspire skills bundle at {CacheDirectory}.", cacheDirectory); return null; } } - private Task LoadCachedBundleAsync(string cacheDirectory, CancellationToken cancellationToken) - { - return AspireSkillsBundle.LoadAsync( - new DirectoryInfo(cacheDirectory), - executionContext.IdentitySdkVersion, - executionContext.IdentitySdkVersion, - skipCompatibilityCheck: true, - cancellationToken); - } - private async Task CacheArchiveAsync( string cacheRoot, string archivePath, string version, - bool skipCompatibilityCheck, + string archiveSha512, + string? githubArchiveSha256, + BundleArchiveSource source, CancellationToken cancellationToken) { - var extractDir = Path.Combine(cacheRoot, $".extract-{Guid.NewGuid():N}"); - var stageDir = Path.Combine(cacheRoot, $".stage-{Guid.NewGuid():N}"); - Directory.CreateDirectory(extractDir); + using var stageDirectory = CreateTemporaryCacheDirectory(cacheRoot, "stage"); + + var stagedBundle = await bundleProvider.CreateAsync( + new FileInfo(archivePath), + new DirectoryInfo(stageDirectory.FullName), + archiveSha512, + cancellationToken).ConfigureAwait(false); + + return await CacheStagedBundleAsync( + cacheRoot, + stageDirectory, + stagedBundle, + archiveSha512, + githubArchiveSha256, + version, + source, + cancellationToken).ConfigureAwait(false); + } - try + private async Task CacheStagedBundleAsync( + string cacheRoot, + TemporaryCacheDirectory stageDirectory, + AspireSkillsBundle stagedBundle, + string archiveSha512, + string? githubArchiveSha256, + string version, + BundleArchiveSource source, + CancellationToken cancellationToken) + { + RemoveInstallerMetadata(stageDirectory.FullName); + ValidateBundleVersion(stagedBundle, version); + // The archive is discarded after extraction. Retain its digest so same-version + // archives can be distinguished without keeping or downloading the archive again. + await File.WriteAllTextAsync( + Path.Combine(stageDirectory.FullName, ArchiveSha512FileName), + AspireSkillsBundleProvider.NormalizeSha512(archiveSha512).ToLowerInvariant(), + cancellationToken).ConfigureAwait(false); + if (githubArchiveSha256 is not null) { - ExtractArchive(archivePath, extractDir); + // GitHub release metadata and artifact attestations currently identify subjects by + // SHA-256. Persist that secondary identity so the next release lookup can find this + // SHA-512-keyed leaf without downloading the archive first. + await File.WriteAllTextAsync( + Path.Combine(stageDirectory.FullName, GitHubArchiveSha256FileName), + AspireSkillsBundleProvider.NormalizeSha256(githubArchiveSha256).ToLowerInvariant(), + cancellationToken).ConfigureAwait(false); + } - var bundleRoot = FindBundleRoot(extractDir); - CopyDirectory(bundleRoot.FullName, stageDir); + if (source == BundleArchiveSource.VerifiedGitHub) + { + await File.WriteAllTextAsync( + Path.Combine(stageDirectory.FullName, GitHubAttestationVerifiedFileName), + string.Empty, + cancellationToken).ConfigureAwait(false); + } - var stagedBundle = await LoadStagedBundleAsync(stageDir, skipCompatibilityCheck, cancellationToken).ConfigureAwait(false); - ValidateBundleVersion(stagedBundle, version); + await using var cacheLock = await AcquireCacheLockAsync(cacheRoot, version, cancellationToken).ConfigureAwait(false); + var versionCacheDirectory = GetVersionCacheDirectory(cacheRoot, version); + var targetDir = GetBundleCacheDirectory(versionCacheDirectory, archiveSha512); + var cachedResult = await TryLoadCachedBundleCoreAsync( + cacheRoot, + version, + archiveSha512, + githubArchiveSha256, + requireVerifiedGitHubSource: source == BundleArchiveSource.VerifiedGitHub, + skipCompatibilityCheck: source == BundleArchiveSource.Embedded, + activity: null, + cancellationToken).ConfigureAwait(false); + if (cachedResult is not null) + { + return cachedResult.Bundle!; + } - await using var cacheLock = await AcquireCacheLockAsync(cacheRoot, version, cancellationToken).ConfigureAwait(false); - var targetDir = GetVersionCacheDirectory(cacheRoot, version); + if (Directory.Exists(targetDir)) + { + logger.LogDebug("Replacing Aspire skills cache directory {CacheDirectory}.", targetDir); + TryDeleteDirectory(targetDir); if (Directory.Exists(targetDir)) { - try - { - var existingBundle = await LoadCachedBundleAsync(targetDir, cancellationToken).ConfigureAwait(false); - ValidateBundleVersion(existingBundle, version); - TouchLastUsed(targetDir); - return existingBundle; - } - catch (InvalidOperationException ex) - { - logger.LogDebug(ex, "Replacing invalid Aspire skills cache directory {CacheDirectory}.", targetDir); - TryDeleteDirectory(targetDir); - if (Directory.Exists(targetDir)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Could not replace invalid Aspire skills cache directory '{0}'.", targetDir), ex); - } - } + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Could not replace Aspire skills cache directory '{0}'.", targetDir)); } + } - Directory.Move(stageDir, targetDir); - TouchLastUsed(targetDir); + Directory.CreateDirectory(versionCacheDirectory); + RemoveLegacyCacheLayout(versionCacheDirectory); + stageDirectory.MoveTo(targetDir); + TouchLastUsed(targetDir); - var installedBundle = await LoadCachedBundleAsync(targetDir, cancellationToken).ConfigureAwait(false); - ValidateBundleVersion(installedBundle, version); + return stagedBundle; + } - return installedBundle; - } - finally - { - TryDeleteDirectory(extractDir); - TryDeleteDirectory(stageDir); - } + private static void RemoveInstallerMetadata(string bundleDirectory) + { + // These files describe local installer state, not bundle content. Always recreate them + // from the acquisition path so an archive cannot claim freshness or GitHub provenance. + File.Delete(Path.Combine(bundleDirectory, ArchiveSha512FileName)); + File.Delete(Path.Combine(bundleDirectory, GitHubArchiveSha256FileName)); + File.Delete(Path.Combine(bundleDirectory, GitHubAttestationVerifiedFileName)); + File.Delete(Path.Combine(bundleDirectory, LastUsedFileName)); } - private Task LoadStagedBundleAsync(string stageDir, bool skipCompatibilityCheck, CancellationToken cancellationToken) + private TemporaryCacheDirectory CreateTemporaryCacheDirectory(string cacheRoot, string prefix) { - return AspireSkillsBundle.LoadAsync( - new DirectoryInfo(stageDir), - executionContext.IdentitySdkVersion, - executionContext.IdentitySdkVersion, - skipCompatibilityCheck, - cancellationToken); + return TemporaryCacheDirectory.Create( + cacheRoot, + prefix, + TryDeleteDirectory, + TryDeleteFile); + } + + private Task AcquireCacheLockAsync(string cacheRoot, string version, CancellationToken cancellationToken) + { + return AcquireCacheLockCoreAsync(cacheRoot, version, maxAttempts: null, cancellationToken); } - private static async Task AcquireCacheLockAsync(string cacheRoot, string version, CancellationToken cancellationToken) + private Task AcquireCacheLockForCleanupAsync(string cacheRoot, string version, CancellationToken cancellationToken) + { + return AcquireCacheLockCoreAsync(cacheRoot, version, CacheLockMaxAttempts, cancellationToken); + } + + private async Task AcquireCacheLockCoreAsync( + string cacheRoot, + string version, + int? maxAttempts, + CancellationToken cancellationToken) { var lockPath = Path.Combine(cacheRoot, $".{GetSafeFileName(version)}.lock"); - while (true) + var retryDelay = s_cacheLockInitialRetryDelay; + for (var attempt = 1; ; attempt++) { + cancellationToken.ThrowIfCancellationRequested(); try { + // Keep the path visible for the lifetime of the cache. Unlinking a held lock on Unix + // would let another process create a different inode and enter the same critical section. return new FileStream( lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, - FileOptions.Asynchronous | FileOptions.DeleteOnClose); + FileOptions.Asynchronous); } - catch (IOException) when (!cancellationToken.IsCancellationRequested) + catch (IOException ex) when ( + IsCacheLockContention(ex, OperatingSystem.IsWindows()) && + (maxAttempts is null || attempt < maxAttempts.Value)) { - await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false); + if (maxAttempts is { } boundedAttempts) + { + logger.LogDebug( + "Acquiring the Aspire skills cache lock for version {Version} failed with HRESULT {HResult}; retrying in {DelayMilliseconds} ms (retry {RetryCount} of {MaxRetries}).", + version, + ex.HResult, + retryDelay.TotalMilliseconds, + attempt, + boundedAttempts - 1); + } + else + { + logger.LogDebug( + "Acquiring the Aspire skills cache lock for version {Version} failed with HRESULT {HResult}; retrying in {DelayMilliseconds} ms.", + version, + ex.HResult, + retryDelay.TotalMilliseconds); + } + + await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); + if (retryDelay < s_cacheLockMaxRetryDelay) + { + retryDelay *= 2; + } } } } + internal static bool IsCacheLockContention(IOException exception, bool isWindows) + { + if (isWindows) + { + return exception.HResult is WindowsSharingViolationHResult or WindowsLockViolationHResult; + } + + // On Unix, FileStream implements FileShare.None with a non-blocking flock and exposes + // EWOULDBLOCK as the raw errno in IOException.HResult: 11 on Linux and 35 on macOS. + // See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/Microsoft/Win32/SafeHandles/SafeFileHandle.Unix.cs. + return exception.HResult is LinuxWouldBlockHResult or MacOsWouldBlockHResult; + } + private static void ValidateBundleVersion(AspireSkillsBundle bundle, string expectedVersion) { if (!string.Equals(bundle.Version, expectedVersion, StringComparison.OrdinalIgnoreCase)) @@ -598,6 +929,18 @@ private static void ValidateBundleVersion(AspireSkillsBundle bundle, string expe } } + private static string ComputeArchiveSha256(string archivePath) + { + using var stream = File.OpenRead(archivePath); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + private static string ComputeArchiveSha512(string archivePath) + { + using var stream = File.OpenRead(archivePath); + return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); + } + private string GetCacheRoot() { return Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills"); @@ -608,7 +951,18 @@ private static string GetVersionCacheDirectory(string cacheRoot, string version) return Path.Combine(cacheRoot, version); } - private void CleanupStaleCacheEntries(string cacheRoot, string currentVersion) + private static string GetBundleCacheDirectory(string versionCacheDirectory, string archiveSha512) + { + return Path.Combine( + versionCacheDirectory, + AspireSkillsBundleProvider.NormalizeSha512(archiveSha512).ToLowerInvariant()); + } + + private async Task CleanupStaleCacheEntriesAsync( + string cacheRoot, + string currentVersion, + string currentArchiveSha512, + CancellationToken cancellationToken) { if (!Directory.Exists(cacheRoot)) { @@ -616,33 +970,128 @@ private void CleanupStaleCacheEntries(string cacheRoot, string currentVersion) } var maxAge = ReadWindow(configuration, MaxCacheAgeKey, s_defaultMaxCacheAge); - var now = DateTimeOffset.UtcNow; + string[] cacheDirectories; + try + { + cacheDirectories = Directory.GetDirectories(cacheRoot); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogDebug(ex, "Failed to enumerate Aspire skills cache directories for cleanup."); + return; + } - foreach (var directory in Directory.GetDirectories(cacheRoot)) + foreach (var directory in cacheDirectories) { + var version = Path.GetFileName(directory); + if (version.StartsWith(".", StringComparison.Ordinal)) + { + try + { + if (IsTemporaryCacheDirectory(version) && + DateTime.UtcNow - Directory.GetLastWriteTimeUtc(directory) > maxAge) + { + var leasePath = TemporaryCacheDirectory.GetLeasePath(directory); + using (TemporaryCacheDirectory.OpenLease(directory)) + { + TryDeleteDirectory(directory); + } + + TryDeleteFile(leasePath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogDebug(ex, "Failed to evaluate temporary Aspire skills cache directory {Directory} for cleanup.", directory); + } + + continue; + } + try { - var name = Path.GetFileName(directory); - if (name.StartsWith(".", StringComparison.Ordinal) || string.Equals(name, currentVersion, StringComparison.Ordinal)) + await using var cacheLock = await AcquireCacheLockForCleanupAsync( + cacheRoot, + version, + cancellationToken).ConfigureAwait(false); + if (!Directory.Exists(directory)) { continue; } - var lastUsed = GetLastUsed(directory); - if (now - lastUsed <= maxAge) + var isCurrentVersion = string.Equals(version, currentVersion, StringComparison.Ordinal); + if (HasLegacyCacheLayout(directory) && + (isCurrentVersion || DateTimeOffset.UtcNow - GetLastUsed(directory) > maxAge)) { - continue; + RemoveLegacyCacheLayout(directory); + } + + foreach (var bundleDirectory in Directory.GetDirectories(directory)) + { + var directoryName = Path.GetFileName(bundleDirectory); + var archiveSha512 = TryNormalizeArchiveSha512(directoryName); + if (archiveSha512 is null) + { + continue; + } + + if ((isCurrentVersion && + string.Equals(archiveSha512, currentArchiveSha512, StringComparison.OrdinalIgnoreCase)) || + DateTimeOffset.UtcNow - GetLastUsed(bundleDirectory) <= maxAge) + { + continue; + } + + TryDeleteDirectory(bundleDirectory); } - TryDeleteDirectory(directory); + if (!Directory.EnumerateFileSystemEntries(directory).Any()) + { + TryDeleteDirectory(directory); + } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - logger.LogDebug(ex, "Failed to evaluate Aspire skills cache directory {Directory} for cleanup.", directory); + // Cleanup is optional. Leave an unavailable cache for a later pass after + // lock acquisition or filesystem access exhausts its retry/error handling. + logger.LogDebug( + ex, + "Skipping cleanup of Aspire skills cache version {Version} because it could not be evaluated.", + version); } } } + private static bool HasLegacyCacheLayout(string versionCacheDirectory) + { + return Directory.Exists(Path.Combine(versionCacheDirectory, "skills")) || + File.Exists(Path.Combine(versionCacheDirectory, "skill-manifest.json")) || + File.Exists(Path.Combine(versionCacheDirectory, ArchiveSha512FileName)) || + File.Exists(Path.Combine(versionCacheDirectory, GitHubArchiveSha256FileName)) || + File.Exists(Path.Combine(versionCacheDirectory, GitHubAttestationVerifiedFileName)) || + File.Exists(Path.Combine(versionCacheDirectory, LastUsedFileName)); + } + + private void RemoveLegacyCacheLayout(string versionCacheDirectory) + { + // Older CLIs stored extracted files directly in the version directory. Remove only + // those known entries so digest-addressed children created by newer CLIs remain intact. + TryDeleteDirectory(Path.Combine(versionCacheDirectory, "skills")); + TryDeleteFile(Path.Combine(versionCacheDirectory, "skill-manifest.json")); + TryDeleteFile(Path.Combine(versionCacheDirectory, ArchiveSha512FileName)); + TryDeleteFile(Path.Combine(versionCacheDirectory, GitHubArchiveSha256FileName)); + TryDeleteFile(Path.Combine(versionCacheDirectory, GitHubAttestationVerifiedFileName)); + TryDeleteFile(Path.Combine(versionCacheDirectory, LastUsedFileName)); + } + + private static bool IsTemporaryCacheDirectory(string name) + { + return name.StartsWith(".github-", StringComparison.Ordinal) || + name.StartsWith(".embedded-", StringComparison.Ordinal) || + name.StartsWith(".extract-", StringComparison.Ordinal) || + name.StartsWith(".stage-", StringComparison.Ordinal); + } + private static TimeSpan ReadWindow(IConfiguration configuration, string key, TimeSpan fallback) { if (configuration[key] is string secondsString && double.TryParse(secondsString, CultureInfo.InvariantCulture, out var seconds) && seconds > 0) @@ -657,7 +1106,7 @@ private void TouchLastUsed(string directory) { try { - File.WriteAllText(Path.Combine(directory, ".lastused"), DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + File.WriteAllText(Path.Combine(directory, LastUsedFileName), DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -667,9 +1116,11 @@ private void TouchLastUsed(string directory) private static DateTimeOffset GetLastUsed(string directory) { - var markerPath = Path.Combine(directory, ".lastused"); + var markerPath = Path.Combine(directory, LastUsedFileName); if (File.Exists(markerPath) && - long.TryParse(File.ReadAllText(markerPath), NumberStyles.Integer, CultureInfo.InvariantCulture, out var unixTime)) + long.TryParse(File.ReadAllText(markerPath), NumberStyles.Integer, CultureInfo.InvariantCulture, out var unixTime) && + unixTime >= DateTimeOffset.MinValue.ToUnixTimeSeconds() && + unixTime <= DateTimeOffset.MaxValue.ToUnixTimeSeconds()) { return DateTimeOffset.FromUnixTimeSeconds(unixTime); } @@ -677,171 +1128,49 @@ private static DateTimeOffset GetLastUsed(string directory) return Directory.GetLastWriteTimeUtc(directory); } - private static void ExtractArchive(string archivePath, string destinationDirectory) + private static string GetSafeFileName(string fileName) { - if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + var safeName = Path.GetFileName(fileName); + foreach (var invalidCharacter in Path.GetInvalidFileNameChars()) { - ExtractZipArchive(archivePath, destinationDirectory); - return; + safeName = safeName.Replace(invalidCharacter, '_'); } - ExtractTarball(archivePath, destinationDirectory); - } - - private static void ExtractTarball(string tarballPath, string destinationDirectory) - { - var destinationRoot = Path.GetFullPath(destinationDirectory); - Directory.CreateDirectory(destinationRoot); - - using var fileStream = File.OpenRead(tarballPath); - using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); - using var tarReader = new TarReader(gzipStream); - - while (tarReader.GetNextEntry() is { } entry) - { - if (string.IsNullOrWhiteSpace(entry.Name)) - { - continue; - } - - var destinationPath = GetSafeArchiveDestinationPath(destinationRoot, entry.Name); - - switch (entry.EntryType) - { - case TarEntryType.Directory: - Directory.CreateDirectory(destinationPath); - break; - - case TarEntryType.RegularFile: - case TarEntryType.V7RegularFile: - var destinationFileDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(destinationFileDirectory)) - { - Directory.CreateDirectory(destinationFileDirectory); - } - - entry.ExtractToFile(destinationPath, overwrite: false); - break; - - case TarEntryType.GlobalExtendedAttributes: - case TarEntryType.ExtendedAttributes: - break; - - default: - throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, "Aspire skills archive entry '{0}' has unsupported type '{1}'.", entry.Name, entry.EntryType)); - } - } + return string.IsNullOrWhiteSpace(safeName) ? $"aspire-skills-{Guid.NewGuid():N}.archive" : safeName; } - private static void ExtractZipArchive(string archivePath, string destinationDirectory) + private void TryDeleteDirectory(string directory) { - var destinationRoot = Path.GetFullPath(destinationDirectory); - Directory.CreateDirectory(destinationRoot); - - using var archive = ZipFile.OpenRead(archivePath); - foreach (var entry in archive.Entries) + try { - if (string.IsNullOrWhiteSpace(entry.FullName)) - { - continue; - } - - var destinationPath = GetSafeArchiveDestinationPath(destinationRoot, entry.FullName); - if (entry.FullName.EndsWith("/", StringComparison.Ordinal) || entry.FullName.EndsWith("\\", StringComparison.Ordinal)) - { - Directory.CreateDirectory(destinationPath); - continue; - } - - var destinationFileDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(destinationFileDirectory)) + if (Directory.Exists(directory)) { - Directory.CreateDirectory(destinationFileDirectory); + Directory.Delete(directory, recursive: true); } - - entry.ExtractToFile(destinationPath, overwrite: false); - } - } - - private static string GetSafeArchiveDestinationPath(string destinationRoot, string entryName) - { - var normalizedEntryName = entryName.Replace('\\', '/'); - if (Path.IsPathRooted(normalizedEntryName) || - normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries).Any(segment => segment is "." or "..")) - { - throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, "Aspire skills archive entry '{0}' is not safe.", entryName)); } - - var destinationPath = Path.GetFullPath(Path.Combine(destinationRoot, normalizedEntryName.Replace('/', Path.DirectorySeparatorChar))); - if (!destinationPath.StartsWith(destinationRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) && - !string.Equals(destinationPath, destinationRoot, StringComparison.Ordinal)) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, "Aspire skills archive entry '{0}' escapes the extraction directory.", entryName)); + logger.LogDebug(ex, "Failed to delete Aspire skills cache directory {Directory}.", directory); } - - return destinationPath; } - private static DirectoryInfo FindBundleRoot(string extractionDirectory) + private void TryDeleteFile(string path) { - var rootManifestPath = Path.Combine(extractionDirectory, "skill-manifest.json"); - if (File.Exists(rootManifestPath)) - { - return new DirectoryInfo(extractionDirectory); - } - - var packageDirectory = Path.Combine(extractionDirectory, "package"); - var packageManifestPath = Path.Combine(packageDirectory, "skill-manifest.json"); - if (File.Exists(packageManifestPath)) - { - return new DirectoryInfo(packageDirectory); - } - - var topLevelBundleDirectories = Directory - .EnumerateDirectories(extractionDirectory) - .Where(directory => File.Exists(Path.Combine(directory, "skill-manifest.json"))) - .ToArray(); - - if (topLevelBundleDirectories.Length == 1) - { - return new DirectoryInfo(topLevelBundleDirectories[0]); - } - - if (topLevelBundleDirectories.Length > 1) + try { - throw new InvalidOperationException("Downloaded Aspire skills package contains multiple skill-manifest.json files."); + File.Delete(path); } - - throw new InvalidOperationException("Downloaded Aspire skills package does not contain skill-manifest.json."); - } - - private static void CopyDirectory(string sourceDirectory, string targetDirectory) - { - Directory.CreateDirectory(targetDirectory); - - foreach (var sourceFile in Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - var relativePath = Path.GetRelativePath(sourceDirectory, sourceFile); - var targetFile = Path.Combine(targetDirectory, relativePath); - var targetFileDirectory = Path.GetDirectoryName(targetFile); - if (!string.IsNullOrEmpty(targetFileDirectory)) - { - Directory.CreateDirectory(targetFileDirectory); - } - - File.Copy(sourceFile, targetFile, overwrite: true); + logger.LogDebug(ex, "Failed to delete Aspire skills cache file {Path}.", path); } } - private static string GetSafeFileName(string fileName) + private enum BundleArchiveSource { - var safeName = Path.GetFileName(fileName); - foreach (var invalidCharacter in Path.GetInvalidFileNameChars()) - { - safeName = safeName.Replace(invalidCharacter, '_'); - } - - return string.IsNullOrWhiteSpace(safeName) ? $"aspire-skills-{Guid.NewGuid():N}.archive" : safeName; + Embedded, + VerifiedGitHub, + UnverifiedGitHub } private enum AcquisitionStatus @@ -851,40 +1180,55 @@ private enum AcquisitionStatus Failed } - private sealed record AcquisitionResult(AcquisitionStatus Status, AspireSkillsBundle? Bundle, string? Message) + private sealed record AcquisitionResult( + AcquisitionStatus Status, + AspireSkillsBundle? Bundle, + string? Message, + string? ArchiveSha512, + string? KnownGitHubArchiveSha256, + bool GitHubReleaseMetadataAvailable) { - public static AcquisitionResult Installed(AspireSkillsBundle bundle) + public static AcquisitionResult Installed( + AspireSkillsBundle bundle, + string archiveSha512, + string? knownGitHubArchiveSha256 = null) { - return new AcquisitionResult(AcquisitionStatus.Installed, bundle, null); + return new AcquisitionResult( + AcquisitionStatus.Installed, + bundle, + null, + archiveSha512, + knownGitHubArchiveSha256, + GitHubReleaseMetadataAvailable: false); } - public static AcquisitionResult Unavailable() + public static AcquisitionResult Unavailable( + string? knownGitHubArchiveSha256 = null, + bool githubReleaseMetadataAvailable = false) { - return new AcquisitionResult(AcquisitionStatus.Unavailable, null, null); + return new AcquisitionResult( + AcquisitionStatus.Unavailable, + null, + null, + null, + knownGitHubArchiveSha256, + githubReleaseMetadataAvailable); } public static AcquisitionResult Failed(string message) { - return new AcquisitionResult(AcquisitionStatus.Failed, null, message); + return new AcquisitionResult( + AcquisitionStatus.Failed, + null, + message, + null, + null, + GitHubReleaseMetadataAvailable: false); } } private sealed record GitHubReleaseInfo(string TagName, IReadOnlyList Assets); - private sealed record GitHubReleaseAsset(string Name, string DownloadUrl); + private sealed record GitHubReleaseAsset(string Name, string DownloadUrl, string? Digest); - private void TryDeleteDirectory(string directory) - { - try - { - if (Directory.Exists(directory)) - { - Directory.Delete(directory, recursive: true); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - logger.LogDebug(ex, "Failed to delete Aspire skills cache directory {Directory}.", directory); - } - } } diff --git a/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs b/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs index 3b7d70cadd1..382d3646d68 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/EmbeddedAspireSkillsBundleProvider.cs @@ -2,24 +2,27 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Agents.AspireSkills; /// -/// Provides access to the Aspire skills bundle snapshot embedded in the CLI assembly. +/// Provides the validated Aspire skills bundle embedded in the CLI assembly. /// internal interface IEmbeddedAspireSkillsBundleProvider { /// - /// Gets metadata for the embedded Aspire skills bundle snapshot. + /// Gets the parsed metadata embedded alongside the Aspire skills bundle archive. /// EmbeddedAspireSkillsBundleMetadata? Metadata { get; } /// - /// Opens the embedded Aspire skills bundle archive. + /// Creates the embedded Aspire skills bundle in the specified directory. /// - Stream? OpenArchive(); + Task CreateBundleAsync( + DirectoryInfo bundleDirectory, + CancellationToken cancellationToken); } internal sealed class EmbeddedAspireSkillsBundleProvider : IEmbeddedAspireSkillsBundleProvider @@ -27,18 +30,68 @@ internal sealed class EmbeddedAspireSkillsBundleProvider : IEmbeddedAspireSkills private const string ArchiveResourceName = "aspire-skills.bundle.tgz"; private const string MetadataResourceName = "aspire-skills.metadata.json"; + private readonly IAspireSkillsBundleProvider _bundleProvider; private readonly ILogger _logger; private readonly Lazy _metadata; - public EmbeddedAspireSkillsBundleProvider(ILogger logger) + public EmbeddedAspireSkillsBundleProvider( + IAspireSkillsBundleProvider bundleProvider, + ILogger logger) { + ArgumentNullException.ThrowIfNull(bundleProvider); + ArgumentNullException.ThrowIfNull(logger); + + _bundleProvider = bundleProvider; _logger = logger; _metadata = new Lazy(LoadMetadata); } public EmbeddedAspireSkillsBundleMetadata? Metadata => _metadata.Value; - public Stream? OpenArchive() + public async Task CreateBundleAsync( + DirectoryInfo bundleDirectory, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(bundleDirectory); + + var metadata = Metadata; + if (metadata is null || string.IsNullOrWhiteSpace(metadata.Sha512)) + { + return null; + } + + await using var archiveStream = OpenArchive(); + if (archiveStream is null) + { + return null; + } + + Directory.CreateDirectory(bundleDirectory.FullName); + var temporaryDirectoryRoot = bundleDirectory.Parent + ?? throw new InvalidOperationException("The Aspire skills bundle staging directory must have a parent directory."); + // Keep the archive beside the staging directory so a transient Windows file lock during + // best-effort cleanup cannot prevent the validated staging directory from being published. + using var temporaryDirectory = TemporaryCacheDirectory.Create( + temporaryDirectoryRoot.FullName, + "embedded", + path => FileDeleteHelper.TryDeleteDirectory(path), + path => FileDeleteHelper.TryDeleteFile(path)); + var archivePath = Path.Combine(temporaryDirectory.FullName, "bundle.tgz"); + + await using (var fileStream = File.Create(archivePath)) + { + await archiveStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + + return await _bundleProvider.CreateAsync( + new FileInfo(archivePath), + bundleDirectory, + metadata.Sha512, + cancellationToken, + skipCompatibilityCheck: true).ConfigureAwait(false); + } + + private Stream? OpenArchive() { var stream = typeof(EmbeddedAspireSkillsBundleProvider).Assembly.GetManifestResourceStream(ArchiveResourceName); if (stream is null) diff --git a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs index 822c56dc11f..32b91a20644 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs @@ -15,7 +15,7 @@ internal sealed class SkillBundleManifest public SkillBundleSupports? Supports { get; init; } - public SkillBundleSkill[] Skills { get; init; } = []; + public SkillBundleSkill?[] Skills { get; init; } = []; } /// @@ -41,7 +41,7 @@ internal sealed class SkillBundleSkill public string[] InstallExcludedRelativePaths { get; init; } = []; - public SkillBundleFile[] Files { get; init; } = []; + public SkillBundleFile?[] Files { get; init; } = []; } /// @@ -53,7 +53,7 @@ internal sealed class SkillBundleFile // Lowercase hex SHA-512 of the file contents (preferred), read from `skill-manifest.json` inside the // bundle archive (an optional `sha512-` SRI-style prefix is tolerated). Emitted per-file by current - // microsoft/aspire-skills' build-aspire-bundles.mjs and verified by AspireSkillsBundle.ValidateFile. + // microsoft/aspire-skills' build-aspire-bundles.mjs and verified by AspireSkillsBundleProvider. public string? Sha512 { get; init; } // Lowercase hex SHA-256 of the file contents, accepted only for bundles published before the SHA-512 diff --git a/src/Aspire.Cli/Agents/AspireSkills/TemporaryCacheDirectory.cs b/src/Aspire.Cli/Agents/AspireSkills/TemporaryCacheDirectory.cs new file mode 100644 index 00000000000..cbf870a302d --- /dev/null +++ b/src/Aspire.Cli/Agents/AspireSkills/TemporaryCacheDirectory.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Agents.AspireSkills; + +/// +/// Owns a leased temporary directory under the Aspire skills cache root. +/// +internal sealed class TemporaryCacheDirectory : IDisposable +{ + private readonly string _leasePath; + private readonly FileStream _lease; + private readonly Action _deleteDirectory; + private readonly Action _deleteFile; + private bool _deleteOnDispose = true; + private bool _disposed; + + private TemporaryCacheDirectory( + string fullName, + string leasePath, + FileStream lease, + Action deleteDirectory, + Action deleteFile) + { + FullName = fullName; + _leasePath = leasePath; + _lease = lease; + _deleteDirectory = deleteDirectory; + _deleteFile = deleteFile; + } + + public string FullName { get; } + + public static TemporaryCacheDirectory Create( + string parentDirectory, + string prefix, + Action deleteDirectory, + Action deleteFile) + { + var fullName = Path.Combine(parentDirectory, $".{prefix}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(fullName); + var leasePath = GetLeasePath(fullName); + + try + { + return new TemporaryCacheDirectory( + fullName, + leasePath, + OpenLease(fullName), + deleteDirectory, + deleteFile); + } + catch + { + deleteDirectory(fullName); + deleteFile(leasePath); + throw; + } + } + + public void MoveTo(string targetDirectory) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Directory.Move(FullName, targetDirectory); + _deleteOnDispose = false; + } + + public static FileStream OpenLease(string directory) + { + return new FileStream( + GetLeasePath(directory), + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + } + + public static string GetLeasePath(string directory) + { + return $"{directory}.lock"; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_deleteOnDispose) + { + _deleteDirectory(FullName); + } + + _lease.Dispose(); + _deleteFile(_leasePath); + } +} diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index d8889b489ec..a9c044167f2 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -296,6 +296,9 @@ false + + false + diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 77705ce87b1..00463cad69e 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -3,11 +3,13 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; +using Aspire.Cli.Git; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -82,13 +84,18 @@ public async Task ResolveAllConnectionsAsync( /// Prompt to display when multiple AppHosts are found. /// Message to display when no AppHosts are found. /// Cancellation token. + /// + /// Whether AppHosts running in a different git worktree are hidden from interactive selection. + /// AppHosts elsewhere in the same worktree remain selectable. + /// /// The resolved connection, or null with an error message. public async Task ResolveConnectionAsync( FileInfo? projectFile, string scanningMessage, string selectPrompt, string notFoundMessage, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool restrictToCurrentWorktree = false) { // Fast path: If --apphost was specified, check directly for its socket if (projectFile is not null) @@ -221,11 +228,31 @@ public async Task ResolveConnectionAsync( } else if (outOfScopeConnections.Count > 0) { + // "Out of scope" combines two independent conditions: the AppHost is not under the + // working directory, and/or it belongs to a different git worktree. Strict callers + // (aspire stop) only want the worktree half enforced - an AppHost elsewhere in the + // same checkout is still theirs to act on, and hiding it silently breaks running + // `aspire stop` from a sibling directory such as repo/tests. + var selectableConnections = restrictToCurrentWorktree + ? outOfScopeConnections.Where(c => IsInWorktreeOfWorkingDirectory(c, workingDirectory)).ToList() + : outOfScopeConnections; + + if (selectableConnections.Count == 0) + { + // Every running AppHost lives in a different worktree. "Use 'aspire run' to start + // one first" would be wrong here - one is already running - so point at the two + // escape hatches that can reach it instead. + return new AppHostConnectionResult + { + ErrorMessage = SharedCommandStrings.AppHostNotRunningInCurrentWorktree, + ExitCode = CliExitCodes.FailedToFindProject, + }; + } + if (!hostEnvironment.SupportsInteractiveInput) { - // No in-scope AppHosts, and selecting from out-of-scope AppHosts requires - // a prompt. In non-interactive mode treat this as "not found" so scripts - // get a clean error and exit code instead of an unexpected prompt failure. + // Treat out-of-scope AppHosts as not found when the caller cannot prompt. + // Explicit --apphost and --all flows bypass this path. return new AppHostConnectionResult { ErrorMessage = notFoundMessage, @@ -234,7 +261,7 @@ public async Task ResolveConnectionAsync( } selectedConnection = await PromptForAppHostSelectionAsync( - outOfScopeConnections, + selectableConnections, SharedCommandStrings.NoInScopeAppHostsShowingAll, selectPrompt, path => path, @@ -251,6 +278,30 @@ public async Task ResolveConnectionAsync( return selectedResult; } + /// + /// Whether an out-of-scope connection belongs to the same git worktree as the working + /// directory. This is the worktree half of + /// without the + /// path-containment half, so callers can restrict selection to the current worktree + /// while still offering AppHosts that simply live outside the working directory. + /// + private static bool IsInWorktreeOfWorkingDirectory(IAppHostAuxiliaryBackchannel connection, string workingDirectory) + { + if (connection.AppHostInfo?.AppHostPath is not { Length: > 0 } appHostPath) + { + // A connection that never reported its path cannot be attributed to a worktree, + // so a caller asking for strict scope must not be offered it. + return false; + } + + // Resolve symlinks on both operands for the same reason the in-scope check does: the OS + // reports a process working directory physically (macOS temp dirs under /var -> /private/var) + // while an AppHost reports its own path unresolved, and the worktree walk compares ancestors. + return GitWorktree.IsSameWorktreeScope( + PathNormalizer.ResolveSymlinks(appHostPath), + PathNormalizer.ResolveSymlinks(workingDirectory)); + } + /// /// Stores the app host's CLI log file path on the execution context so that /// can display it alongside the current CLI's log path on failure. diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs index b1a6a2f05f8..e67fca1e5a7 100644 --- a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; using Aspire.Cli.Commands; +using Aspire.Cli.Git; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Aspire.Hosting.Backchannel; @@ -190,7 +191,8 @@ public IReadOnlyList GetConnectionsForWorkingDirec } /// - /// Determines whether lives within . + /// Determines whether lives within + /// and in the same git worktree. Nested linked worktrees are out of scope of the primary checkout. /// This is the single in-scope implementation shared by . /// internal static bool IsAppHostInScopeOfDirectory(string? appHostPath, string workingDirectory) @@ -209,7 +211,15 @@ internal static bool IsAppHostInScopeOfDirectory(string? appHostPath, string wor // Check if the AppHost path is within the working directory var relativePath = Path.GetRelativePath(normalizedWorkingDirectory, normalizedAppHostPath); - return !relativePath.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativePath); + if (relativePath.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relativePath)) + { + return false; + } + + // Path containment alone treats a nested linked worktree (for example + // repo/.worktrees/feature) as in-scope of the primary checkout. Stop and ps + // should stay inside the current worktree unless --apphost/--all is used. + return GitWorktree.IsSameWorktreeScope(normalizedAppHostPath, normalizedWorkingDirectory); } /// diff --git a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs index ad47bb6a25a..699bbcdcd78 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs @@ -750,7 +750,7 @@ public async Task LaunchAppHostAsync(string projectFile, List arguments, var rpc = await _rpcTaskCompletionSource.Task; - _logger.LogDebug("Running project at {ProjectFile} with arguments: {Arguments}", projectFile, string.Join(" ", arguments)); + _logger.LogDebug("Running project at {ProjectFile} with {ArgumentCount} arguments", projectFile, arguments.Count); await rpc.InvokeWithCancellationAsync( "launchAppHost", diff --git a/src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs b/src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs index 13614250dff..6203dc0bf90 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs @@ -68,6 +68,9 @@ internal sealed class EnvVar /// internal sealed class DebugSessionOptions { + internal const string ExplicitCliAppHostSelectionOrigin = "explicit-cli"; + internal const string DefaultDiscoveryAppHostSelectionOrigin = "default-discovery"; + /// /// Gets or sets the command type for the debug session (e.g., "run", "deploy", "publish", "do"). /// @@ -85,4 +88,10 @@ internal sealed class DebugSessionOptions /// [JsonPropertyName("env")] public Dictionary? EnvironmentVariables { get; set; } + + /// + /// Gets or sets a value describing how the AppHost was selected. + /// + [JsonPropertyName("appHostSelectionOrigin")] + public string? AppHostSelectionOrigin { get; set; } } diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index 19f9ab68f8e..ec21bb293c4 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -259,7 +259,7 @@ private async Task ExtractCoreAsync(string destinationPath, // post-flip sanity check fails. var priorTargets = CaptureLinkTargets(destinationPath); - // Migrate any legacy real directories (managed/, dcp/) and flip the public + // Migrate any legacy real directories (managed/, dashboard/, dcp/) and flip the public // reparse points to point at the new versioned directory. if (!TryFlipLinks(destinationPath, activeVersionDir)) { @@ -294,7 +294,7 @@ private async Task ExtractCoreAsync(string destinationPath, FileDeleteHelper.TryCleanupOldItems(destinationPath, dir); } - // Best-effort cleanup of legacy top-level managed/ and dcp/ paths from + // Best-effort cleanup of legacy top-level managed/, dashboard/, and dcp/ paths from // the old layout (before the single bundle/ link was introduced). These // are no longer needed now that layout discovery resolves through bundle/. TryCleanupLegacyLayoutPaths(destinationPath); @@ -515,7 +515,7 @@ private bool TryFlipLinks(string layoutPath, string activeVersionDir) var linkPath = Path.Combine(layoutPath, dir); // The bundle link points directly at the active version directory — - // components (managed/, dcp/) are subdirectories of the target. + // components (managed/, dashboard/, dcp/) are subdirectories of the target. var target = activeVersionDir; // Clear out legacy stale siblings from prior runs first. @@ -719,20 +719,21 @@ private static LayoutConfiguration CreateVersionRootedLayout(string versionDirec Components = new LayoutComponents { Dcp = BundleDiscovery.DcpDirectoryName, + Dashboard = BundleDiscovery.DashboardDirectoryName, Managed = BundleDiscovery.ManagedDirectoryName, } }; } /// - /// Best-effort removal of legacy top-level managed/ and dcp/ + /// Best-effort removal of legacy top-level managed/, dashboard/, and dcp/ /// directories from the old layout shape (before the single bundle/ link /// was introduced). Failures are silently ignored since the new layout via /// bundle/ is already functional. /// private void TryCleanupLegacyLayoutPaths(string layoutPath) { - string[] legacyDirs = [BundleDiscovery.ManagedDirectoryName, BundleDiscovery.DcpDirectoryName]; + string[] legacyDirs = [BundleDiscovery.ManagedDirectoryName, BundleDiscovery.DashboardDirectoryName, BundleDiscovery.DcpDirectoryName]; foreach (var dir in legacyDirs) { diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index ba97c68be6e..7bd4f53d3ef 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -42,6 +42,8 @@ internal sealed class AppHostLauncher( ILogger logger, TimeProvider timeProvider) { + private const string DefaultDiscoverySelectionOrigin = "default-discovery"; + private const string UserSelectionOrigin = "user-selection"; private const int MaxDisplayedChildLogLines = 80; private const int MaxParentLogReplayLines = 200; private static readonly TimeSpan s_legacyDetachedStartupStabilityWindow = TimeSpan.FromSeconds(2); @@ -79,12 +81,38 @@ internal static void AddLaunchOptions(Command command) command.Options.Add(s_isolatedOption); } + /// + /// Gets the explicitly supplied isolated option value, or when omitted. + /// + internal static bool? GetExplicitIsolated(ParseResult parseResult) + { + return parseResult.GetResult(s_isolatedOption) is { Implicit: false } + ? parseResult.GetValue(s_isolatedOption) + : null; + } + + /// + /// Gets the isolated option value to propagate to another CLI invocation. + /// + internal static bool? ResolveIsolatedOption(bool? explicitIsolated) + { + return explicitIsolated; + } + + /// + /// Resolves whether the AppHost should run isolated from the explicit CLI option. + /// + internal static bool ResolveIsolated(ParseResult parseResult) + { + return ResolveIsolatedOption(GetExplicitIsolated(parseResult)) ?? false; + } + /// /// Launches an AppHost in detached mode, waits for the backchannel, and displays the result. /// /// The project file passed via --project, or null to auto-discover. /// The output format (JSON or table). - /// Whether to run in isolated mode. + /// The explicitly supplied isolated option value, or when omitted. /// Whether running inside VS Code extension. /// Whether the AppHost is waiting for a debugger to attach. /// The maximum number of seconds to wait for AppHost startup. @@ -96,7 +124,7 @@ internal static void AddLaunchOptions(Command command) public async Task LaunchDetachedAsync( FileInfo? passedAppHostProjectFile, OutputFormat? format, - bool isolated, + bool? isolated, bool isExtensionHost, bool waitForDebugger, int timeoutSeconds, @@ -132,15 +160,27 @@ public async Task LaunchDetachedAsync( return CommandResult.Failure(CliExitCodes.FailedToFindProject); } + // The detached child receives the selected file rather than the directory that caused the + // prompt, so carry the prompt provenance explicitly to persist the user's choice. + var appHostSelectionOrigin = searchResult.WasExplicitDirectorySelectionPrompted + ? UserSelectionOrigin + : configuration[KnownConfigNames.CliAppHostSelectionOrigin]; + if (string.IsNullOrEmpty(appHostSelectionOrigin) && passedAppHostProjectFile is null) + { + appHostSelectionOrigin = DefaultDiscoverySelectionOrigin; + } + logger.LogDebug("Starting AppHost in background: {AppHostPath}", effectiveAppHostFile.FullName); + var isolatedOption = ResolveIsolatedOption(isolated); + // Check for running instance and stop it if found (same behavior as regular run) await StopExistingInstancesAsync(effectiveAppHostFile, cancellationToken); // Build child process arguments var childLogFile = GenerateChildLogFilePath(executionContext.LogsDirectory.FullName, timeProvider); executionContext.AppHostCliLogFilePath = childLogFile; - var (executablePath, childArgs) = BuildChildProcessArgs(effectiveAppHostFile, childLogFile, isolated, globalArgs, additionalArgs); + var (executablePath, childArgs) = BuildChildProcessArgs(effectiveAppHostFile, childLogFile, isolatedOption, globalArgs, additionalArgs); // Compute the expected socket prefix for backchannel detection. The AppHost keys its // auxiliary backchannel socket file on the symlink-resolved AppHost path, so the primary @@ -175,7 +215,7 @@ public async Task LaunchDetachedAsync( { launchResult = await interactionService.ShowDynamicStatusAsync( RunCommandStrings.StartingAppHostInBackground, - updateStatus => LaunchAndWaitForBackchannelAsync(executablePath, childArgs, expectedHash, legacyHashes, TimeSpan.FromSeconds(timeoutSeconds), updateStatus, cancellationToken)); + updateStatus => LaunchAndWaitForBackchannelAsync(executablePath, childArgs, expectedHash, legacyHashes, appHostSelectionOrigin, TimeSpan.FromSeconds(timeoutSeconds), updateStatus, cancellationToken)); } catch (OperationCanceledException) { @@ -291,7 +331,7 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can private (string ExecutablePath, List ChildArgs) BuildChildProcessArgs( FileInfo effectiveAppHostFile, string childLogFile, - bool isolated, + bool? isolated, IEnumerable globalArgs, IEnumerable additionalArgs) { @@ -307,9 +347,13 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can args.AddRange(globalArgs); - if (isolated) + if (isolated is not null) { args.Add(s_isolatedOption.Name); + if (!isolated.Value) + { + args.Add("false"); + } } foreach (var token in additionalArgs) @@ -331,8 +375,10 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can childArgs.AddRange(args); + // childArgs ends with the caller's "-- " tail, which is user-supplied AppHost + // input rather than CLI options, so it must not be written to the log verbatim. logger.LogDebug("Spawning child CLI: {Executable} (isDotnetHost={IsDotnetHost}) with args: {Args}", - dotnetPath, isDotnetHost, string.Join(" ", childArgs)); + dotnetPath, isDotnetHost, AppHostArgumentRedactor.RedactToString(childArgs)); logger.LogDebug("Working directory: {WorkingDirectory}", executionContext.WorkingDirectory.FullName); return (dotnetPath, childArgs); @@ -390,6 +436,7 @@ private async Task LaunchAndWaitForBackchannelAsync( List childArgs, string expectedHash, IReadOnlyList legacyHashes, + string? appHostSelectionOrigin, TimeSpan timeout, Action updateStatus, CancellationToken cancellationToken) @@ -409,7 +456,7 @@ private async Task LaunchAndWaitForBackchannelAsync( childProcess = processExecutionFactory.CreateExecution( executablePath, childArgs.ToArray(), - CreateDetachedChildEnvironment(Activity.Current, configuration[KnownConfigNames.CliAppHostSelectionOrigin]), + CreateDetachedChildEnvironment(Activity.Current, appHostSelectionOrigin), executionContext.WorkingDirectory, options); diff --git a/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs b/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs index 5560413395b..e02179cd192 100644 --- a/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs +++ b/src/Aspire.Cli/Commands/AppHostStartupTimeout.cs @@ -4,6 +4,7 @@ using System.Globalization; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; +using Aspire.Hosting; using Microsoft.Extensions.Configuration; namespace Aspire.Cli.Commands; @@ -33,4 +34,25 @@ public static bool TryGetTimeoutSeconds(IConfiguration configuration, IInteracti CliConfigNames.AppHostStartupTimeout)); return false; } + + public static TimeSpan GetBackchannelConnectionTimeout(IConfiguration configuration) + { + var configuredValue = configuration[KnownConfigNames.CliBackchannelConnectTimeoutSeconds]; + if (double.TryParse(configuredValue, CultureInfo.InvariantCulture, out var seconds) + && double.IsFinite(seconds) + && seconds >= 0 + && seconds <= TimeSpan.MaxValue.TotalSeconds) + { + return TimeSpan.FromSeconds(seconds); + } + + var timeout = TimeSpan.FromSeconds(WaitCommand.DefaultTimeoutSeconds); + var configuredStartupTimeout = configuration[CliConfigNames.AppHostStartupTimeout]; + if (int.TryParse(configuredStartupTimeout, CultureInfo.InvariantCulture, out var startupTimeoutSeconds) && startupTimeoutSeconds > timeout.TotalSeconds) + { + timeout = TimeSpan.FromSeconds(startupTimeoutSeconds); + } + + return timeout; + } } diff --git a/src/Aspire.Cli/Commands/DashboardRunCommand.cs b/src/Aspire.Cli/Commands/DashboardRunCommand.cs index 1b46ac6f199..f0027c965e7 100644 --- a/src/Aspire.Cli/Commands/DashboardRunCommand.cs +++ b/src/Aspire.Cli/Commands/DashboardRunCommand.cs @@ -11,6 +11,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Aspire.Hosting; +using Aspire.Shared; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -50,6 +51,10 @@ internal sealed class DashboardRunCommand : BaseCommand Description = DashboardCommandStrings.OtlpHttpUrlOptionDescription }; + // This option explicitly opts into unsecured endpoints. See the security considerations at + // https://aspire.dev/dashboard/security-considerations/ before enabling it outside local development. + // When the corresponding endpoints are enabled, the dashboard logs warnings and displays a warning + // in the UI to inform users about the risks of anonymous access. private static readonly Option s_allowAnonymousOption = new("--allow-anonymous") { Description = DashboardCommandStrings.AllowAnonymousOptionDescription @@ -92,13 +97,17 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.Failure(CliExitCodes.DashboardFailure, DashboardCommandStrings.BundleLayoutNotFound); } - var managedPath = layout.GetManagedPath(); - if (managedPath is null || !File.Exists(managedPath)) + var dashboardPath = layout.GetDashboardPath(); + if (dashboardPath is null || !File.Exists(dashboardPath)) { return CommandResult.Failure(CliExitCodes.DashboardFailure, DashboardCommandStrings.ManagedBinaryNotFound); } - var dashboardArgs = new List { "dashboard" }; + var dashboardArgs = new List(); + if (BundleDiscovery.IsAspireManagedBinary(dashboardPath)) + { + dashboardArgs.Add("dashboard"); + } // Build args from typed options. These are added before unmatched tokens // so that raw pass-through arguments (unmatched tokens) take precedence. @@ -139,7 +148,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul // Resolve URLs for the summary display. var dashboardInfo = ResolveDashboardInfo(dashboardArgs, unmatchedTokens, _environment, browserToken); - return await ExecuteForegroundAsync(managedPath, dashboardArgs, dashboardInfo, environmentVariables, cancellationToken).ConfigureAwait(false); + return await ExecuteForegroundAsync(dashboardPath, dashboardArgs, dashboardInfo, environmentVariables, cancellationToken).ConfigureAwait(false); } private async Task EnsureDashboardBundleAsync(CancellationToken cancellationToken) @@ -366,9 +375,9 @@ internal static void RenderDashboardSummary(IInteractionService interactionServi interactionService.DisplayRenderable(padder); } - private async Task ExecuteForegroundAsync(string managedPath, List dashboardArgs, DashboardInfo dashboardInfo, IDictionary? environmentVariables, CancellationToken cancellationToken) + private async Task ExecuteForegroundAsync(string dashboardPath, List dashboardArgs, DashboardInfo dashboardInfo, IDictionary? environmentVariables, CancellationToken cancellationToken) { - _logger.LogDebug("Starting dashboard in foreground: {ManagedPath}", managedPath); + _logger.LogDebug("Starting dashboard in foreground: {DashboardPath}", dashboardPath); var outputCollector = new OutputCollector(_fileLoggerProvider, CliLogFormat.Categories.Dashboard); var readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -396,11 +405,11 @@ private async Task ExecuteForegroundAsync(string managedPath, Lis // Foreground `aspire dashboard run`: the dashboard is a child of this CLI and must not // outlive it, so bind it to the Windows kill-on-close job as an OS-level backstop on top of // the cross-platform parent-liveness watchdog. No-op on non-Windows hosts. - process = await _layoutProcessRunner.StartAsync(managedPath, dashboardArgs, environmentVariables: environmentVariables, options: options, killOnParentExit: true).ConfigureAwait(false); + process = await _layoutProcessRunner.StartAsync(dashboardPath, dashboardArgs, environmentVariables: environmentVariables, options: options, killOnParentExit: true).ConfigureAwait(false); } catch (Exception ex) { - _logger.LogError(ex, "Failed to start dashboard process: {ManagedPath}", managedPath); + _logger.LogError(ex, "Failed to start dashboard process: {DashboardPath}", dashboardPath); InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, DashboardCommandStrings.DashboardFailedToStart, ex.Message)); return CommandResult.Failure(CliExitCodes.DashboardFailure); } diff --git a/src/Aspire.Cli/Commands/ParseResultHelper.cs b/src/Aspire.Cli/Commands/ParseResultHelper.cs index 246c848991e..f6a151dbf90 100644 --- a/src/Aspire.Cli/Commands/ParseResultHelper.cs +++ b/src/Aspire.Cli/Commands/ParseResultHelper.cs @@ -2,16 +2,315 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.CommandLine.Parsing; using System.Globalization; using Aspire.Cli.Resources; +using Aspire.Cli.Utils; +using CommandLineCommandResult = System.CommandLine.Parsing.CommandResult; namespace Aspire.Cli.Commands; +/// +/// Contains projected CLI and AppHost arguments. +/// +internal sealed class ForwardedArguments +{ + private readonly List _tokens; + + /// + /// Initializes projected arguments with the insertion boundary for CLI options. + /// + internal ForwardedArguments(List tokens, int optionCount) + { + _tokens = tokens; + OptionCount = optionCount; + } + + /// + /// Gets the projected argument tokens. + /// + internal IReadOnlyList Tokens => _tokens; + + /// + /// Gets the insertion boundary before AppHost arguments. + /// + internal int OptionCount { get; private set; } + + /// + /// Inserts CLI-owned tokens before AppHost arguments. + /// + internal void InsertCliOption(params ReadOnlySpan tokens) + { + _tokens.InsertRange(OptionCount, tokens); + OptionCount += tokens.Length; + } +} + /// /// Helpers for inspecting a after parsing. /// internal static class ParseResultHelper { + private const string DoubleDashSeparator = "--"; + + /// + /// Projects explicitly supplied arguments while excluding options handled by the caller. + /// + internal static ForwardedArguments GetForwardedArguments( + ParseResult parseResult, + params Option[] excludedOptions) + { + var (excludedTokens, excludedOptionNames) = GetForwardingExclusions(parseResult, excludedOptions); + + // Command arguments (for example the package name in `aspire add `) belong to the + // CLI command being projected rather than to the child command line, so they stay excluded + // from the owner map here. + var optionValueOwners = GetValueOwners(parseResult.RootCommandResult, includeArgumentResults: false); + var forwardedTokens = new List(parseResult.Tokens.Count); + int? optionCount = null; + Token? lastForwardedToken = null; + + foreach (var token in parseResult.Tokens) + { + if (token.Type == TokenType.DoubleDash) + { + optionCount = forwardedTokens.Count; + break; + } + + var hasOptionValueOwner = optionValueOwners.TryGetValue(token, out var ownerResult); + + // The owner map is built without ArgumentResult entries above, so every owner here is + // an OptionResult; the cast keeps that guarantee explicit rather than assumed. + var optionResult = ownerResult as OptionResult; + if (excludedTokens.Contains(token) || + (token.Type == TokenType.Option && excludedOptionNames.Contains(token.Value))) + { + continue; + } + + if (token.Type != TokenType.Option && + !hasOptionValueOwner) + { + continue; + } + + AddForwardedToken(token, optionResult, forwardedTokens, ref lastForwardedToken); + } + + var finalOptionCount = optionCount ?? forwardedTokens.Count; + + if (parseResult.UnmatchedTokens.Count > 0) + { + forwardedTokens.Add(DoubleDashSeparator); + forwardedTokens.AddRange(parseResult.UnmatchedTokens); + } + + return new ForwardedArguments(forwardedTokens, finalOptionCount); + } + + /// + /// Renders the arguments of in a form that is safe to write to + /// logs: tokens the CLI itself owns are preserved and every token that would be forwarded to + /// the AppHost is replaced with . + /// + /// + /// keys off the literal -- separator, which is + /// correct for a projected child command line ( always + /// emits one) but wrong for raw user input: run and start forward unmatched + /// tokens even when no separator was typed, so + /// + /// aspire run --ApiKey sk-live-... + /// + /// reaches the AppHost as --ApiKey sk-live-... with nothing to key off. The boundary is + /// therefore derived from the parse tree instead: what the CLI did not claim is AppHost input. + /// + internal static string GetLoggableArguments(ParseResult parseResult) + { + var cliOwnedTokens = GetCliOwnedTokens(parseResult); + var loggableTokens = new List(parseResult.Tokens.Count); + var isAfterSeparator = false; + + foreach (var token in parseResult.Tokens) + { + // System.CommandLine synthesizes a leading token holding the root command name when the + // first argument is not that name, so `aspire run` tokenizes as ["aspire", "run"] even + // though the caller only passed ["run"]. Callers render the executable name themselves. + if (ReferenceEquals(token, parseResult.RootCommandResult.IdentifierToken)) + { + continue; + } + + if (token.Type == TokenType.DoubleDash) + { + // The separator carries no user data and marks where AppHost input begins. Keeping + // it readable preserves the boundary that makes the redacted tail interpretable. + isAfterSeparator = true; + loggableTokens.Add(DoubleDashSeparator); + continue; + } + + // Ownership must be checked by reference, not by value: the same raw string can appear + // on both sides of the boundary (`run --apphost same-value -- same-value`) and only the + // occurrence the parser bound to a CLI symbol is safe to log. + loggableTokens.Add(!isAfterSeparator && cliOwnedTokens.Contains(token) + ? token.Value + : AppHostArgumentRedactor.RedactedToken); + } + + return string.Join(' ', loggableTokens); + } + + /// + /// Collects the tokens the CLI itself consumed. Everything else in + /// is AppHost input. + /// + private static HashSet GetCliOwnedTokens(ParseResult parseResult) + { + var ownedTokens = new HashSet(ReferenceEqualityComparer.Instance); + + CommandLineCommandResult? commandResult = parseResult.CommandResult; + while (commandResult is not null) + { + ownedTokens.Add(commandResult.IdentifierToken); + commandResult = commandResult.Parent as CommandLineCommandResult; + } + + foreach (var token in parseResult.Tokens) + { + // The tokenizer assigns TokenType.Option only to arguments that matched a known option + // (including the `--name` half of `--name=value`); an unrecognized `--secret` stays a + // TokenType.Argument token and so is never treated as CLI-owned here. + if (token.Type == TokenType.Option) + { + ownedTokens.Add(token); + } + } + + // Include command argument values (`aspire add `) so ordinary invocations stay + // readable in logs; they are consumed by the CLI command and never forwarded. + foreach (var (token, _) in GetValueOwners(parseResult.RootCommandResult, includeArgumentResults: true)) + { + ownedTokens.Add(token); + } + + return ownedTokens; + } + + private static (HashSet Tokens, HashSet OptionNames) GetForwardingExclusions( + ParseResult parseResult, + Option[] excludedOptions) + { + // A command can contain the same raw value on both sides of a separator: + // run --apphost value -- value + // Reference identity excludes only the command or option occurrence that owns a token. + var excludedTokens = new HashSet(ReferenceEqualityComparer.Instance); + var excludedOptionNames = new HashSet(StringComparer.Ordinal); + + CommandLineCommandResult? commandResult = parseResult.CommandResult; + while (commandResult is not null) + { + excludedTokens.Add(commandResult.IdentifierToken); + commandResult = commandResult.Parent as CommandLineCommandResult; + } + + foreach (var option in excludedOptions) + { + excludedOptionNames.Add(option.Name); + excludedOptionNames.UnionWith(option.Aliases); + + if (parseResult.GetResult(option) is { Implicit: false } optionResult) + { + excludedTokens.UnionWith(optionResult.Tokens); + } + } + + return (excludedTokens, excludedOptionNames); + } + + /// + /// Maps every value token in the parse tree to the symbol result that consumed it. + /// + /// The command result whose subtree is walked. + /// + /// Whether tokens consumed by command arguments are included. Forwarding needs option values + /// only, while log redaction also needs command arguments so they stay readable. + /// + private static Dictionary GetValueOwners( + CommandLineCommandResult commandResult, + bool includeArgumentResults) + { + var owners = new Dictionary(ReferenceEqualityComparer.Instance); + AddValueOwners(commandResult, includeArgumentResults, owners); + + return owners; + + static void AddValueOwners( + CommandLineCommandResult currentCommandResult, + bool includeArguments, + Dictionary currentOwners) + { + foreach (var child in currentCommandResult.Children) + { + switch (child) + { + case OptionResult optionResult: + // System.CommandLine represents both `--option value` and `--option=value` + // as an identifier token plus value tokens owned by the OptionResult. + // Unknown values have no owner, so token identity distinguishes equal raw values. + foreach (var token in optionResult.Tokens) + { + // TryAdd rather than Add: a token reachable from two results must not + // throw, because this map is also built on the logging path. + currentOwners.TryAdd(token, optionResult); + } + break; + case ArgumentResult argumentResult when includeArguments: + foreach (var token in argumentResult.Tokens) + { + currentOwners.TryAdd(token, argumentResult); + } + break; + case CommandLineCommandResult childCommandResult: + AddValueOwners(childCommandResult, includeArguments, currentOwners); + break; + } + } + } + } + + private static void AddForwardedToken( + Token token, + OptionResult? optionResult, + List forwardedTokens, + ref Token? lastForwardedToken) + { + var emittedValue = optionResult is { Tokens.Count: 1 } && + !optionResult.Errors.Any() && + optionResult.Option.ValueType.IsAssignableTo(typeof(FileSystemInfo)) && + optionResult.GetValueOrDefault() is { } fileSystemInfo + ? fileSystemInfo.FullName + : token.Value; + + // System.CommandLine splits an equals-form option into owned identifier and value tokens: + // --values=--flag -> "--values", "--flag" + // Re-emitting that as `--values --flag` can make a child parse `--flag` as an option + // instead of a value. Keep equals syntax for the first option-shaped value, after typed + // normalization so a FileSystemInfo value such as `--` remains an absolute path. + if (token.Value.StartsWith('-') + && optionResult is { IdentifierToken: { } identifierToken } + && ReferenceEquals(lastForwardedToken, identifierToken)) + { + forwardedTokens[^1] = $"{identifierToken.Value}={emittedValue}"; + } + else + { + forwardedTokens.Add(emittedValue); + } + + lastForwardedToken = token; + } + /// /// Checks unmatched tokens for options that differ only by case from a known option, /// and returns an error message if found. Returns null when no near-miss is detected. diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 9c2871f5aff..d18f235aa05 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -134,6 +134,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul // If running in the extension context (Aspire terminal) without a debug session, // intercept and tell VS Code to start a proper debug session for this command. var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); + var explicitAppHost = passedAppHostProjectFile is not null; if (ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _) && string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId])) { @@ -161,6 +162,9 @@ await extensionInteractionService.StartDebugSessionAsync( { Command = Name, Args = commandArgs.Length > 0 ? commandArgs : null, + AppHostSelectionOrigin = explicitAppHost + ? DebugSessionOptions.ExplicitCliAppHostSelectionOrigin + : DebugSessionOptions.DefaultDiscoveryAppHostSelectionOrigin, EnvironmentVariables = new Dictionary { [KnownConfigNames.AspireHome] = ExecutionContext.AspireHomeDirectory.FullName diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 49dd42a995c..b74133c7a23 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -72,6 +72,7 @@ internal sealed class RunCommand : BaseCommand private readonly FileLoggerProvider _fileLoggerProvider; private readonly ICliHostEnvironment _hostEnvironment; private readonly ProfilingTelemetry _profilingTelemetry; + private readonly ProfileCaptureState _profileCaptureState; private readonly TimeProvider _timeProvider; private bool _isDetachMode; private const int MaxDisplayedAppHostStartupOutputLines = 80; @@ -131,6 +132,7 @@ public RunCommand( FileLoggerProvider fileLoggerProvider, ICliHostEnvironment hostEnvironment, ProfilingTelemetry profilingTelemetry, + ProfileCaptureState profileCaptureState, TimeProvider timeProvider, CommonCommandServices services) : base("run", RunCommandStrings.Description, services) @@ -147,6 +149,7 @@ public RunCommand( _fileLoggerProvider = fileLoggerProvider; _hostEnvironment = hostEnvironment; _profilingTelemetry = profilingTelemetry; + _profileCaptureState = profileCaptureState; _timeProvider = timeProvider; Options.Add(s_detachOption); @@ -162,7 +165,6 @@ protected override async Task ExecuteAsync(ParseResult parseResul var detach = parseResult.GetValue(s_detachOption); var noBuild = parseResult.GetValue(s_noBuildOption); var format = parseResult.GetValue(AppHostLauncher.s_formatOption); - var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); var isExtensionHost = ExtensionHelper.IsExtensionHost(InteractionService, out _, out _); var captureProfile = parseResult.GetValue(RootCommand.CaptureProfileOption); var captureProfileDelay = TimeSpan.FromSeconds(parseResult.GetValue(RootCommand.CaptureProfileDelayOption)); @@ -212,8 +214,28 @@ protected override async Task ExecuteAsync(ParseResult parseResul && ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _) && string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId])) { + var debugSessionArguments = ParseResultHelper.GetForwardedArguments( + parseResult, + AppHostLauncher.s_appHostOption.InnerOption, + AppHostLauncher.s_appHostOption.LegacyOption, + AppHostLauncher.s_formatOption, + s_detachOption, + RootCommand.StartDebugSessionOption, + RootCommand.NonInteractiveOption); extensionInteractionService.DisplayConsolePlainText(string.Format(CultureInfo.CurrentCulture, startDebugSession ? RunCommandStrings.StartingDebugSessionInExtension : RunCommandStrings.StartingRunSessionInExtension, "run")); - await extensionInteractionService.StartDebugSessionAsync(ExecutionContext.WorkingDirectory.FullName, passedAppHostProjectFile?.FullName, startDebugSession, new DebugSessionOptions { Command = "run" }); + await extensionInteractionService.StartDebugSessionAsync( + ExecutionContext.WorkingDirectory.FullName, + passedAppHostProjectFile?.FullName, + startDebugSession, + new DebugSessionOptions + { + Command = "run", + Args = [.. debugSessionArguments.Tokens], + AppHostSelectionOrigin = passedAppHostProjectFile is not null + ? DebugSessionOptions.ExplicitCliAppHostSelectionOrigin + : DebugSessionOptions.DefaultDiscoveryAppHostSelectionOrigin + }); + _profileCaptureState.MarkTransferred(); return CommandResult.Success(); } @@ -229,7 +251,6 @@ protected override async Task ExecuteAsync(ParseResult parseResul // all failure paths (project not found, incompatible version, etc.) are captured. runActivity = Telemetry.StartReportedActivity(name: TelemetryConstants.Activities.RunAppHost); runActivity?.SetTag(TelemetryConstants.Tags.AppHostDetached, _configuration.GetBool(KnownConfigNames.CliRunDetached) is true); - runActivity?.SetTag(TelemetryConstants.Tags.AppHostIsolated, isolated); using var activity = _profilingTelemetry.StartRunCommand(); @@ -254,6 +275,9 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.Failure(CliExitCodes.FailedToFindProject); } + var isolated = AppHostLauncher.ResolveIsolated(parseResult); + runActivity?.SetTag(TelemetryConstants.Tags.AppHostIsolated, isolated); + // Resolve the language for this file and get the appropriate handler var project = _projectFactory.TryGetProject(effectiveAppHostFile); if (project is null) @@ -1169,11 +1193,12 @@ public void ProcessResourceState(RpcResourceState resourceState, Action ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passedAppHostProjectFile, bool isExtensionHost, int timeoutSeconds, CancellationToken cancellationToken) { var format = parseResult.GetValue(AppHostLauncher.s_formatOption); - var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); + var isolated = AppHostLauncher.GetExplicitIsolated(parseResult); var noBuild = parseResult.GetValue(s_noBuildOption); var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption); var globalArgs = RootCommand.GetChildProcessArgs(parseResult); - var additionalArgs = parseResult.UnmatchedTokens.Where(t => t != "--detach").ToList(); + var appHostArgs = parseResult.UnmatchedTokens; + var additionalArgs = new List(); var captureProfile = parseResult.GetValue(RootCommand.CaptureProfileOption); var stopAfterLaunchDelay = captureProfile ? TimeSpan.FromSeconds(parseResult.GetValue(RootCommand.CaptureProfileDelayOption)) @@ -1184,6 +1209,12 @@ private Task ExecuteDetachedAsync(ParseResult parseResult, FileIn additionalArgs.Add("--no-build"); } + if (appHostArgs.Count > 0) + { + additionalArgs.Add("--"); + additionalArgs.AddRange(appHostArgs); + } + return _appHostLauncher.LaunchDetachedAsync( passedAppHostProjectFile, format, diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs index 3958cf2fc79..022d2051027 100644 --- a/src/Aspire.Cli/Commands/StartCommand.cs +++ b/src/Aspire.Cli/Commands/StartCommand.cs @@ -4,6 +4,7 @@ using System.CommandLine; using System.Globalization; using Aspire.Cli.Backchannel; +using Aspire.Cli.Profiling; using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Aspire.Hosting; @@ -19,6 +20,7 @@ internal sealed class StartCommand : BaseCommand private readonly AppHostLauncher _appHostLauncher; private readonly IConfiguration _configuration; + private readonly ProfileCaptureState _profileCaptureState; private static readonly Option s_noBuildOption = new("--no-build") { @@ -28,12 +30,14 @@ internal sealed class StartCommand : BaseCommand public StartCommand( AppHostLauncher appHostLauncher, IConfiguration configuration, + ProfileCaptureState profileCaptureState, CommonCommandServices services) : base("start", StartCommandStrings.Description, services) { _appHostLauncher = appHostLauncher; _configuration = configuration; + _profileCaptureState = profileCaptureState; Options.Add(s_noBuildOption); AppHostLauncher.AddLaunchOptions(this); @@ -45,7 +49,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul { var passedAppHostProjectFile = parseResult.GetValue(AppHostLauncher.s_appHostOption); var format = parseResult.GetValue(AppHostLauncher.s_formatOption); - var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); + var explicitIsolated = AppHostLauncher.GetExplicitIsolated(parseResult); var noBuild = parseResult.GetValue(s_noBuildOption); // The detached start path is always user-initiated. When invoked from the @@ -54,7 +58,8 @@ protected override async Task ExecuteAsync(ParseResult parseResul var isExtensionHost = false; var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption); var globalArgs = RootCommand.GetChildProcessArgs(parseResult); - var additionalArgs = parseResult.UnmatchedTokens.ToList(); + var appHostArgs = parseResult.UnmatchedTokens; + var additionalArgs = new List(); var captureProfile = parseResult.GetValue(RootCommand.CaptureProfileOption); var stopAfterLaunchDelay = captureProfile ? TimeSpan.FromSeconds(parseResult.GetValue(RootCommand.CaptureProfileDelayOption)) @@ -71,42 +76,13 @@ protected override async Task ExecuteAsync(ParseResult parseResul && string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId])) { var startDebugSession = parseResult.GetValue(RootCommand.StartDebugSessionOption); - var debugSessionArgs = new List(); - if (isolated) - { - debugSessionArgs.Add("--isolated"); - } - - if (noBuild) - { - debugSessionArgs.Add("--no-build"); - } - - debugSessionArgs.AddRange(globalArgs); - - if (captureProfile) - { - debugSessionArgs.Add("--capture-profile"); - - if (parseResult.GetValue(RootCommand.CaptureProfileOutputOption) is { } captureProfileOutput) - { - debugSessionArgs.Add("--capture-profile-output"); - debugSessionArgs.Add(captureProfileOutput.FullName); - } - - if (parseResult.GetResult(RootCommand.CaptureProfileDelayOption) is { Implicit: false }) - { - debugSessionArgs.Add("--capture-profile-delay"); - debugSessionArgs.Add(parseResult.GetValue(RootCommand.CaptureProfileDelayOption).ToString(CultureInfo.InvariantCulture)); - } - } - - if (additionalArgs.Count > 0) - { - debugSessionArgs.Add("--"); - debugSessionArgs.AddRange(additionalArgs); - } - + var debugSessionArguments = ParseResultHelper.GetForwardedArguments( + parseResult, + AppHostLauncher.s_appHostOption.InnerOption, + AppHostLauncher.s_appHostOption.LegacyOption, + AppHostLauncher.s_formatOption, + RootCommand.StartDebugSessionOption, + RootCommand.NonInteractiveOption); extensionInteractionService.DisplayConsolePlainText(string.Format(CultureInfo.CurrentCulture, startDebugSession ? RunCommandStrings.StartingDebugSessionInExtension : RunCommandStrings.StartingRunSessionInExtension, "start")); await extensionInteractionService.StartDebugSessionAsync( ExecutionContext.WorkingDirectory.FullName, @@ -115,8 +91,12 @@ await extensionInteractionService.StartDebugSessionAsync( new DebugSessionOptions { Command = "run", - Args = debugSessionArgs.Count > 0 ? [.. debugSessionArgs] : null + Args = [.. debugSessionArguments.Tokens], + AppHostSelectionOrigin = passedAppHostProjectFile is not null + ? DebugSessionOptions.ExplicitCliAppHostSelectionOrigin + : DebugSessionOptions.DefaultDiscoveryAppHostSelectionOrigin }); + _profileCaptureState.MarkTransferred(); return CommandResult.Success(); } @@ -126,6 +106,12 @@ await extensionInteractionService.StartDebugSessionAsync( additionalArgs.Add("--no-build"); } + if (appHostArgs.Count > 0) + { + additionalArgs.Add("--"); + additionalArgs.AddRange(appHostArgs); + } + if (!AppHostStartupTimeout.TryGetTimeoutSeconds(_configuration, InteractionService, out var timeoutSeconds)) { return CommandResult.Failure(CliExitCodes.InvalidCommand); @@ -134,7 +120,7 @@ await extensionInteractionService.StartDebugSessionAsync( return await _appHostLauncher.LaunchDetachedAsync( passedAppHostProjectFile, format, - isolated, + explicitIsolated, isExtensionHost, waitForDebugger, timeoutSeconds, diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index 9f0edb31243..10152f5f43e 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -262,7 +262,8 @@ private async Task ExecuteInteractiveWithResultAsync(FileInfo SharedCommandStrings.ScanningForRunningAppHosts, string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, StopCommandStrings.SelectAppHostAction), SharedCommandStrings.AppHostNotRunning, - cancellationToken); + cancellationToken, + restrictToCurrentWorktree: true); if (!result.Success) { diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 509fce8113f..212f10657b4 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -113,6 +113,18 @@ internal sealed class ProcessInvocationOptions /// public Func? EnvironmentVariableFilter { get; set; } + /// + /// Index of the first argument that is user-supplied AppHost input rather than a CLI-owned + /// option, for invocations whose argument list has no -- separator to key off. Leave + /// when the separator is present or the whole list is CLI-owned. + /// + /// + /// Launching a built AppHost directly (rather than through dotnet run) appends the + /// forwarded arguments straight onto the executable's command line, so the redaction boundary + /// has to travel with the invocation instead of being recovered from the arguments. + /// + internal int? AppHostArgumentStartIndex { get; set; } + /// /// Issues the graceful shutdown signal during the shutdown ladder (DCP /// stop-process-tree on Windows, SIGTERM on Unix). When null, the cancellation @@ -147,6 +159,7 @@ internal sealed class ProcessInvocationOptions Detached = Detached, DetachedUnixLauncherPathOverride = DetachedUnixLauncherPathOverride, EnvironmentVariableFilter = EnvironmentVariableFilter, + AppHostArgumentStartIndex = AppHostArgumentStartIndex, GracefulShutdownSignaler = GracefulShutdownSignaler, ShutdownService = ShutdownService, }; @@ -224,6 +237,7 @@ private async Task ExecuteAsync( processActivity.SetDotNetResolvedExecutable( processFileName, effectiveArgs, + options.AppHostArgumentStartIndex, finalEnv.TryGetValue("DOTNET_CLI_USE_MSBUILD_SERVER", out var msBuildServerValue) ? msBuildServerValue : null); processActivity.SetDotNetArgsCount(effectiveArgs.Length); @@ -379,6 +393,9 @@ private static ProcessInvocationOptions CreateInstrumentedProcessOptions( Detached = options.Detached, DetachedUnixLauncherPathOverride = options.DetachedUnixLauncherPathOverride, EnvironmentVariableFilter = options.EnvironmentVariableFilter, + // Without this the redaction boundary is lost between the runner and the process + // factory, and a direct AppHost launch would log its forwarded arguments verbatim. + AppHostArgumentStartIndex = options.AppHostArgumentStartIndex, GracefulShutdownSignaler = options.GracefulShutdownSignaler, ShutdownService = options.ShutdownService, StandardOutputCallback = line => @@ -487,7 +504,7 @@ private async Task StartBackchannelAsync( logger.LogDebug("Starting backchannel connection to AppHost at {SocketPath}", socketPath); var startTime = DateTimeOffset.UtcNow; - var connectionTimeout = GetBackchannelConnectionTimeout(configuration); + var connectionTimeout = AppHostStartupTimeout.GetBackchannelConnectionTimeout(configuration); do { @@ -659,24 +676,6 @@ private static bool HasBundleRoot(string bundleRoot) && BundleDiscovery.TryDiscoverManagedFromDirectory(bundleRoot, out _); } - internal static TimeSpan GetBackchannelConnectionTimeout(IConfiguration configuration) - { - var configuredValue = configuration[KnownConfigNames.CliBackchannelConnectTimeoutSeconds]; - if (double.TryParse(configuredValue, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0) - { - return TimeSpan.FromSeconds(seconds); - } - - var timeout = TimeSpan.FromSeconds(WaitCommand.DefaultTimeoutSeconds); - var configuredStartupTimeout = configuration[CliConfigNames.AppHostStartupTimeout]; - if (int.TryParse(configuredStartupTimeout, CultureInfo.InvariantCulture, out var startupTimeoutSeconds) && startupTimeoutSeconds > timeout.TotalSeconds) - { - timeout = TimeSpan.FromSeconds(startupTimeoutSeconds); - } - - return timeout; - } - // Cache expiry/max age handled inside DiskCache implementation. public async Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken) @@ -915,16 +914,19 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, // Add --verbose flag when using watch and debug is enabled var verboseSwitch = watch && options.Debug ? "--verbose" : string.Empty; - string[] cliArgs = isSingleFile switch + string[] cliOptions = isSingleFile switch { - false => [watchOrRunCommand, nonInteractiveSwitch, verboseSwitch, noBuildSwitch, noRestoreSwitch, noProfileSwitch, "--project", projectFile.FullName, "--", .. args], + false => [watchOrRunCommand, nonInteractiveSwitch, verboseSwitch, noBuildSwitch, noRestoreSwitch, noProfileSwitch, "--project", projectFile.FullName], // File-based dotnet run only recomputes RunCommand during build. Omit --no-build // for single-file AppHosts so the suppression property is applied before launch // and a CLI-launched AppHost cannot recursively enter the run hook. - true => ["run", noRestoreSwitch, noProfileSwitch, suppressCliRunHookProperty, "--file", projectFile.FullName, "--", .. args] + true => ["run", noRestoreSwitch, noProfileSwitch, suppressCliRunHookProperty, "--file", projectFile.FullName] }; - cliArgs = [.. cliArgs.Where(arg => !string.IsNullOrWhiteSpace(arg))]; + // Empty extension-owned entries represent omitted optional switches, while empty or + // whitespace-only entries after "--" are valid AppHost arguments. Filter only the + // option prefix so application argument boundaries survive CLI-to-extension delegation. + string[] cliArgs = [.. cliOptions.Where(arg => !string.IsNullOrWhiteSpace(arg)), "--", .. args]; var finalEnv = CreateRunEnvironment( env, diff --git a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs index 3b51cf68cba..e16f22d6e39 100644 --- a/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs +++ b/src/Aspire.Cli/DotNet/ProcessExecutionFactory.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Processes; using Aspire.Cli.Bundles; using Aspire.Cli.Layout; +using Aspire.Cli.Utils; using Aspire.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -46,7 +47,14 @@ public IProcessExecution CreateExecution(string fileName, string[] args, IDictio { var effectiveLogger = options.SuppressLogging ? (ILogger)NullLogger.Instance : _logger; - effectiveLogger.LogDebug("Running {FileName} in {WorkingDirectory} with args: {Args}", fileName, workingDirectory.FullName, string.Join(" ", args)); + // `dotnet run --project AppHost.csproj -- ` reaches this factory with the + // forwarded AppHost arguments still attached, so redact past the separator before logging. + // Direct AppHost launches have no separator at all and instead declare the boundary through + // ProcessInvocationOptions.AppHostArgumentStartIndex. + var loggableArgs = options.AppHostArgumentStartIndex is { } appHostArgumentStartIndex + ? AppHostArgumentRedactor.RedactFromToString(args, appHostArgumentStartIndex) + : AppHostArgumentRedactor.RedactToString(args); + effectiveLogger.LogDebug("Running {FileName} in {WorkingDirectory} with args: {Args}", fileName, workingDirectory.FullName, loggableArgs); if (env is not null) { @@ -93,7 +101,9 @@ public IProcessExecution CreateExecution(System.Diagnostics.ProcessStartInfo sta { var effectiveLogger = options.SuppressLogging ? (ILogger)NullLogger.Instance : _logger; - effectiveLogger.LogDebug("Running {FileName} in {WorkingDirectory} with args: {Args}", startInfo.FileName, startInfo.WorkingDirectory, string.Join(" ", startInfo.ArgumentList)); + // Same redaction boundary as the ArgumentList-building overload: anything after the first + // "--" is application input forwarded to the AppHost and must not be logged verbatim. + effectiveLogger.LogDebug("Running {FileName} in {WorkingDirectory} with args: {Args}", startInfo.FileName, startInfo.WorkingDirectory, AppHostArgumentRedactor.RedactToString(startInfo.ArgumentList)); var isolatedStartInfo = new IsolatedProcessStartInfo { diff --git a/src/Aspire.Cli/Git/GitWorktree.cs b/src/Aspire.Cli/Git/GitWorktree.cs new file mode 100644 index 00000000000..8b8a5154bcb --- /dev/null +++ b/src/Aspire.Cli/Git/GitWorktree.cs @@ -0,0 +1,427 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; + +namespace Aspire.Cli.Git; + +/// +/// Detects git linked worktrees from the filesystem without spawning git. +/// +/// +/// Git stores linked-worktree metadata in these shapes: +/// +/// Standard: /repo/.git/worktrees/feature +/// Bare: /repo.git/worktrees/feature +/// Separate: /separate-git/worktrees/feature +/// Submodule: /repo/.git/worktrees/feature/modules/dependency +/// +/// /checkout/.git: +/// gitdir: /repo/.git/worktrees/feature +/// +/// /repo/.git/worktrees/feature/gitdir: +/// /checkout/.git +/// +/// The admin directory's gitdir back-pointer distinguishes a real linked worktree +/// from stale metadata, while requiring its direct parent to be worktrees excludes +/// submodules nested under a linked worktree's modules directory. +/// See Git worktree documentation. +/// +internal static class GitWorktree +{ + private const int MaxAncestorWalks = 64; + private const string GitDirPrefix = "gitdir:"; + private const string GitDirFileName = "gitdir"; + private const string GitDirectoryName = ".git"; + private const string WorktreesSegment = "worktrees"; + + /// + /// Returns the root of the linked worktree that contains , + /// or null when the path is in the primary checkout, a submodule, or not a git repo. + /// + public static string? TryGetLinkedWorktreeRoot(string? startPath) + { + if (string.IsNullOrWhiteSpace(startPath)) + { + return null; + } + + string current; + try + { + current = GetWalkStartDirectory(startPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + return null; + } + + for (var i = 0; i < MaxAncestorWalks; i++) + { + var gitPath = Path.Combine(current, GitDirectoryName); + + if (Directory.Exists(gitPath)) + { + // Primary checkout (or any clone with a .git directory). Stop so a nested + // path cannot be classified as a worktree of an ancestor repo. + return null; + } + + if (File.Exists(gitPath)) + { + return IsLinkedWorktreeGitFile(gitPath) + ? CanonicalizePath(current) + : null; + } + + var parent = Directory.GetParent(current); + if (parent is null || string.Equals(parent.FullName, current, StringComparison.Ordinal)) + { + return null; + } + + current = parent.FullName; + } + + return null; + } + + /// + /// Whether should be treated as in the same git worktree + /// as for stop/ps scoping. + /// + /// + /// Both paths are reduced to the scope identity described on + /// . Two paths share a scope only when both identities + /// are non- and equal, or when both are (neither + /// path is in a git repository at all). Because a primary checkout now identifies as its own + /// root, running aspire stop in repository A cannot reach an AppHost in unrelated + /// repository B, and a nested .worktrees/feature checkout still cannot be stolen by + /// the primary checkout that contains it. + /// + public static bool IsSameWorktreeScope(string appHostPath, string workingDirectory) + { + var workingScopeRoot = TryGetScopeWorktreeRoot(workingDirectory); + var appHostScopeRoot = TryGetScopeWorktreeRoot(appHostPath); + + if (workingScopeRoot is null || appHostScopeRoot is null) + { + // "Not in a git repository" is only ever equivalent to another path that is also + // outside every repository. Pairing it with a real checkout root would let an + // unrelated stray path fall into a repository's scope. + return workingScopeRoot is null && appHostScopeRoot is null; + } + + return PathsEqual(workingScopeRoot, appHostScopeRoot); + } + + /// + /// Returns the canonical root of the checkout that owns , used as + /// the scope identity for stop/ps filtering. + /// + /// + /// The identity is three-way and each case must stay distinguishable: + /// + /// inside a linked worktree: the canonical linked worktree root. + /// inside a primary checkout: the canonical primary checkout root. + /// not inside any git repository: . + /// + /// Collapsing the second and third cases to would make every primary + /// checkout compare equal to every other primary checkout and to every non-git directory. + /// + private static string? TryGetScopeWorktreeRoot(string startPath) + { + string current; + try + { + current = GetWalkStartDirectory(startPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + return null; + } + + for (var i = 0; i < MaxAncestorWalks; i++) + { + var gitPath = Path.Combine(current, GitDirectoryName); + if (Directory.Exists(gitPath)) + { + // Primary checkout (or any clone with a real .git directory). Its own root is the + // scope identity so two unrelated clones never compare equal. + return CanonicalizePath(current); + } + + if (File.Exists(gitPath)) + { + if (IsLinkedWorktreeGitFile(gitPath)) + { + return CanonicalizePath(current); + } + + // A submodule's .git is a file whose gitdir points into the superproject: + // /repo/extern/dep/.git -> gitdir: /repo/.git/modules/dep + // /repo/.worktrees/feature/extern/dep/.git + // -> gitdir: /repo/.git/worktrees/feature/modules/dep + // A submodule is a separate checkout, but for scoping it belongs to whatever + // checkout physically contains it: the enclosing linked worktree when the admin + // directory sits under /worktrees//, ... + if (TryReadGitDirTarget(gitPath, out var gitDirectory) && + TryGetEnclosingLinkedWorktreeRoot(gitDirectory) is { } enclosingWorktreeRoot) + { + return enclosingWorktreeRoot; + } + + // ... otherwise keep walking ancestors so a submodule in a primary checkout + // resolves to that checkout's root instead of collapsing to null, which would + // make a submodule of repository A compare equal to a submodule of repository B. + } + + var parent = Directory.GetParent(current); + if (parent is null || string.Equals(parent.FullName, current, StringComparison.Ordinal)) + { + return null; + } + + current = parent.FullName; + } + + return null; + } + + private static string GetWalkStartDirectory(string startPath) + { + var fullPath = Path.GetFullPath(startPath); + if (Directory.Exists(fullPath)) + { + return fullPath; + } + + var directory = Path.GetDirectoryName(fullPath); + return string.IsNullOrEmpty(directory) ? fullPath : directory; + } + + private static bool IsLinkedWorktreeGitFile(string gitFilePath) + { + if (!TryReadGitDirTarget(gitFilePath, out var adminDirectory) || + !Directory.Exists(adminDirectory)) + { + return false; + } + + var canonicalAdminDirectory = CanonicalizePath(adminDirectory); + var adminParent = Directory.GetParent(Path.TrimEndingDirectorySeparator(canonicalAdminDirectory)); + if (!IsWorktreesDirectory(adminParent)) + { + return false; + } + + // Git resolves this back-pointer from the physical admin directory, even when + // the checkout's .git file reached that directory through an alias. + if (!TryReadPath( + Path.Combine(canonicalAdminDirectory, GitDirFileName), + canonicalAdminDirectory, + out var checkoutGitFile)) + { + return false; + } + + return PathsEqual(checkoutGitFile, gitFilePath); + } + + private static string? TryGetEnclosingLinkedWorktreeRoot(string gitDirectory) + { + var current = new DirectoryInfo(CanonicalizePath(gitDirectory)); + for (var i = 0; i < MaxAncestorWalks && current.Parent is not null; i++) + { + if (IsWorktreesDirectory(current.Parent) && + TryReadPath( + Path.Combine(current.FullName, GitDirFileName), + current.FullName, + out var checkoutGitFile) && + File.Exists(checkoutGitFile) && + IsLinkedWorktreeGitFile(checkoutGitFile)) + { + return Path.GetDirectoryName(CanonicalizePath(checkoutGitFile)); + } + + current = current.Parent; + } + + return null; + } + + private static bool IsWorktreesDirectory(DirectoryInfo? directory) + { + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return directory?.Name.Equals(WorktreesSegment, comparison) is true; + } + + private static bool TryReadGitDirTarget(string gitFilePath, out string gitDirectory) + { + gitDirectory = string.Empty; + // Relative gitdir values are based on the physical directory containing this + // metadata file, not the lexical checkout alias used to discover it. + var canonicalGitFilePath = CanonicalizePath(gitFilePath); + var metadataDirectory = Path.GetDirectoryName(canonicalGitFilePath); + if (string.IsNullOrEmpty(metadataDirectory) || + !TryReadFile(canonicalGitFilePath, out var contents)) + { + return false; + } + + foreach (var rawLine in contents.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Trim(); + if (!line.StartsWith(GitDirPrefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var gitDir = line[GitDirPrefix.Length..].Trim(); + if (gitDir.Length == 0) + { + return false; + } + + return TryResolvePath(gitDir, metadataDirectory, out gitDirectory); + } + + return false; + } + + private static bool TryReadPath(string filePath, string relativeTo, out string resolvedPath) + { + resolvedPath = string.Empty; + if (!TryReadFile(filePath, out var contents)) + { + return false; + } + + var value = contents.Trim(); + return value.Length > 0 && TryResolvePath(value, relativeTo, out resolvedPath); + } + + private static bool TryReadFile(string filePath, out string contents) + { + try + { + contents = File.ReadAllText(filePath); + return true; + } + catch (IOException) + { + contents = string.Empty; + return false; + } + catch (UnauthorizedAccessException) + { + contents = string.Empty; + return false; + } + } + + private static bool TryResolvePath(string value, string relativeTo, out string resolvedPath) + { + try + { + resolvedPath = Path.IsPathRooted(value) + ? Path.GetFullPath(value) + : Path.GetFullPath(Path.Combine(relativeTo, value)); + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException) + { + resolvedPath = string.Empty; + return false; + } + } + + private static bool PathsEqual(string left, string right) + { + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals( + CanonicalizePath(left), + CanonicalizePath(right), + comparison); + } + + private static string CanonicalizePath(string path) + { + var resolvedPath = PathNormalizer.ResolveSymlinks(path); + if (!OperatingSystem.IsMacOS() && !OperatingSystem.IsWindows()) + { + return resolvedPath; + } + + var root = Path.GetPathRoot(resolvedPath); + if (string.IsNullOrEmpty(root)) + { + return resolvedPath; + } + + var segments = resolvedPath[root.Length..].Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + var current = root; + + foreach (var segment in segments) + { + var candidate = Path.Combine(current, segment); + if (!File.Exists(candidate) && !Directory.Exists(candidate)) + { + return resolvedPath; + } + + try + { + string? exactMatch = null; + string? caseInsensitiveMatch = null; + foreach (var entry in Directory.EnumerateFileSystemEntries(current)) + { + var entryName = Path.GetFileName(entry); + if (entryName.Equals(segment, StringComparison.Ordinal)) + { + exactMatch = entry; + break; + } + + if (caseInsensitiveMatch is null && + entryName.Equals(segment, StringComparison.OrdinalIgnoreCase)) + { + caseInsensitiveMatch = entry; + } + } + + if (exactMatch is not null) + { + current = exactMatch; + } + else if (caseInsensitiveMatch is not null) + { + // Path APIs preserve the caller's casing. Recover the stored casing only + // when the filesystem resolved the variant, so case-sensitive volumes still + // reject stale paths that differ only by case. + current = caseInsensitiveMatch; + } + else + { + current = candidate; + } + } + catch (IOException) + { + return resolvedPath; + } + catch (UnauthorizedAccessException) + { + return resolvedPath; + } + } + + return current; + } +} diff --git a/src/Aspire.Cli/Layout/LayoutConfiguration.cs b/src/Aspire.Cli/Layout/LayoutConfiguration.cs index 08139cb02b0..bf6ef02bcb3 100644 --- a/src/Aspire.Cli/Layout/LayoutConfiguration.cs +++ b/src/Aspire.Cli/Layout/LayoutConfiguration.cs @@ -11,11 +11,13 @@ namespace Aspire.Cli.Layout; public enum LayoutComponent { /// CLI executable. - Cli, + Cli = 0, /// Developer Control Plane. - Dcp, - /// Unified managed binary (dashboard, server, nuget). - Managed + Dcp = 1, + /// Unified managed binary (server, NuGet, terminal host). + Managed = 2, + /// Dashboard executable and static assets. + Dashboard = 3 } /// @@ -63,6 +65,7 @@ public sealed class LayoutConfiguration { LayoutComponent.Cli => Components.Cli, LayoutComponent.Dcp => Components.Dcp, + LayoutComponent.Dashboard => Components.Dashboard, LayoutComponent.Managed => Components.Managed, _ => null }; @@ -89,6 +92,42 @@ public sealed class LayoutConfiguration return Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); } + + /// + /// Gets the path to the Native AOT Dashboard executable, falling back to the legacy unified binary. + /// + /// The path to the Dashboard executable. + public string? GetDashboardPath() + { + // Current bundles keep the Native AOT Dashboard and its static assets isolated from + // aspire-managed so each executable can use its own content root. + var dashboardDir = GetComponentPath(LayoutComponent.Dashboard); + if (dashboardDir is not null) + { + var dashboardPath = Path.Combine( + dashboardDir, + BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + if (File.Exists(dashboardPath)) + { + return dashboardPath; + } + } + + // Preserve compatibility with bundles created before dashboard/ became a separate + // component. Transitional bundles placed Aspire.Dashboard in managed/, while older + // bundles dispatch the "dashboard" subcommand through aspire-managed itself. + var managedDir = GetComponentPath(LayoutComponent.Managed); + if (managedDir is null) + { + return null; + } + + var legacyDashboardPath = Path.Combine( + managedDir, + BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + + return File.Exists(legacyDashboardPath) ? legacyDashboardPath : GetManagedPath(); + } } /// @@ -106,6 +145,11 @@ public sealed class LayoutComponents /// public string? Dcp { get; set; } = BundleDiscovery.DcpDirectoryName; + /// + /// Path to the Dashboard executable and static assets directory. + /// + public string? Dashboard { get; set; } = BundleDiscovery.DashboardDirectoryName; + /// /// Path to the unified managed binary directory. /// diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs index b0038c8d9f6..4d275b4dd2c 100644 --- a/src/Aspire.Cli/Layout/LayoutDiscovery.cs +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -246,7 +246,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) private LayoutConfiguration? TryInferLayout(string layoutPath) { - // New layout: a single bundle/ link whose target contains managed/ and dcp/. + // New layout: a single bundle/ link whose target contains managed/, dashboard/, and dcp/. var bundlePath = Path.Combine(layoutPath, BundleDiscovery.BundleDirectoryName); var bundleManagedPath = Path.Combine(bundlePath, BundleDiscovery.ManagedDirectoryName); var bundleDcpPath = Path.Combine(bundlePath, BundleDiscovery.DcpDirectoryName); @@ -272,6 +272,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) Components = new LayoutComponents { Dcp = Path.Combine(BundleDiscovery.BundleDirectoryName, BundleDiscovery.DcpDirectoryName), + Dashboard = Path.Combine(BundleDiscovery.BundleDirectoryName, BundleDiscovery.DashboardDirectoryName), Managed = Path.Combine(BundleDiscovery.BundleDirectoryName, BundleDiscovery.ManagedDirectoryName), } }; diff --git a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs index e54b3d0c571..178a2c93d4b 100644 --- a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs +++ b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Globalization; using System.Text; using Aspire.Cli.DotNet; using Aspire.Cli.Processes; @@ -13,10 +12,6 @@ namespace Aspire.Cli.Layout; /// internal sealed class LayoutProcessRunner(IProcessExecutionFactory executionFactory) { - // Layout helpers include network-bound NuGet operations. If one wedges, the helper needs its own - // command-level bound instead of relying on an outer test hang dump or a hard-killed parent CLI. - internal static TimeSpan DefaultRunTimeout { get; } = TimeSpan.FromMinutes(3); - /// public async Task<(int ExitCode, string Output, string Error)> RunAsync( string toolPath, @@ -24,16 +19,10 @@ internal sealed class LayoutProcessRunner(IProcessExecutionFactory executionFact string? workingDirectory = null, IDictionary? environmentVariables = null, bool killOnParentExit = false, - CancellationToken ct = default, - TimeSpan? timeout = null) + CancellationToken ct = default) { var outputBuilder = new StringBuilder(); var errorBuilder = new StringBuilder(); - var effectiveTimeout = timeout ?? DefaultRunTimeout; - if (effectiveTimeout <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "The process timeout must be greater than zero."); - } var options = new ProcessInvocationOptions { @@ -66,17 +55,7 @@ internal sealed class LayoutProcessRunner(IProcessExecutionFactory executionFact throw new InvalidOperationException($"Failed to start process: {toolPath}"); } - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(effectiveTimeout); - int exitCode; - try - { - exitCode = await execution.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested && timeoutCts.IsCancellationRequested) - { - throw new TimeoutException(BuildTimeoutMessage(toolPath, args, workDir, effectiveTimeout)); - } + var exitCode = await execution.WaitForExitAsync(ct).ConfigureAwait(false); return (exitCode, outputBuilder.ToString(), errorBuilder.ToString()); } @@ -136,65 +115,4 @@ private static IDictionary CopyEnvironment(IDictionary environmentVariables is null ? new Dictionary(StringComparer.Ordinal) : new Dictionary(environmentVariables, StringComparer.Ordinal); - - private static string BuildTimeoutMessage(string toolPath, IReadOnlyList arguments, DirectoryInfo workingDirectory, TimeSpan timeout) - { - var timeoutText = FormatTimeout(timeout); - if (arguments is ["nuget", "search", ..]) - { - var query = TryGetArgumentValue(arguments, "--query"); - var packageDescription = string.IsNullOrEmpty(query) - ? "NuGet package search" - : $"NuGet package search for '{query}'"; - return $"{packageDescription} timed out after {timeoutText} contacting configured NuGet sources from '{workingDirectory.FullName}'."; - } - - if (arguments is ["nuget", "restore", ..]) - { - return $"NuGet package restore timed out after {timeoutText} contacting configured NuGet sources from '{workingDirectory.FullName}'."; - } - - return $"Process '{Path.GetFileName(toolPath)}' timed out after {timeoutText} in '{workingDirectory.FullName}'."; - } - - private static string? TryGetArgumentValue(IReadOnlyList arguments, string name) - { - for (var i = 0; i < arguments.Count - 1; i++) - { - if (string.Equals(arguments[i], name, StringComparison.Ordinal)) - { - return arguments[i + 1]; - } - } - - return null; - } - - private static string FormatTimeout(TimeSpan timeout) - { - if (timeout.TotalHours >= 1 && timeout.TotalMinutes % 60 == 0) - { - return FormatTimeoutUnit(timeout.TotalHours, "0", "hour", "hours"); - } - - if (timeout.TotalMinutes >= 1 && timeout.TotalSeconds % 60 == 0) - { - return FormatTimeoutUnit(timeout.TotalMinutes, "0", "minute", "minutes"); - } - - if (timeout.TotalSeconds >= 1) - { - return FormatTimeoutUnit(timeout.TotalSeconds, "0.###", "second", "seconds"); - } - - return FormatTimeoutUnit(timeout.TotalMilliseconds, "0.###", "millisecond", "milliseconds"); - } - - private static string FormatTimeoutUnit(double value, string format, string singularUnit, string pluralUnit) - { - var formattedValue = value.ToString(format, CultureInfo.InvariantCulture); - var unit = string.Equals(formattedValue, "1", StringComparison.Ordinal) ? singularUnit : pluralUnit; - - return $"{formattedValue} {unit}"; - } } diff --git a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs index d8da54c2eb3..f13e2096ea9 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Layout; using Aspire.Cli.Resources; +using Aspire.Shared; using Microsoft.Extensions.Logging; using NuGetPackage = Aspire.Shared.NuGetPackageCli; @@ -224,7 +225,11 @@ private async Task> SearchPackagesInternalAsync( return []; } - var result = JsonSerializer.Deserialize(output, BundleSearchJsonContext.Default.BundleSearchResult); + // The aspire-managed helper writes the search result as JSON to stdout, but a NuGet credential + // provider can write diagnostics before or after that payload. Extract exactly one complete root object + // with the expected bundle-search shape so provider diagnostics cannot be deserialized as the result. + // See https://github.com/microsoft/aspire/issues/19339. + var result = JsonSerializer.Deserialize(PackageUpdateHelpers.ExtractJsonPayload(output, IsBundleSearchPayload), BundleSearchJsonContext.Default.BundleSearchResult); if (result?.Packages is null) { return []; @@ -263,6 +268,15 @@ private async Task> SearchPackagesInternalAsync( } } + private static bool IsBundleSearchPayload(JsonElement root) + { + return root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("packages", out var packages) && + packages.ValueKind == JsonValueKind.Array && + root.TryGetProperty("totalHits", out var totalHits) && + totalHits.ValueKind == JsonValueKind.Number; + } + private IEnumerable FilterPackages(IEnumerable packages, Func? filter) { var showDeprecatedPackages = _features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false); diff --git a/src/Aspire.Cli/Profiling/ProfileCaptureService.cs b/src/Aspire.Cli/Profiling/ProfileCaptureService.cs index 11fe1d544f4..31efe524e93 100644 --- a/src/Aspire.Cli/Profiling/ProfileCaptureService.cs +++ b/src/Aspire.Cli/Profiling/ProfileCaptureService.cs @@ -59,36 +59,39 @@ internal async Task StartAsync( { ArgumentNullException.ThrowIfNull(options); - var managedPath = ResolveManagedPathOverride(configuration); + var dashboardPath = ResolveManagedPathOverride(configuration); BundleLayoutLease? layoutLease = null; - if (managedPath is null) + if (dashboardPath is null) { layoutLease = await bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "profile-dashboard", cancellationToken).ConfigureAwait(false); var layout = layoutLease?.Layout; - managedPath = layout?.GetManagedPath(); + dashboardPath = layout?.GetDashboardPath(); } // `ASPIRE_REPO_ROOT` is the shared opt-in for repo-local assets. Avoid independently // walking from the command directory or process path here, because installed CLIs should not // accidentally bind to a nearby checkout while profiling unrelated applications. - managedPath ??= ResolveRepoLocalManagedPath(configuration[BundleDiscovery.RepoRootEnvVar]); + dashboardPath ??= ResolveRepoLocalDashboardPath(configuration[BundleDiscovery.RepoRootEnvVar]); - if (managedPath is null || !File.Exists(managedPath)) + if (dashboardPath is null || !File.Exists(dashboardPath)) { layoutLease?.Dispose(); throw new InvalidOperationException(DashboardCommandStrings.ManagedBinaryNotFound); } var outputCollector = new OutputCollector(fileLoggerProvider, "ProfileDashboard"); - var dashboardArgs = new[] + var dashboardArgs = new List { - "dashboard", $"--{KnownAspNetCoreConfigNames.Urls}={options.DashboardUrl}", $"--{KnownConfigNames.DashboardOtlpGrpcEndpointUrl}={options.OtlpGrpcUrl}", $"--{KnownConfigNames.DashboardOtlpHttpEndpointUrl}={options.OtlpHttpUrl}", $"--{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=true", $"--{KnownConfigNames.DashboardApiEnabled}=true" }; + if (BundleDiscovery.IsAspireManagedBinary(dashboardPath)) + { + dashboardArgs.Insert(0, "dashboard"); + } var processOptions = new ProcessInvocationOptions { @@ -102,13 +105,13 @@ internal async Task StartAsync( var environmentVariables = CreateDashboardEnvironment(); layoutLease?.AddEnvironment(environmentVariables); - // Launch aspire-managed directly instead of calling `aspire dashboard run`. Calling + // Launch the bundle-owned Dashboard directly instead of calling `aspire dashboard run`. Calling // back through the CLI being profiled would recursively apply --capture-profile and // make the collector part of the measurement. // Bind the collector dashboard to the Windows kill-on-close job so it cannot outlive a // hard-killed CLI (OS-level backstop on top of the cross-platform watchdog). No-op off Windows. dashboardProcess = await layoutProcessRunner.StartAsync( - managedPath, + dashboardPath, dashboardArgs, environmentVariables: environmentVariables, options: processOptions, @@ -167,8 +170,7 @@ internal static Dictionary CreateDashboardEnvironment() // configuration-backed so callers can set them via environment variables or other CLI config // providers without this path reading process environment directly. - // ASPIRE_DASHBOARD_PATH is the older hosting/DCP override name. It is expected to point - // directly at the aspire-managed executable that hosts the dashboard collector. + // ASPIRE_DASHBOARD_PATH is the hosting/DCP override for the Dashboard executable. if (configuration[BundleDiscovery.DashboardPathEnvVar] is { Length: > 0 } dashboardPath && File.Exists(dashboardPath)) { @@ -194,7 +196,7 @@ internal static Dictionary CreateDashboardEnvironment() return null; } - internal static string? ResolveRepoLocalManagedPath(string? repoRoot) + internal static string? ResolveRepoLocalDashboardPath(string? repoRoot) { if (string.IsNullOrEmpty(repoRoot)) { @@ -202,17 +204,17 @@ internal static Dictionary CreateDashboardEnvironment() } // `ASPIRE_REPO_ROOT` is a dev-build escape hatch, so keep it predictable: use the same Debug - // net10.0 output produced by the normal repo-local build instead of scanning every artifact + // net11.0 output produced by the normal repo-local build instead of scanning every artifact // folder and guessing between configurations. - var managedPath = Path.Combine( + var dashboardPath = Path.Combine( repoRoot, "artifacts", "bin", - "Aspire.Managed", + "Aspire.Dashboard", "Debug", - "net10.0", - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - return File.Exists(managedPath) ? managedPath : null; + "net11.0", + BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + return File.Exists(dashboardPath) ? dashboardPath : null; } internal sealed class ProfileCaptureSession : IAsyncDisposable diff --git a/src/Aspire.Cli/Profiling/ProfileCaptureState.cs b/src/Aspire.Cli/Profiling/ProfileCaptureState.cs new file mode 100644 index 00000000000..507e5353218 --- /dev/null +++ b/src/Aspire.Cli/Profiling/ProfileCaptureState.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Profiling; + +/// +/// Tracks whether command execution was transferred to a delegated CLI process. +/// +internal sealed class ProfileCaptureState +{ + // A delegated command writes this before the same command flow reads it, so no synchronization is required. + internal bool IsTransferred { get; private set; } + + internal void MarkTransferred() => IsTransferred = true; +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 06e2c64f023..e559d19b2ed 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -504,6 +504,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -544,6 +545,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -1078,6 +1080,7 @@ public static async Task Main(string[] args) var telemetry = app.Services.GetRequiredService(); var telemetryManager = app.Services.GetRequiredService(); var profilingTelemetry = app.Services.GetRequiredService(); + var profileCaptureState = app.Services.GetRequiredService(); // Log feature state at startup for diagnostics app.Services.GetRequiredService().LogFeatureState(); @@ -1132,12 +1135,20 @@ public static async Task Main(string[] args) profileCaptureSession = await app.Services.GetRequiredService().StartAsync(profileCaptureOptions, cancellationManager.Token).ConfigureAwait(false); } - // Log command invocation details for debugging - var commandLine = args.Length > 0 ? $"aspire {string.Join(" ", args)}" : "aspire"; + // Parse before logging. `aspire run --ApiKey sk-live-...` forwards unmatched tokens + // to the AppHost even though the user never typed a `--` separator, so the parse + // tree is the only reliable way to tell CLI-owned tokens from AppHost input. + // Reordering is safe because Parse collects errors into the result instead of + // throwing, and nothing between here and the original call site inspects args. + var parseResult = rootCommand.Parse(args); + + // Log command invocation details for debugging. Anything forwarded to the AppHost + // can contain secrets, so it is redacted. + var loggableArgs = ParseResultHelper.GetLoggableArguments(parseResult); + var commandLine = loggableArgs.Length > 0 ? $"aspire {loggableArgs}" : "aspire"; logger.LogInformation("Command: {CommandLine}", commandLine); - logger.LogDebug("Parsing arguments: {Args}", string.Join(" ", args)); - var parseResult = rootCommand.Parse(args); + logger.LogDebug("Parsing arguments: {Args}", loggableArgs); #if DEBUG WaitForDebuggerIfRequested(parseResult, app.Services, WaitForDebugger); @@ -1213,7 +1224,9 @@ public static async Task Main(string[] args) } } - if (profileCaptureSession is not null) + // This state is only consulted when the parent started a capture session. A successful + // extension handoff transfers export to the child, while the parent still disposes its session. + if (profileCaptureSession is not null && !profileCaptureState.IsTransferred) { try { diff --git a/src/Aspire.Cli/Projects/DefaultLanguageDiscovery.cs b/src/Aspire.Cli/Projects/DefaultLanguageDiscovery.cs index 81ae6ceee8d..e3d6875088e 100644 --- a/src/Aspire.Cli/Projects/DefaultLanguageDiscovery.cs +++ b/src/Aspire.Cli/Projects/DefaultLanguageDiscovery.cs @@ -16,6 +16,11 @@ namespace Aspire.Cli.Projects; /// internal sealed class DefaultLanguageDiscovery(IFeatures features) : ILanguageDiscovery { + /// + /// Every supported language, including the ones whose feature flag is off. + /// + internal static IReadOnlyList AllLanguages => s_allLanguages; + private static readonly LanguageInfo[] s_allLanguages = [ new LanguageInfo( @@ -55,7 +60,8 @@ internal sealed class DefaultLanguageDiscovery(IFeatures features) : ILanguageDi DetectionPatterns: ["AppHost.java"], CodeGenerator: "Java", AppHostFileName: "AppHost.java", - IsExperimental: true), + IsExperimental: true, + PreserveUnchangedGeneratedFiles: true), new LanguageInfo( LanguageId: new LanguageId(KnownLanguageId.Rust), DisplayName: KnownLanguageId.RustDisplayName, diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 10d5334815a..814c1c70f64 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -1575,6 +1575,12 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken using var runDotnetActivity = _profilingTelemetry.StartAppHostRunDotnetLifetime(watch, noBuild, context.NoRestore); if (directRun is not null) { + // The direct command line has no "--" separator, so the forwarded-argument boundary + // has to be carried alongside it for logging. Clone rather than mutate because the + // caller may reuse runOptions for other invocations. + var directRunOptions = runOptions.Clone(); + directRunOptions.AppHostArgumentStartIndex = directRun.AppHostArgumentStartIndex; + return await _runner.RunAppHostCommandAsync( effectiveAppHostFile, directRun.Command, @@ -1582,7 +1588,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken directRun.Arguments, directRun.Environment, backchannelCompletionSource, - runOptions, + directRunOptions, cancellationToken); } @@ -1813,13 +1819,17 @@ private async Task EnsureDevCertificatesTrustedAsync(AppHostProjectContext conte arguments.AddRange(unmatchedTokens); + // Everything before this index came from MSBuild RunArguments or the launch profile; the + // tail is user-supplied AppHost input that can carry connection strings and API keys. + var appHostArgumentStartIndex = arguments.Count - unmatchedTokens.Length; + _logger.LogDebug( "Launching AppHost directly via {Command} in {WorkingDirectory} with arguments {Arguments}.", command, workingDirectory.FullName, - string.Join(" ", arguments)); + AppHostArgumentRedactor.RedactFromToString(arguments, appHostArgumentStartIndex)); - return new DirectAppHostRunSpec(command, workingDirectory, [.. arguments], directEnv); + return new DirectAppHostRunSpec(command, workingDirectory, [.. arguments], directEnv, appHostArgumentStartIndex); } private async Task IsDirectLaunchDisabledAsync(FileInfo effectiveAppHostFile, CancellationToken cancellationToken) @@ -2499,10 +2509,10 @@ private void ConfigureCliBundleEnvironment( } if (!IsUsableDashboardPath(GetEffectiveEnvironmentValue(env, BundleDiscovery.DashboardPathEnvVar)) && - layout.GetManagedPath() is { } layoutManagedPath && - IsUsableDashboardPath(layoutManagedPath)) + layout.GetDashboardPath() is { } layoutDashboardPath && + IsUsableDashboardPath(layoutDashboardPath)) { - env[BundleDiscovery.DashboardPathEnvVar] = layoutManagedPath; + env[BundleDiscovery.DashboardPathEnvVar] = layoutDashboardPath; } } @@ -2608,5 +2618,6 @@ private sealed record DirectAppHostRunSpec( string Command, DirectoryInfo WorkingDirectory, string[] Arguments, - Dictionary Environment); + Dictionary Environment, + int AppHostArgumentStartIndex); } diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 034cd7b3d3a..1427fc1ea95 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -7,6 +7,7 @@ using System.Text; using System.Xml.Linq; using Aspire.Cli.Configuration; +using Aspire.Cli.Documentation; using Aspire.Cli.DotNet; using Aspire.Cli.Packaging; using Aspire.Cli.Processes; @@ -25,6 +26,12 @@ internal sealed class DotNetBasedAppHostServerProject : IAppHostServerProject { private const string ProjectHashFileName = ".projecthash"; private const string AppsFolder = "hosts"; + + /// + /// Bump when the scaffold's shape changes in a way that requires a rewrite even though the + /// generated content for a given input would hash the same as a previously-cached scaffold. + /// + private const int ScaffoldSchemaVersion = 1; public const string ProjectFileName = "AppHostServer.csproj"; private const string ProjectDllName = "AppHostServer.dll"; internal const string TargetFramework = "net10.0"; @@ -99,7 +106,7 @@ public DotNetBasedAppHostServerProject( /// public string GetProjectFilePath() => Path.Combine(_projectModelPath, ProjectFileName); - public string GetProjectHash() + private string GetProjectHash() { var hashFilePath = Path.Combine(_projectModelPath, ProjectHashFileName); @@ -111,7 +118,7 @@ public string GetProjectHash() return string.Empty; } - public void SaveProjectHash(string hash) + private void SaveProjectHash(string hash) { var hashFilePath = Path.Combine(_projectModelPath, ProjectHashFileName); File.WriteAllText(hashFilePath, hash); @@ -153,7 +160,7 @@ private XDocument CreateProjectFile(IEnumerable integratio 42.42.42 $([MSBuild]::EnsureTrailingSlash('$(NuGetPackageRoot)')){dcpPackageName}/{dcpVersion}/tools/ - {_repoRoot}artifacts/bin/Aspire.Dashboard/Debug/net8.0/ + {_repoRoot}artifacts/bin/Aspire.Dashboard/Debug/net11.0/ @@ -267,20 +274,6 @@ private XDocument CreateProjectFile(IEnumerable integratio string? packageSourceOverride = null, CancellationToken cancellationToken = default) { - // Clean obj folder to ensure fresh NuGet restore - var objPath = Path.Combine(_projectModelPath, "obj"); - if (Directory.Exists(objPath)) - { - try - { - Directory.Delete(objPath, recursive: true); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to delete obj folder at {ObjPath}", objPath); - } - } - // Create Program.cs var programCs = """ await Aspire.Hosting.RemoteHost.RemoteHostServer.RunAsync(args); @@ -319,17 +312,14 @@ private XDocument CreateProjectFile(IEnumerable integratio ] } """; - File.WriteAllText(Path.Combine(_projectModelPath, "appsettings.json"), appSettingsJson); // Handle NuGet config and channel resolution string? channelName = null; var userNugetConfig = FindNuGetConfig(_appPath); - if (userNugetConfig is not null) - { - var nugetConfigPath = Path.Combine(_projectModelPath, "nuget.config"); - File.Copy(userNugetConfig, nugetConfigPath, overwrite: true); - } + var nugetConfigContent = userNugetConfig is not null + ? File.ReadAllText(userNugetConfig) + : null; var configuredChannelName = requestedChannel ?? AspireConfigFile.Load(_appPath)?.Channel @@ -402,24 +392,150 @@ private XDocument CreateProjectFile(IEnumerable integratio """; - File.WriteAllText(Path.Combine(_projectModelPath, "Directory.Packages.props"), directoryPackagesProps); - - // Write empty Directory.Build.props/targets to prevent MSBuild from walking up and - // importing the repo's build infrastructure (Arcade SDK, etc.) which can rewrite - // project reference paths and cause resolution failures from the temp directory. - File.WriteAllText(Path.Combine(_projectModelPath, "Directory.Build.props"), ""); - File.WriteAllText(Path.Combine(_projectModelPath, "Directory.Build.targets"), ""); var projectFileName = Path.Combine(_projectModelPath, ProjectFileName); // Log the full project XML for debugging _logger.LogTrace("Generated AppHostServer project file:\n{ProjectXml}", doc.ToString()); - doc.Save(projectFileName); + // Every file this method owns, keyed by name relative to _projectModelPath. Writing the + // scaffold wipes obj/, so the whole set is fingerprinted together and rewritten only when + // something actually changed. Directory.Build.props/targets are deliberately empty: they + // stop MSBuild walking up into the repo's Arcade infrastructure, which rewrites project + // reference paths and breaks resolution from this directory. + var scaffold = new Dictionary(StringComparer.Ordinal) + { + ["Program.cs"] = programCs, + ["appsettings.json"] = appSettingsJson, + ["Directory.Packages.props"] = directoryPackagesProps, + ["Directory.Build.props"] = "", + ["Directory.Build.targets"] = "", + [ProjectFileName] = doc.ToString(), + }; + + // nuget.config is copied from the user's config rather than generated, so its *content* + // participates in the fingerprint. When the user's config disappears the key is absent, + // which both busts the hash and drives deletion of the stale copy below. + if (nugetConfigContent is not null) + { + scaffold["nuget.config"] = nugetConfigContent; + } + + if (TryReuseScaffold(scaffold)) + { + _logger.LogDebug("AppHostServer scaffold is up to date; preserving restore artifacts in {ProjectModelPath}", _projectModelPath); + return (projectFileName, channelName); + } + + WriteScaffold(scaffold, doc); return (projectFileName, channelName); } + /// + /// Determines whether the on-disk scaffold already matches and is + /// backed by a usable NuGet restore, in which case it can be left alone. + /// + /// + /// Rewriting the scaffold deletes obj/, which discards project.assets.json and the generated + /// nuget.g.props/targets. That forces a full restore and a full project-reference graph walk on + /// the next build. Doing that on every launch dominated in-repo polyglot startup, so the + /// destructive path is now taken only when the generated content actually changes. + /// + /// This deliberately does not attempt to detect changes to the repo sources this project + /// references. BuildAsync still runs on every launch, so MSBuild's own incremental + /// build remains the source of truth for those; skipping the scaffold only preserves the + /// restore output that describes an unchanged project. + /// + private bool TryReuseScaffold(Dictionary scaffold) + { + var fingerprint = ComputeScaffoldFingerprint(scaffold); + + if (!string.Equals(GetProjectHash(), fingerprint, StringComparison.Ordinal)) + { + return false; + } + + // A matching fingerprint only says the *inputs* are unchanged. The outputs still have to be + // present: a half-written scaffold or a hand-deleted file would otherwise be cached forever. + foreach (var fileName in scaffold.Keys) + { + if (!File.Exists(Path.Combine(_projectModelPath, fileName))) + { + return false; + } + } + + // Preserving obj/ is the entire point of the cache, so there has to be something worth + // preserving. Without project.assets.json the next build would restore from scratch anyway, + // and an interrupted restore can leave obj/ populated but unusable. + if (!File.Exists(Path.Combine(_projectModelPath, "obj", "project.assets.json"))) + { + return false; + } + + return true; + } + + private void WriteScaffold(Dictionary scaffold, XDocument projectDocument) + { + // Clean obj folder to ensure fresh NuGet restore + var objPath = Path.Combine(_projectModelPath, "obj"); + if (Directory.Exists(objPath)) + { + try + { + Directory.Delete(objPath, recursive: true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to delete obj folder at {ObjPath}", objPath); + } + } + + foreach (var (fileName, content) in scaffold) + { + // The csproj is written through XDocument.Save so it keeps its XML declaration; + // the dictionary holds only the declaration-less string form used for hashing. + if (string.Equals(fileName, ProjectFileName, StringComparison.Ordinal)) + { + continue; + } + + File.WriteAllText(Path.Combine(_projectModelPath, fileName), content); + } + + projectDocument.Save(Path.Combine(_projectModelPath, ProjectFileName)); + + // The user's nuget.config can be removed between launches. Leaving our copy behind would + // keep feeding the build sources the user has deleted. + if (!scaffold.ContainsKey("nuget.config")) + { + var staleNugetConfig = Path.Combine(_projectModelPath, "nuget.config"); + if (File.Exists(staleNugetConfig)) + { + File.Delete(staleNugetConfig); + } + } + + // Persisted last so an interrupted write can never be mistaken for a complete scaffold. + SaveProjectHash(ComputeScaffoldFingerprint(scaffold)); + } + + private static string ComputeScaffoldFingerprint(Dictionary scaffold) + { + var builder = new StringBuilder(); + + foreach (var fileName in scaffold.Keys.Order(StringComparer.Ordinal)) + { + // The NUL separators keep file boundaries unambiguous: without them a rename that + // shifts content across the boundary could hash identically. + builder.Append(fileName).Append('\0').Append(scaffold[fileName]).Append('\0'); + } + + return SourceContentFingerprint.Compute(builder.ToString(), ScaffoldSchemaVersion); + } + /// /// Restores and builds the project. /// diff --git a/src/Aspire.Cli/Projects/GeneratedFileWriter.cs b/src/Aspire.Cli/Projects/GeneratedFileWriter.cs new file mode 100644 index 00000000000..5bba5dfe1b2 --- /dev/null +++ b/src/Aspire.Cli/Projects/GeneratedFileWriter.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Projects; + +/// +/// Writes the files the CLI generates on every launch without disturbing files that have not changed. +/// +/// +/// Rewriting a file with identical content still moves its last-write time forward, and every +/// incremental build the CLI sits in front of -- MSBuild, javac, Maven, Gradle, and the IDE builders +/// that watch the same directories -- decides what to rebuild from those timestamps. Comparing before +/// writing costs a read of a file that is almost always still in the page cache and keeps those +/// timestamps stable across launches. +/// +internal static class GeneratedFileWriter +{ + /// + /// Writes only when it differs from what is already on disk. + /// + /// + /// A file that cannot be read is rewritten rather than treated as current. The CLI owns these + /// files, so an unreadable one is far more likely to be a transient hold -- an antivirus scanner + /// on Windows, a mode a user changed on POSIX -- than a deliberate state worth preserving, and + /// preserving it would silently launch against stale generated code. The write that follows + /// surfaces the real error if the condition is not transient. + /// + /// when the file was written. + public static async Task WriteIfChangedAsync(string path, string content, CancellationToken cancellationToken) + { + if (File.Exists(path)) + { + try + { + var existing = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); + if (string.Equals(existing, content, StringComparison.Ordinal)) + { + return false; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + await File.WriteAllTextAsync(path, content, cancellationToken).ConfigureAwait(false); + return true; + } +} diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index f12f38830fd..cb57ce1a9cc 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -7,6 +7,7 @@ using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Certificates; +using Aspire.Cli.Commands; using Aspire.Cli.Configuration; using Aspire.Cli.Diagnostics; using Aspire.Cli.DotNet; @@ -58,6 +59,12 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly LanguageInfo _resolvedLanguage; private GuestRuntime? _guestRuntime; + /// + /// Set when the AppHost is Java, so the install path can clear staged dependencies the build tool + /// will not prune itself. Null for every other language. + /// + private JavaAppHostToolchainResolution? _javaToolchainResolution; + public GuestAppHostProject( LanguageInfo language, IInteractionService interactionService, @@ -192,11 +199,21 @@ private async Task> GetIntegrationReferencesAsync( var defaultSdkVersion = GetEffectiveSdkVersion(); var integrations = config.GetIntegrationReferences(defaultSdkVersion, directory.FullName).ToList(); var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(_resolvedLanguage.LanguageId, cancellationToken); - if (codeGenPackage is not null) + + // The config can already declare the code generation integration itself, most often as a + // project reference in this repo's own playgrounds. Adding the package on top of that puts + // the same package identity in the closure twice, and when the two resolve to different + // versions NuGet fails the restore with a package downgrade (NU1605). Package identities are + // compared case-insensitively because NuGet treats them that way. + var alreadyDeclared = codeGenPackage is not null + && integrations.Any(i => string.Equals(i.Name, codeGenPackage, StringComparison.OrdinalIgnoreCase)); + + if (codeGenPackage is not null && !alreadyDeclared) { var codeGenVersion = config.GetEffectiveSdkVersion(defaultSdkVersion); integrations.Add(IntegrationReference.FromPackage(codeGenPackage, codeGenVersion)); } + return integrations; } @@ -524,7 +541,7 @@ await GenerateCodeViaRpcAsync( // Internal escalation CTS for the AppHost system. We cancel this when something fatal // happens to either the server or the guest (e.g. backchannel polling fails after the - // 60s timeout, the server exits unexpectedly) so the remaining process gets torn down + // configured timeout, the server exits unexpectedly) so the remaining process gets torn down // promptly. Without this, a hung guest can keep pendingRun alive forever after the CLI // has already given up on the backchannel, causing aspire run/start to hang instead of // surfacing the failure. Linked to the outer cancellationToken (which IS CCM.Token in @@ -758,6 +775,7 @@ Task StartBackchannelConnectionAfterGuestAppHostLaunchesAsync() context.BuildCompletionSource?.TrySetResult(false); _logger.LogError(ex, "Failed to run {Language} AppHost", DisplayName); _interactionService.DisplayError($"Failed to run {DisplayName} AppHost: {ex.Message}"); + return CliExitCodes.FailedToDotnetRunAppHost; } } @@ -831,9 +849,38 @@ internal static Dictionary CreateGuestEnvironmentVariables( AppHostEnvironmentDefaults.ApplyEffectiveEnvironment(environmentVariables, defaultEnvironment, inheritedEnvironmentVariables, args); + ForwardAppHostArguments(environmentVariables, args); + return environmentVariables; } + /// + /// Publishes the arguments the CLI also passes on the guest process command line into + /// ASPIRE_APPHOST_ARGS. + /// + /// + /// Python, TypeScript and Rust AppHosts read the process arguments themselves + /// (sys.argv[1:], process.argv.slice(2), std::env::args()), so a builder + /// created without arguments still observes --operation publish. A JVM cannot do the + /// same: main(String[]) is the only place those arguments exist, and + /// ProcessHandle.current().info().arguments() reports the JVM's own arguments (options + /// and main class) rather than the application's. Without this, a Java AppHost that calls + /// CreateBuilder() instead of CreateBuilder(args) silently runs the application + /// when the user asked to publish. + /// + /// Newline is the separator because it is the one character an argument never contains in + /// practice, whereas spaces are common in paths. + /// + private static void ForwardAppHostArguments(IDictionary environmentVariables, string[]? args) + { + if (args is not { Length: > 0 }) + { + return; + } + + environmentVariables["ASPIRE_APPHOST_ARGS"] = string.Join('\n', args); + } + private static void MergeLaunchProfileEnvironmentVariables( IDictionary? launchProfileEnvironmentVariables, IDictionary environmentVariables, @@ -1250,7 +1297,7 @@ private async Task StartBackchannelConnectionAsync( ActivityContext parentContext, CancellationToken cancellationToken) { - const int ConnectionTimeoutSeconds = 60; + var connectionTimeout = AppHostStartupTimeout.GetBackchannelConnectionTimeout(_configuration); using var activity = _profilingTelemetry.StartBackchannelConnect(socketPath, parentContext, enableHotReload, retryCount: 0); var startTime = DateTimeOffset.UtcNow; @@ -1309,11 +1356,13 @@ private async Task StartBackchannelConnectionAsync( { var waitingFor = DateTimeOffset.UtcNow - startTime; - // Timeout after ConnectionTimeoutSeconds - the AppHost server should have started by now - if (waitingFor > TimeSpan.FromSeconds(ConnectionTimeoutSeconds)) + // The AppHost server cannot open this backchannel until the guest has executed its + // builder. Use the outer startup budget so an extension-managed debugger can remain + // paused before builder creation without the guest path tearing down the session first. + if (waitingFor > connectionTimeout) { - _logger.LogError("Timed out waiting for AppHost server to start after {Timeout} seconds", ConnectionTimeoutSeconds); - var timeoutException = new TimeoutException($"Timed out waiting for AppHost server to start after {ConnectionTimeoutSeconds} seconds. Check the debug logs for more details."); + _logger.LogError("Timed out waiting for AppHost server to start after {Timeout} seconds", connectionTimeout.TotalSeconds); + var timeoutException = new TimeoutException($"Timed out waiting for AppHost server to start after {connectionTimeout.TotalSeconds} seconds. Check the debug logs for more details."); activity.SetError(timeoutException); backchannelCompletionSource.TrySetException(timeoutException); return; @@ -1613,6 +1662,7 @@ private async Task GenerateCodeViaRpcAsync( // Write generated files to the output directory Directory.CreateDirectory(outputPath); + var writtenCount = 0; foreach (var (fileName, content) in files) { var filePath = Path.Combine(outputPath, fileName); @@ -1621,14 +1671,48 @@ private async Task GenerateCodeViaRpcAsync( { Directory.CreateDirectory(directory); } - await File.WriteAllTextAsync(filePath, content, cancellationToken); + + if (await WriteGeneratedFileAsync(filePath, content, _resolvedLanguage.PreserveUnchangedGeneratedFiles, cancellationToken)) + { + writtenCount++; + } } // Write generation hash for caching SaveGenerationHash(outputPath, integrationsList); - _logger.LogInformation("Generated {Count} {CodeGenerator} files in {Path}", - files.Count, codeGenerator, outputPath); + await PruneObsoleteGeneratedFilesAsync(outputPath, files.Keys, cancellationToken); + + _logger.LogInformation("Generated {Count} {CodeGenerator} files in {Path} ({WrittenCount} changed)", + files.Count, codeGenerator, outputPath, writtenCount); + } + + /// + /// Writes a generated file, skipping the write when is + /// set and the content already on disk is identical. + /// + /// + /// The generated SDK is hundreds of files and is regenerated on every launch, but its content is + /// identical from one launch to the next unless the app model changed. See + /// for why leaving those timestamps alone matters. + /// + /// Only languages that compile the generated sources in place opt in, via + /// . A language that installs them into + /// an environment first cannot: uv reuses its cached build of .aspire/modules when the + /// sources have not changed, so leaving an unchanged file alone leaves the Python AppHost importing + /// a stale install of the SDK. + /// + /// + /// when the file was written. + internal static async Task WriteGeneratedFileAsync(string filePath, string content, bool preserveUnchangedFiles, CancellationToken cancellationToken) + { + if (preserveUnchangedFiles) + { + return await GeneratedFileWriter.WriteIfChangedAsync(filePath, content, cancellationToken); + } + + await File.WriteAllTextAsync(filePath, content, cancellationToken); + return true; } internal static Dictionary ConvertGeneratedFilesForLegacyTypeScriptAppHost(Dictionary files) @@ -1818,6 +1902,99 @@ private void RenderCodeGenerationFailure(AppHostCodeGenerationException exceptio /// When project references are present, the hash is always unique to force regeneration /// since project outputs are mutable. /// + /// + /// Deletes generated files a previous generation wrote that the current one no longer produces, + /// and records the current set for the next run. + /// + /// + /// Removing a package from aspire.config.json, or renaming a resource type, changes which + /// files the generator emits. Without pruning the old ones stay on disk, and for languages that + /// compile the generated sources in place that is not merely untidy: javac compiles + /// everything under the source root, so a leftover file referencing a type that no longer exists + /// fails the AppHost build outright, with an error pointing at generated code the user never wrote. + /// + /// Only paths a previous run recorded in the manifest are eligible, so a file Aspire did not write + /// is never deleted - including on the first run, when no manifest exists yet. A failure to delete + /// or to write the manifest is not fatal: the worst case is the stale file surviving, which is the + /// behaviour before this existed. + /// + /// + internal static async Task PruneObsoleteGeneratedFilesAsync( + string outputPath, + IEnumerable generatedRelativePaths, + CancellationToken cancellationToken) + { + var manifestPath = Path.Combine(outputPath, GeneratedManifestFileName); + var current = new HashSet(generatedRelativePaths.Select(NormalizeManifestPath), StringComparer.Ordinal); + + if (File.Exists(manifestPath)) + { + try + { + foreach (var line in await File.ReadAllLinesAsync(manifestPath, cancellationToken).ConfigureAwait(false)) + { + var recorded = line.Trim(); + if (recorded.Length == 0 || current.Contains(NormalizeManifestPath(recorded))) + { + continue; + } + + DeleteObsoleteGeneratedFile(outputPath, recorded); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // An unreadable manifest only costs pruning for this run. + } + } + + try + { + Directory.CreateDirectory(outputPath); + await File.WriteAllLinesAsync(manifestPath, current.Order(StringComparer.Ordinal), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Losing the manifest only means the next run cannot prune. + } + } + + /// + /// Manifest of the files the last generation wrote, relative to the generated folder. + /// + private const string GeneratedManifestFileName = ".codegen-manifest"; + + /// + /// Stores manifest entries with forward slashes so a manifest written on Windows still prunes on + /// Unix, and vice versa, when a repository is shared between them. + /// + private static string NormalizeManifestPath(string path) => path.Replace('\\', '/'); + + private static void DeleteObsoleteGeneratedFile(string outputPath, string recordedRelativePath) + { + var fullPath = Path.GetFullPath(Path.Combine(outputPath, recordedRelativePath)); + + // A manifest is written by Aspire, but it is a file on disk in the user's repository, so a + // hand-edited or corrupted entry must not be able to reach outside the generated folder. + var root = Path.GetFullPath(outputPath); + if (!fullPath.StartsWith(root + Path.DirectorySeparatorChar, PathComparison)) + { + return; + } + + try + { + File.Delete(fullPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A file that cannot be deleted is left alone; that is the pre-existing behaviour. + } + } + + private static StringComparison PathComparison => + OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + private static void SaveGenerationHash(string generatedPath, List integrations) { var hashPath = Path.Combine(generatedPath, ".codegen-hash"); @@ -1872,6 +2049,13 @@ private async Task EnsureRuntimeCreatedAsync( var toolchain = TypeScriptAppHostToolchainResolver.Resolve(directory, _environment, _logger); runtimeSpec = TypeScriptAppHostToolchainResolver.ApplyToRuntimeSpec(runtimeSpec, toolchain); } + else if (JavaAppHostToolchainResolver.IsJavaLanguage(_resolvedLanguage)) + { + var resolution = JavaAppHostToolchainResolver.Resolve(directory, _logger); + await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(resolution, cancellationToken); + runtimeSpec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(runtimeSpec, resolution, directory); + _javaToolchainResolution = resolution; + } _guestRuntime = new GuestRuntime(runtimeSpec, _logger, PathLookupHelper.FindFullPathFromPath, _environment, _profilingTelemetry, _fileLoggerProvider); @@ -1918,6 +2102,12 @@ private async Task InstallDependenciesAsync( return initResult; } + if (_javaToolchainResolution is { } javaToolchain) + { + // Immediately before staging, so the AppHost can never be left with a cleared classpath. + JavaAppHostToolchainResolver.ClearStagedDependencies(javaToolchain); + } + var (result, output) = await _guestRuntime.InstallDependenciesAsync(directory, cancellationToken); if (result != 0) { diff --git a/src/Aspire.Cli/Projects/GuestRuntime.cs b/src/Aspire.Cli/Projects/GuestRuntime.cs index b28c5a4c2cf..e46e2a187d4 100644 --- a/src/Aspire.Cli/Projects/GuestRuntime.cs +++ b/src/Aspire.Cli/Projects/GuestRuntime.cs @@ -256,6 +256,13 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm var args = ReplacePlaceholders(commandSpec.Args, appHostFile, directory, null); var mergedEnvironment = MergeEnvironmentVariables(environmentVariables, commandSpec); + var stampFile = ResolveStampFile(commandSpec.UpToDateCheck, appHostFile, directory); + if (stampFile is not null && IsUpToDate(commandSpec.UpToDateCheck!, stampFile, appHostFile, directory)) + { + _logger.LogDebug("Skipping up-to-date pre-execution command: {Command}", commandSpec.Command); + continue; + } + _logger.LogDebug("Launching pre-execution command: {Command} {Args}", commandSpec.Command, string.Join(" ", args)); using var activity = _profilingTelemetry.StartGuestExecuteCommand(_spec.Language, _spec.DisplayName, commandSpec.Command, args, directory, ProfilingTelemetry.Values.GuestCommandPhasePreExecute); var (exitCode, output) = await preExecuteLauncher.LaunchAsync(commandSpec.Command, args, directory, mergedEnvironment, afterLaunchAsync: null, options: null, cancellationToken); @@ -265,11 +272,262 @@ public GuestRuntime(RuntimeSpec spec, ILogger logger, Func comm activity.SetError($"{_spec.DisplayName} pre-execution exited with code {exitCode}."); return (exitCode, output ?? new OutputCollector()); } + + if (stampFile is not null) + { + WriteStamp(stampFile); + } } return (0, new OutputCollector()); } + /// + /// Resolves the stamp file for a command's up-to-date check, or null when the command has none. + /// + private static FileInfo? ResolveStampFile(CommandUpToDateCheck? check, FileInfo appHostFile, DirectoryInfo directory) + { + if (check is null) + { + return null; + } + + var resolved = ReplacePlaceholders([check.StampFile], appHostFile, directory, null)[0]; + return new FileInfo(Path.Combine(directory.FullName, resolved)); + } + + /// + /// Determines whether every declared input is older than the stamp file. + /// + /// + /// Comparison is strictly "no input newer than the stamp". An input written in the same second as + /// the stamp therefore counts as up to date, which matches how make-style checks behave and is + /// safe here because the stamp is written after the compile reads its inputs. + /// + private bool IsUpToDate(CommandUpToDateCheck check, FileInfo stampFile, FileInfo appHostFile, DirectoryInfo directory) + { + stampFile.Refresh(); + if (!stampFile.Exists) + { + return false; + } + + var stampWriteTime = stampFile.LastWriteTimeUtc; + var stampDirectory = stampFile.Directory?.FullName; + var inputs = ReplacePlaceholders(check.Inputs, appHostFile, directory, null); + + foreach (var input in inputs) + { + // A spec may name a path only some project layouts have (src/main/java, for instance), so a + // missing input is not a change. A missing *output* is handled by the stamp check above. + var recursive = input.EndsWith("/**", StringComparison.Ordinal) || input.EndsWith(@"\**", StringComparison.Ordinal); + var trimmed = recursive ? input[..^3] : input; + var path = Path.IsPathRooted(trimmed) ? trimmed : Path.Combine(directory.FullName, trimmed); + + if (File.Exists(path)) + { + // A file the spec names outright is not a scan result, so the extension filter - which + // exists to keep a directory scan from picking up the command's own outputs - does not + // apply to it. This is what lets a build descriptor such as pom.xml or build.gradle be + // declared as an input of a check whose scans are restricted to sources. + if (File.GetLastWriteTimeUtc(path) > stampWriteTime) + { + return false; + } + + continue; + } + + if (!Directory.Exists(path)) + { + continue; + } + + try + { + // A directory's own timestamp moves when an entry is added, removed, or renamed inside + // it, and not when an existing entry is rewritten. Both POSIX and NTFS guarantee that, + // and it is the only signal here that catches a *deleted* input: after a delete every + // surviving file is older than the stamp, so comparing files alone sees no change at + // all and the command keeps reusing outputs built from a source that is gone. It also + // covers a changed set of staged dependency JARs, whose own timestamps are rewritten + // by the staging step on every launch and so cannot be compared directly. + // + // The cost is being occasionally eager: an unrelated file appearing in an input + // directory - an editor swap file, a .DS_Store - triggers one extra run, after which + // the new stamp settles it. That is the safe direction for this to be wrong in. + if (Directory.GetLastWriteTimeUtc(path) > stampWriteTime) + { + return false; + } + + // EnumerateFiles is lazy: the traversal - and any IOException or + // UnauthorizedAccessException an unreadable subdirectory raises - happens while the + // foreach pulls from it, not at the call. Iterating inside the same try is what puts + // that failure in front of this catch; enumerating outside it let an unreadable tree + // abort AppHost startup instead of falling back to running the command. + foreach (var file in Directory.EnumerateFiles(path)) + { + if (MatchesExtension(check, file) && File.GetLastWriteTimeUtc(file) > stampWriteTime) + { + return false; + } + } + + if (!recursive) + { + continue; + } + + foreach (var subdirectory in EnumerateInputDirectories(path, stampDirectory)) + { + // Unlike the declared root above, a subdirectory's own timestamp only counts when + // the directory still holds inputs, or holds nothing at all. A recursive input + // reaches whatever happens to sit under it - a log directory, a tool's scratch + // space - and the timestamp rule ignores the extension filter, so checking every + // subdirectory would let any unrelated write force a rebuild, and a command that + // writes under its own input root would never settle. An emptied directory is + // kept because that is exactly what deleting the last input in a package looks + // like, and a delete is the one change no file timestamp can reveal. + var holdsInputs = false; + var holdsFiles = false; + + foreach (var file in Directory.EnumerateFiles(subdirectory)) + { + holdsFiles = true; + + if (!MatchesExtension(check, file)) + { + continue; + } + + holdsInputs = true; + + if (File.GetLastWriteTimeUtc(file) > stampWriteTime) + { + return false; + } + } + + if ((holdsInputs || !holdsFiles) && Directory.GetLastWriteTimeUtc(subdirectory) > stampWriteTime) + { + return false; + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // An unreadable input tree cannot be proven unchanged, so fall back to running the command. + _logger.LogDebug(ex, "Unable to scan up-to-date check input {Input}; treating the command as out of date.", path); + return false; + } + } + + return true; + } + + /// + /// Enumerates the subdirectories of , skipping trees that cannot hold an + /// input. + /// + /// + /// Lazy on purpose: an or from + /// an unreadable subdirectory has to surface while the caller is iterating, inside the caller's + /// try, rather than at the call. + /// + private static IEnumerable EnumerateInputDirectories(string root, string? outputDirectory) + { + var pending = new Stack(); + pending.Push(root); + + while (pending.Count > 0) + { + foreach (var child in Directory.EnumerateDirectories(pending.Pop())) + { + if (IsNeverAnInput(child, outputDirectory)) + { + continue; + } + + yield return child; + pending.Push(child); + } + } + } + + /// + /// Returns whether a directory can be skipped entirely while scanning a recursive input. + /// + /// + /// A recursive input has to descend to be correct - a source in a package subdirectory is + /// compiled, and rewriting it in place moves no ancestor's timestamp - but descending everywhere + /// costs the launch time the check exists to save, and would let the command's own outputs + /// invalidate the check that produced them. + /// + /// Dot-directories are tooling state (.git, .gradle, .idea) rather than + /// sources, and no language here has a package or module segment that may begin with a dot. The + /// generated SDK lives in one and is declared as its own input, so it stays tracked. + /// + /// + private static bool IsNeverAnInput(string directory, string? outputDirectory) + { + var name = Path.GetFileName(directory.AsSpan()); + + if (name.StartsWith(".")) + { + return true; + } + + // A dependency tree, never a first-party source root, and by far the largest thing likely to + // sit beside an AppHost. + if (name.Equals("node_modules", StringComparison.Ordinal)) + { + return true; + } + + return outputDirectory is not null + && string.Equals(directory, outputDirectory, PathComparison); + } + + private static StringComparison PathComparison => + OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + private static bool MatchesExtension(CommandUpToDateCheck check, string path) + { + if (check.FileExtensions is null or { Length: 0 }) + { + return true; + } + + var extension = Path.GetExtension(path); + foreach (var candidate in check.FileExtensions) + { + if (string.Equals(extension, candidate, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private void WriteStamp(FileInfo stampFile) + { + try + { + stampFile.Directory?.Create(); + // The content is never read; only the write time matters. Rewriting rather than touching + // keeps this working on file systems where setting a time on a missing file would throw. + File.WriteAllBytes(stampFile.FullName, []); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A stamp that cannot be written only costs the next launch a rebuild, so this must never + // be the reason a run fails. + _logger.LogDebug(ex, "Unable to write up-to-date stamp {Stamp}.", stampFile.FullName); + } + } + private async Task<(int ExitCode, OutputCollector? Output)> ExecuteCommandAsync( CommandSpec commandSpec, FileInfo appHostFile, diff --git a/src/Aspire.Cli/Projects/ILanguageDiscovery.cs b/src/Aspire.Cli/Projects/ILanguageDiscovery.cs index dd66c887ce1..3af3aa3bb9c 100644 --- a/src/Aspire.Cli/Projects/ILanguageDiscovery.cs +++ b/src/Aspire.Cli/Projects/ILanguageDiscovery.cs @@ -37,6 +37,19 @@ internal readonly record struct LanguageId(string Value) /// The code generator name to use for this language (e.g., "TypeScript"). Must match ICodeGenerator.Language. /// The default filename for the AppHost entry point (e.g., "apphost.mts"). /// Whether this language is experimental and requires an additional per-language feature flag to be enabled. +/// +/// Whether regenerating identical content may leave the existing file, and therefore its last-write +/// time, untouched. Only safe for languages whose toolchain reads the generated sources in place and +/// decides what to rebuild from their timestamps, which is what makes skipping the rewrite the point: +/// Java compiles .aspire/modules directly, so rewriting unchanged files forces javac to +/// recompile the whole generated SDK on every launch. +/// +/// It must stay for languages that install the generated sources into an +/// environment before running them. Python builds .aspire/modules into the virtual environment +/// through uv, which decides whether to reuse its cached build from the source timestamps, so an +/// unchanged file silently keeps a stale install rather than picking the regenerated SDK up. +/// +/// internal sealed record LanguageInfo( LanguageId LanguageId, string DisplayName, @@ -44,7 +57,8 @@ internal sealed record LanguageInfo( string[] DetectionPatterns, string CodeGenerator, string? AppHostFileName = null, - bool IsExperimental = false) + bool IsExperimental = false, + bool PreserveUnchangedGeneratedFiles = false) { /// /// The default folder path where generated code is placed for guest languages. diff --git a/src/Aspire.Cli/Projects/JavaAppHostToolchainResolver.cs b/src/Aspire.Cli/Projects/JavaAppHostToolchainResolver.cs new file mode 100644 index 00000000000..d9596ab9abb --- /dev/null +++ b/src/Aspire.Cli/Projects/JavaAppHostToolchainResolver.cs @@ -0,0 +1,754 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TypeSystem; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Projects; + +/// +/// The build tool that owns a Java AppHost project. +/// +internal enum JavaAppHostToolchain +{ + /// + /// No build tool. The AppHost is a single file compiled directly with javac. + /// + Javac, + + /// + /// A Maven project, identified by pom.xml. + /// + Maven, + + /// + /// A Gradle project, identified by build.gradle or build.gradle.kts. + /// + Gradle +} + +/// +/// Selects the build tool for a Java AppHost from the files on disk and rewrites the runtime spec to +/// match, mirroring what does for package managers. +/// +/// +/// +/// Java AppHosts started life as a single AppHost.java compiled with javac, which needs +/// nothing but a JDK. That remains the default and the fallback: adopting a build tool is opt-in, and +/// is done by adding a pom.xml or build.gradle. A project model is what unlocks +/// third-party dependencies and full IDE support, because the Java language server derives its +/// classpath from build files. +/// +/// +/// Under a build tool the AppHost is still launched with a plain java command rather than +/// through mvn exec:java or gradle run. Those run the application inside, or as a child +/// of, the build tool's own JVM, so console signals reach the build tool instead of the AppHost and an +/// orderly shutdown never happens. Launching directly also means the arguments the CLI appends arrive +/// as real argv entries instead of being spliced into a shell string. +/// +/// +internal static class JavaAppHostToolchainResolver +{ + private const string MavenPomFileName = "pom.xml"; + private const string GradleBuildFileName = "build.gradle"; + private const string GradleKotlinBuildFileName = "build.gradle.kts"; + private const string GradleSettingsFileName = "settings.gradle"; + private const string GradleKotlinSettingsFileName = "settings.gradle.kts"; + + /// + /// Directory the resolved runtime dependencies are copied into, relative to the build output. + /// A directory of JARs is used rather than a classpath string because the JVM expands a + /// dir/* classpath entry itself, so no shell and no generated argument file is needed. + /// + private const string DependencyDirectoryName = "aspire-deps"; + + /// + /// Gradle init script that adds a dependency-copying task to whatever project is being built. + /// + /// + /// An init script is used so a Gradle AppHost needs no cooperation from the user's build file. + /// Maven ships dependency:copy-dependencies out of the box, but Gradle has no built-in + /// equivalent, and requiring an edit to build.gradle would make adopting Gradle a worse + /// experience than Maven for no reason. + /// See https://docs.gradle.org/current/userguide/init_scripts.html. + /// + private const string GradleInitScript = $$""" + // Generated by Aspire. Adds the task that stages the AppHost's runtime classpath so the + // AppHost can be launched with a plain `java` command instead of through Gradle. + // + // Sync rather than Copy: the classpath is the whole directory, so a JAR left behind by a + // dependency that was upgraded or removed would still be loaded. Sync deletes whatever is + // in the destination but not in the configuration, and stays incremental while doing it. + // https://docs.gradle.org/current/dsl/org.gradle.api.tasks.Sync.html + allprojects { + tasks.register("aspireCopyDependencies", Sync) { + from configurations.named("runtimeClasspath") + into layout.buildDirectory.dir("{{DependencyDirectoryName}}") + } + } + """; + + internal const string GradleInitScriptRelativePath = ".aspire/aspire-gradle-init.gradle"; + + /// + /// with the separators this platform uses. + /// + private static string GradleInitScriptPath => GradleInitScriptRelativePath.Replace('/', Path.DirectorySeparatorChar); + + public static bool IsJavaLanguage(LanguageInfo? language) + { + return language is not null && + language.LanguageId.Value.Equals(KnownLanguageId.Java, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Conventional source root a build tool compiles from. When the AppHost lives here the build + /// file is at the project root, three directories up. + /// https://maven.apache.org/guides/introduction/introduction-to-the-standard-directory-layout.html + /// + private static readonly string[] s_conventionalSourceRootSegments = ["src", "main", "java"]; + + public static JavaAppHostToolchainResolution Resolve(DirectoryInfo appHostDirectory, ILogger? logger = null) + { + var resolution = ResolveWithReason(appHostDirectory); + logger?.LogDebug( + "Selected Java AppHost build tool '{BuildTool}' rooted at '{ProjectDirectory}' because {Reason}.", + resolution.Toolchain, + resolution.ProjectDirectory.FullName, + resolution.Reason); + + return resolution; + } + + internal static JavaAppHostToolchainResolution ResolveWithReason(DirectoryInfo appHostDirectory) + { + // The AppHost directory is checked first, then the project root implied by the conventional + // source layout. An unbounded walk up the tree is deliberately avoided: a pom.xml in some + // arbitrary ancestor usually belongs to an unrelated project that happens to contain the + // AppHost folder, whereas src/main/java is only ever a build tool's source root. + foreach (var candidate in GetCandidateProjectDirectories(appHostDirectory)) + { + if (File.Exists(Path.Combine(candidate.FullName, MavenPomFileName))) + { + return new(JavaAppHostToolchain.Maven, candidate, $"{MavenPomFileName} found in {candidate.FullName}"); + } + + foreach (var gradleBuildFileName in new[] { GradleBuildFileName, GradleKotlinBuildFileName }) + { + if (File.Exists(Path.Combine(candidate.FullName, gradleBuildFileName))) + { + return new(JavaAppHostToolchain.Gradle, candidate, $"{gradleBuildFileName} found in {candidate.FullName}"); + } + } + } + + return new(JavaAppHostToolchain.Javac, appHostDirectory, $"no build file found in {appHostDirectory.FullName}"); + } + + private static IEnumerable GetCandidateProjectDirectories(DirectoryInfo appHostDirectory) + { + yield return appHostDirectory; + + // Walk back out of src/main/java only when the directory names actually match, so an AppHost + // that merely sits three levels deep is not mistaken for a conventional project layout. + var candidate = appHostDirectory; + foreach (var segment in s_conventionalSourceRootSegments.Reverse()) + { + if (!string.Equals(candidate.Name, segment, PathComparison) || candidate.Parent is null) + { + yield break; + } + + candidate = candidate.Parent; + } + + yield return candidate; + } + + /// + /// Locates the wrapper for , preferring the project's own directory and + /// otherwise walking up to the build root. + /// + /// + /// A Gradle multi-project build has exactly one gradlew, beside the settings.gradle + /// that declares the subprojects, and a Maven multi-module repository keeps mvnw beside the + /// aggregator POM. An AppHost that is one of those modules carries only its own build file, so + /// requiring a wrapper next to it would reject the standard layout outright. + /// + /// An ancestor only qualifies when it also holds that tool's build-root marker and is not + /// world-writable, and the walk stops at the directory holding .git so a submodule or nested + /// clone uses its own wrapper rather than the outer repository's. These are the same rules + /// Aspire.Hosting.Java's JavaBuildToolResolver applies to hosted resources; the logic is + /// duplicated rather than shared because the CLI does not reference the hosting package. + /// + /// + private static string? FindWrapper(DirectoryInfo projectDirectory, string wrapperName, JavaAppHostToolchain toolchain) + { + for (var directory = projectDirectory; directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, wrapperName); + var isProjectDirectory = directory.FullName == projectDirectory.FullName; + + // The project directory is named by the AppHost, so a wrapper beside it is the developer's + // own instruction and needs no further qualification. Ancestors are inferred instead. + if (File.Exists(candidate) + && (isProjectDirectory + || (IsBuildRoot(directory.FullName, toolchain) + && !IsWorldWritable(directory.FullName) + && !IsWorldWritable(candidate)))) + { + return candidate; + } + + // A worktree or submodule records .git as a file rather than a directory, so both count. + var gitPath = Path.Combine(directory.FullName, ".git"); + if (Directory.Exists(gitPath) || File.Exists(gitPath)) + { + return null; + } + } + + return null; + } + + /// + /// Returns whether a directory is the root of a build for . + /// + private static bool IsBuildRoot(string directory, JavaAppHostToolchain toolchain) => toolchain switch + { + // Gradle requires a settings file at the root of a multi-project build; that is the directory + // the wrapper is generated into. https://docs.gradle.org/current/userguide/multi_project_builds.html + JavaAppHostToolchain.Gradle => File.Exists(Path.Combine(directory, GradleSettingsFileName)) + || File.Exists(Path.Combine(directory, GradleKotlinSettingsFileName)), + // A Maven aggregator is itself a project, so its POM is the marker. + // https://maven.apache.org/guides/introduction/introduction-to-the-pom.html + JavaAppHostToolchain.Maven => File.Exists(Path.Combine(directory, MavenPomFileName)), + _ => false + }; + + /// + /// Returns whether any user on the machine can write to . + /// + /// + /// Only inferred ancestors are checked. On a shared machine an AppHost under a world-writable + /// directory such as /tmp could otherwise pick up a wrapper another user planted beside a + /// pom.xml, and the CLI would execute it with the developer's privileges before anything is + /// built. Applied to the wrapper file as well as its directory, because rewriting a file in place + /// needs write permission on the file rather than on the directory holding it. + /// + /// Group-writable is deliberately not rejected: distributions that enable user private groups pair + /// a umask of 002 with a group per user, so an ordinary checkout is mode 775 and rejecting it + /// would break wrapper resolution for a large share of Linux users. + /// + /// + /// Windows uses ACLs that does not describe, and .NET reports + /// there, so the check is skipped. + /// + /// + private static bool IsWorldWritable(string path) + { + if (OperatingSystem.IsWindows()) + { + return false; + } + + try + { + return (File.GetUnixFileMode(path) & UnixFileMode.OtherWrite) != 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A path whose mode cannot be read cannot be shown safe, so treat it as unusable. + return true; + } + } + + private static StringComparison PathComparison => + OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + public static string GetDisplayName(JavaAppHostToolchain toolchain) + { + return toolchain switch + { + JavaAppHostToolchain.Javac => "Java", + JavaAppHostToolchain.Maven => "Java (Maven)", + JavaAppHostToolchain.Gradle => "Java (Gradle)", + _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null) + }; + } + + /// + /// Returns how the build tool should be invoked. The wrapper checked into the project is required, + /// so the AppHost always builds with the tool version the project pins. + /// + /// Directory holding the build file and the wrapper. + /// Directory the command runs in, which the wrapper path is relative to. + /// The resolved build tool. + internal static JavaToolInvocation GetToolInvocation( + DirectoryInfo projectDirectory, + DirectoryInfo appHostDirectory, + JavaAppHostToolchain toolchain) + { + var (wrapperName, generateCommand) = toolchain switch + { + // -N keeps the goal from recursing into the modules of a multi-module build. + JavaAppHostToolchain.Maven => (OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw", "mvn -N wrapper:wrapper"), + JavaAppHostToolchain.Gradle => (OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew", "gradle wrapper"), + _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null) + }; + + var wrapperPath = FindWrapper(projectDirectory, wrapperName, toolchain); + + // A globally installed Maven or Gradle is deliberately not used as a fallback: the wrapper pins the + // tool version in the repository, so every machine builds the AppHost with the same one. Falling + // back silently would make the AppHost build depend on whatever the developer happens to have. + if (wrapperPath is null) + { + throw new InvalidOperationException( + $"The Java AppHost project in '{projectDirectory.FullName}' declares a {GetDisplayName(toolchain)} " + + $"build but ships no {wrapperName}, and none was found at an enclosing build root. Generate one " + + $"with '{generateCommand}', or remove the build file to build the AppHost with javac instead."); + } + + if (!OperatingSystem.IsWindows()) + { + // Invoked through "sh" rather than executed directly because a wrapper checked out on + // Windows, or committed without its mode bit, arrives without the executable bit and + // exec fails with "Permission denied". The wrappers are POSIX shell scripts and are + // documented to be run that way, so "sh " is always valid. This matches how the + // hosted Java resources invoke wrappers (JavaHostingExtensions.WrapperInvocationFor). + // + // The absolute path is kept because the process is started without a shell, so a bare + // "mvnw" would be looked up on PATH and never found in the project directory. + return new JavaToolInvocation("sh", [wrapperPath]); + } + + // On Windows the wrappers are batch files. Launching one directly with redirected stdout can + // silently produce no output (see NpmRunner, which hits the same problem with npm.cmd), so the + // command interpreter runs it instead. + // + // The path is made relative to the working directory to keep it short, and "call" runs it. That + // matters because cmd.exe strips quotes in a way that does not match how ProcessStartInfo + // escapes arguments: when the *first* token on the line is quoted, cmd removes that quote and + // the last one on the line, mangling everything in between. A wrapper reached through a + // directory whose name contains a space is quoted, so with the wrapper first the line would be + // mangled; with "call" first the first character is never a quote and the rule cannot apply. + // "call" is also how one batch file invokes another: it returns control and propagates the + // exit code. See the quote-processing rules printed by `cmd /?`. + var relativeWrapperPath = Path.GetRelativePath(appHostDirectory.FullName, wrapperPath); + + return new JavaToolInvocation( + Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", + ["/c", "call", relativeWrapperPath]); + } + + /// + /// Rewrites the runtime spec for the resolved build tool. The + /// spec is returned unchanged so an AppHost without build files behaves exactly as before. + /// + public static RuntimeSpec ApplyToRuntimeSpec(RuntimeSpec baseRuntimeSpec, JavaAppHostToolchainResolution resolution, DirectoryInfo appHostDirectory) + { + var toolchain = resolution.Toolchain; + if (toolchain == JavaAppHostToolchain.Javac) + { + return baseRuntimeSpec; + } + + var invocation = GetToolInvocation(resolution.ProjectDirectory, appHostDirectory, toolchain); + + // Commands run with the AppHost directory as their working directory, which is not the project + // directory in the src/main/java layout, so every path below is rewritten relative to it. + var projectPath = GetRelativeProjectPath(resolution.ProjectDirectory, appHostDirectory); + var classesDirectory = CombineProjectPath(projectPath, GetClassesDirectory(toolchain)); + var dependencyDirectory = CombineProjectPath(projectPath, GetDependencyDirectory(toolchain)); + + return new RuntimeSpec + { + Language = baseRuntimeSpec.Language, + DisplayName = GetDisplayName(toolchain), + CodeGenLanguage = baseRuntimeSpec.CodeGenLanguage, + DetectionPatterns = baseRuntimeSpec.DetectionPatterns, + Initialize = baseRuntimeSpec.Initialize, + InstallDependencies = CreateInstallCommand(toolchain, invocation, projectPath), + PreExecute = [CreateCompileCommand(toolchain, baseRuntimeSpec, projectPath, classesDirectory, dependencyDirectory)], + Execute = CreateExecuteCommand(classesDirectory, dependencyDirectory), + WatchExecute = baseRuntimeSpec.WatchExecute, + PublishExecute = baseRuntimeSpec.PublishExecute, + ExtensionLaunchCapability = baseRuntimeSpec.ExtensionLaunchCapability, + CertificateBundleEnvironmentVariable = baseRuntimeSpec.CertificateBundleEnvironmentVariable, + MigrationFiles = baseRuntimeSpec.MigrationFiles + }; + } + + /// + /// Writes the Gradle init script when the resolved toolchain needs it. + /// + /// + /// This is not done through because those are written + /// with a plain file write that fails when the parent directory does not exist yet, and the + /// script lives under .aspire. + /// + public static async Task EnsureToolchainFilesExistAsync( + JavaAppHostToolchainResolution resolution, + CancellationToken cancellationToken) + { + if (resolution.Toolchain != JavaAppHostToolchain.Gradle) + { + return; + } + + // Written into the project directory rather than the AppHost directory so it sits alongside + // the build file it augments, which is where the --init-script argument points. + var scriptPath = Path.Combine(resolution.ProjectDirectory.FullName, GradleInitScriptPath); + + Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); + + // Rewritten every run rather than only when missing, so a script left behind by an older + // Aspire version cannot silently keep staging dependencies the wrong way. + // + // Written through a temporary sibling and moved into place because writing directly truncates + // first: a second `aspire run` starting while this one is mid-write leaves Gradle reading a + // half-written script, which fails with a syntax error that points at the generated file rather + // than at the race. File.Move within a directory is atomic on every platform we support. + var temporaryPath = $"{scriptPath}.{Environment.ProcessId}.tmp"; + + try + { + await File.WriteAllTextAsync(temporaryPath, GradleInitScript, cancellationToken).ConfigureAwait(false); + File.Move(temporaryPath, scriptPath, overwrite: true); + } + catch + { + try + { + File.Delete(temporaryPath); + } + catch + { + // Losing a temp file matters far less than the original failure, which is rethrown. + } + + throw; + } + } + + /// + /// Removes staged dependencies that the build tool will not remove itself, immediately before it + /// restages them. + /// + /// + /// The AppHost is launched with the whole staging directory on its classpath as dir/*, and + /// dependency:copy-dependencies only ever adds to that directory. Upgrading a dependency + /// changes the file name, so the old JAR stays behind and both versions end up on the classpath, + /// where which one wins is left to directory order. + /// + /// Gradle needs nothing here because its staging task is a Sync, which prunes the destination + /// itself and stays incremental while doing it. Maven has no equivalent. + /// + /// + /// This is called from the install path rather than when the toolchain is resolved so that clearing + /// and restaging cannot be separated. Clearing without restaging would leave the AppHost with an + /// empty classpath. + /// + /// + public static void ClearStagedDependencies(JavaAppHostToolchainResolution resolution) + { + if (resolution.Toolchain != JavaAppHostToolchain.Maven) + { + return; + } + + var dependencyDirectory = Path.Combine( + resolution.ProjectDirectory.FullName, + GetDependencyDirectory(resolution.Toolchain)); + + try + { + if (Directory.Exists(dependencyDirectory)) + { + Directory.Delete(dependencyDirectory, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Continuing here would defeat the only reason this method exists. Maven adds the new + // versioned JAR beside the stale one, both end up on the "dir/*" classpath, and which one + // the JVM loads is left to directory order — a failure that surfaces later as an unrelated + // NoSuchMethodError. Better to stop now with a message that names the directory. + throw new InvalidOperationException( + $"The staged dependency directory '{dependencyDirectory}' could not be cleared, so the " + + "AppHost would run with both the old and new versions of any upgraded dependency on its " + + "classpath. Close anything holding files in that directory, or delete it manually, then " + + "run the command again.", + ex); + } + } + + /// + /// Path from the AppHost directory to the project directory, or when they are + /// the same. Null rather than "." so the common flat layout keeps clean, unprefixed relative paths. + /// + private static string? GetRelativeProjectPath(DirectoryInfo projectDirectory, DirectoryInfo appHostDirectory) + { + var relativePath = Path.GetRelativePath(appHostDirectory.FullName, projectDirectory.FullName); + + return relativePath == "." ? null : relativePath; + } + + private static string CombineProjectPath(string? projectPath, string path) + { + return projectPath is null ? path : Path.Combine(projectPath, path); + } + + /// + /// Arguments that point the build tool at a project other than its working directory. Both tools + /// need this because the AppHost is launched from its own directory, not the project root. + /// + private static string[] GetProjectSelectionArgs(JavaAppHostToolchain toolchain, string? projectPath) + { + if (projectPath is null) + { + return []; + } + + return toolchain switch + { + // https://maven.apache.org/ref/current/maven-embedder/cli.html + JavaAppHostToolchain.Maven => ["-f", Path.Combine(projectPath, MavenPomFileName)], + // https://docs.gradle.org/current/userguide/command_line_interface.html + JavaAppHostToolchain.Gradle => ["-p", projectPath], + _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null) + }; + } + + private static string GetClassesDirectory(JavaAppHostToolchain toolchain) + { + return toolchain switch + { + // Both are the build tool's conventional output for main classes. + // https://maven.apache.org/guides/introduction/introduction-to-the-standard-directory-layout.html + // https://docs.gradle.org/current/userguide/java_plugin.html#sec:java_project_layout + JavaAppHostToolchain.Maven => Path.Combine("target", "classes"), + JavaAppHostToolchain.Gradle => Path.Combine("build", "classes", "java", "main"), + _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null) + }; + } + + private static string GetDependencyDirectory(JavaAppHostToolchain toolchain) + { + return toolchain switch + { + JavaAppHostToolchain.Maven => Path.Combine("target", DependencyDirectoryName), + JavaAppHostToolchain.Gradle => Path.Combine("build", DependencyDirectoryName), + _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null) + }; + } + + private static CommandSpec CreateInstallCommand( + JavaAppHostToolchain toolchain, + JavaToolInvocation invocation, + string? projectPath) + { + var toolArgs = toolchain switch + { + // Batch mode keeps the transfer progress spinner out of the CLI's captured output. + // Only runtime-scoped dependencies are staged: test and provided dependencies are not + // on the application's runtime classpath and staging them can shadow real versions. + JavaAppHostToolchain.Maven => (string[]) + [ + "-B", "-q", + .. GetProjectSelectionArgs(toolchain, projectPath), + "dependency:copy-dependencies", + // Maven resolves a relative outputDirectory against the project's base directory, so + // the path is expressed relative to the project rather than the working directory. + $"-DoutputDirectory={GetDependencyDirectory(toolchain)}", + "-DincludeScope=runtime" + ], + JavaAppHostToolchain.Gradle => + [ + "-q", + .. GetProjectSelectionArgs(toolchain, projectPath), + "--init-script", CombineProjectPath(projectPath, GradleInitScriptPath), + "aspireCopyDependencies" + ], + _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null) + }; + + return invocation.CreateCommand(toolArgs); + } + + /// + /// Compiles the AppHost with javac, against the dependency classpath the build tool staged. + /// + /// + /// The build tool resolves dependencies but does not compile, because neither Maven nor Gradle can + /// be told from the command line about the generated SDK under .aspire/modules. Maven has no + /// user property for an extra source root or compiler argument — compilerArgs, + /// compilerArgument, and the source roots are all pom-only — so mvnw compile fails with + /// "package aspire does not exist" no matter what is passed after the goal. Editing the user's + /// pom.xml to add a source root is not an option either: the build file belongs to them. + /// + /// Compiling with javac instead keeps the AppHost building the same way under all three toolchains, + /// and the build tool keeps the two jobs it is actually needed for: resolving third-party + /// dependencies, and giving the IDE a real project model. + /// + /// + /// The consequence, which is worth knowing: compiler configuration in pom.xml or + /// build.gradle — annotation processors such as Lombok, custom lint settings, an alternative + /// compiler — does not apply to AppHost.java. + /// + /// + /// + /// Inputs that decide what the AppHost is compiled against, as opposed to what it is + /// compiled from. + /// + /// + /// A dependency bump changes no Java source at all: the build descriptor changes, and the build + /// tool then stages a differently-named JAR. Without these the cached bytecode is reused and the + /// AppHost runs against an API that is no longer on its classpath, usually surfacing as a + /// NoSuchMethodError at the point of use rather than anything that names the real cause. + /// + /// The staged dependency directory is listed non-recursively and its JARs are outside the check's + /// source extensions on purpose. Staging runs on every launch and can rewrite the JARs in place, + /// so only the directory's own timestamp is meaningful — and that moves exactly when the resolved + /// set changes, which is the question being asked. + /// + /// + private static string[] GetDependencyInputs(JavaAppHostToolchain toolchain, string? projectPath, string dependencyDirectory) + { + string[] buildDescriptors = toolchain switch + { + JavaAppHostToolchain.Maven => [MavenPomFileName], + // The version catalog is Gradle's other place for dependency coordinates, and it is + // conventionally a sibling of the settings file rather than of the build file. + // https://docs.gradle.org/current/userguide/version_catalogs.html + JavaAppHostToolchain.Gradle => + [ + GradleBuildFileName, + GradleKotlinBuildFileName, + GradleSettingsFileName, + GradleKotlinSettingsFileName, + Path.Combine("gradle", "libs.versions.toml") + ], + _ => throw new ArgumentOutOfRangeException(nameof(toolchain), toolchain, null) + }; + + return + [ + .. buildDescriptors.Select(descriptor => CombineProjectPath(projectPath, descriptor)), + dependencyDirectory + ]; + } + + private static CommandSpec CreateCompileCommand(JavaAppHostToolchain toolchain, RuntimeSpec baseRuntimeSpec, string? projectPath, string classesDirectory, string dependencyDirectory) + { + var baseCompile = baseRuntimeSpec.PreExecute?.FirstOrDefault() + ?? throw new InvalidOperationException("The Java runtime spec has no compile step to adapt for a build tool."); + + var baseArgs = baseCompile.Args ?? []; + + // "dir/*" is expanded by the JVM's own launcher rather than the shell, so it needs no shell and + // stays correct when the directory is empty. + // https://docs.oracle.com/en/java/javase/25/docs/specs/man/javac.html#standard-options + string[] addedArgs = + [ + "-classpath", Path.Combine(dependencyDirectory, "*"), + // The AppHost directory is the working directory and is also a source root under both + // supported layouts, so this is what lets the AppHost reference the project's own classes. + // It has to be set explicitly because javac otherwise defaults the source path to the class + // path, which -classpath has just replaced. + // + // src/main/java is included as well for the flat layout, where AppHost.java sits beside + // pom.xml at the project root and the project's own sources are one source root down. The + // build tool only stages dependencies here - it never compiles - so without this entry a + // reference to the project's own class fails with "cannot find symbol". Under the + // conventional layout the working directory already is src/main/java, which makes the extra + // entry a path that does not exist; javac ignores those, so it costs nothing. + "-sourcepath", string.Join(Path.PathSeparator, ".", Path.Combine("src", "main", "java")), + // Output goes to the build tool's own directory rather than the javac toolchain's + // .java-build, so that `mvn clean` / `gradle clean` removes it and it is already ignored by + // any Maven or Gradle .gitignore. + "-d", classesDirectory + ]; + + // The javac options and the source arguments are reused from the base spec rather than repeated + // here, so the two toolchains cannot drift apart. The output directory is the one thing replaced. + var outputIndex = Array.IndexOf(baseArgs, "-d"); + + var args = outputIndex >= 0 && outputIndex + 1 < baseArgs.Length + ? (string[])[.. baseArgs[..outputIndex], .. addedArgs, .. baseArgs[(outputIndex + 2)..]] + : [.. addedArgs, .. baseArgs]; + + return new CommandSpec + { + Command = baseCompile.Command, + Args = args, + // The stamp has to move with the output directory. Left in .java-build it would survive a + // `mvn clean`, and the next launch would skip a compile whose classes had just been deleted. + UpToDateCheck = baseCompile.UpToDateCheck is null + ? null + : new CommandUpToDateCheck + { + // The base spec guesses at "src/main/java" relative to the AppHost directory, which + // is only right for the flat layout. Here the project's real source root is known, + // so it replaces the guess: under the conventional layout that root *is* the AppHost + // directory, and it has to be scanned recursively for the packages beneath it. + Inputs = + [ + .. baseCompile.UpToDateCheck.Inputs.Where(static input => !input.StartsWith("src/main/java", StringComparison.Ordinal)), + $"{CombineProjectPath(projectPath, Path.Combine("src", "main", "java"))}/**", + .. GetDependencyInputs(toolchain, projectPath, dependencyDirectory) + ], + FileExtensions = baseCompile.UpToDateCheck.FileExtensions, + StampFile = Path.Combine(classesDirectory, Path.GetFileName(baseCompile.UpToDateCheck.StampFile)) + } + }; + } + + private static CommandSpec CreateExecuteCommand(string classesDirectory, string dependencyDirectory) + { + // "dir/*" is expanded by the JVM into every JAR in that directory. It is not a shell glob, so + // it works with no shell involved and stays correct when the directory is empty. + // https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html#standard-options-for-java + var classPath = string.Join(Path.PathSeparator, [classesDirectory, Path.Combine(dependencyDirectory, "*")]); + + return new CommandSpec + { + Command = "java", + // {args} is deliberately absent. When no argument contains that placeholder the CLI + // appends its arguments as separate argv entries, whereas substituting the placeholder + // joins them into one space-separated string that the AppHost then has to re-split. + Args = ["-cp", classPath, "AppHost"] + }; + } +} + +/// +/// How a build tool wrapper is launched: the executable, plus any arguments that must precede the +/// tool's own. Windows needs a command interpreter in front of the batch wrapper, other platforms +/// invoke it directly. +/// +internal readonly record struct JavaToolInvocation(string Command, string[] PrefixArgs) +{ + public CommandSpec CreateCommand(string[] args) + { + return new CommandSpec + { + Command = Command, + Args = [.. PrefixArgs, .. args] + }; + } +} + +/// +/// The build tool selected for a Java AppHost, the directory its build file lives in, and why it was chosen. +/// +/// +/// is not always the AppHost's own directory: with the conventional +/// src/main/java/AppHost.java layout the build file is at the project root. Commands are still +/// launched from the AppHost directory, so paths are made relative to it rather than to the project. +/// +internal readonly record struct JavaAppHostToolchainResolution( + JavaAppHostToolchain Toolchain, + DirectoryInfo ProjectDirectory, + string Reason); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index de52d8bed43..86f6f2c1d27 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -1,11 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; using System.Diagnostics; +using System.Globalization; +using System.IO.Hashing; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml; using System.Xml.Linq; using Aspire.Cli.Bundles; using Aspire.Cli.Configuration; @@ -14,6 +19,7 @@ using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Processes; +using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Aspire.Hosting; using Aspire.Shared; @@ -26,7 +32,7 @@ namespace Aspire.Cli.Projects; /// This is used when running in bundle mode (without .NET SDK) to avoid /// dynamic project generation and building. /// -internal sealed class PrebuiltAppHostServer : IAppHostServerProject, IDisposable +internal sealed partial class PrebuiltAppHostServer : IAppHostServerProject, IDisposable { internal const string ClosureMetadataFileName = "closure-metadata.txt"; internal const string ClosureSourcesFileName = "closure-sources.txt"; @@ -36,6 +42,7 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject, IDisposable internal const string ProjectRefAssemblyNamesFileName = "project-ref-assemblies.txt"; private const string ProjectAssetsFileName = "project.assets.json"; + private const string RestoreStampFileName = "aspire-restore.stamp"; private readonly string _appDirectoryPath; private readonly string _socketPath; @@ -313,6 +320,407 @@ private async Task RestoreNuGetPackagesAsync( ct: cancellationToken).ConfigureAwait(false); } + /// + /// Writes only when it differs from what is already on disk. + /// + /// + /// Rewriting an identical file still updates its timestamp, which MSBuild treats as a changed + /// input and responds to by rebuilding. Writing only on a real change keeps the incremental + /// build intact across launches. + /// + internal static Task WriteIfChangedAsync(string path, string content, CancellationToken cancellationToken) + => GeneratedFileWriter.WriteIfChangedAsync(path, content, cancellationToken); + + /// + /// Reads every restore input, returning its fingerprint and whether the closure is eligible + /// for a skipped restore at all. + /// + /// + /// The generated project file encodes package identities and versions, project reference paths, + /// channel sources, and the synthesized NuGet.config path. Referenced project files are hashed + /// as well because restore resolves their dependencies too: a referenced project bumping its own + /// Aspire.Hosting version changes the resolved closure without changing a single byte of the + /// generated project file. + /// + /// The whole project-reference graph is walked, not just its first level, because restore + /// resolves the graph: a package bump two hops out changes the closure exactly as much as one + /// hop out does. Each project's directory-scoped MSBuild imports are hashed with it, since under + /// central package management the reference carries no version at all and bumping + /// Directory.Packages.props changes what restore resolves while every project file stays + /// byte-for-byte identical. + /// + /// + /// Every project in that closure is also scanned for floating versions, because a float anywhere + /// in it can resolve to a different package without any local input changing. + /// + /// + internal static async Task ComputeRestoreInputsAsync( + string projectContent, + IReadOnlyList packageRefs, + IReadOnlyList projectRefs, + CancellationToken cancellationToken) + { + var hash = new XxHash3(); + hash.Append(Encoding.UTF8.GetBytes(projectContent)); + + var isFloating = HasFloatingPackageVersion(packageRefs); + + var pending = new Queue(); + // Ordinal rather than a path-aware comparer: a duplicate spelling of the same path costs one + // extra read, whereas treating two genuinely different paths as one would drop an input. + var visited = new HashSet(StringComparer.Ordinal); + var closure = new List(); + + foreach (var projectRef in projectRefs) + { + if (projectRef.ProjectPath is { } path) + { + pending.Enqueue(path); + } + } + + while (pending.Count > 0) + { + var projectPath = pending.Dequeue(); + var normalizedPath = NormalizeProjectPath(projectPath); + + // Terminates on its own rather than hanging the launch: MSBuild rejects a project + // reference cycle, but the fingerprint is computed before anything validates the graph. + if (!visited.Add(normalizedPath)) + { + continue; + } + + closure.Add(normalizedPath); + + foreach (var referenced in ReadProjectReferences(normalizedPath)) + { + pending.Enqueue(referenced); + } + } + + // Ordering makes the fingerprint independent of the order the graph happened to be walked in. + // Hash the path as well as the content so that repointing a reference at a different project + // with identical content is still seen as a change. + foreach (var projectPath in closure.OrderBy(static path => path, StringComparer.Ordinal)) + { + hash.Append(Encoding.UTF8.GetBytes(projectPath)); + + if (!File.Exists(projectPath)) + { + continue; + } + + var projectBytes = await File.ReadAllBytesAsync(projectPath, cancellationToken).ConfigureAwait(false); + hash.Append(projectBytes); + + if (!isFloating && HasFloatingVersionAttribute(Encoding.UTF8.GetString(projectBytes))) + { + isFloating = true; + } + } + + foreach (var importPath in FindDirectoryScopedImports(closure).OrderBy(static path => path, StringComparer.Ordinal)) + { + hash.Append(Encoding.UTF8.GetBytes(importPath)); + + var importBytes = await File.ReadAllBytesAsync(importPath, cancellationToken).ConfigureAwait(false); + hash.Append(importBytes); + + if (!isFloating && HasFloatingVersionAttribute(Encoding.UTF8.GetString(importBytes))) + { + isFloating = true; + } + } + + return new RestoreInputs(Convert.ToHexString(hash.GetCurrentHash()), IsEligibleForSkip: !isFloating); + } + + /// + /// Resolves a project path to a comparable absolute form so the same project reached by two + /// different spellings is hashed once. + /// + private static string NormalizeProjectPath(string projectPath) + { + try + { + return Path.GetFullPath(projectPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + // An unresolvable path is still hashed verbatim: it cannot be read, but the fact that the + // closure names it is itself an input, and a later change to a valid path is then seen. + return projectPath; + } + } + + /// + /// Reads the <ProjectReference Include="..." /> paths a project declares, resolved against + /// the project's own directory the way MSBuild resolves them. + /// + /// + /// Parsed as XML rather than with a regex because an Include can be spread across attributes and + /// whitespace. A project that cannot be read or parsed contributes no references: the file itself + /// is still hashed above, so a later fix to it changes the fingerprint. + /// + private static List ReadProjectReferences(string projectPath) + { + var references = new List(); + + if (!File.Exists(projectPath)) + { + return references; + } + + XDocument document; + try + { + using var stream = File.OpenRead(projectPath); + // DTD processing stays off: these files are inputs from the user's checkout and an + // external entity must never be fetched while computing a fingerprint. + using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + document = XDocument.Load(reader); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or XmlException) + { + return references; + } + + var projectDirectory = Path.GetDirectoryName(projectPath); + if (projectDirectory is null) + { + return references; + } + + foreach (var element in document.Descendants().Where(static e => e.Name.LocalName == "ProjectReference")) + { + var include = element.Attribute("Include")?.Value; + if (string.IsNullOrWhiteSpace(include)) + { + continue; + } + + // An MSBuild property in the path ($(RepoRoot)/...) cannot be expanded without evaluating + // the project, so the reference is skipped rather than hashed under a nonsense path. + if (include.Contains("$(", StringComparison.Ordinal)) + { + continue; + } + + references.Add(Path.Combine(projectDirectory, include.Replace('\\', Path.DirectorySeparatorChar))); + } + + return references; + } + + /// + /// Finds the directory-scoped files MSBuild and NuGet import automatically for the projects in a + /// closure, by walking from each project's directory to the root the way they do. + /// + /// + /// These carry version information that never appears in the project file itself - most + /// importantly Directory.Packages.props under central package management, where the reference is + /// written without a version at all. + /// + /// https://learn.microsoft.com/nuget/consume-packages/central-package-management + /// https://learn.microsoft.com/visualstudio/msbuild/customize-by-directory + /// + /// + private static HashSet FindDirectoryScopedImports(IReadOnlyList closure) + { + var imports = new HashSet(StringComparer.Ordinal); + var scannedDirectories = new HashSet(StringComparer.Ordinal); + + foreach (var projectPath in closure) + { + var directory = Path.GetDirectoryName(projectPath); + + while (directory is not null && scannedDirectories.Add(directory)) + { + foreach (var fileName in s_directoryScopedImportFileNames) + { + var candidate = Path.Combine(directory, fileName); + if (File.Exists(candidate)) + { + imports.Add(candidate); + } + } + + directory = Path.GetDirectoryName(directory); + } + } + + return imports; + } + + // NuGet.config is matched case-insensitively by NuGet itself, but the two spellings below are the + // ones it documents and the ones repositories actually use. + private static readonly string[] s_directoryScopedImportFileNames = + [ + "Directory.Packages.props", + "Directory.Build.props", + "Directory.Build.targets", + "NuGet.config", + "nuget.config" + ]; + + /// + /// The restore inputs for one integration closure. + /// + /// Identifies the exact set of inputs the restore reads. + /// + /// Whether an unchanged fingerprint is enough to prove the resolved closure is unchanged. + /// + internal readonly record struct RestoreInputs(string Fingerprint, bool IsEligibleForSkip); + + /// + /// Returns when a project file declares a package version that NuGet + /// resolves against the feed rather than pinning exactly. + /// + /// + /// Matches the version attribute of a reference, for example + /// <PackageReference Include="Aspire.Hosting" Version="13.4.*" /> or + /// VersionOverride="[13.4,14)". The word boundary keeps unrelated attributes that merely + /// end in "Version" (such as ToolsVersion) from matching. A false positive only forces a + /// restore, which is the safe direction. + /// + internal static bool HasFloatingVersionAttribute(string projectText) + => FloatingVersionAttributeRegex().IsMatch(projectText); + + [GeneratedRegex("""\b(?:VersionOverride|Version)\s*=\s*"[^"]*[*\[(,]""", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex FloatingVersionAttributeRegex(); + + /// + /// Returns when any package version can resolve to a different package + /// without any local input changing, which makes the closure ineligible for a skipped restore. + /// + /// + /// A floating version ("13.4.*") or a range ("[13.4,14)") is resolved by NuGet at restore time + /// against the feed, so an unchanged fingerprint does not imply an unchanged closure. + /// + internal static bool HasFloatingPackageVersion(IReadOnlyList packageRefs) + => packageRefs.Any(static r => r.Version is { } version && version.AsSpan().ContainsAny(s_floatingVersionChars)); + + // '*' is a float, and '[', '(', ',' delimit a version range. An exact version contains none of them. + private static readonly SearchValues s_floatingVersionChars = SearchValues.Create("*[(,"); + + /// + /// Determines whether the last successful restore already saw this exact set of inputs. + /// + /// + /// The stamp is written only after a restore succeeds, so its presence with a matching + /// fingerprint means a complete restore has run for these inputs. This is compared by content + /// rather than by timestamp because file modification times are unreliable across coarse + /// filesystems, clock skew, and caches that restore mtimes. + /// + internal static bool CanSkipIntegrationRestore(string restoreDir, string expectedFingerprint, ILogger logger) + { + var assetsPath = Path.Combine(restoreDir, "obj", ProjectAssetsFileName); + var stampPath = Path.Combine(restoreDir, "obj", RestoreStampFileName); + if (!File.Exists(assetsPath) || !File.Exists(stampPath)) + { + return false; + } + + try + { + return string.Equals(File.ReadAllText(stampPath), expectedFingerprint, StringComparison.Ordinal); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogDebug(ex, "Unable to read the integration restore stamp; restoring."); + return false; + } + } + + /// + /// Records that a restore completed successfully for . + /// + private static async Task WriteRestoreStampAsync(string restoreDir, string fingerprint, ILogger logger, CancellationToken cancellationToken) + { + try + { + var objDir = Path.Combine(restoreDir, "obj"); + Directory.CreateDirectory(objDir); + await File.WriteAllTextAsync(Path.Combine(objDir, RestoreStampFileName), fingerprint, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A missing stamp only costs a restore on the next launch, so this is not worth failing over. + logger.LogDebug(ex, "Unable to write the integration restore stamp."); + } + } + + /// + /// Returns when a build failure looks like one that restoring would fix. + /// + /// + /// Only a package-resolution failure is worth a second build. Retrying every failure would + /// double the cost of an ordinary compile error and would replace its diagnostic with whatever + /// the restore attempt produced. + /// The restore fingerprint covers this app's own inputs but cannot see the shared global package + /// cache, so a `dotnet nuget locals all --clear` (or any cache eviction) leaves the fingerprint + /// unchanged while the packages it assumes are gone. Because the stamp is only ever written + /// after a successful restore and is never cleared, a no-restore build that fails this way would + /// otherwise fail identically on every subsequent run until the user manually deleted obj/. + /// Examples of the failures this matches: + /// error NETSDK1004: Assets file '/path/obj/project.assets.json' not found. Run a NuGet package restore. + /// error NETSDK1064: Package Aspire.Hosting.Redis, version 13.5.0 was not found. It might have been deleted since NuGet restore. + /// error NU1101: Unable to find package Aspire.Hosting.Java. No packages exist with this id in source(s): dotnet-public + /// error NU1102: Unable to find package Aspire.Hosting with version (>= 13.6.0-dev) + /// + internal static bool ShouldRetryWithRestore(OutputCollector buildOutput) + => buildOutput.GetLines().Any(static l => + l.Line.Contains("NETSDK1004", StringComparison.Ordinal) || + l.Line.Contains("NETSDK1064", StringComparison.Ordinal) || + l.Line.Contains("NU1101", StringComparison.Ordinal) || + l.Line.Contains("NU1102", StringComparison.Ordinal) || + l.Line.Contains(ProjectAssetsFileName, StringComparison.Ordinal)); + + /// + /// Produces the failure message for a failed integration build, recognizing the one failure + /// mode that is a configuration problem rather than a build problem. + /// + /// + /// The AppHost server is the CLI itself, so the synthesized project pins Aspire.Hosting to the + /// CLI's own version. A project reference that requires a newer Aspire.Hosting cannot be + /// satisfied, and NuGet reports it as a downgrade: + /// error NU1605: Warning As Error: Detected package downgrade: Aspire.Hosting from 13.6.0-dev to 13.5.0 + /// The raw output is unusable here because MSBuild localizes it, so the diagnostic is matched on + /// the error code alone and the actionable explanation is supplied in the CLI's own language. + /// + internal static string GetIntegrationBuildFailureMessage(OutputCollector buildOutput) + { + var hasPackageDowngrade = buildOutput.GetLines() + .Any(static l => l.Line.Contains("NU1605", StringComparison.Ordinal)); + + return hasPackageDowngrade + ? string.Format( + CultureInfo.CurrentCulture, + ErrorStrings.IntegrationBuildPackageDowngradeFailed, + VersionHelper.GetDefaultTemplateVersion()) + : ErrorStrings.IntegrationBuildFailed; + } + + private async Task<(int ExitCode, OutputCollector Output)> BuildIntegrationProjectAsync( + string projectFilePath, + bool noRestore, + CancellationToken cancellationToken) + { + var buildOutput = new OutputCollector(); + var exitCode = await _dotNetCliRunner.BuildAsync( + new FileInfo(projectFilePath), + noRestore, + new ProcessInvocationOptions + { + StandardOutputCallback = buildOutput.AppendOutput, + StandardErrorCallback = buildOutput.AppendError + }, + cancellationToken).ConfigureAwait(false); + + return (exitCode, buildOutput); + } + /// /// Creates a synthetic .csproj with all package and project references, /// then builds it to get the full transitive DLL closure via CopyLocalLockFileAssemblies. @@ -348,7 +756,7 @@ private async Task BuildIntegrationClosureManifest useExactPackageVersions: !string.IsNullOrWhiteSpace(packageSourceOverride), restoreConfigFile: temporaryNuGetConfig?.ConfigFile.FullName); var projectFilePath = Path.Combine(restoreDir, IntegrationProjectFileName); - await File.WriteAllTextAsync(projectFilePath, projectContent, cancellationToken); + await WriteIfChangedAsync(projectFilePath, projectContent, cancellationToken); // Write a Directory.Packages.props to opt out of Central Package Management var directoryPackagesProps = """ @@ -358,34 +766,49 @@ private async Task BuildIntegrationClosureManifest """; - await File.WriteAllTextAsync( + await WriteIfChangedAsync( Path.Combine(restoreDir, "Directory.Packages.props"), directoryPackagesProps, cancellationToken); // Also write an empty Directory.Build.props/targets to prevent parent imports - await File.WriteAllTextAsync( + await WriteIfChangedAsync( Path.Combine(restoreDir, "Directory.Build.props"), "", cancellationToken); - await File.WriteAllTextAsync( + await WriteIfChangedAsync( Path.Combine(restoreDir, "Directory.Build.targets"), "", cancellationToken); - _logger.LogDebug("Building integration project with {PackageCount} packages and {ProjectCount} project references", - packageRefs.Count, projectRefs.Count); - - var buildOutput = new OutputCollector(); - var exitCode = await _dotNetCliRunner.BuildAsync( - new FileInfo(projectFilePath), - noRestore: false, - new ProcessInvocationOptions - { - StandardOutputCallback = buildOutput.AppendOutput, - StandardErrorCallback = buildOutput.AppendError - }, - cancellationToken); + // Restore dominates this build - measured at 5.6s of a 6.7s warm build - and it only needs to + // run again when something restore actually reads has changed. That set of inputs is captured + // as a content fingerprint rather than a timestamp comparison, and the stamp recording it is + // written only after a restore succeeds. + // + // Skipping restore never skips the build itself, so an edit to a referenced project is still + // compiled. And because a stale or partially cleaned obj/ directory is the one thing the + // fingerprint cannot see, a no-restore build that fails on the assets file is retried with + // restore rather than reported. + var restoreInputs = await ComputeRestoreInputsAsync(projectContent, packageRefs, projectRefs, cancellationToken).ConfigureAwait(false); + var restoreFingerprint = restoreInputs.IsEligibleForSkip ? restoreInputs.Fingerprint : null; + var skipRestore = restoreFingerprint is not null && CanSkipIntegrationRestore(restoreDir, restoreFingerprint, _logger); + + _logger.LogDebug("Building integration project with {PackageCount} packages and {ProjectCount} project references (restore {RestoreState})", + packageRefs.Count, projectRefs.Count, skipRestore ? "skipped" : "requested"); + + var (exitCode, buildOutput) = await BuildIntegrationProjectAsync(projectFilePath, noRestore: skipRestore, cancellationToken).ConfigureAwait(false); + if (exitCode != 0 && skipRestore && ShouldRetryWithRestore(buildOutput)) + { + _logger.LogDebug("Integration project build failed on the restore assets; retrying with restore. First attempt output:\n{BuildOutput}", + string.Join(Environment.NewLine, buildOutput.GetLines().Select(l => l.Line))); + (exitCode, buildOutput) = await BuildIntegrationProjectAsync(projectFilePath, noRestore: false, cancellationToken).ConfigureAwait(false); + } if (exitCode != 0) { var outputLines = string.Join(Environment.NewLine, buildOutput.GetLines().Select(l => l.Line)); _logger.LogError("Integration project build failed. Output:\n{BuildOutput}", outputLines); - throw new AppHostServerPrepareFailedException("Failed to build integration project.", buildOutput); + throw new AppHostServerPrepareFailedException(GetIntegrationBuildFailureMessage(buildOutput), buildOutput); + } + + if (restoreFingerprint is not null && !skipRestore) + { + await WriteRestoreStampAsync(restoreDir, restoreFingerprint, _logger, cancellationToken).ConfigureAwait(false); } var closureSourcesPath = Path.Combine(restoreDir, ClosureSourcesFileName); @@ -1056,12 +1479,44 @@ internal ProcessStartInfo CreateStartInfo( { startInfo.Environment[BundleDiscovery.DcpPathEnvVar] = dcpPath; } + else + { + // Without this variable the AppHost falls back to the DcpCliPath assembly metadata baked in + // by the AppHost SDK, which points into ~/.nuget/packages. A guest-language AppHost never + // restores that package, so the run fails with "The Aspire orchestration component is not + // installed at " - a message that describes the fallback rather than the real + // problem, which is that no layout supplied DCP. Log the real cause where the CLI logs are. + _logger.LogWarning( + "No layout supplied a DCP path, so {EnvironmentVariable} was not set. The AppHost will fall back to its baked-in NuGet package path, which a guest-language AppHost does not restore.", + BundleDiscovery.DcpPathEnvVar); + } // Set the dashboard path so the AppHost can locate and launch the dashboard binary - var managedPath = _layout.GetManagedPath(); - if (managedPath is not null) + var dashboardPath = _layout.GetDashboardPath(); + if (dashboardPath is not null) + { + startInfo.Environment[BundleDiscovery.DashboardPathEnvVar] = dashboardPath; + } + + // New bundles launch the Dashboard from its standalone executable, so the Dashboard path can + // no longer double as the terminal host. Supply aspire-managed and its dispatch argument as + // defaults while retaining inherited and apphost.run.json overrides as a pair. + bool HasEnvironmentOverride(string name) + { + var value = environmentVariables is not null && environmentVariables.TryGetValue(name, out var configuredValue) + ? configuredValue + : startInfo.Environment.TryGetValue(name, out var inheritedValue) ? inheritedValue : null; + return !string.IsNullOrWhiteSpace(value); + } + + if (!HasEnvironmentOverride(BundleDiscovery.TerminalHostPathEnvVar) && + _layout.GetManagedPath() is { } terminalHostPath) { - startInfo.Environment[BundleDiscovery.DashboardPathEnvVar] = managedPath; + startInfo.Environment[BundleDiscovery.TerminalHostPathEnvVar] = terminalHostPath; + if (!HasEnvironmentOverride(BundleDiscovery.TerminalHostInvocationArgsEnvVar)) + { + startInfo.Environment[BundleDiscovery.TerminalHostInvocationArgsEnvVar] = "terminalhost"; + } } // Apply environment variables from apphost.run.json diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index a85cc67decd..5af7d954c02 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -150,6 +150,7 @@ internal sealed class ProjectLocator( private const string AspireConfigAppHostPathKey = "appHost.path"; private const string LegacySettingsAppHostPathKey = "appHostPath"; private const string ExplicitLaunchConfigurationSelectionOrigin = "explicit-launch-configuration"; + private const string ExplicitCliSelectionOrigin = "explicit-cli"; private static readonly TimeSpan s_workspaceConfigLockTimeout = TimeSpan.FromSeconds(30); /// @@ -885,6 +886,7 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf public async Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, bool displayProgress, CancellationToken cancellationToken = default) { logger.LogDebug("Finding project file in {CurrentDirectory}", executionContext.WorkingDirectory); + var explicitSelectionWasPrompted = false; if (projectFile is not null) { @@ -966,6 +968,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F if (multipleAppHostProjectsFoundBehavior is MultipleAppHostProjectsFoundBehavior.Prompt) { logger.LogDebug("Multiple AppHost project files found in directory {Directory}, prompting user to select", directory.FullName); + explicitSelectionWasPrompted = true; projectFile = await interactionService.PromptForSelectionAsync( InteractionServiceStrings.SelectAppHostToUse, appHostProjects, @@ -1024,10 +1027,13 @@ public async Task UseOrFindAppHostProjectFileAsync(F logger.LogDebug("Using {Language} apphost {ProjectFile}", handler.DisplayName, projectFile.FullName); if (createSettingsFile) { - await CreateSettingsFileAsync(projectFile, cancellationToken); + await CreateSettingsFileAsync(projectFile, preserveExistingDefault: !explicitSelectionWasPrompted, cancellationToken); } - return new AppHostProjectSearchResult(projectFile, [projectFile]); + return new AppHostProjectSearchResult(projectFile, [projectFile]) + { + WasExplicitDirectorySelectionPrompted = explicitSelectionWasPrompted + }; } if (validationResult.IsPossiblyUnbuildable) @@ -1072,7 +1078,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F // was only kept because MSBuild failed into a confirmed choice. if (createSettingsFile && !settingsResult.IsUnverified) { - await CreateSettingsFileAsync(settingsAppHost, cancellationToken); + await CreateSettingsFileAsync(settingsAppHost, preserveExistingDefault: false, cancellationToken); } return new AppHostProjectSearchResult(settingsAppHost, [settingsAppHost]); @@ -1165,7 +1171,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F if (createSettingsFile && !selectionIsUnverifiedSettingsAppHost) { - await CreateSettingsFileAsync(selectedAppHost!, cancellationToken); + await CreateSettingsFileAsync(selectedAppHost!, preserveExistingDefault: false, cancellationToken); } // Ensure the selected AppHost is always represented in the candidate list so callers @@ -1200,10 +1206,22 @@ internal static bool IsSamePersistedAppHostPath(string persistedPath, string sel return string.Equals(persistedPath, selectedPath, pathComparison); } - private async Task CreateSettingsFileAsync(FileInfo projectFile, CancellationToken cancellationToken) + private async Task CreateSettingsFileAsync(FileInfo projectFile, bool preserveExistingDefault, CancellationToken cancellationToken) { - var selectionOrigin = configuration[KnownConfigNames.CliAppHostSelectionOrigin]; - var isExplicitLaunchConfiguration = string.Equals(selectionOrigin, ExplicitLaunchConfigurationSelectionOrigin, StringComparison.OrdinalIgnoreCase); + var configuredSelectionOrigin = configuration[KnownConfigNames.CliAppHostSelectionOrigin]; + var isExplicitLaunchConfigurationSelection = string.Equals( + configuredSelectionOrigin, + ExplicitLaunchConfigurationSelectionOrigin, + StringComparison.OrdinalIgnoreCase); + var isExplicitCliSelection = string.Equals( + configuredSelectionOrigin, + ExplicitCliSelectionOrigin, + StringComparison.OrdinalIgnoreCase); + var hasConfiguredSelectionOrigin = !string.IsNullOrEmpty(configuredSelectionOrigin); + var selectionOrigin = hasConfiguredSelectionOrigin ? configuredSelectionOrigin : "--apphost"; + var shouldPreserveExistingDefault = + isExplicitLaunchConfigurationSelection || + (preserveExistingDefault && (isExplicitCliSelection || !hasConfiguredSelectionOrigin)); var (settingsFile, appHostDirForScopedConfig) = ResolveWorkspaceConfigTarget(projectFile); @@ -1230,9 +1248,9 @@ private async Task CreateSettingsFileAsync(FileInfo projectFile, CancellationTok return; } - // A launch configuration or agent-selected target is for this invocation only. Preserve + // An explicit CLI target or launch configuration is for this invocation only. Preserve // an existing workspace default, but let the selected AppHost replace a deleted target. - if (isExplicitLaunchConfiguration && File.Exists(resolvedPath)) + if (shouldPreserveExistingDefault && File.Exists(resolvedPath)) { logger.LogDebug( "Not replacing recorded AppHost default {RecordedAppHost} with {AppHost} because the latter was selected by {SelectionOrigin}.", @@ -1422,7 +1440,10 @@ internal enum ProjectLocatorFailureReason UnsupportedProjects, } -internal record AppHostProjectSearchResult(FileInfo? SelectedProjectFile, List AllProjectFileCandidates); +internal record AppHostProjectSearchResult(FileInfo? SelectedProjectFile, List AllProjectFileCandidates) +{ + internal bool WasExplicitDirectorySelectionPrompted { get; init; } +} internal enum MultipleAppHostProjectsFoundBehavior { diff --git a/src/Aspire.Cli/Resources/DashboardCommandStrings.resx b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx index ca8a6a5bd07..734eae5d2b2 100644 --- a/src/Aspire.Cli/Resources/DashboardCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx @@ -130,7 +130,7 @@ The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. Dashboard started (PID {0}). diff --git a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs index d23ecda8250..e4599559dcb 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/ErrorStrings.Designer.cs @@ -488,5 +488,23 @@ public static string ConfigurationFileMustBeJsonObject { return ResourceManager.GetString("ConfigurationFileMustBeJsonObject", resourceCulture); } } + + /// + /// Looks up a localized string similar to The integration project could not be built.. + /// + public static string IntegrationBuildFailed { + get { + return ResourceManager.GetString("IntegrationBuildFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects.. + /// + public static string IntegrationBuildPackageDowngradeFailed { + get { + return ResourceManager.GetString("IntegrationBuildPackageDowngradeFailed", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/ErrorStrings.resx b/src/Aspire.Cli/Resources/ErrorStrings.resx index cb9cea9daea..95334f66125 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.resx +++ b/src/Aspire.Cli/Resources/ErrorStrings.resx @@ -291,4 +291,11 @@ The configuration file '{0}' must contain a JSON object. {0} is the configuration file path that the user authored. + + The integration project could not be built. + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs index 1f7d52af435..576d548c6e5 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs @@ -69,6 +69,12 @@ internal static string AppHostNotRunning { } } + internal static string AppHostNotRunningInCurrentWorktree { + get { + return ResourceManager.GetString("AppHostNotRunningInCurrentWorktree", resourceCulture); + } + } + internal static string AppHostNotRunningAtPath { get { return ResourceManager.GetString("AppHostNotRunningAtPath", resourceCulture); diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx index 82331e311fe..77599c9382a 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.resx +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx @@ -129,6 +129,9 @@ No running AppHost found. Use 'aspire run' to start one first. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf index 622b1e2e1c4..cfcb35be03e 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf index 1c1bae874e0..f6603997623 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf index d6053101689..8524d049a6d 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf index b7bfd5860d0..e37dc95778c 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf index 5ce223ccea0..4f4cb5ac2be 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf index e60d3b7b420..ee1c0dc6726 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf index cac5f89e8c4..29e1446f56b 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf index 1dcdfcde143..bf846e00aeb 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf index 59b7e57ab01..bf14dc67208 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf index 88f7f7162ac..d994f5fc910 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf index c70d48db2f1..b08b09587f1 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf index 6f5f77b0dd3..661703e84e8 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf index 976480d845a..412149cda9f 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf @@ -83,8 +83,8 @@ - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. - The CLI bundle layout was found, but the dashboard binary (aspire-managed) is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. + The CLI bundle layout was found, but the Dashboard executable is missing. The bundle may be corrupted or incomplete. Run 'aspire setup --force' to re-extract the bundle, or reinstall the Aspire CLI. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf index fceae00ca36..ba2329333ab 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf index 94beff83466..6d520ce31e4 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf index ef4b4424d0f..885f5acc240 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf index e305610db76..b4e0cc9568a 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf index 43d7b14656b..b8928b75884 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf index 20c710d666a..86ad16f6593 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf index c890d2e608e..d340c4db062 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf index 89d1eae777e..40e17a49c2f 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf index cf2441581f9..0338167c801 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf index 131c475b2cc..a40c7e83b1e 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf index e5b04a80aed..9e8859bf815 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf index 2b398c04c4c..7ddd0388dbf 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf index 1ecd465dedb..8718b39e291 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf @@ -177,6 +177,16 @@ Global settings file '{0}' contains '{1}', which is not supported. AppHost paths must be configured locally. {0} is the global settings file path, {1} is the configured key such as appHost.path or appHostPath. + + The integration project could not be built. + The integration project could not be built. + + + + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + The integration project could not be built because a referenced project requires a newer version of Aspire.Hosting than this Aspire CLI ({0}) provides. The AppHost server is the CLI itself, so project references in aspire.config.json must target the same version the CLI ships. Either use an Aspire CLI that matches the referenced projects, or reference published packages instead of local projects. + {0} is the version of the running Aspire CLI, for example "13.5.0". + The configuration file '{0}' contains invalid JSON: {1} The configuration file '{0}' contains invalid JSON: {1} diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf index 6221f3d9536..7a1592b933d 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf index 3bd23e14cae..7df8ee2a751 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf index 9e32f4c1df5..038c8ff605e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf index 07b31d02642..384ba87e769 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf index 1baaa8ad43b..134df30d7f2 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf index abc7e24b25d..d9e47b6804e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf index 588dbfca2ee..6c79ed2005f 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf index 30a5172f133..72b3b8d5058 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf index 36ff7a1c4ba..35a2a75a8a2 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf index 3404f56a955..d6e00a8fe20 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf index 8338ccac311..e5c63c1038e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf index c3fbe2f91d0..b3e99b15964 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf index c5cc2d850d7..14f168fe43b 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf @@ -17,6 +17,11 @@ No AppHost is currently running for '{0}'. Use 'aspire run' to start it first. {0} is the AppHost project path. + + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + No running AppHost found in the current worktree. Use '--apphost <path>' to target one directly, or '--all' to include AppHosts from other worktrees. + + Output format for detached AppHost results Output format for detached AppHost results diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 5aca9475e51..1690e0db7ed 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -22,17 +22,18 @@ namespace Aspire.Cli.Scaffolding; internal sealed class ScaffoldingService : IScaffoldingService { private const string PackageJsonFileName = "package.json"; + private const string VsCodeSettingsFileName = ".vscode/settings.json"; private const string JavaScriptHostingPackageName = "Aspire.Hosting.JavaScript"; internal const string BrownfieldTypeScriptAppHostDirectoryName = "aspire-apphost"; - private static readonly JsonSerializerOptions s_packageJsonSerializerOptions = new() + private static readonly JsonSerializerOptions s_scaffoldJsonSerializerOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, IndentSize = 2 }; - private static readonly JsonDocumentOptions s_packageJsonDocumentOptions = new() + private static readonly JsonDocumentOptions s_scaffoldJsonDocumentOptions = new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true @@ -176,7 +177,8 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can return false; } - // Step 4: Write scaffold files to disk, merging package.json and .gitignore when they already exist. + // Step 4: Write scaffold files to disk, merging package.json, .gitignore, and VS Code + // settings when they already exist. foreach (var (fileName, content) in scaffoldFiles) { var filePath = Path.Combine(scaffoldDirectory.FullName, fileName); @@ -201,6 +203,11 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can var existingContent = await File.ReadAllTextAsync(filePath, cancellationToken); contentToWrite = MergeGitIgnoreContent(existingContent, content); } + else if (IsVsCodeSettingsFile(fileName) && File.Exists(filePath)) + { + var existingContent = await File.ReadAllTextAsync(filePath, cancellationToken); + contentToWrite = MergeVsCodeSettingsContent(existingContent, content, _logger); + } await File.WriteAllTextAsync(filePath, contentToWrite, cancellationToken); } @@ -290,7 +297,7 @@ private async Task AddRootTypeScriptAppHostScriptsAsync(DirectoryInfo rootDirect JsonObject packageJson; try { - packageJson = JsonNode.Parse(existingContent, documentOptions: s_packageJsonDocumentOptions) as JsonObject + packageJson = JsonNode.Parse(existingContent, documentOptions: s_scaffoldJsonDocumentOptions) as JsonObject ?? throw new JsonException("The root package.json is not a JSON object."); } catch (JsonException ex) @@ -334,7 +341,7 @@ internal static IReadOnlyList AddRootTypeScriptAppHostDelegateScripts(Js internal static string SerializePackageJson(JsonObject packageJson, string existingContent) { - var serializedPackageJson = packageJson.ToJsonString(s_packageJsonSerializerOptions); + var serializedPackageJson = packageJson.ToJsonString(s_scaffoldJsonSerializerOptions); var trailingNewLine = existingContent.EndsWith("\r\n", StringComparison.Ordinal) ? "\r\n" : existingContent.EndsWith('\n') ? "\n" : null; @@ -534,7 +541,7 @@ internal static IReadOnlyList GetConflictingScaffoldFiles(string rootDir foreach (var fileName in scaffoldFileNames) { - if (IsGitIgnoreFile(fileName) || IsPackageJsonFile(fileName)) + if (IsGitIgnoreFile(fileName) || IsPackageJsonFile(fileName) || IsVsCodeSettingsFile(fileName)) { continue; } @@ -590,6 +597,104 @@ private static bool IsGitIgnoreFile(string fileName) private static bool IsPackageJsonFile(string fileName) => Path.GetFileName(fileName).Equals(PackageJsonFileName, StringComparison.OrdinalIgnoreCase); + private static bool IsVsCodeSettingsFile(string fileName) + { + // The scaffold keys are wire values that always use forward slashes. + var normalized = fileName.Replace('\\', '/'); + + return normalized.Equals(VsCodeSettingsFileName, StringComparison.OrdinalIgnoreCase) + || normalized.EndsWith("/" + VsCodeSettingsFileName, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Merges the scaffolded VS Code workspace settings into the developer's existing ones. + /// + /// + /// Settings the developer already chose win, because they configured their editor deliberately + /// and a scaffold has no standing to change that. Array-valued settings are unioned rather than + /// replaced: java.project.sourcePaths is how the generated SDK under .aspire/modules + /// becomes resolvable, and replacing it would break whatever source roots the project already + /// declared. + /// + /// The existing file is returned unchanged when it already covers everything, so re-running init + /// preserves comments and formatting. When something has to be added the file is reserialized and + /// comments are lost; that is the same tradeoff the package.json merge makes, and VS Code writes + /// this file itself the same way. + /// + /// + /// An existing file that cannot be parsed is returned unchanged. A settings.json broken mid-edit, + /// or using a JSONC construct this parser does not accept, still represents editor configuration + /// the developer accumulated, and silently replacing it with the scaffold's three settings is a + /// far worse outcome than skipping the merge. The caller reports the skip. + /// + /// + /// Content already on disk. + /// Content the scaffold wants to contribute. + /// Receives a warning when the existing file could not be parsed. + internal static string MergeVsCodeSettingsContent(string existingContent, string scaffoldContent, ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(existingContent); + ArgumentNullException.ThrowIfNull(scaffoldContent); + + if (ParseJsonC(scaffoldContent) is not { } scaffold) + { + return scaffoldContent; + } + + // VS Code settings are JSONC: its own generated file opens with a "// Place your settings" + // comment, and hand-edited ones routinely end with a trailing comma. + // https://code.visualstudio.com/docs/languages/json#_json-with-comments + if (ParseJsonC(existingContent) is not { } existing) + { + logger?.LogWarning( + "The existing VS Code settings file could not be parsed, so Aspire's settings were not merged into it. " + + "Fix the JSON and re-run the command, or add the settings by hand."); + return existingContent; + } + + var changed = false; + + foreach (var (key, scaffoldValue) in scaffold) + { + if (existing[key] is not { } existingValue) + { + existing[key] = scaffoldValue?.DeepClone(); + changed = true; + continue; + } + + if (existingValue is not JsonArray existingArray || scaffoldValue is not JsonArray scaffoldArray) + { + continue; + } + + foreach (var entry in scaffoldArray) + { + if (!existingArray.Any(present => JsonNode.DeepEquals(present, entry))) + { + existingArray.Add(entry?.DeepClone()); + changed = true; + } + } + } + + return changed + ? existing.ToJsonString(s_scaffoldJsonSerializerOptions) + : existingContent; + } + + private static JsonObject? ParseJsonC(string content) + { + try + { + return JsonNode.Parse(content, documentOptions: s_scaffoldJsonDocumentOptions) as JsonObject; + } + catch (JsonException) + { + return null; + } + } + private static IEnumerable ReadGitIgnoreEntries(string content) { using var reader = new StringReader(content); diff --git a/src/Aspire.Cli/Telemetry/ProfilingTelemetry.cs b/src/Aspire.Cli/Telemetry/ProfilingTelemetry.cs index 807f19ffd06..2189d9c5031 100644 --- a/src/Aspire.Cli/Telemetry/ProfilingTelemetry.cs +++ b/src/Aspire.Cli/Telemetry/ProfilingTelemetry.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.DotNet; using Aspire.Cli.Projects; +using Aspire.Cli.Utils; using Aspire.Hosting; using Microsoft.Extensions.Configuration; @@ -410,7 +411,9 @@ internal ActivityScope StartDetachedGetDashboardUrls() internal ActivityScope StartDetachedSpawnChild(string executablePath, IReadOnlyList args, string childCommand) { var activity = StartActivity(Activities.Process, ActivityKind.Client); - activity.SetProcessInvocation(executablePath, args); + // Profiling traces are exported and persisted, so the forwarded AppHost tail of the child + // command line is redacted for the same reason the CLI log redacts it. + activity.SetProcessInvocation(executablePath, AppHostArgumentRedactor.Redact(args)); activity.SetChildCommand(childCommand); return activity; } @@ -1133,9 +1136,14 @@ public void SetLsInvocation(string outputFormat, bool includeAll) public void SetDotNetMsBuildServer(string? msBuildServer) => SetTag(Tags.DotNetMsBuildServer, msBuildServer); - public void SetDotNetResolvedExecutable(string dotnetPath, IReadOnlyList args, string? msBuildServer) + public void SetDotNetResolvedExecutable(string dotnetPath, IReadOnlyList args, int? appHostArgumentStartIndex, string? msBuildServer) { - SetProcessInvocation(dotnetPath, args); + // `dotnet run --project AppHost.csproj -- ` flows through here, and the + // recorded tag is persisted with the exported trace, so redact the forwarded tail. + // A direct AppHost launch has no separator and declares its boundary explicitly. + SetProcessInvocation(dotnetPath, appHostArgumentStartIndex is { } startIndex + ? AppHostArgumentRedactor.RedactFrom(args, startIndex) + : AppHostArgumentRedactor.Redact(args)); SetDotNetMsBuildServer(msBuildServer); } diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.JavaStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.JavaStarterTemplate.cs new file mode 100644 index 00000000000..73780b9c82b --- /dev/null +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.JavaStarterTemplate.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Projects; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Templating; + +internal sealed partial class CliTemplateFactory +{ + private async Task ApplyJavaStarterTemplateAsync(CallbackTemplate template, TemplateInputs inputs, System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken) + { + var projectName = inputs.Name; + if (string.IsNullOrWhiteSpace(projectName)) + { + var defaultName = template.Name; + projectName = await _prompter.PromptForProjectNameAsync(defaultName, parseResult, cancellationToken); + } + + if (string.IsNullOrWhiteSpace(inputs.Version)) + { + _interactionService.DisplayError("Unable to determine Aspire version for the Java starter template."); + return new TemplateResult(CliExitCodes.InvalidCommand); + } + + var aspireVersion = inputs.Version; + var outputPath = await ResolveOutputPathAsync(inputs, template.PathDeriver, projectName, parseResult, cancellationToken); + if (outputPath is null) + { + return new TemplateResult(CliExitCodes.FailedToCreateNewProject); + } + + _logger.LogDebug("Applying Java starter template. ProjectName: {ProjectName}, OutputPath: {OutputPath}, AspireVersion: {AspireVersion}.", projectName, outputPath, aspireVersion); + + var useLocalhostTld = await ResolveUseLocalhostTldAsync(parseResult, cancellationToken); + + TemplateResult templateResult; + try + { + if (!Directory.Exists(outputPath)) + { + Directory.CreateDirectory(outputPath); + } + + templateResult = await _interactionService.ShowStatusAsync( + "Creating new Aspire Java project...", + (Func>)(async () => + { + var projectNameLower = projectName.ToLowerInvariant(); + var ports = GenerateRandomPorts(); + var hostName = useLocalhostTld ? $"{projectNameLower}.dev.localhost" : "localhost"; + string ApplyAllTokens(string content) => ApplyTokens(content, projectName, projectNameLower, aspireVersion, ports, hostName); + + _logger.LogDebug("Copying embedded Java starter template files to '{OutputPath}'.", outputPath); + await CopyTemplateTreeToDiskAsync("java-starter", outputPath, ApplyAllTokens, cancellationToken); + + // Persist the resolved SDK version, and the resolved channel when NewCommand + // resolved an Explicit one (pr-, daily, staging, local), into the scaffolded + // project's aspire.config.json. + // + // The SDK version is written unconditionally so `aspire new --version` produces a + // project pinned to the version it was scaffolded with; without it the AppHost and + // the packages can resolve to different versions on the next restore. Implicit + // channel selections are left unwritten so `aspire add`/`aspire restore` use the + // user's ambient NuGet config without a per-project pin. Mirrors + // CliTemplateFactory.TypeScriptStarterTemplate and DotNetTemplateFactory. + var config = AspireConfigFile.LoadOrCreate(outputPath, aspireVersion); + if (!string.IsNullOrEmpty(inputs.Channel)) + { + config.Channel = inputs.Channel; + } + config.Save(outputPath); + + var appHostProject = _projectFactory.TryGetProject(new FileInfo(Path.Combine(outputPath, "AppHost.java"))); + if (appHostProject is not IGuestAppHostSdkGenerator guestProject) + { + _interactionService.DisplayError("Automatic 'aspire restore' is unavailable for the new Java starter project because no Java AppHost SDK generator was found."); + return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); + } + + _logger.LogDebug("Generating SDK code for Java starter in '{OutputPath}'.", outputPath); + var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), packageSourceOverride: inputs.Source, cancellationToken: cancellationToken); + if (!restoreSucceeded) + { + _interactionService.DisplayError("Automatic 'aspire restore' failed for the new Java starter project. Run 'aspire restore' in the project directory for more details."); + return new TemplateResult((int)CliExitCodes.FailedToBuildArtifacts, outputPath); + } + await _templateNuGetConfigService.CreateOrUpdateNuGetConfigForSourceOverrideAsync(inputs.Source, inputs.Channel, outputPath, cancellationToken); + + return new TemplateResult((int)CliExitCodes.Success, outputPath); + }), emoji: KnownEmojis.Rocket); + + if (templateResult.ExitCode != CliExitCodes.Success) + { + return templateResult; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _interactionService.DisplayError($"Failed to create project files: {ex.Message}"); + return new TemplateResult(CliExitCodes.FailedToCreateNewProject); + } + + _interactionService.DisplaySuccess($"Created Java starter project at {outputPath.EscapeMarkup()}"); + DisplayPostCreationInstructions(outputPath); + + return templateResult; + } +} diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.cs index 34bfabf9adc..5a25fee9d56 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.cs @@ -43,6 +43,15 @@ internal sealed partial class CliTemplateFactory : ITemplateFactory ".otf" ]; + // Embedded resources carry no file mode, so every scaffolded file lands with the default + // 0644. Build wrappers are invoked as "./mvnw" / "./gradlew" and fail with "Permission + // denied" on Unix unless the execute bit is restored here. + private static readonly HashSet s_executableTemplateFileNames = + [ + "mvnw", + "gradlew" + ]; + private readonly Option _localhostTldOption = new("--localhost-tld") { Description = TemplatingStrings.UseLocalhostTld_Description @@ -202,7 +211,16 @@ private IEnumerable GetTemplateDefinitions() cmd => AddOptionIfMissing(cmd, _localhostTldOption), ApplyGoStarterTemplateAsync, runtime: TemplateRuntime.Cli, - languageId: KnownLanguageId.Go) + languageId: KnownLanguageId.Go), + + new CallbackTemplate( + KnownTemplateId.JavaStarter, + "Starter App (Spring Boot/React, Java AppHost)", + (ctx, projectName) => OutputPathHelper.GetUniqueDefaultOutputPath(projectName, ctx.WorkingDirectory.FullName), + cmd => AddOptionIfMissing(cmd, _localhostTldOption), + ApplyJavaStarterTemplateAsync, + runtime: TemplateRuntime.Cli, + languageId: KnownLanguageId.Java) ]; return templates.Where(IsTemplateAvailable); @@ -330,6 +348,30 @@ private async Task CopyTemplateTreeToDiskAsync(string templateRoot, string outpu await writer.WriteAsync(transformedContent.AsMemory(), cancellationToken); await writer.FlushAsync(cancellationToken); } + + MakeExecutableIfBuildWrapper(filePath); + } + } + + private void MakeExecutableIfBuildWrapper(string filePath) + { + if (_environment.IsWindows() || !s_executableTemplateFileNames.Contains(Path.GetFileName(filePath))) + { + return; + } + + try + { +#pragma warning disable CA1416 // Guarded by the IsWindows check above, which the analyzer cannot see through. + var mode = File.GetUnixFileMode(filePath); + File.SetUnixFileMode(filePath, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); +#pragma warning restore CA1416 + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + // A wrapper that is not executable still runs through "sh ./mvnw", so a file system that + // rejects mode changes should degrade rather than fail the whole scaffold. + _logger.LogDebug(ex, "Unable to set the execute bit on scaffolded build wrapper '{FilePath}'.", filePath); } } diff --git a/src/Aspire.Cli/Templating/KnownTemplateId.cs b/src/Aspire.Cli/Templating/KnownTemplateId.cs index b673d6477b9..ecc528558ab 100644 --- a/src/Aspire.Cli/Templating/KnownTemplateId.cs +++ b/src/Aspire.Cli/Templating/KnownTemplateId.cs @@ -57,4 +57,9 @@ internal static class KnownTemplateId /// The template ID for the Go starter template (Redis + Go HTTP API). /// public const string GoStarter = "aspire-go-starter"; + + /// + /// The template ID for the Java starter template (Express API + React frontend, Java AppHost). + /// + public const string JavaStarter = "aspire-java-starter"; } diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore b/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore index 855df1ccf12..4f017fdc4c0 100644 --- a/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore +++ b/src/Aspire.Cli/Templating/Templates/java-starter/.gitignore @@ -1,3 +1,7 @@ .java-build/ .aspire/ .modules/ + +# Maven build output for the api service, including the OpenTelemetry agent the POM +# downloads into target/agent during the build. +target/ diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/.vscode/extensions.json b/src/Aspire.Cli/Templating/Templates/java-starter/.vscode/extensions.json new file mode 100644 index 00000000000..6d859425b08 --- /dev/null +++ b/src/Aspire.Cli/Templating/Templates/java-starter/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "microsoft-aspire.aspire-vscode", + "vscjava.vscode-java-pack" + ] +} diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/.vscode/settings.json b/src/Aspire.Cli/Templating/Templates/java-starter/.vscode/settings.json new file mode 100644 index 00000000000..40bd772518d --- /dev/null +++ b/src/Aspire.Cli/Templating/Templates/java-starter/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "java.project.sourcePaths": [ + ".", + ".aspire/modules" + ] +} diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/AppHost.java b/src/Aspire.Cli/Templating/Templates/java-starter/AppHost.java index 31bbe44c623..1bd2a7eae5c 100644 --- a/src/Aspire.Cli/Templating/Templates/java-starter/AppHost.java +++ b/src/Aspire.Cli/Templating/Templates/java-starter/AppHost.java @@ -1,18 +1,33 @@ -package aspire; +import aspire.*; final class AppHost { void main() throws Exception { IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); - NodeAppResource app = builder.addNodeApp("app", "./api", "src/index.ts"); - app.withHttpEndpoint(new WithHttpEndpointOptions().env("PORT")); - app.withExternalHttpEndpoints(); + // The Spring Boot API in ./api. Maven is detected from its pom.xml, so the AppHost never + // names a build tool: addSpringBootApp builds the project, launches it through + // spring-boot:run, and declares an HTTP endpoint by setting SERVER_PORT. + JavaAppResource api = builder.addSpringBootApp("api", "./api"); + + // Attaches target/agent/opentelemetry-javaagent.jar, which the POM downloads during the + // build. The agent instruments Spring's web and HTTP client layers, so traces, metrics, and + // logs reach the Aspire dashboard without any code in the service. + api.withOtelAgentDefaultPath(); + + // Holds anything that waits on the API back until Spring reports it can serve traffic, + // rather than merely until the process exists. + api.withHttpHealthCheck(new WithHttpHealthCheckOptions().path("/actuator/health")); + api.withExternalHttpEndpoints(); ViteAppResource frontend = builder.addViteApp("frontend", "./frontend"); - frontend.withReference(app); - frontend.waitFor(app); + frontend.withReference(api); + frontend.waitFor(api); - app.publishWithContainerFiles(frontend, "./static"); + // In publish and deploy mode the frontend's build output is copied into the API container at + // ./static, which application.properties adds to the Spring resource locations. That puts the + // SPA and /api/weatherforecast on one origin. During local development this does nothing and + // Vite serves the frontend, proxying /api to the API instead. + api.publishWithContainerFiles(frontend, "./static"); builder.build().run(); } diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/.mvn/wrapper/maven-wrapper.properties b/src/Aspire.Cli/Templating/Templates/java-starter/api/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000000..d58dfb70bab --- /dev/null +++ b/src/Aspire.Cli/Templating/Templates/java-starter/api/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/mvnw b/src/Aspire.Cli/Templating/Templates/java-starter/api/mvnw new file mode 100755 index 00000000000..bd8896bf221 --- /dev/null +++ b/src/Aspire.Cli/Templating/Templates/java-starter/api/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/mvnw.cmd b/src/Aspire.Cli/Templating/Templates/java-starter/api/mvnw.cmd new file mode 100644 index 00000000000..92450f93273 --- /dev/null +++ b/src/Aspire.Cli/Templating/Templates/java-starter/api/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/package.json b/src/Aspire.Cli/Templating/Templates/java-starter/api/package.json deleted file mode 100644 index ed306920321..00000000000 --- a/src/Aspire.Cli/Templating/Templates/java-starter/api/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "{{projectNameLower}}-api", - "private": true, - "type": "module", - "scripts": { - "start": "tsx src/index.ts" - }, - "dependencies": { - "@opentelemetry/auto-instrumentations-node": "^0.76.0", - "@opentelemetry/exporter-logs-otlp-grpc": "^0.218.0", - "@opentelemetry/exporter-metrics-otlp-grpc": "^0.218.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.218.0", - "@opentelemetry/sdk-logs": "^0.218.0", - "@opentelemetry/sdk-metrics": "^2.7.1", - "@opentelemetry/sdk-node": "^0.218.0", - "express": "^5.1.0" - }, - "devDependencies": { - "@types/express": "^5.0.6", - "tsx": "^4.21.0" - } -} diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/pom.xml b/src/Aspire.Cli/Templating/Templates/java-starter/api/pom.xml new file mode 100644 index 00000000000..9afd68f8f5c --- /dev/null +++ b/src/Aspire.Cli/Templating/Templates/java-starter/api/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.14 + + + + com.example + api + 0.0.1-SNAPSHOT + + + 21 + 2.24.0 + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-opentelemetry-javaagent + initialize + + copy + + + + + io.opentelemetry.javaagent + opentelemetry-javaagent + ${opentelemetry-javaagent.version} + ${project.build.directory}/agent + opentelemetry-javaagent.jar + + + true + + + + + + + diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/src/index.ts b/src/Aspire.Cli/Templating/Templates/java-starter/api/src/index.ts deleted file mode 100644 index 4d2c25855e1..00000000000 --- a/src/Aspire.Cli/Templating/Templates/java-starter/api/src/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Import the OpenTelemetry instrumentation setup first, before any other modules. - * This ensures all subsequent imports are automatically instrumented for - * distributed tracing, metrics, and logging in the Aspire dashboard. - */ -import "./instrumentation.ts"; -import express from "express"; -import { existsSync } from "fs"; -import { join } from "path"; - -const app = express(); -const port = process.env.PORT || 5000; - -/** Returns a random 5-day weather forecast as JSON. */ -app.get("/api/weatherforecast", (_req, res) => { - const summaries = ["Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"]; - const forecasts = Array.from({ length: 5 }, (_, i) => { - const temperatureC = Math.floor(Math.random() * 75) - 20; - return { - date: new Date(Date.now() + (i + 1) * 86400000).toISOString(), - temperatureC, - temperatureF: 32 + Math.trunc(temperatureC / 0.5556), - summary: summaries[Math.floor(Math.random() * summaries.length)], - }; - }); - res.json(forecasts); -}); - -app.get("/health", (_req, res) => { - res.send("Healthy"); -}); - -// Serve static files from the "static" directory if it exists (used in publish/deploy mode -// when the frontend's build output is bundled into this container via publishWithContainerFiles) -const staticDir = join(import.meta.dirname, "..", "static"); -if (existsSync(staticDir)) { - app.use(express.static(staticDir)); -} - -app.listen(port, () => { - console.log(`API server listening on port ${port}`); -}); diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/src/instrumentation.ts b/src/Aspire.Cli/Templating/Templates/java-starter/api/src/instrumentation.ts deleted file mode 100644 index c19e073d172..00000000000 --- a/src/Aspire.Cli/Templating/Templates/java-starter/api/src/instrumentation.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * OpenTelemetry instrumentation for the app. - * - * When the OTEL_EXPORTER_OTLP_ENDPOINT environment variable is set (automatically - * configured by Aspire), this module initializes the OpenTelemetry Node.js SDK to - * collect and export distributed traces, metrics, and logs to the Aspire dashboard. - * - * This file must be imported before any other modules to ensure all libraries - * are automatically instrumented. - * - * @see https://opentelemetry.io/docs/languages/js/getting-started/nodejs/ - */ -import { env } from 'node:process'; -import { NodeSDK } from '@opentelemetry/sdk-node'; -import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; -import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc'; -import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-grpc'; -import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs'; -import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; - -if (env.OTEL_EXPORTER_OTLP_ENDPOINT) { - const sdk = new NodeSDK({ - traceExporter: new OTLPTraceExporter(), - metricReader: new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter(), - }), - logRecordProcessor: new BatchLogRecordProcessor( - new OTLPLogExporter(), - ), - instrumentations: [getNodeAutoInstrumentations()], - }); - - sdk.start(); - - process.on('SIGTERM', () => { - sdk.shutdown().finally(() => process.exit(0)); - }); - process.on('SIGINT', () => { - sdk.shutdown().finally(() => process.exit(0)); - }); -} diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/src/main/java/com/example/api/ApiApplication.java b/src/Aspire.Cli/Templating/Templates/java-starter/api/src/main/java/com/example/api/ApiApplication.java new file mode 100644 index 00000000000..f3077c81055 --- /dev/null +++ b/src/Aspire.Cli/Templating/Templates/java-starter/api/src/main/java/com/example/api/ApiApplication.java @@ -0,0 +1,22 @@ +package com.example.api; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Sample Spring Boot API for the Aspire starter template. + * + *

Aspire injects: + *

    + *
  • {@code SERVER_PORT} - the port the AppHost's HTTP endpoint expects this app to listen on.
  • + *
  • {@code OTEL_EXPORTER_OTLP_*} - the dashboard's OTLP endpoint and headers, consumed by the + * OpenTelemetry Java agent that {@code withOtelAgentDefaultPath()} attaches.
  • + *
+ */ +@SpringBootApplication +public class ApiApplication { + + public static void main(String[] args) { + SpringApplication.run(ApiApplication.class, args); + } +} diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/src/main/java/com/example/api/WeatherForecastController.java b/src/Aspire.Cli/Templating/Templates/java-starter/api/src/main/java/com/example/api/WeatherForecastController.java new file mode 100644 index 00000000000..cc266275762 --- /dev/null +++ b/src/Aspire.Cli/Templating/Templates/java-starter/api/src/main/java/com/example/api/WeatherForecastController.java @@ -0,0 +1,45 @@ +package com.example.api; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.IntStream; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class WeatherForecastController { + + private static final List Summaries = List.of( + "Freezing", "Bracing", "Chilly", "Cool", "Mild", + "Warm", "Balmy", "Hot", "Sweltering", "Scorching"); + + /** + * Serialized as {@code {"date","temperatureC","temperatureF","summary"}}, which is the shape the + * React frontend in ../frontend expects. Records are serialized by their component names, so + * renaming a component here is a breaking change for the frontend. + */ + public record WeatherForecast(String date, int temperatureC, int temperatureF, String summary) { + } + + @GetMapping("/api/weatherforecast") + public List weatherForecast() { + var random = ThreadLocalRandom.current(); + + return IntStream.rangeClosed(1, 5) + .mapToObj(day -> { + var temperatureC = random.nextInt(-20, 55); + + return new WeatherForecast( + Instant.now().plus(day, ChronoUnit.DAYS).toString(), + temperatureC, + // Matches the Celsius-to-Fahrenheit conversion the other Aspire starter templates + // use, so every language's starter reports the same numbers for the same input. + 32 + (int) (temperatureC / 0.5556), + Summaries.get(random.nextInt(Summaries.size()))); + }) + .toList(); + } +} diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/src/main/resources/application.properties b/src/Aspire.Cli/Templating/Templates/java-starter/api/src/main/resources/application.properties new file mode 100644 index 00000000000..1149ff9d6ef --- /dev/null +++ b/src/Aspire.Cli/Templating/Templates/java-starter/api/src/main/resources/application.properties @@ -0,0 +1,13 @@ +spring.application.name={{projectNameLower}}-api + +# Only the health endpoint is exposed. The AppHost polls /actuator/health through +# withHttpHealthCheck, and the remaining actuator endpoints report internals that a +# service reachable through withExternalHttpEndpoints should not publish. +management.endpoints.web.exposure.include=health + +# In publish and deploy mode the AppHost bundles the built frontend into this container +# at /app/static via publishWithContainerFiles, so the API serves the SPA from the same +# origin as /api/weatherforecast and no CORS or proxy configuration is needed. The +# directory does not exist during local development, where Vite serves the frontend and +# proxies /api here instead; Spring skips locations that are not present. +spring.web.resources.static-locations=classpath:/static/,file:./static/ diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/api/tsconfig.json b/src/Aspire.Cli/Templating/Templates/java-starter/api/tsconfig.json deleted file mode 100644 index 6f1f8cb7405..00000000000 --- a/src/Aspire.Cli/Templating/Templates/java-starter/api/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["src"] -} diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/aspire.config.json b/src/Aspire.Cli/Templating/Templates/java-starter/aspire.config.json index ec9a8f30d23..7758b6277cd 100644 --- a/src/Aspire.Cli/Templating/Templates/java-starter/aspire.config.json +++ b/src/Aspire.Cli/Templating/Templates/java-starter/aspire.config.json @@ -4,6 +4,7 @@ "language": "java" }, "packages": { + "Aspire.Hosting.Java": "{{aspireVersion}}", "Aspire.Hosting.JavaScript": "{{aspireVersion}}" }, "profiles": { diff --git a/src/Aspire.Cli/Templating/Templates/java-starter/frontend/vite.config.ts b/src/Aspire.Cli/Templating/Templates/java-starter/frontend/vite.config.ts index a11dd97e3bf..ba1d61d7746 100644 --- a/src/Aspire.Cli/Templating/Templates/java-starter/frontend/vite.config.ts +++ b/src/Aspire.Cli/Templating/Templates/java-starter/frontend/vite.config.ts @@ -6,9 +6,12 @@ export default defineConfig({ plugins: [react()], server: { proxy: { - // Proxy API calls to the Express service + // Proxy API calls to the Spring Boot service. API_HTTP is injected by + // frontend.withReference(api) in AppHost.java, which names the variable after + // the resource and its endpoint. Spring Boot serves plain HTTP here, so + // API_HTTPS is only set if the AppHost adds an HTTPS endpoint to the service. '/api': { - target: process.env.APP_HTTPS || process.env.APP_HTTP, + target: process.env.API_HTTPS || process.env.API_HTTP, changeOrigin: true } } diff --git a/src/Aspire.Cli/Utils/AppHostArgumentRedactor.cs b/src/Aspire.Cli/Utils/AppHostArgumentRedactor.cs new file mode 100644 index 00000000000..574194783ef --- /dev/null +++ b/src/Aspire.Cli/Utils/AppHostArgumentRedactor.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Utils; + +/// +/// Redacts the arguments a user forwards to an AppHost so they never reach CLI logs or +/// profiling traces. +/// +/// +/// Everything after the first -- separator is application input the CLI never +/// interprets, so it routinely carries connection strings, API keys, and other secrets: +/// +/// aspire run -- --ConnectionStrings:db "Server=...;Password=hunter2" +/// dotnet run --project AppHost.csproj -- --ApiKey sk-live-... +/// +/// The VS Code extension already redacts the same values before logging a debug +/// configuration (see extension/src/debugger/AspireDebugSession.ts); this keeps the +/// CLI consistent with it. One placeholder is emitted per token so log readers can still +/// diagnose argument-count and boundary problems. +/// +/// This type only handles command lines the CLI itself projected or built, where a literal +/// -- always precedes AppHost input. Raw user arguments carry no such guarantee — the +/// run and start commands also forward unmatched tokens — so those must be +/// redacted with ParseResultHelper.GetLoggableArguments instead. +/// +/// +internal static class AppHostArgumentRedactor +{ + /// + /// Placeholder substituted for each argument that follows the separator. + /// + internal const string RedactedToken = ""; + + private const string ArgumentSeparator = "--"; + + /// + /// Returns a copy of that is safe to log: tokens up to and + /// including the first -- separator are preserved verbatim and every later token is + /// replaced with . When there is no separator the arguments are + /// entirely CLI-owned and are returned unchanged. + /// + internal static IReadOnlyList Redact(IReadOnlyList args) + { + var separatorIndex = -1; + for (var i = 0; i < args.Count; i++) + { + if (string.Equals(args[i], ArgumentSeparator, StringComparison.Ordinal)) + { + separatorIndex = i; + break; + } + } + + if (separatorIndex < 0) + { + return args; + } + + // Every token past the separator is redacted, including a literal "--" that appears after + // the first one: only the first separator is a boundary, later ones are AppHost input. + return RedactFrom(args, separatorIndex + 1); + } + + /// + /// Returns a copy of in which every token from + /// onward is replaced with . + /// + /// + /// Some invocations carry AppHost input without a separator to key off. Launching a built + /// AppHost directly is one of them, because the executable is invoked without the + /// dotnet run boundary: + /// + /// bin/Debug/net10.0/MyApp.AppHost --ApiKey sk-live-... + /// + /// Those callers know where CLI-owned arguments stop and pass that index explicitly. + /// + internal static IReadOnlyList RedactFrom(IReadOnlyList args, int firstRedactedIndex) + { + if (firstRedactedIndex >= args.Count) + { + return args; + } + + var redacted = new List(args.Count); + for (var i = 0; i < args.Count; i++) + { + redacted.Add(i < firstRedactedIndex ? args[i] : RedactedToken); + } + + return redacted; + } + + /// + /// Returns the space-joined, redacted form of for message templates + /// that log a whole command line. + /// + internal static string RedactToString(IReadOnlyList args) => string.Join(' ', Redact(args)); + + /// + /// Returns the space-joined form of with every token from + /// onward replaced by . + /// + internal static string RedactFromToString(IReadOnlyList args, int firstRedactedIndex) + => string.Join(' ', RedactFrom(args, firstRedactedIndex)); +} diff --git a/src/Aspire.Cli/Utils/ExtensionHelper.cs b/src/Aspire.Cli/Utils/ExtensionHelper.cs index ee0ca12353e..3a4a6289325 100644 --- a/src/Aspire.Cli/Utils/ExtensionHelper.cs +++ b/src/Aspire.Cli/Utils/ExtensionHelper.cs @@ -47,8 +47,11 @@ internal static class KnownCapabilities // before opting into newline-delimited JSON candidate discovery. public const string LsJsonStream = "ls-json-stream.v1"; + // Advertised so tooling can pass `aspire run --isolated` only to CLIs that understand it. + public const string IsolatedLaunch = "isolated-launch.v1"; + /// /// Gets the set of capabilities this CLI advertises to extensions. /// - public static string[] GetAdvertisedCapabilities() => [DevKit, Project, BuildDotnetUsingCli, Baseline, SecretPrompts, FilePickers, Pipelines, DescribeIncludeDisabledCommands, LsJsonStream]; + public static string[] GetAdvertisedCapabilities() => [DevKit, Project, BuildDotnetUsingCli, Baseline, SecretPrompts, FilePickers, Pipelines, DescribeIncludeDisabledCommands, LsJsonStream, IsolatedLaunch]; } diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index d365c608d38..555fdb8445e 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -335,7 +335,7 @@ public async IAsyncEnumerable FollowLogsAsync( await foreach (var log in telemetryRepository.WatchLogsAsync(watchRequest, cancellationToken).ConfigureAwait(false)) { var otlpData = TelemetryExportService.ConvertLogsToOtlpJson([log]); - yield return JsonSerializer.Serialize(otlpData, OtlpJsonSerializerContext.DefaultOptions); + yield return JsonSerializer.Serialize(otlpData, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); } } diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 9466089f39c..b3518b9630b 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -2,8 +2,17 @@ - win-x86;win-x64;win-arm64;linux-x64;linux-arm64;linux-musl-x64;osx-x64;osx-arm64 - $(DefaultTargetFramework) + win-x64;win-arm64;linux-x64;linux-arm64;linux-musl-x64;osx-x64;osx-arm64 + net11.0 + true + + true + Size + true + true + true + true 1 $(NoWarn);CS1591;CS8002 - - - Major + $(WarningsNotAsErrors);ASPDEPR005;BL0011;BL0012;BL0016;CS0618;IL2026;IL2057;IL2062;IL2065;IL2067;IL2072;IL2080;IL2101;IL2104;IL2110;IL2111;IL3002;IL3050;IL3053;SYSLIB0026;SYSLIB0027;SYSLIB0028;SYSLIB0057;SYSLIB0065 + false $(DefineConstants);ASPIRE_DASHBOARD - - use-roslyn-tokenizer - true @@ -62,10 +61,8 @@ - - + - @@ -273,6 +270,9 @@ + + + @@ -323,6 +323,4 @@ - -
diff --git a/src/Aspire.Dashboard/BlazorAssets.targets b/src/Aspire.Dashboard/BlazorAssets.targets deleted file mode 100644 index 11bf744c80f..00000000000 --- a/src/Aspire.Dashboard/BlazorAssets.targets +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Aspire.Dashboard/Components/BlazorScript.razor b/src/Aspire.Dashboard/Components/BlazorScript.razor index 7b219297f3b..a1972225e8d 100644 --- a/src/Aspire.Dashboard/Components/BlazorScript.razor +++ b/src/Aspire.Dashboard/Components/BlazorScript.razor @@ -1,19 +1 @@ -@* - Emits a versioned blazor.web.js script tag that matches the runtime the dashboard is running on. - - The dashboard targets net8.0 but rolls forward to .NET 10 or 11 at runtime via RollForward=Major. - In .NET 10+ the Blazor JS is a static web asset delivered by the SDK, but since we don't target - 10+ the SDK never provides it. At build time, BlazorAssets.targets downloads the - Microsoft.AspNetCore.App.Internal.Assets NuGet package for each supported major version and copies - blazor.web.js into wwwroot/framework/blazor.web.{major}.js. This component selects the right file - based on the running runtime version. -*@ -@{ - // Supported major versions that have pre-built blazor.web.js files (see BlazorAssets.targets). - // When adding a new runtime, update BlazorAssets.targets + eng/Versions.props and adjust MaxSupportedMajor. - const int MinSupportedMajor = 10; - const int MaxSupportedMajor = 11; - - var major = Math.Clamp(Environment.Version.Major, MinSupportedMajor, MaxSupportedMajor); -} - + diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 4e9d4e6e992..f29a4f616d8 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -160,7 +160,7 @@ await MessageService.ShowMessageBarAsync(options => options.Link = new() { Text = Loc[nameof(Resources.Layout.MessageUnsecuredEndpointLink)], - Href = "https://aka.ms/aspire/api-endpoint-unsecured", + Href = "https://aspire.dev/dashboard/security-considerations/", Target = "_blank" }; options.Intent = MessageIntent.Warning; diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 5be0cac8966..63156a96116 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -384,6 +384,16 @@ internal bool TryParseOptions([NotNullWhen(false)] out IEnumerable? erro public sealed class ClaimAction { + /// + /// Initializes a new instance of the class. + /// + [SetsRequiredMembers] + public ClaimAction() + { + ClaimType = null!; + JsonKey = null!; + } + public required string ClaimType { get; set; } public required string JsonKey { get; set; } public string? SubKey { get; set; } diff --git a/src/Aspire.Dashboard/Configuration/FrontendAuthMode.cs b/src/Aspire.Dashboard/Configuration/FrontendAuthMode.cs index efaf246515f..953fe524bd4 100644 --- a/src/Aspire.Dashboard/Configuration/FrontendAuthMode.cs +++ b/src/Aspire.Dashboard/Configuration/FrontendAuthMode.cs @@ -3,9 +3,24 @@ namespace Aspire.Dashboard.Configuration; +/// +/// Specifies how access to the dashboard frontend is authenticated. +/// public enum FrontendAuthMode { + /// + /// Allows anonymous access to the dashboard. See the + /// dashboard security considerations. + /// Unsecured, + + /// + /// Authenticates users with OpenID Connect. + /// OpenIdConnect, + + /// + /// Authenticates users with a browser token. This is the default authentication mode. + /// BrowserToken } diff --git a/src/Aspire.Dashboard/Configuration/OtlpAuthMode.cs b/src/Aspire.Dashboard/Configuration/OtlpAuthMode.cs index 8eba4e3ea3c..c0a71a83020 100644 --- a/src/Aspire.Dashboard/Configuration/OtlpAuthMode.cs +++ b/src/Aspire.Dashboard/Configuration/OtlpAuthMode.cs @@ -3,9 +3,25 @@ namespace Aspire.Dashboard.Configuration; +/// +/// Specifies how clients sending telemetry to the dashboard are authenticated. +/// public enum OtlpAuthMode { + /// + /// Allows clients to send telemetry without authentication. See the + /// dashboard security considerations. + /// When an OTLP endpoint is enabled, the dashboard logs a warning and displays a warning in the UI by default. + /// Unsecured, + + /// + /// Authenticates clients with an API key. + /// ApiKey, + + /// + /// Authenticates clients with a client certificate. + /// ClientCertificate } diff --git a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs index 114d0901c2e..ef3bb6bbc3b 100644 --- a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs @@ -54,6 +54,10 @@ public void PostConfigure(string? name, DashboardOptions options) if (_configuration.GetBool(DashboardConfigNames.DashboardUnsecuredAllowAnonymousName.ConfigKey, DashboardConfigNames.Legacy.DashboardUnsecuredAllowAnonymousName.ConfigKey) ?? false) { + // This setting explicitly opts into unsecured endpoints. See the security considerations at + // https://aspire.dev/dashboard/security-considerations/ before enabling it outside local development. + // When the corresponding endpoints are enabled, the dashboard logs warnings and displays a warning + // in the UI to inform users about the risks of anonymous access. options.Frontend.AuthMode = FrontendAuthMode.Unsecured; options.Otlp.AuthMode = OtlpAuthMode.Unsecured; options.Api.AuthMode = ApiAuthMode.Unsecured; @@ -61,6 +65,8 @@ public void PostConfigure(string? name, DashboardOptions options) else { options.Frontend.AuthMode ??= FrontendAuthMode.BrowserToken; + // OTLP is unsecured by default for local development. See the security considerations at + // https://aspire.dev/dashboard/security-considerations/ when exposing the endpoint outside a trusted environment. options.Otlp.AuthMode ??= OtlpAuthMode.Unsecured; options.Api.AuthMode ??= ApiAuthMode.ApiKey; } diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 421fa1b1a8b..40328186f8c 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -6,6 +6,7 @@ using System.Net.Sockets; using System.Security.Claims; using System.Security.Cryptography; +using System.Text.Json.Serialization.Metadata; using Aspire.Dashboard.Api; using Aspire.Dashboard.Authentication; using Aspire.Dashboard.Authentication.Connection; @@ -20,6 +21,7 @@ using Aspire.Dashboard.Otlp.Grpc; using Aspire.Dashboard.Otlp.Http; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Dashboard.Serialization; using Aspire.Dashboard.Telemetry; using Aspire.Dashboard.Terminal; using Aspire.Dashboard.Utils; @@ -30,6 +32,7 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpsPolicy; @@ -226,7 +229,10 @@ public DashboardWebApplication( ConfigureAuthentication(builder, dashboardOptions); // Add services to the container. - builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + builder.Services.AddRazorComponents().AddInteractiveServerComponents(options => + { + AddDashboardJsonTypeInfoResolver(options); + }); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddResponseCompression(options => { @@ -529,7 +535,10 @@ public DashboardWebApplication( private void PrintSummary(ResolvedEndpointInfo? frontendEndpointInfo) { var options = _app.Services.GetRequiredService>().CurrentValue; - var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; + var suppressBrowserToken = _app.Configuration.GetBool(KnownConfigNames.DashboardSuppressBrowserTokenInOutput) ?? false; + var token = !suppressBrowserToken && options.Frontend.AuthMode == FrontendAuthMode.BrowserToken + ? options.Frontend.BrowserToken + : null; var frontendAddress = frontendEndpointInfo?.GetResolvedAddress(replaceIPAnyWithLocalhost: true); var otlpGrpcAddress = _otlpServiceGrpcEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); var otlpHttpAddress = _otlpServiceHttpEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); @@ -738,8 +747,9 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb .AddScheme(ConnectionTypeAuthenticationDefaults.AuthenticationSchemeOtlp, o => o.RequiredConnectionTypes = [ConnectionType.OtlpGrpc, ConnectionType.OtlpHttp]) .AddCertificate(options => { - // Bind options to configuration so they can be overridden by environment variables. - builder.Configuration.Bind("Dashboard:Otlp:CertificateAuthOptions", options); + BindCertificateAuthenticationOptions( + builder.Configuration.GetSection("Dashboard:Otlp:CertificateAuthOptions"), + options); options.Events = new CertificateAuthenticationEvents { @@ -923,6 +933,48 @@ static string ConfigureDefaultAuthScheme(DashboardOptions dashboardOptions) } } + private static void BindCertificateAuthenticationOptions( + IConfigurationSection configuration, + CertificateAuthenticationOptions options) + { + // The configuration binding generator cannot generate bindings for the certificate collection + // and TimeProvider exposed by CertificateAuthenticationOptions. Bind only the scalar settings + // that the Dashboard supports overriding rather than falling back to reflection under Native AOT. + options.AllowedCertificateTypes = configuration.GetValue( + nameof(CertificateAuthenticationOptions.AllowedCertificateTypes), + options.AllowedCertificateTypes); + options.ChainTrustValidationMode = configuration.GetValue( + nameof(CertificateAuthenticationOptions.ChainTrustValidationMode), + options.ChainTrustValidationMode); + options.RevocationFlag = configuration.GetValue( + nameof(CertificateAuthenticationOptions.RevocationFlag), + options.RevocationFlag); + options.RevocationMode = configuration.GetValue( + nameof(CertificateAuthenticationOptions.RevocationMode), + options.RevocationMode); + options.ValidateCertificateUse = configuration.GetValue( + nameof(CertificateAuthenticationOptions.ValidateCertificateUse), + options.ValidateCertificateUse); + options.ValidateValidityPeriod = configuration.GetValue( + nameof(CertificateAuthenticationOptions.ValidateValidityPeriod), + options.ValidateValidityPeriod); + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to target method", + Justification = "The .NET 11 resolver property is public and its declaring framework type is statically rooted.")] + private static void AddDashboardJsonTypeInfoResolver(CircuitOptions options) + { + // The resolver API was merged after .NET 11 Preview 7. Keep the Dashboard buildable with + // earlier 11.0 targeting packs while using the public API as soon as the matching runtime is present. + if (typeof(CircuitOptions).GetProperty("JsonTypeInfoResolvers")?.GetValue(options) + is IList resolvers) + { + resolvers.Add(DashboardJsonSerializerContext.Default); + } + } + internal static Action GetOidcClaimActionConfigure(ClaimAction action) { Action configureAction = (action.SubKey is null, action.IsUnique) switch diff --git a/src/Aspire.Dashboard/Model/BrowserStorage/LocalBrowserStorage.cs b/src/Aspire.Dashboard/Model/BrowserStorage/LocalBrowserStorage.cs index 3a3cb4b991b..c107d9f571d 100644 --- a/src/Aspire.Dashboard/Model/BrowserStorage/LocalBrowserStorage.cs +++ b/src/Aspire.Dashboard/Model/BrowserStorage/LocalBrowserStorage.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Aspire.Dashboard.Serialization; using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; using Microsoft.JSInterop; @@ -13,6 +15,7 @@ public class LocalBrowserStorage : BrowserStorageBase, ILocalStorage { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, + TypeInfoResolver = DashboardJsonSerializerContext.Default, }; private readonly IJSRuntime _jsRuntime; @@ -33,7 +36,8 @@ public async Task> GetUnprotectedAsync(string key) try { - return new StorageResult(true, JsonSerializer.Deserialize(json, s_options)); + var typeInfo = (JsonTypeInfo)s_options.GetTypeInfo(typeof(TValue)); + return new StorageResult(true, JsonSerializer.Deserialize(json, typeInfo)); } catch (Exception ex) { @@ -45,7 +49,8 @@ public async Task> GetUnprotectedAsync(string key) public async Task SetUnprotectedAsync(string key, TValue value) { - var json = JsonSerializer.Serialize(value, s_options); + var typeInfo = (JsonTypeInfo)s_options.GetTypeInfo(typeof(TValue)); + var json = JsonSerializer.Serialize(value, typeInfo); await SetJsonAsync(key, json).ConfigureAwait(false); } diff --git a/src/Aspire.Dashboard/Model/GenAI/GenAIMessages.cs b/src/Aspire.Dashboard/Model/GenAI/GenAIMessages.cs index 0bb5f78870f..b143e68da1e 100644 --- a/src/Aspire.Dashboard/Model/GenAI/GenAIMessages.cs +++ b/src/Aspire.Dashboard/Model/GenAI/GenAIMessages.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace Aspire.Dashboard.Model.GenAI; @@ -220,16 +221,16 @@ internal sealed class MessagePartConverter : JsonConverter return type switch { - MessagePart.TextType => doc.RootElement.Deserialize(options), - MessagePart.ToolCallType => TryParseStringArguments(doc.RootElement.Deserialize(options)), - MessagePart.ToolCallResponseType => doc.RootElement.Deserialize(options), - MessagePart.BlobType => doc.RootElement.Deserialize(options), - MessagePart.FileType => doc.RootElement.Deserialize(options), - MessagePart.UriType => doc.RootElement.Deserialize(options), - MessagePart.ReasoningType => doc.RootElement.Deserialize(options), - MessagePart.ServerToolCallType => TryParseServerToolCallArguments(doc.RootElement.Deserialize(options)), - MessagePart.ServerToolCallResponseType => doc.RootElement.Deserialize(options), - _ => doc.RootElement.Deserialize(options), + MessagePart.TextType => Deserialize(doc.RootElement, options), + MessagePart.ToolCallType => TryParseStringArguments(Deserialize(doc.RootElement, options)), + MessagePart.ToolCallResponseType => Deserialize(doc.RootElement, options), + MessagePart.BlobType => Deserialize(doc.RootElement, options), + MessagePart.FileType => Deserialize(doc.RootElement, options), + MessagePart.UriType => Deserialize(doc.RootElement, options), + MessagePart.ReasoningType => Deserialize(doc.RootElement, options), + MessagePart.ServerToolCallType => TryParseServerToolCallArguments(Deserialize(doc.RootElement, options)), + MessagePart.ServerToolCallResponseType => Deserialize(doc.RootElement, options), + _ => Deserialize(doc.RootElement, options), }; } catch (JsonException ex) @@ -239,7 +240,7 @@ internal sealed class MessagePartConverter : JsonConverter UnexpectedErrorPart? errorPart = null; try { - errorPart = doc.RootElement.Deserialize(options); + errorPart = Deserialize(doc.RootElement, options); } catch { @@ -258,7 +259,13 @@ internal sealed class MessagePartConverter : JsonConverter public override void Write(Utf8JsonWriter writer, MessagePart value, JsonSerializerOptions options) { - JsonSerializer.Serialize(writer, (object)value, value.GetType(), options); + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(value.GetType())); + } + + private static TValue? Deserialize(JsonElement element, JsonSerializerOptions options) + { + var typeInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(TValue)); + return JsonSerializer.Deserialize(element, typeInfo); } /// diff --git a/src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs b/src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs index 0d1c0b81b37..73ca61f9e21 100644 --- a/src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs +++ b/src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs @@ -2,9 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; -using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using Aspire.Dashboard.Serialization; using Aspire.DashboardService.Proto.V1; namespace Aspire.Dashboard.Model.Interaction; @@ -16,11 +16,6 @@ public sealed class InputViewModel // so this constant is only used as a defensive safety net. internal const long DefaultMaxUploadedFileBytes = 100 * 1024 * 1024; // 100 MB - private static readonly JsonSerializerOptions s_jsonSerializerOptions = new() - { - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - public InteractionInput Input { get; private set; } = default!; public InputViewModel(InteractionInput input) @@ -133,7 +128,7 @@ public void SetFileReferences(IEnumerable files) var successfulRefs = FileReferences.Where(f => f.Id is not null).ToList(); // Use empty string (not "[]") when no files were accepted, so required-field checks work correctly. Input.Value = successfulRefs.Count > 0 - ? JsonSerializer.Serialize(successfulRefs, s_jsonSerializerOptions) + ? JsonSerializer.Serialize(successfulRefs, DashboardJsonSerializerContext.DefaultContext.ListFileReferenceViewModel) : string.Empty; } diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs index 05e1e27496b..1b899cb5a51 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs @@ -204,6 +204,120 @@ public bool IsResourceHidden(bool showHiddenResources) ?? Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy; } + /// + /// Properties whose values describe the current run of a resource rather than its identity or + /// configuration. When a parent row displays a replica's state these have to travel with that state, + /// otherwise the parent shows a state derived from the replica alongside stale values of its own + /// (for example a leftover non-zero exit code, or a "waiting for" list the replica has moved past). + /// + private static readonly string[] s_stateOwnedPropertyNames = + [ + KnownProperties.Resource.State, + KnownProperties.Resource.ExitCode, + KnownProperties.Resource.StartTime, + KnownProperties.Resource.StopTime, + KnownProperties.Resource.HealthState, + KnownProperties.Resource.WaitingFor + ]; + + /// + /// Determines whether this resource and agree on every property in + /// , treating a property either side is missing as absent. + /// + internal bool HasSameStateOwnedProperties(ResourceViewModel other) + { + foreach (var name in s_stateOwnedPropertyNames) + { + var hasValue = Properties.TryGetValue(name, out var property); + var otherHasValue = other.Properties.TryGetValue(name, out var otherProperty); + + if (hasValue != otherHasValue) + { + return false; + } + + if (hasValue && !HasSameProjectedProperty(property!, otherProperty!)) + { + return false; + } + } + + return true; + } + + /// + /// Compares the fields of a state-owned property that carries onto + /// a derived parent row. + /// + private static bool HasSameProjectedProperty(ResourcePropertyViewModel property, ResourcePropertyViewModel other) + { + // ResourcePropertyViewModel is a plain class with reference equality, and every snapshot allocates new + // instances, so comparing the wrappers themselves would report a change on every batch. The projection + // copies the whole wrapper, not just its value, so compare each field the AppHost snapshot owns: a + // metadata-only move (sensitivity, display name, highlighting, sort order) is still a visible change to + // the parent's resource details. IsValueMasked is excluded on purpose because it is view state the + // details grid toggles on the instance it was handed, not something the snapshot decides. + return string.Equals(property.Name, other.Name, StringComparisons.ResourcePropertyName) && + Equals(property.Value, other.Value) && + Equals(property.KnownProperty, other.KnownProperty) && + string.Equals(property.DisplayName, other.DisplayName, StringComparison.Ordinal) && + property.IsValueSensitive == other.IsValueSensitive && + property.IsHighlighted == other.IsHighlighted && + property.SortOrder == other.SortOrder; + } + + internal ResourceViewModel WithStateFrom(ResourceViewModel stateSource) + { + return new ResourceViewModel + { + Name = Name, + ResourceType = ResourceType, + DisplayName = DisplayName, + Uid = Uid, + ReplicaIndex = ReplicaIndex, + State = stateSource.State, + KnownState = stateSource.KnownState, + StateStyle = stateSource.StateStyle, + CreationTimeStamp = CreationTimeStamp, + StartTimeStamp = stateSource.StartTimeStamp, + StopTimeStamp = stateSource.StopTimeStamp, + Environment = Environment, + Urls = Urls, + Volumes = Volumes, + Relationships = Relationships, + Properties = ProjectStateOwnedProperties(stateSource), + Commands = Commands, + HealthReports = stateSource.HealthReports, + IsHidden = IsHidden, + SupportsDetailedTelemetry = SupportsDetailedTelemetry, + IconName = IconName, + IconVariant = IconVariant + }; + } + + /// + /// Keeps this resource's identity and configuration properties (name, uid, source, parent name, + /// connection string, and so on) and replaces only the state-owned ones with the state source's + /// values. A state-owned property the state source doesn't have is dropped rather than inherited. + /// + private ImmutableDictionary ProjectStateOwnedProperties(ResourceViewModel stateSource) + { + var builder = Properties.ToBuilder(); + foreach (var name in s_stateOwnedPropertyNames) + { + if (stateSource.Properties.TryGetValue(name, out var property)) + { + builder[name] = property.CloneForProjection(); + } + else + { + builder.Remove(name); + } + } + + return builder.ToImmutable(); + } + public static string GetResourceName(ResourceViewModel resource, IDictionary allResources) { return GetResourceName(resource, allResources.Values); @@ -453,6 +567,16 @@ public ResourcePropertyViewModel(string name, Value value, bool isValueSensitive SortOrder = sortOrder; IsValueMasked = isValueSensitive; } + + internal ResourcePropertyViewModel CloneForProjection() + { + return new ResourcePropertyViewModel(Name, Value, IsValueSensitive, KnownProperty, SortOrder, DisplayName, IsHighlighted) + { + // The projected parent row should start from the same masking state, but future + // toggles are row-local UI state and must not mutate the replica row's property. + IsValueMasked = IsValueMasked + }; + } } public sealed record KnownProperty(string Key, Func, string> GetDisplayName); diff --git a/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs b/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs index 4b8ff531711..11893bed780 100644 --- a/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs +++ b/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs @@ -39,4 +39,9 @@ internal sealed partial class ResourceJsonSerializerContext : JsonSerializerCont Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, TypeInfoResolver = Default }; + + /// + /// Gets source-generated metadata bound to . + /// + public static ResourceJsonSerializerContext IndentedContext { get; } = new(IndentedOptions); } diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index e56518813ab..bbab6f7bad8 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -327,8 +327,10 @@ internal static string ConvertSpanToJson(OtlpSpan span, IOutgoingPeerResolver[] ], ResourceLogs = ConvertLogsToResourceLogs(logs) }; - var options = indent ? OtlpJsonSerializerContext.IndentedOptions : OtlpJsonSerializerContext.DefaultOptions; - return JsonSerializer.Serialize(data, options); + var typeInfo = indent + ? OtlpJsonSerializerContext.IndentedContext.OtlpTelemetryDataJson + : OtlpJsonSerializerContext.DefaultContext.OtlpTelemetryDataJson; + return JsonSerializer.Serialize(data, typeInfo); } internal static string ConvertTraceToJson(OtlpTrace trace, IOutgoingPeerResolver[] outgoingPeerResolvers, List? logs = null) @@ -357,7 +359,7 @@ internal static string ConvertTraceToJson(OtlpTrace trace, IOutgoingPeerResolver ResourceSpans = spansByResourceAndScope, ResourceLogs = ConvertLogsToResourceLogs(logs) }; - return JsonSerializer.Serialize(data, OtlpJsonSerializerContext.IndentedOptions); + return JsonSerializer.Serialize(data, OtlpJsonSerializerContext.IndentedContext.OtlpTelemetryDataJson); } internal static string ConvertLogEntryToJson(OtlpLogEntry logEntry) @@ -380,7 +382,7 @@ internal static string ConvertLogEntryToJson(OtlpLogEntry logEntry) } ] }; - return JsonSerializer.Serialize(data, OtlpJsonSerializerContext.IndentedOptions); + return JsonSerializer.Serialize(data, OtlpJsonSerializerContext.IndentedContext.OtlpTelemetryDataJson); } private static OtlpSpanJson ConvertSpan(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers) @@ -690,7 +692,7 @@ private static OtlpInstrumentationScopeJson ConvertScope(OtlpScope scope) internal static string ConvertResourceToJson(ResourceViewModel resource, IReadOnlyList allResources) { var resourceJson = CreateResourceJson(resource, allResources); - return JsonSerializer.Serialize(resourceJson, ResourceJsonSerializerContext.IndentedOptions); + return JsonSerializer.Serialize(resourceJson, ResourceJsonSerializerContext.IndentedContext.ResourceJson); } internal static ResourceJson CreateResourceJson(ResourceViewModel resource, IReadOnlyList allResources) diff --git a/src/Aspire.Dashboard/Model/TelemetryImportService.cs b/src/Aspire.Dashboard/Model/TelemetryImportService.cs index 8d3bea3600f..6a6436562d7 100644 --- a/src/Aspire.Dashboard/Model/TelemetryImportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryImportService.cs @@ -116,7 +116,7 @@ private async Task ImportJsonAsync(string fileName, Stream stream, CancellationT OtlpTelemetryDataJson? telemetryData; try { - telemetryData = JsonSerializer.Deserialize(jsonContent, OtlpJsonSerializerContext.DefaultOptions); + telemetryData = JsonSerializer.Deserialize(jsonContent, OtlpJsonSerializerContext.DefaultContext.OtlpTelemetryDataJson); } catch (JsonException ex) { diff --git a/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs b/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs index 14723769ef8..4af9e49583c 100644 --- a/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs +++ b/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs @@ -9,6 +9,7 @@ using System.Text.Json; using Aspire.Dashboard.Authentication; using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Serialization; using Aspire.Dashboard.Utils; using Google.Protobuf; using Microsoft.Extensions.Logging.Abstractions; @@ -25,10 +26,6 @@ public static class OtlpHttpEndpointsBuilder public const string JsonContentType = "application/json"; public const string CorsPolicyName = "OtlpHttpCors"; - private static readonly JsonSerializerOptions s_jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - private sealed record StatusResponse(int Code, string Message); - public static void MapHttpOtlpApi(this IEndpointRouteBuilder endpoints, OtlpOptions options) { var httpEndpoint = options.GetHttpEndpointAddress(); @@ -113,7 +110,7 @@ private static async Task WriteUnsupportedContentTypeResponse(HttpContext httpCo Code: 15, // UNIMPLEMENTED from gRPC status codes Message: $"Content type '{httpContext.Request.ContentType}' is not supported. Only '{ProtobufContentType}' is supported."); - var json = JsonSerializer.Serialize(status, s_jsonOptions); + var json = JsonSerializer.Serialize(status, DashboardJsonSerializerContext.Default.StatusResponse); await httpContext.Response.WriteAsync(json, Encoding.UTF8).ConfigureAwait(false); } @@ -125,7 +122,7 @@ private static T AddOtlpHttpMetadata(this T builder) where T : IEndpointConve return builder; } - private sealed class MessageBindable : IBindableFromHttpContext> where TMessage : IMessage, new() + internal sealed class MessageBindable : IBindableFromHttpContext> where TMessage : IMessage, new() { public static readonly MessageBindable Empty = new MessageBindable() { Logger = NullLogger.Instance }; @@ -292,3 +289,5 @@ private static async Task ReadOtlpData( } } } + +internal sealed record StatusResponse(int Code, string Message); diff --git a/src/Aspire.Dashboard/Program.cs b/src/Aspire.Dashboard/Program.cs index fe4866c7e69..1cba2131c9c 100644 --- a/src/Aspire.Dashboard/Program.cs +++ b/src/Aspire.Dashboard/Program.cs @@ -2,6 +2,25 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard; +using Aspire.Shared; -var app = new DashboardWebApplication(); -return app.Run(); +var options = new WebApplicationOptions +{ + Args = args, + ContentRootPath = AppContext.BaseDirectory +}; +var app = new DashboardWebApplication(options: options); + +using var shutdownCts = new CancellationTokenSource(); +var parentWatchdog = ParentProcessWatchdog.Start(shutdownCts); +try +{ + return await app.RunAsync(shutdownCts.Token).ConfigureAwait(false); +} +finally +{ + if (parentWatchdog is not null) + { + await parentWatchdog.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/Aspire.Dashboard/README.md b/src/Aspire.Dashboard/README.md index 6a4d288aecd..cd2ab54863d 100644 --- a/src/Aspire.Dashboard/README.md +++ b/src/Aspire.Dashboard/README.md @@ -35,14 +35,14 @@ Example JSON configuration file: - `ASPNETCORE_URLS` specifies one or more HTTP endpoints through which the dashboard frontend is served. The frontend endpoint is used to view the dashboard in a browser. Defaults to http://localhost:18888. - `DOTNET_DASHBOARD_OTLP_ENDPOINT_URL` specifies the OTLP/gRPC endpoint. OTLP/gRPC endpoint hosts an OTLP service and receives telemetry using gRPC. Defaults to http://localhost:18889. - `DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL` specifies the OTLP/HTTP endpoint. OTLP/HTTP endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP. Defaults to http://localhost:18890. -- `DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS` specifies the dashboard doesn't use authentication and accepts anonymous access. This setting is a shortcut to configuring `Dashboard:Frontend:AuthMode` and `Dashboard:Otlp:AuthMode` to `Unsecured`. +- `DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS` specifies the dashboard doesn't use authentication and accepts anonymous access. This setting is a shortcut to configuring `Dashboard:Frontend:AuthMode`, `Dashboard:Otlp:AuthMode`, and `Dashboard:Api:AuthMode` to `Unsecured`. When the corresponding endpoints are enabled, the dashboard writes warnings to the logs and displays a warning in the UI to inform users about the risks of anonymous access. Review the [dashboard security considerations](https://aspire.dev/dashboard/security-considerations/) before enabling this setting. - `DOTNET_DASHBOARD_CONFIG_FILE_PATH` specifies the path for an optional JSON configuration file. ### Frontend authentication The dashboard frontend endpoint can be secured with OpenID Connect (OIDC) or browser token authentication. -It may also be run unsecured. Set `Dashboard:Frontend:AuthMode` to `Unsecured`. The frontend endpoint will allow anonymous access. This setting should only be used during local development. It's not recommended when hosting the dashboard publicly or in other settings. +It may also be run unsecured. Set `Dashboard:Frontend:AuthMode` to `Unsecured`. The frontend endpoint will allow anonymous access. This setting should only be used during local development. It's not recommended when hosting the dashboard publicly or in other settings. Review the [dashboard security considerations](https://aspire.dev/dashboard/security-considerations/) before enabling this setting. #### Frontend browser token authentication @@ -71,7 +71,7 @@ Set `Dashboard:Frontend:AuthMode` to `OpenIdConnect`, then add the following con The OTLP endpoint can be secured with [client certificate](https://learn.microsoft.com/aspnet/core/security/authentication/certauth) or API key authentication. -It may also be run unsecured. Set `Dashboard:Otlp:AuthMode` to `Unsecured`. The OTLP endpoint will allow anonymous access. This setting is used during local development, but is not recommended if you attempt to host the dashboard in other settings. +It may also be run unsecured. Set `Dashboard:Otlp:AuthMode` to `Unsecured`. The OTLP endpoint will allow anonymous access. This setting is used during local development, but is not recommended if you attempt to host the dashboard in other settings. Review the [dashboard security considerations](https://aspire.dev/dashboard/security-considerations/) before enabling this setting. For more information, see the official Aspire docs—[Dashboard configuration: OTLP authentication](https://aspire.dev/dashboard/configuration#otlp-authentication). diff --git a/src/Aspire.Dashboard/Serialization/DashboardJsonSerializerContext.cs b/src/Aspire.Dashboard/Serialization/DashboardJsonSerializerContext.cs new file mode 100644 index 00000000000..14b5cf81190 --- /dev/null +++ b/src/Aspire.Dashboard/Serialization/DashboardJsonSerializerContext.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Aspire.Dashboard.Components.Controls; +using Aspire.Dashboard.Components.Dialogs; +using Aspire.Dashboard.Components.Pages; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.Interaction; +using Aspire.Dashboard.Otlp.Http; +using Aspire.Dashboard.Utils; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Serialization; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(ViewportSize))] +[JsonSerializable(typeof(BrowserInfo))] +[JsonSerializable(typeof(TerminalSizePreset[]))] +[JsonSerializable(typeof(ConsoleLogsFilters))] +[JsonSerializable(typeof(ConsoleLogs.ConsoleLogConsoleSettings))] +[JsonSerializable(typeof(TextVisualizerDialog.TextVisualizerDialogSettings))] +[JsonSerializable(typeof(TimeFormat))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(StatusResponse))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(Orientation))] +internal sealed partial class DashboardJsonSerializerContext : JsonSerializerContext +{ + public static JsonSerializerOptions DefaultOptions { get; } = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + TypeInfoResolver = Default + }; + + public static DashboardJsonSerializerContext DefaultContext { get; } = new(DefaultOptions); +} diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 87e4649406b..c5b6c7e6cc7 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -9,6 +9,7 @@ using System.Security.Cryptography.X509Certificates; using System.Threading.Channels; using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; using Aspire.DashboardService.Proto.V1; @@ -20,6 +21,7 @@ using Microsoft.Extensions.Options; using Semver; using DashboardResources = Aspire.Dashboard.Resources.Resources; +using ResourceHealthStatus = Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus; using ResourceCommandResponseKind = Aspire.Dashboard.Model.ResourceCommandResponseKind; namespace Aspire.Dashboard.ServiceClient; @@ -50,7 +52,11 @@ internal sealed class DashboardClient : IDashboardClient // the minimum version required by the AppHost. private static readonly SemVersion? s_dashboardVersion = GetDashboardVersion(); + // _resourceByName is the displayed model exposed through IDashboardClient. Keep the raw AppHost + // snapshots separately so a parent row that temporarily displays a replica's state can fall back + // to its own state as soon as the replica disappears. private readonly Dictionary _resourceByName = new(StringComparers.ResourceName); + private readonly Dictionary _sourceResourceByName = new(StringComparers.ResourceName); private readonly InteractionCollection _pendingInteractionCollection = new(); private readonly CancellationTokenSource _cts = new(); private readonly CancellationToken _clientCancellationToken; @@ -158,7 +164,9 @@ GrpcChannel CreateChannel() ClientCertificates = certificates }; - configuration.Bind("Dashboard:ResourceServiceClient:Ssl", httpHandler.SslOptions); + BindSslClientAuthenticationOptions( + configuration.GetSection("Dashboard:ResourceServiceClient:Ssl"), + httpHandler.SslOptions); } // https://learn.microsoft.com/aspnet/core/grpc/retries @@ -203,6 +211,30 @@ X509CertificateCollection GetFileCertificate() return [new X509Certificate2(filePath, password)]; } + static void BindSslClientAuthenticationOptions( + IConfigurationSection configuration, + SslClientAuthenticationOptions options) + { + // SslClientAuthenticationOptions exposes certificate collections and callback properties + // that the configuration binding generator cannot construct. Preserve the supported scalar + // overrides without falling back to reflection under Native AOT. + options.AllowRenegotiation = configuration.GetValue( + nameof(SslClientAuthenticationOptions.AllowRenegotiation), + options.AllowRenegotiation); + options.AllowTlsResume = configuration.GetValue( + nameof(SslClientAuthenticationOptions.AllowTlsResume), + options.AllowTlsResume); + options.CertificateRevocationCheckMode = configuration.GetValue( + nameof(SslClientAuthenticationOptions.CertificateRevocationCheckMode), + options.CertificateRevocationCheckMode); + options.EnabledSslProtocols = configuration.GetValue( + nameof(SslClientAuthenticationOptions.EnabledSslProtocols), + options.EnabledSslProtocols); + options.EncryptionPolicy = configuration.GetValue( + nameof(SslClientAuthenticationOptions.EncryptionPolicy), + options.EncryptionPolicy); + } + X509CertificateCollection GetKeyStoreCertificate() { Debug.Assert( @@ -541,7 +573,7 @@ private int CalculateReplicaIndex(string displayName) // There is no consistent way to know which replica is instance 1 vs instance 2. It shouldn't ever matter. // This index provides an easy way to identify resources across app runs that takes into account replicas. - var replicas = _resourceByName.Values.Count(r => r.DisplayName == displayName); + var replicas = _sourceResourceByName.Values.Count(r => r.DisplayName == displayName); return replicas + 1; } @@ -556,6 +588,9 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C lock (_lock) { + var previousDisplayedResources = new Dictionary(_resourceByName, StringComparers.ResourceName); + List? sourceChanges = null; + // We received a message, which means we are connected. Clear the error count. if (retryContext.ErrorCount > 0) { @@ -566,19 +601,23 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C if (response.KindCase == WatchResourcesUpdate.KindOneofCase.InitialData) { // Populate our map using the initial data. + _sourceResourceByName.Clear(); _resourceByName.Clear(); - // TODO send a "clear" event via outgoing channels, in case consumers have extra items to be removed + // An initial snapshot replaces everything subscribers currently display, so the diff below has + // to run even when the snapshot carries no resources. Leaving this null for an empty snapshot + // would skip the diff entirely, so reconnecting to an AppHost that no longer reports a resource + // would never emit its delete and subscribers would keep showing the stale row. + sourceChanges = []; foreach (var resource in response.InitialData.Resources) { // Add to map. var viewModel = resource.ToViewModel(CalculateReplicaIndex(resource.DisplayName), _knownPropertyLookup, _logger); - _resourceByName[resource.Name] = viewModel; + _sourceResourceByName[resource.Name] = viewModel; // Send this update to any subscribers too. - changes ??= []; - changes.Add(new(ResourceViewModelChangeType.Upsert, viewModel)); + sourceChanges.Add(new(ResourceViewModelChangeType.Upsert, viewModel)); } _initialDataReceivedTcs.TrySetResult(); @@ -588,21 +627,21 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C // Apply changes to the model. foreach (var change in response.Changes.Value) { - changes ??= []; + sourceChanges ??= []; if (change.KindCase == WatchResourcesChange.KindOneofCase.Upsert) { // Upsert (i.e. add or replace) var viewModel = change.Upsert.ToViewModel(CalculateReplicaIndex(change.Upsert.DisplayName), _knownPropertyLookup, _logger); - _resourceByName[change.Upsert.Name] = viewModel; - changes.Add(new(ResourceViewModelChangeType.Upsert, viewModel)); + _sourceResourceByName[change.Upsert.Name] = viewModel; + sourceChanges.Add(new(ResourceViewModelChangeType.Upsert, viewModel)); } else if (change.KindCase == WatchResourcesChange.KindOneofCase.Delete) { // Remove - if (_resourceByName.Remove(change.Delete.ResourceName, out var removed)) + if (_sourceResourceByName.Remove(change.Delete.ResourceName, out var removed)) { - changes.Add(new(ResourceViewModelChangeType.Delete, removed)); + sourceChanges.Add(new(ResourceViewModelChangeType.Delete, removed)); } else { @@ -620,6 +659,18 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C throw new FormatException($"Unexpected {nameof(WatchResourcesUpdate)} kind: {response.KindCase}"); } + if (sourceChanges is not null) + { + var displayedResources = CreateDisplayedResources(_sourceResourceByName.Values); + changes = CreateDisplayedResourceChanges(previousDisplayedResources, displayedResources, sourceChanges); + + _resourceByName.Clear(); + foreach (var resource in displayedResources.Values) + { + _resourceByName[resource.Name] = resource; + } + } + // Resolve resource colors for all resources so that color assignment is // deterministic of order returned from the service, not order that the color for a resource is first used. if (changes is not null) @@ -650,6 +701,157 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C return RetryResult.Retry; } + private static Dictionary CreateDisplayedResources(IEnumerable sourceResources) + { + var sourceResourceList = sourceResources.ToList(); + var childrenByParentName = sourceResourceList + .Where(r => r.GetResourcePropertyValue(KnownProperties.Resource.ParentName) is { Length: > 0 }) + .ToLookup(r => r.GetResourcePropertyValue(KnownProperties.Resource.ParentName)!, StringComparers.ResourceName); + + var displayedResources = new Dictionary(StringComparers.ResourceName); + foreach (var resource in sourceResourceList) + { + var stateSource = GetReplicaStateSource(resource, childrenByParentName); + displayedResources[resource.Name] = stateSource is not null ? resource.WithStateFrom(stateSource) : resource; + } + + return displayedResources; + } + + private static List? CreateDisplayedResourceChanges( + IReadOnlyDictionary previousDisplayedResources, + IReadOnlyDictionary displayedResources, + IReadOnlyList sourceChanges) + { + var changes = new List(); + var emittedResourceNames = new HashSet(StringComparers.ResourceName); + + foreach (var (changeType, sourceResource) in sourceChanges) + { + if (changeType == ResourceViewModelChangeType.Upsert) + { + if (displayedResources.TryGetValue(sourceResource.Name, out var displayedResource)) + { + changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, displayedResource)); + emittedResourceNames.Add(sourceResource.Name); + } + } + else if (changeType == ResourceViewModelChangeType.Delete) + { + if (!displayedResources.ContainsKey(sourceResource.Name) && + previousDisplayedResources.TryGetValue(sourceResource.Name, out var previousDisplayedResource)) + { + changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Delete, previousDisplayedResource)); + emittedResourceNames.Add(sourceResource.Name); + } + } + } + + foreach (var (resourceName, displayedResource) in displayedResources) + { + if (emittedResourceNames.Contains(resourceName)) + { + continue; + } + + if (!previousDisplayedResources.TryGetValue(resourceName, out var previousDisplayedResource) || + !HasSameDisplayedReplicaState(previousDisplayedResource, displayedResource)) + { + changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, displayedResource)); + emittedResourceNames.Add(resourceName); + } + } + + foreach (var (resourceName, previousDisplayedResource) in previousDisplayedResources) + { + if (!emittedResourceNames.Contains(resourceName) && !displayedResources.ContainsKey(resourceName)) + { + changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Delete, previousDisplayedResource)); + } + } + + return changes.Count > 0 ? changes : null; + } + + private static bool HasSameDisplayedReplicaState(ResourceViewModel previousResource, ResourceViewModel resource) + { + return string.Equals(previousResource.State, resource.State, StringComparison.Ordinal) && + previousResource.KnownState == resource.KnownState && + string.Equals(previousResource.StateStyle, resource.StateStyle, StringComparison.Ordinal) && + previousResource.StartTimeStamp == resource.StartTimeStamp && + previousResource.StopTimeStamp == resource.StopTimeStamp && + previousResource.HealthStatus == resource.HealthStatus && + previousResource.HealthReports.SequenceEqual(resource.HealthReports) && + previousResource.HasSameStateOwnedProperties(resource); + } + + private static ResourceViewModel? GetReplicaStateSource(ResourceViewModel parent, ILookup childrenByParentName) + { + // The legacy Hidden state controls parent visibility and downstream filtering, so a replica must not replace it. + if (parent.KnownState is KnownResourceState.Hidden) + { + return null; + } + + ResourceViewModel? best = null; + var bestTier = int.MaxValue; + + foreach (var child in childrenByParentName[parent.Name]) + { + if (ReferenceEquals(child, parent) || !IsReplicaChild(parent, child)) + { + continue; + } + + var tier = GetReplicaPriorityTier(child); + if (tier is null) + { + continue; + } + + if (tier < bestTier || (tier == bestTier && CompareReplicaRelevance(child, best!) < 0)) + { + best = child; + bestTier = tier.Value; + } + } + + return best; + + static int? GetReplicaPriorityTier(ResourceViewModel r) => r switch + { + _ when r.IsRunningState() => 0, + _ when r.IsUnusableTransitoryState() => 1, + _ when r.IsRuntimeUnhealthy() || r.IsFailedToStart() => 2, + // Hidden is only meaningful on the row that owns it. Projecting it onto the logical parent + // would make an otherwise visible resource disappear as soon as one replica opts out of display. + _ when r.KnownState is KnownResourceState.Hidden => null, + _ when !r.HasNoState() => 3, + _ => null + }; + + static int CompareReplicaRelevance(ResourceViewModel a, ResourceViewModel b) + { + var healthComparison = (a.HealthStatus ?? ResourceHealthStatus.Unhealthy).CompareTo(b.HealthStatus ?? ResourceHealthStatus.Unhealthy); + if (healthComparison != 0) + { + return healthComparison; + } + + // CalculateReplicaIndex derives the index from the current number of resources sharing the + // display name. An inserted resource gets a different index on its first replacement, and + // later replacements can change it again after that count changes, so it is not stable across + // upserts. Tie-break on the immutable resource name so update order cannot change the parent state. + return StringComparers.ResourceName.Compare(a.Name, b.Name); + } + } + + private static bool IsReplicaChild(ResourceViewModel parent, ResourceViewModel child) + { + return string.Equals(child.GetResourcePropertyValue(KnownProperties.Resource.ParentName), parent.Name, StringComparisons.ResourceName) && + string.Equals(child.DisplayName, parent.DisplayName, StringComparisons.ResourceName); + } + private async Task WatchInteractionsAsync(RetryContext retryContext, CancellationToken cancellationToken) { // Create the watch interactions call. This is a bidirectional streaming call. @@ -1111,9 +1313,18 @@ internal void SetInitialDataReceived(IList? initialData = null) { lock (_lock) { + _sourceResourceByName.Clear(); + _resourceByName.Clear(); + foreach (var data in initialData) { - _resourceByName[data.Name] = data.ToViewModel(CalculateReplicaIndex(data.DisplayName), _knownPropertyLookup, _logger); + var resource = data.ToViewModel(CalculateReplicaIndex(data.DisplayName), _knownPropertyLookup, _logger); + _sourceResourceByName[data.Name] = resource; + } + + foreach (var (name, resource) in CreateDisplayedResources(_sourceResourceByName.Values)) + { + _resourceByName[name] = resource; } } } diff --git a/src/Aspire.Dashboard/Telemetry/DashboardTelemetryJsonSerializerContext.cs b/src/Aspire.Dashboard/Telemetry/DashboardTelemetryJsonSerializerContext.cs new file mode 100644 index 00000000000..018c8de02c8 --- /dev/null +++ b/src/Aspire.Dashboard/Telemetry/DashboardTelemetryJsonSerializerContext.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Aspire.Dashboard.Telemetry; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(StartOperationRequest))] +[JsonSerializable(typeof(EndOperationRequest))] +[JsonSerializable(typeof(PostOperationRequest))] +[JsonSerializable(typeof(PostFaultRequest))] +[JsonSerializable(typeof(PostAssetRequest))] +[JsonSerializable(typeof(PostPropertyRequest))] +[JsonSerializable(typeof(PostCommandLineFlagsRequest))] +[JsonSerializable(typeof(StartOperationResponse))] +[JsonSerializable(typeof(TelemetryEventCorrelation))] +[JsonSerializable(typeof(TelemetryEnabledResponse))] +internal sealed partial class DashboardTelemetryJsonSerializerContext : JsonSerializerContext; \ No newline at end of file diff --git a/src/Aspire.Dashboard/Telemetry/DashboardTelemetrySender.cs b/src/Aspire.Dashboard/Telemetry/DashboardTelemetrySender.cs index 685a16af267..6f2c3f33e4a 100644 --- a/src/Aspire.Dashboard/Telemetry/DashboardTelemetrySender.cs +++ b/src/Aspire.Dashboard/Telemetry/DashboardTelemetrySender.cs @@ -140,7 +140,8 @@ private async Task TryStartTelemetrySessionCoreAsync() try { var response = await Client.GetAsync(TelemetryEndpoints.TelemetryEnabled).ConfigureAwait(false); - var isTelemetryEnabled = response.IsSuccessStatusCode && await response.Content.ReadFromJsonAsync().ConfigureAwait(false) is { IsEnabled: true }; + var isTelemetryEnabled = response.IsSuccessStatusCode && + await response.Content.ReadFromJsonAsync(DashboardTelemetryJsonSerializerContext.Default.TelemetryEnabledResponse).ConfigureAwait(false) is { IsEnabled: true }; if (!isTelemetryEnabled) { diff --git a/src/Aspire.Dashboard/Telemetry/DashboardTelemetryService.cs b/src/Aspire.Dashboard/Telemetry/DashboardTelemetryService.cs index 3ba59578d5c..89629102840 100644 --- a/src/Aspire.Dashboard/Telemetry/DashboardTelemetryService.cs +++ b/src/Aspire.Dashboard/Telemetry/DashboardTelemetryService.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization.Metadata; using Aspire.Shared; namespace Aspire.Dashboard.Telemetry; @@ -113,7 +114,12 @@ public OperationContext StartOperation(string eventName, Dictionary().ToArray(), postStartEvent); - var response = await PostRequestAsync(client, TelemetryEndpoints.TelemetryStartOperation, new StartOperationRequest(eventName, scopeSettings)).ConfigureAwait(false); + var response = await PostRequestAsync( + client, + TelemetryEndpoints.TelemetryStartOperation, + new StartOperationRequest(eventName, scopeSettings), + DashboardTelemetryJsonSerializerContext.Default.StartOperationRequest, + DashboardTelemetryJsonSerializerContext.Default.StartOperationResponse).ConfigureAwait(false); context.Properties[0].SetValue(response.OperationId); context.Properties[1].SetValue(response.Correlation); }); @@ -134,7 +140,10 @@ public void EndOperation(OperationContextProperty operationId, TelemetryResult r var context = OperationContext.Create(propertyCount: 0, name: TelemetryEndpoints.TelemetryEndOperation); _telemetrySender.QueueRequest(context, async (client, propertyGetter) => { - await client.PostAsJsonAsync(TelemetryEndpoints.TelemetryEndOperation, new EndOperationRequest(Id: (string)propertyGetter(operationId), Result: result, ErrorMessage: errorMessage)).ConfigureAwait(false); + await client.PostAsJsonAsync( + TelemetryEndpoints.TelemetryEndOperation, + new EndOperationRequest(Id: (string)propertyGetter(operationId), Result: result, ErrorMessage: errorMessage), + DashboardTelemetryJsonSerializerContext.Default.EndOperationRequest).ConfigureAwait(false); }); } @@ -159,7 +168,12 @@ public OperationContext StartUserTask(string eventName, Dictionary().ToArray(), postStartEvent); - var response = await PostRequestAsync(client, TelemetryEndpoints.TelemetryStartUserTask, new StartOperationRequest(eventName, scopeSettings)).ConfigureAwait(false); + var response = await PostRequestAsync( + client, + TelemetryEndpoints.TelemetryStartUserTask, + new StartOperationRequest(eventName, scopeSettings), + DashboardTelemetryJsonSerializerContext.Default.StartOperationRequest, + DashboardTelemetryJsonSerializerContext.Default.StartOperationResponse).ConfigureAwait(false); context.Properties[0].SetValue(response.OperationId); context.Properties[1].SetValue(response.Correlation); }); @@ -180,7 +194,10 @@ public void EndUserTask(OperationContextProperty operationId, TelemetryResult re var context = OperationContext.Create(propertyCount: 0, name: TelemetryEndpoints.TelemetryEndUserTask); _telemetrySender.QueueRequest(context, async (client, propertyGetter) => { - await client.PostAsJsonAsync(TelemetryEndpoints.TelemetryEndUserTask, new EndOperationRequest(Id: (string)propertyGetter(operationId), Result: result, ErrorMessage: errorMessage)).ConfigureAwait(false); + await client.PostAsJsonAsync( + TelemetryEndpoints.TelemetryEndUserTask, + new EndOperationRequest(Id: (string)propertyGetter(operationId), Result: result, ErrorMessage: errorMessage), + DashboardTelemetryJsonSerializerContext.Default.EndOperationRequest).ConfigureAwait(false); }); } @@ -206,7 +223,12 @@ public OperationContext PostOperation(string eventName, TelemetryResult result, IncludeDefaultProperties(properties), correlatedWith?.Select(propertyGetter).Cast().ToArray()); - var response = await PostRequestAsync(client, TelemetryEndpoints.TelemetryPostOperation, request).ConfigureAwait(false); + var response = await PostRequestAsync( + client, + TelemetryEndpoints.TelemetryPostOperation, + request, + DashboardTelemetryJsonSerializerContext.Default.PostOperationRequest, + DashboardTelemetryJsonSerializerContext.Default.TelemetryEventCorrelation).ConfigureAwait(false); context.Properties[0].SetValue(response); }); @@ -234,7 +256,12 @@ public OperationContext PostUserTask(string eventName, TelemetryResult result, s IncludeDefaultProperties(properties), correlatedWith?.Select(propertyGetter).Cast().ToArray()); - var response = await PostRequestAsync(client, TelemetryEndpoints.TelemetryPostUserTask, request).ConfigureAwait(false); + var response = await PostRequestAsync( + client, + TelemetryEndpoints.TelemetryPostUserTask, + request, + DashboardTelemetryJsonSerializerContext.Default.PostOperationRequest, + DashboardTelemetryJsonSerializerContext.Default.TelemetryEventCorrelation).ConfigureAwait(false); context.Properties[0].SetValue(response); }); @@ -262,7 +289,12 @@ public OperationContext PostFault(string eventName, string description, FaultSev IncludeDefaultProperties(properties), correlatedWith?.Select(propertyGetter).Cast().ToArray()); - var response = await PostRequestAsync(client, TelemetryEndpoints.TelemetryPostFault, request).ConfigureAwait(false); + var response = await PostRequestAsync( + client, + TelemetryEndpoints.TelemetryPostFault, + request, + DashboardTelemetryJsonSerializerContext.Default.PostFaultRequest, + DashboardTelemetryJsonSerializerContext.Default.TelemetryEventCorrelation).ConfigureAwait(false); context.Properties[0].SetValue(response); }); @@ -291,7 +323,12 @@ public OperationContext PostAsset(string eventName, string assetId, int assetEve IncludeDefaultProperties(additionalProperties), correlatedWith?.Select(propertyGetter).Cast().ToArray()); - var response = await PostRequestAsync(client, TelemetryEndpoints.TelemetryPostAsset, request).ConfigureAwait(false); + var response = await PostRequestAsync( + client, + TelemetryEndpoints.TelemetryPostAsset, + request, + DashboardTelemetryJsonSerializerContext.Default.PostAssetRequest, + DashboardTelemetryJsonSerializerContext.Default.TelemetryEventCorrelation).ConfigureAwait(false); context.Properties[0].SetValue(response); }); @@ -312,7 +349,10 @@ public void PostProperty(string propertyName, AspireTelemetryProperty propertyVa _telemetrySender.QueueRequest(context, async (client, _) => { var request = new PostPropertyRequest(propertyName, propertyValue); - await client.PostAsJsonAsync(TelemetryEndpoints.TelemetryPostProperty, request).ConfigureAwait(false); + await client.PostAsJsonAsync( + TelemetryEndpoints.TelemetryPostProperty, + request, + DashboardTelemetryJsonSerializerContext.Default.PostPropertyRequest).ConfigureAwait(false); }); } @@ -330,7 +370,10 @@ public void PostRecurringProperty(string propertyName, AspireTelemetryProperty p _telemetrySender.QueueRequest(context, async (client, _) => { var request = new PostPropertyRequest(propertyName, propertyValue); - await client.PostAsJsonAsync(TelemetryEndpoints.TelemetryPostRecurringProperty, request).ConfigureAwait(false); + await client.PostAsJsonAsync( + TelemetryEndpoints.TelemetryPostRecurringProperty, + request, + DashboardTelemetryJsonSerializerContext.Default.PostPropertyRequest).ConfigureAwait(false); }); } @@ -348,15 +391,23 @@ public void PostCommandLineFlags(List flagPrefixes, Dictionary { var request = new PostCommandLineFlagsRequest(flagPrefixes, additionalProperties); - await client.PostAsJsonAsync(TelemetryEndpoints.TelemetryPostCommandLineFlags, request).ConfigureAwait(false); + await client.PostAsJsonAsync( + TelemetryEndpoints.TelemetryPostCommandLineFlags, + request, + DashboardTelemetryJsonSerializerContext.Default.PostCommandLineFlagsRequest).ConfigureAwait(false); }); } - private static async Task PostRequestAsync(HttpClient client, string endpoint, TRequest request) + private static async Task PostRequestAsync( + HttpClient client, + string endpoint, + TRequest request, + JsonTypeInfo requestTypeInfo, + JsonTypeInfo responseTypeInfo) { - var httpResponseMessage = await client.PostAsJsonAsync(endpoint, request).ConfigureAwait(false); + var httpResponseMessage = await client.PostAsJsonAsync(endpoint, request, requestTypeInfo).ConfigureAwait(false); httpResponseMessage.EnsureSuccessStatusCode(); - var response = await httpResponseMessage.Content.ReadFromJsonAsync().ConfigureAwait(false); + var response = await httpResponseMessage.Content.ReadFromJsonAsync(responseTypeInfo).ConfigureAwait(false); if (response is null) { throw new InvalidOperationException("Response was null."); diff --git a/src/Aspire.Dashboard/Telemetry/VisualStudioTelemetryTypes.cs b/src/Aspire.Dashboard/Telemetry/VisualStudioTelemetryTypes.cs index ec73c66265a..4684dc06d89 100644 --- a/src/Aspire.Dashboard/Telemetry/VisualStudioTelemetryTypes.cs +++ b/src/Aspire.Dashboard/Telemetry/VisualStudioTelemetryTypes.cs @@ -13,7 +13,7 @@ public class TelemetryEventCorrelation public required Guid Id { get; set; } [JsonPropertyName("eventType")] - [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonConverter(typeof(JsonStringEnumConverter))] public DataModelEventType EventType { get; set; } } diff --git a/src/Aspire.Dashboard/wwwroot/js/app-metrics.js b/src/Aspire.Dashboard/wwwroot/js/app-metrics.js index 1fcb91dd84c..d039c82bc54 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app-metrics.js +++ b/src/Aspire.Dashboard/wwwroot/js/app-metrics.js @@ -1,4 +1,4 @@ -import './plotly-basic-2.35.2.min.js' +import './plotly-basic-4.0.0-rc.0.min.js' export function initializeChart(id, traces, exemplarTrace, rangeStartTime, rangeEndTime, serverLocale, chartInterop) { registerLocale(serverLocale); @@ -80,7 +80,19 @@ export function initializeChart(id, traces, exemplarTrace, rangeStartTime, range } }; - var options = { scrollZoom: false, displayModeBar: false }; + // Plotly 4.x flipped the `showSendToCloud` default to true and points `plotlyServerURL` + // at https://cloud.plotly.com/newchart. That surfaces a "Share Chart" modebar button which + // uploads the full chart JSON — i.e. the user's telemetry — to a third-party service. + // `displayModeBar: false` already hides the button today, but these are set explicitly so + // enabling the modebar later can never silently re-introduce outbound data egress. + // Neither window.open nor postMessage is governed by our CSP, so this is the only guard. + // See https://github.com/plotly/plotly.js/blob/master/CHANGELOG.md (v4.0.0 breaking changes). + var options = { + scrollZoom: false, + displayModeBar: false, + showSendToCloud: false, + plotlyServerURL: '' + }; var plot = Plotly.newPlot(chartDiv, data, layout, options); diff --git a/src/Aspire.Dashboard/wwwroot/js/plotly-basic-2.35.2.min.js b/src/Aspire.Dashboard/wwwroot/js/plotly-basic-2.35.2.min.js deleted file mode 100644 index 2f1f2651f55..00000000000 --- a/src/Aspire.Dashboard/wwwroot/js/plotly-basic-2.35.2.min.js +++ /dev/null @@ -1,8 +0,0 @@ -/** -* plotly.js (basic - minified) v2.35.2 -* Copyright 2012-2024, Plotly, Inc. -* All rights reserved. -* Licensed under the MIT license -*/ -/*! For license information please see plotly-basic.min.js.LICENSE.txt */ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Plotly=t():e.Plotly=t()}(self,(function(){return function(){var e={6713:function(e,t,r){"use strict";var n=r(4809),a={"X,X div":'direction:ltr;font-family:"Open Sans",verdana,arial,sans-serif;margin:0;padding:0;',"X input,X button":'font-family:"Open Sans",verdana,arial,sans-serif;',"X input:focus,X button:focus":"outline:none;","X a":"text-decoration:none;","X a:hover":"text-decoration:none;","X .crisp":"shape-rendering:crispEdges;","X .user-select-none":"-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none;","X svg":"overflow:hidden;","X svg a":"fill:#447adb;","X svg a:hover":"fill:#3c6dc5;","X .main-svg":"position:absolute;top:0;left:0;pointer-events:none;","X .main-svg .draglayer":"pointer-events:all;","X .cursor-default":"cursor:default;","X .cursor-pointer":"cursor:pointer;","X .cursor-crosshair":"cursor:crosshair;","X .cursor-move":"cursor:move;","X .cursor-col-resize":"cursor:col-resize;","X .cursor-row-resize":"cursor:row-resize;","X .cursor-ns-resize":"cursor:ns-resize;","X .cursor-ew-resize":"cursor:ew-resize;","X .cursor-sw-resize":"cursor:sw-resize;","X .cursor-s-resize":"cursor:s-resize;","X .cursor-se-resize":"cursor:se-resize;","X .cursor-w-resize":"cursor:w-resize;","X .cursor-e-resize":"cursor:e-resize;","X .cursor-nw-resize":"cursor:nw-resize;","X .cursor-n-resize":"cursor:n-resize;","X .cursor-ne-resize":"cursor:ne-resize;","X .cursor-grab":"cursor:-webkit-grab;cursor:grab;","X .modebar":"position:absolute;top:2px;right:2px;","X .ease-bg":"-webkit-transition:background-color .3s ease 0s;-moz-transition:background-color .3s ease 0s;-ms-transition:background-color .3s ease 0s;-o-transition:background-color .3s ease 0s;transition:background-color .3s ease 0s;","X .modebar--hover>:not(.watermark)":"opacity:0;-webkit-transition:opacity .3s ease 0s;-moz-transition:opacity .3s ease 0s;-ms-transition:opacity .3s ease 0s;-o-transition:opacity .3s ease 0s;transition:opacity .3s ease 0s;","X:hover .modebar--hover .modebar-group":"opacity:1;","X .modebar-group":"float:left;display:inline-block;box-sizing:border-box;padding-left:8px;position:relative;vertical-align:middle;white-space:nowrap;","X .modebar-btn":"position:relative;font-size:16px;padding:3px 4px;height:22px;cursor:pointer;line-height:normal;box-sizing:border-box;","X .modebar-btn svg":"position:relative;top:2px;","X .modebar.vertical":"display:flex;flex-direction:column;flex-wrap:wrap;align-content:flex-end;max-height:100%;","X .modebar.vertical svg":"top:-1px;","X .modebar.vertical .modebar-group":"display:block;float:none;padding-left:0px;padding-bottom:8px;","X .modebar.vertical .modebar-group .modebar-btn":"display:block;text-align:center;","X [data-title]:before,X [data-title]:after":"position:absolute;-webkit-transform:translate3d(0, 0, 0);-moz-transform:translate3d(0, 0, 0);-ms-transform:translate3d(0, 0, 0);-o-transform:translate3d(0, 0, 0);transform:translate3d(0, 0, 0);display:none;opacity:0;z-index:1001;pointer-events:none;top:110%;right:50%;","X [data-title]:hover:before,X [data-title]:hover:after":"display:block;opacity:1;","X [data-title]:before":'content:"";position:absolute;background:rgba(0,0,0,0);border:6px solid rgba(0,0,0,0);z-index:1002;margin-top:-12px;border-bottom-color:#69738a;margin-right:-6px;',"X [data-title]:after":"content:attr(data-title);background:#69738a;color:#fff;padding:8px 10px;font-size:12px;line-height:12px;white-space:nowrap;margin-right:-18px;border-radius:2px;","X .vertical [data-title]:before,X .vertical [data-title]:after":"top:0%;right:200%;","X .vertical [data-title]:before":"border:6px solid rgba(0,0,0,0);border-left-color:#69738a;margin-top:8px;margin-right:-30px;",Y:'font-family:"Open Sans",verdana,arial,sans-serif;position:fixed;top:50px;right:20px;z-index:10000;font-size:10pt;max-width:180px;',"Y p":"margin:0;","Y .notifier-note":"min-width:180px;max-width:250px;border:1px solid #fff;z-index:3000;margin:0;background-color:#8c97af;background-color:rgba(140,151,175,.9);color:#fff;padding:10px;overflow-wrap:break-word;word-wrap:break-word;-ms-hyphens:auto;-webkit-hyphens:auto;hyphens:auto;","Y .notifier-close":"color:#fff;opacity:.8;float:right;padding:0 5px;background:none;border:none;font-size:20px;font-weight:bold;line-height:20px;","Y .notifier-close:hover":"color:#444;text-decoration:none;cursor:pointer;"};for(var i in a){var o=i.replace(/^,/," ,").replace(/X/g,".js-plotly-plot .plotly").replace(/Y/g,".plotly-notifier");n.addStyleRule(o,a[i])}},4187:function(e,t,r){"use strict";e.exports=r(7908)},273:function(e,t,r){"use strict";e.exports=r(8218)},8847:function(e,t,r){"use strict";e.exports=r(7317)},7549:function(e,t,r){"use strict";e.exports=r(7430)},6200:function(e,t,r){"use strict";e.exports=r(2849)},3430:function(e,t,r){"use strict";e.exports=r(453)},5611:function(e,t,r){"use strict";var n=r(7549);n.register([r(273),r(8170),r(4187),r(6200),r(3430),r(590),r(8847)]),e.exports=n},8170:function(e,t,r){"use strict";e.exports=r(9913)},590:function(e,t,r){"use strict";e.exports=r(9855)},1645:function(e){"use strict";e.exports=[{path:"",backoff:0},{path:"M-2.4,-3V3L0.6,0Z",backoff:.6},{path:"M-3.7,-2.5V2.5L1.3,0Z",backoff:1.3},{path:"M-4.45,-3L-1.65,-0.2V0.2L-4.45,3L1.55,0Z",backoff:1.55},{path:"M-2.2,-2.2L-0.2,-0.2V0.2L-2.2,2.2L-1.4,3L1.6,0L-1.4,-3Z",backoff:1.6},{path:"M-4.4,-2.1L-0.6,-0.2V0.2L-4.4,2.1L-4,3L2,0L-4,-3Z",backoff:2},{path:"M2,0A2,2 0 1,1 0,-2A2,2 0 0,1 2,0Z",backoff:0,noRotate:!0},{path:"M2,2V-2H-2V2Z",backoff:0,noRotate:!0}]},222:function(e,t,r){"use strict";var n=r(1645),a=r(337),i=r(4826),o=r(8032).templatedArray;r(5081),e.exports=o("annotation",{visible:{valType:"boolean",dflt:!0,editType:"calc+arraydraw"},text:{valType:"string",editType:"calc+arraydraw"},textangle:{valType:"angle",dflt:0,editType:"calc+arraydraw"},font:a({editType:"calc+arraydraw",colorEditType:"arraydraw"}),width:{valType:"number",min:1,dflt:null,editType:"calc+arraydraw"},height:{valType:"number",min:1,dflt:null,editType:"calc+arraydraw"},opacity:{valType:"number",min:0,max:1,dflt:1,editType:"arraydraw"},align:{valType:"enumerated",values:["left","center","right"],dflt:"center",editType:"arraydraw"},valign:{valType:"enumerated",values:["top","middle","bottom"],dflt:"middle",editType:"arraydraw"},bgcolor:{valType:"color",dflt:"rgba(0,0,0,0)",editType:"arraydraw"},bordercolor:{valType:"color",dflt:"rgba(0,0,0,0)",editType:"arraydraw"},borderpad:{valType:"number",min:0,dflt:1,editType:"calc+arraydraw"},borderwidth:{valType:"number",min:0,dflt:1,editType:"calc+arraydraw"},showarrow:{valType:"boolean",dflt:!0,editType:"calc+arraydraw"},arrowcolor:{valType:"color",editType:"arraydraw"},arrowhead:{valType:"integer",min:0,max:n.length,dflt:1,editType:"arraydraw"},startarrowhead:{valType:"integer",min:0,max:n.length,dflt:1,editType:"arraydraw"},arrowside:{valType:"flaglist",flags:["end","start"],extras:["none"],dflt:"end",editType:"arraydraw"},arrowsize:{valType:"number",min:.3,dflt:1,editType:"calc+arraydraw"},startarrowsize:{valType:"number",min:.3,dflt:1,editType:"calc+arraydraw"},arrowwidth:{valType:"number",min:.1,editType:"calc+arraydraw"},standoff:{valType:"number",min:0,dflt:0,editType:"calc+arraydraw"},startstandoff:{valType:"number",min:0,dflt:0,editType:"calc+arraydraw"},ax:{valType:"any",editType:"calc+arraydraw"},ay:{valType:"any",editType:"calc+arraydraw"},axref:{valType:"enumerated",dflt:"pixel",values:["pixel",i.idRegex.x.toString()],editType:"calc"},ayref:{valType:"enumerated",dflt:"pixel",values:["pixel",i.idRegex.y.toString()],editType:"calc"},xref:{valType:"enumerated",values:["paper",i.idRegex.x.toString()],editType:"calc"},x:{valType:"any",editType:"calc+arraydraw"},xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"auto",editType:"calc+arraydraw"},xshift:{valType:"number",dflt:0,editType:"calc+arraydraw"},yref:{valType:"enumerated",values:["paper",i.idRegex.y.toString()],editType:"calc"},y:{valType:"any",editType:"calc+arraydraw"},yanchor:{valType:"enumerated",values:["auto","top","middle","bottom"],dflt:"auto",editType:"calc+arraydraw"},yshift:{valType:"number",dflt:0,editType:"calc+arraydraw"},clicktoshow:{valType:"enumerated",values:[!1,"onoff","onout"],dflt:!1,editType:"arraydraw"},xclick:{valType:"any",editType:"arraydraw"},yclick:{valType:"any",editType:"arraydraw"},hovertext:{valType:"string",editType:"arraydraw"},hoverlabel:{bgcolor:{valType:"color",editType:"arraydraw"},bordercolor:{valType:"color",editType:"arraydraw"},font:a({editType:"arraydraw"}),editType:"arraydraw"},captureevents:{valType:"boolean",editType:"arraydraw"},editType:"calc",_deprecated:{ref:{valType:"string",editType:"calc"}}})},317:function(e,t,r){"use strict";var n=r(4809),a=r(9714),i=r(3377).draw;function o(e){var t=e._fullLayout;n.filterVisible(t.annotations).forEach((function(t){var r=a.getFromId(e,t.xref),n=a.getFromId(e,t.yref),i=a.getRefType(t.xref),o=a.getRefType(t.yref);t._extremes={},"range"===i&&l(t,r),"range"===o&&l(t,n)}))}function l(e,t){var r,n=t._id,i=n.charAt(0),o=e[i],l=e["a"+i],s=e[i+"ref"],c=e["a"+i+"ref"],u=e["_"+i+"padplus"],f=e["_"+i+"padminus"],d={x:1,y:-1}[i]*e[i+"shift"],h=3*e.arrowsize*e.arrowwidth||0,p=h+d,v=h-d,g=3*e.startarrowsize*e.arrowwidth||0,m=g+d,y=g-d;if(c===s){var x=a.findExtremes(t,[t.r2c(o)],{ppadplus:p,ppadminus:v}),b=a.findExtremes(t,[t.r2c(l)],{ppadplus:Math.max(u,m),ppadminus:Math.max(f,y)});r={min:[x.min[0],b.min[0]],max:[x.max[0],b.max[0]]}}else m=l?m+l:m,y=l?y-l:y,r=a.findExtremes(t,[t.r2c(o)],{ppadplus:Math.max(u,p,m),ppadminus:Math.max(f,v,y)});e._extremes[n]=r}e.exports=function(e){var t=e._fullLayout;if(n.filterVisible(t.annotations).length&&e._fullData.length)return n.syncOrAsync([i,o],e)}},6035:function(e,t,r){"use strict";var n=r(4809),a=r(3626),i=r(8032).arrayEditor;function o(e,t){var r,n,a,i,o,s,c,u=e._fullLayout.annotations,f=[],d=[],h=[],p=(t||[]).length;for(r=0;r0||r.explicitOff.length>0},onClick:function(e,t){var r,l,s=o(e,t),c=s.on,u=s.off.concat(s.explicitOff),f={},d=e._fullLayout.annotations;if(c.length||u.length){for(r=0;r2/3?"right":"center"),{center:0,middle:0,left:.5,bottom:-.5,right:-.5,top:.5}[t]}for(var Z=!1,W=["x","y"],J=0;J1)&&(ne===re?((he=ae.r2fraction(t["a"+te]))<0||he>1)&&(Z=!0):Z=!0),X=ae._offset+ae.r2p(t[te]),$=.5}else{var pe="domain"===de;"x"===te?(Q=t[te],X=pe?ae._offset+ae._length*Q:X=T.l+T.w*Q):(Q=1-t[te],X=pe?ae._offset+ae._length*Q:X=T.t+T.h*Q),$=t.showarrow?.5:Q}if(t.showarrow){fe.head=X;var ve=t["a"+te];if(ee=oe*U(.5,t.xanchor)-le*U(.5,t.yanchor),ne===re){var ge=s.getRefType(ne);"domain"===ge?("y"===te&&(ve=1-ve),fe.tail=ae._offset+ae._length*ve):"paper"===ge?"y"===te?(ve=1-ve,fe.tail=T.t+T.h*ve):fe.tail=T.l+T.w*ve:fe.tail=ae._offset+ae.r2p(ve),K=ee}else fe.tail=X+ve,K=ee+ve;fe.text=fe.tail+ee;var me=w["x"===te?"width":"height"];if("paper"===re&&(fe.head=o.constrain(fe.head,1,me-1)),"pixel"===ne){var ye=-Math.max(fe.tail-3,fe.text),xe=Math.min(fe.tail+3,fe.text)-me;ye>0?(fe.tail+=ye,fe.text+=ye):xe>0&&(fe.tail-=xe,fe.text-=xe)}fe.tail+=ue,fe.head+=ue}else K=ee=se*U($,ce),fe.text=X+ee;fe.text+=ue,ee+=ue,K+=ue,t["_"+te+"padplus"]=se/2+K,t["_"+te+"padminus"]=se/2-K,t["_"+te+"size"]=se,t["_"+te+"shift"]=ee}if(Z)R.remove();else{var be=0,_e=0;if("left"!==t.align&&(be=(k-b)*("center"===t.align?.5:1)),"top"!==t.valign&&(_e=(E-_)*("middle"===t.valign?.5:1)),f)n.select("svg").attr({x:H+be-1,y:H+_e}).call(u.setClipUrl,j?C:null,e);else{var we=H+_e-v.top,Te=H+be-v.left;q.call(d.positionText,Te,we).call(u.setClipUrl,j?C:null,e)}Y.select("rect").call(u.setRect,H,H,k,E),B.call(u.setRect,N/2,N/2,F-N,V-N),R.call(u.setTranslate,Math.round(O.x.text-F/2),Math.round(O.y.text-V/2)),z.attr({transform:"rotate("+D+","+O.x.text+","+O.y.text+")"});var Me,ke=function(r,n){P.selectAll(".annotation-arrow-g").remove();var s=O.x.head,f=O.y.head,d=O.x.tail+r,h=O.y.tail+n,v=O.x.text+r,b=O.y.text+n,_=o.rotationXYMatrix(D,v,b),w=o.apply2DTransform(_),k=o.apply2DTransform2(_),C=+B.attr("width"),I=+B.attr("height"),E=v-.5*C,N=E+C,F=b-.5*I,H=F+I,j=[[E,F,E,H],[E,H,N,H],[N,H,N,F],[N,F,E,F]].map(k);if(!j.reduce((function(e,t){return e^!!o.segmentsIntersect(s,f,s+1e6,f+1e6,t[0],t[1],t[2],t[3])}),!1)){j.forEach((function(e){var t=o.segmentsIntersect(d,h,s,f,e[0],e[1],e[2],e[3]);t&&(d=t.x,h=t.y)}));var Y=t.arrowwidth,V=t.arrowcolor,U=t.arrowside,q=P.append("g").style({opacity:c.opacity(V)}).classed("annotation-arrow-g",!0),G=q.append("path").attr("d","M"+d+","+h+"L"+s+","+f).style("stroke-width",Y+"px").call(c.stroke,c.rgb(V));if(g(G,U,t),M.annotationPosition&&G.node().parentNode&&!i){var Z=s,W=f;if(t.standoff){var J=Math.sqrt(Math.pow(s-d,2)+Math.pow(f-h,2));Z+=t.standoff*(d-s)/J,W+=t.standoff*(h-f)/J}var X,K,Q=q.append("path").classed("annotation-arrow",!0).classed("anndrag",!0).classed("cursor-move",!0).attr({d:"M3,3H-3V-3H3ZM0,0L"+(d-Z)+","+(h-W),transform:l(Z,W)}).style("stroke-width",Y+6+"px").call(c.stroke,"rgba(0,0,0,0)").call(c.fill,"rgba(0,0,0,0)");p.init({element:Q.node(),gd:e,prepFn:function(){var e=u.getTranslate(R);X=e.x,K=e.y,m&&m.autorange&&A(m._name+".autorange",!0),x&&x.autorange&&A(x._name+".autorange",!0)},moveFn:function(e,r){var n=w(X,K),a=n[0]+e,i=n[1]+r;R.call(u.setTranslate,a,i),L("x",y(m,e,"x",T,t)),L("y",y(x,r,"y",T,t)),t.axref===t.xref&&L("ax",y(m,e,"ax",T,t)),t.ayref===t.yref&&L("ay",y(x,r,"ay",T,t)),q.attr("transform",l(e,r)),z.attr({transform:"rotate("+D+","+a+","+i+")"})},doneFn:function(){a.call("_guiRelayout",e,S());var t=document.querySelector(".js-notes-box-panel");t&&t.redraw(t.selectedObj)}})}}};t.showarrow&&ke(0,0),I&&p.init({element:R.node(),gd:e,prepFn:function(){Me=z.attr("transform")},moveFn:function(e,r){var n="pointer";if(t.showarrow)t.axref===t.xref?L("ax",y(m,e,"ax",T,t)):L("ax",t.ax+e),t.ayref===t.yref?L("ay",y(x,r,"ay",T.w,t)):L("ay",t.ay+r),ke(e,r);else{if(i)return;var a,o;if(m)a=y(m,e,"x",T,t);else{var s=t._xsize/T.w,c=t.x+(t._xshift-t.xshift)/T.w-s/2;a=p.align(c+e/T.w,s,0,1,t.xanchor)}if(x)o=y(x,r,"y",T,t);else{var u=t._ysize/T.h,f=t.y-(t._yshift+t.yshift)/T.h-u/2;o=p.align(f-r/T.h,u,0,1,t.yanchor)}L("x",a),L("y",o),m&&x||(n=p.getCursor(m?.5:a,x?.5:o,t.xanchor,t.yanchor))}z.attr({transform:l(e,r)+Me}),h(R,n)},clickFn:function(r,n){t.captureevents&&e.emit("plotly_clickannotation",G(n))},doneFn:function(){h(R),a.call("_guiRelayout",e,S());var t=document.querySelector(".js-notes-box-panel");t&&t.redraw(t.selectedObj)}})}}}e.exports={draw:function(e){var t=e._fullLayout;t._infolayer.selectAll(".annotation").remove();for(var r=0;r=0,x=t.indexOf("end")>=0,b=p.backoff*g+r.standoff,_=v.backoff*m+r.startstandoff;if("line"===h.nodeName){o={x:+e.attr("x1"),y:+e.attr("y1")},u={x:+e.attr("x2"),y:+e.attr("y2")};var w=o.x-u.x,T=o.y-u.y;if(d=(f=Math.atan2(T,w))+Math.PI,b&&_&&b+_>Math.sqrt(w*w+T*T))return void I();if(b){if(b*b>w*w+T*T)return void I();var M=b*Math.cos(f),k=b*Math.sin(f);u.x+=M,u.y+=k,e.attr({x2:u.x,y2:u.y})}if(_){if(_*_>w*w+T*T)return void I();var A=_*Math.cos(f),L=_*Math.sin(f);o.x-=A,o.y-=L,e.attr({x1:o.x,y1:o.y})}}else if("path"===h.nodeName){var S=h.getTotalLength(),C="";if(S1){c=!0;break}}c?e.fullLayout._infolayer.select(".annotation-"+e.id+'[data-index="'+l+'"]').remove():(s._pdata=a(e.glplot.cameraParams,[t.xaxis.r2l(s.x)*r[0],t.yaxis.r2l(s.y)*r[1],t.zaxis.r2l(s.z)*r[2]]),n(e.graphDiv,s,l,e.id,s._xa,s._ya))}}},3348:function(e,t,r){"use strict";var n=r(3626),a=r(4809);e.exports={moduleType:"component",name:"annotations3d",schema:{subplots:{scene:{annotations:r(8239)}}},layoutAttributes:r(8239),handleDefaults:r(4232),includeBasePlot:function(e,t){var r=n.subplotsRegistry.gl3d;if(r)for(var i=r.attrRegex,o=Object.keys(e),l=0;l=0))return e;if(3===o)n[o]>1&&(n[o]=1);else if(n[o]>=1)return e}var l=Math.round(255*n[0])+", "+Math.round(255*n[1])+", "+Math.round(255*n[2]);return i?"rgba("+l+", "+n[3]+")":"rgb("+l+")"}o.tinyRGB=function(e){var t=e.toRgb();return"rgb("+Math.round(t.r)+", "+Math.round(t.g)+", "+Math.round(t.b)+")"},o.rgb=function(e){return o.tinyRGB(n(e))},o.opacity=function(e){return e?n(e).getAlpha():0},o.addOpacity=function(e,t){var r=n(e).toRgb();return"rgba("+Math.round(r.r)+", "+Math.round(r.g)+", "+Math.round(r.b)+", "+t+")"},o.combine=function(e,t){var r=n(e).toRgb();if(1===r.a)return n(e).toRgbString();var a=n(t||c).toRgb(),i=1===a.a?a:{r:255*(1-a.a)+a.r*a.a,g:255*(1-a.a)+a.g*a.a,b:255*(1-a.a)+a.b*a.a},o={r:i.r*(1-r.a)+r.r*r.a,g:i.g*(1-r.a)+r.g*r.a,b:i.b*(1-r.a)+r.b*r.a};return n(o).toRgbString()},o.interpolate=function(e,t,r){var a=n(e).toRgb(),i=n(t).toRgb(),o={r:r*a.r+(1-r)*i.r,g:r*a.g+(1-r)*i.g,b:r*a.b+(1-r)*i.b};return n(o).toRgbString()},o.contrast=function(e,t,r){var a=n(e);return 1!==a.getAlpha()&&(a=n(o.combine(e,c))),(a.isDark()?t?a.lighten(t):c:r?a.darken(r):s).toString()},o.stroke=function(e,t){var r=n(t);e.style({stroke:o.tinyRGB(r),"stroke-opacity":r.getAlpha()})},o.fill=function(e,t){var r=n(t);e.style({fill:o.tinyRGB(r),"fill-opacity":r.getAlpha()})},o.clean=function(e){if(e&&"object"==typeof e){var t,r,n,a,l=Object.keys(e);for(t=0;t0?n>=s:n<=s));a++)n>u&&n0?n>=s:n<=s));a++)n>r[0]&&n1){var he=Math.pow(10,Math.floor(Math.log(de)/Math.LN10));ue*=he*c.roundUp(de/he,[2,5,10]),(Math.abs(G.start)/G.size+1e-6)%1<2e-6&&(se.tick0=0)}se.dtick=ue}se.domain=o?[oe+P/F.h,oe+$-P/F.h]:[oe+D/F.w,oe+$-D/F.w],se.setScale(),e.attr("transform",u(Math.round(F.l),Math.round(F.t)));var pe,ve=e.select("."+k.cbtitleunshift).attr("transform",u(-Math.round(F.l),-Math.round(F.t))),ge=se.ticklabelposition,me=se.title.font.size,ye=e.select("."+k.cbaxis),xe=0,be=0;function _e(n,a){var i={propContainer:se,propName:t._propPrefix+"title",traceIndex:t._traceIndex,_meta:t._meta,placeholder:N._dfltTitle.colorbar,containerGroup:e.select("."+k.cbtitle)},o="h"===n.charAt(0)?n.substr(1):"h"+n;e.selectAll("."+o+",."+o+"-math-group").remove(),v.draw(r,n,f(i,a||{}))}return c.syncOrAsync([i.previousPromises,function(){var e,t;(o&&ce||!o&&!ce)&&("top"===Y&&(e=D+F.l+ee*z,t=P+F.t+te*(1-oe-$)+3+.75*me),"bottom"===Y&&(e=D+F.l+ee*z,t=P+F.t+te*(1-oe)-3-.25*me),"right"===Y&&(t=P+F.t+te*I+3+.75*me,e=D+F.l+ee*oe),_e(se._id+"title",{attributes:{x:e,y:t,"text-anchor":o?"start":"middle"}}))},function(){if(!o&&!ce||o&&ce){var i,s=e.select("."+k.cbtitle),f=s.select("text"),d=[-A/2,A/2],p=s.select(".h"+se._id+"title-math-group").node(),v=15.6;if(f.node()&&(v=parseInt(f.node().style.fontSize,10)*w),p?(i=h.bBox(p),be=i.width,(xe=i.height)>v&&(d[1]-=(xe-v)/2)):f.node()&&!f.classed(k.jsPlaceholder)&&(i=h.bBox(f.node()),be=i.width,xe=i.height),o){if(xe){if(xe+=5,"top"===Y)se.domain[1]-=xe/F.h,d[1]*=-1;else{se.domain[0]+=xe/F.h;var m=g.lineCount(f);d[1]+=(1-m)*v}s.attr("transform",u(d[0],d[1])),se.setScale()}}else be&&("right"===Y&&(se.domain[0]+=(be+me/2)/F.w),s.attr("transform",u(d[0],d[1])),se.setScale())}e.selectAll("."+k.cbfills+",."+k.cblines).attr("transform",o?u(0,Math.round(F.h*(1-se.domain[1]))):u(Math.round(F.w*se.domain[0]),0)),ye.attr("transform",o?u(0,Math.round(-F.t)):u(Math.round(-F.l),0));var y=e.select("."+k.cbfills).selectAll("rect."+k.cbfill).attr("style","").data(W);y.enter().append("rect").classed(k.cbfill,!0).attr("style",""),y.exit().remove();var x=V.map(se.c2p).map(Math.round).sort((function(e,t){return e-t}));y.each((function(e,i){var l=[0===i?V[0]:(W[i]+W[i-1])/2,i===W.length-1?V[1]:(W[i]+W[i+1])/2].map(se.c2p).map(Math.round);o&&(l[1]=c.constrain(l[1]+(l[1]>l[0])?1:-1,x[0],x[1]));var s=n.select(this).attr(o?"x":"y",re).attr(o?"y":"x",n.min(l)).attr(o?"width":"height",Math.max(X,2)).attr(o?"height":"width",Math.max(n.max(l)-n.min(l),2));if(t._fillgradient)h.gradient(s,r,t._id,o?"vertical":"horizontalreversed",t._fillgradient,"fill");else{var u=q(e).replace("e-","");s.attr("fill",a(u).toHexString())}}));var b=e.select("."+k.cblines).selectAll("path."+k.cbline).data(B.color&&B.width?J:[]);b.enter().append("path").classed(k.cbline,!0),b.exit().remove(),b.each((function(e){var t=re,r=Math.round(se.c2p(e))+B.width/2%1;n.select(this).attr("d","M"+(o?t+","+r:r+","+t)+(o?"h":"v")+X).call(h.lineGroupStyle,B.width,U(e),B.dash)})),ye.selectAll("g."+se._id+"tick,path").remove();var _=re+X+(A||0)/2-("outside"===t.ticks?1:0),T=l.calcTicks(se),M=l.getTickSigns(se)[2];return l.drawTicks(r,se,{vals:"inside"===se.ticks?l.clipEnds(se,T):T,layer:ye,path:l.makeTickPath(se,_,M),transFn:l.makeTransTickFn(se)}),l.drawLabels(r,se,{vals:T,layer:ye,transFn:l.makeTransTickLabelFn(se),labelFns:l.makeLabelFns(se,_)})},function(){if(o&&!ce||!o&&ce){var e,a,i=se.position||0,l=se._offset+se._length/2;if("right"===Y)a=l,e=F.l+ee*i+10+me*(se.showticklabels?1:.5);else if(e=l,"bottom"===Y&&(a=F.t+te*i+10+(-1===ge.indexOf("inside")?se.tickfont.size:0)+("intside"!==se.ticks&&t.ticklen||0)),"top"===Y){var s=j.text.split("
").length;a=F.t+te*i+10-X-w*me*s}_e((o?"h":"v")+se._id+"title",{avoid:{selection:n.select(r).selectAll("g."+se._id+"tick"),side:Y,offsetTop:o?0:F.t,offsetLeft:o?F.l:0,maxShift:o?N.width:N.height},attributes:{x:e,y:a,"text-anchor":"middle"},transform:{rotate:o?-90:0,offset:0}})}},i.previousPromises,function(){var n,l=X+A/2;-1===ge.indexOf("inside")&&(n=h.bBox(ye.node()),l+=o?n.width:n.height),pe=ve.select("text");var c=0,f=o&&"top"===Y,v=!o&&"right"===Y,g=0;if(pe.node()&&!pe.classed(k.jsPlaceholder)){var y,x=ve.select(".h"+se._id+"title-math-group").node();x&&(o&&ce||!o&&!ce)?(c=(n=h.bBox(x)).width,y=n.height):(c=(n=h.bBox(ve.node())).right-F.l-(o?re:le),y=n.bottom-F.t-(o?le:re),o||"top"!==Y||(l+=n.height,g=n.height)),v&&(pe.attr("transform",u(c/2+me/2,0)),c*=2),l=Math.max(l,o?c:y)}var b=2*(o?D:P)+l+L+A/2,w=0;!o&&j.text&&"bottom"===O&&I<=0&&(b+=w=b/2,g+=w),N._hColorbarMoveTitle=w,N._hColorbarMoveCBTitle=g;var H=L+A,B=(o?re:le)-H/2-(o?D:0),V=(o?le:re)-(o?Q:P+g-w);e.select("."+k.cbbg).attr("x",B).attr("y",V).attr(o?"width":"height",Math.max(b-w,2)).attr(o?"height":"width",Math.max(Q+H,2)).call(p.fill,S).call(p.stroke,t.bordercolor).style("stroke-width",L);var U=v?Math.max(c-10,0):0;e.selectAll("."+k.cboutline).attr("x",(o?re:le+D)+U).attr("y",(o?le+P-Q:re)+(f?xe:0)).attr(o?"width":"height",Math.max(X,2)).attr(o?"height":"width",Math.max(Q-(o?2*P+xe:2*D+U),2)).call(p.stroke,t.outlinecolor).style({fill:"none","stroke-width":A});var q=o?ne*b:0,G=o?0:(1-ae)*b-g;if(q=R?F.l-q:-q,G=E?F.t-G:-G,e.attr("transform",u(q,G)),!o&&(L||a(S).getAlpha()&&!a.equals(N.paper_bgcolor,S))){var Z=ye.selectAll("text"),W=Z[0].length,J=e.select("."+k.cbbg).node(),K=h.bBox(J),$=h.getTranslate(e);Z.each((function(e,t){var r=W-1;if(0===t||t===r){var n,a=h.bBox(this),i=h.getTranslate(this);if(t===r){var o=a.right+i.x;(n=K.right+$.x+le-L-2+z-o)>0&&(n=0)}else if(0===t){var l=a.left+i.x;(n=K.left+$.x+le+L+2-l)<0&&(n=0)}n&&(W<3?this.setAttribute("transform","translate("+n+",0) "+this.getAttribute("transform")):this.setAttribute("visibility","hidden"))}}))}var ee={},te=T[C],ie=M[C],oe=T[O],ue=M[O],fe=b-X;o?("pixels"===d?(ee.y=I,ee.t=Q*oe,ee.b=Q*ue):(ee.t=ee.b=0,ee.yt=I+s*oe,ee.yb=I-s*ue),"pixels"===_?(ee.x=z,ee.l=b*te,ee.r=b*ie):(ee.l=fe*te,ee.r=fe*ie,ee.xl=z-m*te,ee.xr=z+m*ie)):("pixels"===d?(ee.x=z,ee.l=Q*te,ee.r=Q*ie):(ee.l=ee.r=0,ee.xl=z+s*te,ee.xr=z-s*ie),"pixels"===_?(ee.y=1-I,ee.t=b*oe,ee.b=b*ue):(ee.t=fe*oe,ee.b=fe*ue,ee.yt=I-m*oe,ee.yb=I+m*ue));var de=t.y<.5?"b":"t",he=t.x<.5?"l":"r";r._fullLayout._reservedMargin[t._id]={};var be={r:N.width-B-q,l:B+ee.r,b:N.height-V-G,t:V+ee.b};R&&E?i.autoMargin(r,t._id,ee):R?r._fullLayout._reservedMargin[t._id][de]=be[de]:E||o?r._fullLayout._reservedMargin[t._id][he]=be[he]:r._fullLayout._reservedMargin[t._id][de]=be[de]}],r)}(r,t,e);m&&m.then&&(e._promises||[]).push(m),e._context.edits.colorbarPosition&&function(e,t,r){var n,a,i,l="v"===t.orientation,c=r._fullLayout._size;s.init({element:e.node(),gd:r,prepFn:function(){n=e.attr("transform"),d(e)},moveFn:function(r,o){e.attr("transform",n+u(r,o)),a=s.align((l?t._uFrac:t._vFrac)+r/c.w,l?t._thickFrac:t._lenFrac,0,1,t.xanchor),i=s.align((l?t._vFrac:1-t._uFrac)-o/c.h,l?t._lenFrac:t._thickFrac,0,1,t.yanchor);var f=s.getCursor(a,i,t.xanchor,t.yanchor);d(e,f)},doneFn:function(){if(d(e),void 0!==a&&void 0!==i){var n={};n[t._propPrefix+"x"]=a,n[t._propPrefix+"y"]=i,void 0!==t._traceIndex?o.call("_guiRestyle",r,n,t._traceIndex):o.call("_guiRelayout",r,n)}}})}(r,t,e)})),t.exit().each((function(t){i.autoMargin(e,t._id)})).remove(),t.order()}}},1362:function(e,t,r){"use strict";var n=r(4809);e.exports=function(e){return n.isPlainObject(e.colorbar)}},6919:function(e,t,r){"use strict";e.exports={moduleType:"component",name:"colorbar",attributes:r(5158),supplyDefaults:r(2097),draw:r(5881).draw,hasColorbar:r(1362)}},7163:function(e,t,r){"use strict";var n=r(5158),a=r(694).counter,i=r(2994),o=r(9017).scales;function l(e){return"`"+e+"`"}i(o),e.exports=function(e,t){e=e||"";var r,i=(t=t||{}).cLetter||"c",s=("onlyIfNumerical"in t?t.onlyIfNumerical:Boolean(e),"noScale"in t?t.noScale:"marker.line"===e),c="showScaleDflt"in t?t.showScaleDflt:"z"===i,u="string"==typeof t.colorscaleDflt?o[t.colorscaleDflt]:null,f=t.editTypeOverride||"",d=e?e+".":"";"colorAttr"in t?(r=t.colorAttr,t.colorAttr):l(d+(r={z:"z",c:"color"}[i]));var h=i+"auto",p=i+"min",v=i+"max",g=i+"mid",m=(l(d+h),l(d+p),l(d+v),{});m[p]=m[v]=void 0;var y={};y[h]=!1;var x={};return"color"===r&&(x.color={valType:"color",arrayOk:!0,editType:f||"style"},t.anim&&(x.color.anim=!0)),x[h]={valType:"boolean",dflt:!0,editType:"calc",impliedEdits:m},x[p]={valType:"number",dflt:null,editType:f||"plot",impliedEdits:y},x[v]={valType:"number",dflt:null,editType:f||"plot",impliedEdits:y},x[g]={valType:"number",dflt:null,editType:"calc",impliedEdits:m},x.colorscale={valType:"colorscale",editType:"calc",dflt:u,impliedEdits:{autocolorscale:!1}},x.autocolorscale={valType:"boolean",dflt:!1!==t.autoColorDflt,editType:"calc",impliedEdits:{colorscale:void 0}},x.reversescale={valType:"boolean",dflt:!1,editType:"plot"},s||(x.showscale={valType:"boolean",dflt:c,editType:"calc"},x.colorbar=n),t.noColorAxis||(x.coloraxis={valType:"subplotid",regex:a("coloraxis"),dflt:null,editType:"calc"}),x}},8379:function(e,t,r){"use strict";var n=r(721),a=r(4809),i=r(5477).extractOpts;e.exports=function(e,t,r){var o,l=e._fullLayout,s=r.vals,c=r.containerStr,u=c?a.nestedProperty(t,c).get():t,f=i(u),d=!1!==f.auto,h=f.min,p=f.max,v=f.mid,g=function(){return a.aggNums(Math.min,null,s)},m=function(){return a.aggNums(Math.max,null,s)};void 0===h?h=g():d&&(h=u._colorAx&&n(h)?Math.min(h,g()):g()),void 0===p?p=m():d&&(p=u._colorAx&&n(p)?Math.max(p,m()):m()),d&&void 0!==v&&(p-v>v-h?h=v-(p-v):p-v=0?l.colorscale.sequential:l.colorscale.sequentialminus,f._sync("colorscale",o))}},7623:function(e,t,r){"use strict";var n=r(4809),a=r(5477).hasColorscale,i=r(5477).extractOpts;e.exports=function(e,t){function r(e,t){var r=e["_"+t];void 0!==r&&(e[t]=r)}function o(e,a){var o=a.container?n.nestedProperty(e,a.container).get():e;if(o)if(o.coloraxis)o._colorAx=t[o.coloraxis];else{var l=i(o),s=l.auto;(s||void 0===l.min)&&r(o,a.min),(s||void 0===l.max)&&r(o,a.max),l.autocolorscale&&r(o,"colorscale")}}for(var l=0;l=0;n--,a++){var i=e[n];r[a]=[1-i[0],i[1]]}return r}function p(e,t){t=t||{};for(var r=e.domain,o=e.range,s=o.length,c=new Array(s),u=0;u4/3-l?o:l}},4001:function(e,t,r){"use strict";var n=r(4809),a=[["sw-resize","s-resize","se-resize"],["w-resize","move","e-resize"],["nw-resize","n-resize","ne-resize"]];e.exports=function(e,t,r,i){return e="left"===r?0:"center"===r?1:"right"===r?2:n.constrain(Math.floor(3*e),0,2),t="bottom"===i?0:"middle"===i?1:"top"===i?2:n.constrain(Math.floor(3*t),0,2),a[t][e]}},414:function(e,t){"use strict";t.selectMode=function(e){return"lasso"===e||"select"===e},t.drawMode=function(e){return"drawclosedpath"===e||"drawopenpath"===e||"drawline"===e||"drawrect"===e||"drawcircle"===e},t.openMode=function(e){return"drawline"===e||"drawopenpath"===e},t.rectMode=function(e){return"select"===e||"drawline"===e||"drawrect"===e||"drawcircle"===e},t.freeMode=function(e){return"lasso"===e||"drawclosedpath"===e||"drawopenpath"===e},t.selectingOrDrawing=function(e){return t.freeMode(e)||t.rectMode(e)}},4751:function(e,t,r){"use strict";var n=r(4039),a=r(9784),i=r(4043),o=r(4809).removeElement,l=r(4826),s=e.exports={};s.align=r(3770),s.getCursor=r(4001);var c=r(148);function u(){var e=document.createElement("div");e.className="dragcover";var t=e.style;return t.position="fixed",t.left=0,t.right=0,t.top=0,t.bottom=0,t.zIndex=999999999,t.background="none",document.body.appendChild(e),e}function f(e){return n(e.changedTouches?e.changedTouches[0]:e,document.body)}s.unhover=c.wrapped,s.unhoverRaw=c.raw,s.init=function(e){var t,r,n,c,d,h,p,v,g=e.gd,m=1,y=g._context.doubleClickDelay,x=e.element;g._mouseDownTime||(g._mouseDownTime=0),x.style.pointerEvents="all",x.onmousedown=_,i?(x._ontouchstart&&x.removeEventListener("touchstart",x._ontouchstart),x._ontouchstart=_,x.addEventListener("touchstart",_,{passive:!1})):x.ontouchstart=_;var b=e.clampFn||function(e,t,r){return Math.abs(e)y&&(m=Math.max(m-1,1)),g._dragged)e.doneFn&&e.doneFn();else if(e.clickFn&&e.clickFn(m,h),!v){var r;try{r=new MouseEvent("click",t)}catch(e){var n=f(t);(r=document.createEvent("MouseEvents")).initMouseEvent("click",t.bubbles,t.cancelable,t.view,t.detail,t.screenX,t.screenY,n[0],n[1],t.ctrlKey,t.altKey,t.shiftKey,t.metaKey,t.button,t.relatedTarget)}p.dispatchEvent(r)}g._dragging=!1,g._dragged=!1}else g._dragged=!1}},s.coverSlip=u},148:function(e,t,r){"use strict";var n=r(8596),a=r(4025),i=r(5425).getGraphDiv,o=r(5988),l=e.exports={};l.wrapped=function(e,t,r){(e=i(e))._fullLayout&&a.clear(e._fullLayout._uid+o.HOVERID),l.raw(e,t,r)},l.raw=function(e,t){var r=e._fullLayout,a=e._hoverdata;t||(t={}),t.target&&!e._dragged&&!1===n.triggerHandler(e,"plotly_beforehover",t)||(r._hoverlayer.selectAll("g").remove(),r._hoverlayer.selectAll("line").remove(),r._hoverlayer.selectAll("circle").remove(),e._hoverdata=void 0,t.target&&a&&e.emit("plotly_unhover",{event:t,points:a}))}},4850:function(e,t){"use strict";t.T={valType:"string",values:["solid","dot","dash","longdash","dashdot","longdashdot"],dflt:"solid",editType:"style"},t.k={shape:{valType:"enumerated",values:["","/","\\","x","-","|","+","."],dflt:"",arrayOk:!0,editType:"style"},fillmode:{valType:"enumerated",values:["replace","overlay"],dflt:"replace",editType:"style"},bgcolor:{valType:"color",arrayOk:!0,editType:"style"},fgcolor:{valType:"color",arrayOk:!0,editType:"style"},fgopacity:{valType:"number",editType:"style",min:0,max:1},size:{valType:"number",min:0,dflt:8,arrayOk:!0,editType:"style"},solidity:{valType:"number",min:0,max:1,dflt:.3,arrayOk:!0,editType:"style"},editType:"style"}},2203:function(e,t,r){"use strict";var n=r(5568),a=r(4809),i=a.numberFormat,o=r(721),l=r(5657),s=r(3626),c=r(8766),u=r(8856),f=a.strTranslate,d=r(635),h=r(2972),p=r(4530).LINE_SPACING,v=r(438).DESELECTDIM,g=r(4726),m=r(2527),y=r(6040).appendArrayPointValue,x=e.exports={};function b(e){return"none"===e?void 0:e}x.font=function(e,t){var r=t.variant,n=t.style,a=t.weight,i=t.color,o=t.size,l=t.family,s=t.shadow,u=t.lineposition,f=t.textcase;l&&e.style("font-family",l),o+1&&e.style("font-size",o+"px"),i&&e.call(c.fill,i),a&&e.style("font-weight",a),n&&e.style("font-style",n),r&&e.style("font-variant",r),f&&e.style("text-transform",b(function(e){return _[e]}(f))),s&&e.style("text-shadow","auto"===s?d.makeTextShadow(c.contrast(i)):b(s)),u&&e.style("text-decoration-line",b(function(e){return e.replace("under","underline").replace("over","overline").replace("through","line-through").split("+").join(" ")}(u)))};var _={normal:"none",lower:"lowercase",upper:"uppercase","word caps":"capitalize"};function w(e,t,r,n){var a=t.fillpattern,i=t.fillgradient,o=a&&x.getPatternAttr(a.shape,0,"");if(o){var l=x.getPatternAttr(a.bgcolor,0,null),s=x.getPatternAttr(a.fgcolor,0,null),u=a.fgopacity,f=x.getPatternAttr(a.size,0,8),d=x.getPatternAttr(a.solidity,0,.3),h=t.uid;x.pattern(e,"point",r,h,o,f,d,void 0,a.fillmode,l,s,u)}else if(i&&"none"!==i.type){var p,v,g=i.type,m="scatterfill-"+t.uid;n&&(m="legendfill-"+t.uid),n||void 0===i.start&&void 0===i.stop?("horizontal"===g&&(g+="reversed"),e.call(x.gradient,r,m,g,i.colorscale,"fill")):("horizontal"===g?(p={x:i.start,y:0},v={x:i.stop,y:0}):"vertical"===g&&(p={x:0,y:i.start},v={x:0,y:i.stop}),p.x=t._xA.c2p(void 0===p.x?t._extremes.x.min[0].val:p.x,!0),p.y=t._yA.c2p(void 0===p.y?t._extremes.y.min[0].val:p.y,!0),v.x=t._xA.c2p(void 0===v.x?t._extremes.x.max[0].val:v.x,!0),v.y=t._yA.c2p(void 0===v.y?t._extremes.y.max[0].val:v.y,!0),e.call(S,r,m,"linear",i.colorscale,"fill",p,v,!0,!1))}else t.fillcolor&&e.call(c.fill,t.fillcolor)}x.setPosition=function(e,t,r){e.attr("x",t).attr("y",r)},x.setSize=function(e,t,r){e.attr("width",t).attr("height",r)},x.setRect=function(e,t,r,n,a){e.call(x.setPosition,t,r).call(x.setSize,n,a)},x.translatePoint=function(e,t,r,n){var a=r.c2p(e.x),i=n.c2p(e.y);return!!(o(a)&&o(i)&&t.node())&&("text"===t.node().nodeName?t.attr("x",a).attr("y",i):t.attr("transform",f(a,i)),!0)},x.translatePoints=function(e,t,r){e.each((function(e){var a=n.select(this);x.translatePoint(e,a,t,r)}))},x.hideOutsideRangePoint=function(e,t,r,n,a,i){t.attr("display",r.isPtWithinRange(e,a)&&n.isPtWithinRange(e,i)?null:"none")},x.hideOutsideRangePoints=function(e,t){if(t._hasClipOnAxisFalse){var r=t.xaxis,a=t.yaxis;e.each((function(t){var i=t[0].trace,o=i.xcalendar,l=i.ycalendar,c=s.traceIs(i,"bar-like")?".bartext":".point,.textpoint";e.selectAll(c).each((function(e){x.hideOutsideRangePoint(e,n.select(this),r,a,o,l)}))}))}},x.crispRound=function(e,t,r){return t&&o(t)?e._context.staticPlot?t:t<1?1:Math.round(t):r||0},x.singleLineStyle=function(e,t,r,n,a){t.style("fill","none");var i=(((e||[])[0]||{}).trace||{}).line||{},o=r||i.width||0,l=a||i.dash||"";c.stroke(t,n||i.color),x.dashLine(t,l,o)},x.lineGroupStyle=function(e,t,r,a){e.style("fill","none").each((function(e){var i=(((e||[])[0]||{}).trace||{}).line||{},o=t||i.width||0,l=a||i.dash||"";n.select(this).call(c.stroke,r||i.color).call(x.dashLine,l,o)}))},x.dashLine=function(e,t,r){r=+r||0,t=x.dashStyle(t,r),e.style({"stroke-dasharray":t,"stroke-width":r+"px"})},x.dashStyle=function(e,t){t=+t||1;var r=Math.max(t,3);return"solid"===e?e="":"dot"===e?e=r+"px,"+r+"px":"dash"===e?e=3*r+"px,"+3*r+"px":"longdash"===e?e=5*r+"px,"+5*r+"px":"dashdot"===e?e=3*r+"px,"+r+"px,"+r+"px,"+r+"px":"longdashdot"===e&&(e=5*r+"px,"+2*r+"px,"+r+"px,"+2*r+"px"),e},x.singleFillStyle=function(e,t){var r=n.select(e.node());w(e,((r.data()[0]||[])[0]||{}).trace||{},t,!1)},x.fillGroupStyle=function(e,t,r){e.style("stroke-width",0).each((function(e){var a=n.select(this);e[0].trace&&w(a,e[0].trace,t,r)}))};var T=r(8882);x.symbolNames=[],x.symbolFuncs=[],x.symbolBackOffs=[],x.symbolNeedLines={},x.symbolNoDot={},x.symbolNoFill={},x.symbolList=[],Object.keys(T).forEach((function(e){var t=T[e],r=t.n;x.symbolList.push(r,String(r),e,r+100,String(r+100),e+"-open"),x.symbolNames[r]=e,x.symbolFuncs[r]=t.f,x.symbolBackOffs[r]=t.backoff||0,t.needLine&&(x.symbolNeedLines[r]=!0),t.noDot?x.symbolNoDot[r]=!0:x.symbolList.push(r+200,String(r+200),e+"-dot",r+300,String(r+300),e+"-open-dot"),t.noFill&&(x.symbolNoFill[r]=!0)}));var M=x.symbolNames.length;function k(e,t,r,n){var a=e%100;return x.symbolFuncs[a](t,r,n)+(e>=200?"M0,0.5L0.5,0L0,-0.5L-0.5,0Z":"")}x.symbolNumber=function(e){if(o(e))e=+e;else if("string"==typeof e){var t=0;e.indexOf("-open")>0&&(t=100,e=e.replace("-open","")),e.indexOf("-dot")>0&&(t+=200,e=e.replace("-dot","")),(e=x.symbolNames.indexOf(e))>=0&&(e+=t)}return e%100>=M||e>=400?0:Math.floor(Math.max(e,0))};var A=i("~f"),L={radial:{type:"radial"},radialreversed:{type:"radial",reversed:!0},horizontal:{type:"linear",start:{x:1,y:0},stop:{x:0,y:0}},horizontalreversed:{type:"linear",start:{x:1,y:0},stop:{x:0,y:0},reversed:!0},vertical:{type:"linear",start:{x:0,y:1},stop:{x:0,y:0}},verticalreversed:{type:"linear",start:{x:0,y:1},stop:{x:0,y:0},reversed:!0}};function S(e,t,r,i,o,s,u,f,d,h){var p,v=o.length;"linear"===i?p={node:"linearGradient",attrs:{x1:u.x,y1:u.y,x2:f.x,y2:f.y,gradientUnits:d?"userSpaceOnUse":"objectBoundingBox"},reversed:h}:"radial"===i&&(p={node:"radialGradient",reversed:h});for(var g=new Array(v),m=0;m=0&&void 0===e.i&&(e.i=o.i),t.style("opacity",n.selectedOpacityFn?n.selectedOpacityFn(e):void 0===e.mo?l.opacity:e.mo),n.ms2mrc){var u;u="various"===e.ms||"various"===l.size?3:n.ms2mrc(e.ms),e.mrc=u,n.selectedSizeFn&&(u=e.mrc=n.selectedSizeFn(e));var f=x.symbolNumber(e.mx||l.symbol)||0;e.om=f%200>=100;var d=ne(e,r),h=G(e,r);t.attr("d",k(f,u,d,h))}var p,v,g,m=!1;if(e.so)g=s.outlierwidth,v=s.outliercolor,p=l.outliercolor;else{var y=(s||{}).width;g=(e.mlw+1||y+1||(e.trace?(e.trace.marker.line||{}).width:0)+1)-1||0,v="mlc"in e?e.mlcc=n.lineScale(e.mlc):a.isArrayOrTypedArray(s.color)?c.defaultLine:s.color,a.isArrayOrTypedArray(l.color)&&(p=c.defaultLine,m=!0),p="mc"in e?e.mcc=n.markerScale(e.mc):l.color||l.colors||"rgba(0,0,0,0)",n.selectedColorFn&&(p=n.selectedColorFn(e))}if(e.om)t.call(c.stroke,p).style({"stroke-width":(g||1)+"px",fill:"none"});else{t.style("stroke-width",(e.isBlank?0:g)+"px");var b=l.gradient,_=e.mgt;_?m=!0:_=b&&b.type,a.isArrayOrTypedArray(_)&&(_=_[0],L[_]||(_=0));var w=l.pattern,T=w&&x.getPatternAttr(w.shape,e.i,"");if(_&&"none"!==_){var M=e.mgc;M?m=!0:M=b.color;var A=r.uid;m&&(A+="-"+e.i),x.gradient(t,i,A,_,[[0,M],[1,p]],"fill")}else if(T){var S=!1,C=w.fgcolor;!C&&o&&o.color&&(C=o.color,S=!0);var O=x.getPatternAttr(C,e.i,o&&o.color||null),D=x.getPatternAttr(w.bgcolor,e.i,null),P=w.fgopacity,z=x.getPatternAttr(w.size,e.i,8),I=x.getPatternAttr(w.solidity,e.i,.3);S=S||e.mcc||a.isArrayOrTypedArray(w.shape)||a.isArrayOrTypedArray(w.bgcolor)||a.isArrayOrTypedArray(w.fgcolor)||a.isArrayOrTypedArray(w.size)||a.isArrayOrTypedArray(w.solidity);var E=r.uid;S&&(E+="-"+e.i),x.pattern(t,"point",i,E,T,z,I,e.mcc,w.fillmode,D,O,P)}else a.isArrayOrTypedArray(p)?c.fill(t,p[e.i]):c.fill(t,p);g&&c.stroke(t,v)}},x.makePointStyleFns=function(e){var t={},r=e.marker;return t.markerScale=x.tryColorscale(r,""),t.lineScale=x.tryColorscale(r,"line"),s.traceIs(e,"symbols")&&(t.ms2mrc=g.isBubble(e)?m(e):function(){return(r.size||6)/2}),e.selectedpoints&&a.extendFlat(t,x.makeSelectedPointStyleFns(e)),t},x.makeSelectedPointStyleFns=function(e){var t={},r=e.selected||{},n=e.unselected||{},i=e.marker||{},o=r.marker||{},l=n.marker||{},c=i.opacity,u=o.opacity,f=l.opacity,d=void 0!==u,h=void 0!==f;(a.isArrayOrTypedArray(c)||d||h)&&(t.selectedOpacityFn=function(e){var t=void 0===e.mo?i.opacity:e.mo;return e.selected?d?u:t:h?f:v*t});var p=i.color,g=o.color,m=l.color;(g||m)&&(t.selectedColorFn=function(e){var t=e.mcc||p;return e.selected?g||t:m||t});var y=i.size,x=o.size,b=l.size,_=void 0!==x,w=void 0!==b;return s.traceIs(e,"symbols")&&(_||w)&&(t.selectedSizeFn=function(e){var t=e.mrc||y/2;return e.selected?_?x/2:t:w?b/2:t}),t},x.makeSelectedTextStyleFns=function(e){var t={},r=e.selected||{},n=e.unselected||{},a=e.textfont||{},i=r.textfont||{},o=n.textfont||{},l=a.color,s=i.color,u=o.color;return t.selectedTextColorFn=function(e){var t=e.tc||l;return e.selected?s||t:u||(s?t:c.addOpacity(t,v))},t},x.selectedPointStyle=function(e,t){if(e.size()&&t.selectedpoints){var r=x.makeSelectedPointStyleFns(t),a=t.marker||{},i=[];r.selectedOpacityFn&&i.push((function(e,t){e.style("opacity",r.selectedOpacityFn(t))})),r.selectedColorFn&&i.push((function(e,t){c.fill(e,r.selectedColorFn(t))})),r.selectedSizeFn&&i.push((function(e,n){var i=n.mx||a.symbol||0,o=r.selectedSizeFn(n);e.attr("d",k(x.symbolNumber(i),o,ne(n,t),G(n,t))),n.mrc2=o})),i.length&&e.each((function(e){for(var t=n.select(this),r=0;r0?r:0}function I(e,t,r){return r&&(e=B(e)),t?R(e[1]):E(e[0])}function E(e){var t=n.round(e,2);return C=t,t}function R(e){var t=n.round(e,2);return O=t,t}function N(e,t,r,n){var a=e[0]-t[0],i=e[1]-t[1],o=r[0]-t[0],l=r[1]-t[1],s=Math.pow(a*a+i*i,.25),c=Math.pow(o*o+l*l,.25),u=(c*c*a-s*s*o)*n,f=(c*c*i-s*s*l)*n,d=3*c*(s+c),h=3*s*(s+c);return[[E(t[0]+(d&&u/d)),R(t[1]+(d&&f/d))],[E(t[0]-(h&&u/h)),R(t[1]-(h&&f/h))]]}x.textPointStyle=function(e,t,r){if(e.size()){var i;if(t.selectedpoints){var o=x.makeSelectedTextStyleFns(t);i=o.selectedTextColorFn}var l=t.texttemplate,s=r._fullLayout;e.each((function(e){var o=n.select(this),c=l?a.extractOption(e,t,"txt","texttemplate"):a.extractOption(e,t,"tx","text");if(c||0===c){if(l){var u=t._module.formatLabels,f=u?u(e,t,s):{},h={};y(h,t,e.i);var p=t._meta||{};c=a.texttemplateString(c,f,s._d3locale,h,e,p)}var v=e.tp||t.textposition,g=z(e,t),m=i?i(e):e.tc||t.textfont.color;o.call(x.font,{family:e.tf||t.textfont.family,weight:e.tw||t.textfont.weight,style:e.ty||t.textfont.style,variant:e.tv||t.textfont.variant,textcase:e.tC||t.textfont.textcase,lineposition:e.tE||t.textfont.lineposition,shadow:e.tS||t.textfont.shadow,size:g,color:m}).text(c).call(d.convertToTspans,r).call(P,v,g,e.mrc)}else o.remove()}))}},x.selectedTextStyle=function(e,t){if(e.size()&&t.selectedpoints){var r=x.makeSelectedTextStyleFns(t);e.each((function(e){var a=n.select(this),i=r.selectedTextColorFn(e),o=e.tp||t.textposition,l=z(e,t);c.fill(a,i);var u=s.traceIs(t,"bar-like");P(a,o,l,e.mrc2||e.mrc,u)}))}},x.smoothopen=function(e,t){if(e.length<3)return"M"+e.join("L");var r,n="M"+e[0],a=[];for(r=1;r=c||w>=f&&w<=c)&&(T<=d&&T>=u||T>=d&&T<=u)&&(e=[w,T])}return e}x.steps=function(e){var t=F[e]||H;return function(e){for(var r="M"+E(e[0][0])+","+R(e[0][1]),n=e.length,a=1;a=1e4&&(x.savedBBoxes={},j=0),r&&(x.savedBBoxes[r]=g),j++,a.extendFlat({},g)},x.setClipUrl=function(e,t,r){e.attr("clip-path",V(t,r))},x.getTranslate=function(e){var t=(e[e.attr?"attr":"getAttribute"]("transform")||"").replace(/.*\btranslate\((-?\d*\.?\d*)[^-\d]*(-?\d*\.?\d*)[^\d].*/,(function(e,t,r){return[t,r].join(" ")})).split(" ");return{x:+t[0]||0,y:+t[1]||0}},x.setTranslate=function(e,t,r){var n=e.attr?"attr":"getAttribute",a=e.attr?"attr":"setAttribute",i=e[n]("transform")||"";return t=t||0,r=r||0,i=i.replace(/(\btranslate\(.*?\);?)/,"").trim(),i=(i+=f(t,r)).trim(),e[a]("transform",i),i},x.getScale=function(e){var t=(e[e.attr?"attr":"getAttribute"]("transform")||"").replace(/.*\bscale\((\d*\.?\d*)[^\d]*(\d*\.?\d*)[^\d].*/,(function(e,t,r){return[t,r].join(" ")})).split(" ");return{x:+t[0]||1,y:+t[1]||1}},x.setScale=function(e,t,r){var n=e.attr?"attr":"getAttribute",a=e.attr?"attr":"setAttribute",i=e[n]("transform")||"";return t=t||1,r=r||1,i=i.replace(/(\bscale\(.*?\);?)/,"").trim(),i=(i+="scale("+t+","+r+")").trim(),e[a]("transform",i),i};var U=/\s*sc.*/;x.setPointGroupScale=function(e,t,r){if(t=t||1,r=r||1,e){var n=1===t&&1===r?"":"scale("+t+","+r+")";e.each((function(){var e=(this.getAttribute("transform")||"").replace(U,"");e=(e+=n).trim(),this.setAttribute("transform",e)}))}};var q=/translate\([^)]*\)\s*$/;function G(e,t){var r;return e&&(r=e.mf),void 0===r&&(r=t.marker&&t.marker.standoff||0),t._geo||t._xA?r:-r}x.setTextPointsScale=function(e,t,r){e&&e.each((function(){var e,a=n.select(this),i=a.select("text");if(i.node()){var o=parseFloat(i.attr("x")||0),l=parseFloat(i.attr("y")||0),s=(a.attr("transform")||"").match(q);e=1===t&&1===r?[]:[f(o,l),"scale("+t+","+r+")",f(-o,-l)],s&&e.push(s),a.attr("transform",e.join(""))}}))},x.getMarkerStandoff=G;var Z,W,J,X,K,Q,$=Math.atan2,ee=Math.cos,te=Math.sin;function re(e,t){var r=t[0],n=t[1];return[r*ee(e)-n*te(e),r*te(e)+n*ee(e)]}function ne(e,t){var r,n,i=e.ma;void 0===i&&((i=t.marker.angle)&&!a.isArrayOrTypedArray(i)||(i=0));var l=t.marker.angleref;if("previous"===l||"north"===l){if(t._geo){var s=t._geo.project(e.lonlat);r=s[0],n=s[1]}else{var c=t._xA,u=t._yA;if(!c||!u)return 90;r=c.c2p(e.x),n=u.c2p(e.y)}if(t._geo){var f,d=e.lonlat[0],h=e.lonlat[1],p=t._geo.project([d,h+1e-5]),v=t._geo.project([d+1e-5,h]),g=$(v[1]-n,v[0]-r),m=$(p[1]-n,p[0]-r);if("north"===l)f=i/180*Math.PI;else if("previous"===l){var y=d/180*Math.PI,x=h/180*Math.PI,b=Z/180*Math.PI,_=W/180*Math.PI,w=b-y,T=ee(_)*te(w),M=te(_)*ee(x)-ee(_)*te(x)*ee(w);f=-$(T,M)-Math.PI,Z=d,W=h}var k=re(g,[ee(f),0]),A=re(m,[te(f),0]);i=$(k[1]+A[1],k[0]+A[0])/Math.PI*180,"previous"!==l||Q===t.uid&&e.i===K+1||(i=null)}if("previous"===l&&!t._geo)if(Q===t.uid&&e.i===K+1&&o(r)&&o(n)){var L=r-J,S=n-X,C=t.line&&t.line.shape||"",O=C.slice(C.length-1);"h"===O&&(S=0),"v"===O&&(L=0),i+=$(S,L)/Math.PI*180+90}else i=null}return J=r,X=n,K=e.i,Q=t.uid,i}x.getMarkerAngle=ne},8882:function(e,t,r){"use strict";var n,a,i,o,l=r(6953),s=r(5568).round,c="M0,0Z",u=Math.sqrt(2),f=Math.sqrt(3),d=Math.PI,h=Math.cos,p=Math.sin;function v(e){return null===e}function g(e,t,r){if(!(e&&e%360!=0||t))return r;if(i===e&&o===t&&n===r)return a;function s(e,r){var n=h(e),a=p(e),i=r[0],o=r[1]+(t||0);return[i*n-o*a,i*a+o*n]}i=e,o=t,n=r;for(var c=e/180*d,u=0,f=0,v=l(r),g="",m=0;m0,f=e._context.staticPlot;t.each((function(t){var d,h=t[0].trace,p=h.error_x||{},v=h.error_y||{};h.ids&&(d=function(e){return e.id});var g=o.hasMarkers(h)&&h.marker.maxdisplayed>0;v.visible||p.visible||(t=[]);var m=n.select(this).selectAll("g.errorbar").data(t,d);if(m.exit().remove(),t.length){p.visible||m.selectAll("path.xerror").remove(),v.visible||m.selectAll("path.yerror").remove(),m.style("opacity",1);var y=m.enter().append("g").classed("errorbar",!0);u&&y.style("opacity",0).transition().duration(l.duration).style("opacity",1),i.setClipUrl(m,r.layerClipId,e),m.each((function(e){var t=n.select(this),r=function(e,t,r){var n={x:t.c2p(e.x),y:r.c2p(e.y)};return void 0!==e.yh&&(n.yh=r.c2p(e.yh),n.ys=r.c2p(e.ys),a(n.ys)||(n.noYS=!0,n.ys=r.c2p(e.ys,!0))),void 0!==e.xh&&(n.xh=t.c2p(e.xh),n.xs=t.c2p(e.xs),a(n.xs)||(n.noXS=!0,n.xs=t.c2p(e.xs,!0))),n}(e,s,c);if(!g||e.vis){var i,o=t.select("path.yerror");if(v.visible&&a(r.x)&&a(r.yh)&&a(r.ys)){var d=v.width;i="M"+(r.x-d)+","+r.yh+"h"+2*d+"m-"+d+",0V"+r.ys,r.noYS||(i+="m-"+d+",0h"+2*d),o.size()?u&&(o=o.transition().duration(l.duration).ease(l.easing)):o=t.append("path").style("vector-effect",f?"none":"non-scaling-stroke").classed("yerror",!0),o.attr("d",i)}else o.remove();var h=t.select("path.xerror");if(p.visible&&a(r.y)&&a(r.xh)&&a(r.xs)){var m=(p.copy_ystyle?v:p).width;i="M"+r.xh+","+(r.y-m)+"v"+2*m+"m0,-"+m+"H"+r.xs,r.noXS||(i+="m0,-"+m+"v"+2*m),h.size()?u&&(h=h.transition().duration(l.duration).ease(l.easing)):h=t.append("path").style("vector-effect",f?"none":"non-scaling-stroke").classed("xerror",!0),h.attr("d",i)}else h.remove()}}))}}))}},2800:function(e,t,r){"use strict";var n=r(5568),a=r(8766);e.exports=function(e){e.each((function(e){var t=e[0].trace,r=t.error_y||{},i=t.error_x||{},o=n.select(this);o.selectAll("path.yerror").style("stroke-width",r.thickness+"px").call(a.stroke,r.color),i.copy_ystyle&&(i=r),o.selectAll("path.xerror").style("stroke-width",i.thickness+"px").call(a.stroke,i.color)}))}},192:function(e,t,r){"use strict";var n=r(337),a=r(6811).hoverlabel,i=r(3049).extendFlat;e.exports={hoverlabel:{bgcolor:i({},a.bgcolor,{arrayOk:!0}),bordercolor:i({},a.bordercolor,{arrayOk:!0}),font:n({arrayOk:!0,editType:"none"}),align:i({},a.align,{arrayOk:!0}),namelength:i({},a.namelength,{arrayOk:!0}),editType:"none"}}},3552:function(e,t,r){"use strict";var n=r(4809),a=r(3626);function i(e,t,r,a){a=a||n.identity,Array.isArray(e)&&(t[0][r]=a(e))}e.exports=function(e){var t=e.calcdata,r=e._fullLayout;function o(e){return function(t){return n.coerceHoverinfo({hoverinfo:t},{_module:e._module},r)}}for(var l=0;l=0&&r.indexX[0]._length||_e<0||_e>K[0]._length)return v.unhoverRaw(e,t)}else be="xpx"in t?t.xpx:X[0]._length/2,_e="ypx"in t?t.ypx:K[0]._length/2;if(t.pointerX=be+X[0]._offset,t.pointerY=_e+K[0]._offset,ne="xval"in t?x.flat(b,t.xval):x.p2c(X,be),ae="yval"in t?x.flat(b,t.yval):x.p2c(K,_e),!a(ne[0])||!a(ae[0]))return o.warn("Fx.hover failed",t,e),v.unhoverRaw(e,t)}var ke=1/0;function Ae(r,n){for(oe=0;oeve&&(ge.splice(0,ve),ke=ge[0].distance),A&&0!==re&&0===ge.length){pe.distance=re,pe.index=!1;var u=se._module.hoverPoints(pe,de,he,"closest",{hoverLayer:_._hoverlayer});if(u&&(u=u.filter((function(e){return e.spikeDistance<=re}))),u&&u.length){var f,d=u.filter((function(e){return e.xa.showspikes&&"hovered data"!==e.xa.spikesnap}));if(d.length){var h=d[0];a(h.x0)&&a(h.y0)&&(f=Se(h),(!ye.vLinePoint||ye.vLinePoint.spikeDistance>f.spikeDistance)&&(ye.vLinePoint=f))}var v=u.filter((function(e){return e.ya.showspikes&&"hovered data"!==e.ya.spikesnap}));if(v.length){var g=v[0];a(g.x0)&&a(g.y0)&&(f=Se(g),(!ye.hLinePoint||ye.hLinePoint.spikeDistance>f.spikeDistance)&&(ye.hLinePoint=f))}}}}}function Le(e,t,r){for(var n,a=null,i=1/0,o=0;o0&&Math.abs(e.distance)He-1;Be--)Ue(ge[Be]);ge=je,Pe()}var qe=e._hoverdata,Ge=[],Ze=U(e),We=q(e);for(ie=0;ie1||ge.length>1)||"closest"===L&&xe&&ge.length>1,lt=p.combine(_.plot_bgcolor||p.background,_.paper_bgcolor),st=E(ge,{gd:e,hovermode:L,rotateLabels:ot,bgColor:lt,container:_._hoverlayer,outerContainer:_._paper.node(),commonLabelOpts:_.hoverlabel,hoverdistance:_.hoverdistance}),ct=st.hoverLabels;if(x.isUnifiedHover(L)||(function(e,t,r,n){var a,i,o,l,s,c,u,f=t?"xa":"ya",d=t?"ya":"xa",h=0,p=1,v=e.size(),g=new Array(v),m=0,y=n.minX,x=n.maxX,b=n.minY,_=n.maxY,w=function(e){return e*r._invScaleX},T=function(e){return e*r._invScaleY};function M(e){var t=e[0],r=e[e.length-1];if(i=t.pmin-t.pos-t.dp+t.size,o=r.pos+r.dp+r.size-t.pmax,i>.01){for(s=e.length-1;s>=0;s--)e[s].dp+=i;a=!1}if(!(o<.01)){if(i<-.01){for(s=e.length-1;s>=0;s--)e[s].dp-=o;a=!1}if(a){var n=0;for(l=0;lt.pmax&&n++;for(l=e.length-1;l>=0&&!(n<=0);l--)(c=e[l]).pos>t.pmax-1&&(c.del=!0,n--);for(l=0;l=0;s--)e[s].dp-=o;for(l=e.length-1;l>=0&&!(n<=0);l--)(c=e[l]).pos+c.dp+c.size>t.pmax&&(c.del=!0,n--)}}}for(e.each((function(e){var n=e[f],a=e[d],i="x"===n._id.charAt(0),o=n.range;0===m&&o&&o[0]>o[1]!==i&&(p=-1);var l=0,s=i?r.width:r.height;if("x"===r.hovermode||"y"===r.hovermode){var c,u,h=N(e,t),v=e.anchor,M="end"===v?-1:1;if("middle"===v)u=(c=e.crossPos+(i?T(h.y-e.by/2):w(e.bx/2+e.tx2width/2)))+(i?T(e.by):w(e.bx));else if(i)u=(c=e.crossPos+T(S+h.y)-T(e.by/2-S))+T(e.by);else{var A=w(M*S+h.x),L=A+w(M*e.bx);c=e.crossPos+Math.min(A,L),u=e.crossPos+Math.max(A,L)}i?void 0!==b&&void 0!==_&&Math.min(u,_)-Math.max(c,b)>1&&("left"===a.side?(l=a._mainLinePosition,s=r.width):s=a._mainLinePosition):void 0!==y&&void 0!==x&&Math.min(u,x)-Math.max(c,y)>1&&("top"===a.side?(l=a._mainLinePosition,s=r.height):s=a._mainLinePosition)}g[m++]=[{datum:e,traceIndex:e.trace.index,dp:0,pos:e.pos,posref:e.posref,size:e.by*(i?k:1)/2,pmin:l,pmax:s}]})),g.sort((function(e,t){return e[0].posref-t[0].posref||p*(t[0].traceIndex-e[0].traceIndex)}));!a&&h<=v;){for(h++,a=!0,l=0;l.01){for(s=L.length-1;s>=0;s--)L[s].dp+=i;for(A.push.apply(A,L),g.splice(l+1,1),u=0,s=A.length-1;s>=0;s--)u+=A[s].dp;for(o=u/A.length,s=A.length-1;s>=0;s--)A[s].dp-=o;a=!1}else l++}g.forEach(M)}for(l=g.length-1;l>=0;l--){var D=g[l];for(s=D.length-1;s>=0;s--){var P=D[s],z=P.datum;z.offset=P.dp,z.del=P.del}}}(ct,ot,_,st.commonLabelBoundingBox),F(ct,ot,_._invScaleX,_._invScaleY)),s&&s.tagName){var ut=y.getComponentMethod("annotations","hasClickToShow")(e,Ge);d(n.select(s),ut?"pointer":"")}s&&!i&&function(e,t,r){if(!r||r.length!==e._hoverdata.length)return!0;for(var n=r.length-1;n>=0;n--){var a=r[n],i=e._hoverdata[n];if(a.curveNumber!==i.curveNumber||String(a.pointNumber)!==String(i.pointNumber)||String(a.pointNumbers)!==String(i.pointNumbers))return!0}return!1}(e,0,qe)&&(qe&&e.emit("plotly_unhover",{event:t,points:qe}),e.emit("plotly_hover",{event:t,points:e._hoverdata,xaxes:X,yaxes:K,xvals:ne,yvals:ae}))}(e,t,r,i,s)}))},t.loneHover=function(e,t){var r=!0;Array.isArray(e)||(r=!1,e=[e]);var a=t.gd,i=U(a),o=q(a),l=E(e.map((function(e){var r=e._x0||e.x0||e.x||0,n=e._x1||e.x1||e.x||0,l=e._y0||e.y0||e.y||0,s=e._y1||e.y1||e.y||0,c=e.eventData;if(c){var u=Math.min(r,n),f=Math.max(r,n),d=Math.min(l,s),h=Math.max(l,s),v=e.trace;if(y.traceIs(v,"gl3d")){var g=a._fullLayout[v.scene]._scene.container,m=g.offsetLeft,x=g.offsetTop;u+=m,f+=m,d+=x,h+=x}c.bbox={x0:u+o,x1:f+o,y0:d+i,y1:h+i},t.inOut_bbox&&t.inOut_bbox.push(c.bbox)}else c=!1;return{color:e.color||p.defaultLine,x0:e.x0||e.x||0,x1:e.x1||e.x||0,y0:e.y0||e.y||0,y1:e.y1||e.y||0,xLabel:e.xLabel,yLabel:e.yLabel,zLabel:e.zLabel,text:e.text,name:e.name,idealAlign:e.idealAlign,borderColor:e.borderColor,fontFamily:e.fontFamily,fontSize:e.fontSize,fontColor:e.fontColor,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontVariant:e.fontVariant,nameLength:e.nameLength,textAlign:e.textAlign,trace:e.trace||{index:0,hoverinfo:""},xa:{_offset:0},ya:{_offset:0},index:0,hovertemplate:e.hovertemplate||!1,hovertemplateLabels:e.hovertemplateLabels||!1,eventData:c}})),{gd:a,hovermode:"closest",rotateLabels:!1,bgColor:t.bgColor||p.background,container:n.select(t.container),outerContainer:t.outerContainer||t.container}).hoverLabels,s=0,c=0;return l.sort((function(e,t){return e.y0-t.y0})).each((function(e,r){var n=e.y0-e.by/2;e.offset=n-5([\s\S]*)<\/extra>/;function E(e,t){var r=t.gd,a=r._fullLayout,i=t.hovermode,l=t.rotateLabels,u=t.bgColor,d=t.container,v=t.outerContainer,g=t.commonLabelOpts||{};if(0===e.length)return[[]];var m=t.fontFamily||b.HOVERFONT,M=t.fontSize||b.HOVERFONTSIZE,k=t.fontWeight||a.font.weight,A=t.fontStyle||a.font.style,L=t.fontVariant||a.font.variant,O=t.fontTextcase||a.font.textcase,D=t.fontLineposition||a.font.lineposition,P=t.fontShadow||a.font.shadow,I=e[0],E=I.xa,N=I.ya,F=i.charAt(0),H=F+"Label",B=I[H];if(void 0===B&&"multicategory"===E.type)for(var j=0;ja.width-T&&(z=a.width-T),t.attr("d","M"+(x-z)+",0L"+(x-z+S)+","+w+S+"H"+T+"v"+w+(2*C+_.height)+"H"+-T+"V"+w+S+"H"+(x-z-S)+"Z"),x=z,$.minX=x-T,$.maxX=x+T,"top"===E.side?($.minY=b-(2*C+_.height),$.maxY=b-C):($.minY=b+C,$.maxY=b+(2*C+_.height))}else{var R,F,H;"right"===N.side?(R="start",F=1,H="",x=E._offset+E._length):(R="end",F=-1,H="-",x=E._offset),b=N._offset+(I.y0+I.y1)/2,l.attr("text-anchor",R),t.attr("d","M0,0L"+H+S+","+S+"V"+(C+_.height/2)+"h"+H+(2*C+_.width)+"V-"+(C+_.height/2)+"H"+H+S+"V-"+S+"Z"),$.minY=b-(C+_.height/2),$.maxY=b+(C+_.height/2),"right"===N.side?($.minX=x+S,$.maxX=x+S+(2*C+_.width)):($.minX=x-S-(2*C+_.width),$.maxX=x-S);var j,Y=_.height/2,U=V-_.top-Y,q="clip"+a._uid+"commonlabel"+N._id;if(x<_.width+2*C+S){j="M-"+(S+C)+"-"+Y+"h-"+(_.width-C)+"V"+Y+"h"+(_.width-C)+"Z";var Z=_.width-x+C;f.positionText(l,Z,U),"end"===R&&l.selectAll("tspan").each((function(){var e=n.select(this),t=h.tester.append("text").text(e.text()).call(h.font,y),a=G(r,t.node());Math.round(a.width)=0?pe:ve+ye=0?ve:Ae+ye=0?de:he+xe=0?he:Le+xe=0,"top"!==e.idealAlign&&K||!Q?K?(H+=Y/2,e.anchor="start"):e.anchor="middle":(H-=Y/2,e.anchor="end"),e.crossPos=H;else{if(e.pos=H,K=F+j/2+$<=U,Q=F-j/2-$>=0,"left"!==e.idealAlign&&K||!Q)if(K)F+=j/2,e.anchor="start";else{e.anchor="middle";var ee=$/2,te=F+ee-U,re=F-ee;te>0&&(F-=te),re<0&&(F+=-re)}else F-=j/2,e.anchor="end";e.crossPos=F}w.attr("text-anchor",e.anchor),I&&z.attr("text-anchor",e.anchor),t.attr("transform",s(F,H)+(l?c(T):""))})),{hoverLabels:Se,commonLabelBoundingBox:$}}function R(e,t,r,n,a,i){var l="",s="";void 0!==e.nameOverride&&(e.name=e.nameOverride),e.name&&(e.trace._meta&&(e.name=o.templateString(e.name,e.trace._meta)),l=Y(e.name,e.nameLength));var c=r.charAt(0),u="x"===c?"y":"x";void 0!==e.zLabel?(void 0!==e.xLabel&&(s+="x: "+e.xLabel+"
"),void 0!==e.yLabel&&(s+="y: "+e.yLabel+"
"),"choropleth"!==e.trace.type&&"choroplethmapbox"!==e.trace.type&&"choroplethmap"!==e.trace.type&&(s+=(s?"z: ":"")+e.zLabel)):t&&e[c+"Label"]===a?s=e[u+"Label"]||"":void 0===e.xLabel?void 0!==e.yLabel&&"scattercarpet"!==e.trace.type&&(s=e.yLabel):s=void 0===e.yLabel?e.xLabel:"("+e.xLabel+", "+e.yLabel+")",!e.text&&0!==e.text||Array.isArray(e.text)||(s+=(s?"
":"")+e.text),void 0!==e.extraText&&(s+=(s?"
":"")+e.extraText),i&&""===s&&!e.hovertemplate&&(""===l&&i.remove(),s=l);var f=e.hovertemplate||!1;if(f){var d=e.hovertemplateLabels||e;e[c+"Label"]!==a&&(d[c+"other"]=d[c+"Val"],d[c+"otherLabel"]=d[c+"Label"]),s=(s=o.hovertemplateString(f,d,n._d3locale,e.eventData[0]||{},e.trace._meta)).replace(I,(function(t,r){return l=Y(r,e.nameLength),""}))}return[s,l]}function N(e,t){var r=0,n=e.offset;return t&&(n*=-L,r=e.offset*A),{x:r,y:n}}function F(e,t,r,a){var i=function(e){return e*r},o=function(e){return e*a};e.each((function(e){var r=n.select(this);if(e.del)return r.remove();var a,l,s,c,u=r.select("text.nums"),d=e.anchor,p="end"===d?-1:1,v=(c=(s=(l={start:1,end:-1,middle:0}[(a=e).anchor])*(S+C))+l*(a.txwidth+C),"middle"===a.anchor&&(s-=a.tx2width/2,c+=a.txwidth/2+C),{alignShift:l,textShiftX:s,text2ShiftX:c}),g=N(e,t),m=g.x,y=g.y,x="middle"===d;r.select("path").attr("d",x?"M-"+i(e.bx/2+e.tx2width/2)+","+o(y-e.by/2)+"h"+i(e.bx)+"v"+o(e.by)+"h-"+i(e.bx)+"Z":"M0,0L"+i(p*S+m)+","+o(S+y)+"v"+o(e.by/2-S)+"h"+i(p*e.bx)+"v-"+o(e.by)+"H"+i(p*S+m)+"V"+o(y-S)+"Z");var b=m+v.textShiftX,_=y+e.ty0-e.by/2+C,w=e.textAlign||"auto";"auto"!==w&&("left"===w&&"start"!==d?(u.attr("text-anchor","start"),b=x?-e.bx/2-e.tx2width/2+C:-e.bx-C):"right"===w&&"end"!==d&&(u.attr("text-anchor","end"),b=x?e.bx/2-e.tx2width/2-C:e.bx+C)),u.call(f.positionText,i(b),o(_)),e.tx2width&&(r.select("text.name").call(f.positionText,i(v.text2ShiftX+v.alignShift*C+m),o(y+e.ty0-e.by/2+C)),r.select("rect").call(h.setRect,i(v.text2ShiftX+(v.alignShift-1)*e.tx2width/2+m),o(y-e.by/2-1),i(e.tx2width),o(e.by+2)))}))}function H(e,t){var r=e.index,n=e.trace||{},i=e.cd[0],l=e.cd[r]||{};function s(e){return e||a(e)&&0===e}var c=Array.isArray(r)?function(e,t){var a=o.castOption(i,r,e);return s(a)?a:o.extractOption({},n,"",t)}:function(e,t){return o.extractOption(l,n,e,t)};function u(t,r,n){var a=c(r,n);s(a)&&(e[t]=a)}if(u("hoverinfo","hi","hoverinfo"),u("bgcolor","hbg","hoverlabel.bgcolor"),u("borderColor","hbc","hoverlabel.bordercolor"),u("fontFamily","htf","hoverlabel.font.family"),u("fontSize","hts","hoverlabel.font.size"),u("fontColor","htc","hoverlabel.font.color"),u("fontWeight","htw","hoverlabel.font.weight"),u("fontStyle","hty","hoverlabel.font.style"),u("fontVariant","htv","hoverlabel.font.variant"),u("nameLength","hnl","hoverlabel.namelength"),u("textAlign","hta","hoverlabel.align"),e.posref="y"===t||"closest"===t&&"h"===n.orientation?e.xa._offset+(e.x0+e.x1)/2:e.ya._offset+(e.y0+e.y1)/2,e.x0=o.constrain(e.x0,0,e.xa._length),e.x1=o.constrain(e.x1,0,e.xa._length),e.y0=o.constrain(e.y0,0,e.ya._length),e.y1=o.constrain(e.y1,0,e.ya._length),void 0!==e.xLabelVal&&(e.xLabel="xLabel"in e?e.xLabel:g.hoverLabelText(e.xa,e.xLabelVal,n.xhoverformat),e.xVal=e.xa.c2d(e.xLabelVal)),void 0!==e.yLabelVal&&(e.yLabel="yLabel"in e?e.yLabel:g.hoverLabelText(e.ya,e.yLabelVal,n.yhoverformat),e.yVal=e.ya.c2d(e.yLabelVal)),void 0!==e.zLabelVal&&void 0===e.zLabel&&(e.zLabel=String(e.zLabelVal)),!(isNaN(e.xerr)||"log"===e.xa.type&&e.xerr<=0)){var f=g.tickText(e.xa,e.xa.c2l(e.xerr),"hover").text;void 0!==e.xerrneg?e.xLabel+=" +"+f+" / -"+g.tickText(e.xa,e.xa.c2l(e.xerrneg),"hover").text:e.xLabel+=" ± "+f,"x"===t&&(e.distance+=1)}if(!(isNaN(e.yerr)||"log"===e.ya.type&&e.yerr<=0)){var d=g.tickText(e.ya,e.ya.c2l(e.yerr),"hover").text;void 0!==e.yerrneg?e.yLabel+=" +"+d+" / -"+g.tickText(e.ya,e.ya.c2l(e.yerrneg),"hover").text:e.yLabel+=" ± "+d,"y"===t&&(e.distance+=1)}var h=e.hoverinfo||e.trace.hoverinfo;return h&&"all"!==h&&(-1===(h=Array.isArray(h)?h:h.split("+")).indexOf("x")&&(e.xLabel=void 0),-1===h.indexOf("y")&&(e.yLabel=void 0),-1===h.indexOf("z")&&(e.zLabel=void 0),-1===h.indexOf("text")&&(e.text=void 0),-1===h.indexOf("name")&&(e.name=void 0)),e}function B(e,t,r){var n,a,o=r.container,l=r.fullLayout,s=l._size,c=r.event,u=!!t.hLinePoint,f=!!t.vLinePoint;if(o.selectAll(".spikeline").remove(),f||u){var d=p.combine(l.plot_bgcolor,l.paper_bgcolor);if(u){var v,m,y=t.hLinePoint;n=y&&y.xa,"cursor"===(a=y&&y.ya).spikesnap?(v=c.pointerX,m=c.pointerY):(v=n._offset+y.x,m=a._offset+y.y);var x,b,_=i.readability(y.color,d)<1.5?p.contrast(d):y.color,w=a.spikemode,T=a.spikethickness,M=a.spikecolor||_,k=g.getPxPosition(e,a);if(-1!==w.indexOf("toaxis")||-1!==w.indexOf("across")){if(-1!==w.indexOf("toaxis")&&(x=k,b=v),-1!==w.indexOf("across")){var A=a._counterDomainMin,L=a._counterDomainMax;"free"===a.anchor&&(A=Math.min(A,a.position),L=Math.max(L,a.position)),x=s.l+A*s.w,b=s.l+L*s.w}o.insert("line",":first-child").attr({x1:x,x2:b,y1:m,y2:m,"stroke-width":T,stroke:M,"stroke-dasharray":h.dashStyle(a.spikedash,T)}).classed("spikeline",!0).classed("crisp",!0),o.insert("line",":first-child").attr({x1:x,x2:b,y1:m,y2:m,"stroke-width":T+2,stroke:d}).classed("spikeline",!0).classed("crisp",!0)}-1!==w.indexOf("marker")&&o.insert("circle",":first-child").attr({cx:k+("right"!==a.side?T:-T),cy:m,r:T,fill:M}).classed("spikeline",!0)}if(f){var S,C,O=t.vLinePoint;n=O&&O.xa,a=O&&O.ya,"cursor"===n.spikesnap?(S=c.pointerX,C=c.pointerY):(S=n._offset+O.x,C=a._offset+O.y);var D,P,z=i.readability(O.color,d)<1.5?p.contrast(d):O.color,I=n.spikemode,E=n.spikethickness,R=n.spikecolor||z,N=g.getPxPosition(e,n);if(-1!==I.indexOf("toaxis")||-1!==I.indexOf("across")){if(-1!==I.indexOf("toaxis")&&(D=N,P=C),-1!==I.indexOf("across")){var F=n._counterDomainMin,H=n._counterDomainMax;"free"===n.anchor&&(F=Math.min(F,n.position),H=Math.max(H,n.position)),D=s.t+(1-H)*s.h,P=s.t+(1-F)*s.h}o.insert("line",":first-child").attr({x1:S,x2:S,y1:D,y2:P,"stroke-width":E,stroke:R,"stroke-dasharray":h.dashStyle(n.spikedash,E)}).classed("spikeline",!0).classed("crisp",!0),o.insert("line",":first-child").attr({x1:S,x2:S,y1:D,y2:P,"stroke-width":E+2,stroke:d}).classed("spikeline",!0).classed("crisp",!0)}-1!==I.indexOf("marker")&&o.insert("circle",":first-child").attr({cx:S,cy:N-("top"!==n.side?E:-E),r:E,fill:R}).classed("spikeline",!0)}}}function j(e,t){return!t||t.vLinePoint!==e._spikepoints.vLinePoint||t.hLinePoint!==e._spikepoints.hLinePoint}function Y(e,t){return f.plainText(e||"",{len:t,allowedTags:["br","sub","sup","b","i","em","s","u"]})}function V(e,t,r){var n=t[e+"a"],a=t[e+"Val"],i=t.cd[0];if("category"===n.type||"multicategory"===n.type)a=n._categoriesMap[a];else if("date"===n.type){var o=t.trace[e+"periodalignment"];if(o){var l=t.cd[t.index],s=l[e+"Start"];void 0===s&&(s=l[e]);var c=l[e+"End"];void 0===c&&(c=l[e]);var u=c-s;"end"===o?a+=u:"middle"===o&&(a+=u/2)}a=n.d2c(a)}return i&&i.t&&i.t.posLetter===n._id&&("group"!==r.boxmode&&"group"!==r.violinmode||(a+=i.t.dPos)),a}function U(e){return e.offsetTop+e.clientTop}function q(e){return e.offsetLeft+e.clientLeft}function G(e,t){var r=e._fullLayout,n=t.getBoundingClientRect(),a=n.left,i=n.top,l=a+n.width,s=i+n.height,c=o.apply3DTransform(r._invTransform)(a,i),u=o.apply3DTransform(r._invTransform)(l,s),f=c[0],d=c[1],h=u[0],p=u[1];return{x:f,y:d,width:h-f,height:p-d,top:Math.min(d,p),left:Math.min(f,h),right:Math.max(f,h),bottom:Math.max(d,p)}}},6430:function(e,t,r){"use strict";var n=r(4809),a=r(8766),i=r(6040).isUnifiedHover;e.exports=function(e,t,r,o){o=o||{};var l=t.legend;function s(e){o.font[e]||(o.font[e]=l?t.legend.font[e]:t.font[e])}t&&i(t.hovermode)&&(o.font||(o.font={}),s("size"),s("family"),s("color"),s("weight"),s("style"),s("variant"),l?(o.bgcolor||(o.bgcolor=a.combine(t.legend.bgcolor,t.paper_bgcolor)),o.bordercolor||(o.bordercolor=t.legend.bordercolor)):o.bgcolor||(o.bgcolor=t.paper_bgcolor)),r("hoverlabel.bgcolor",o.bgcolor),r("hoverlabel.bordercolor",o.bordercolor),r("hoverlabel.namelength",o.namelength),n.coerceFont(r,"hoverlabel.font",o.font),r("hoverlabel.align",o.align)}},5265:function(e,t,r){"use strict";var n=r(4809),a=r(6811);e.exports=function(e,t){function r(r,i){return void 0!==t[r]?t[r]:n.coerce(e,t,a,r,i)}return r("clickmode"),r("hoversubplots"),r("hovermode")}},2141:function(e,t,r){"use strict";var n=r(5568),a=r(4809),i=r(4751),o=r(6040),l=r(6811),s=r(8103);e.exports={moduleType:"component",name:"fx",constants:r(5988),schema:{layout:l},attributes:r(192),layoutAttributes:l,supplyLayoutGlobalDefaults:r(5358),supplyDefaults:r(3239),supplyLayoutDefaults:r(8412),calc:r(3552),getDistanceFunction:o.getDistanceFunction,getClosest:o.getClosest,inbox:o.inbox,quadrature:o.quadrature,appendArrayPointValue:o.appendArrayPointValue,castHoverOption:function(e,t,r){return a.castOption(e,t,"hoverlabel."+r)},castHoverinfo:function(e,t,r){return a.castOption(e,r,"hoverinfo",(function(r){return a.coerceHoverinfo({hoverinfo:r},{_module:e._module},t)}))},hover:s.hover,unhover:i.unhover,loneHover:s.loneHover,loneUnhover:function(e){var t=a.isD3Selection(e)?e:n.select(e);t.selectAll("g.hovertext").remove(),t.selectAll(".spikeline").remove()},click:r(4225)}},6811:function(e,t,r){"use strict";var n=r(5988),a=r(337),i=a({editType:"none"});i.family.dflt=n.HOVERFONT,i.size.dflt=n.HOVERFONTSIZE,e.exports={clickmode:{valType:"flaglist",flags:["event","select"],dflt:"event",editType:"plot",extras:["none"]},dragmode:{valType:"enumerated",values:["zoom","pan","select","lasso","drawclosedpath","drawopenpath","drawline","drawrect","drawcircle","orbit","turntable",!1],dflt:"zoom",editType:"modebar"},hovermode:{valType:"enumerated",values:["x","y","closest",!1,"x unified","y unified"],dflt:"closest",editType:"modebar"},hoversubplots:{valType:"enumerated",values:["single","overlaying","axis"],dflt:"overlaying",editType:"none"},hoverdistance:{valType:"integer",min:-1,dflt:20,editType:"none"},spikedistance:{valType:"integer",min:-1,dflt:-1,editType:"none"},hoverlabel:{bgcolor:{valType:"color",editType:"none"},bordercolor:{valType:"color",editType:"none"},font:i,grouptitlefont:a({editType:"none"}),align:{valType:"enumerated",values:["left","right","auto"],dflt:"auto",editType:"none"},namelength:{valType:"integer",min:-1,dflt:15,editType:"none"},editType:"none"},selectdirection:{valType:"enumerated",values:["h","v","d","any"],dflt:"any",editType:"none"}}},8412:function(e,t,r){"use strict";var n=r(4809),a=r(6811),i=r(5265),o=r(6430);e.exports=function(e,t){function r(r,i){return n.coerce(e,t,a,r,i)}i(e,t)&&(r("hoverdistance"),r("spikedistance")),"select"===r("dragmode")&&r("selectdirection");var l=t._has("mapbox"),s=t._has("map"),c=t._has("geo"),u=t._basePlotModules.length;"zoom"===t.dragmode&&((l||s||c)&&1===u||(l||s)&&c&&2===u)&&(t.dragmode="pan"),o(e,t,r),n.coerceFont(r,"hoverlabel.grouptitlefont",t.hoverlabel.font)}},5358:function(e,t,r){"use strict";var n=r(4809),a=r(6430),i=r(6811);e.exports=function(e,t){a(e,t,(function(r,a){return n.coerce(e,t,i,r,a)}))}},3595:function(e,t,r){"use strict";var n=r(4809),a=r(694).counter,i=r(3792).u,o=r(4826).idRegex,l=r(8032),s={rows:{valType:"integer",min:1,editType:"plot"},roworder:{valType:"enumerated",values:["top to bottom","bottom to top"],dflt:"top to bottom",editType:"plot"},columns:{valType:"integer",min:1,editType:"plot"},subplots:{valType:"info_array",freeLength:!0,dimensions:2,items:{valType:"enumerated",values:[a("xy").toString(),""],editType:"plot"},editType:"plot"},xaxes:{valType:"info_array",freeLength:!0,items:{valType:"enumerated",values:[o.x.toString(),""],editType:"plot"},editType:"plot"},yaxes:{valType:"info_array",freeLength:!0,items:{valType:"enumerated",values:[o.y.toString(),""],editType:"plot"},editType:"plot"},pattern:{valType:"enumerated",values:["independent","coupled"],dflt:"coupled",editType:"plot"},xgap:{valType:"number",min:0,max:1,editType:"plot"},ygap:{valType:"number",min:0,max:1,editType:"plot"},domain:i({name:"grid",editType:"plot",noGridCell:!0},{}),xside:{valType:"enumerated",values:["bottom","bottom plot","top plot","top"],dflt:"bottom plot",editType:"plot"},yside:{valType:"enumerated",values:["left","left plot","right plot","right"],dflt:"left plot",editType:"plot"},editType:"plot"};function c(e,t,r){var n=t[r+"axes"],a=Object.keys((e._splomAxes||{})[r]||{});return Array.isArray(n)?n:a.length?a:void 0}function u(e,t,r,n,a,i){var o=t(e+"gap",r),l=t("domain."+e);t(e+"side",n);for(var s=new Array(a),c=l[0],u=(l[1]-c)/(a-o),f=u*(1-o),d=0;d1){d||h||p||"independent"===M("pattern")&&(d=!0),g._hasSubplotGrid=d;var x,b,_="top to bottom"===M("roworder"),w=d?.2:.1,T=d?.3:.1;v&&t._splomGridDflt&&(x=t._splomGridDflt.xside,b=t._splomGridDflt.yside),g._domains={x:u("x",M,w,x,y),y:u("y",M,T,b,m,_)}}else delete t.grid}function M(e,t){return n.coerce(r,g,s,e,t)}},contentDefaults:function(e,t){var r=t.grid;if(r&&r._domains){var n,a,i,o,l,s,u,d=e.grid||{},h=t._subplots,p=r._hasSubplotGrid,v=r.rows,g=r.columns,m="independent"===r.pattern,y=r._axisMap={};if(p){var x=d.subplots||[];s=r.subplots=new Array(v);var b=1;for(n=0;n("legend"===e?1:0));if(!1===A&&(r[e]=void 0),(!1!==A||f.uirevision)&&(h("uirevision",r.uirevision),!1!==A)){h("borderwidth");var L,S,C,O="h"===h("orientation"),D="paper"===h("yref"),P="paper"===h("xref"),z="left";if(O?(L=0,n.getComponentMethod("rangeslider","isVisible")(t.xaxis)?D?(S=1.1,C="bottom"):(S=1,C="top"):D?(S=-.1,C="top"):(S=0,C="bottom")):(S=1,C="auto",P?L=1.02:(L=1,z="right")),a.coerce(f,d,{x:{valType:"number",editType:"legend",min:P?-2:0,max:P?3:1,dflt:L}},"x"),a.coerce(f,d,{y:{valType:"number",editType:"legend",min:D?-2:0,max:D?3:1,dflt:S}},"y"),h("traceorder",_),c.isGrouped(r[e])&&h("tracegroupgap"),h("entrywidth"),h("entrywidthmode"),h("indentation"),h("itemsizing"),h("itemwidth"),h("itemclick"),h("itemdoubleclick"),h("groupclick"),h("xanchor",z),h("yanchor",C),h("valign"),a.noneOrAll(f,d,["x","y"]),h("title.text")){h("title.side",O?"left":"top");var I=a.extendFlat({},p,{size:a.bigFont(p.size)});a.coerceFont(h,"title.font",I)}}}}e.exports=function(e,t,r){var n,i=r.slice(),o=t.shapes;if(o)for(n=0;n1)}var F=p.hiddenlabels||[];if(!(T||p.showlegend&&L.length))return l.selectAll("."+w).remove(),p._topdefs.select("#"+r).remove(),i.autoMargin(e,w);var H=a.ensureSingle(l,"g",w,(function(e){T||e.attr("pointer-events","all")})),B=a.ensureSingleById(p._topdefs,"clipPath",r,(function(e){e.append("rect")})),j=a.ensureSingle(H,"rect","bg",(function(e){e.attr("shape-rendering","crispEdges")}));j.call(u.stroke,d.bordercolor).call(u.fill,d.bgcolor).style("stroke-width",d.borderwidth+"px");var Y,V=a.ensureSingle(H,"g","scrollbox"),U=d.title;d._titleWidth=0,d._titleHeight=0,U.text?((Y=a.ensureSingle(V,"text",w+"titletext")).attr("text-anchor","start").call(c.font,U.font).text(U.text),C(Y,V,e,d,_)):V.selectAll("."+w+"titletext").remove();var q=a.ensureSingle(H,"rect","scrollbar",(function(e){e.attr(h.scrollBarEnterAttrs).call(u.fill,h.scrollBarColor)})),G=V.selectAll("g.groups").data(L);G.enter().append("g").attr("class","groups"),G.exit().remove();var Z=G.selectAll("g.traces").data(a.identity);Z.enter().append("g").attr("class","traces"),Z.exit().remove(),Z.style("opacity",(function(e){var t=e[0].trace;return o.traceIs(t,"pie-like")?-1!==F.indexOf(e[0].label)?.5:1:"legendonly"===t.visible?.5:1})).each((function(){n.select(this).call(A,e,d)})).call(x,e,d).each((function(){T||n.select(this).call(S,e,w)})),a.syncOrAsync([i.previousPromises,function(){return function(e,t,r,a){var i=e._fullLayout,o=P(a);a||(a=i[o]);var l=i._size,s=b.isVertical(a),u=b.isGrouped(a),f="fraction"===a.entrywidthmode,d=a.borderwidth,p=2*d,v=h.itemGap,g=a.indentation+a.itemwidth+2*v,m=2*(d+v),y=D(a),x=a.y<0||0===a.y&&"top"===y,_=a.y>1||1===a.y&&"bottom"===y,w=a.tracegroupgap,T={};a._maxHeight=Math.max(x||_?i.height/2:l.h,30);var k=0;a._width=0,a._height=0;var A=function(e){var t=0,r=0,n=e.title.side;return n&&(-1!==n.indexOf("left")&&(t=e._titleWidth),-1!==n.indexOf("top")&&(r=e._titleHeight)),[t,r]}(a);if(s)r.each((function(e){var t=e[0].height;c.setTranslate(this,d+A[0],d+A[1]+a._height+t/2+v),a._height+=t,a._width=Math.max(a._width,e[0].width)})),k=g+a._width,a._width+=v+g+p,a._height+=m,u&&(t.each((function(e,t){c.setTranslate(this,0,t*a.tracegroupgap)})),a._height+=(a._lgroupsLength-1)*a.tracegroupgap);else{var L=O(a),S=a.x<0||0===a.x&&"right"===L,C=a.x>1||1===a.x&&"left"===L,z=_||x,I=i.width/2;a._maxWidth=Math.max(S?z&&"left"===L?l.l+l.w:I:C?z&&"right"===L?l.r+l.w:I:l.w,2*g);var E=0,R=0;r.each((function(e){var t=M(e,a,g);E=Math.max(E,t),R+=t})),k=null;var N=0;if(u){var F=0,H=0,B=0;t.each((function(){var e=0,t=0;n.select(this).selectAll("g.traces").each((function(r){var n=M(r,a,g),i=r[0].height;c.setTranslate(this,A[0],A[1]+d+v+i/2+t),t+=i,e=Math.max(e,n),T[r[0].trace.legendgroup]=e}));var r=e+v;H>0&&r+d+H>a._maxWidth?(N=Math.max(N,H),H=0,B+=F+w,F=t):F=Math.max(F,t),c.setTranslate(this,H,B),H+=r})),a._width=Math.max(N,H)+d,a._height=B+F+m}else{var j=r.size(),Y=R+p+(j-1)*v=a._maxWidth&&(N=Math.max(N,G),U=0,q+=V,a._height+=V,V=0),c.setTranslate(this,A[0]+d+U,A[1]+d+q+t/2+v),G=U+r+v,U+=n,V=Math.max(V,t)})),Y?(a._width=U+p,a._height=V+m):(a._width=Math.max(N,G)+p,a._height+=V+m)}}a._width=Math.ceil(Math.max(a._width+A[0],a._titleWidth+2*(d+h.titlePad))),a._height=Math.ceil(Math.max(a._height+A[1],a._titleHeight+2*(d+h.itemGap))),a._effHeight=Math.min(a._height,a._maxHeight);var Z=e._context.edits,W=Z.legendText||Z.legendPosition;r.each((function(e){var t=n.select(this).select("."+o+"toggle"),r=e[0].height,i=e[0].trace.legendgroup,l=M(e,a,g);u&&""!==i&&(l=T[i]);var d=W?g:k||l;s||f||(d+=v/2),c.setRect(t,0,-r/2,d,r)}))}(e,G,Z,d)},function(){var t,u,y,x,b=p._size,_=d.borderwidth,M="paper"===d.xref,A="paper"===d.yref;if(U.text&&function(e,t,r){if("top center"===t.title.side||"top right"===t.title.side){var n=t.title.font.size*v,a=0,i=e.node(),o=c.bBox(i).width;"top center"===t.title.side?a=.5*(t._width-2*r-2*h.titlePad-o):"top right"===t.title.side&&(a=t._width-2*r-2*h.titlePad-o),f.positionText(e,r+h.titlePad+a,r+n)}}(Y,d,_),!T){var L,S;L=M?b.l+b.w*d.x-g[O(d)]*d._width:p.width*d.x-g[O(d)]*d._width,S=A?b.t+b.h*(1-d.y)-g[D(d)]*d._effHeight:p.height*(1-d.y)-g[D(d)]*d._effHeight;var C=function(e,t,r,n){var a=e._fullLayout,o=a[t],l=O(o),s=D(o),c="paper"===o.xref,u="paper"===o.yref;e._fullLayout._reservedMargin[t]={};var f=o.y<.5?"b":"t",d=o.x<.5?"l":"r",h={r:a.width-r,l:r+o._width,b:a.height-n,t:n+o._effHeight};if(c&&u)return i.autoMargin(e,t,{x:o.x,y:o.y,l:o._width*g[l],r:o._width*m[l],b:o._effHeight*m[s],t:o._effHeight*g[s]});c?e._fullLayout._reservedMargin[t][f]=h[f]:u||"v"===o.orientation?e._fullLayout._reservedMargin[t][d]=h[d]:e._fullLayout._reservedMargin[t][f]=h[f]}(e,w,L,S);if(C)return;if(p.margin.autoexpand){var P=L,z=S;L=M?a.constrain(L,0,p.width-d._width):P,S=A?a.constrain(S,0,p.height-d._effHeight):z,L!==P&&a.log("Constrain "+w+".x to make legend fit inside graph"),S!==z&&a.log("Constrain "+w+".y to make legend fit inside graph")}c.setTranslate(H,L,S)}if(q.on(".drag",null),H.on("wheel",null),T||d._height<=d._maxHeight||e._context.staticPlot){var I=d._effHeight;T&&(I=d._height),j.attr({width:d._width-_,height:I-_,x:_/2,y:_/2}),c.setTranslate(V,0,0),B.select("rect").attr({width:d._width-2*_,height:I-2*_,x:_,y:_}),c.setClipUrl(V,r,e),c.setRect(q,0,0,0,0),delete d._scrollY}else{var E,R,N,F=Math.max(h.scrollBarMinHeight,d._effHeight*d._effHeight/d._height),G=d._effHeight-F-2*h.scrollBarMargin,Z=d._height-d._effHeight,W=G/Z,J=Math.min(d._scrollY||0,Z);j.attr({width:d._width-2*_+h.scrollBarWidth+h.scrollBarMargin,height:d._effHeight-_,x:_/2,y:_/2}),B.select("rect").attr({width:d._width-2*_+h.scrollBarWidth+h.scrollBarMargin,height:d._effHeight-2*_,x:_,y:_+J}),c.setClipUrl(V,r,e),Q(J,F,W),H.on("wheel",(function(){Q(J=a.constrain(d._scrollY+n.event.deltaY/G*Z,0,Z),F,W),0!==J&&J!==Z&&n.event.preventDefault()}));var X=n.behavior.drag().on("dragstart",(function(){var e=n.event.sourceEvent;E="touchstart"===e.type?e.changedTouches[0].clientY:e.clientY,N=J})).on("drag",(function(){var e=n.event.sourceEvent;2===e.buttons||e.ctrlKey||(R="touchmove"===e.type?e.changedTouches[0].clientY:e.clientY,J=function(e,t,r){var n=(r-t)/W+e;return a.constrain(n,0,Z)}(N,E,R),Q(J,F,W))}));q.call(X);var K=n.behavior.drag().on("dragstart",(function(){var e=n.event.sourceEvent;"touchstart"===e.type&&(E=e.changedTouches[0].clientY,N=J)})).on("drag",(function(){var e=n.event.sourceEvent;"touchmove"===e.type&&(R=e.changedTouches[0].clientY,J=function(e,t,r){var n=(t-r)/W+e;return a.constrain(n,0,Z)}(N,E,R),Q(J,F,W))}));V.call(K)}function Q(t,r,n){d._scrollY=e._fullLayout[w]._scrollY=t,c.setTranslate(V,0,-t),c.setRect(q,d._width,h.scrollBarMargin+t*n,h.scrollBarWidth,r),B.select("rect").attr("y",_+t)}e._context.edits.legendPosition&&(H.classed("cursor-move",!0),s.init({element:H.node(),gd:e,prepFn:function(e){if(e.target!==q.node()){var t=c.getTranslate(H);y=t.x,x=t.y}},moveFn:function(e,r){if(void 0!==y&&void 0!==x){var n=y+e,a=x+r;c.setTranslate(H,n,a),t=s.align(n,d._width,b.l,b.l+b.w,d.xanchor),u=s.align(a+d._height,-d._height,b.t+b.h,b.t,d.yanchor)}},doneFn:function(){if(void 0!==t&&void 0!==u){var r={};r[w+".x"]=t,r[w+".y"]=u,o.call("_guiRelayout",e,r)}},clickFn:function(t,r){var n=l.selectAll("g.traces").filter((function(){var e=this.getBoundingClientRect();return r.clientX>=e.left&&r.clientX<=e.right&&r.clientY>=e.top&&r.clientY<=e.bottom}));n.size()>0&&k(e,H,n,t,r)}}))}],e)}}function M(e,t,r){var n=e[0],a=n.width,i=t.entrywidthmode,o=n.trace.legendwidth||t.entrywidth;return"fraction"===i?t._maxWidth*o:r+(o||a)}function k(e,t,r,n,a){var i=r.data()[0][0].trace,s={event:a,node:r.node(),curveNumber:i.index,expandedIndex:i._expandedIndex,data:e.data,layout:e.layout,frames:e._transitionData._frames,config:e._context,fullData:e._fullData,fullLayout:e._fullLayout};i._group&&(s.group=i._group),o.traceIs(i,"pie-like")&&(s.label=r.datum()[0].label);var c=l.triggerHandler(e,"plotly_legendclick",s);if(1===n){if(!1===c)return;t._clickTimeout=setTimeout((function(){e._fullLayout&&d(r,e,n)}),e._context.doubleClickDelay)}else 2===n&&(t._clickTimeout&&clearTimeout(t._clickTimeout),e._legendMouseDownTime=0,!1!==l.triggerHandler(e,"plotly_legenddoubleclick",s)&&!1!==c&&d(r,e,n))}function A(e,t,r){var n,i,l=P(r),s=e.data()[0][0],u=s.trace,d=o.traceIs(u,"pie-like"),p=!r._inHover&&t._context.edits.legendText&&!d,v=r._maxNameLength;s.groupTitle?(n=s.groupTitle.text,i=s.groupTitle.font):(i=r.font,r.entries?n=s.text:(n=d?s.label:u.name,u._meta&&(n=a.templateString(n,u._meta))));var g=a.ensureSingle(e,"text",l+"text");g.attr("text-anchor","start").call(c.font,i).text(p?L(n,v):n);var m=r.indentation+r.itemwidth+2*h.itemGap;f.positionText(g,m,0),p?g.call(f.makeEditable,{gd:t,text:n}).call(C,e,t,r).on("edit",(function(n){this.text(L(n,v)).call(C,e,t,r);var i=s.trace._fullInput||{},l={};if(o.hasTransform(i,"groupby")){var c=o.getTransformIndices(i,"groupby"),f=c[c.length-1],d=a.keyedContainer(i,"transforms["+f+"].styles","target","value.name");d.set(s.trace._group,n),l=d.constructUpdate()}else l.name=n;return i._isShape?o.call("_guiRelayout",t,"shapes["+u.index+"].name",l.name):o.call("_guiRestyle",t,l,u.index)})):C(g,e,t,r)}function L(e,t){var r=Math.max(4,t);if(e&&e.trim().length>=r/2)return e;for(var n=r-(e=e||"").length;n>0;n--)e+=" ";return e}function S(e,t,r){var i,o=t._context.doubleClickDelay,l=1,s=a.ensureSingle(e,"rect",r+"toggle",(function(e){t._context.staticPlot||e.style("cursor","pointer").attr("pointer-events","all"),e.call(u.fill,"rgba(0,0,0,0)")}));t._context.staticPlot||(s.on("mousedown",(function(){(i=(new Date).getTime())-t._legendMouseDownTimeo&&(l=Math.max(l-1,1)),k(t,a,e,l,n.event)}})))}function C(e,t,r,n,a){n._inHover&&e.attr("data-notex",!0),f.convertToTspans(e,r,(function(){!function(e,t,r,n){var a=e.data()[0][0];if(r._inHover||!a||a.trace.showlegend){var i=e.select("g[class*=math-group]"),o=i.node(),l=P(r);r||(r=t._fullLayout[l]);var s,u,d=r.borderwidth,p=(n===_?r.title.font:a.groupTitle?a.groupTitle.font:r.font).size*v;if(o){var g=c.bBox(o);s=g.height,u=g.width,n===_?c.setTranslate(i,d,d+.75*s):c.setTranslate(i,0,.25*s)}else{var m="."+l+(n===_?"title":"")+"text",y=e.select(m),x=f.lineCount(y),b=y.node();if(s=p*x,u=b?c.bBox(b).width:0,n===_)"left"===r.title.side&&(u+=2*h.itemGap),f.positionText(y,d+h.titlePad,d+p);else{var w=2*h.itemGap+r.indentation+r.itemwidth;a.groupTitle&&(w=h.itemGap,u-=r.indentation+r.itemwidth),f.positionText(y,w,-p*((x-1)/2-.3))}}n===_?(r._titleWidth=u,r._titleHeight=s):(a.lineHeight=p,a.height=Math.max(s,16)+3,a.width=u)}else e.remove()}(t,r,n,a)}))}function O(e){return a.isRightAnchor(e)?"right":a.isCenterAnchor(e)?"center":"left"}function D(e){return a.isBottomAnchor(e)?"bottom":a.isMiddleAnchor(e)?"middle":"top"}function P(e){return e._id||"legend"}e.exports=function(e,t){if(t)T(e,t);else{var r=e._fullLayout,a=r._legends;r._infolayer.selectAll('[class^="legend"]').each((function(){var e=n.select(this),t=e.attr("class").split(" ")[0];t.match(w)&&-1===a.indexOf(t)&&e.remove()}));for(var i=0;iL&&(A=L)}M[i][0]._groupMinRank=A,M[i][0]._preGroupSort=i}var S=function(e,t){return e.trace.legendrank-t.trace.legendrank||e._preSort-t._preSort};for(M.forEach((function(e,t){e[0]._preGroupSort=t})),M.sort((function(e,t){return e[0]._groupMinRank-t[0]._groupMinRank||e[0]._preGroupSort-t[0]._preGroupSort})),i=0;ir?r:e}e.exports=function(e,t,r){var m=t._fullLayout;r||(r=m.legend);var y="constant"===r.itemsizing,x=r.itemwidth,b=(x+2*h.itemGap)/2,_=o(b,0),w=function(e,t,r,n){var a;if(e+1)a=e;else{if(!(t&&t.width>0))return 0;a=t.width}return y?n:Math.min(a,r)};function T(e,i,o){var u=e[0].trace,f=u.marker||{},d=f.line||{},h=f.cornerradius?"M6,3a3,3,0,0,1-3,3H-3a3,3,0,0,1-3-3V-3a3,3,0,0,1,3-3H3a3,3,0,0,1,3,3Z":"M6,6H-6V-6H6Z",p=o?u.visible&&u.type===o:a.traceIs(u,"bar"),v=n.select(i).select("g.legendpoints").selectAll("path.legend"+o).data(p?[e]:[]);v.enter().append("path").classed("legend"+o,!0).attr("d",h).attr("transform",_),v.exit().remove(),v.each((function(e){var a=n.select(this),i=e[0],o=w(i.mlw,f.line,5,2);a.style("stroke-width",o+"px");var h=i.mcc;if(!r._inHover&&"mc"in i){var p=c(f),v=p.mid;void 0===v&&(v=(p.max+p.min)/2),h=l.tryColorscale(f,"")(v)}var m=h||i.mc||f.color,y=f.pattern,x=y&&l.getPatternAttr(y.shape,0,"");if(x){var b=l.getPatternAttr(y.bgcolor,0,null),_=l.getPatternAttr(y.fgcolor,0,null),T=y.fgopacity,M=g(y.size,8,10),k=g(y.solidity,.5,1),A="legend-"+u.uid;a.call(l.pattern,"legend",t,A,x,M,k,h,y.fillmode,b,_,T)}else a.call(s.fill,m);o&&s.stroke(a,i.mlc||d.color)}))}function M(e,r,o){var l=e[0],s=l.trace,c=o?s.visible&&s.type===o:a.traceIs(s,o),u=n.select(r).select("g.legendpoints").selectAll("path.legend"+o).data(c?[e]:[]);if(u.enter().append("path").classed("legend"+o,!0).attr("d","M6,6H-6V-6H6Z").attr("transform",_),u.exit().remove(),u.size()){var h=s.marker||{},p=w(d(h.line.width,l.pts),h.line,5,2),v="pieLike",g=i.minExtend(s,{marker:{line:{width:p}}},v),m=i.minExtend(l,{trace:g},v);f(u,m,g,t)}}e.each((function(e){var t=n.select(this),a=i.ensureSingle(t,"g","layers");a.style("opacity",e[0].trace.opacity);var l=r.indentation,s=r.valign,c=e[0].lineHeight,u=e[0].height;if("middle"===s&&0===l||!c||!u)a.attr("transform",null);else{var f={top:1,bottom:-1}[s]*(.5*(c-u+3))||0,d=r.indentation;a.attr("transform",o(d,f))}a.selectAll("g.legendfill").data([e]).enter().append("g").classed("legendfill",!0),a.selectAll("g.legendlines").data([e]).enter().append("g").classed("legendlines",!0);var h=a.selectAll("g.legendsymbols").data([e]);h.enter().append("g").classed("legendsymbols",!0),h.selectAll("g.legendpoints").data([e]).enter().append("g").classed("legendpoints",!0)})).each((function(e){var r,a=e[0].trace,o=[];if(a.visible)switch(a.type){case"histogram2d":case"heatmap":o=[["M-15,-2V4H15V-2Z"]],r=!0;break;case"choropleth":case"choroplethmapbox":case"choroplethmap":o=[["M-6,-6V6H6V-6Z"]],r=!0;break;case"densitymapbox":case"densitymap":o=[["M-6,0 a6,6 0 1,0 12,0 a 6,6 0 1,0 -12,0"]],r="radial";break;case"cone":o=[["M-6,2 A2,2 0 0,0 -6,6 V6L6,4Z"],["M-6,-6 A2,2 0 0,0 -6,-2 L6,-4Z"],["M-6,-2 A2,2 0 0,0 -6,2 L6,0Z"]],r=!1;break;case"streamtube":o=[["M-6,2 A2,2 0 0,0 -6,6 H6 A2,2 0 0,1 6,2 Z"],["M-6,-6 A2,2 0 0,0 -6,-2 H6 A2,2 0 0,1 6,-6 Z"],["M-6,-2 A2,2 0 0,0 -6,2 H6 A2,2 0 0,1 6,-2 Z"]],r=!1;break;case"surface":o=[["M-6,-6 A2,3 0 0,0 -6,0 H6 A2,3 0 0,1 6,-6 Z"],["M-6,1 A2,3 0 0,1 -6,6 H6 A2,3 0 0,0 6,0 Z"]],r=!0;break;case"mesh3d":o=[["M-6,6H0L-6,-6Z"],["M6,6H0L6,-6Z"],["M-6,-6H6L0,6Z"]],r=!1;break;case"volume":o=[["M-6,6H0L-6,-6Z"],["M6,6H0L6,-6Z"],["M-6,-6H6L0,6Z"]],r=!0;break;case"isosurface":o=[["M-6,6H0L-6,-6Z"],["M6,6H0L6,-6Z"],["M-6,-6 A12,24 0 0,0 6,-6 L0,6Z"]],r=!1}var u=n.select(this).select("g.legendpoints").selectAll("path.legend3dandfriends").data(o);u.enter().append("path").classed("legend3dandfriends",!0).attr("transform",_).style("stroke-miterlimit",1),u.exit().remove(),u.each((function(e,o){var u,f=n.select(this),d=c(a),h=d.colorscale,v=d.reversescale;if(h){if(!r){var g=h.length;u=0===o?h[v?g-1:0][1]:1===o?h[v?0:g-1][1]:h[Math.floor((g-1)/2)][1]}}else{var m=a.vertexcolor||a.facecolor||a.color;u=i.isArrayOrTypedArray(m)?m[o]||m[0]:m}f.attr("d",e[0]),u?f.call(s.fill,u):f.call((function(e){if(e.size()){var n="legendfill-"+a.uid;l.gradient(e,t,n,p(v,"radial"===r),h,"fill")}}))}))})).each((function(e){var t=e[0].trace,r="waterfall"===t.type;if(e[0]._distinct&&r){var a=e[0].trace[e[0].dir].marker;return e[0].mc=a.color,e[0].mlw=a.line.width,e[0].mlc=a.line.color,T(e,this,"waterfall")}var i=[];t.visible&&r&&(i=e[0].hasTotals?[["increasing","M-6,-6V6H0Z"],["totals","M6,6H0L-6,-6H-0Z"],["decreasing","M6,6V-6H0Z"]]:[["increasing","M-6,-6V6H6Z"],["decreasing","M6,6V-6H-6Z"]]);var o=n.select(this).select("g.legendpoints").selectAll("path.legendwaterfall").data(i);o.enter().append("path").classed("legendwaterfall",!0).attr("transform",_).style("stroke-miterlimit",1),o.exit().remove(),o.each((function(e){var r=n.select(this),a=t[e[0]].marker,i=w(void 0,a.line,5,2);r.attr("d",e[1]).style("stroke-width",i+"px").call(s.fill,a.color),i&&r.call(s.stroke,a.line.color)}))})).each((function(e){T(e,this,"funnel")})).each((function(e){T(e,this)})).each((function(e){var r=e[0].trace,o=n.select(this).select("g.legendpoints").selectAll("path.legendbox").data(r.visible&&a.traceIs(r,"box-violin")?[e]:[]);o.enter().append("path").classed("legendbox",!0).attr("d","M6,6H-6V-6H6Z").attr("transform",_),o.exit().remove(),o.each((function(){var e=n.select(this);if("all"!==r.boxpoints&&"all"!==r.points||0!==s.opacity(r.fillcolor)||0!==s.opacity((r.line||{}).color)){var a=w(void 0,r.line,5,2);e.style("stroke-width",a+"px").call(s.fill,r.fillcolor),a&&s.stroke(e,r.line.color)}else{var c=i.minExtend(r,{marker:{size:y?12:i.constrain(r.marker.size,2,16),sizeref:1,sizemin:1,sizemode:"diameter"}});o.call(l.pointStyle,c,t)}}))})).each((function(e){M(e,this,"funnelarea")})).each((function(e){M(e,this,"pie")})).each((function(e){var r,a,o=v(e),s=o.showFill,f=o.showLine,d=o.showGradientLine,h=o.showGradientFill,g=o.anyFill,m=o.anyLine,y=e[0],b=y.trace,_=c(b),T=_.colorscale,M=_.reversescale,k=u.hasMarkers(b)||!g?"M5,0":m?"M5,-2":"M5,-3",A=n.select(this),L=A.select(".legendfill").selectAll("path").data(s||h?[e]:[]);if(L.enter().append("path").classed("js-fill",!0),L.exit().remove(),L.attr("d",k+"h"+x+"v6h-"+x+"z").call((function(e){if(e.size())if(s)l.fillGroupStyle(e,t,!0);else{var r="legendfill-"+b.uid;l.gradient(e,t,r,p(M),T,"fill")}})),f||d){var S=w(void 0,b.line,10,5);a=i.minExtend(b,{line:{width:S}}),r=[i.minExtend(y,{trace:a})]}var C=A.select(".legendlines").selectAll("path").data(f||d?[r]:[]);C.enter().append("path").classed("js-line",!0),C.exit().remove(),C.attr("d",k+(d?"l"+x+",0.0001":"h"+x)).call(f?l.lineGroupStyle:function(e){if(e.size()){var r="legendline-"+b.uid;l.lineGroupStyle(e),l.gradient(e,t,r,p(M),T,"stroke")}})})).each((function(e){var r,a,o=v(e),s=o.anyFill,c=o.anyLine,f=o.showLine,d=o.showMarker,h=e[0],p=h.trace,g=!d&&!c&&!s&&u.hasText(p);function m(e,t,r,n){var a=i.nestedProperty(p,e).get(),o=i.isArrayOrTypedArray(a)&&t?t(a):a;if(y&&o&&void 0!==n&&(o=n),r){if(or[1])return r[1]}return o}function x(e){return h._distinct&&h.index&&e[h.index]?e[h.index]:e[0]}if(d||g||f){var b={},w={};if(d){b.mc=m("marker.color",x),b.mx=m("marker.symbol",x),b.mo=m("marker.opacity",i.mean,[.2,1]),b.mlc=m("marker.line.color",x),b.mlw=m("marker.line.width",i.mean,[0,5],2),w.marker={sizeref:1,sizemin:1,sizemode:"diameter"};var T=m("marker.size",i.mean,[2,16],12);b.ms=T,w.marker.size=T}f&&(w.line={width:m("line.width",x,[0,10],5)}),g&&(b.tx="Aa",b.tp=m("textposition",x),b.ts=10,b.tc=m("textfont.color",x),b.tf=m("textfont.family",x),b.tw=m("textfont.weight",x),b.ty=m("textfont.style",x),b.tv=m("textfont.variant",x),b.tC=m("textfont.textcase",x),b.tE=m("textfont.lineposition",x),b.tS=m("textfont.shadow",x)),r=[i.minExtend(h,b)],(a=i.minExtend(p,w)).selectedpoints=null,a.texttemplate=null}var M=n.select(this).select("g.legendpoints"),k=M.selectAll("path.scatterpts").data(d?r:[]);k.enter().insert("path",":first-child").classed("scatterpts",!0).attr("transform",_),k.exit().remove(),k.call(l.pointStyle,a,t),d&&(r[0].mrc=3);var A=M.selectAll("g.pointtext").data(g?r:[]);A.enter().append("g").classed("pointtext",!0).append("text").attr("transform",_),A.exit().remove(),A.selectAll("text").call(l.textPointStyle,a,t)})).each((function(e){var t=e[0].trace,r=n.select(this).select("g.legendpoints").selectAll("path.legendcandle").data(t.visible&&"candlestick"===t.type?[e,e]:[]);r.enter().append("path").classed("legendcandle",!0).attr("d",(function(e,t){return t?"M-15,0H-8M-8,6V-6H8Z":"M15,0H8M8,-6V6H-8Z"})).attr("transform",_).style("stroke-miterlimit",1),r.exit().remove(),r.each((function(e,r){var a=n.select(this),i=t[r?"increasing":"decreasing"],o=w(void 0,i.line,5,2);a.style("stroke-width",o+"px").call(s.fill,i.fillcolor),o&&s.stroke(a,i.line.color)}))})).each((function(e){var t=e[0].trace,r=n.select(this).select("g.legendpoints").selectAll("path.legendohlc").data(t.visible&&"ohlc"===t.type?[e,e]:[]);r.enter().append("path").classed("legendohlc",!0).attr("d",(function(e,t){return t?"M-15,0H0M-8,-6V0":"M15,0H0M8,6V0"})).attr("transform",_).style("stroke-miterlimit",1),r.exit().remove(),r.each((function(e,r){var a=n.select(this),i=t[r?"increasing":"decreasing"],o=w(void 0,i.line,5,2);a.style("fill","none").call(l.dashLine,i.line.dash,o),o&&s.stroke(a,i.line.color)}))}))}},308:function(e,t,r){"use strict";r(7632),e.exports={editType:"modebar",orientation:{valType:"enumerated",values:["v","h"],dflt:"h",editType:"modebar"},bgcolor:{valType:"color",editType:"modebar"},color:{valType:"color",editType:"modebar"},activecolor:{valType:"color",editType:"modebar"},uirevision:{valType:"any",editType:"none"},add:{valType:"string",arrayOk:!0,dflt:"",editType:"modebar"},remove:{valType:"string",arrayOk:!0,dflt:"",editType:"modebar"}}},5832:function(e,t,r){"use strict";var n=r(3626),a=r(4122),i=r(5975),o=r(5188),l=r(8231).eraseActiveShape,s=r(4809),c=s._,u=e.exports={};function f(e,t){var r,a,o=t.currentTarget,l=o.getAttribute("data-attr"),s=o.getAttribute("data-val")||!0,c=e._fullLayout,u={},f=i.list(e,null,!0),d=c._cartesianSpikesEnabled;if("zoom"===l){var h,p="in"===s?.5:2,v=(1+p)/2,g=(1-p)/2;for(a=0;a1?(z=["toggleHover"],I=["resetViews"]):m?(P=["zoomInGeo","zoomOutGeo"],z=["hoverClosestGeo"],I=["resetGeo"]):g?(z=["hoverClosest3d"],I=["resetCameraDefault3d","resetCameraLastSave3d"]):w?(P=["zoomInMapbox","zoomOutMapbox"],z=["toggleHover"],I=["resetViewMapbox"]):T?(P=["zoomInMap","zoomOutMap"],z=["toggleHover"],I=["resetViewMap"]):b?z=["hoverClosestGl2d"]:y?z=["hoverClosestPie"]:A?(z=["hoverClosestCartesian","hoverCompareCartesian"],I=["resetViewSankey"]):z=["toggleHover"],v&&z.push("toggleSpikelines","hoverClosestCartesian","hoverCompareCartesian"),(function(e){for(var t=0;t0)){var v=function(e,t,r){for(var n=r.filter((function(r){return t[r].anchor===e._id})),a=0,i=0;i0?e.touches[0].clientX:0}function m(e,t,r,n){var a=o.ensureSingle(e,"rect",v.bgClassName,(function(e){e.attr({x:0,y:0,"shape-rendering":"crispEdges"})})),i=n.borderwidth%2==0?n.borderwidth:n.borderwidth-1,u=-n._offsetShift,f=s.crispRound(t,n.borderwidth);a.attr({width:n._width+i,height:n._height+i,transform:l(u,u),"stroke-width":f}).call(c.stroke,n.bordercolor).call(c.fill,n.bgcolor)}function y(e,t,r,n){var a=t._fullLayout;o.ensureSingleById(a._topdefs,"clipPath",n._clipId,(function(e){e.append("rect").attr({x:0,y:0})})).select("rect").attr({width:n._width,height:n._height})}function x(e,t,r,a){var l,c=t.calcdata,u=e.selectAll("g."+v.rangePlotClassName).data(r._subplotsWith,o.identity);u.enter().append("g").attr("class",(function(e){return v.rangePlotClassName+" "+e})).call(s.setClipUrl,a._clipId,t),u.order(),u.exit().remove(),u.each((function(e,o){var s=n.select(this),u=0===o,h=d.getFromId(t,e,"y"),p=h._name,v=a[p],g={data:[],layout:{xaxis:{type:r.type,domain:[0,1],range:a.range.slice(),calendar:r.calendar},width:a._width,height:a._height,margin:{t:0,b:0,l:0,r:0}},_context:t._context};r.rangebreaks&&(g.layout.xaxis.rangebreaks=r.rangebreaks),g.layout[p]={type:h.type,domain:[0,1],range:"match"!==v.rangemode?v.range.slice():h.range.slice(),calendar:h.calendar},h.rangebreaks&&(g.layout[p].rangebreaks=h.rangebreaks),i.supplyDefaults(g);var m=g._fullLayout.xaxis,y=g._fullLayout[p];m.clearCalc(),m.setScale(),y.clearCalc(),y.setScale();var x={id:e,plotgroup:s,xaxis:m,yaxis:y,isRangePlot:!0};u?l=x:(x.mainplot="xy",x.mainplotinfo=l),f.rangePlot(t,x,function(e,t){for(var r=[],n=0;n=n.max)t=F[r+1];else if(e=n.pmax)t=F[r+1];else if(er._length||y+_<0)return;u=m+_,h=y+_;break;case s:if(b="col-resize",m+_>r._length)return;u=m+_,h=y;break;case c:if(b="col-resize",y+_<0)return;u=m,h=y+_;break;default:b="ew-resize",u=v,h=v+_}if(h=0;M--){var k=r.append("path").attr(g).style("opacity",M?.1:m).call(o.stroke,x).call(o.fill,y).call(l.dashLine,M?"solid":_,M?4+b:b);if(p(k,e,i),w){var A=s(e.layout,"selections",i);k.style({cursor:"move"});var L={element:k.node(),plotinfo:h,gd:e,editHelpers:A,isActiveSelection:!0},S=n(c,e);a(S,k,L)}else k.style("pointer-events",M?"all":"none");T[M]=k}var C=T[0];T[1].node().addEventListener("click",(function(){return function(e,t){if(d(e)){var r=+t.node().getAttribute("data-index");if(r>=0){if(r===e._fullLayout._activeSelectionIndex)return void v(e);e._fullLayout._activeSelectionIndex=r,e._fullLayout._deactivateSelection=v,f(e)}}}(e,C)}))}(e._fullLayout._selectionLayer)}function p(e,t,r){var n=r.xref+r.yref;l.setClipUrl(e,"clip"+t._fullLayout._uid+n,t)}function v(e){d(e)&&e._fullLayout._activeSelectionIndex>=0&&(i(e),delete e._fullLayout._activeSelectionIndex,f(e))}e.exports={draw:f,drawOne:h,activateLastSelection:function(e){if(d(e)){var t=e._fullLayout.selections.length-1;e._fullLayout._activeSelectionIndex=t,e._fullLayout._deactivateSelection=v,f(e)}}}},2307:function(e,t,r){"use strict";var n=r(4850).T,a=r(3049).extendFlat;e.exports={newselection:{mode:{valType:"enumerated",values:["immediate","gradual"],dflt:"immediate",editType:"none"},line:{color:{valType:"color",editType:"none"},width:{valType:"number",min:1,dflt:1,editType:"none"},dash:a({},n,{dflt:"dot",editType:"none"}),editType:"none"},editType:"none"},activeselection:{fillcolor:{valType:"color",dflt:"rgba(0,0,0,0)",editType:"none"},opacity:{valType:"number",min:0,max:1,dflt:.5,editType:"none"},editType:"none"}}},3028:function(e){"use strict";e.exports=function(e,t,r){r("newselection.mode"),r("newselection.line.width")&&(r("newselection.line.color"),r("newselection.line.dash")),r("activeselection.fillcolor"),r("activeselection.opacity")}},4198:function(e,t,r){"use strict";var n=r(414).selectMode,a=r(8534).clearOutline,i=r(1055),o=i.readPaths,l=i.writePaths,s=i.fixDatesForPaths;e.exports=function(e,t){if(e.length){var r=e[0][0];if(r){var i=r.getAttribute("d"),c=t.gd,u=c._fullLayout.newselection,f=t.plotinfo,d=f.xaxis,h=f.yaxis,p=t.isActiveSelection,v=t.dragmode,g=(c.layout||{}).selections||[];if(!n(v)&&void 0!==p){var m=c._fullLayout._activeSelectionIndex;if(m-1,b=[];if(function(e){return e&&Array.isArray(e)&&!0!==e[0].hoverOnBox}(m)){G(e,t,i);var _=function(e,t){var r,n,a=e[0],i=-1,o=[];for(n=0;n0?function(e,t){var r,n,a,i=[];for(a=0;a0&&i.push(r);if(1===i.length&&i[0]===t.searchInfo&&(n=t.searchInfo.cd[0].trace).selectedpoints.length===t.pointNumbers.length){for(a=0;a1)return!1;if((n+=t.selectedpoints.length)>1)return!1}return 1===n}(l)&&(d=K(_))){for(o&&o.remove(),g=0;g=0})(a)&&a._fullLayout._deactivateShape(a),function(e){return e._fullLayout._activeSelectionIndex>=0}(a)&&a._fullLayout._deactivateSelection(a);var o=a._fullLayout._zoomlayer,l=h(r),s=v(r);if(l||s){var c,u,f=o.selectAll(".select-outline-"+n.id);f&&a._fullLayout._outlining&&(l&&(c=T(f,e)),c&&i.call("_guiRelayout",a,{shapes:c}),s&&!j(e)&&(u=M(f,e)),u&&(a._fullLayout._noEmitSelectedAtStart=!0,i.call("_guiRelayout",a,{selections:u}).then((function(){t&&k(a)}))),a._fullLayout._outlining=!1)}n.selection={},n.selection.selectionDefs=e.selectionDefs=[],n.selection.mergedPolygons=e.mergedPolygons=[]}function W(e){return e._id}function J(e,t,r,n){if(!e.calcdata)return[];var a,i,o,l=[],s=t.map(W),c=r.map(W);for(o=0;o0?n[0]:r;return!!t.selectedpoints&&t.selectedpoints.indexOf(a)>-1}function Q(e,t,r){var n,a;for(n=0;n-1&&t;if(!i&&t){var te=oe(e,!0);if(te.length){var ne=te[0].xref,he=te[0].yref;if(ne&&he){var pe=ce(te);ue([O(e,ne,"x"),O(e,he,"y")])($,pe)}}e._fullLayout._noEmitSelectedAtStart?e._fullLayout._noEmitSelectedAtStart=!1:ee&&fe(e,$),d._reselect=!1}if(!i&&d._deselect){var ve=d._deselect;(function(e,t,r){for(var n=0;n=0)M._fullLayout._deactivateShape(M);else if(!x){var r=k.clickmode;C.done(Ae).then((function(){if(C.clear(Ae),2===e){for(be.remove(),K=0;K-1&&Y(t,M,n.xaxes,n.yaxes,n.subplot,n,be),"event"===r&&fe(M,void 0);s.click(M,t,D.id)})).catch(A.error)}},n.doneFn=function(){Me.remove(),C.done(Ae).then((function(){C.clear(Ae),!L&&X&&n.selectionDefs&&(X.subtract=xe,n.selectionDefs.push(X),n.mergedPolygons.length=0,[].push.apply(n.mergedPolygons,W)),(L||x)&&Z(n,L),n.doneFnCompleted&&n.doneFnCompleted(Le),b&&fe(M,ie)})).catch(A.error)}},clearOutline:x,clearSelectionsCache:Z,selectOnClick:Y}},3144:function(e,t,r){"use strict";var n=r(222),a=r(337),i=r(6640).line,o=r(4850).T,l=r(3049).extendFlat,s=r(8032).templatedArray,c=(r(5081),r(9829)),u=r(3208).LF,f=r(1235);e.exports=s("shape",{visible:l({},c.visible,{editType:"calc+arraydraw"}),showlegend:{valType:"boolean",dflt:!1,editType:"calc+arraydraw"},legend:l({},c.legend,{editType:"calc+arraydraw"}),legendgroup:l({},c.legendgroup,{editType:"calc+arraydraw"}),legendgrouptitle:{text:l({},c.legendgrouptitle.text,{editType:"calc+arraydraw"}),font:a({editType:"calc+arraydraw"}),editType:"calc+arraydraw"},legendrank:l({},c.legendrank,{editType:"calc+arraydraw"}),legendwidth:l({},c.legendwidth,{editType:"calc+arraydraw"}),type:{valType:"enumerated",values:["circle","rect","path","line"],editType:"calc+arraydraw"},layer:{valType:"enumerated",values:["below","above","between"],dflt:"above",editType:"arraydraw"},xref:l({},n.xref,{}),xsizemode:{valType:"enumerated",values:["scaled","pixel"],dflt:"scaled",editType:"calc+arraydraw"},xanchor:{valType:"any",editType:"calc+arraydraw"},x0:{valType:"any",editType:"calc+arraydraw"},x1:{valType:"any",editType:"calc+arraydraw"},x0shift:{valType:"number",dflt:0,min:-1,max:1,editType:"calc"},x1shift:{valType:"number",dflt:0,min:-1,max:1,editType:"calc"},yref:l({},n.yref,{}),ysizemode:{valType:"enumerated",values:["scaled","pixel"],dflt:"scaled",editType:"calc+arraydraw"},yanchor:{valType:"any",editType:"calc+arraydraw"},y0:{valType:"any",editType:"calc+arraydraw"},y1:{valType:"any",editType:"calc+arraydraw"},y0shift:{valType:"number",dflt:0,min:-1,max:1,editType:"calc"},y1shift:{valType:"number",dflt:0,min:-1,max:1,editType:"calc"},path:{valType:"string",editType:"calc+arraydraw"},opacity:{valType:"number",min:0,max:1,dflt:1,editType:"arraydraw"},line:{color:l({},i.color,{editType:"arraydraw"}),width:l({},i.width,{editType:"calc+arraydraw"}),dash:l({},o,{editType:"arraydraw"}),editType:"calc+arraydraw"},fillcolor:{valType:"color",dflt:"rgba(0,0,0,0)",editType:"arraydraw"},fillrule:{valType:"enumerated",values:["evenodd","nonzero"],dflt:"evenodd",editType:"arraydraw"},editable:{valType:"boolean",dflt:!1,editType:"calc+arraydraw"},label:{text:{valType:"string",dflt:"",editType:"arraydraw"},texttemplate:u({},{keys:Object.keys(f)}),font:a({editType:"calc+arraydraw",colorEditType:"arraydraw"}),textposition:{valType:"enumerated",values:["top left","top center","top right","middle left","middle center","middle right","bottom left","bottom center","bottom right","start","middle","end"],editType:"arraydraw"},textangle:{valType:"angle",dflt:"auto",editType:"calc+arraydraw"},xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"auto",editType:"calc+arraydraw"},yanchor:{valType:"enumerated",values:["top","middle","bottom"],editType:"calc+arraydraw"},padding:{valType:"number",dflt:3,min:0,editType:"arraydraw"},editType:"arraydraw"},editType:"arraydraw"})},4959:function(e,t,r){"use strict";var n=r(4809),a=r(9714),i=r(2956),o=r(9728);function l(e){return c(e.line.width,e.xsizemode,e.x0,e.x1,e.path,!1)}function s(e){return c(e.line.width,e.ysizemode,e.y0,e.y1,e.path,!0)}function c(e,t,r,a,l,s){var c=e/2,u=s;if("pixel"===t){var f=l?o.extractPathCoords(l,s?i.paramIsY:i.paramIsX):[r,a],d=n.aggNums(Math.max,null,f),h=n.aggNums(Math.min,null,f),p=h<0?Math.abs(h)+c:c,v=d>0?d+c:c;return{ppad:c,ppadplus:u?p:v,ppadminus:u?v:p}}return{ppad:c}}function u(e,t,r){var n,a,l="x"===e._id.charAt(0)?"x":"y",s="category"===e.type||"multicategory"===e.type,c=0,u=0,f=s?e.r2c:e.d2c;if("scaled"===t[l+"sizemode"]?(n=t[l+"0"],a=t[l+"1"],s&&(c=t[l+"0shift"],u=t[l+"1shift"])):(n=t[l+"anchor"],a=t[l+"anchor"]),void 0!==n)return[f(n)+c,f(a)+u];if(t.path){var d,h,p,v,g=1/0,m=-1/0,y=t.path.match(i.segmentRE);for("date"===e.type&&(f=o.decodeDate(f)),d=0;dm&&(m=v)));return m>=g?[g,m]:void 0}}e.exports=function(e){var t=e._fullLayout,r=n.filterVisible(t.shapes);if(r.length&&e._fullData.length)for(var o=0;o=e?t-n:n-t,-180/Math.PI*Math.atan2(a,i)}(x,_,b,w):0),k.call((function(t){return t.call(o.font,M).attr({}),i.convertToTspans(t,e),t}));var q=function(e,t,r,n,a,i,o){var l,s,c,u,d=a.label.textposition,h=a.label.textangle,p=a.label.padding,v=a.type,g=Math.PI/180*i,m=Math.sin(g),y=Math.cos(g),x=a.label.xanchor,b=a.label.yanchor;if("line"===v){"start"===d?(l=e,s=t):"end"===d?(l=r,s=n):(l=(e+r)/2,s=(t+n)/2),"auto"===x&&(x="start"===d?"auto"===h?r>e?"left":re?"right":re?"right":re?"left":r1&&(2!==e.length||"Z"!==e[1][0])&&(0===O&&(e[0][0]="M"),t[C]=e,k(),A())}}()}}function Y(e,r){!function(e,r){if(t.length)for(var n=0;n_?(A=h,O="y0",L=_,D="y1"):(A=_,O="y1",L=h,D="y0"),ae(n),le(s,r),function(e,t,r){var n=t.xref,a=t.yref,i=o.getFromId(r,n),l=o.getFromId(r,a),s="";"paper"===n||i.autorange||(s+=n),"paper"===a||l.autorange||(s+=a),d.setClipUrl(e,s?"clip"+r._fullLayout._uid+s:null,r)}(t,r,e),ne.moveFn="move"===E?ie:oe,ne.altKey=n.altKey)},doneFn:function(){b(e)||(v(t),se(s),T(t,e,r),a.call("_guiRelayout",e,u.getUpdateObj()))},clickFn:function(){b(e)||se(s)}};function ae(r){if(b(e))E=null;else if(B)E="path"===r.target.tagName?"move":"start-point"===r.target.attributes["data-line-point"].value?"resize-over-start-point":"resize-over-end-point";else{var n=ne.element.getBoundingClientRect(),a=n.right-n.left,i=n.bottom-n.top,o=r.clientX-n.left,l=r.clientY-n.top,s=!j&&a>R&&i>N&&!r.shiftKey?p.getCursor(o/a,1-l/i):"move";v(t,s),E=s.split("-")[0]}}function ie(n,a){if("path"===r.type){var i=function(e){return e},o=i,u=i;F?Y("xanchor",r.xanchor=ee(w+n)):(o=function(e){return ee(Q(e)+n)},U&&"date"===U.type&&(o=m.encodeDate(o))),H?Y("yanchor",r.yanchor=te(k+a)):(u=function(e){return te($(e)+a)},G&&"date"===G.type&&(u=m.encodeDate(u))),Y("path",r.path=M(I,o,u))}else F?Y("xanchor",r.xanchor=ee(w+n)):(Y("x0",r.x0=ee(f+n)),Y("x1",r.x1=ee(x+n))),H?Y("yanchor",r.yanchor=te(k+a)):(Y("y0",r.y0=te(h+a)),Y("y1",r.y1=te(_+a)));t.attr("d",y(e,r)),le(s,r),c(e,l,r,V)}function oe(n,a){if(j){var i=function(e){return e},o=i,u=i;F?Y("xanchor",r.xanchor=ee(w+n)):(o=function(e){return ee(Q(e)+n)},U&&"date"===U.type&&(o=m.encodeDate(o))),H?Y("yanchor",r.yanchor=te(k+a)):(u=function(e){return te($(e)+a)},G&&"date"===G.type&&(u=m.encodeDate(u))),Y("path",r.path=M(I,o,u))}else if(B){if("resize-over-start-point"===E){var d=f+n,p=H?h-a:h+a;Y("x0",r.x0=F?d:ee(d)),Y("y0",r.y0=H?p:te(p))}else if("resize-over-end-point"===E){var v=x+n,g=H?_-a:_+a;Y("x1",r.x1=F?v:ee(v)),Y("y1",r.y1=H?g:te(g))}}else{var b=function(e){return-1!==E.indexOf(e)},T=b("n"),q=b("s"),Z=b("w"),W=b("e"),J=T?A+a:A,X=q?L+a:L,K=Z?S+n:S,re=W?C+n:C;H&&(T&&(J=A-a),q&&(X=L-a)),(!H&&X-J>N||H&&J-X>N)&&(Y(O,r[O]=H?J:te(J)),Y(D,r[D]=H?X:te(X))),re-K>R&&(Y(P,r[P]=F?K:ee(K)),Y(z,r[z]=F?re:ee(re)))}t.attr("d",y(e,r)),le(s,r),c(e,l,r,V)}function le(e,t){(F||H)&&function(){var r="path"!==t.type,n=e.selectAll(".visual-cue").data([0]);n.enter().append("path").attr({fill:"#fff","fill-rule":"evenodd",stroke:"#000","stroke-width":1}).classed("visual-cue",!0);var a=Q(F?t.xanchor:i.midRange(r?[t.x0,t.x1]:m.extractPathCoords(t.path,g.paramIsX))),o=$(H?t.yanchor:i.midRange(r?[t.y0,t.y1]:m.extractPathCoords(t.path,g.paramIsY)));if(a=m.roundPositionForSharpStrokeRendering(a,1),o=m.roundPositionForSharpStrokeRendering(o,1),F&&H){var l="M"+(a-1-1)+","+(o-1-1)+"h-8v2h8 v8h2v-8 h8v-2h-8 v-8h-2 Z";n.attr("d",l)}else if(F){var s="M"+(a-1-1)+","+(o-9-1)+"v18 h2 v-18 Z";n.attr("d",s)}else{var c="M"+(a-9-1)+","+(o-1-1)+"h18 v2 h-18 Z";n.attr("d",c)}}()}function se(e){e.selectAll(".visual-cue").remove()}p.init(ne),re.node().onmousemove=ae}(e,N,u,t,r,E):!0===u.editable&&N.style("pointer-events",z||f.opacity(C)*S<=.5?"stroke":"all");N.node().addEventListener("click",(function(){return function(e,t){if(_(e)){var r=+t.node().getAttribute("data-index");if(r>=0){if(r===e._fullLayout._activeShapeIndex)return void k(e);e._fullLayout._activeShapeIndex=r,e._fullLayout._deactivateShape=k,x(e)}}}(e,N)}))}u._input&&!0===u.visible&&("above"===u.layer?A(e._fullLayout._shapeUpperLayer):"paper"===u.xref||"paper"===u.yref?A(e._fullLayout._shapeLowerLayer):"between"===u.layer?A(w.shapelayerBetween):w._hadPlotinfo?A((w.mainplotinfo||w).shapelayer):A(e._fullLayout._shapeLowerLayer))}function T(e,t,r){var n=(r.xref+r.yref).replace(/paper/g,"").replace(/[xyz][1-9]* *domain/g,"");d.setClipUrl(e,n?"clip"+t._fullLayout._uid+n:null,t)}function M(e,t,r){return e.replace(g.segmentRE,(function(e){var n=0,a=e.charAt(0),i=g.paramIsX[a],o=g.paramIsY[a],l=g.numParams[a];return a+e.substr(1).replace(g.paramRE,(function(e){return n>=l||(i[n]?e=t(e):o[n]&&(e=r(e)),n++),e}))}))}function k(e){_(e)&&e._fullLayout._activeShapeIndex>=0&&(u(e),delete e._fullLayout._activeShapeIndex,x(e))}e.exports={draw:x,drawOne:w,eraseActiveShape:function(e){if(_(e)){u(e);var t=e._fullLayout._activeShapeIndex,r=(e.layout||{}).shapes||[];if(t0&&sh&&(e="X"),e}));return i>h&&(p=p.replace(/[\s,]*X.*/,""),a.log("Ignoring extra params in segment "+e)),u+p}))}(r,s,u);if("pixel"===r.xsizemode){var k=s(r.xanchor);f=k+r.x0+_,d=k+r.x1+w}else f=s(r.x0)+_,d=s(r.x1)+w;if("pixel"===r.ysizemode){var A=u(r.yanchor);h=A-r.y0+T,p=A-r.y1+M}else h=u(r.y0)+T,p=u(r.y1)+M;if("line"===v)return"M"+f+","+h+"L"+d+","+p;if("rect"===v)return"M"+f+","+h+"H"+d+"V"+p+"H"+f+"Z";var L=(f+d)/2,S=(h+p)/2,C=Math.abs(L-f),O=Math.abs(S-h),D="A"+C+","+O,P=L+C+","+S;return"M"+P+D+" 0 1,1 "+L+","+(S-O)+D+" 0 0,1 "+P+"Z"}},3701:function(e,t,r){"use strict";var n=r(8231);e.exports={moduleType:"component",name:"shapes",layoutAttributes:r(3144),supplyLayoutDefaults:r(4367),supplyDrawNewShapeDefaults:r(5522),includeBasePlot:r(706)("shapes"),calcAutorange:r(4959),draw:n.draw,drawOne:n.drawOne}},1235:function(e){"use strict";function t(e,t){return t?t.d2l(e):e}function r(e,t){return t?t.l2d(e):e}function n(e){return e.x0shift||0}function a(e){return e.x1shift||0}function i(e){return e.y0shift||0}function o(e){return e.y1shift||0}function l(e,r){return t(e.x1,r)+a(e)-t(e.x0,r)-n(e)}function s(e,r,n){return t(e.y1,n)+o(e)-t(e.y0,n)-i(e)}e.exports={x0:function(e){return e.x0},x1:function(e){return e.x1},y0:function(e){return e.y0},y1:function(e){return e.y1},slope:function(e,t,r){return"line"!==e.type?void 0:s(e,0,r)/l(e,t)},dx:l,dy:s,width:function(e,t){return Math.abs(l(e,t))},height:function(e,t,r){return Math.abs(s(e,0,r))},length:function(e,t,r){return"line"!==e.type?void 0:Math.sqrt(Math.pow(l(e,t),2)+Math.pow(s(e,0,r),2))},xcenter:function(e,i){return r((t(e.x1,i)+a(e)+t(e.x0,i)+n(e))/2,i)},ycenter:function(e,n,a){return r((t(e.y1,a)+o(e)+t(e.y0,a)+i(e))/2,a)}}},8606:function(e,t,r){"use strict";var n=r(337),a=r(7891),i=r(3049).extendDeepAll,o=r(3582).overrideAll,l=r(9722),s=r(8032).templatedArray,c=r(4194),u=s("step",{visible:{valType:"boolean",dflt:!0},method:{valType:"enumerated",values:["restyle","relayout","animate","update","skip"],dflt:"restyle"},args:{valType:"info_array",freeLength:!0,items:[{valType:"any"},{valType:"any"},{valType:"any"}]},label:{valType:"string"},value:{valType:"string"},execute:{valType:"boolean",dflt:!0}});e.exports=o(s("slider",{visible:{valType:"boolean",dflt:!0},active:{valType:"number",min:0,dflt:0},steps:u,lenmode:{valType:"enumerated",values:["fraction","pixels"],dflt:"fraction"},len:{valType:"number",min:0,dflt:1},x:{valType:"number",min:-2,max:3,dflt:0},pad:i(a({editType:"arraydraw"}),{},{t:{dflt:20}}),xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"left"},y:{valType:"number",min:-2,max:3,dflt:0},yanchor:{valType:"enumerated",values:["auto","top","middle","bottom"],dflt:"top"},transition:{duration:{valType:"number",min:0,dflt:150},easing:{valType:"enumerated",values:l.transition.easing.values,dflt:"cubic-in-out"}},currentvalue:{visible:{valType:"boolean",dflt:!0},xanchor:{valType:"enumerated",values:["left","center","right"],dflt:"left"},offset:{valType:"number",dflt:10},prefix:{valType:"string"},suffix:{valType:"string"},font:n({})},font:n({}),activebgcolor:{valType:"color",dflt:c.gripBgActiveColor},bgcolor:{valType:"color",dflt:c.railBgColor},bordercolor:{valType:"color",dflt:c.railBorderColor},borderwidth:{valType:"number",min:0,dflt:c.railBorderWidth},ticklen:{valType:"number",min:0,dflt:c.tickLength},tickcolor:{valType:"color",dflt:c.tickColor},tickwidth:{valType:"number",min:0,dflt:1},minorticklen:{valType:"number",min:0,dflt:c.minorTickLength}}),"arraydraw","from-root")},4194:function(e){"use strict";e.exports={name:"sliders",containerClassName:"slider-container",groupClassName:"slider-group",inputAreaClass:"slider-input-area",railRectClass:"slider-rail-rect",railTouchRectClass:"slider-rail-touch-rect",gripRectClass:"slider-grip-rect",tickRectClass:"slider-tick-rect",inputProxyClass:"slider-input-proxy",labelsClass:"slider-labels",labelGroupClass:"slider-label-group",labelClass:"slider-label",currentValueClass:"slider-current-value",railHeight:5,menuIndexAttrName:"slider-active-index",autoMarginIdRoot:"slider-",minWidth:30,minHeight:30,textPadX:40,arrowOffsetX:4,railRadius:2,railWidth:5,railBorder:4,railBorderWidth:1,railBorderColor:"#bec8d9",railBgColor:"#f8fafc",railInset:8,stepInset:10,gripRadius:10,gripWidth:20,gripHeight:20,gripBorder:20,gripBorderWidth:1,gripBorderColor:"#bec8d9",gripBgColor:"#f6f8fa",gripBgActiveColor:"#dbdde0",labelPadding:8,labelOffset:0,tickWidth:1,tickColor:"#333",tickOffset:25,tickLength:7,minorTickOffset:25,minorTickColor:"#333",minorTickLength:4,currentValuePadding:8,currentValueInset:0}},4537:function(e,t,r){"use strict";var n=r(4809),a=r(9008),i=r(8606),o=r(4194).name,l=i.steps;function s(e,t,r){function o(r,a){return n.coerce(e,t,i,r,a)}for(var l=a(e,t,{name:"steps",handleItemDefaults:c}),s=0,u=0;u0&&(l=l.transition().duration(t.transition.duration).ease(t.transition.easing)),l.attr("transform",s(o-.5*f.gripWidth,t._dims.currentValueTotalHeight))}}function S(e,t){var r=e._dims;return r.inputAreaStart+f.stepInset+(r.inputAreaLength-2*f.stepInset)*Math.min(1,Math.max(0,t))}function C(e,t){var r=e._dims;return Math.min(1,Math.max(0,(t-f.stepInset-r.inputAreaStart)/(r.inputAreaLength-2*f.stepInset-2*r.inputAreaStart)))}function O(e,t,r){var n=r._dims,a=l.ensureSingle(e,"rect",f.railTouchRectClass,(function(n){n.call(k,t,e,r).style("pointer-events","all")}));a.attr({width:n.inputAreaLength,height:Math.max(n.inputAreaWidth,f.tickOffset+r.ticklen+n.labelHeight)}).call(i.fill,r.bgcolor).attr("opacity",0),o.setTranslate(a,0,n.currentValueTotalHeight)}function D(e,t){var r=t._dims,n=r.inputAreaLength-2*f.railInset,a=l.ensureSingle(e,"rect",f.railRectClass);a.attr({width:n,height:f.railWidth,rx:f.railRadius,ry:f.railRadius,"shape-rendering":"crispEdges"}).call(i.stroke,t.bordercolor).call(i.fill,t.bgcolor).style("stroke-width",t.borderwidth+"px"),o.setTranslate(a,f.railInset,.5*(r.inputAreaWidth-f.railWidth)+r.currentValueTotalHeight)}e.exports=function(e){var t=e._context.staticPlot,r=e._fullLayout,i=function(e,t){for(var r=e[f.name],n=[],a=0;a0?[0]:[]);function s(t){t._commandObserver&&(t._commandObserver.remove(),delete t._commandObserver),a.autoMargin(e,g(t))}if(l.enter().append("g").classed(f.containerClassName,!0).style("cursor",t?null:"ew-resize"),l.exit().each((function(){n.select(this).selectAll("g."+f.groupClassName).each(s)})).remove(),0!==i.length){var c=l.selectAll("g."+f.groupClassName).data(i,m);c.enter().append("g").classed(f.groupClassName,!0),c.exit().each(s).remove();for(var u=0;u0||T<0){var S={left:[-M,0],right:[M,0],top:[0,-M],bottom:[0,M]}[_.side];i.attr("transform",s(S[0],S[1]))}}}function de(e,t){e.text(t).on("mouseover.opacity",(function(){n.select(this).transition().duration(d.SHOW_PLACEHOLDER).style("opacity",1)})).on("mouseout.opacity",(function(){n.select(this).transition().duration(d.HIDE_PLACEHOLDER).style("opacity",0)}))}if(ie.call(ce,oe),te&&(L?ie.on(".opacity",null):(de(ie,x),S=!0),ie.call(f.makeEditable,{gd:e}).on("edit",(function(t){void 0!==b?o.call("_guiRestyle",e,y,t,b):o.call("_guiRelayout",e,y,t)})).on("cancel",(function(){this.text(this.attr("data-unformatted")).call(ce)})).on("input",(function(e){this.text(e||" ").call(f.positionText,w.x,w.y)})),H)){if(H&&!L){var he=ie.node().getBBox(),pe=he.y+he.height+1.6*Z;oe.attr("y",pe)}Y?oe.on(".opacity",null):(de(oe,B),V=!0),oe.call(f.makeEditable,{gd:e}).on("edit",(function(t){o.call("_guiRelayout",e,"title.subtitle.text",t)})).on("cancel",(function(){this.text(this.attr("data-unformatted")).call(ce)})).on("input",(function(e){this.text(e||" ").call(f.positionText,oe.attr("x"),oe.attr("y"))}))}return ie.classed("js-placeholder",S),oe&&oe.classed("js-placeholder",V),M},SUBTITLE_PADDING_EM:1.6,SUBTITLE_PADDING_MATHJAX_EM:1.6}},5389:function(e,t,r){"use strict";var n=r(337),a=r(229),i=r(3049).extendFlat,o=r(3582).overrideAll,l=r(7891),s=r(8032).templatedArray,c=s("button",{visible:{valType:"boolean"},method:{valType:"enumerated",values:["restyle","relayout","animate","update","skip"],dflt:"restyle"},args:{valType:"info_array",freeLength:!0,items:[{valType:"any"},{valType:"any"},{valType:"any"}]},args2:{valType:"info_array",freeLength:!0,items:[{valType:"any"},{valType:"any"},{valType:"any"}]},label:{valType:"string",dflt:""},execute:{valType:"boolean",dflt:!0}});e.exports=o(s("updatemenu",{_arrayAttrRegexps:[/^updatemenus\[(0|[1-9][0-9]+)\]\.buttons/],visible:{valType:"boolean"},type:{valType:"enumerated",values:["dropdown","buttons"],dflt:"dropdown"},direction:{valType:"enumerated",values:["left","right","up","down"],dflt:"down"},active:{valType:"integer",min:-1,dflt:0},showactive:{valType:"boolean",dflt:!0},buttons:c,x:{valType:"number",min:-2,max:3,dflt:-.05},xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"right"},y:{valType:"number",min:-2,max:3,dflt:1},yanchor:{valType:"enumerated",values:["auto","top","middle","bottom"],dflt:"top"},pad:i(l({editType:"arraydraw"}),{}),font:n({}),bgcolor:{valType:"color"},bordercolor:{valType:"color",dflt:a.borderLine},borderwidth:{valType:"number",min:0,dflt:1,editType:"arraydraw"}}),"arraydraw","from-root")},1559:function(e){"use strict";e.exports={name:"updatemenus",containerClassName:"updatemenu-container",headerGroupClassName:"updatemenu-header-group",headerClassName:"updatemenu-header",headerArrowClassName:"updatemenu-header-arrow",dropdownButtonGroupClassName:"updatemenu-dropdown-button-group",dropdownButtonClassName:"updatemenu-dropdown-button",buttonClassName:"updatemenu-button",itemRectClassName:"updatemenu-item-rect",itemTextClassName:"updatemenu-item-text",menuIndexAttrName:"updatemenu-active-index",autoMarginIdRoot:"updatemenu-",blankHeaderOpts:{label:" "},minWidth:30,minHeight:30,textPadX:24,arrowPadX:16,rx:2,ry:2,textOffsetX:12,textOffsetY:3,arrowOffsetX:4,gapButtonHeader:5,gapButton:2,activeColor:"#F4FAFF",hoverColor:"#F4FAFF",arrowSymbol:{left:"◄",right:"►",up:"▲",down:"▼"}}},2746:function(e,t,r){"use strict";var n=r(4809),a=r(9008),i=r(5389),o=r(1559).name,l=i.buttons;function s(e,t,r){function o(r,a){return n.coerce(e,t,i,r,a)}o("visible",a(e,t,{name:"buttons",handleItemDefaults:c}).length>0)&&(o("active"),o("direction"),o("type"),o("showactive"),o("x"),o("y"),n.noneOrAll(e,t,["x","y"]),o("xanchor"),o("yanchor"),o("pad.t"),o("pad.r"),o("pad.b"),o("pad.l"),n.coerceFont(o,"font",r.font),o("bgcolor",r.paper_bgcolor),o("bordercolor"),o("borderwidth"))}function c(e,t){function r(r,a){return n.coerce(e,t,l,r,a)}r("visible","skip"===e.method||Array.isArray(e.args))&&(r("method"),r("args"),r("args2"),r("label"),r("execute"))}e.exports=function(e,t){a(e,t,{name:o,handleItemDefaults:s})}},974:function(e,t,r){"use strict";var n=r(5568),a=r(4122),i=r(8766),o=r(2203),l=r(4809),s=r(635),c=r(8032).arrayEditor,u=r(4530).LINE_SPACING,f=r(1559),d=r(1736);function h(e){return e._index}function p(e,t){return+e.attr(f.menuIndexAttrName)===t._index}function v(e,t,r,n,a,i,o,l){t.active=o,c(e.layout,f.name,t).applyUpdate("active",o),"buttons"===t.type?m(e,n,null,null,t):"dropdown"===t.type&&(a.attr(f.menuIndexAttrName,"-1"),g(e,n,a,i,t),l||m(e,n,a,i,t))}function g(e,t,r,n,a){var i=l.ensureSingle(t,"g",f.headerClassName,(function(e){e.style("pointer-events","all")})),s=a._dims,c=a.active,u=a.buttons[c]||f.blankHeaderOpts,d={y:a.pad.t,yPad:0,x:a.pad.l,xPad:0,index:0},h={width:s.headerWidth,height:s.headerHeight};i.call(y,a,u,e).call(A,a,d,h),l.ensureSingle(t,"text",f.headerArrowClassName,(function(e){e.attr("text-anchor","end").call(o.font,a.font).text(f.arrowSymbol[a.direction])})).attr({x:s.headerWidth-f.arrowOffsetX+a.pad.l,y:s.headerHeight/2+f.textOffsetY+a.pad.t}),i.on("click",(function(){r.call(L,String(p(r,a)?-1:a._index)),m(e,t,r,n,a)})),i.on("mouseover",(function(){i.call(w)})),i.on("mouseout",(function(){i.call(T,a)})),o.setTranslate(t,s.lx,s.ly)}function m(e,t,r,i,o){r||(r=t).attr("pointer-events","all");var s=function(e){return-1==+e.attr(f.menuIndexAttrName)}(r)&&"buttons"!==o.type?[]:o.buttons,c="dropdown"===o.type?f.dropdownButtonClassName:f.buttonClassName,u=r.selectAll("g."+c).data(l.filterVisible(s)),d=u.enter().append("g").classed(c,!0),h=u.exit();"dropdown"===o.type?(d.attr("opacity","0").transition().attr("opacity","1"),h.transition().attr("opacity","0").remove()):h.remove();var p=0,g=0,m=o._dims,x=-1!==["up","down"].indexOf(o.direction);"dropdown"===o.type&&(x?g=m.headerHeight+f.gapButtonHeader:p=m.headerWidth+f.gapButtonHeader),"dropdown"===o.type&&"up"===o.direction&&(g=-f.gapButtonHeader+f.gapButton-m.openHeight),"dropdown"===o.type&&"left"===o.direction&&(p=-f.gapButtonHeader+f.gapButton-m.openWidth);var b={x:m.lx+p+o.pad.l,y:m.ly+g+o.pad.t,yPad:f.gapButton,xPad:f.gapButton,index:0},M={l:b.x+o.borderwidth,t:b.y+o.borderwidth};u.each((function(l,s){var c=n.select(this);c.call(y,o,l,e).call(A,o,b),c.on("click",(function(){n.event.defaultPrevented||(l.execute&&(l.args2&&o.active===s?(v(e,o,0,t,r,i,-1),a.executeAPICommand(e,l.method,l.args2)):(v(e,o,0,t,r,i,s),a.executeAPICommand(e,l.method,l.args))),e.emit("plotly_buttonclicked",{menu:o,button:l,active:o.active}))})),c.on("mouseover",(function(){c.call(w)})),c.on("mouseout",(function(){c.call(T,o),u.call(_,o)}))})),u.call(_,o),x?(M.w=Math.max(m.openWidth,m.headerWidth),M.h=b.y-M.t):(M.w=b.x-M.l,M.h=Math.max(m.openHeight,m.headerHeight)),M.direction=o.direction,i&&(u.size()?function(e,t,r,n,a,i){var o,l,s,c=a.direction,u="up"===c||"down"===c,d=a._dims,h=a.active;if(u)for(l=0,s=0;s0?[0]:[]);if(o.enter().append("g").classed(f.containerClassName,!0).style("cursor","pointer"),o.exit().each((function(){n.select(this).selectAll("g."+f.headerGroupClassName).each(i)})).remove(),0!==r.length){var s=o.selectAll("g."+f.headerGroupClassName).data(r,h);s.enter().append("g").classed(f.headerGroupClassName,!0);for(var c=l.ensureSingle(o,"g",f.dropdownButtonGroupClassName,(function(e){e.style("pointer-events","all")})),u=0;uw,k=l.barLength+2*l.barPad,A=l.barWidth+2*l.barPad,L=p,S=g+m;S+A>c&&(S=c-A);var C=this.container.selectAll("rect.scrollbar-horizontal").data(M?[0]:[]);C.exit().on(".drag",null).remove(),C.enter().append("rect").classed("scrollbar-horizontal",!0).call(a.fill,l.barColor),M?(this.hbar=C.attr({rx:l.barRadius,ry:l.barRadius,x:L,y:S,width:k,height:A}),this._hbarXMin=L+k/2,this._hbarTranslateMax=w-k):(delete this.hbar,delete this._hbarXMin,delete this._hbarTranslateMax);var O=m>T,D=l.barWidth+2*l.barPad,P=l.barLength+2*l.barPad,z=p+v,I=g;z+D>s&&(z=s-D);var E=this.container.selectAll("rect.scrollbar-vertical").data(O?[0]:[]);E.exit().on(".drag",null).remove(),E.enter().append("rect").classed("scrollbar-vertical",!0).call(a.fill,l.barColor),O?(this.vbar=E.attr({rx:l.barRadius,ry:l.barRadius,x:z,y:I,width:D,height:P}),this._vbarYMin=I+P/2,this._vbarTranslateMax=T-P):(delete this.vbar,delete this._vbarYMin,delete this._vbarTranslateMax);var R=this.id,N=u-.5,F=O?f+D+.5:f+.5,H=d-.5,B=M?h+A+.5:h+.5,j=o._topdefs.selectAll("#"+R).data(M||O?[0]:[]);if(j.exit().remove(),j.enter().append("clipPath").attr("id",R).append("rect"),M||O?(this._clipRect=j.select("rect").attr({x:Math.floor(N),y:Math.floor(H),width:Math.ceil(F)-Math.floor(N),height:Math.ceil(B)-Math.floor(H)}),this.container.call(i.setClipUrl,R,this.gd),this.bg.attr({x:p,y:g,width:v,height:m})):(this.bg.attr({width:0,height:0}),this.container.on("wheel",null).on(".drag",null).call(i.setClipUrl,null),delete this._clipRect),M||O){var Y=n.behavior.drag().on("dragstart",(function(){n.event.sourceEvent.preventDefault()})).on("drag",this._onBoxDrag.bind(this));this.container.on("wheel",null).on("wheel",this._onBoxWheel.bind(this)).on(".drag",null).call(Y);var V=n.behavior.drag().on("dragstart",(function(){n.event.sourceEvent.preventDefault(),n.event.sourceEvent.stopPropagation()})).on("drag",this._onBarDrag.bind(this));M&&this.hbar.on(".drag",null).call(V),O&&this.vbar.on(".drag",null).call(V)}this.setTranslate(t,r)},l.prototype.disable=function(){(this.hbar||this.vbar)&&(this.bg.attr({width:0,height:0}),this.container.on("wheel",null).on(".drag",null).call(i.setClipUrl,null),delete this._clipRect),this.hbar&&(this.hbar.on(".drag",null),this.hbar.remove(),delete this.hbar,delete this._hbarXMin,delete this._hbarTranslateMax),this.vbar&&(this.vbar.on(".drag",null),this.vbar.remove(),delete this.vbar,delete this._vbarYMin,delete this._vbarTranslateMax)},l.prototype._onBoxDrag=function(){var e=this.translateX,t=this.translateY;this.hbar&&(e-=n.event.dx),this.vbar&&(t-=n.event.dy),this.setTranslate(e,t)},l.prototype._onBoxWheel=function(){var e=this.translateX,t=this.translateY;this.hbar&&(e+=n.event.deltaY),this.vbar&&(t+=n.event.deltaY),this.setTranslate(e,t)},l.prototype._onBarDrag=function(){var e=this.translateX,t=this.translateY;if(this.hbar){var r=e+this._hbarXMin,a=r+this._hbarTranslateMax;e=(o.constrain(n.event.x,r,a)-r)/(a-r)*(this.position.w-this._box.w)}if(this.vbar){var i=t+this._vbarYMin,l=i+this._vbarTranslateMax;t=(o.constrain(n.event.y,i,l)-i)/(l-i)*(this.position.h-this._box.h)}this.setTranslate(e,t)},l.prototype.setTranslate=function(e,t){var r=this.position.w-this._box.w,n=this.position.h-this._box.h;if(e=o.constrain(e||0,0,r),t=o.constrain(t||0,0,n),this.translateX=e,this.translateY=t,this.container.call(i.setTranslate,this._box.l-this.position.l-e,this._box.t-this.position.t-t),this._clipRect&&this._clipRect.attr({x:Math.floor(this.position.l+e-.5),y:Math.floor(this.position.t+t-.5)}),this.hbar){var a=e/r;this.hbar.call(i.setTranslate,e+a*this._hbarTranslateMax,t)}if(this.vbar){var l=t/n;this.vbar.call(i.setTranslate,e,t+l*this._vbarTranslateMax)}}},4530:function(e){"use strict";e.exports={FROM_BL:{left:0,center:.5,right:1,bottom:0,middle:.5,top:1},FROM_TL:{left:0,center:.5,right:1,bottom:1,middle:.5,top:0},FROM_BR:{left:1,center:.5,right:0,bottom:0,middle:.5,top:1},LINE_SPACING:1.3,CAP_SHIFT:.7,MID_SHIFT:.35,OPPOSITE_SIDE:{left:"right",right:"left",top:"bottom",bottom:"top"}}},5081:function(e){"use strict";e.exports={axisRefDescription:function(e,t,r){return["If set to a",e,"axis id (e.g. *"+e+"* or","*"+e+"2*), the `"+e+"` position refers to a",e,"coordinate. If set to *paper*, the `"+e+"`","position refers to the distance from the",t,"of the plotting","area in normalized coordinates where *0* (*1*) corresponds to the",t,"("+r+"). If set to a",e,"axis ID followed by","*domain* (separated by a space), the position behaves like for","*paper*, but refers to the distance in fractions of the domain","length from the",t,"of the domain of that axis: e.g.,","*"+e+"2 domain* refers to the domain of the second",e," axis and a",e,"position of 0.5 refers to the","point between the",t,"and the",r,"of the domain of the","second",e,"axis."].join(" ")}}},7296:function(e){"use strict";e.exports={FORMAT_LINK:"https://github.com/d3/d3-format/tree/v1.4.5#d3-format",DATE_FORMAT_LINK:"https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format"}},726:function(e){"use strict";e.exports={COMPARISON_OPS:["=","!=","<",">=",">","<="],COMPARISON_OPS2:["=","<",">=",">","<="],INTERVAL_OPS:["[]","()","[)","(]","][",")(","](",")["],SET_OPS:["{}","}{"],CONSTRAINT_REDUCTION:{"=":"=","<":"<","<=":"<",">":">",">=":">","[]":"[]","()":"[]","[)":"[]","(]":"[]","][":"][",")(":"][","](":"][",")[":"]["}}},438:function(e){"use strict";e.exports={SHOW_PLACEHOLDER:100,HIDE_PLACEHOLDER:1e3,DESELECTDIM:.2}},3821:function(e){"use strict";e.exports={BADNUM:void 0,FP_SAFE:1e-4*Number.MAX_VALUE,ONEMAXYEAR:316224e5,ONEAVGYEAR:315576e5,ONEMINYEAR:31536e6,ONEMAXQUARTER:79488e5,ONEAVGQUARTER:78894e5,ONEMINQUARTER:76896e5,ONEMAXMONTH:26784e5,ONEAVGMONTH:26298e5,ONEMINMONTH:24192e5,ONEWEEK:6048e5,ONEDAY:864e5,ONEHOUR:36e5,ONEMIN:6e4,ONESEC:1e3,ONEMILLI:1,ONEMICROSEC:.001,EPOCHJD:2440587.5,ALMOST_EQUAL:.999999,LOG_CLIP:10,MINUS_SIGN:"−"}},2972:function(e,t){"use strict";t.xmlns="http://www.w3.org/2000/xmlns/",t.svg="http://www.w3.org/2000/svg",t.xlink="http://www.w3.org/1999/xlink",t.svgAttrs={xmlns:t.svg,"xmlns:xlink":t.xlink}},7430:function(e,t,r){"use strict";t.version=r(9697).version,r(1116),r(6713);for(var n=r(3626),a=t.register=n.register,i=r(742),o=Object.keys(i),l=0;l",""," ",""," plotly-logomark"," "," "," "," "," "," "," "," "," "," "," "," "," ",""].join("")}}},2546:function(e,t){"use strict";t.isLeftAnchor=function(e){return"left"===e.xanchor||"auto"===e.xanchor&&e.x<=1/3},t.isCenterAnchor=function(e){return"center"===e.xanchor||"auto"===e.xanchor&&e.x>1/3&&e.x<2/3},t.isRightAnchor=function(e){return"right"===e.xanchor||"auto"===e.xanchor&&e.x>=2/3},t.isTopAnchor=function(e){return"top"===e.yanchor||"auto"===e.yanchor&&e.y>=2/3},t.isMiddleAnchor=function(e){return"middle"===e.yanchor||"auto"===e.yanchor&&e.y>1/3&&e.y<2/3},t.isBottomAnchor=function(e){return"bottom"===e.yanchor||"auto"===e.yanchor&&e.y<=1/3}},4313:function(e,t,r){"use strict";var n=r(8953),a=n.mod,i=n.modHalf,o=Math.PI,l=2*o;function s(e){return Math.abs(e[1]-e[0])>l-1e-14}function c(e,t){return i(t-e,l)}function u(e,t){if(s(t))return!0;var r,n;t[0](n=a(n,l))&&(n+=l);var i=a(e,l),o=i+l;return i>=r&&i<=n||o>=r&&o<=n}function f(e,t,r,n,a,i,c){a=a||0,i=i||0;var u,f,d,h,p,v=s([r,n]);function g(e,t){return[e*Math.cos(t)+a,i-e*Math.sin(t)]}v?(u=0,f=o,d=l):r=a&&e<=i);var a,i},pathArc:function(e,t,r,n,a){return f(null,e,t,r,n,a,0)},pathSector:function(e,t,r,n,a){return f(null,e,t,r,n,a,1)},pathAnnulus:function(e,t,r,n,a,i){return f(e,t,r,n,a,i,1)}}},7800:function(e,t,r){"use strict";var n=r(3229).decode,a=r(6174),i=Array.isArray,o=ArrayBuffer,l=DataView;function s(e){return o.isView(e)&&!(e instanceof l)}function c(e){return i(e)||s(e)}t.isTypedArray=s,t.isArrayOrTypedArray=c,t.isArray1D=function(e){return!c(e[0])},t.ensureArray=function(e,t){return i(e)||(e=[]),e.length=t,e};var u={u1c:"undefined"==typeof Uint8ClampedArray?void 0:Uint8ClampedArray,i1:"undefined"==typeof Int8Array?void 0:Int8Array,u1:"undefined"==typeof Uint8Array?void 0:Uint8Array,i2:"undefined"==typeof Int16Array?void 0:Int16Array,u2:"undefined"==typeof Uint16Array?void 0:Uint16Array,i4:"undefined"==typeof Int32Array?void 0:Int32Array,u4:"undefined"==typeof Uint32Array?void 0:Uint32Array,f4:"undefined"==typeof Float32Array?void 0:Float32Array,f8:"undefined"==typeof Float64Array?void 0:Float64Array};function f(e){return e.constructor===ArrayBuffer}function d(e,t,r){if(c(e)){if(c(e[0])){for(var n=r,a=0;aa.max?t.set(r):t.set(+e)}},integer:{coerceFunction:function(e,t,r,a){-1===(a.extras||[]).indexOf(e)?(p(e)&&(e=v(e)),e%1||!n(e)||void 0!==a.min&&ea.max?t.set(r):t.set(+e)):t.set(e)}},string:{coerceFunction:function(e,t,r,n){if("string"!=typeof e){var a="number"==typeof e;!0!==n.strict&&a?t.set(String(e)):t.set(r)}else n.noBlank&&!e?t.set(r):t.set(e)}},color:{coerceFunction:function(e,t,r){p(e)&&(e=v(e)),a(e).isValid()?t.set(e):t.set(r)}},colorlist:{coerceFunction:function(e,t,r){Array.isArray(e)&&e.length&&e.every((function(e){return a(e).isValid()}))?t.set(e):t.set(r)}},colorscale:{coerceFunction:function(e,t,r){t.set(l.get(e,r))}},angle:{coerceFunction:function(e,t,r){p(e)&&(e=v(e)),"auto"===e?t.set("auto"):n(e)?t.set(d(+e,360)):t.set(r)}},subplotid:{coerceFunction:function(e,t,r,n){var a=n.regex||f(r);"string"==typeof e&&a.test(e)?t.set(e):t.set(r)},validateFunction:function(e,t){var r=t.dflt;return e===r||"string"==typeof e&&!!f(r).test(e)}},flaglist:{coerceFunction:function(e,t,r,n){if(-1===(n.extras||[]).indexOf(e))if("string"==typeof e){for(var a=e.split("+"),i=0;i=n&&e<=a?e:u}if("string"!=typeof e&&"number"!=typeof e)return u;e=String(e);var c=_(r),m=e.charAt(0);!c||"G"!==m&&"g"!==m||(e=e.substr(1),r="");var w=c&&"chinese"===r.substr(0,7),T=e.match(w?x:y);if(!T)return u;var M=T[1],k=T[3]||"1",A=Number(T[5]||1),L=Number(T[7]||0),S=Number(T[9]||0),C=Number(T[11]||0);if(c){if(2===M.length)return u;var O;M=Number(M);try{var D=g.getComponentMethod("calendars","getCal")(r);if(w){var P="i"===k.charAt(k.length-1);k=parseInt(k,10),O=D.newDate(M,D.toMonthIndex(M,k,P),A)}else O=D.newDate(M,Number(k),A)}catch(e){return u}return O?(O.toJD()-v)*f+L*d+S*h+C*p:u}M=2===M.length?(Number(M)+2e3-b)%100+b:Number(M),k-=1;var z=new Date(Date.UTC(2e3,k,A,L,S));return z.setUTCFullYear(M),z.getUTCMonth()!==k||z.getUTCDate()!==A?u:z.getTime()+C*p},n=t.MIN_MS=t.dateTime2ms("-9999"),a=t.MAX_MS=t.dateTime2ms("9999-12-31 23:59:59.9999"),t.isDateTime=function(e,r){return t.dateTime2ms(e,r)!==u};var T=90*f,M=3*d,k=5*h;function A(e,t,r,n,a){if((t||r||n||a)&&(e+=" "+w(t,2)+":"+w(r,2),(n||a)&&(e+=":"+w(n,2),a))){for(var i=4;a%10==0;)i-=1,a/=10;e+="."+w(a,i)}return e}t.ms2DateTime=function(e,t,r){if("number"!=typeof e||!(e>=n&&e<=a))return u;t||(t=0);var i,o,l,c,y,x,b=Math.floor(10*s(e+.05,1)),w=Math.round(e-b/10);if(_(r)){var L=Math.floor(w/f)+v,S=Math.floor(s(e,f));try{i=g.getComponentMethod("calendars","getCal")(r).fromJD(L).formatDate("yyyy-mm-dd")}catch(e){i=m("G%Y-%m-%d")(new Date(w))}if("-"===i.charAt(0))for(;i.length<11;)i="-0"+i.substr(1);else for(;i.length<10;)i="0"+i;o=t=n+f&&e<=a-f))return u;var t=Math.floor(10*s(e+.05,1)),r=new Date(Math.round(e-t/10));return A(i("%Y-%m-%d")(r),r.getHours(),r.getMinutes(),r.getSeconds(),10*r.getUTCMilliseconds()+t)},t.cleanDate=function(e,r,n){if(e===u)return r;if(t.isJSDate(e)||"number"==typeof e&&isFinite(e)){if(_(n))return l.error("JS Dates and milliseconds are incompatible with world calendars",e),r;if(!(e=t.ms2DateTimeLocal(+e))&&void 0!==r)return r}else if(!t.isDateTime(e,n))return l.error("unrecognized date",e),r;return e};var L=/%\d?f/g,S=/%h/g,C={1:"1",2:"1",3:"2",4:"2"};function O(e,t,r,n){e=e.replace(L,(function(e){var r=Math.min(+e.charAt(1)||6,6);return(t/1e3%1+2).toFixed(r).substr(2).replace(/0+$/,"")||"0"}));var a=new Date(Math.floor(t+.05));if(e=e.replace(S,(function(){return C[r("%q")(a)]})),_(n))try{e=g.getComponentMethod("calendars","worldCalFmt")(e,t,n)}catch(e){return"Invalid"}return r(e)(a)}var D=[59,59.9,59.99,59.999,59.9999];t.formatDate=function(e,t,r,n,a,i){if(a=_(a)&&a,!t)if("y"===r)t=i.year;else if("m"===r)t=i.month;else{if("d"!==r)return function(e,t){var r=s(e+.05,f),n=w(Math.floor(r/d),2)+":"+w(s(Math.floor(r/h),60),2);if("M"!==t){o(t)||(t=0);var a=(100+Math.min(s(e/p,60),D[t])).toFixed(t).substr(1);t>0&&(a=a.replace(/0+$/,"").replace(/[\.]$/,"")),n+=":"+a}return n}(e,r)+"\n"+O(i.dayMonthYear,e,n,a);t=i.dayMonth+"\n"+i.year}return O(t,e,n,a)};var P=3*f;t.incrementMonth=function(e,t,r){r=_(r)&&r;var n=s(e,f);if(e=Math.round(e-n),r)try{var a=Math.round(e/f)+v,i=g.getComponentMethod("calendars","getCal")(r),o=i.fromJD(a);return t%12?i.add(o,t,"m"):i.add(o,t/12,"y"),(o.toJD()-v)*f+n}catch(t){l.error("invalid ms "+e+" in calendar "+r)}var c=new Date(e+P);return c.setUTCMonth(c.getUTCMonth()+t)+n-P},t.findExactDates=function(e,t){for(var r,n,a=0,i=0,l=0,s=0,c=_(t)&&g.getComponentMethod("calendars","getCal")(t),u=0;u1||v<0||v>1?null:{x:e+s*v,y:t+f*v}}function s(e,t,r,n,a){var i=n*e+a*t;if(i<0)return n*n+a*a;if(i>r){var o=n-e,l=a-t;return o*o+l*l}var s=n*t-a*e;return s*s/r}t.segmentsIntersect=l,t.segmentDistance=function(e,t,r,n,a,i,o,c){if(l(e,t,r,n,a,i,o,c))return 0;var u=r-e,f=n-t,d=o-a,h=c-i,p=u*u+f*f,v=d*d+h*h,g=Math.min(s(u,f,p,a-e,i-t),s(u,f,p,o-e,c-t),s(d,h,v,e-a,t-i),s(d,h,v,r-a,n-i));return Math.sqrt(g)},t.getTextLocation=function(e,t,r,l){if(e===a&&l===i||(n={},a=e,i=l),n[r])return n[r];var s=e.getPointAtLength(o(r-l/2,t)),c=e.getPointAtLength(o(r+l/2,t)),u=Math.atan((c.y-s.y)/(c.x-s.x)),f=e.getPointAtLength(o(r,t)),d={x:(4*f.x+s.x+c.x)/6,y:(4*f.y+s.y+c.y)/6,theta:u};return n[r]=d,d},t.clearLocationCache=function(){a=null},t.getVisibleSegment=function(e,t,r){var n,a,i=t.left,o=t.right,l=t.top,s=t.bottom,c=0,u=e.getTotalLength(),f=u;function d(t){var r=e.getPointAtLength(t);0===t?n=r:t===u&&(a=r);var c=r.xo?r.x-o:0,f=r.ys?r.y-s:0;return Math.sqrt(c*c+f*f)}for(var h=d(c);h;){if((c+=h+r)>f)return;h=d(c)}for(h=d(f);h;){if(c>(f-=h+r))return;h=d(f)}return{min:c,max:f,len:f-c,total:u,isClosed:0===c&&f===u&&Math.abs(n.x-a.x)<.1&&Math.abs(n.y-a.y)<.1}},t.findPointOnPath=function(e,t,r,n){for(var a,i,o,l=(n=n||{}).pathLength||e.getTotalLength(),s=n.tolerance||.001,c=n.iterationLimit||30,u=e.getPointAtLength(0)[r]>e.getPointAtLength(l)[r]?-1:1,f=0,d=0,h=l;f0?h=a:d=a,f++}return i}},9527:function(e){"use strict";e.exports=function(e){return e}},688:function(e){"use strict";e.exports=function(e,t){if(!t)return e;var r=1/Math.abs(t),n=r>1?(r*e+r*t)/r:e+t,a=String(n).length;if(a>16){var i=String(t).length;if(a>=String(e).length+i){var o=parseFloat(n).toPrecision(12);-1===o.indexOf("e+")&&(n=+o)}}return n}},4809:function(e,t,r){"use strict";var n=r(5568),a=r(2696).aL,i=r(6464).GP,o=r(721),l=r(3821),s=l.FP_SAFE,c=-s,u=l.BADNUM,f=e.exports={};f.adjustFormat=function(e){return!e||/^\d[.]\df/.test(e)||/[.]\d%/.test(e)?e:"0.f"===e?"~f":/^\d%/.test(e)?"~%":/^\ds/.test(e)?"~s":!/^[~,.0$]/.test(e)&&/[&fps]/.test(e)?"~"+e:e};var d={};f.warnBadFormat=function(e){var t=String(e);d[t]||(d[t]=1,f.warn('encountered bad format: "'+t+'"'))},f.noFormat=function(e){return String(e)},f.numberFormat=function(e){var t;try{t=i(f.adjustFormat(e))}catch(t){return f.warnBadFormat(e),f.noFormat}return t},f.nestedProperty=r(5632),f.keyedContainer=r(4967),f.relativeAttr=r(2047),f.isPlainObject=r(6174),f.toLogRange=r(8083),f.relinkPrivateKeys=r(428);var h=r(7800);f.isArrayBuffer=h.isArrayBuffer,f.isTypedArray=h.isTypedArray,f.isArrayOrTypedArray=h.isArrayOrTypedArray,f.isArray1D=h.isArray1D,f.ensureArray=h.ensureArray,f.concat=h.concat,f.maxRowLength=h.maxRowLength,f.minRowLength=h.minRowLength;var p=r(8953);f.mod=p.mod,f.modHalf=p.modHalf;var v=r(4220);f.valObjectMeta=v.valObjectMeta,f.coerce=v.coerce,f.coerce2=v.coerce2,f.coerceFont=v.coerceFont,f.coercePattern=v.coercePattern,f.coerceHoverinfo=v.coerceHoverinfo,f.coerceSelectionMarkerOpacity=v.coerceSelectionMarkerOpacity,f.validate=v.validate;var g=r(2596);f.dateTime2ms=g.dateTime2ms,f.isDateTime=g.isDateTime,f.ms2DateTime=g.ms2DateTime,f.ms2DateTimeLocal=g.ms2DateTimeLocal,f.cleanDate=g.cleanDate,f.isJSDate=g.isJSDate,f.formatDate=g.formatDate,f.incrementMonth=g.incrementMonth,f.dateTick0=g.dateTick0,f.dfltRange=g.dfltRange,f.findExactDates=g.findExactDates,f.MIN_MS=g.MIN_MS,f.MAX_MS=g.MAX_MS;var m=r(8813);f.findBin=m.findBin,f.sorterAsc=m.sorterAsc,f.sorterDes=m.sorterDes,f.distinctVals=m.distinctVals,f.roundUp=m.roundUp,f.sort=m.sort,f.findIndexOfMin=m.findIndexOfMin,f.sortObjectKeys=r(2994);var y=r(9258);f.aggNums=y.aggNums,f.len=y.len,f.mean=y.mean,f.geometricMean=y.geometricMean,f.median=y.median,f.midRange=y.midRange,f.variance=y.variance,f.stdev=y.stdev,f.interp=y.interp;var x=r(5236);f.init2dArray=x.init2dArray,f.transposeRagged=x.transposeRagged,f.dot=x.dot,f.translationMatrix=x.translationMatrix,f.rotationMatrix=x.rotationMatrix,f.rotationXYMatrix=x.rotationXYMatrix,f.apply3DTransform=x.apply3DTransform,f.apply2DTransform=x.apply2DTransform,f.apply2DTransform2=x.apply2DTransform2,f.convertCssMatrix=x.convertCssMatrix,f.inverseTransformMatrix=x.inverseTransformMatrix;var b=r(4313);f.deg2rad=b.deg2rad,f.rad2deg=b.rad2deg,f.angleDelta=b.angleDelta,f.angleDist=b.angleDist,f.isFullCircle=b.isFullCircle,f.isAngleInsideSector=b.isAngleInsideSector,f.isPtInsideSector=b.isPtInsideSector,f.pathArc=b.pathArc,f.pathSector=b.pathSector,f.pathAnnulus=b.pathAnnulus;var _=r(2546);f.isLeftAnchor=_.isLeftAnchor,f.isCenterAnchor=_.isCenterAnchor,f.isRightAnchor=_.isRightAnchor,f.isTopAnchor=_.isTopAnchor,f.isMiddleAnchor=_.isMiddleAnchor,f.isBottomAnchor=_.isBottomAnchor;var w=r(3447);f.segmentsIntersect=w.segmentsIntersect,f.segmentDistance=w.segmentDistance,f.getTextLocation=w.getTextLocation,f.clearLocationCache=w.clearLocationCache,f.getVisibleSegment=w.getVisibleSegment,f.findPointOnPath=w.findPointOnPath;var T=r(3049);f.extendFlat=T.extendFlat,f.extendDeep=T.extendDeep,f.extendDeepAll=T.extendDeepAll,f.extendDeepNoArrays=T.extendDeepNoArrays;var M=r(8636);f.log=M.log,f.warn=M.warn,f.error=M.error;var k=r(694);f.counterRegex=k.counter;var A=r(4025);f.throttle=A.throttle,f.throttleDone=A.done,f.clearThrottle=A.clear;var L=r(5425);function S(e){var t={};for(var r in e)for(var n=e[r],a=0;as||e=t)&&o(e)&&e>=0&&e%1==0},f.noop=r(4969),f.identity=r(9527),f.repeat=function(e,t){for(var r=new Array(t),n=0;nr?Math.max(r,Math.min(t,e)):Math.max(t,Math.min(r,e))},f.bBoxIntersect=function(e,t,r){return r=r||0,e.left<=t.right+r&&t.left<=e.right+r&&e.top<=t.bottom+r&&t.top<=e.bottom+r},f.simpleMap=function(e,t,r,n,a){for(var i=e.length,o=new Array(i),l=0;l=Math.pow(2,r)?a>10?(f.warn("randstr failed uniqueness"),s):e(t,r,n,(a||0)+1):s},f.OptionControl=function(e,t){e||(e={}),t||(t="opt");var r={optionList:[],_newoption:function(n){n[t]=e,r[n.name]=n,r.optionList.push(n)}};return r["_"+t]=e,r},f.smooth=function(e,t){if((t=Math.round(t)||0)<2)return e;var r,n,a,i,o=e.length,l=2*o,s=2*t-1,c=new Array(s),u=new Array(o);for(r=0;r=l&&(a-=l*Math.floor(a/l)),a<0?a=-1-a:a>=o&&(a=l-1-a),i+=e[a]*c[n];u[r]=i}return u},f.syncOrAsync=function(e,t,r){var n;function a(){return f.syncOrAsync(e,t,r)}for(;e.length;)if((n=(0,e.splice(0,1)[0])(t))&&n.then)return n.then(a);return r&&r(t)},f.stripTrailingSlash=function(e){return"/"===e.substr(-1)?e.substr(0,e.length-1):e},f.noneOrAll=function(e,t,r){if(e){var n,a=!1,i=!0;for(n=0;n0?t:0}))},f.fillArray=function(e,t,r,n){if(n=n||f.identity,f.isArrayOrTypedArray(e))for(var a=0;a1?a+o[1]:"";if(i&&(o.length>1||l.length>4||r))for(;n.test(l);)l=l.replace(n,"$1"+i+"$2");return l+s},f.TEMPLATE_STRING_REGEX=/%{([^\s%{}:]*)([:|\|][^}]*)?}/g;var E=/^\w*$/;f.templateString=function(e,t){var r={};return e.replace(f.TEMPLATE_STRING_REGEX,(function(e,n){var a;return E.test(n)?a=t[n]:(r[n]=r[n]||f.nestedProperty(t,n).get,a=r[n]()),f.isValidTextValue(a)?a:""}))};var R={max:10,count:0,name:"hovertemplate"};f.hovertemplateString=function(){return j.apply(R,arguments)};var N={max:10,count:0,name:"texttemplate"};f.texttemplateString=function(){return j.apply(N,arguments)};var F=/^(\S+)([\*\/])(-?\d+(\.\d+)?)$/,H={max:10,count:0,name:"texttemplate",parseMultDiv:!0};f.texttemplateStringForShapes=function(){return j.apply(H,arguments)};var B=/^[:|\|]/;function j(e,t,r){var n=this,i=arguments;t||(t={});var o={};return e.replace(f.TEMPLATE_STRING_REGEX,(function(e,l,s){var c="_xother"===l||"_yother"===l,u="_xother_"===l||"_yother_"===l,d="xother_"===l||"yother_"===l,h="xother"===l||"yother"===l||c||d||u,p=l;(c||u)&&(p=p.substring(1)),(d||u)&&(p=p.substring(0,p.length-1));var v,g,m,y=null,x=null;if(n.parseMultDiv){var b=function(e){var t=e.match(F);return t?{key:t[1],op:t[2],number:Number(t[3])}:{key:e,op:null,number:null}}(p);p=b.key,y=b.op,x=b.number}if(h){if(void 0===(v=t[p]))return""}else for(m=3;m=48&&o<=57,c=l>=48&&l<=57;if(s&&(n=10*n+o-48),c&&(a=10*a+l-48),!s||!c){if(n!==a)return n-a;if(o!==l)return o-l}}return a-n};var Y=2e9;f.seedPseudoRandom=function(){Y=2e9},f.pseudoRandom=function(){var e=Y;return Y=(69069*Y+1)%4294967296,Math.abs(Y-e)<429496729?f.pseudoRandom():Y/4294967296},f.fillText=function(e,t,r){var n=Array.isArray(r)?function(e){r.push(e)}:function(e){r.text=e},a=f.extractOption(e,t,"htx","hovertext");if(f.isValidTextValue(a))return n(a);var i=f.extractOption(e,t,"tx","text");return f.isValidTextValue(i)?n(i):void 0},f.isValidTextValue=function(e){return e||0===e},f.formatPercent=function(e,t){t=t||0;for(var r=(Math.round(100*e*Math.pow(10,t))*Math.pow(.1,t)).toFixed(t)+"%",n=0;n1&&(c=1):c=0,f.strTranslate(a-c*(r+o),i-c*(n+l))+f.strScale(c)+(s?"rotate("+s+(t?"":" "+r+" "+n)+")":"")},f.setTransormAndDisplay=function(e,t){e.attr("transform",f.getTextTransform(t)),e.style("display",t.scale?null:"none")},f.ensureUniformFontSize=function(e,t){var r=f.extendFlat({},t);return r.size=Math.max(t.size,e._fullLayout.uniformtext.minsize||0),r},f.join2=function(e,t,r){var n=e.length;return n>1?e.slice(0,-1).join(t)+r+e[n-1]:e.join(t)},f.bigFont=function(e){return Math.round(1.2*e)};var V=f.getFirefoxVersion(),U=null!==V&&V<86;f.getPositionFromD3Event=function(){return U?[n.event.layerX,n.event.layerY]:[n.event.offsetX,n.event.offsetY]}},6174:function(e){"use strict";e.exports=function(e){return window&&window.process&&window.process.versions?"[object Object]"===Object.prototype.toString.call(e):"[object Object]"===Object.prototype.toString.call(e)&&Object.getPrototypeOf(e).hasOwnProperty("hasOwnProperty")}},4967:function(e,t,r){"use strict";var n=r(5632),a=/^\w*$/;e.exports=function(e,t,r,i){var o,l,s;r=r||"name",i=i||"value";var c={};t&&t.length?(s=n(e,t),l=s.get()):l=e,t=t||"";var u={};if(l)for(o=0;o2)return c[t]=2|c[t],d.set(e,null);if(f){for(o=t;o1){var t=["LOG:"];for(e=0;e1){var r=[];for(e=0;e"),"long")}},i.warn=function(){var e;if(n.logging>0){var t=["WARN:"];for(e=0;e0){var r=[];for(e=0;e"),"stick")}},i.error=function(){var e;if(n.logging>0){var t=["ERROR:"];for(e=0;e0){var r=[];for(e=0;e"),"stick")}}},5944:function(e,t,r){"use strict";var n=r(5568);e.exports=function(e,t,r){var a=e.selectAll("g."+r.replace(/\s/g,".")).data(t,(function(e){return e[0].trace.uid}));a.exit().remove(),a.enter().append("g").attr("class",r),a.order();var i=e.classed("rangeplot")?"nodeRangePlot3":"node3";return a.each((function(e){e[0][i]=n.select(this)})),a}},5236:function(e,t,r){"use strict";var n=r(1191);t.init2dArray=function(e,t){for(var r=new Array(e),n=0;nt/2?e-Math.round(e/t)*t:e}}},5632:function(e,t,r){"use strict";var n=r(721),a=r(7800).isArrayOrTypedArray;function i(e,t){return function(){var r,n,o,l,s,c=e;for(l=0;l/g),s=0;si||c===a||cl||t&&s(e))}:function(e,t){var s=e[0],c=e[1];if(s===a||si||c===a||cl)return!1;var u,f,d,h,p,v=r.length,g=r[0][0],m=r[0][1],y=0;for(u=1;uMath.max(f,g)||c>Math.max(d,m)))if(cu||Math.abs(n(o,d))>a)return!0;return!1},i.filter=function(e,t){var r=[e[0]],n=0,a=0;function o(o){e.push(o);var l=r.length,s=n;r.splice(a+1);for(var c=s+1;c1&&o(e.pop()),{addPt:o,raw:e,filtered:r}}},2521:function(e,t,r){"use strict";var n=r(721),a=r(3087);e.exports=function(e){var t;if("string"!=typeof(t=e&&e.hasOwnProperty("userAgent")?e.userAgent:function(){var e;return"undefined"!=typeof navigator&&(e=navigator.userAgent),e&&e.headers&&"string"==typeof e.headers["user-agent"]&&(e=e.headers["user-agent"]),e}()))return!0;var r=a({ua:{headers:{"user-agent":t}},tablet:!0,featureDetect:!1});if(!r)for(var i=t.split(" "),o=1;o-1;l--){var s=i[l];if("Version/"===s.substr(0,8)){var c=s.substr(8).split(".")[0];if(n(c)&&(c=+c),c>=13)return!0}}return r}},6539:function(e){"use strict";e.exports=function(e,t){if(t instanceof RegExp){for(var r=t.toString(),n=0;na.queueLength&&(e.undoQueue.queue.shift(),e.undoQueue.index--))},startSequence:function(e){e.undoQueue=e.undoQueue||{index:0,queue:[],sequence:!1},e.undoQueue.sequence=!0,e.undoQueue.beginSequence=!0},stopSequence:function(e){e.undoQueue=e.undoQueue||{index:0,queue:[],sequence:!1},e.undoQueue.sequence=!1,e.undoQueue.beginSequence=!1},undo:function(e){var t,r;if(!(void 0===e.undoQueue||isNaN(e.undoQueue.index)||e.undoQueue.index<=0)){for(e.undoQueue.index--,t=e.undoQueue.queue[e.undoQueue.index],e.undoQueue.inSequence=!0,r=0;r=e.undoQueue.queue.length)){for(t=e.undoQueue.queue[e.undoQueue.index],e.undoQueue.inSequence=!0,r=0;rt}function f(e,t){return e>=t}t.findBin=function(e,t,r){if(n(t.start))return r?Math.ceil((e-t.start)/t.size-l)-1:Math.floor((e-t.start)/t.size+l);var i,o,d=0,h=t.length,p=0,v=h>1?(t[h-1]-t[0])/(h-1):1;for(o=v>=0?r?s:c:r?f:u,e+=v*l*(r?-1:1)*(v>=0?1:-1);d90&&a.log("Long binary search..."),d-1},t.sorterAsc=function(e,t){return e-t},t.sorterDes=function(e,t){return t-e},t.distinctVals=function(e){var r,n=e.slice();for(n.sort(t.sorterAsc),r=n.length-1;r>-1&&n[r]===o;r--);for(var a,i=n[r]-n[0]||1,l=i/(r||1)/1e4,s=[],c=0;c<=r;c++){var u=n[c],f=u-a;void 0===a?(s.push(u),a=u):f>l&&(i=Math.min(i,f),s.push(u),a=u)}return{vals:s,minDiff:i}},t.roundUp=function(e,t,r){for(var n,a=0,i=t.length-1,o=0,l=r?0:1,s=r?1:0,c=r?Math.ceil:Math.floor;a0&&(n=1),r&&n)return e.sort(t)}return n?e:e.reverse()},t.findIndexOfMin=function(e,t){t=t||i;for(var r,n=1/0,a=0;ai.length)&&(o=i.length),n(r)||(r=!1),a(i[0])){for(s=new Array(o),l=0;le.length-1)return e[e.length-1];var r=t%1;return r*e[Math.ceil(t)]+(1-r)*e[Math.floor(t)]}},635:function(e,t,r){"use strict";var n=r(5568),a=r(4809),i=a.strTranslate,o=r(2972),l=r(4530).LINE_SPACING,s=/([^$]*)([$]+[^$]*[$]+)([^$]*)/;t.convertToTspans=function(e,r,g){var L=e.text(),S=!e.attr("data-notex")&&r&&r._context.typesetMath&&"undefined"!=typeof MathJax&&L.match(s),D=n.select(e.node().parentNode);if(!D.empty()){var P=e.attr("class")?e.attr("class").split(" ")[0]:"text";return P+="-math",D.selectAll("svg."+P).remove(),D.selectAll("g."+P+"-group").remove(),e.style("display",null).attr({"data-unformatted":L,"data-math":"N"}),S?(r&&r._promises||[]).push(new Promise((function(t){e.style("display","none");var r=parseInt(e.node().style.fontSize,10),o={fontSize:r};!function(e,t,r){var i,o,l,s,d=parseInt((MathJax.version||"").split(".")[0]);if(2===d||3===d){var h=function(){var r="math-output-"+a.randstr({},64),i=(s=n.select("body").append("div").attr({id:r}).style({visibility:"hidden",position:"absolute","font-size":t.fontSize+"px"}).text(e.replace(c,"\\lt ").replace(u,"\\gt "))).node();return 2===d?MathJax.Hub.Typeset(i):MathJax.typeset([i])},p=function(){var t=s.select(2===d?".MathJax_SVG":".MathJax"),i=!t.empty()&&s.select("svg").node();if(i){var o,l=i.getBoundingClientRect();o=2===d?n.select("body").select("#MathJax_SVG_glyphs"):t.select("defs"),r(t,o,l)}else a.log("There was an error in the tex syntax.",e),r();s.remove()};2===d?MathJax.Hub.Queue((function(){return o=a.extendDeepAll({},MathJax.Hub.config),l=MathJax.Hub.processSectionDelay,void 0!==MathJax.Hub.processSectionDelay&&(MathJax.Hub.processSectionDelay=0),MathJax.Hub.Config({messageStyle:"none",tex2jax:{inlineMath:f},displayAlign:"left"})}),(function(){if("SVG"!==(i=MathJax.Hub.config.menuSettings.renderer))return MathJax.Hub.setRenderer("SVG")}),h,p,(function(){if("SVG"!==i)return MathJax.Hub.setRenderer(i)}),(function(){return void 0!==l&&(MathJax.Hub.processSectionDelay=l),MathJax.Hub.Config(o)})):3===d&&(o=a.extendDeepAll({},MathJax.config),MathJax.config.tex||(MathJax.config.tex={}),MathJax.config.tex.inlineMath=f,"svg"!==(i=MathJax.config.startup.output)&&(MathJax.config.startup.output="svg"),MathJax.startup.defaultReady(),MathJax.startup.promise.then((function(){h(),p(),"svg"!==i&&(MathJax.config.startup.output=i),MathJax.config=o})))}else a.warn("No MathJax version:",MathJax.version)}(S[2],o,(function(n,a,o){D.selectAll("svg."+P).remove(),D.selectAll("g."+P+"-group").remove();var l=n&&n.select("svg");if(!l||!l.node())return z(),void t();var s=D.append("g").classed(P+"-group",!0).attr({"pointer-events":"none","data-unformatted":L,"data-math":"Y"});s.node().appendChild(l.node()),a&&a.node()&&l.node().insertBefore(a.node().cloneNode(!0),l.node().firstChild);var c=o.width,u=o.height;l.attr({class:P,height:u,preserveAspectRatio:"xMinYMin meet"}).style({overflow:"visible","pointer-events":"none"});var f=e.node().style.fill||"black",d=l.select("g");d.attr({fill:f,stroke:f});var h=d.node().getBoundingClientRect(),p=h.width,v=h.height;(p>c||v>u)&&(l.style("overflow","hidden"),p=(h=l.node().getBoundingClientRect()).width,v=h.height);var m=+e.attr("x"),y=+e.attr("y"),x=-(r||e.node().getBoundingClientRect().height)/4;if("y"===P[0])s.attr({transform:"rotate("+[-90,m,y]+")"+i(-p/2,x-v/2)});else if("l"===P[0])y=x-v/2;else if("a"===P[0]&&0!==P.indexOf("atitle"))m=0,y=x;else{var b=e.attr("text-anchor");m-=p*("middle"===b?.5:"end"===b?1:0),y=y+x-v/2}l.attr({x:m,y:y}),g&&g.call(e,s),t(s)}))}))):z(),e}function z(){D.empty()||(P=e.attr("class")+"-math",D.select("svg."+P).remove()),e.text("").style("white-space","pre");var r=function(e,t){t=t.replace(m," ");var r,i=!1,s=[],c=-1;function u(){c++;var t=document.createElementNS(o.svg,"tspan");n.select(t).attr({class:"line",dy:c*l+"em"}),e.appendChild(t),r=t;var a=s;if(s=[{node:t}],a.length>1)for(var i=1;i doesnt match end tag <"+e+">. Pretending it did match.",t),r=s[s.length-1].node}else a.log("Ignoring unexpected end tag .",t)}b.test(t)?u():(r=e,s=[{node:e}]);for(var S=t.split(y),D=0;D|>|>)/g,f=[["$","$"],["\\(","\\)"]],d={sup:"font-size:70%",sub:"font-size:70%",s:"text-decoration:line-through",u:"text-decoration:underline",b:"font-weight:bold",i:"font-style:italic",a:"cursor:pointer",span:"",em:"font-style:italic;font-weight:bold"},h={sub:"0.3em",sup:"-0.6em"},p={sub:"-0.21em",sup:"0.42em"},v="​",g=["http:","https:","mailto:","",void 0,":"],m=t.NEWLINES=/(\r\n?|\n)/g,y=/(<[^<>]*>)/,x=/<(\/?)([^ >]*)(\s+(.*))?>/i,b=//i;t.BR_TAG_ALL=//gi;var _=/(^|[\s"'])style\s*=\s*("([^"]*);?"|'([^']*);?')/i,w=/(^|[\s"'])href\s*=\s*("([^"]*)"|'([^']*)')/i,T=/(^|[\s"'])target\s*=\s*("([^"\s]*)"|'([^'\s]*)')/i,M=/(^|[\s"'])popup\s*=\s*("([\w=,]*)"|'([\w=,]*)')/i;function k(e,t){if(!e)return null;var r=e.match(t),n=r&&(r[3]||r[4]);return n&&C(n)}var A=/(^|;)\s*color:/;t.plainText=function(e,t){for(var r=void 0!==(t=t||{}).len&&-1!==t.len?t.len:1/0,n=void 0!==t.allowedTags?t.allowedTags:["br"],a=e.split(y),i=[],o="",l=0,s=0;s3?i.push(c.substr(0,h-3)+"..."):i.push(c.substr(0,h));break}o=""}}return i.join("")};var L={mu:"μ",amp:"&",lt:"<",gt:">",nbsp:" ",times:"×",plusmn:"±",deg:"°"},S=/&(#\d+|#x[\da-fA-F]+|[a-z]+);/g;function C(e){return e.replace(S,(function(e,t){return("#"===t.charAt(0)?function(e){if(!(e>1114111)){var t=String.fromCodePoint;if(t)return t(e);var r=String.fromCharCode;return e<=65535?r(e):r(55232+(e>>10),e%1024+56320)}}("x"===t.charAt(1)?parseInt(t.substr(2),16):parseInt(t.substr(1),10)):L[t])||e}))}function O(e){var t=encodeURI(decodeURI(e)),r=document.createElement("a"),n=document.createElement("a");r.href=e,n.href=t;var a=r.protocol,i=n.protocol;return-1!==g.indexOf(a)&&-1!==g.indexOf(i)?t:""}function D(e,t,r){var n,i,o,l=r.horizontalAlign,s=r.verticalAlign||"top",c=e.node().getBoundingClientRect(),u=t.node().getBoundingClientRect();return i="bottom"===s?function(){return c.bottom-n.height}:"middle"===s?function(){return c.top+(c.height-n.height)/2}:function(){return c.top},o="right"===l?function(){return c.right-n.width}:"center"===l?function(){return c.left+(c.width-n.width)/2}:function(){return c.left},function(){n=this.node().getBoundingClientRect();var e=o()-u.left,t=i()-u.top,l=r.gd||{};if(r.gd){l._fullLayout._calcInverseTransform(l);var s=a.apply3DTransform(l._fullLayout._invTransform)(e,t);e=s[0],t=s[1]}return this.style({top:t+"px",left:e+"px","z-index":1e3}),this}}t.convertEntities=C,t.sanitizeHTML=function(e){e=e.replace(m," ");for(var t=document.createElement("p"),r=t,a=[],i=e.split(y),o=0;oi.ts+t?s():i.timer=setTimeout((function(){s(),i.timer=null}),t)},t.done=function(e){var t=r[e];return t&&t.timer?new Promise((function(e){var r=t.onDone;t.onDone=function(){r&&r(),e(),t.onDone=null}})):Promise.resolve()},t.clear=function(e){if(e)n(r[e]),delete r[e];else for(var a in r)t.clear(a)}},8083:function(e,t,r){"use strict";var n=r(721);e.exports=function(e,t){if(e>0)return Math.log(e)/Math.LN10;var r=Math.log(Math.min(t[0],t[1]))/Math.LN10;return n(r)||(r=Math.log(Math.max(t[0],t[1]))/Math.LN10-6),r}},4611:function(e){"use strict";e.exports={moduleType:"locale",name:"en-US",dictionary:{"Click to enter Colorscale title":"Click to enter Colorscale title"},format:{date:"%m/%d/%Y"}}},227:function(e){"use strict";e.exports={moduleType:"locale",name:"en",dictionary:{"Click to enter Colorscale title":"Click to enter Colourscale title"},format:{days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],periods:["AM","PM"],dateTime:"%a %b %e %X %Y",date:"%d/%m/%Y",time:"%H:%M:%S",decimal:".",thousands:",",grouping:[3],currency:["$",""],year:"%Y",month:"%b %Y",dayMonth:"%b %-d",dayMonthYear:"%b %-d, %Y"}}},6037:function(e,t,r){"use strict";var n=r(3626);e.exports=function(e){for(var t,r,a=n.layoutArrayContainers,i=n.layoutArrayRegexes,o=e.split("[")[0],l=0;l0&&o.log("Clearing previous rejected promises from queue."),e._promises=[]},t.cleanLayout=function(e){var r,n;e||(e={}),e.xaxis1&&(e.xaxis||(e.xaxis=e.xaxis1),delete e.xaxis1),e.yaxis1&&(e.yaxis||(e.yaxis=e.yaxis1),delete e.yaxis1),e.scene1&&(e.scene||(e.scene=e.scene1),delete e.scene1);var i=(l.subplotsRegistry.cartesian||{}).attrRegex,s=(l.subplotsRegistry.polar||{}).attrRegex,f=(l.subplotsRegistry.ternary||{}).attrRegex,d=(l.subplotsRegistry.gl3d||{}).attrRegex,v=Object.keys(e);for(r=0;r3?(z.x=1.02,z.xanchor="left"):z.x<-2&&(z.x=-.02,z.xanchor="right"),z.y>3?(z.y=1.02,z.yanchor="bottom"):z.y<-2&&(z.y=-.02,z.yanchor="top")),p(e),"rotate"===e.dragmode&&(e.dragmode="orbit"),c.clean(e),e.template&&e.template.layout&&t.cleanLayout(e.template.layout),e},t.cleanData=function(e){for(var r=0;r0)return e.substr(0,t)}t.hasParent=function(e,t){for(var r=b(t);r;){if(r in e)return!0;r=b(r)}return!1};var _=["x","y","z"];t.clearAxisTypes=function(e,t,r){for(var n=0;n1&&i.warn("Full array edits are incompatible with other edits",f);var y=r[""][""];if(c(y))t.set(null);else{if(!Array.isArray(y))return i.warn("Unrecognized full array edit value",f,y),!0;t.set(y)}return!v&&(d(g,m),h(e),!0)}var x,b,_,w,T,M,k,A,L=Object.keys(r).map(Number).sort(o),S=t.get(),C=S||[],O=u(m,f).get(),D=[],P=-1,z=C.length;for(x=0;xC.length-(k?0:1))i.warn("index out of range",f,_);else if(void 0!==M)T.length>1&&i.warn("Insertion & removal are incompatible with edits to the same index.",f,_),c(M)?D.push(_):k?("add"===M&&(M={}),C.splice(_,0,M),O&&O.splice(_,0,{})):i.warn("Unrecognized full object edit value",f,_,M),-1===P&&(P=_);else for(b=0;b=0;x--)C.splice(D[x],1),O&&O.splice(D[x],1);if(C.length?S||t.set(C):t.set(null),v)return!1;if(d(g,m),p!==a){var I;if(-1===P)I=L;else{for(z=Math.max(C.length,z),I=[],x=0;x=P);x++)I.push(_);for(x=P;x=e.data.length||a<-e.data.length)throw new Error(r+" must be valid indices for gd.data.");if(t.indexOf(a,n+1)>-1||a>=0&&t.indexOf(-e.data.length+a)>-1||a<0&&t.indexOf(e.data.length+a)>-1)throw new Error("each index in "+r+" must be unique.")}}function I(e,t,r){if(!Array.isArray(e.data))throw new Error("gd.data must be an array.");if(void 0===t)throw new Error("currentIndices is a required argument.");if(Array.isArray(t)||(t=[t]),z(e,t,"currentIndices"),void 0===r||Array.isArray(r)||(r=[r]),void 0!==r&&z(e,r,"newIndices"),void 0!==r&&t.length!==r.length)throw new Error("current and new indices must be of equal length.")}function E(e,t,r,n,i){!function(e,t,r,n){var a=o.isPlainObject(n);if(!Array.isArray(e.data))throw new Error("gd.data must be an array");if(!o.isPlainObject(t))throw new Error("update must be a key:value object");if(void 0===r)throw new Error("indices must be an integer or array of integers");for(var i in z(e,r,"indices"),t){if(!Array.isArray(t[i])||t[i].length!==r.length)throw new Error("attribute "+i+" must be an array of length equal to indices array length");if(a&&(!(i in n)||!Array.isArray(n[i])||n[i].length!==t[i].length))throw new Error("when maxPoints is set as a key:value object it must contain a 1:1 corrispondence with the keys and number of traces in the update object")}}(e,t,r,n);for(var s=function(e,t,r,n){var i,s,c,u,f,d=o.isPlainObject(n),h=[];for(var p in Array.isArray(r)||(r=[r]),r=P(r,e.data.length-1),t)for(var v=0;v-1&&-1===r.indexOf("grouptitlefont")?s(r,r.replace("titlefont","title.font")):r.indexOf("titleposition")>-1?s(r,r.replace("titleposition","title.position")):r.indexOf("titleside")>-1?s(r,r.replace("titleside","title.side")):r.indexOf("titleoffset")>-1&&s(r,r.replace("titleoffset","title.offset")):s(r,r.replace("title","title.text"));function s(t,r){e[r]=e[t],delete e[t]}}function V(e,t,r){e=o.getGraphDiv(e),T.clearPromiseQueue(e);var n={};if("string"==typeof t)n[t]=r;else{if(!o.isPlainObject(t))return o.warn("Relayout fail.",t,r),Promise.reject();n=o.extendFlat({},t)}Object.keys(n).length&&(e.changed=!0);var a=J(e,n),i=a.flags;i.calc&&(e.calcdata=void 0);var l=[d.previousPromises];i.layoutReplot?l.push(M.layoutReplot):Object.keys(n).length&&(U(e,i,a)||d.supplyDefaults(e),i.legend&&l.push(M.doLegend),i.layoutstyle&&l.push(M.layoutStyles),i.axrange&&q(l,a.rangesAltered),i.ticks&&l.push(M.doTicksRelayout),i.modebar&&l.push(M.doModeBar),i.camera&&l.push(M.doCamera),i.colorbars&&l.push(M.doColorBars),l.push(S)),l.push(d.rehover,d.redrag,d.reselect),c.add(e,V,[e,a.undoit],V,[e,a.redoit]);var s=o.syncOrAsync(l,e);return s&&s.then||(s=Promise.resolve(e)),s.then((function(){return e.emit("plotly_relayout",a.eventData),e}))}function U(e,t,r){var n,a,i=e._fullLayout;if(!t.axrange)return!1;for(var l in t)if("axrange"!==l&&t[l])return!1;var s=function(e,t){return o.coerce(n,a,v,e,t)},c={};for(var u in r.rangesAltered){var f=h.id2name(u);if(n=e.layout[f],a=i[f],p(n,a,s,c),a._matchGroup)for(var d in a._matchGroup)if(d!==u){var g=i[h.id2name(d)];g.autorange=a.autorange,g.range=a.range.slice(),g._input.range=a.range.slice()}}return!0}function q(e,t){var r=t?function(e){var r=[];for(var n in t){var a=h.getFromId(e,n);if(r.push(n),-1!==(a.ticklabelposition||"").indexOf("inside")&&a._anchorAxis&&r.push(a._anchorAxis._id),a._matchGroup)for(var i in a._matchGroup)t[i]||r.push(i)}return h.draw(e,r,{skipTitle:!0})}:function(e){return h.draw(e,"redraw")};e.push(b,M.doAutoRangeAndConstraints,r,M.drawData,M.finalDraw)}var G=/^[xyz]axis[0-9]*\.range(\[[0|1]\])?$/,Z=/^[xyz]axis[0-9]*\.autorange$/,W=/^[xyz]axis[0-9]*\.domain(\[[0|1]\])?$/;function J(e,t){var r,n,a,i=e.layout,s=e._fullLayout,c=s._guiEditing,d=H(s._preGUI,c),p=Object.keys(t),v=h.list(e),g=o.extendDeepAll({},t),m={};for(Y(t),p=Object.keys(t),n=0;n0&&"string"!=typeof z.parts[E];)E--;var R=z.parts[E],N=z.parts[E-1]+"."+R,B=z.parts.slice(0,E).join("."),j=l(e.layout,B).get(),V=l(s,B).get(),U=z.get();if(void 0!==I){M[P]=I,L[P]="reverse"===R?I:F(U);var q=f.getLayoutValObject(s,z.parts);if(q&&q.impliedEdits&&null!==I)for(var J in q.impliedEdits)S(o.relativeAttr(P,J),q.impliedEdits[J]);if(-1!==["width","height"].indexOf(P))if(I){S("autosize",null);var K="height"===P?"width":"height";S(K,s[K])}else s[P]=e._initialAutoSize[P];else if("autosize"===P)S("width",I?null:s.width),S("height",I?null:s.height);else if(N.match(G))D(N),l(s,B+"._inputRange").set(null);else if(N.match(Z)){D(N),l(s,B+"._inputRange").set(null);var Q=l(s,B).get();Q._inputDomain&&(Q._input.domain=Q._inputDomain.slice())}else N.match(W)&&l(s,B+"._inputDomain").set(null);if("type"===R){C=j;var $="linear"===V.type&&"log"===I,ee="log"===V.type&&"linear"===I;if($||ee){if(C&&C.range)if(V.autorange)$&&(C.range=C.range[1]>C.range[0]?[1,2]:[2,1]);else{var te=C.range[0],re=C.range[1];$?(te<=0&&re<=0&&S(B+".autorange",!0),te<=0?te=re/1e6:re<=0&&(re=te/1e6),S(B+".range[0]",Math.log(te)/Math.LN10),S(B+".range[1]",Math.log(re)/Math.LN10)):(S(B+".range[0]",Math.pow(10,te)),S(B+".range[1]",Math.pow(10,re)))}else S(B+".autorange",!0);Array.isArray(s._subplots.polar)&&s._subplots.polar.length&&s[z.parts[0]]&&"radialaxis"===z.parts[1]&&delete s[z.parts[0]]._subplot.viewInitial["radialaxis.range"],u.getComponentMethod("annotations","convertCoords")(e,V,I,S),u.getComponentMethod("images","convertCoords")(e,V,I,S)}else S(B+".autorange",!0),S(B+".range",null);l(s,B+"._inputRange").set(null)}else if(R.match(A)){var ne=l(s,P).get(),ae=(I||{}).type;ae&&"-"!==ae||(ae="linear"),u.getComponentMethod("annotations","convertCoords")(e,ne,ae,S),u.getComponentMethod("images","convertCoords")(e,ne,ae,S)}var ie=w.containerArrayMatch(P);if(ie){r=ie.array,n=ie.index;var oe=ie.property,le=q||{editType:"calc"};""!==n&&""===oe&&(w.isAddVal(I)?L[P]=null:w.isRemoveVal(I)?L[P]=(l(i,r).get()||[])[n]:o.warn("unrecognized full object value",t)),k.update(_,le),m[r]||(m[r]={});var se=m[r][n];se||(se=m[r][n]={}),se[oe]=I,delete t[P]}else"reverse"===R?(j.range?j.range.reverse():(S(B+".autorange",!0),j.range=[1,0]),V.autorange?_.calc=!0:_.plot=!0):("dragmode"===P&&(!1===I&&!1!==U||!1!==I&&!1===U)||s._has("scatter-like")&&s._has("regl")&&"dragmode"===P&&("lasso"===I||"select"===I)&&"lasso"!==U&&"select"!==U||s._has("gl2d")?_.plot=!0:q?k.update(_,q):_.calc=!0,z.set(I))}}for(r in m)w.applyContainerArrayChanges(e,d(i,r),m[r],_,d)||(_.plot=!0);for(var ce in O){var ue=(C=h.getFromId(e,ce))&&C._constraintGroup;if(ue)for(var fe in _.calc=!0,ue)O[fe]||(h.getFromId(e,fe)._constraintShrinkable=!0)}(X(e)||t.height||t.width)&&(_.plot=!0);var de=s.shapes;for(n=0;n1;)if(n.pop(),void 0!==(r=l(t,n.join(".")+".uirevision").get()))return r;return t.uirevision}function ne(e,t){for(var r=0;r=a.length?a[0]:a[e]:a}function s(e){return Array.isArray(i)?e>=i.length?i[0]:i[e]:i}function c(e,t){var r=0;return function(){if(e&&++r===t)return e()}}return void 0===n._frameWaitingCnt&&(n._frameWaitingCnt=0),new Promise((function(i,u){function f(){e.emit("plotly_animating"),n._lastFrameAt=-1/0,n._timeToNext=0,n._runningTransitions=0,n._currentFrame=null;var t=function(){n._animationRaf=window.requestAnimationFrame(t),Date.now()-n._lastFrameAt>n._timeToNext&&function(){n._currentFrame&&n._currentFrame.onComplete&&n._currentFrame.onComplete();var t=n._currentFrame=n._frameQueue.shift();if(t){var r=t.name?t.name.toString():null;e._fullLayout._currentFrame=r,n._lastFrameAt=Date.now(),n._timeToNext=t.frameOpts.duration,d.transition(e,t.frame.data,t.frame.layout,T.coerceTraceIndices(e,t.frame.traces),t.frameOpts,t.transitionOpts).then((function(){t.onComplete&&t.onComplete()})),e.emit("plotly_animatingframe",{name:r,frame:t.frame,animation:{frame:t.frameOpts,transition:t.transitionOpts}})}else e.emit("plotly_animated"),window.cancelAnimationFrame(n._animationRaf),n._animationRaf=null}()};t()}var h,p,v=0;function g(e){return Array.isArray(a)?v>=a.length?e.transitionOpts=a[v]:e.transitionOpts=a[0]:e.transitionOpts=a,v++,e}var m=[],y=null==t,x=Array.isArray(t);if(y||x||!o.isPlainObject(t)){if(y||-1!==["string","number"].indexOf(typeof t))for(h=0;h0&&ww)&&M.push(p);m=M}}m.length>0?function(t){if(0!==t.length){for(var a=0;a=0;n--)if(o.isPlainObject(t[n])){var v=t[n].name,g=(u[v]||p[v]||{}).name,m=t[n].name,y=u[g]||p[g];g&&m&&"number"==typeof m&&y&&L<5&&(L++,o.warn('addFrames: overwriting frame "'+(u[g]||p[g]).name+'" with a frame whose name of type "number" also equates to "'+g+'". This is valid but may potentially lead to unexpected behavior since all plotly.js frame names are stored internally as strings.'),5===L&&o.warn("addFrames: This API call has yielded too many of these warnings. For the rest of this call, further warnings about numeric frame names will be suppressed.")),p[v]={name:v},h.push({frame:d.supplyFrameDefaults(t[n]),index:r&&void 0!==r[n]&&null!==r[n]?r[n]:f+n})}h.sort((function(e,t){return e.index>t.index?-1:e.index=0;n--){if("number"==typeof(a=h[n].frame).name&&o.warn("Warning: addFrames accepts frames with numeric names, but the numbers areimplicitly cast to strings"),!a.name)for(;u[a.name="frame "+e._transitionData._counter++];);if(u[a.name]){for(i=0;i=0;r--)n=t[r],i.push({type:"delete",index:n}),l.unshift({type:"insert",index:n,value:a[n]});var s=d.modifyFrames,u=d.modifyFrames,f=[e,l],h=[e,i];return c&&c.add(e,s,f,u,h),d.modifyFrames(e,i)},t.addTraces=function e(r,n,a){r=o.getGraphDiv(r);var i,l,s=[],u=t.deleteTraces,f=e,d=[r,s],h=[r,n];for(function(e,t,r){var n,a;if(!Array.isArray(e.data))throw new Error("gd.data must be an array.");if(void 0===t)throw new Error("traces must be defined.");for(Array.isArray(t)||(t=[t]),n=0;n=0&&r=0&&r=i.length)return!1;if(2===e.dimensions){if(r++,t.length===r)return e;var o=t[r];if(!_(o))return!1;e=i[a][o]}else e=i[a]}else e=i}}return e}function _(e){return e===Math.round(e)&&e>=0}function w(){var e,t,r={};for(e in f(r,o),n.subplotsRegistry)if((t=n.subplotsRegistry[e]).layoutAttributes)if(Array.isArray(t.attr))for(var a=0;a=s.length)return!1;a=(r=(n.transformsRegistry[s[c].type]||{}).attributes)&&r[t[2]],l=3}else{var u=e._module;if(u||(u=(n.modules[e.type||i.type.dflt]||{})._module),!u)return!1;if(!(a=(r=u.attributes)&&r[o])){var f=u.basePlotModule;f&&f.attributes&&(a=f.attributes[o])}a||(a=i[o])}return b(a,t,l)},t.getLayoutValObject=function(e,t){var r=function(e,t){var r,a,i,l,s=e._basePlotModules;if(s){var c;for(r=0;r=a&&(r._input||{})._templateitemname;l&&(o=a);var s,c=t+"["+o+"]";function u(){s={},l&&(s[c]={},s[c][i]=l)}function f(e,t){l?n.nestedProperty(s[c],e).set(t):s[c+"."+e]=t}function d(){var e=s;return u(),e}return u(),{modifyBase:function(e,t){s[e]=t},modifyItem:f,getUpdateObj:d,applyUpdate:function(t,r){t&&f(t,r);var a=d();for(var i in a)n.nestedProperty(e,i).set(a[i])}}}},1817:function(e,t,r){"use strict";var n=r(5568),a=r(3626),i=r(4122),o=r(4809),l=r(635),s=r(4823),c=r(8766),u=r(2203),f=r(7240),d=r(5433),h=r(9714),p=r(4530),v=r(4391),g=v.enforce,m=v.clean,y=r(2919).doAutoRange,x="start",b=r(4826).zindexSeparator;function _(e,t,r){for(var n=0;n=e[1]||a[1]<=e[0])&&i[0]t[0])return!0}return!1}function w(e){var r,a,l,s,f,v,g=e._fullLayout,m=g._size,y=m.p,x=h.list(e,"",!0);if(g._paperdiv.style({width:e._context.responsive&&g.autosize&&!e._context._hasZeroWidth&&!e.layout.width?"100%":g.width+"px",height:e._context.responsive&&g.autosize&&!e._context._hasZeroHeight&&!e.layout.height?"100%":g.height+"px"}).selectAll(".main-svg").call(u.setSize,g.width,g.height),e._context.setBackground(e,g.paper_bgcolor),t.drawMainTitle(e),d.manage(e),!g._has("cartesian"))return i.previousPromises(e);function w(e,t,r){var n=e._lw/2;return"x"===e._id.charAt(0)?t?"top"===r?t._offset-y-n:t._offset+t._length+y+n:m.t+m.h*(1-(e.position||0))+n%1:t?"right"===r?t._offset+t._length+y+n:t._offset-y-n:m.l+m.w*(e.position||0)+n%1}for(r=0;r.5?"t":"b",o=e._fullLayout.margin[i],l=0;return"paper"===t.yref?l=r+t.pad.t+t.pad.b:"container"===t.yref&&(l=function(e,t,r,n,a){var i=0;return"middle"===r&&(i+=a/2),"t"===e?("top"===r&&(i+=a),i+=n-t*n):("bottom"===r&&(i+=a),i+=t*n),i}(i,n,a,e._fullLayout.height,r)+t.pad.t+t.pad.b),l>o?l:0}(e,t,v);if(g>0){!function(e,t,r,n){var a="title.automargin",l=e._fullLayout.title,s=l.y>.5?"t":"b",c={x:l.x,y:l.y,t:0,b:0},u={};"paper"===l.yref&&function(e,t,r,n,a){var i="paper"===t.yref?e._fullLayout._size.h:e._fullLayout.height,l=o.isTopAnchor(t)?n:n-a,s="b"===r?i-l:l;return!(o.isTopAnchor(t)&&"t"===r||o.isBottomAnchor(t)&&"b"===r)&&sT?u.push({code:"unused",traceType:y,templateCount:w,dataCount:T}):T>w&&u.push({code:"reused",traceType:y,templateCount:w,dataCount:T})}}else u.push({code:"data"});if(function e(t,r){for(var n in t)if("_"!==n.charAt(0)){var i=t[n],o=v(t,n,r);a(i)?(Array.isArray(t)&&!1===i._template&&i.templateitemname&&u.push({code:"missing",path:o,templateitemname:i.templateitemname}),e(i,o)):Array.isArray(i)&&g(i)&&e(i,o)}}({data:h,layout:d},""),u.length)return u.map(m)}},491:function(e,t,r){"use strict";var n=r(721),a=r(1420),i=r(4122),o=r(4809),l=r(4619),s=r(6243),c=r(2914),u=r(9697).version,f={format:{valType:"enumerated",values:["png","jpeg","webp","svg","full-json"],dflt:"png"},width:{valType:"number",min:1},height:{valType:"number",min:1},scale:{valType:"number",min:0,dflt:1},setBackground:{valType:"any",dflt:!1},imageDataOnly:{valType:"boolean",dflt:!1}};e.exports=function(e,t){var r,d,h,p;function v(e){return!(e in t)||o.validate(t[e],f[e])}if(t=t||{},o.isPlainObject(e)?(r=e.data||[],d=e.layout||{},h=e.config||{},p={}):(e=o.getGraphDiv(e),r=o.extendDeep([],e.data),d=o.extendDeep({},e.layout),h=e._context,p=e._fullLayout||{}),!v("width")&&null!==t.width||!v("height")&&null!==t.height)throw new Error("Height and width should be pixel values.");if(!v("format"))throw new Error("Export format is not "+o.join2(f.format.values,", "," or ")+".");var g={};function m(e,r){return o.coerce(t,g,f,e,r)}var y=m("format"),x=m("width"),b=m("height"),_=m("scale"),w=m("setBackground"),T=m("imageDataOnly"),M=document.createElement("div");M.style.position="absolute",M.style.left="-5000px",document.body.appendChild(M);var k=o.extendFlat({},d);x?k.width=x:null===t.width&&n(p.width)&&(k.width=p.width),b?k.height=b:null===t.height&&n(p.height)&&(k.height=p.height);var A=o.extendFlat({},h,{_exportedPlot:!0,staticPlot:!0,setBackground:w}),L=l.getRedrawFunc(M);function S(){return new Promise((function(e){setTimeout(e,l.getDelay(M._fullLayout))}))}function C(){return new Promise((function(e,t){var r=s(M,y,_),n=M._fullLayout.width,f=M._fullLayout.height;function d(){a.purge(M),document.body.removeChild(M)}if("full-json"===y){var h=i.graphJson(M,!1,"keepdata","object",!0,!0);return h.version=u,h=JSON.stringify(h),d(),e(T?h:l.encodeJSON(h))}if(d(),"svg"===y)return e(T?r:l.encodeSVG(r));var p=document.createElement("canvas");p.id=o.randstr(),c({format:y,width:n,height:f,scale:_,canvas:p,svg:r,promise:!0}).then(e).catch(t)}))}return new Promise((function(e,t){a.newPlot(M,r,k,A).then(L).then(S).then(C).then((function(t){e(function(e){return T?e.replace(l.IMAGE_URL_PREFIX,""):e}(t))})).catch((function(e){t(e)}))}))}},2466:function(e,t,r){"use strict";var n=r(4809),a=r(4122),i=r(7297),o=r(4452).dfltConfig,l=n.isPlainObject,s=Array.isArray,c=n.isArrayOrTypedArray;function u(e,t,r,a,i,o){o=o||[];for(var f=Object.keys(e),d=0;dx.length&&a.push(h("unused",i,m.concat(x.length)));var k,A,L,S,C,O=x.length,D=Array.isArray(M);if(D&&(O=Math.min(O,M.length)),2===b.dimensions)for(A=0;Ax[A].length&&a.push(h("unused",i,m.concat(A,x[A].length)));var P=x[A].length;for(k=0;k<(D?Math.min(P,M[A].length):P);k++)L=D?M[A][k]:M,S=y[A][k],C=x[A][k],n.validate(S,L)?C!==S&&C!==+S&&a.push(h("dynamic",i,m.concat(A,k),S,C)):a.push(h("value",i,m.concat(A,k),S))}else a.push(h("array",i,m.concat(A),y[A]));else for(A=0;A1&&d.push(h("object","layout"))),a.supplyDefaults(p);for(var v=p._fullData,g=r.length,m=0;m0&&Math.round(f)===f))return{vals:a};c=f}for(var d=t.calendar,h="start"===s,p="end"===s,v=e[r+"period0"],g=i(v,d)||0,m=[],y=[],x=[],b=a.length,_=0;_k;)M=o(M,-c,d);for(;M<=k;)M=o(M,c,d);T=o(M,-c,d)}else{for(M=g+(w=Math.round((k-g)/u))*u;M>k;)M-=u;for(;M<=k;)M+=u;T=M-u}m[_]=h?T:p?M:(T+M)/2,y[_]=T,x[_]=M}return{vals:m,starts:y,ends:x}}},5126:function(e){"use strict";e.exports={xaxis:{valType:"subplotid",dflt:"x",editType:"calc+clearAxisTypes"},yaxis:{valType:"subplotid",dflt:"y",editType:"calc+clearAxisTypes"}}},2919:function(e,t,r){"use strict";var n=r(5568),a=r(721),i=r(4809),o=r(3821).FP_SAFE,l=r(3626),s=r(2203),c=r(5975),u=c.getFromId,f=c.isLinked;function d(e,t){var r,n,a=[],o=e._fullLayout,l=p(o,t,0),s=p(o,t,1),c=g(e,t),u=c.min,f=c.max;if(0===u.length||0===f.length)return i.simpleMap(t.range,t.r2l);var d=u[0].val,v=f[0].val;for(r=1;r0&&((k=O-l(b)-s(_))>D?A/k>P&&(w=b,T=_,P=A/k):A/O>P&&(w={val:b.val,nopad:1},T={val:_.val,nopad:1},P=A/O));if(d===v){var z=d-1,I=d+1;if(S)if(0===d)a=[0,1];else{var E=(d>0?f:u).reduce((function(e,t){return Math.max(e,s(t))}),0),R=d/(1-Math.min(.5,E/O));a=d>0?[0,R]:[R,0]}else a=C?[Math.max(0,z),Math.max(1,I)]:[z,I]}else S?(w.val>=0&&(w={val:0,nopad:1}),T.val<=0&&(T={val:0,nopad:1})):C&&(w.val-P*l(w)<0&&(w={val:0,nopad:1}),T.val<=0&&(T={val:1,nopad:1})),P=(T.val-w.val-h(t,b.val,_.val))/(O-l(w)-s(T)),a=[w.val-P*l(w),T.val+P*s(T)];return a=M(a,t),t.limitRange&&t.limitRange(),y&&a.reverse(),i.simpleMap(a,t.l2r||Number)}function h(e,t,r){var n=0;if(e.rangebreaks)for(var a=e.locateBreaks(t,r),i=0;i0?r.ppadplus:r.ppadminus)||r.ppad||0),L=k((e._m>0?r.ppadminus:r.ppadplus)||r.ppad||0),S=k(r.vpadplus||r.vpad),C=k(r.vpadminus||r.vpad);if(!T){if(d=1/0,h=-1/0,w)for(n=0;n0&&(d=i),i>h&&i-o&&(d=i),i>h&&i=P;n--)D(n);return{min:p,max:v,opts:r}},concatExtremes:g};var v=3;function g(e,t,r){var n,a,i,o=t._id,l=e._fullData,s=e._fullLayout,c=[],f=[];function d(e,t){for(n=0;n=r&&(c.extrapad||!o)){l=!1;break}a(t,c.val)&&c.pad<=r&&(o||!c.extrapad)&&(e.splice(s,1),s--)}if(l){var u=i&&0===t;e.push({val:t,pad:u?0:r,extrapad:!u&&o})}}function b(e){return a(e)&&Math.abs(e)=t}function T(e,t,r){return void 0===t||void 0===r||(t=e.d2l(t))=c&&(o=c,r=c),l<=c&&(l=c,n=c)}}return r=function(e,t){var r=t.autorangeoptions;return r&&void 0!==r.minallowed&&T(t,r.minallowed,r.maxallowed)?r.minallowed:r&&void 0!==r.clipmin&&T(t,r.clipmin,r.clipmax)?Math.max(e,t.d2l(r.clipmin)):e}(r,t),n=function(e,t){var r=t.autorangeoptions;return r&&void 0!==r.maxallowed&&T(t,r.minallowed,r.maxallowed)?r.maxallowed:r&&void 0!==r.clipmax&&T(t,r.clipmin,r.clipmax)?Math.min(e,t.d2l(r.clipmax)):e}(n,t),[r,n]}},5511:function(e){"use strict";e.exports=function(e,t,r){var n,a;if(r){var i="reversed"===t||"min reversed"===t||"max reversed"===t;n=r[i?1:0],a=r[i?0:1]}var o=e("autorangeoptions.minallowed",null===a?n:void 0),l=e("autorangeoptions.maxallowed",null===n?a:void 0);void 0===o&&e("autorangeoptions.clipmin"),void 0===l&&e("autorangeoptions.clipmax"),e("autorangeoptions.include")}},9714:function(e,t,r){"use strict";var n=r(5568),a=r(721),i=r(4122),o=r(3626),l=r(4809),s=l.strTranslate,c=r(635),u=r(7240),f=r(8766),d=r(2203),h=r(5829),p=r(8599),v=r(3821),g=v.ONEMAXYEAR,m=v.ONEAVGYEAR,y=v.ONEMINYEAR,x=v.ONEMAXQUARTER,b=v.ONEAVGQUARTER,_=v.ONEMINQUARTER,w=v.ONEMAXMONTH,T=v.ONEAVGMONTH,M=v.ONEMINMONTH,k=v.ONEWEEK,A=v.ONEDAY,L=A/2,S=v.ONEHOUR,C=v.ONEMIN,O=v.ONESEC,D=v.ONEMILLI,P=v.ONEMICROSEC,z=v.MINUS_SIGN,I=v.BADNUM,E={K:"zeroline"},R={K:"gridline",L:"path"},N={K:"minor-gridline",L:"path"},F={K:"tick",L:"path"},H={K:"tick",L:"text"},B={width:["x","r","l","xl","xr"],height:["y","t","b","yt","yb"],right:["r","xr"],left:["l","xl"],top:["t","yt"],bottom:["b","yb"]},j=r(4530),Y=j.MID_SHIFT,V=j.CAP_SHIFT,U=j.LINE_SPACING,q=j.OPPOSITE_SIDE,G=e.exports={};G.setConvert=r(9091);var Z=r(9666),W=r(5975),J=W.idSort,X=W.isLinked;G.id2name=W.id2name,G.name2id=W.name2id,G.cleanId=W.cleanId,G.list=W.list,G.listIds=W.listIds,G.getFromId=W.getFromId,G.getFromTrace=W.getFromTrace;var K=r(2919);G.getAutoRange=K.getAutoRange,G.findExtremes=K.findExtremes;var Q=1e-4;function $(e){var t=(e[1]-e[0])*Q;return[e[0]-t,e[1]+t]}G.coerceRef=function(e,t,r,n,a,i){var o=n.charAt(n.length-1),s=r._fullLayout._subplots[o+"axis"],c=n+"ref",u={};return a||(a=s[0]||("string"==typeof i?i:i[0])),i||(i=a),s=s.concat(s.map((function(e){return e+" domain"}))),u[c]={valType:"enumerated",values:s.concat(i?"string"==typeof i?[i]:i:[]),dflt:a},l.coerce(e,t,u,c)},G.getRefType=function(e){return void 0===e?e:"paper"===e?"paper":"pixel"===e?"pixel":/( domain)$/.test(e)?"domain":"range"},G.coercePosition=function(e,t,r,n,a,i){var o,s;if("range"!==G.getRefType(n))o=l.ensureNumber,s=r(a,i);else{var c=G.getFromId(t,n);s=r(a,i=c.fraction2r(i)),o=c.cleanPos}e[a]=o(s)},G.cleanPosition=function(e,t,r){return("paper"===r||"pixel"===r?l.ensureNumber:G.getFromId(t,r).cleanPos)(e)},G.redrawComponents=function(e,t){t=t||G.listIds(e);var r=e._fullLayout;function n(n,a,i,l){for(var s=o.getComponentMethod(n,a),c={},u=0;un&&d2e-6||((r-e._forceTick0)/e._minDtick%1+1.000001)%1>2e-6)&&(e._minDtick=0)):e._minDtick=0},G.saveRangeInitial=function(e,t){for(var r=G.list(e,"",!0),n=!1,a=0;a.3*d||u(n)||u(i))){var h=r.dtick/2;e+=e+h.8){var o=Number(r.substr(1));i.exactYears>.8&&o%12==0?e=G.tickIncrement(e,"M6","reverse")+1.5*A:i.exactMonths>.8?e=G.tickIncrement(e,"M1","reverse")+15.5*A:e-=L;var s=G.tickIncrement(e,r);if(s<=n)return s}return e}(y,e,m,c,i)),g=y;g<=u;)g=G.tickIncrement(g,m,!1,i);return{start:t.c2r(y,0,i),end:t.c2r(g,0,i),size:m,_dataSpan:u-c}},G.prepMinorTicks=function(e,t,r){if(!t.minor.dtick){delete e.dtick;var n,i=t.dtick&&a(t._tmin);if(i){var o=G.tickIncrement(t._tmin,t.dtick,!0);n=[t._tmin,.99*o+.01*t._tmin]}else{var s=l.simpleMap(t.range,t.r2l);n=[s[0],.8*s[0]+.2*s[1]]}if(e.range=l.simpleMap(n,t.l2r),e._isMinor=!0,G.prepTicks(e,r),i){var c=a(t.dtick),u=a(e.dtick),f=c?t.dtick:+t.dtick.substring(1),d=u?e.dtick:+e.dtick.substring(1);c&&u?ne(f,d)?f===2*k&&d===2*A&&(e.dtick=k):f===2*k&&d===3*A?e.dtick=k:f!==k||(t._input.minor||{}).nticks?ae(f/d,2.5)?e.dtick=f/2:e.dtick=f:e.dtick=A:"M"===String(t.dtick).charAt(0)?u?e.dtick="M1":ne(f,d)?f>=12&&2===d&&(e.dtick="M3"):e.dtick=t.dtick:"L"===String(e.dtick).charAt(0)?"L"===String(t.dtick).charAt(0)?ne(f,d)||(e.dtick=ae(f/d,2.5)?t.dtick/2:t.dtick):e.dtick="D1":"D2"===e.dtick&&+t.dtick>1&&(e.dtick=1)}e.range=t.range}void 0===t.minor._tick0Init&&(e.tick0=t.tick0)},G.prepTicks=function(e,t){var r=l.simpleMap(e.range,e.r2l,void 0,void 0,t);if("auto"===e.tickmode||!e.dtick){var n,i=e.nticks;i||("category"===e.type||"multicategory"===e.type?(n=e.tickfont?l.bigFont(e.tickfont.size||12):15,i=e._length/n):(n="y"===e._id.charAt(0)?40:80,i=l.constrain(e._length/n,4,9)+1),"radialaxis"===e._name&&(i*=2)),e.minor&&"array"!==e.minor.tickmode||"array"===e.tickmode&&(i*=100),e._roughDTick=Math.abs(r[1]-r[0])/i,G.autoTicks(e,e._roughDTick),e._minDtick>0&&e.dtick<2*e._minDtick&&(e.dtick=e._minDtick,e.tick0=e.l2r(e._forceTick0))}"period"===e.ticklabelmode&&function(e){var t;function r(){return!(a(e.dtick)||"M"!==e.dtick.charAt(0))}var n=r(),i=G.getTickFormat(e);if(i){var o=e._dtickInit!==e.dtick;/%[fLQsSMX]/.test(i)||(/%[HI]/.test(i)?(t=S,o&&!n&&e.dticke.range[1],h=!e.ticklabelindex||l.isArrayOrTypedArray(e.ticklabelindex)?e.ticklabelindex:[e.ticklabelindex],p=l.simpleMap(e.range,e.r2l,void 0,void 0,t),v=p[1]=(Y?0:1);V--){var U=!V;V?(e._dtickInit=e.dtick,e._tick0Init=e.tick0):(e.minor._dtickInit=e.minor.dtick,e.minor._tick0Init=e.minor.tick0);var q=V?e:l.extendFlat({},e,e.minor);if(U?G.prepMinorTicks(q,e,t):G.prepTicks(q,t),"array"!==q.tickmode)if("sync"!==q.tickmode){var Z=$(p),W=Z[0],J=Z[1],X=a(q.dtick),K="log"===s&&!(X||"L"===q.dtick.charAt(0)),Q=G.tickFirst(q,t);if(V){if(e._tmin=Q,Q=J:ne<=J;ne=G.tickIncrement(ne,ae,v,c)){if(V&&ee++,q.rangebreaks&&!v){if(ne=E)break}if(H.length>R||ne===re)break;re=ne;var ie={value:ne};V?(K&&ne!==(0|ne)&&(ie.simpleLabel=!0),u>1&&ee%u&&(ie.skipLabel=!0),H.push(ie)):(ie.minor=!0,B.push(ie))}}else H=[],N=le(e);else V?(H=[],N=se(e,!U)):(B=[],F=se(e,!U))}!B||B.length<2?h=!1:(r=(B[1].value-B[0].value)*(d?-1:1),n=e.tickformat,(/%f/.test(n)?r>=P:/%L/.test(n)?r>=D:/%[SX]/.test(n)?r>=O:/%M/.test(n)?r>=C:/%[HI]/.test(n)?r>=S:/%p/.test(n)?r>=L:/%[Aadejuwx]/.test(n)?r>=A:/%[UVW]/.test(n)?r>=k:/%[Bbm]/.test(n)?r>=M:/%[q]/.test(n)?r>=_:!/%[Yy]/.test(n)||r>=y)||(h=!1));if(h){var oe=H.concat(B);f&&H.length&&(oe=oe.slice(1)),(oe=oe.sort((function(e,t){return e.value-t.value})).filter((function(e,t,r){return 0===t||e.value!==r[t-1].value}))).map((function(e,t){return void 0!==e.minor||e.skipLabel?null:t})).filter((function(e){return null!==e})).forEach((function(e){h.map((function(t){var r=e+t;r>=0&&r0?(i=n-1,o=n):(i=n,o=n);var l,s=e[i].value,c=e[o].value,u=Math.abs(c-s),f=r||u,d=0;f>=y?d=u>=y&&u<=g?u:m:r===b&&f>=_?d=u>=_&&u<=x?u:b:f>=M?d=u>=M&&u<=w?u:T:r===k&&f>=k?d=k:f>=A?d=A:r===L&&f>=L?d=L:r===S&&f>=S&&(d=S),d>=u&&(d=u,l=!0);var h=a+d;if(t.rangebreaks&&d>0){for(var p=0,v=0;v<84;v++){var C=(v+.5)/84;t.maskBreaks(a*(1-C)+C*h)!==I&&p++}(d*=p/84)||(e[n].drop=!0),l&&u>k&&(d=u)}(d>0||0===n)&&(e[n].periodX=a+d/2)}}(j,e,e._definedDelta),e.rangebreaks){var ge="y"===e._id.charAt(0),me=1;"auto"===e.tickmode&&(me=e.tickfont?e.tickfont.size:12);var ye=NaN;for(i=H.length-1;i>-1;i--)if(H[i].drop)H.splice(i,1);else{H[i].value=Ne(H[i].value,e);var xe=e.c2p(H[i].value);(ge?ye>xe-me:yeE||nE&&(r.periodX=E),n10||"01-01"!==n.substr(5)?e._tickround="d":e._tickround=+t.substr(1)%12==0?"y":"m";else if(t>=A&&i<=10||t>=15*A)e._tickround="d";else if(t>=C&&i<=16||t>=S)e._tickround="M";else if(t>=O&&i<=19||t>=C)e._tickround="S";else{var o=e.l2r(r+t).replace(/^-/,"").length;e._tickround=Math.max(i,o)-20,e._tickround<0&&(e._tickround=4)}}else if(a(t)||"L"===t.charAt(0)){var l=e.range.map(e.r2d||Number);a(t)||(t=Number(t.substr(1))),e._tickround=2-Math.floor(Math.log(t)/Math.LN10+.01);var s=Math.max(Math.abs(l[0]),Math.abs(l[1])),c=Math.floor(Math.log(s)/Math.LN10+.01),u=void 0===e.minexponent?3:e.minexponent;Math.abs(c)>u&&(be(e.exponentformat)&&!_e(c)?e._tickexponent=3*Math.round((c-1)/3):e._tickexponent=c)}else e._tickround=null}function ye(e,t,r){var n=e.tickfont||{};return{x:t,dx:0,dy:0,text:r||"",fontSize:n.size,font:n.family,fontWeight:n.weight,fontStyle:n.style,fontVariant:n.variant,fontTextcase:n.textcase,fontLineposition:n.lineposition,fontShadow:n.shadow,fontColor:n.color}}G.autoTicks=function(e,t,r){var n;function i(e){return Math.pow(e,Math.floor(Math.log(t)/Math.LN10))}if("date"===e.type){e.tick0=l.dateTick0(e.calendar,0);var o=2*t;if(o>m)t/=m,n=i(10),e.dtick="M"+12*ge(t,n,ce);else if(o>T)t/=T,e.dtick="M"+ge(t,1,ue);else if(o>A){if(e.dtick=ge(t,A,e._hasDayOfWeekBreaks?[1,2,7,14]:de),!r){var s=G.getTickFormat(e),c="period"===e.ticklabelmode;c&&(e._rawTick0=e.tick0),/%[uVW]/.test(s)?e.tick0=l.dateTick0(e.calendar,2):e.tick0=l.dateTick0(e.calendar,1),c&&(e._dowTick0=e.tick0)}}else o>S?e.dtick=ge(t,S,ue):o>C?e.dtick=ge(t,C,fe):o>O?e.dtick=ge(t,O,fe):(n=i(10),e.dtick=ge(t,n,ce))}else if("log"===e.type){e.tick0=0;var u=l.simpleMap(e.range,e.r2l);if(e._isMinor&&(t*=1.5),t>.7)e.dtick=Math.ceil(t);else if(Math.abs(u[1]-u[0])<1){var f=1.5*Math.abs((u[1]-u[0])/t);t=Math.abs(Math.pow(10,u[1])-Math.pow(10,u[0]))/f,n=i(10),e.dtick="L"+ge(t,n,ce)}else e.dtick=t>.3?"D2":"D1"}else"category"===e.type||"multicategory"===e.type?(e.tick0=0,e.dtick=Math.ceil(Math.max(t,1))):Re(e)?(e.tick0=0,n=1,e.dtick=ge(t,n,ve)):(e.tick0=0,n=i(10),e.dtick=ge(t,n,ce));if(0===e.dtick&&(e.dtick=1),!a(e.dtick)&&"string"!=typeof e.dtick){var d=e.dtick;throw e.dtick=1,"ax.dtick error: "+String(d)}},G.tickIncrement=function(e,t,r,i){var o=r?-1:1;if(a(t))return l.increment(e,o*t);var s=t.charAt(0),c=o*Number(t.substr(1));if("M"===s)return l.incrementMonth(e,c,i);if("L"===s)return Math.log(Math.pow(10,e)+c)/Math.LN10;if("D"===s){var u="D2"===t?pe:he,f=e+.01*o,d=l.roundUp(l.mod(f,1),u,r);return Math.floor(f)+Math.log(n.round(Math.pow(10,d),1))/Math.LN10}throw"unrecognized dtick "+String(t)},G.tickFirst=function(e,t){var r=e.r2l||Number,i=l.simpleMap(e.range,r,void 0,void 0,t),o=i[1]=0&&r<=e._length?t:null};if(s&&l.isArrayOrTypedArray(e.ticktext)){var h=l.simpleMap(e.range,e.r2l),p=(Math.abs(h[1]-h[0])-(e._lBreaks||0))/1e4;for(i=0;i ")}else e._prevDateHead=s,c+="
"+s;t.text=c}(e,o,r,c):"log"===u?function(e,t,r,n,i){var o=e.dtick,s=t.x,c=e.tickformat,u="string"==typeof o&&o.charAt(0);if("never"===i&&(i=""),n&&"L"!==u&&(o="L3",u="L"),c||"L"===u)t.text=we(Math.pow(10,s),e,i,n);else if(a(o)||"D"===u&&l.mod(s+.01,1)<.1){var f=Math.round(s),d=Math.abs(f),h=e.exponentformat;"power"===h||be(h)&&_e(f)?(t.text=0===f?1:1===f?"10":"10"+(f>1?"":z)+d+"",t.fontSize*=1.25):("e"===h||"E"===h)&&d>2?t.text="1"+h+(f>0?"+":z)+d:(t.text=we(Math.pow(10,s),e,"","fakehover"),"D1"===o&&"y"===e._id.charAt(0)&&(t.dy-=t.fontSize/6))}else{if("D"!==u)throw"unrecognized dtick "+String(o);t.text=String(Math.round(Math.pow(10,l.mod(s,1)))),t.fontSize*=.75}if("D1"===e.dtick){var p=String(t.text).charAt(0);"0"!==p&&"1"!==p||("y"===e._id.charAt(0)?t.dx-=t.fontSize/4:(t.dy+=t.fontSize/2,t.dx+=(e.range[1]>e.range[0]?1:-1)*t.fontSize*(s<0?.5:.25)))}}(e,o,0,c,g):"category"===u?function(e,t){var r=e._categories[Math.round(t.x)];void 0===r&&(r=""),t.text=String(r)}(e,o):"multicategory"===u?function(e,t,r){var n=Math.round(t.x),a=e._categories[n]||[],i=void 0===a[1]?"":String(a[1]),o=void 0===a[0]?"":String(a[0]);r?t.text=o+" - "+i:(t.text=i,t.text2=o)}(e,o,r):Re(e)?function(e,t,r,n,a){if("radians"!==e.thetaunit||r)t.text=we(t.x,e,a,n);else{var i=t.x/180;if(0===i)t.text="0";else{var o=function(e){function t(e,t){return Math.abs(e-t)<=1e-6}var r=function(e){for(var r=1;!t(Math.round(e*r)/r,e);)r*=10;return r}(e),n=e*r,a=Math.abs(function e(r,n){return t(n,0)?r:e(n,r%n)}(n,r));return[Math.round(n/a),Math.round(r/a)]}(i);if(o[1]>=100)t.text=we(l.deg2rad(t.x),e,a,n);else{var s=t.x<0;1===o[1]?1===o[0]?t.text="π":t.text=o[0]+"π":t.text=["",o[0],"","⁄","",o[1],"","π"].join(""),s&&(t.text=z+t.text)}}}}(e,o,r,c,g):function(e,t,r,n,a){"never"===a?a="":"all"===e.showexponent&&Math.abs(t.x/e.dtick)<1e-6&&(a="hide"),t.text=we(t.x,e,a,n)}(e,o,0,c,g),n||(e.tickprefix&&!v(e.showtickprefix)&&(o.text=e.tickprefix+o.text),e.ticksuffix&&!v(e.showticksuffix)&&(o.text+=e.ticksuffix)),e.labelalias&&e.labelalias.hasOwnProperty(o.text)){var m=e.labelalias[o.text];"string"==typeof m&&(o.text=m)}return("boundaries"===e.tickson||e.showdividers)&&(o.xbnd=[d(o.x-.5),d(o.x+e.dtick-.5)]),o},G.hoverLabelText=function(e,t,r){r&&(e=l.extendFlat({},e,{hoverformat:r}));var n=l.isArrayOrTypedArray(t)?t[0]:t,a=l.isArrayOrTypedArray(t)?t[1]:void 0;if(void 0!==a&&a!==n)return G.hoverLabelText(e,n,r)+" - "+G.hoverLabelText(e,a,r);var i="log"===e.type&&n<=0,o=G.tickText(e,e.c2l(i?-n:n),"hover").text;return i?0===n?"0":z+o:o};var xe=["f","p","n","μ","m","","k","M","G","T"];function be(e){return"SI"===e||"B"===e}function _e(e){return e>14||e<-15}function we(e,t,r,n){var i=e<0,o=t._tickround,s=r||t.exponentformat||"B",c=t._tickexponent,u=G.getTickFormat(t),f=t.separatethousands;if(n){var d={exponentformat:s,minexponent:t.minexponent,dtick:"none"===t.showexponent?t.dtick:a(e)&&Math.abs(e)||1,range:"none"===t.showexponent?t.range.map(t.r2d):[0,e||1]};me(d),o=(Number(d._tickround)||0)+4,c=d._tickexponent,t.hoverformat&&(u=t.hoverformat)}if(u)return t._numFormat(u)(e).replace(/-/g,z);var h,p=Math.pow(10,-o)/2;if("none"===s&&(c=0),(e=Math.abs(e))"+h+"":"B"===s&&9===c?e+="B":be(s)&&(e+=xe[c/3+5])),i?z+e:e}function Te(e,t){if(e){var r=Object.keys(B).reduce((function(e,r){return-1!==t.indexOf(r)&&B[r].forEach((function(t){e[t]=1})),e}),{});Object.keys(e).forEach((function(t){r[t]||(1===t.length?e[t]=0:delete e[t])}))}}function Me(e,t){for(var r=[],n={},a=0;a1&&r=a.min&&e=0,i=u(e,t[1])<=0;return(r||a)&&(n||i)}if(e.tickformatstops&&e.tickformatstops.length>0)switch(e.type){case"date":case"linear":for(t=0;t=o(a)))){r=n;break}break;case"log":for(t=0;t=0&&a.unshift(a.splice(n,1).shift())}}));var o={false:{left:0,right:0}};return l.syncOrAsync(a.map((function(t){return function(){if(t){var n=G.getFromId(e,t);r||(r={}),r.axShifts=o,r.overlayingShiftedAx=i;var a=G.drawOne(e,n,r);return n._shiftPusher&&Be(n,n._fullDepth||0,o,!0),n._r=n.range.slice(),n._rl=l.simpleMap(n._r,n.r2l),a}}})))},G.drawOne=function(e,t,r){var n,a,s,h=(r=r||{}).axShifts||{},p=r.overlayingShiftedAx||[];t.setScale();var v=e._fullLayout,g=t._id,m=g.charAt(0),y=G.counterLetter(g),x=v._plots[t._mainSubplot];if(x){if(t._shiftPusher=t.autoshift||-1!==p.indexOf(t._id)||-1!==p.indexOf(t.overlaying),t._shiftPusher&"free"===t.anchor){var b=t.linewidth/2||0;"inside"===t.ticks&&(b+=t.ticklen),Be(t,b,h,!0),Be(t,t.shift||0,h,!1)}!0===r.skipTitle&&void 0!==t._shift||(t._shift=function(e,t){return e.autoshift?t[e.overlaying][e.side]:e.shift||0}(t,h));var _=x[m+"axislayer"],w=t._mainLinePosition,T=w+=t._shift,M=t._mainMirrorPosition,k=t._vals=G.calcTicks(t),A=[t.mirror,T,M].join("_");for(n=0;n0?r.bottom-u:0,f))));var d=0,h=0;if(t._shiftPusher&&(d=Math.max(f,r.height>0?"l"===s?u-r.left:r.right-u:0),t.title.text!==v._dfltTitle[m]&&(h=(t._titleStandoff||0)+(t._titleScoot||0),"l"===s&&(h+=Le(t))),t._fullDepth=Math.max(d,h)),t.automargin){n={x:0,y:0,r:0,l:0,t:0,b:0};var p=[0,1],g="number"==typeof t._shift?t._shift:0;if("x"===m){if("b"===s?n[s]=t._depth:(n[s]=t._depth=Math.max(r.width>0?u-r.top:0,f),p.reverse()),r.width>0){var x=r.right-(t._offset+t._length);x>0&&(n.xr=1,n.r=x);var b=t._offset-r.left;b>0&&(n.xl=0,n.l=b)}}else if("l"===s?(t._depth=Math.max(r.height>0?u-r.left:0,f),n[s]=t._depth-g):(t._depth=Math.max(r.height>0?r.right-u:0,f),n[s]=t._depth+g,p.reverse()),r.height>0){var _=r.bottom-(t._offset+t._length);_>0&&(n.yb=0,n.b=_);var w=t._offset-r.top;w>0&&(n.yt=1,n.t=w)}n[y]="free"===t.anchor?t.position:t._anchorAxis.domain[p[0]],t.title.text!==v._dfltTitle[m]&&(n[s]+=Le(t)+(t.title.standoff||0)),t.mirror&&"free"!==t.anchor&&((a={x:0,y:0,r:0,l:0,t:0,b:0})[c]=t.linewidth,t.mirror&&!0!==t.mirror&&(a[c]+=f),!0===t.mirror||"ticks"===t.mirror?a[y]=t._anchorAxis.domain[p[1]]:"all"!==t.mirror&&"allticks"!==t.mirror||(a[y]=[t._counterDomainMin,t._counterDomainMax][p[1]]))}fe&&(l=o.getComponentMethod("rangeslider","autoMarginOpts")(e,t)),"string"==typeof t.automargin&&(Te(n,t.automargin),Te(a,t.automargin)),i.autoMargin(e,Oe(t),n),i.autoMargin(e,De(t),a),i.autoMargin(e,Pe(t),l)})),l.syncOrAsync(ce)}}function de(e){var r=g+(e||"tick");return L[r]||(L[r]=function(e,t,r){var n,a,i,o;if(e._selections[t].size())n=1/0,a=-1/0,i=1/0,o=-1/0,e._selections[t].each((function(){var e=Ce(this),t=d.bBox(e.node().parentNode);n=Math.min(n,t.top),a=Math.max(a,t.bottom),i=Math.min(i,t.left),o=Math.max(o,t.right)}));else{var l=G.makeLabelFns(e,r);n=a=l.yFn({dx:0,dy:0,fontSize:0}),i=o=l.xFn({dx:0,dy:0,fontSize:0})}return{top:n,bottom:a,left:i,right:o,height:a-n,width:o-i}}(t,r,T)),L[r]}},G.getTickSigns=function(e,t){var r=e._id.charAt(0),n={x:"top",y:"right"}[r],a=e.side===n?1:-1,i=[-1,1,a,-a];return"inside"!==(t?(e.minor||{}).ticks:e.ticks)==("x"===r)&&(i=i.map((function(e){return-e}))),e.side&&i.push({l:-1,t:-1,r:1,b:1}[e.side.charAt(0)]),i},G.makeTransTickFn=function(e){return"x"===e._id.charAt(0)?function(t){return s(e._offset+e.l2p(t.x),0)}:function(t){return s(0,e._offset+e.l2p(t.x))}},G.makeTransTickLabelFn=function(e){var t=function(e){var t=e.ticklabelposition||"",r=function(e){return-1!==t.indexOf(e)},n=r("top"),a=r("left"),i=r("right"),o=r("bottom"),l=r("inside"),s=o||a||n||i;if(!s&&!l)return[0,0];var c=e.side,u=s?(e.tickwidth||0)/2:0,f=3,d=e.tickfont?e.tickfont.size:12;return(o||n)&&(u+=d*V,f+=(e.linewidth||0)/2),(a||i)&&(u+=(e.linewidth||0)/2,f+=3),l&&"top"===c&&(f-=d*(1-V)),(a||n)&&(u=-u),"bottom"!==c&&"right"!==c||(f=-f),[s?u:0,l?f:0]}(e),r=e.ticklabelshift||0,n=e.ticklabelstandoff||0,a=t[0],i=t[1],o=e.range[0]>e.range[1],l=e.ticklabelposition&&-1!==e.ticklabelposition.indexOf("inside"),c=!l;if(r&&(r*=o?-1:1),n){var u=e.side;n*=l&&("top"===u||"left"===u)||c&&("bottom"===u||"right"===u)?1:-1}return"x"===e._id.charAt(0)?function(t){return s(a+e._offset+e.l2p(ke(t))+r,i+n)}:function(t){return s(i+n,a+e._offset+e.l2p(ke(t))+r)}},G.makeTickPath=function(e,t,r,n){n||(n={});var a=n.minor;if(a&&!e.minor)return"";var i=void 0!==n.len?n.len:a?e.minor.ticklen:e.ticklen,o=e._id.charAt(0),l=(e.linewidth||1)/2;return"x"===o?"M0,"+(t+l*r)+"v"+i*r:"M"+(t+l*r)+",0h"+i*r},G.makeLabelFns=function(e,t,r){var n=e.ticklabelposition||"",i=function(e){return-1!==n.indexOf(e)},o=i("top"),s=i("left"),c=i("right"),u=i("bottom")||s||o||c,f=i("inside"),d="inside"===n&&"inside"===e.ticks||!f&&"outside"===e.ticks&&"boundaries"!==e.tickson,h=0,p=0,v=d?e.ticklen:0;if(f?v*=-1:u&&(v=0),d&&(h+=v,r)){var g=l.deg2rad(r);h=v*Math.cos(g)+1,p=v*Math.sin(g)}e.showticklabels&&(d||e.showline)&&(h+=.2*e.tickfont.size);var m,y,x,b,_,w={labelStandoff:h+=(e.linewidth||1)/2*(f?-1:1),labelShift:p},T=0,M=e.side,k=e._id.charAt(0),A=e.tickangle;if("x"===k)b=(_=!f&&"bottom"===M||f&&"top"===M)?1:-1,f&&(b*=-1),m=p*b,y=t+h*b,x=_?1:-.2,90===Math.abs(A)&&(f?x+=Y:x=-90===A&&"bottom"===M?V:90===A&&"top"===M?Y:.5,T=Y/2*(A/90)),w.xFn=function(e){return e.dx+m+T*e.fontSize},w.yFn=function(e){return e.dy+y+e.fontSize*x},w.anchorFn=function(e,t){if(u){if(s)return"end";if(c)return"start"}return a(t)&&0!==t&&180!==t?t*b<0!==f?"end":"start":"middle"},w.heightFn=function(t,r,n){return r<-60||r>60?-.5*n:"top"===e.side!==f?-n:0};else if("y"===k){if(b=(_=!f&&"left"===M||f&&"right"===M)?1:-1,f&&(b*=-1),m=h,y=p*b,x=0,f||90!==Math.abs(A)||(x=-90===A&&"left"===M||90===A&&"right"===M?V:.5),f){var L=a(A)?+A:0;if(0!==L){var S=l.deg2rad(L);T=Math.abs(Math.sin(S))*V*b,x=0}}w.xFn=function(e){return e.dx+t-(m+e.fontSize*x)*b+T*e.fontSize},w.yFn=function(e){return e.dy+y+e.fontSize*Y},w.anchorFn=function(e,t){return a(t)&&90===Math.abs(t)?"middle":_?"end":"start"},w.heightFn=function(t,r,n){return"right"===e.side&&(r*=-1),r<-30?-n:r<30?-.5*n:0}}return w},G.drawTicks=function(e,t,r){r=r||{};var a=t._id+"tick",i=[].concat(t.minor&&t.minor.ticks?r.vals.filter((function(e){return e.minor&&!e.noTick})):[]).concat(t.ticks?r.vals.filter((function(e){return!e.minor&&!e.noTick})):[]),o=r.layer.selectAll("path."+a).data(i,Ae);o.exit().remove(),o.enter().append("path").classed(a,1).classed("ticks",1).classed("crisp",!1!==r.crisp).each((function(e){return f.stroke(n.select(this),e.minor?t.minor.tickcolor:t.tickcolor)})).style("stroke-width",(function(r){return d.crispRound(e,r.minor?t.minor.tickwidth:t.tickwidth,1)+"px"})).attr("d",r.path).style("display",null),He(t,[F]),o.attr("transform",r.transFn)},G.drawGrid=function(e,t,r){if(r=r||{},"sync"!==t.tickmode){var a=t._id+"grid",i=t.minor&&t.minor.showgrid,o=i?r.vals.filter((function(e){return e.minor})):[],l=t.showgrid?r.vals.filter((function(e){return!e.minor})):[],s=r.counterAxis;if(s&&G.shouldShowZeroLine(e,t,s))for(var c="array"===t.tickmode,u=0;u=0;m--){var y=m?v:g;if(y){var x=y.selectAll("path."+a).data(m?l:o,Ae);x.exit().remove(),x.enter().append("path").classed(a,1).classed("crisp",!1!==r.crisp),x.attr("transform",r.transFn).attr("d",r.path).each((function(e){return f.stroke(n.select(this),e.minor?t.minor.gridcolor:t.gridcolor||"#ddd")})).style("stroke-dasharray",(function(e){return d.dashStyle(e.minor?t.minor.griddash:t.griddash,e.minor?t.minor.gridwidth:t.gridwidth)})).style("stroke-width",(function(e){return(e.minor?p:t._gw)+"px"})).style("display",null),"function"==typeof r.path&&x.attr("d",r.path)}}He(t,[R,N])}},G.drawZeroLine=function(e,t,r){r=r||r;var n=t._id+"zl",a=G.shouldShowZeroLine(e,t,r.counterAxis),i=r.layer.selectAll("path."+n).data(a?[{x:0,id:t._id}]:[]);i.exit().remove(),i.enter().append("path").classed(n,1).classed("zl",1).classed("crisp",!1!==r.crisp).each((function(){r.layer.selectAll("path").sort((function(e,t){return J(e.id,t.id)}))})),i.attr("transform",r.transFn).attr("d",r.path).call(f.stroke,t.zerolinecolor||f.defaultLine).style("stroke-width",d.crispRound(e,t.zerolinewidth,t._gw||1)+"px").style("display",null),He(t,[E])},G.drawLabels=function(e,t,r){r=r||{};var i=e._fullLayout,o=t._id,u=r.cls||o+"tick",f=r.vals.filter((function(e){return e.text})),h=r.labelFns,p=r.secondary?0:t.tickangle,v=(t._prevTickAngles||{})[u],g=r.layer.selectAll("g."+u).data(t.showticklabels?f:[],Ae),m=[];function y(e,i){e.each((function(e){var o=n.select(this),l=o.select(".text-math-group"),u=h.anchorFn(e,i),f=r.transFn.call(o.node(),e)+(a(i)&&0!=+i?" rotate("+i+","+h.xFn(e)+","+(h.yFn(e)-e.fontSize/2)+")":""),p=c.lineCount(o),v=U*e.fontSize,g=h.heightFn(e,a(i)?+i:0,(p-1)*v);if(g&&(f+=s(0,g)),l.empty()){var m=o.select("text");m.attr({transform:f,"text-anchor":u}),m.style("opacity",1),t._adjustTickLabelsOverflow&&t._adjustTickLabelsOverflow()}else{var y=d.bBox(l.node()).width*{end:-.5,start:.5}[u];l.attr("transform",f+s(y,0))}}))}g.enter().append("g").classed(u,1).append("text").attr("text-anchor","middle").each((function(t){var r=n.select(this),a=e._promises.length;r.call(c.positionText,h.xFn(t),h.yFn(t)).call(d.font,{family:t.font,size:t.fontSize,color:t.fontColor,weight:t.fontWeight,style:t.fontStyle,variant:t.fontVariant,textcase:t.fontTextcase,lineposition:t.fontLineposition,shadow:t.fontShadow}).text(t.text).call(c.convertToTspans,e),e._promises[a]?m.push(e._promises.pop().then((function(){y(r,p)}))):y(r,p)})),He(t,[H]),g.exit().remove(),r.repositionOnUpdate&&g.each((function(e){n.select(this).select("text").call(c.positionText,h.xFn(e),h.yFn(e))})),t._adjustTickLabelsOverflow=function(){var r=t.ticklabeloverflow;if(r&&"allow"!==r){var a=-1!==r.indexOf("hide"),o="x"===t._id.charAt(0),s=0,c=o?e._fullLayout.width:e._fullLayout.height;if(-1!==r.indexOf("domain")){var u=l.simpleMap(t.range,t.r2l);s=t.l2p(u[0])+t._offset,c=t.l2p(u[1])+t._offset}var f=Math.min(s,c),h=Math.max(s,c),p=t.side,v=1/0,m=-1/0;for(var y in g.each((function(e){var r=n.select(this);if(r.select(".text-math-group").empty()){var i=d.bBox(r.node()),l=0;o?(i.right>h||i.lefth||i.top+(t.tickangle?0:e.fontSize/4)t["_visibleLabelMin_"+r._id]?s.style("display","none"):"tick"!==e.K||a||s.style("display",null)}))}))}))}))},y(g,v+1?v:p);var x=null;t._selections&&(t._selections[u]=g);var b=[function(){return m.length&&Promise.all(m)}];t.automargin&&i._redrawFromAutoMarginCount&&90===v?(x=v,b.push((function(){y(g,v)}))):b.push((function(){if(y(g,p),f.length&&t.autotickangles&&("log"!==t.type||"D"!==String(t.dtick).charAt(0))){x=t.autotickangles[0];var e,n=0,a=[],i=1;g.each((function(e){n=Math.max(n,e.fontSize);var r=t.l2p(e.x),o=Ce(this),l=d.bBox(o.node());i=Math.max(i,c.lineCount(o)),a.push({top:0,bottom:10,height:10,left:r-l.width/2,right:r+l.width/2+2,width:l.width+2})}));var o=("boundaries"===t.tickson||t.showdividers)&&!r.secondary,s=f.length,u=Math.abs((f[s-1].x-f[0].x)*t._m)/(s-1),h=o?u/2:u,v=o?t.ticklen:1.25*n*i,m=h/Math.sqrt(Math.pow(h,2)+Math.pow(v,2)),b=t.autotickangles.map((function(e){return e*Math.PI/180})),_=b.find((function(e){return Math.abs(Math.cos(e))<=m}));void 0===_&&(_=b.reduce((function(e,t){return Math.abs(Math.cos(e))B*I&&(D=I,S[L]=C[L]=P[L])}var j=Math.abs(D-O);j-M>0?M*=1+M/(j-=M):M=0,"y"!==t._id.charAt(0)&&(M=-M),S[A]=w.p2r(w.r2p(C[A])+k*M),"min"===w.autorange||"max reversed"===w.autorange?(S[0]=null,w._rangeInitial0=void 0,w._rangeInitial1=void 0):"max"!==w.autorange&&"min reversed"!==w.autorange||(S[1]=null,w._rangeInitial0=void 0,w._rangeInitial1=void 0),i._insideTickLabelsUpdaterange[w._name+".range"]=S}var Y=l.syncOrAsync(b);return Y&&Y.then&&e._promises.push(Y),Y},G.getPxPosition=function(e,t){var r,n=e._fullLayout._size,a=t._id.charAt(0),i=t.side;return"free"!==t.anchor?r=t._anchorAxis:"x"===a?r={_offset:n.t+(1-(t.position||0))*n.h,_length:0}:"y"===a&&(r={_offset:n.l+(t.position||0)*n.w+t._shift,_length:0}),"top"===i||"left"===i?r._offset:"bottom"===i||"right"===i?r._offset+r._length:void 0},G.shouldShowZeroLine=function(e,t,r){var n=l.simpleMap(t.range,t.r2l);return n[0]*n[1]<=0&&t.zeroline&&("linear"===t.type||"-"===t.type)&&!(t.rangebreaks&&t.maskBreaks(0)===I)&&(Se(t,0)||!function(e,t,r,n){var a=r._mainAxis;if(a){var i=e._fullLayout,o=t._id.charAt(0),l=G.counterLetter(t._id),s=t._offset+(Math.abs(n[0])1)for(n=1;n2*o}(a,t))return"date";var g="strict"!==r.autotypenumbers;return function(e,t){for(var r=e.length,n=f(r),a=0,o=0,l={},u=0;u2*a}(a,g)?"category":function(e,t){for(var r=e.length,n=0;n=2){var l,c,u="";if(2===o.length)for(l=0;l<2;l++)if(c=_(o[l])){u=m;break}var f=a("pattern",u);if(f===m)for(l=0;l<2;l++)(c=_(o[l]))&&(t.bounds[l]=o[l]=c-1);if(f)for(l=0;l<2;l++)switch(c=o[l],f){case m:if(!n(c))return void(t.enabled=!1);if((c=+c)!==Math.floor(c)||c<0||c>=7)return void(t.enabled=!1);t.bounds[l]=o[l]=c;break;case y:if(!n(c))return void(t.enabled=!1);if((c=+c)<0||c>24)return void(t.enabled=!1);t.bounds[l]=o[l]=c}if(!1===r.autorange){var d=r.range;if(d[0]d[1])return void(t.enabled=!1)}else if(o[0]>d[0]&&o[1]n?1:-1:+(e.substr(1)||1)-+(t.substr(1)||1)},t.ref2id=function(e){return!!/^[xyz]/.test(e)&&e.split(" ")[0]},t.isLinked=function(e,t){return i(t,e._axisMatchGroups)||i(t,e._axisConstraintGroups)}},6473:function(e,t,r){"use strict";var n=r(7800).isTypedArraySpec;e.exports=function(e,t,r,a){if("category"===t.type){var i,o=e.categoryarray,l=Array.isArray(o)&&o.length>0||n(o);l&&(i="array");var s,c=r("categoryorder",i);"array"===c&&(s=r("categoryarray")),l||"array"!==c||(c=t.categoryorder="trace"),"trace"===c?t._initialCategories=[]:"array"===c?t._initialCategories=s.slice():(s=function(e,t){var r,n,a,i=t.dataAttr||e._id.charAt(0),o={};if(t.axData)r=t.axData;else for(r=[],n=0;nn?a.substr(n):i.substr(r))+o:a+i+e*t:o}function g(e,t){for(var r=t._size,n=r.h/r.w,a={},i=Object.keys(e),o=0;oc*x)||T)for(r=0;rz&&ND&&(D=N);d/=(D-O)/(2*P),O=s.l2r(O),D=s.l2r(D),s.range=s._input.range=L=0?Math.min(e,.9):1/(1/Math.max(e,-.3)+3.222))}function H(e,t,r,n,a){return e.append("path").attr("class","zoombox").style({fill:t>.2?"rgba(0,0,0,0)":"rgba(255,255,255,0)","stroke-width":0}).attr("transform",c(r,n)).attr("d",a+"Z")}function B(e,t,r){return e.append("path").attr("class","zoombox-corners").style({fill:f.background,stroke:f.defaultLine,"stroke-width":1,opacity:0}).attr("transform",c(t,r)).attr("d","M0,0Z")}function j(e,t,r,n,a,i){e.attr("d",n+"M"+r.l+","+r.t+"v"+r.h+"h"+r.w+"v-"+r.h+"h-"+r.w+"Z"),Y(e,t,a,i)}function Y(e,t,r,n){r||(e.transition().style("fill",n>.2?"rgba(0,0,0,0.4)":"rgba(255,255,255,0.3)").duration(200),t.transition().style("opacity",1).duration(200))}function V(e){n.select(e).selectAll(".zoombox,.js-zoombox-backdrop,.js-zoombox-menu,.zoombox-corners").remove()}function U(e){P&&e.data&&e._context.showTips&&(a.notifier(a._(e,"Double-click to zoom back out"),"long"),P=!1)}function q(e){var t=Math.floor(Math.min(e.b-e.t,e.r-e.l,D)/2);return"M"+(e.l-3.5)+","+(e.t-.5+t)+"h3v"+-t+"h"+t+"v-3h-"+(t+3)+"ZM"+(e.r+3.5)+","+(e.t-.5+t)+"h-3v"+-t+"h"+-t+"v-3h"+(t+3)+"ZM"+(e.r+3.5)+","+(e.b+.5-t)+"h-3v"+t+"h"+-t+"v3h"+(t+3)+"ZM"+(e.l-3.5)+","+(e.b+.5-t)+"h3v"+t+"h"+t+"v3h-"+(t+3)+"Z"}function G(e,t,r,n,i){for(var o,l,s,c,u=!1,f={},d={},h=(i||{}).xaHash,p=(i||{}).yaHash,v=0;v=0)a._fullLayout._deactivateShape(a);else{var o=a._fullLayout.clickmode;if(V(a),2!==e||me||Ue(),ge)o.indexOf("select")>-1&&L(r,a,X,K,t.id,De),o.indexOf("event")>-1&&h.click(a,r,t.id);else if(1===e&&me){var l=v?z:P,c="s"===v||"w"===m?0:1,f=l._name+".range["+c+"]",d=function(e,t){var r,n=e.range[t],a=Math.abs(n-e.range[1-t]);return"date"===e.type?n:"log"===e.type?(r=Math.ceil(Math.max(0,-Math.log(a)/Math.LN10))+3,i("."+r+"g")(Math.pow(10,n))):(r=Math.floor(Math.log(Math.abs(n))/Math.LN10)-Math.floor(Math.log(a)/Math.LN10)+4,i("."+String(r)+"g")(n))}(l,c),p="left",g="middle";if(l.fixedrange)return;v?(g="n"===v?"top":"bottom","right"===l.side&&(p="right")):"e"===m&&(p="right"),a._context.showAxisRangeEntryBoxes&&n.select(be).call(u.makeEditable,{gd:a,immediate:!0,background:a._fullLayout.paper_bgcolor,text:String(d),fill:l.tickfont?l.tickfont.color:"#444",horizontalAlign:p,verticalAlign:g}).on("edit",(function(e){var t=l.d2r(e);void 0!==t&&s.call("_guiRelayout",a,f,t)}))}}}function Ie(t,r){if(e._transitioningWithDuration)return!1;var n=Math.max(0,Math.min(ee,he*t+_e)),a=Math.max(0,Math.min(te,pe*r+we)),i=Math.abs(n-_e),o=Math.abs(a-we);function l(){Le="",Te.r=Te.l,Te.t=Te.b,Ce.attr("d","M0,0Z")}if(Te.l=Math.min(_e,n),Te.r=Math.max(_e,n),Te.t=Math.min(we,a),Te.b=Math.max(we,a),re.isSubplotConstrained)i>D||o>D?(Le="xy",i/ee>o/te?(o=i*te/ee,we>a?Te.t=we-o:Te.b=we+o):(i=o*ee/te,_e>n?Te.l=_e-i:Te.r=_e+i),Ce.attr("d",q(Te))):l();else if(ne.isSubplotConstrained)if(i>D||o>D){Le="xy";var s=Math.min(Te.l/ee,(te-Te.b)/te),c=Math.max(Te.r/ee,(te-Te.t)/te);Te.l=s*ee,Te.r=c*ee,Te.b=(1-s)*te,Te.t=(1-c)*te,Ce.attr("d",q(Te))}else l();else!ie||o0){var u;if(ne.isSubplotConstrained||!ae&&1===ie.length){for(u=0;u1&&(void 0!==i.maxallowed&&le===(i.range[0]1&&(void 0!==o.maxallowed&&se===(o.range[0]1)if(s)t.xlines=d(n,"path","xlines-above"),t.ylines=d(n,"path","ylines-above"),t.xaxislayer=d(n,"g","xaxislayer-above"),t.yaxislayer=d(n,"g","yaxislayer-above");else{if(!i){var f=d(n,"g","layer-subplot");t.shapelayer=d(f,"g","shapelayer"),t.imagelayer=d(f,"g","imagelayer"),t.minorGridlayer=d(n,"g","minor-gridlayer"),t.gridlayer=d(n,"g","gridlayer"),t.zerolinelayer=d(n,"g","zerolinelayer");var v=d(n,"g","layer-between");t.shapelayerBetween=d(v,"g","shapelayer"),t.imagelayerBetween=d(v,"g","imagelayer"),d(n,"path","xlines-below"),d(n,"path","ylines-below"),t.overlinesBelow=d(n,"g","overlines-below"),d(n,"g","xaxislayer-below"),d(n,"g","yaxislayer-below"),t.overaxesBelow=d(n,"g","overaxes-below")}t.overplot=d(n,"g","overplot"),t.plot=d(t.overplot,"g",a),i||(t.xlines=d(n,"path","xlines-above"),t.ylines=d(n,"path","ylines-above"),t.overlinesAbove=d(n,"g","overlines-above"),d(n,"g","xaxislayer-above"),d(n,"g","yaxislayer-above"),t.overaxesAbove=d(n,"g","overaxes-above"),t.xlines=n.select(".xlines-"+o),t.ylines=n.select(".ylines-"+l),t.xaxislayer=n.select(".xaxislayer-"+o),t.yaxislayer=n.select(".yaxislayer-"+l))}else{var g=t.mainplotinfo,m=g.plotgroup,y=a+"-x",x=a+"-y";t.minorGridlayer=g.minorGridlayer,t.gridlayer=g.gridlayer,t.zerolinelayer=g.zerolinelayer,d(g.overlinesBelow,"path",y),d(g.overlinesBelow,"path",x),d(g.overaxesBelow,"g",y),d(g.overaxesBelow,"g",x),t.plot=d(g.overplot,"g",a),d(g.overlinesAbove,"path",y),d(g.overlinesAbove,"path",x),d(g.overaxesAbove,"g",y),d(g.overaxesAbove,"g",x),t.xlines=m.select(".overlines-"+o).select("."+y),t.ylines=m.select(".overlines-"+l).select("."+x),t.xaxislayer=m.select(".overaxes-"+o).select("."+y),t.yaxislayer=m.select(".overaxes-"+l).select("."+x)}i||(s||(h(t.minorGridlayer,"g",t.xaxis._id),h(t.minorGridlayer,"g",t.yaxis._id),t.minorGridlayer.selectAll("g").map((function(e){return e[0]})).sort(c.idSort),h(t.gridlayer,"g",t.xaxis._id),h(t.gridlayer,"g",t.yaxis._id),t.gridlayer.selectAll("g").map((function(e){return e[0]})).sort(c.idSort)),t.xlines.style("fill","none").classed("crisp",!0),t.ylines.style("fill","none").classed("crisp",!0))}function m(e,t){if(e){var r={};for(var a in e.each((function(e){var a=e[0];n.select(this).remove(),y(a,t),r[a]=!0})),t._plots)for(var i=t._plots[a].overlays||[],o=0;o0){var g=h.id;if(-1!==g.indexOf(p))continue;g+=p+(u+1),h=i.extendFlat({},h,{id:g,plot:o._cartesianlayer.selectAll(".subplot").select("."+g)})}for(var m,y=[],x=0;x1&&(w+=p+_),b.push(n+w),r=0;rb[1]-1/4096&&(t.domain=l),a.noneOrAll(e.domain,t.domain,l),"sync"===t.tickmode&&(t.tickmode="auto")}return r("layer"),t}},4616:function(e,t,r){"use strict";var n=r(7703);e.exports=function(e,t,r,a,i){i||(i={});var o=i.tickSuffixDflt,l=n(e);r("tickprefix")&&r("showtickprefix",l),r("ticksuffix",o)&&r("showticksuffix",l)}},259:function(e,t,r){"use strict";var n=r(5511);e.exports=function(e,t,r,a){var i=t._template||{},o=t.type||i.type||"-";r("minallowed"),r("maxallowed");var l,s=r("range");s||a.noInsiderange||"log"===o||(!(l=r("insiderange"))||null!==l[0]&&null!==l[1]||(t.insiderange=!1,l=void 0),l&&(s=r("range",l)));var c,u=t.getAutorangeDflt(s,a),f=r("autorange",u);!s||(null!==s[0]||null!==s[1])&&(null!==s[0]&&null!==s[1]||"reversed"!==f&&!0!==f)&&(null===s[0]||"min"!==f&&"max reversed"!==f)&&(null===s[1]||"max"!==f&&"min reversed"!==f)||(s=void 0,delete t.range,t.autorange=!0,c=!0),c||(f=r("autorange",u=t.getAutorangeDflt(s,a))),f&&(n(r,f,s),"linear"!==o&&"-"!==o||r("rangemode")),t.cleanRange()}},7611:function(e,t,r){"use strict";var n=r(4530).FROM_BL;e.exports=function(e,t,r){void 0===r&&(r=n[e.constraintoward||"center"]);var a=[e.r2l(e.range[0]),e.r2l(e.range[1])],i=a[0]+(a[1]-a[0])*r;e.range=e._input.range=[e.l2r(i+(a[0]-i)*t),e.l2r(i+(a[1]-i)*t)],e.setScale()}},9091:function(e,t,r){"use strict";var n=r(5568),a=r(2696).aL,i=r(4809),o=i.numberFormat,l=r(721),s=i.cleanNumber,c=i.ms2DateTime,u=i.dateTime2ms,f=i.ensureNumber,d=i.isArrayOrTypedArray,h=r(3821),p=h.FP_SAFE,v=h.BADNUM,g=h.LOG_CLIP,m=h.ONEWEEK,y=h.ONEDAY,x=h.ONEHOUR,b=h.ONEMIN,_=h.ONESEC,w=r(5975),T=r(4826),M=T.HOUR_PATTERN,k=T.WEEKDAY_PATTERN;function A(e){return Math.pow(10,e)}function L(e){return null!=e}e.exports=function(e,t){t=t||{};var r=e._id||"x",h=r.charAt(0);function S(t,r){if(t>0)return Math.log(t)/Math.LN10;if(t<=0&&r&&e.range&&2===e.range.length){var n=e.range[0],a=e.range[1];return.5*(n+a-2*g*Math.abs(n-a))}return v}function C(t,r,n,a){if((a||{}).msUTC&&l(t))return+t;var o=u(t,n||e.calendar);if(o===v){if(!l(t))return v;t=+t;var s=Math.floor(10*i.mod(t+.05,1)),c=Math.round(t-s/10);o=u(new Date(c))+s/10}return o}function O(t,r,n){return c(t,r,n||e.calendar)}function D(t){return e._categories[Math.round(t)]}function P(t){if(L(t)){if(void 0===e._categoriesMap&&(e._categoriesMap={}),void 0!==e._categoriesMap[t])return e._categoriesMap[t];e._categories.push("number"==typeof t?String(t):t);var r=e._categories.length-1;return e._categoriesMap[t]=r,r}return v}function z(t){if(e._categoriesMap)return e._categoriesMap[t]}function I(e){var t=z(e);return void 0!==t?t:l(e)?+e:void 0}function E(e){return l(e)?+e:z(e)}function R(e,t,r){return n.round(r+t*e,2)}function N(e,t,r){return(e-r)/t}var F=function(t){return l(t)?R(t,e._m,e._b):v},H=function(t){return N(t,e._m,e._b)};if(e.rangebreaks){var B="y"===h;F=function(t){if(!l(t))return v;var r=e._rangebreaks.length;if(!r)return R(t,e._m,e._b);var n=B;e.range[0]>e.range[1]&&(n=!n);for(var a=n?-1:1,i=a*t,o=0,s=0;su)){o=i<(c+u)/2?s:s+1;break}o=s+1}var f=e._B[o]||0;return isFinite(f)?R(t,e._m2,f):0},H=function(t){var r=e._rangebreaks.length;if(!r)return N(t,e._m,e._b);for(var n=0,a=0;ae._rangebreaks[a].pmax&&(n=a+1);return N(t,e._m2,e._B[n])}}e.c2l="log"===e.type?S:f,e.l2c="log"===e.type?A:f,e.l2p=F,e.p2l=H,e.c2p="log"===e.type?function(e,t){return F(S(e,t))}:F,e.p2c="log"===e.type?function(e){return A(H(e))}:H,-1!==["linear","-"].indexOf(e.type)?(e.d2r=e.r2d=e.d2c=e.r2c=e.d2l=e.r2l=s,e.c2d=e.c2r=e.l2d=e.l2r=f,e.d2p=e.r2p=function(t){return e.l2p(s(t))},e.p2d=e.p2r=H,e.cleanPos=f):"log"===e.type?(e.d2r=e.d2l=function(e,t){return S(s(e),t)},e.r2d=e.r2c=function(e){return A(s(e))},e.d2c=e.r2l=s,e.c2d=e.l2r=f,e.c2r=S,e.l2d=A,e.d2p=function(t,r){return e.l2p(e.d2r(t,r))},e.p2d=function(e){return A(H(e))},e.r2p=function(t){return e.l2p(s(t))},e.p2r=H,e.cleanPos=f):"date"===e.type?(e.d2r=e.r2d=i.identity,e.d2c=e.r2c=e.d2l=e.r2l=C,e.c2d=e.c2r=e.l2d=e.l2r=O,e.d2p=e.r2p=function(t,r,n){return e.l2p(C(t,0,n))},e.p2d=e.p2r=function(e,t,r){return O(H(e),t,r)},e.cleanPos=function(t){return i.cleanDate(t,v,e.calendar)}):"category"===e.type?(e.d2c=e.d2l=P,e.r2d=e.c2d=e.l2d=D,e.d2r=e.d2l_noadd=I,e.r2c=function(t){var r=E(t);return void 0!==r?r:e.fraction2r(.5)},e.l2r=e.c2r=f,e.r2l=E,e.d2p=function(t){return e.l2p(e.r2c(t))},e.p2d=function(e){return D(H(e))},e.r2p=e.d2p,e.p2r=H,e.cleanPos=function(e){return"string"==typeof e&&""!==e?e:f(e)}):"multicategory"===e.type&&(e.r2d=e.c2d=e.l2d=D,e.d2r=e.d2l_noadd=I,e.r2c=function(t){var r=I(t);return void 0!==r?r:e.fraction2r(.5)},e.r2c_just_indices=z,e.l2r=e.c2r=f,e.r2l=I,e.d2p=function(t){return e.l2p(e.r2c(t))},e.p2d=function(e){return D(H(e))},e.r2p=e.d2p,e.p2r=H,e.cleanPos=function(e){return Array.isArray(e)||"string"==typeof e&&""!==e?e:f(e)},e.setupMultiCategory=function(n){var a,o,l=e._traceIndices,s=e._matchGroup;if(s&&0===e._categories.length)for(var c in s)if(c!==r){var u=t[w.id2name(c)];l=l.concat(u._traceIndices)}var f=[[0,{}],[0,{}]],p=[];for(a=0;as[1]&&(a[l?0:1]=n),a[0]===a[1]){var c=e.l2r(r),u=e.l2r(n);if(void 0!==r){var f=c+1;void 0!==n&&(f=Math.min(f,u)),a[l?1:0]=f}if(void 0!==n){var d=u+1;void 0!==r&&(d=Math.max(d,c)),a[l?0:1]=d}}}},e.cleanRange=function(t,r){e._cleanRange(t,r),e.limitRange(t)},e._cleanRange=function(t,r){r||(r={}),t||(t="range");var n,a,o=i.nestedProperty(e,t).get();if(a=(a="date"===e.type?i.dfltRange(e.calendar):"y"===h?T.DFLTRANGEY:"realaxis"===e._name?[0,1]:r.dfltRange||T.DFLTRANGEX).slice(),"tozero"!==e.rangemode&&"nonnegative"!==e.rangemode||(a[0]=0),o&&2===o.length){var s=null===o[0],c=null===o[1];for("date"!==e.type||e.autorange||(o[0]=i.cleanDate(o[0],v,e.calendar),o[1]=i.cleanDate(o[1],v,e.calendar)),n=0;n<2;n++)if("date"===e.type){if(!i.isDateTime(o[n],e.calendar)){e[t]=a;break}if(e.r2l(o[0])===e.r2l(o[1])){var u=i.constrain(e.r2l(o[0]),i.MIN_MS+1e3,i.MAX_MS-1e3);o[0]=e.l2r(u-1e3),o[1]=e.l2r(u+1e3);break}}else{if(!l(o[n])){if(s||c||!l(o[1-n])){e[t]=a;break}o[n]=o[1-n]*(n?10:.1)}if(o[n]<-p?o[n]=-p:o[n]>p&&(o[n]=p),o[0]===o[1]){var f=Math.max(1,Math.abs(1e-6*o[0]));o[0]-=f,o[1]+=f}}}else i.nestedProperty(e,t).set(a)},e.setScale=function(r){var n=t._size;if(e.overlaying){var a=w.getFromId({_fullLayout:t},e.overlaying);e.domain=a.domain}var i=r&&e._r?"_r":"range",o=e.calendar;e.cleanRange(i);var l,s,c=e.r2l(e[i][0],o),u=e.r2l(e[i][1],o),f="y"===h;if(f?(e._offset=n.t+(1-e.domain[1])*n.h,e._length=n.h*(e.domain[1]-e.domain[0]),e._m=e._length/(c-u),e._b=-e._m*u):(e._offset=n.l+e.domain[0]*n.w,e._length=n.w*(e.domain[1]-e.domain[0]),e._m=e._length/(u-c),e._b=-e._m*c),e._rangebreaks=[],e._lBreaks=0,e._m2=0,e._B=[],e.rangebreaks&&(e._rangebreaks=e.locateBreaks(Math.min(c,u),Math.max(c,u)),e._rangebreaks.length)){for(l=0;lu&&(d=!d),d&&e._rangebreaks.reverse();var p=d?-1:1;for(e._m2=p*e._length/(Math.abs(u-c)-e._lBreaks),e._B.push(-e._m2*(f?u:c)),l=0;la&&(a+=7,oa&&(a+=24,o=n&&o=n&&t=l.min&&(el.max&&(l.max=n),a=!1)}a&&c.push({min:e,max:n})}};for(n=0;nr.duration?(function(){for(var r={},n=0;n rect").call(o.setTranslate,0,0).call(o.setScale,1,1),e.plot.call(o.setTranslate,t._offset,r._offset).call(o.setScale,1,1);var n=e.plot.selectAll(".scatterlayer .trace");n.selectAll(".point").call(o.setPointGroupScale,1,1),n.selectAll(".textpoint").call(o.setTextPointsScale,1,1),n.call(o.hideOutsideRangePoints,e)}function g(t,r){var n=t.plotinfo,a=n.xaxis,s=n.yaxis,c=a._length,u=s._length,f=!!t.xr1,d=!!t.yr1,h=[];if(f){var p=i.simpleMap(t.xr0,a.r2l),v=i.simpleMap(t.xr1,a.r2l),g=p[1]-p[0],m=v[1]-v[0];h[0]=(p[0]*(1-r)+r*v[0]-p[0])/(p[1]-p[0])*c,h[2]=c*(1-r+r*m/g),a.range[0]=a.l2r(p[0]*(1-r)+r*v[0]),a.range[1]=a.l2r(p[1]*(1-r)+r*v[1])}else h[0]=0,h[2]=c;if(d){var y=i.simpleMap(t.yr0,s.r2l),x=i.simpleMap(t.yr1,s.r2l),b=y[1]-y[0],_=x[1]-x[0];h[1]=(y[1]*(1-r)+r*x[1]-y[1])/(y[0]-y[1])*u,h[3]=u*(1-r+r*_/b),s.range[0]=a.l2r(y[0]*(1-r)+r*x[0]),s.range[1]=s.l2r(y[1]*(1-r)+r*x[1])}else h[1]=0,h[3]=u;l.drawOne(e,a,{skipTitle:!0}),l.drawOne(e,s,{skipTitle:!0}),l.redrawComponents(e,[a._id,s._id]);var w=f?c/h[2]:1,T=d?u/h[3]:1,M=f?h[0]:0,k=d?h[1]:0,A=f?h[0]/h[2]*c:0,L=d?h[1]/h[3]*u:0,S=a._offset-A,C=s._offset-L;n.clipRect.call(o.setTranslate,M,k).call(o.setScale,1/w,1/T),n.plot.call(o.setTranslate,S,C).call(o.setScale,w,T),o.setPointGroupScale(n.zoomScalePts,1/w,1/T),o.setTextPointsScale(n.zoomScaleTxt,1/w,1/T)}l.redrawComponents(e)}},4392:function(e,t,r){"use strict";var n=r(3626).traceIs,a=r(9666);function i(e){return{v:"x",h:"y"}[e.orientation||"v"]}function o(e,t){var r=i(e),a=n(e,"box-violin"),o=n(e._fullInput||{},"candlestick");return a&&!o&&t===r&&void 0===e[r]&&void 0===e[r+"0"]}e.exports=function(e,t,r,l){r("autotypenumbers",l.autotypenumbersDflt),"-"===r("type",(l.splomStash||{}).type)&&(function(e,t){if("-"===e.type){var r,l=e._id,s=l.charAt(0);-1!==l.indexOf("scene")&&(l=s);var c=function(e,t,r){for(var n=0;n0&&(a["_"+r+"axes"]||{})[t])return a;if((a[r+"axis"]||r)===t){if(o(a,r))return a;if((a[r]||[]).length||a[r+"0"])return a}}}(t,l,s);if(c)if("histogram"!==c.type||s!=={v:"y",h:"x"}[c.orientation||"v"]){var u=s+"calendar",f=c[u],d={noMultiCategory:!n(c,"cartesian")||n(c,"noMultiCategory")};if("box"===c.type&&c._hasPreCompStats&&s==={h:"x",v:"y"}[c.orientation||"v"]&&(d.noMultiCategory=!0),d.autotypenumbers=e.autotypenumbers,o(c,s)){var h=i(c),p=[];for(r=0;r0?".":"")+i;a.isPlainObject(o)?s(o,t,l,n+1):t(l,i,o)}}))}t.manageCommandObserver=function(e,r,n,o){var l={},s=!0;r&&r._commandObserver&&(l=r._commandObserver),l.cache||(l.cache={}),l.lookupTable={};var c=t.hasSimpleAPICommandBindings(e,n,l.lookupTable);if(r&&r._commandObserver){if(c)return l;if(r._commandObserver.remove)return r._commandObserver.remove(),r._commandObserver=null,l}if(c){i(e,c,l.cache),l.check=function(){if(s){var t=i(e,c,l.cache);return t.changed&&o&&void 0!==l.lookupTable[t.value]&&(l.disable(),Promise.resolve(o({value:t.value,type:c.type,prop:c.prop,traces:c.traces,index:l.lookupTable[t.value]})).then(l.enable,l.enable)),t.changed}};for(var u=["plotly_relayout","plotly_redraw","plotly_restyle","plotly_update","plotly_animatingframe","plotly_afterplot"],f=0;f=t.width-20?(i["text-anchor"]="start",i.x=5):(i["text-anchor"]="end",i.x=t._paper.attr("width")-7),r.attr(i);var o=r.select(".js-link-to-tool"),l=r.select(".js-link-spacer"),s=r.select(".js-sourcelinks");e._context.showSources&&e._context.showSources(e),e._context.showLink&&function(e,t){t.text("");var r=t.append("a").attr({"xlink:xlink:href":"#",class:"link--impt link--embedview","font-weight":"bold"}).text(e._context.linkText+" "+String.fromCharCode(187));if(e._context.sendData)r.on("click",(function(){w.sendDataToCloud(e)}));else{var n=window.location.pathname.split("/"),a=window.location.search;r.attr({"xlink:xlink:show":"new","xlink:xlink:href":"/"+n[2].split(".")[0]+"/"+n[1]+a})}}(e,o),l.text(o.text()&&s.text()?" - ":"")}},w.sendDataToCloud=function(e){var t=(window.PLOTLYENV||{}).BASE_URL||e._context.plotlyServerURL;if(t){e.emit("plotly_beforeexport");var r=n.select(e).append("div").attr("id","hiddenform").style("display","none"),a=r.append("form").attr({action:t+"/external",method:"post",target:"_blank"});return a.append("input").attr({type:"text",name:"data"}).node().value=w.graphJson(e,!1,"keepdata"),a.node().submit(),r.remove(),e.emit("plotly_afterexport"),!1}};var k=["days","shortDays","months","shortMonths","periods","dateTime","date","time","decimal","thousands","grouping","currency"],A=["year","month","dayMonth","dayMonthYear"];function L(e,t){var r=e._context.locale;r||(r="en-US");var n=!1,a={};function i(e){for(var r=!0,i=0;i1&&I.length>1){for(s.getComponentMethod("grid","sizeDefaults")(c,l),o=0;o15&&I.length>15&&0===l.shapes.length&&0===l.images.length,w.linkSubplots(d,l,u,n),w.cleanPlot(d,l,u,n);var H=!(!n._has||!n._has("gl2d")),B=!(!l._has||!l._has("gl2d")),j=!(!n._has||!n._has("cartesian"))||H,Y=!(!l._has||!l._has("cartesian"))||B;j&&!Y?n._bgLayer.remove():Y&&!j&&(l._shouldCreateBgLayer=!0),n._zoomlayer&&!e._dragging&&v({_fullLayout:n}),function(e,t){var r,n=[];t.meta&&(r=t._meta={meta:t.meta,layout:{meta:t.meta}});for(var a=0;a0){var u=1-2*l;n=Math.round(u*n),a=Math.round(u*a)}}var d=w.layoutAttributes.width.min,h=w.layoutAttributes.height.min;n1,v=!t.height&&Math.abs(r.height-a)>1;(v||p)&&(p&&(r.width=n),v&&(r.height=a)),e._initialAutoSize||(e._initialAutoSize={width:n,height:a}),w.sanitizeMargins(r)},w.supplyLayoutModuleDefaults=function(e,t,r,n){var a,i,o,l=s.componentsRegistry,c=t._basePlotModules,u=s.subplotsRegistry.cartesian;for(a in l)(o=l[a]).includeBasePlot&&o.includeBasePlot(e,t);for(var d in c.length||c.push(u),t._has("cartesian")&&(s.getComponentMethod("grid","contentDefaults")(e,t),u.finalizeSubplots(e,t)),t._subplots)t._subplots[d].sort(f.subplotSort);for(i=0;i1&&(r.l/=m,r.r/=m)}if(h){var y=(r.t+r.b)/h;y>1&&(r.t/=y,r.b/=y)}var x=void 0!==r.xl?r.xl:r.x,b=void 0!==r.xr?r.xr:r.x,_=void 0!==r.yt?r.yt:r.y,T=void 0!==r.yb?r.yb:r.y;p[t]={l:{val:x,size:r.l+g},r:{val:b,size:r.r+g},b:{val:T,size:r.b+g},t:{val:_,size:r.t+g}},v[t]=1}else delete p[t],delete v[t];if(!n._replotting)return w.doAutoMargin(e)}},w.doAutoMargin=function(e){var t=e._fullLayout,r=t.width,n=t.height;t._size||(t._size={}),P(t);var a=t._size,i=t.margin,l={t:0,b:0,l:0,r:0},c=f.extendFlat({},a),u=i.l,d=i.r,h=i.t,v=i.b,g=t._pushmargin,m=t._pushmarginIds,y=t.minreducedwidth,x=t.minreducedheight;if(!1!==i.autoexpand){for(var b in g)m[b]||delete g[b];var _=e._fullLayout._reservedMargin;for(var T in _)for(var M in _[T]){var k=_[T][M];l[M]=Math.max(l[M],k)}for(var A in g.base={l:{val:0,size:u},r:{val:1,size:d},t:{val:1,size:h},b:{val:0,size:v}},l){var L=0;for(var S in g)"base"!==S&&o(g[S][A].size)&&(L=g[S][A].size>L?g[S][A].size:L);var C=Math.max(0,i[A]-L);l[A]=Math.max(0,l[A]-C)}for(var O in g){var D=g[O].l||{},z=g[O].b||{},I=D.val,E=D.size,R=z.val,N=z.size,F=r-l.r-l.l,H=n-l.t-l.b;for(var B in g){if(o(E)&&g[B].r){var j=g[B].r.val,Y=g[B].r.size;if(j>I){var V=(E*j+(Y-F)*I)/(j-I),U=(Y*(1-I)+(E-F)*(1-j))/(j-I);V+U>u+d&&(u=V,d=U)}}if(o(N)&&g[B].t){var q=g[B].t.val,G=g[B].t.size;if(q>R){var Z=(N*q+(G-H)*R)/(q-R),W=(G*(1-R)+(N-H)*(1-q))/(q-R);Z+W>v+h&&(v=Z,h=W)}}}}}var J=f.constrain(r-i.l-i.r,2,y),X=f.constrain(n-i.t-i.b,2,x),K=Math.max(0,r-J),Q=Math.max(0,n-X);if(K){var $=(u+d)/K;$>1&&(u/=$,d/=$)}if(Q){var ee=(v+h)/Q;ee>1&&(v/=ee,h/=ee)}if(a.l=Math.round(u)+l.l,a.r=Math.round(d)+l.r,a.t=Math.round(h)+l.t,a.b=Math.round(v)+l.b,a.p=Math.round(i.pad),a.w=Math.round(r)-a.l-a.r,a.h=Math.round(n)-a.t-a.b,!t._replotting&&(w.didMarginChange(c,a)||function(e){if("_redrawFromAutoMarginCount"in e._fullLayout)return!1;var t=p.list(e,"",!0);for(var r in t)if(t[r].autoshift||t[r].shift)return!0;return!1}(e))){"_redrawFromAutoMarginCount"in t?t._redrawFromAutoMarginCount++:t._redrawFromAutoMarginCount=1;var te=3*(1+Object.keys(m).length);if(t._redrawFromAutoMarginCount0&&(e._transitioningWithDuration=!0),e._transitionData._interruptCallbacks.push((function(){n=!0})),r.redraw&&e._transitionData._interruptCallbacks.push((function(){return s.call("redraw",e)})),e._transitionData._interruptCallbacks.push((function(){e.emit("plotly_transitioninterrupted",[])}));var i=0,o=0;function l(){return i++,function(){var t;o++,n||o!==i||(t=a,e._transitionData&&(function(e){if(e)for(;e.length;)e.shift()}(e._transitionData._interruptCallbacks),Promise.resolve().then((function(){if(r.redraw)return s.call("redraw",e)})).then((function(){e._transitioning=!1,e._transitioningWithDuration=!1,e.emit("plotly_transitioned",[])})).then(t)))}}r.runFn(l),setTimeout(l())}))}],i=f.syncOrAsync(a,e);return i&&i.then||(i=Promise.resolve()),i.then((function(){return e}))}w.didMarginChange=function(e,t){for(var r=0;r1)return!0}return!1},w.graphJson=function(e,t,r,n,a,i){(a&&t&&!e._fullData||a&&!t&&!e._fullLayout)&&w.supplyDefaults(e);var o=a?e._fullData:e.data,s=a?e._fullLayout:e.layout,c=(e._transitionData||{})._frames;function u(e,t){if("function"==typeof e)return t?"_function_":null;if(f.isPlainObject(e)){var n,a={};return Object.keys(e).sort().forEach((function(i){if(-1===["_","["].indexOf(i.charAt(0)))if("function"!=typeof e[i]){if("keepdata"===r){if("src"===i.substr(i.length-3))return}else if("keepstream"===r){if("string"==typeof(n=e[i+"src"])&&n.indexOf(":")>0&&!f.isPlainObject(e.stream))return}else if("keepall"!==r&&"string"==typeof(n=e[i+"src"])&&n.indexOf(":")>0)return;a[i]=u(e[i],t)}else t&&(a[i]="_function")})),a}var i=Array.isArray(e),o=f.isTypedArray(e);if((i||o)&&e.dtype&&e.shape){var s=e.bdata;return u({dtype:e.dtype,shape:e.shape,bdata:f.isArrayBuffer(s)?l.encode(s):s},t)}return i?e.map((function(e){return u(e,t)})):o?f.simpleMap(e,f.identity):f.isJSDate(e)?f.ms2DateTimeLocal(+e):e}var d={data:(o||[]).map((function(e){var r=u(e);return t&&delete r.fit,r}))};if(!t&&(d.layout=u(s),a)){var h=s._size;d.layout.computed={margin:{b:h.b,l:h.l,r:h.r,t:h.t}}}return c&&(d.frames=u(c)),i&&(d.config=u(e._context,!0)),"object"===n?d:JSON.stringify(d)},w.modifyFrames=function(e,t){var r,n,a,i=e._transitionData._frames,o=e._transitionData._frameHash;for(r=0;r=0;i--)if(s[i].enabled){r._indexToPoints=s[i]._indexToPoints;break}n&&n.calc&&(o=n.calc(e,r))}Array.isArray(o)&&o[0]||(o=[{x:h,y:h}]),o[0].t||(o[0].t={}),o[0].trace=r,d[t]=o}}for(R(o,l,u),a=0;a0){for(var n=[],a=0;a-1&&(f[h[r]].title={text:""});for(r=0;r")?"":t.html(e).text()}));return t.remove(),r}(w)).replace(/&(?!\w+;|\#[0-9]+;| \#x[0-9A-F]+;)/g,"&")).replace(u,"'"),a.isIE()&&(w=(w=(w=w.replace(/"/gi,"'")).replace(/(\('#)([^']*)('\))/gi,'("#$2")')).replace(/(\\')/gi,'"')),w}},5374:function(e,t,r){"use strict";var n=r(4809);e.exports=function(e,t){for(var r=0;rf+c||!n(u))}for(var h=0;h=0)return e}else if("string"==typeof e&&"%"===(e=e.trim()).slice(-1)&&n(e.slice(0,-1))&&(e=+e.slice(0,-1))>=0)return e+"%"}function p(e,t,r,n,i,o){var l=!(!1===(o=o||{}).moduleHasSelected),s=!(!1===o.moduleHasUnselected),c=!(!1===o.moduleHasConstrain),u=!(!1===o.moduleHasCliponaxis),f=!(!1===o.moduleHasTextangle),h=!(!1===o.moduleHasInsideanchor),p=!!o.hasPathbar,v=Array.isArray(i)||"auto"===i,g=v||"inside"===i,m=v||"outside"===i;if(g||m){var y=d(n,"textfont",r.font),x=a.extendFlat({},y),b=!(e.textfont&&e.textfont.color);if(b&&delete x.color,d(n,"insidetextfont",x),p){var _=a.extendFlat({},y);b&&delete _.color,d(n,"pathbar.textfont",_)}m&&d(n,"outsidetextfont",y),l&&n("selected.textfont.color"),s&&n("unselected.textfont.color"),c&&n("constraintext"),u&&n("cliponaxis"),f&&n("textangle"),n("texttemplate")}g&&h&&n("insidetextanchor")}e.exports={supplyDefaults:function(e,t,r,n){function u(r,n){return a.coerce(e,t,f,r,n)}if(l(e,t,n,u)){s(e,t,n,u),u("xhoverformat"),u("yhoverformat"),u("zorder"),u("orientation",t.x&&!t.y?"h":"v"),u("base"),u("offset"),u("width"),u("text"),u("hovertext"),u("hovertemplate");var d=u("textposition");p(e,0,n,u,d,{moduleHasSelected:!0,moduleHasUnselected:!0,moduleHasConstrain:!0,moduleHasCliponaxis:!0,moduleHasTextangle:!0,moduleHasInsideanchor:!0}),c(e,t,u,r,n);var h=(t.marker.line||{}).color,v=o.getComponentMethod("errorbars","supplyDefaults");v(e,t,h||i.defaultLine,{axis:"y"}),v(e,t,h||i.defaultLine,{axis:"x",inherit:"y"}),a.coerceSelectionMarkerOpacity(t,u)}else t.visible=!1},crossTraceDefaults:function(e,t){var r,n;function i(e,t){return a.coerce(n._input,n,f,e,t)}for(var o=0;oi))return t}return void 0!==r?r:e.dflt},t.coerceColor=function(e,t,r){return a(t).isValid()?t:void 0!==r?r:e.dflt},t.coerceEnumerated=function(e,t,r){return e.coerceNumber&&(t=+t),-1!==e.values.indexOf(t)?t:void 0!==r?r:e.dflt},t.getValue=function(e,t){var r;return i(e)?t0?t+=r:u<0&&(t-=r)}return t}function I(e){var t=u,r=e.b,a=z(e);return n.inbox(r-t,a-t,_+(a-t)/(a-r)-1)}var E=e[f+"a"],R=e[d+"a"];v=Math.abs(E.r2c(E.range[1])-E.r2c(E.range[0]));var N=n.getDistanceFunction(a,h,p,(function(e){return(h(e)+p(e))/2}));if(n.getClosest(g,N,e),!1!==e.index&&g[e.index].p!==c){M||(C=function(e){return Math.min(k(e),e.p-y.bargroupwidth/2)},O=function(e){return Math.max(A(e),e.p+y.bargroupwidth/2)});var F=g[e.index],H=m.base?F.b+F.s:F.s;e[d+"0"]=e[d+"1"]=R.c2p(F[d],!0),e[d+"LabelVal"]=H;var B=y.extents[y.extents.round(F.p)];e[f+"0"]=E.c2p(x?C(F):B[0],!0),e[f+"1"]=E.c2p(x?O(F):B[1],!0);var j=void 0!==F.orig_p;return e[f+"LabelVal"]=j?F.orig_p:F.p,e.labelLabel=s(E,e[f+"LabelVal"],m[f+"hoverformat"]),e.valueLabel=s(R,e[d+"LabelVal"],m[d+"hoverformat"]),e.baseLabel=s(R,F.b,m[d+"hoverformat"]),e.spikeDistance=(function(e){var t=u,r=e.b,a=z(e);return n.inbox(r-t,a-t,w+(a-t)/(a-r)-1)}(F)+function(e){return D(k(e),A(e),w)}(F))/2,e[f+"Spike"]=E.c2p(F.p,!0),o(F,m,e),e.hovertemplate=m.hovertemplate,e}}function f(e,t){var r=t.mcc||e.marker.color,n=t.mlcc||e.marker.line.color,a=l(e,t);return i.opacity(r)?r:i.opacity(n)&&a?n:void 0}e.exports={hoverPoints:function(e,t,r,n,i){var o=u(e,t,r,n,i);if(o){var l=o.cd,s=l[0].trace,c=l[o.index];return o.color=f(s,c),a.getComponentMethod("errorbars","hoverInfo")(c,s,o),[o]}},hoverOnBars:u,getTraceColor:f}},8218:function(e,t,r){"use strict";e.exports={attributes:r(1481),layoutAttributes:r(5412),supplyDefaults:r(7550).supplyDefaults,crossTraceDefaults:r(7550).crossTraceDefaults,supplyLayoutDefaults:r(8931),calc:r(7565),crossTraceCalc:r(4782).crossTraceCalc,colorbar:r(1146),arraysToCalcdata:r(5374),plot:r(2995).plot,style:r(6851).style,styleOnSelect:r(6851).styleOnSelect,hoverPoints:r(1664).hoverPoints,eventData:r(9541),selectPoints:r(8384),moduleType:"trace",name:"bar",basePlotModule:r(84),categories:["bar-like","cartesian","svg","bar","oriented","errorBarsOK","showLegend","zoomScale"],animatable:!0,meta:{}}},5412:function(e){"use strict";e.exports={barmode:{valType:"enumerated",values:["stack","group","overlay","relative"],dflt:"group",editType:"calc"},barnorm:{valType:"enumerated",values:["","fraction","percent"],dflt:"",editType:"calc"},bargap:{valType:"number",min:0,max:1,editType:"calc"},bargroupgap:{valType:"number",min:0,max:1,dflt:0,editType:"calc"},barcornerradius:{valType:"any",editType:"calc"}}},8931:function(e,t,r){"use strict";var n=r(3626),a=r(9714),i=r(4809),o=r(5412),l=r(7550).validateCornerradius;e.exports=function(e,t,r){function s(r,n){return i.coerce(e,t,o,r,n)}for(var c=!1,u=!1,f=!1,d={},h=s("barmode"),p=0;p0)-(e<0)}function k(e,t){return e0}function S(e,t,r,n,a){return!(e<0||t<0)&&(r<=e&&n<=t||r<=t&&n<=e||(a?e>=r*(t/n):t>=n*(e/r)))}function C(e){return"auto"===e?0:e}function O(e,t){var r=Math.PI/180*t,n=Math.abs(Math.sin(r)),a=Math.abs(Math.cos(r));return{x:e.width*a+e.height*n,y:e.width*n+e.height*a}}function D(e,t,r,n,a,i){var o=!!i.isHorizontal,l=!!i.constrained,s=i.angle||0,c=i.anchor,u="end"===c,f="start"===c,d=((i.leftToRight||0)+1)/2,h=1-d,p=i.hasB,v=i.r,g=i.overhead,m=a.width,y=a.height,x=Math.abs(t-e),b=Math.abs(n-r),w=x>2*_&&b>2*_?_:0;x-=2*w,b-=2*w;var T=C(s);"auto"!==s||m<=x&&y<=b||!(m>x||y>b)||(m>b||y>x)&&m_){var S=function(e,t,r,n,a,i,o,l,s){var c,u,f,d,h=Math.max(0,Math.abs(t-e)-2*_),p=Math.max(0,Math.abs(n-r)-2*_),v=i-_,g=o?v-Math.sqrt(v*v-(v-o)*(v-o)):v,m=s?2*v:l?v-o:2*g,y=s?2*v:l?2*g:v-o;return a.y/a.x>=p/(h-m)?d=p/a.y:a.y/a.x<=(p-y)/h?d=h/a.x:!s&&l?(c=a.x*a.x+a.y*a.y/4,f=(h-v)*(h-v)+(p/2-v)*(p/2-v)-v*v,d=(-(u=-2*a.x*(h-v)-a.y*(p/2-v))+Math.sqrt(u*u-4*c*f))/(2*c)):s?(c=(a.x*a.x+a.y*a.y)/4,f=(h/2-v)*(h/2-v)+(p/2-v)*(p/2-v)-v*v,d=(-(u=-a.x*(h/2-v)-a.y*(p/2-v))+Math.sqrt(u*u-4*c*f))/(2*c)):(c=a.x*a.x/4+a.y*a.y,f=(h/2-v)*(h/2-v)+(p-v)*(p-v)-v*v,d=(-(u=-a.x*(h/2-v)-2*a.y*(p-v))+Math.sqrt(u*u-4*c*f))/(2*c)),{scale:d=Math.min(1,d),pad:l?Math.max(0,v-Math.sqrt(Math.max(0,v*v-(v-(p-a.y*d)/2)*(v-(p-a.y*d)/2)))-o):Math.max(0,v-Math.sqrt(Math.max(0,v*v-(v-(h-a.x*d)/2)*(v-(h-a.x*d)/2)))-o)}}(e,t,r,n,L,v,g,o,p);M=S.scale,A=S.pad}else M=1,l&&(M=Math.min(1,x/L.x,b/L.y)),A=0;var D=a.left*h+a.right*d,P=(a.top+a.bottom)/2,z=(e+_)*h+(t-_)*d,I=(r+n)/2,E=0,R=0;if(f||u){var N=(o?L.x:L.y)/2;v&&(u||p)&&(w+=A);var F=o?k(e,t):k(r,n);o?f?(z=e+F*w,E=-F*N):(z=t-F*w,E=F*N):f?(I=r+F*w,R=-F*N):(I=n-F*w,R=F*N)}return{textX:D,textY:P,targetX:z,targetY:I,anchorX:E,anchorY:R,scale:M,rotate:T}}e.exports={plot:function(e,t,r,f,g,m){var w=t.xaxis,P=t.yaxis,z=e._fullLayout,I=e._context.staticPlot;g||(g={mode:z.barmode,norm:z.barmode,gap:z.bargap,groupgap:z.bargroupgap},h("bar",z));var E=i.makeTraceGroups(f,r,"trace bars").each((function(r){var c=n.select(this),f=r[0].trace,h=r[0].t,E="waterfall"===f.type,R="funnel"===f.type,N="histogram"===f.type,F="bar"===f.type,H=F||R,B=0;E&&f.connector.visible&&"between"===f.connector.mode&&(B=f.connector.line.width/2);var j="h"===f.orientation,Y=L(g),V=i.ensureSingle(c,"g","points"),U=T(f),q=V.selectAll("g.point").data(i.identity,U);q.enter().append("g").classed("point",!0),q.exit().remove(),q.each((function(c,T){var L,E,R=n.select(this),V=function(e,t,r,n){var a=[],i=[],o=n?t:r,l=n?r:t;return a[0]=o.c2p(e.s0,!0),i[0]=l.c2p(e.p0,!0),a[1]=o.c2p(e.s1,!0),i[1]=l.c2p(e.p1,!0),n?[a,i]:[i,a]}(c,w,P,j),U=V[0][0],q=V[0][1],G=V[1][0],Z=V[1][1],W=0==(j?q-U:Z-G);if(W&&H&&v.getLineWidth(f,c)&&(W=!1),W||(W=!(a(U)&&a(q)&&a(G)&&a(Z))),c.isBlank=W,W&&(j?q=U:Z=G),B&&!W&&(j?(U-=k(U,q)*B,q+=k(U,q)*B):(G-=k(G,Z)*B,Z+=k(G,Z)*B)),"waterfall"===f.type){if(!W){var J=f[c.dir].marker;L=J.line.width,E=J.color}}else L=v.getLineWidth(f,c),E=c.mc||f.marker.color;function X(e){var t=n.round(L/2%1,2);return 0===g.gap&&0===g.groupgap?n.round(Math.round(e)-t,2):e}var K=l.opacity(E)<1||L>.01?X:function(e,t,r){return r&&e===t?e:Math.abs(e-t)>=2?X(e):e>t?Math.ceil(e):Math.floor(e)};e._context.staticPlot||(U=K(U,q,j),q=K(q,U,j),G=K(G,Z,!j),Z=K(Z,G,!j));var Q,$=j?w.c2p:P.c2p;Q=c.s0>0?c._sMax:c.s0<0?c._sMin:c.s1>0?c._sMax:c._sMin;var ee,te,re=F||N?function(e,t){if(!e)return 0;var r,n=j?Math.abs(Z-G):Math.abs(q-U),a=j?Math.abs(q-U):Math.abs(Z-G),i=K(Math.abs($(Q,!0)-$(0,!0))),o=c.hasB?Math.min(n/2,a/2):Math.min(n/2,i);return r="%"===t?n*(Math.min(50,e)/100):e,K(Math.max(Math.min(r,o),0))}(h.cornerradiusvalue,h.cornerradiusform):0,ne="M"+U+","+G+"V"+Z+"H"+q+"V"+G+"Z",ae=0;if(re&&c.s){var ie=0===M(c.s0)||M(c.s)===M(c.s0)?c.s1:c.s0;if((ae=K(c.hasB?0:Math.abs($(Q,!0)-$(ie,!0))))0?Math.sqrt(ae*(2*re-ae)):0,fe=oe>0?Math.max:Math.min;ee="M"+U+","+G+"V"+(Z-ce*le)+"H"+fe(q-(re-ae)*oe,U)+"A "+re+","+re+" 0 0 "+se+" "+q+","+(Z-re*le-ue)+"V"+(G+re*le+ue)+"A "+re+","+re+" 0 0 "+se+" "+fe(q-(re-ae)*oe,U)+","+(G+ce*le)+"Z"}else if(c.hasB)ee="M"+(U+re*oe)+","+G+"A "+re+","+re+" 0 0 "+se+" "+U+","+(G+re*le)+"V"+(Z-re*le)+"A "+re+","+re+" 0 0 "+se+" "+(U+re*oe)+","+Z+"H"+(q-re*oe)+"A "+re+","+re+" 0 0 "+se+" "+q+","+(Z-re*le)+"V"+(G+re*le)+"A "+re+","+re+" 0 0 "+se+" "+(q-re*oe)+","+G+"Z";else{var de=(te=Math.abs(Z-G)+ae)0?Math.sqrt(ae*(2*re-ae)):0,pe=le>0?Math.max:Math.min;ee="M"+(U+de*oe)+","+G+"V"+pe(Z-(re-ae)*le,G)+"A "+re+","+re+" 0 0 "+se+" "+(U+re*oe-he)+","+Z+"H"+(q-re*oe+he)+"A "+re+","+re+" 0 0 "+se+" "+(q-de*oe)+","+pe(Z-(re-ae)*le,G)+"V"+G+"Z"}}else ee=ne}else ee=ne;var ve=A(i.ensureSingle(R,"path"),z,g,m);if(ve.style("vector-effect",I?"none":"non-scaling-stroke").attr("d",isNaN((q-U)*(Z-G))||W&&e._context.staticPlot?"M0,0Z":ee).call(s.setClipUrl,t.layerClipId,e),!z.uniformtext.mode&&Y){var ge=s.makePointStyleFns(f);s.singlePointStyle(c,ve,f,ge,e)}!function(e,t,r,n,a,l,c,f,h,g,m,w,T){var M,L=t.xaxis,P=t.yaxis,z=e._fullLayout;function I(t,r,n){return i.ensureSingle(t,"text").text(r).attr({class:"bartext bartext-"+M,"text-anchor":"middle","data-notex":1}).call(s.font,n).call(o.convertToTspans,e)}var E=n[0].trace,R="h"===E.orientation,N=function(e,t,r,n,a){var o,l=t[0].trace;return o=l.texttemplate?function(e,t,r,n,a){var o=t[0].trace,l=i.castOption(o,r,"texttemplate");if(!l)return"";var s,c,f,d,h="histogram"===o.type,p="waterfall"===o.type,v="funnel"===o.type,g="h"===o.orientation;function m(e){return u(d,d.c2l(e),!0).text}g?(s="y",c=a,f="x",d=n):(s="x",c=n,f="y",d=a);var y,x=t[r],_={};_.label=x.p,_.labelLabel=_[s+"Label"]=(y=x.p,u(c,c.c2l(y),!0).text);var w=i.castOption(o,x.i,"text");(0===w||w)&&(_.text=w),_.value=x.s,_.valueLabel=_[f+"Label"]=m(x.s);var T={};b(T,o,x.i),(h||void 0===T.x)&&(T.x=g?_.value:_.label),(h||void 0===T.y)&&(T.y=g?_.label:_.value),(h||void 0===T.xLabel)&&(T.xLabel=g?_.valueLabel:_.labelLabel),(h||void 0===T.yLabel)&&(T.yLabel=g?_.labelLabel:_.valueLabel),p&&(_.delta=+x.rawS||x.s,_.deltaLabel=m(_.delta),_.final=x.v,_.finalLabel=m(_.final),_.initial=_.final-_.delta,_.initialLabel=m(_.initial)),v&&(_.value=x.s,_.valueLabel=m(_.value),_.percentInitial=x.begR,_.percentInitialLabel=i.formatPercent(x.begR),_.percentPrevious=x.difR,_.percentPreviousLabel=i.formatPercent(x.difR),_.percentTotal=x.sumR,_.percenTotalLabel=i.formatPercent(x.sumR));var M=i.castOption(o,x.i,"customdata");return M&&(_.customdata=M),i.texttemplateString(l,_,e._d3locale,T,_,o._meta||{})}(e,t,r,n,a):l.textinfo?function(e,t,r,n){var a=e[0].trace,o="h"===a.orientation,l="waterfall"===a.type,s="funnel"===a.type;function c(e){return u(o?r:n,+e,!0).text}var f,d,h=a.textinfo,p=e[t],v=h.split("+"),g=[],m=function(e){return-1!==v.indexOf(e)};if(m("label")&&g.push((d=e[t].p,u(o?n:r,d,!0).text)),m("text")&&(0===(f=i.castOption(a,p.i,"text"))||f)&&g.push(f),l){var y=+p.rawS||p.s,x=p.v,b=x-y;m("initial")&&g.push(c(b)),m("delta")&&g.push(c(y)),m("final")&&g.push(c(x))}if(s){m("value")&&g.push(c(p.s));var _=0;m("percent initial")&&_++,m("percent previous")&&_++,m("percent total")&&_++;var w=_>1;m("percent initial")&&(f=i.formatPercent(p.begR),w&&(f+=" of initial"),g.push(f)),m("percent previous")&&(f=i.formatPercent(p.difR),w&&(f+=" of previous"),g.push(f)),m("percent total")&&(f=i.formatPercent(p.sumR),w&&(f+=" of total"),g.push(f))}return g.join("
")}(t,r,n,a):v.getValue(l.text,r),v.coerceString(y,o)}(z,n,a,L,P);M=function(e,t){var r=v.getValue(e.textposition,t);return v.coerceEnumerated(x,r)}(E,a);var F="stack"===w.mode||"relative"===w.mode,H=n[a],B=!F||H._outmost,j=H.hasB,Y=g&&g-m>_;if(N&&"none"!==M&&(!H.isBlank&&l!==c&&f!==h||"auto"!==M&&"inside"!==M)){var V=z.font,U=p.getBarColor(n[a],E),q=p.getInsideTextFont(E,a,V,U),G=p.getOutsideTextFont(E,a,V),Z=E.insidetextanchor||"end",W=r.datum();R?"log"===L.type&&W.s0<=0&&(l=L.range[0]0&&Q>0;ae=Y?j?S(re-2*g,ne,K,Q,R)||S(re,ne-2*g,K,Q,R):R?S(re-(g-m),ne,K,Q,R)||S(re,ne-2*(g-m),K,Q,R):S(re,ne-(g-m),K,Q,R)||S(re-2*(g-m),ne,K,Q,R):S(re,ne,K,Q,R),ie&&ae?M="inside":(M="outside",J.remove(),J=null)}else M="inside";if(!J){var oe=(J=I(r,N,$=i.ensureUniformFontSize(e,"outside"===M?G:q))).attr("transform");if(J.attr("transform",""),K=(X=s.bBox(J.node())).width,Q=X.height,J.attr("transform",oe),K<=0||Q<=0)return void J.remove()}var le,se=E.textangle;le="outside"===M?function(e,t,r,n,a,i){var o,l=!!i.isHorizontal,s=!!i.constrained,c=i.angle||0,u=a.width,f=a.height,d=Math.abs(t-e),h=Math.abs(n-r);o=l?h>2*_?_:0:d>2*_?_:0;var p=1;s&&(p=l?Math.min(1,h/f):Math.min(1,d/u));var v=C(c),g=O(a,v),m=(l?g.x:g.y)/2,y=(a.left+a.right)/2,x=(a.top+a.bottom)/2,b=(e+t)/2,w=(r+n)/2,T=0,M=0,A=l?k(t,e):k(r,n);return l?(b=t-A*o,T=A*m):(w=n+A*o,M=-A*m),{textX:y,textY:x,targetX:b,targetY:w,anchorX:T,anchorY:M,scale:p,rotate:v}}(l,c,f,h,X,{isHorizontal:R,constrained:"both"===E.constraintext||"outside"===E.constraintext,angle:se}):D(l,c,f,h,X,{isHorizontal:R,constrained:"both"===E.constraintext||"inside"===E.constraintext,angle:se,anchor:Z,hasB:j,r:g,overhead:m}),le.fontSize=$.size,d("histogram"===E.type?"bar":E.type,le,z),H.transform=le;var ce=A(J,z,w,T);i.setTransormAndDisplay(ce,le)}else r.select("text").remove()}(e,t,R,r,T,U,q,G,Z,re,ae,g,m),t.layerClipId&&s.hideOutsideRangePoint(c,R.select("text"),w,P,f.xcalendar,f.ycalendar)}));var G=!1===f.cliponaxis;s.setClipUrl(c,G?null:t.layerClipId,e)}));c.getComponentMethod("errorbars","plot")(e,E,t,g)},toMoveInsideBar:D}},8384:function(e){"use strict";function t(e,t,r,n,a){var i=t.c2p(n?e.s0:e.p0,!0),o=t.c2p(n?e.s1:e.p1,!0),l=r.c2p(n?e.p0:e.s0,!0),s=r.c2p(n?e.p1:e.s1,!0);return a?[(i+o)/2,(l+s)/2]:n?[o,(l+s)/2]:[(i+o)/2,s]}e.exports=function(e,r){var n,a=e.cd,i=e.xaxis,o=e.yaxis,l=a[0].trace,s="funnel"===l.type,c="h"===l.orientation,u=[];if(!1===r)for(n=0;n1||0===a.bargap&&0===a.bargroupgap&&!e[0].trace.marker.line.width)&&n.select(this).attr("shape-rendering","crispEdges")})),t.selectAll("g.points").each((function(t){p(n.select(this),t[0].trace,e)})),l.getComponentMethod("errorbars","style")(t)},styleTextPoints:v,styleOnSelect:function(e,t,r){var a=t[0].trace;a.selectedpoints?function(e,t,r){i.selectedPointStyle(e.selectAll("path"),t),function(e,t,r){e.each((function(e){var a,l=n.select(this);if(e.selected){a=o.ensureUniformFontSize(r,g(l,e,t,r));var s=t.selected.textfont&&t.selected.textfont.color;s&&(a.color=s),i.font(l,a)}else i.selectedTextStyle(l,t)}))}(e.selectAll("text"),t,r)}(r,a,e):(p(r,a,e),l.getComponentMethod("errorbars","style")(r))},getInsideTextFont:y,getOutsideTextFont:x,getBarColor:_,resizeText:s}},9760:function(e,t,r){"use strict";var n=r(8766),a=r(5477).hasColorscale,i=r(9356),o=r(4809).coercePattern;e.exports=function(e,t,r,l,s){var c=r("marker.color",l),u=a(e,"marker");u&&i(e,t,s,r,{prefix:"marker.",cLetter:"c"}),r("marker.line.color",n.defaultLine),a(e,"marker.line")&&i(e,t,s,r,{prefix:"marker.line.",cLetter:"c"}),r("marker.line.width"),r("marker.opacity"),o(r,"marker.pattern",c,u),r("selected.marker.color"),r("unselected.marker.color")}},4102:function(e,t,r){"use strict";var n=r(5568),a=r(4809);function i(e){return"_"+e+"Text_minsize"}e.exports={recordMinTextSize:function(e,t,r){if(r.uniformtext.mode){var n=i(e),a=r.uniformtext.minsize,o=t.scale*t.fontSize;t.hide=o=0})),("funnelarea"===t.type?m:t.sort)&&i.sort((function(e,t){return t.v-e.v})),i[0]&&(i[0].vTotal=g),i},crossTraceCalc:function(e,t){var r=(t||{}).type;r||(r="pie");var n=e._fullLayout,a=e.calcdata,i=n[r+"colorway"],l=n["_"+r+"colormap"];n["extend"+r+"colors"]&&(i=s(i,o));for(var c=0,u=0;u0){l=!0;break}}l||(o=0)}return{hasLabels:r,hasValues:i,len:o}}function u(e,t,r,n,a){n("marker.line.width")&&n("marker.line.color",a?void 0:r.paper_bgcolor);var i=n("marker.colors");s(n,"marker.pattern",i),e.marker&&!t.marker.pattern.fgcolor&&(t.marker.pattern.fgcolor=e.marker.colors),t.marker.pattern.bgcolor||(t.marker.pattern.bgcolor=r.paper_bgcolor)}e.exports={handleLabelsAndValues:c,handleMarkerDefaults:u,supplyDefaults:function(e,t,r,n){function s(r,n){return a.coerce(e,t,i,r,n)}var f=c(s("labels"),s("values")),d=f.len;if(t._hasLabels=f.hasLabels,t._hasValues=f.hasValues,!t._hasLabels&&t._hasValues&&(s("label0"),s("dlabel")),d){t._length=d,u(e,t,n,s,!0),s("scalegroup");var h,p=s("text"),v=s("texttemplate");if(v||(h=s("textinfo",a.isArrayOrTypedArray(p)?"text+percent":"percent")),s("hovertext"),s("hovertemplate"),v||h&&"none"!==h){var g=s("textposition");l(e,t,n,s,g,{moduleHasSelected:!1,moduleHasUnselected:!1,moduleHasConstrain:!1,moduleHasCliponaxis:!1,moduleHasTextangle:!1,moduleHasInsideanchor:!1}),(Array.isArray(g)||"auto"===g||"outside"===g)&&s("automargin"),("inside"===g||"auto"===g||Array.isArray(g))&&s("insidetextorientation")}else"none"===h&&s("textposition","none");o(t,n,s);var m=s("hole");if(s("title.text")){var y=s("title.position",m?"middle center":"top center");m||"middle center"!==y||(t.title.position="top center"),a.coerceFont(s,"title.font",n.font)}s("sort"),s("direction"),s("rotation"),s("pull")}else t.visible=!1}}},568:function(e,t,r){"use strict";var n=r(6040).appendArrayMultiPointValues;e.exports=function(e,t){var r={curveNumber:t.index,pointNumbers:e.pts,data:t._input,fullData:t,label:e.label,color:e.color,value:e.v,percent:e.percent,text:e.text,bbox:e.bbox,v:e.v};return 1===e.pts.length&&(r.pointNumber=r.i=e.pts[0]),n(r,t,e.pts),"funnelarea"===t.type&&(delete r.v,delete r.i),r}},5067:function(e,t,r){"use strict";var n=r(2203),a=r(8766);e.exports=function(e,t,r,i){var o=r.marker.pattern;o&&o.shape?n.pointStyle(e,r,i,t):a.fill(e,t.color)}},7252:function(e,t,r){"use strict";var n=r(4809);function a(e){return-1!==e.indexOf("e")?e.replace(/[.]?0+e/,"e"):-1!==e.indexOf(".")?e.replace(/[.]?0+$/,""):e}t.formatPiePercent=function(e,t){var r=a((100*e).toPrecision(3));return n.numSeparate(r,t)+"%"},t.formatPieValue=function(e,t){var r=a(e.toPrecision(10));return n.numSeparate(r,t)},t.getFirstFilled=function(e,t){if(n.isArrayOrTypedArray(e))for(var r=0;r"),name:f.hovertemplate||-1!==d.indexOf("name")?f.name:void 0,idealAlign:e.pxmid[0]<0?"left":"right",color:g.castOption(_.bgcolor,e.pts)||e.color,borderColor:g.castOption(_.bordercolor,e.pts),fontFamily:g.castOption(w.family,e.pts),fontSize:g.castOption(w.size,e.pts),fontColor:g.castOption(w.color,e.pts),nameLength:g.castOption(_.namelength,e.pts),textAlign:g.castOption(_.align,e.pts),hovertemplate:g.castOption(f.hovertemplate,e.pts),hovertemplateLabels:e,eventData:[m(e,f)]},{container:r._hoverlayer.node(),outerContainer:r._paper.node(),gd:t,inOut_bbox:T}),e.bbox=T[0],c._hasHoverLabel=!0}c._hasHoverEvent=!0,t.emit("plotly_hover",{points:[m(e,f)],event:n.event})}})),e.on("mouseout",(function(e){var r=t._fullLayout,a=t._fullData[c.index],o=n.select(this).datum();c._hasHoverEvent&&(e.originalEvent=n.event,t.emit("plotly_unhover",{points:[m(o,a)],event:n.event}),c._hasHoverEvent=!1),c._hasHoverLabel&&(i.loneUnhover(r._hoverlayer.node()),c._hasHoverLabel=!1)})),e.on("click",(function(e){var r=t._fullLayout,a=t._fullData[c.index];t._dragging||!1===r.hovermode||(t._hoverdata=[m(e,a)],i.click(t,n.event))}))}function b(e,t,r){var n=g.castOption(e.insidetextfont.color,t.pts);!n&&e._input.textfont&&(n=g.castOption(e._input.textfont.color,t.pts));var a=g.castOption(e.insidetextfont.family,t.pts)||g.castOption(e.textfont.family,t.pts)||r.family,i=g.castOption(e.insidetextfont.size,t.pts)||g.castOption(e.textfont.size,t.pts)||r.size,l=g.castOption(e.insidetextfont.weight,t.pts)||g.castOption(e.textfont.weight,t.pts)||r.weight,s=g.castOption(e.insidetextfont.style,t.pts)||g.castOption(e.textfont.style,t.pts)||r.style,c=g.castOption(e.insidetextfont.variant,t.pts)||g.castOption(e.textfont.variant,t.pts)||r.variant,u=g.castOption(e.insidetextfont.textcase,t.pts)||g.castOption(e.textfont.textcase,t.pts)||r.textcase,f=g.castOption(e.insidetextfont.lineposition,t.pts)||g.castOption(e.textfont.lineposition,t.pts)||r.lineposition,d=g.castOption(e.insidetextfont.shadow,t.pts)||g.castOption(e.textfont.shadow,t.pts)||r.shadow;return{color:n||o.contrast(t.color),family:a,size:i,weight:l,style:s,variant:c,textcase:u,lineposition:f,shadow:d}}function _(e,t){for(var r,n,a=0;at&&t>n||r=-4;g-=2)m(Math.PI*g,"tan");for(g=4;g>=-4;g-=2)m(Math.PI*(g+1),"tan")}if(f||h){for(g=4;g>=-4;g-=2)m(Math.PI*(g+1.5),"rad");for(g=4;g>=-4;g-=2)m(Math.PI*(g+.5),"rad")}}if(l||p||f){var y=Math.sqrt(e.width*e.width+e.height*e.height);if((i={scale:a*n*2/y,rCenter:1-a,rotate:0}).textPosAngle=(t.startangle+t.stopangle)/2,i.scale>=1)return i;v.push(i)}(p||h)&&((i=T(e,n,o,s,c)).textPosAngle=(t.startangle+t.stopangle)/2,v.push(i)),(p||d)&&((i=M(e,n,o,s,c)).textPosAngle=(t.startangle+t.stopangle)/2,v.push(i));for(var x=0,b=0,_=0;_=1)break}return v[x]}function T(e,t,r,n,a){t=Math.max(0,t-2*v);var i=e.width/e.height,o=L(i,n,t,r);return{scale:2*o/e.height,rCenter:k(i,o/t),rotate:A(a)}}function M(e,t,r,n,a){t=Math.max(0,t-2*v);var i=e.height/e.width,o=L(i,n,t,r);return{scale:2*o/e.width,rCenter:k(i,o/t),rotate:A(a+Math.PI/2)}}function k(e,t){return Math.cos(t)-e*t}function A(e){return(180/Math.PI*e+720)%180-90}function L(e,t,r,n){var a=e+1/(2*Math.tan(t));return r*Math.min(1/(Math.sqrt(a*a+.5)+a),n/(Math.sqrt(e*e+n/2)+e))}function S(e,t){return e.v!==t.vTotal||t.trace.hole?Math.min(1/(1+1/Math.sin(e.halfangle)),e.ring/2):1}function C(e,t){var r=t.pxmid[0],n=t.pxmid[1],a=e.width/2,i=e.height/2;return r<0&&(a*=-1),n<0&&(i*=-1),{scale:1,rCenter:1,rotate:0,x:a+Math.abs(i)*(a>0?1:-1)/2,y:i/(1+r*r/(n*n)),outside:!0}}function O(e,t){var r,n,a,i=e.trace,o={x:e.cx,y:e.cy},l={tx:0,ty:0};l.ty+=i.title.font.size,a=P(i),-1!==i.title.position.indexOf("top")?(o.y-=(1+a)*e.r,l.ty-=e.titleBox.height):-1!==i.title.position.indexOf("bottom")&&(o.y+=(1+a)*e.r);var s,c=e.r/(void 0===(s=e.trace.aspectratio)?1:s),u=t.w*(i.domain.x[1]-i.domain.x[0])/2;return-1!==i.title.position.indexOf("left")?(u+=c,o.x-=(1+a)*c,l.tx+=e.titleBox.width/2):-1!==i.title.position.indexOf("center")?u*=2:-1!==i.title.position.indexOf("right")&&(u+=c,o.x+=(1+a)*c,l.tx-=e.titleBox.width/2),r=u/e.titleBox.width,n=D(e,t)/e.titleBox.height,{x:o.x,y:o.y,scale:Math.min(r,n),tx:l.tx,ty:l.ty}}function D(e,t){var r=e.trace,n=t.h*(r.domain.y[1]-r.domain.y[0]);return Math.min(e.titleBox.height,n/2)}function P(e){var t,r=e.pull;if(!r)return 0;if(s.isArrayOrTypedArray(r))for(r=0,t=0;tr&&(r=e.pull[t]);return r}function z(e,t){for(var r=[],n=0;n1?u=(c=r.r)/a.aspectratio:c=(u=r.r)*a.aspectratio,s=(c*=(1+a.baseratio)/2)*u}o=Math.min(o,s/r.vTotal)}for(n=0;n")}if(i){var x=s.castOption(a,t.i,"texttemplate");if(x){var b=function(e){return{label:e.label,value:e.v,valueLabel:g.formatPieValue(e.v,n.separators),percent:e.v/r.vTotal,percentLabel:g.formatPiePercent(e.v/r.vTotal,n.separators),color:e.color,text:e.text,customdata:s.castOption(a,e.i,"customdata")}}(t),_=g.getFirstFilled(a.text,t.pts);(y(_)||""===_)&&(b.text=_),t.text=s.texttemplateString(x,b,e._fullLayout._d3locale,b,a._meta||{})}else t.text=""}}function R(e,t){var r=e.rotate*Math.PI/180,n=Math.cos(r),a=Math.sin(r),i=(t.left+t.right)/2,o=(t.top+t.bottom)/2;e.textX=i*n-o*a,e.textY=i*a+o*n,e.noCenter=!0}e.exports={plot:function(e,t){var r=e._context.staticPlot,i=e._fullLayout,d=i._size;p("pie",i),_(t,e),z(t,d);var v=s.makeTraceGroups(i._pielayer,t,"trace").each((function(t){var p=n.select(this),v=t[0],m=v.trace;!function(e){var t,r,n,a=e[0],i=a.r,o=a.trace,l=g.getRotationAngle(o.rotation),s=2*Math.PI/a.vTotal,c="px0",u="px1";if("counterclockwise"===o.direction){for(t=0;ta.vTotal/2?1:0,r.halfangle=Math.PI*Math.min(r.v/a.vTotal,.5),r.ring=1-o.hole,r.rInscribed=S(r,a))}(t),p.attr("stroke-linejoin","round"),p.each((function(){var y=n.select(this).selectAll("g.slice").data(t);y.enter().append("g").classed("slice",!0),y.exit().remove();var _=[[[],[]],[[],[]]],T=!1;y.each((function(a,o){if(a.hidden)n.select(this).selectAll("path,g").remove();else{a.pointNumber=a.i,a.curveNumber=m.index,_[a.pxmid[1]<0?0:1][a.pxmid[0]<0?0:1].push(a);var c=v.cx,u=v.cy,d=n.select(this),p=d.selectAll("path.surface").data([a]);if(p.enter().append("path").classed("surface",!0).style({"pointer-events":r?"none":"all"}),d.call(x,e,t),m.pull){var y=+g.castOption(m.pull,a.pts)||0;y>0&&(c+=y*a.pxmid[0],u+=y*a.pxmid[1])}a.cxFinal=c,a.cyFinal=u;var M=m.hole;if(a.v===v.vTotal){var k="M"+(c+a.px0[0])+","+(u+a.px0[1])+D(a.px0,a.pxmid,!0,1)+D(a.pxmid,a.px0,!0,1)+"Z";M?p.attr("d","M"+(c+M*a.px0[0])+","+(u+M*a.px0[1])+D(a.px0,a.pxmid,!1,M)+D(a.pxmid,a.px0,!1,M)+"Z"+k):p.attr("d",k)}else{var A=D(a.px0,a.px1,!0,1);if(M){var L=1-M;p.attr("d","M"+(c+M*a.px1[0])+","+(u+M*a.px1[1])+D(a.px1,a.px0,!1,M)+"l"+L*a.px0[0]+","+L*a.px0[1]+A+"Z")}else p.attr("d","M"+c+","+u+"l"+a.px0[0]+","+a.px0[1]+A+"Z")}E(e,a,v);var S=g.castOption(m.textposition,a.pts),O=d.selectAll("g.slicetext").data(a.text&&"none"!==S?[0]:[]);O.enter().append("g").classed("slicetext",!0),O.exit().remove(),O.each((function(){var r=s.ensureSingle(n.select(this),"text","",(function(e){e.attr("data-notex",1)})),d=s.ensureUniformFontSize(e,"outside"===S?function(e,t,r){return{color:g.castOption(e.outsidetextfont.color,t.pts)||g.castOption(e.textfont.color,t.pts)||r.color,family:g.castOption(e.outsidetextfont.family,t.pts)||g.castOption(e.textfont.family,t.pts)||r.family,size:g.castOption(e.outsidetextfont.size,t.pts)||g.castOption(e.textfont.size,t.pts)||r.size,weight:g.castOption(e.outsidetextfont.weight,t.pts)||g.castOption(e.textfont.weight,t.pts)||r.weight,style:g.castOption(e.outsidetextfont.style,t.pts)||g.castOption(e.textfont.style,t.pts)||r.style,variant:g.castOption(e.outsidetextfont.variant,t.pts)||g.castOption(e.textfont.variant,t.pts)||r.variant,textcase:g.castOption(e.outsidetextfont.textcase,t.pts)||g.castOption(e.textfont.textcase,t.pts)||r.textcase,lineposition:g.castOption(e.outsidetextfont.lineposition,t.pts)||g.castOption(e.textfont.lineposition,t.pts)||r.lineposition,shadow:g.castOption(e.outsidetextfont.shadow,t.pts)||g.castOption(e.textfont.shadow,t.pts)||r.shadow}}(m,a,i.font):b(m,a,i.font));r.text(a.text).attr({class:"slicetext",transform:"","text-anchor":"middle"}).call(l.font,d).call(f.convertToTspans,e);var p,y=l.bBox(r.node());if("outside"===S)p=C(y,a);else if(p=w(y,a,v),"auto"===S&&p.scale<1){var x=s.ensureUniformFontSize(e,m.outsidetextfont);r.call(l.font,x),p=C(y=l.bBox(r.node()),a)}var _=p.textPosAngle,M=void 0===_?a.pxmid:I(v.r,_);if(p.targetX=c+M[0]*p.rCenter+(p.x||0),p.targetY=u+M[1]*p.rCenter+(p.y||0),R(p,y),p.outside){var k=p.targetY;a.yLabelMin=k-y.height/2,a.yLabelMid=k,a.yLabelMax=k+y.height/2,a.labelExtraX=0,a.labelExtraY=0,T=!0}p.fontSize=d.size,h(m.type,p,i),t[o].transform=p,s.setTransormAndDisplay(r,p)}))}function D(e,t,r,n){var i=n*(t[0]-e[0]),o=n*(t[1]-e[1]);return"a"+n*v.r+","+n*v.r+" 0 "+a.largeArc+(r?" 1 ":" 0 ")+i+","+o}}));var M=n.select(this).selectAll("g.titletext").data(m.title.text?[0]:[]);if(M.enter().append("g").classed("titletext",!0),M.exit().remove(),M.each((function(){var t,r=s.ensureSingle(n.select(this),"text","",(function(e){e.attr("data-notex",1)})),a=m.title.text;m._meta&&(a=s.templateString(a,m._meta)),r.text(a).attr({class:"titletext",transform:"","text-anchor":"middle"}).call(l.font,m.title.font).call(f.convertToTspans,e),t="middle center"===m.title.position?function(e){var t=Math.sqrt(e.titleBox.width*e.titleBox.width+e.titleBox.height*e.titleBox.height);return{x:e.cx,y:e.cy,scale:e.trace.hole*e.r*2/t,tx:0,ty:-e.titleBox.height/2+e.trace.title.font.size}}(v):O(v,d),r.attr("transform",u(t.x,t.y)+c(Math.min(1,t.scale))+u(t.tx,t.ty))})),T&&function(e,t){var r,n,a,i,o,l,c,u,f,d,h,p,v;function m(e,t){return e.pxmid[1]-t.pxmid[1]}function y(e,t){return t.pxmid[1]-e.pxmid[1]}function x(e,r){r||(r={});var a,u,f,h,p=r.labelExtraY+(n?r.yLabelMax:r.yLabelMin),v=n?e.yLabelMin:e.yLabelMax,m=n?e.yLabelMax:e.yLabelMin,y=e.cyFinal+o(e.px0[1],e.px1[1]),x=p-v;if(x*c>0&&(e.labelExtraY=x),s.isArrayOrTypedArray(t.pull))for(u=0;u=(g.castOption(t.pull,f.pts)||0)||((e.pxmid[1]-f.pxmid[1])*c>0?(x=f.cyFinal+o(f.px0[1],f.px1[1])-v-e.labelExtraY)*c>0&&(e.labelExtraY+=x):(m+e.labelExtraY-y)*c>0&&(a=3*l*Math.abs(u-d.indexOf(e)),(h=f.cxFinal+i(f.px0[0],f.px1[0])+a-(e.cxFinal+e.pxmid[0])-e.labelExtraX)*l>0&&(e.labelExtraX+=h)))}for(n=0;n<2;n++)for(a=n?m:y,o=n?Math.max:Math.min,c=n?1:-1,r=0;r<2;r++){for(i=r?Math.max:Math.min,l=r?1:-1,(u=e[n][r]).sort(a),f=e[1-n][r],d=f.concat(u),p=[],h=0;hMath.abs(f)?l+="l"+f*e.pxmid[0]/e.pxmid[1]+","+f+"H"+(i+e.labelExtraX+c):l+="l"+e.labelExtraX+","+u+"v"+(f-u)+"h"+c}else l+="V"+(e.yLabelMid+e.labelExtraY)+"h"+c;s.ensureSingle(r,"path","textline").call(o.stroke,t.outsidetextfont.color).attr({"stroke-width":Math.min(2,t.outsidetextfont.size/8),d:l,fill:"none"})}else r.select("path.textline").remove()}))}(y,m),T&&m.automargin){var k=l.bBox(p.node()),A=m.domain,L=d.w*(A.x[1]-A.x[0]),S=d.h*(A.y[1]-A.y[0]),D=(.5*L-v.r)/d.w,P=(.5*S-v.r)/d.h;a.autoMargin(e,"pie."+m.uid+".automargin",{xl:A.x[0]-D,xr:A.x[1]+D,yb:A.y[0]-P,yt:A.y[1]+P,l:Math.max(v.cx-v.r-k.left,0),r:Math.max(k.right-(v.cx+v.r),0),b:Math.max(k.bottom-(v.cy+v.r),0),t:Math.max(v.cy-v.r-k.top,0),pad:5})}}))}));setTimeout((function(){v.selectAll("tspan").each((function(){var e=n.select(this);e.attr("dy")&&e.attr("dy",e.attr("dy"))}))}),0)},formatSliceLabel:E,transformInsideText:w,determineInsideTextFont:b,positionTitleOutside:O,prerenderTitles:_,layoutAreas:z,attachFxHandlers:x,computeTransform:R}},140:function(e,t,r){"use strict";var n=r(5568),a=r(2891),i=r(4102).resizeText;e.exports=function(e){var t=e._fullLayout._pielayer.selectAll(".trace");i(e,t,"pie"),t.each((function(t){var r=t[0].trace,i=n.select(this);i.style({opacity:r.opacity}),i.selectAll("path.surface").each((function(t){n.select(this).call(a,t,r,e)}))}))}},2891:function(e,t,r){"use strict";var n=r(8766),a=r(7252).castOption,i=r(5067);e.exports=function(e,t,r,o){var l=r.marker.line,s=a(l.color,t.pts)||n.defaultLine,c=a(l.width,t.pts)||0;e.call(i,t,r,o).style("stroke-width",c).call(n.stroke,s)}},9203:function(e,t,r){"use strict";var n=r(4809);e.exports=function(e,t){for(var r=0;rs&&O[m].gap;)m--;for(x=O[m].s,v=O.length-1;v>m;v--)O[v].s=x;for(;sL[f]&&f=0;f--){var d=e[f];if("scatter"===d.type&&d.xaxis===c.xaxis&&d.yaxis===c.yaxis){d.opacity=void 0;break}}}}}},247:function(e,t,r){"use strict";var n=r(4809),a=r(3626),i=r(6640),o=r(2660),l=r(4726),s=r(9867),c=r(9669),u=r(382),f=r(4272),d=r(8168),h=r(1602),p=r(663),v=r(4114),g=r(4809).coercePattern;e.exports=function(e,t,r,m){function y(r,a){return n.coerce(e,t,i,r,a)}var x=s(e,t,m,y);if(x||(t.visible=!1),t.visible){c(e,t,m,y),y("xhoverformat"),y("yhoverformat"),y("zorder");var b=u(e,t,m,y);"group"===m.scattermode&&void 0===t.orientation&&y("orientation","v");var _=!b&&x=Math.min(t,r)&&p<=Math.max(t,r)?0:1/0}var n=Math.max(3,e.mrc||0),a=1-1/n,i=Math.abs(d.c2p(e.x)-p);return i=Math.min(t,r)&&v<=Math.max(t,r)?0:1/0}var n=Math.max(3,e.mrc||0),a=1-1/n,i=Math.abs(h.c2p(e.y)-v);return ir!=(c=a[n][1])>=r&&(o=a[n-1][0],l=a[n][0],c-s&&(i=o+(l-o)*(r-s)/(c-s),f=Math.min(f,i),p=Math.max(p,i)));return{x0:f=Math.max(f,0),x1:p=Math.min(p,d._length),y0:r,y1:r}}(f._polygons);null===P&&(P={x0:g[0],x1:g[0],y0:g[1],y1:g[1]});var z=l.defaultLine;return l.opacity(f.fillcolor)?z=f.fillcolor:l.opacity((f.line||{}).color)&&(z=f.line.color),n.extendFlat(e,{distance:e.maxHoverDistance,x0:P.x0,x1:P.x1,y0:P.y0,y1:P.y1,color:z,hovertemplate:!1}),delete e.index,f.text&&!n.isArrayOrTypedArray(f.text)?e.text=String(f.text):e.text=f.name,[e]}}},9693:function(e,t,r){"use strict";var n=r(4726);e.exports={hasLines:n.hasLines,hasMarkers:n.hasMarkers,hasText:n.hasText,isBubble:n.isBubble,attributes:r(6640),layoutAttributes:r(6667),supplyDefaults:r(247),crossTraceDefaults:r(3044),supplyLayoutDefaults:r(2332),calc:r(6544).calc,crossTraceCalc:r(5603),arraysToCalcdata:r(9203),plot:r(6098),colorbar:r(1146),formatLabels:r(5294),style:r(9408).style,styleOnSelect:r(9408).styleOnSelect,hoverPoints:r(7255),selectPoints:r(2665),animatable:!0,moduleType:"trace",name:"scatter",basePlotModule:r(84),categories:["cartesian","svg","symbols","errorBarsOK","showLegend","scatter-like","zoomScale"],meta:{}}},6667:function(e){"use strict";e.exports={scattermode:{valType:"enumerated",values:["group","overlay"],dflt:"overlay",editType:"calc"},scattergap:{valType:"number",min:0,max:1,editType:"calc"}}},2332:function(e,t,r){"use strict";var n=r(4809),a=r(6667);e.exports=function(e,t){var r,i="group"===t.barmode;"group"===t.scattermode&&("scattergap",r=i?t.bargap:.2,n.coerce(e,t,a,"scattergap",r))}},8168:function(e,t,r){"use strict";var n=r(4809).isArrayOrTypedArray,a=r(5477).hasColorscale,i=r(9356);e.exports=function(e,t,r,o,l,s){s||(s={});var c=(e.marker||{}).color;c&&c._inputArray&&(c=c._inputArray),l("line.color",r),a(e,"line")?i(e,t,o,l,{prefix:"line.",cLetter:"c"}):l("line.color",!n(c)&&c||r),l("line.width"),s.noDash||l("line.dash"),s.backoff&&l("line.backoff")}},5525:function(e,t,r){"use strict";var n=r(2203),a=r(3821),i=a.BADNUM,o=a.LOG_CLIP,l=o+.5,s=o-.5,c=r(4809),u=c.segmentsIntersect,f=c.constrain,d=r(2660);e.exports=function(e,t){var r,a,o,h,p,v,g,m,y,x,b,_,w,T,M,k,A,L,S=t.trace||{},C=t.xaxis,O=t.yaxis,D="log"===C.type,P="log"===O.type,z=C._length,I=O._length,E=t.backoff,R=S.marker,N=t.connectGaps,F=t.baseTolerance,H=t.shape,B="linear"===H,j=S.fill&&"none"!==S.fill,Y=[],V=d.minTolerance,U=e.length,q=new Array(U),G=0;function Z(r){var n=e[r];if(!n)return!1;var a=t.linearized?C.l2p(n.x):C.c2p(n.x),o=t.linearized?O.l2p(n.y):O.c2p(n.y);if(a===i){if(D&&(a=C.c2p(n.x,!0)),a===i)return!1;P&&o===i&&(a*=Math.abs(C._m*I*(C._m>0?l:s)/(O._m*z*(O._m>0?l:s)))),a*=1e3}if(o===i){if(P&&(o=O.c2p(n.y,!0)),o===i)return!1;o*=1e3}return[a,o]}function W(e,t,r,n){var a=r-e,i=n-t,o=.5-e,l=.5-t,s=a*a+i*i,c=a*o+i*l;if(c>0&&coe||e[1]se)return[f(e[0],ie,oe),f(e[1],le,se)]}function fe(e,t){return e[0]===t[0]&&(e[0]===ie||e[0]===oe)||e[1]===t[1]&&(e[1]===le||e[1]===se)||void 0}function de(e,t,r){return function(n,a){var i=ue(n),o=ue(a),l=[];if(i&&o&&fe(i,o))return l;i&&l.push(i),o&&l.push(o);var s=2*c.constrain((n[e]+a[e])/2,t,r)-((i||n)[e]+(o||a)[e]);return s&&((i&&o?s>0==i[e]>o[e]?i:o:i||o)[e]+=s),l}}function he(e){var t=e[0],r=e[1],n=t===q[G-1][0],a=r===q[G-1][1];if(!n||!a)if(G>1){var i=t===q[G-2][0],o=r===q[G-2][1];n&&(t===ie||t===oe)&&i?o?G--:q[G-1]=e:a&&(r===le||r===se)&&o?i?G--:q[G-1]=e:q[G++]=e}else q[G++]=e}function pe(e){q[G-1][0]!==e[0]&&q[G-1][1]!==e[1]&&he([$,ee]),he(e),te=null,$=ee=0}"linear"===H||"spline"===H?ne=function(e,t){for(var r=[],n=0,a=0;a<4;a++){var i=ce[a],o=u(e[0],e[1],t[0],t[1],i[0],i[1],i[2],i[3]);o&&(!n||Math.abs(o.x-r[0][0])>1||Math.abs(o.y-r[0][1])>1)&&(o=[o.x,o.y],n&&X(o,e)oe?oe:0,Q=t[1]se?se:0,K||Q){if(G)if(te){var n=ne(te,t);n.length>1&&(pe(n[0]),q[G++]=n[1])}else re=ne(q[G-1],t)[0],q[G++]=re;else q[G++]=[K||t[0],Q||t[1]];var a=q[G-1];K&&Q&&(a[0]!==K||a[1]!==Q)?(te&&($!==K&&ee!==Q?he($&&ee?(i=te,l=(o=t)[0]-i[0],s=(o[1]-i[1])/l,(i[1]*o[0]-o[1]*i[0])/l>0?[s>0?ie:oe,se]:[s>0?oe:ie,le]):[$||K,ee||Q]):$&&ee&&he([$,ee])),he([K,Q])):$-K&&ee-Q&&he([K||$,Q||ee]),te=t,$=K,ee=Q}else te&&pe(ne(te,t)[0]),q[G++]=t;var i,o,l,s}for(r=0;rJ(v,me))break;o=v,(w=y[0]*m[0]+y[1]*m[1])>b?(b=w,h=v,g=!1):w<_&&(_=w,p=v,g=!0)}if(g?(ge(h),o!==p&&ge(p)):(p!==a&&ge(p),o!==h&&ge(h)),ge(o),r>=e.length||!v)break;ge(v),a=v}}else ge(h)}te&&he([$||te[0],ee||te[1]]),Y.push(q.slice(0,G))}var ye=H.slice(H.length-1);if(E&&"h"!==ye&&"v"!==ye){for(var xe=!1,be=-1,_e=[],we=0;we=0?s=h:(s=h=d,d++),s0?Math.max(r,i):0}}},1146:function(e){"use strict";e.exports={container:"marker",min:"cmin",max:"cmax"}},4272:function(e,t,r){"use strict";var n=r(8766),a=r(5477).hasColorscale,i=r(9356),o=r(4726);e.exports=function(e,t,r,l,s,c){var u=o.isBubble(e),f=(e.line||{}).color;c=c||{},f&&(r=f),s("marker.symbol"),s("marker.opacity",u?.7:1),s("marker.size"),c.noAngle||(s("marker.angle"),c.noAngleRef||s("marker.angleref"),c.noStandOff||s("marker.standoff")),s("marker.color",r),a(e,"marker")&&i(e,t,l,s,{prefix:"marker.",cLetter:"c"}),c.noSelect||(s("selected.marker.color"),s("unselected.marker.color"),s("selected.marker.size"),s("unselected.marker.size")),c.noLine||(s("marker.line.color",f&&!Array.isArray(f)&&t.marker.color!==f?f:u?n.background:n.defaultLine),a(e,"marker.line")&&i(e,t,l,s,{prefix:"marker.line.",cLetter:"c"}),s("marker.line.width",u?1:0)),u&&(s("marker.sizeref"),s("marker.sizemin"),s("marker.sizemode")),c.gradient&&"none"!==s("marker.gradient.type")&&s("marker.gradient.color")}},9669:function(e,t,r){"use strict";var n=r(4809).dateTick0,a=r(3821).ONEWEEK;function i(e,t){return n(t,e%a==0?1:0)}e.exports=function(e,t,r,n,a){if(a||(a={x:!0,y:!0}),a.x){var o=n("xperiod");o&&(n("xperiod0",i(o,t.xcalendar)),n("xperiodalignment"))}if(a.y){var l=n("yperiod");l&&(n("yperiod0",i(l,t.ycalendar)),n("yperiodalignment"))}}},6098:function(e,t,r){"use strict";var n=r(5568),a=r(3626),i=r(4809),o=i.ensureSingle,l=i.identity,s=r(2203),c=r(4726),u=r(5525),f=r(7210),d=r(899).tester;function h(e,t,r,f,h,p,v){var g,m=e._context.staticPlot;!function(e,t,r,a,o){var l=r.xaxis,s=r.yaxis,u=n.extent(i.simpleMap(l.range,l.r2c)),f=n.extent(i.simpleMap(s.range,s.r2c)),d=a[0].trace;if(c.hasMarkers(d)){var h=d.marker.maxdisplayed;if(0!==h){var p=a.filter((function(e){return e.x>=u[0]&&e.x<=u[1]&&e.y>=f[0]&&e.y<=f[1]})),v=Math.ceil(p.length/h),g=0;o.forEach((function(e,r){var n=e[0].trace;c.hasMarkers(n)&&n.marker.maxdisplayed>0&&r0;function x(e){return y?e.transition():e}var b=r.xaxis,_=r.yaxis,w=f[0].trace,T=w.line,M=n.select(p),k=o(M,"g","errorbars"),A=o(M,"g","lines"),L=o(M,"g","points"),S=o(M,"g","text");if(a.getComponentMethod("errorbars","plot")(e,k,r,v),!0===w.visible){var C,O;x(M).style("opacity",w.opacity);var D,P,z=w.fill.charAt(w.fill.length-1);"x"!==z&&"y"!==z&&(z=""),"y"===z?(D=1,P=_.c2p(0,!0)):"x"===z&&(D=0,P=b.c2p(0,!0)),f[0][r.isRangePlot?"nodeRangePlot3":"node3"]=M;var I,E,R="",N=[],F=w._prevtrace,H=null,B=null;F&&(R=F._prevRevpath||"",O=F._nextFill,N=F._ownPolygons,H=F._fillsegments,B=F._fillElement);var j,Y,V,U,q,G,Z="",W="",J=[];w._polygons=[];var X=[],K=[],Q=i.noop;if(C=w._ownFill,c.hasLines(w)||"none"!==w.fill){O&&O.datum(f),-1!==["hv","vh","hvh","vhv"].indexOf(T.shape)?(j=s.steps(T.shape),Y=s.steps(T.shape.split("").reverse().join(""))):j=Y="spline"===T.shape?function(e){var t=e[e.length-1];return e.length>1&&e[0][0]===t[0]&&e[0][1]===t[1]?s.smoothclosed(e.slice(1),T.smoothing):s.smoothopen(e,T.smoothing)}:function(e){return"M"+e.join("L")},V=function(e){return Y(e.reverse())},K=u(f,{xaxis:b,yaxis:_,trace:w,connectGaps:w.connectgaps,baseTolerance:Math.max(T.width||1,3)/4,shape:T.shape,backoff:T.backoff,simplify:T.simplify,fill:w.fill}),X=new Array(K.length);var $=0;for(g=0;g0,g=f(e,t,r);(u=a.selectAll("g.trace").data(g,(function(e){return e[0].trace.uid}))).enter().append("g").attr("class",(function(e){return"trace scatter trace"+e[0].trace.uid})).style("stroke-miterlimit",2),u.order(),function(e,t,r){t.each((function(t){var a=o(n.select(this),"g","fills");s.setClipUrl(a,r.layerClipId,e);var i=t[0].trace,c=[];i._ownfill&&c.push("_ownFill"),i._nexttrace&&c.push("_nextFill");var u=a.selectAll("g").data(c,l);u.enter().append("g"),u.exit().each((function(e){i[e]=null})).remove(),u.order().each((function(e){i[e]=o(n.select(this),"path","js-fill")}))}))}(e,u,t),v?(c&&(d=c()),n.transition().duration(i.duration).ease(i.easing).each("end",(function(){d&&d()})).each("interrupt",(function(){d&&d()})).each((function(){a.selectAll("g.trace").each((function(r,n){h(e,n,t,r,g,this,i)}))}))):u.each((function(r,n){h(e,n,t,r,g,this,i)})),p&&u.exit().remove(),a.selectAll("path:not([d])").remove()}},2665:function(e,t,r){"use strict";var n=r(4726);e.exports=function(e,t){var r,a,i,o,l=e.cd,s=e.xaxis,c=e.yaxis,u=[],f=l[0].trace;if(!n.hasMarkers(f)&&!n.hasText(f))return[];if(!1===t)for(r=0;ra&&(a=u,o=c)}}return a?i(o):l};case"rms":return function(e,t){for(var r=0,a=0,o=0;o":return function(e){return h(e)>f};case">=":return function(e){return h(e)>=f};case"[]":return function(e){var t=h(e);return t>=f[0]&&t<=f[1]};case"()":return function(e){var t=h(e);return t>f[0]&&t=f[0]&&tf[0]&&t<=f[1]};case"][":return function(e){var t=h(e);return t<=f[0]||t>=f[1]};case")(":return function(e){var t=h(e);return tf[1]};case"](":return function(e){var t=h(e);return t<=f[0]||t>f[1]};case")[":return function(e){var t=h(e);return t=f[1]};case"{}":return function(e){return-1!==f.indexOf(h(e))};case"}{":return function(e){return-1===f.indexOf(h(e))}}}(r,i.getDataToCoordFunc(e,t,l,a),d),x={},b={},_=0;p?(g=function(e){x[e.astr]=n.extendDeep([],e.get()),e.set(new Array(f))},m=function(e,t){var r=x[e.astr][t];e.get()[t]=r}):(g=function(e){x[e.astr]=n.extendDeep([],e.get()),e.set([])},m=function(e,t){var r=x[e.astr][t];e.get().push(r)}),M(g);for(var w=o(t.transforms,r),T=0;T1?"%{group} (%{trace})":"%{group}");var s=e.styles,c=o.styles=[];if(s)for(i=0;i1&&-1!==a.indexOf("Macintosh")&&-1!==a.indexOf("Safari")&&(i=!0),i}},5955:function(e,t,r){"use strict";var n=r(2413),a=r.n(n),i=r(1070),o=r.n(i),l=r(2133),s=r.n(l),c=new URL(r(7035),r.b),u=new URL(r(3470),r.b),f=new URL(r(8164),r.b),d=new URL(r(4665),r.b),h=new URL(r(4890),r.b),p=new URL(r(3363),r.b),v=new URL(r(3490),r.b),g=new URL(r(7603),r.b),m=new URL(r(3913),r.b),y=new URL(r(1413),r.b),x=new URL(r(4643),r.b),b=new URL(r(216),r.b),_=new URL(r(1907),r.b),w=new URL(r(8605),r.b),T=new URL(r(5446),r.b),M=new URL(r(6694),r.b),k=new URL(r(4420),r.b),A=new URL(r(5796),r.b),L=new URL(r(2228),r.b),S=new URL(r(9819),r.b),C=new URL(r(7695),r.b),O=new URL(r(8869),r.b),D=new URL(r(557),r.b),P=new URL(r(8460),r.b),z=new URL(r(8920),r.b),I=new URL(r(3737),r.b),E=new URL(r(7914),r.b),R=new URL(r(6117),r.b),N=new URL(r(6311),r.b),F=o()(a()),H=s()(c),B=s()(u),j=s()(f),Y=s()(d),V=s()(h),U=s()(p),q=s()(v),G=s()(g),Z=s()(m),W=s()(y),J=s()(x),X=s()(b),K=s()(_),Q=s()(w),$=s()(T),ee=s()(M),te=s()(k),re=s()(A),ne=s()(L),ae=s()(S),ie=s()(C),oe=s()(O),le=s()(D),se=s()(P),ce=s()(z),ue=s()(I),fe=s()(E),de=s()(R),he=s()(N);F.push([e.id,".maplibregl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0 0 0/0)}.maplibregl-canvas{left:0;position:absolute;top:0}.maplibregl-map:fullscreen{height:100%;width:100%}.maplibregl-ctrl-group button.maplibregl-ctrl-compass{touch-action:none}.maplibregl-canvas-container.maplibregl-interactive,.maplibregl-ctrl-group button.maplibregl-ctrl-compass{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-canvas-container.maplibregl-interactive.maplibregl-track-pointer{cursor:pointer}.maplibregl-canvas-container.maplibregl-interactive:active,.maplibregl-ctrl-group button.maplibregl-ctrl-compass:active{cursor:grabbing}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-canvas-container.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:pinch-zoom}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:none}.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures,.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.maplibregl-ctrl-top-left{left:0;top:0}.maplibregl-ctrl-top-right{right:0;top:0}.maplibregl-ctrl-bottom-left{bottom:0;left:0}.maplibregl-ctrl-bottom-right{bottom:0;right:0}.maplibregl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.maplibregl-ctrl-top-left .maplibregl-ctrl{float:left;margin:10px 0 0 10px}.maplibregl-ctrl-top-right .maplibregl-ctrl{float:right;margin:10px 10px 0 0}.maplibregl-ctrl-bottom-left .maplibregl-ctrl{float:left;margin:0 0 10px 10px}.maplibregl-ctrl-bottom-right .maplibregl-ctrl{float:right;margin:0 10px 10px 0}.maplibregl-ctrl-group{background:#fff;border-radius:4px}.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (forced-colors:active){.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.maplibregl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;padding:0;width:29px}.maplibregl-ctrl-group button+button{border-top:1px solid #ddd}.maplibregl-ctrl button .maplibregl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (forced-colors:active){.maplibregl-ctrl-icon{background-color:transparent}.maplibregl-ctrl-group button+button{border-top:1px solid ButtonText}}.maplibregl-ctrl button::-moz-focus-inner{border:0;padding:0}.maplibregl-ctrl-attrib-button:focus,.maplibregl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl button:disabled{cursor:not-allowed}.maplibregl-ctrl button:disabled .maplibregl-ctrl-icon{opacity:.25}.maplibregl-ctrl button:not(:disabled):hover{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.maplibregl-ctrl-group button:focus:first-child{border-radius:4px 4px 0 0}.maplibregl-ctrl-group button:focus:last-child{border-radius:0 0 4px 4px}.maplibregl-ctrl-group button:focus:only-child{border-radius:inherit}.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("+H+")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("+B+")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("+j+")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("+Y+")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("+V+")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("+U+")}}.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("+q+")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("+G+")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("+Z+")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("+W+")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("+J+")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("+G+")}}.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("+X+")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("+K+")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("+Q+")}}.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon{background-image:url("+$+")}.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon{background-image:url("+ee+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("+te+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("+re+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("+ne+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("+ae+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("+ie+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("+oe+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-waiting .maplibregl-ctrl-icon{animation:maplibregl-spin 2s linear infinite}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("+le+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("+se+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("+ne+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("+ae+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("+ie+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("+oe+")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("+ce+")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("+ue+")}}@keyframes maplibregl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.maplibregl-ctrl-logo{background-image:url("+fe+");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.maplibregl-ctrl-logo.maplibregl-compact{width:14px}@media (forced-colors:active){a.maplibregl-ctrl-logo{background-color:transparent;background-image:url("+fe+")}}@media (forced-colors:active) and (prefers-color-scheme:light){a.maplibregl-ctrl-logo{background-image:url("+fe+")}}.maplibregl-ctrl.maplibregl-ctrl-attrib{background-color:hsla(0,0%,100%,.5);margin:0;padding:0 5px}@media screen{.maplibregl-ctrl-attrib.maplibregl-compact{background-color:#fff;border-radius:12px;box-sizing:content-box;color:#000;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.maplibregl-ctrl-attrib.maplibregl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact-show,.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner{display:none}.maplibregl-ctrl-attrib-button{background-color:hsla(0,0%,100%,.5);background-image:url("+de+");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;list-style:none}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker{display:none}.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button{left:0}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner{display:block}.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-bottom-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;right:0}.maplibregl-ctrl-top-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{right:0;top:0}.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{left:0;top:0}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;left:0}}@media screen and (forced-colors:active){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("+he+")}}@media screen and (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("+de+')}}.maplibregl-ctrl-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.maplibregl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.maplibregl-attrib-empty{display:none}.maplibregl-ctrl-scale{background-color:hsla(0,0%,100%,.75);border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px}.maplibregl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.maplibregl-popup-anchor-top,.maplibregl-popup-anchor-top-left,.maplibregl-popup-anchor-top-right{flex-direction:column}.maplibregl-popup-anchor-bottom,.maplibregl-popup-anchor-bottom-left,.maplibregl-popup-anchor-bottom-right{flex-direction:column-reverse}.maplibregl-popup-anchor-left{flex-direction:row}.maplibregl-popup-anchor-right{flex-direction:row-reverse}.maplibregl-popup-tip{border:10px solid transparent;height:0;width:0;z-index:1}.maplibregl-popup-anchor-top .maplibregl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.maplibregl-popup-anchor-top-left .maplibregl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.maplibregl-popup-anchor-top-right .maplibregl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.maplibregl-popup-anchor-bottom .maplibregl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.maplibregl-popup-anchor-left .maplibregl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.maplibregl-popup-anchor-right .maplibregl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.maplibregl-popup-close-button{background-color:transparent;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.maplibregl-popup-close-button:hover{background-color:rgb(0 0 0/5%)}.maplibregl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:15px 10px;pointer-events:auto;position:relative}.maplibregl-popup-anchor-top-left .maplibregl-popup-content{border-top-left-radius:0}.maplibregl-popup-anchor-top-right .maplibregl-popup-content{border-top-right-radius:0}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-content{border-bottom-left-radius:0}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-content{border-bottom-right-radius:0}.maplibregl-popup-track-pointer{display:none}.maplibregl-popup-track-pointer *{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-map:hover .maplibregl-popup-track-pointer{display:flex}.maplibregl-map:active .maplibregl-popup-track-pointer{display:none}.maplibregl-marker{left:0;position:absolute;top:0;transition:opacity .2s;will-change:transform}.maplibregl-user-location-dot,.maplibregl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.maplibregl-user-location-dot:before{animation:maplibregl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.maplibregl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px rgba(0,0,0,.35);box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}@keyframes maplibregl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.maplibregl-user-location-dot-stale{background-color:#aaa}.maplibregl-user-location-dot-stale:after{display:none}.maplibregl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive,.maplibregl-crosshair .maplibregl-interactive:active{cursor:crosshair}.maplibregl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}.maplibregl-cooperative-gesture-screen{align-items:center;background:rgba(0,0,0,.4);color:#fff;display:flex;font-size:1.4em;inset:0;justify-content:center;line-height:1.2;opacity:0;padding:1rem;pointer-events:none;position:absolute;transition:opacity 1s ease 1s;z-index:99999}.maplibregl-cooperative-gesture-screen.maplibregl-show{opacity:1;transition:opacity .05s}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:none}@media (hover:none),(width <= 480px){.maplibregl-cooperative-gesture-screen .maplibregl-desktop-message{display:none}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:block}}.maplibregl-pseudo-fullscreen{height:100%!important;left:0!important;position:fixed!important;top:0!important;width:100%!important;z-index:99999}',""]),t.A=F},5568:function(e,t,r){var n,a;(function(){var i={version:"3.8.2"},o=[].slice,l=function(e){return o.call(e)},s=self.document;function c(e){return e&&(e.ownerDocument||e.document||e).documentElement}function u(e){return e&&(e.ownerDocument&&e.ownerDocument.defaultView||e.document&&e||e.defaultView)}if(s)try{l(s.documentElement.childNodes)[0].nodeType}catch(e){l=function(e){for(var t=e.length,r=new Array(t);t--;)r[t]=e[t];return r}}if(Date.now||(Date.now=function(){return+new Date}),s)try{s.createElement("DIV").style.setProperty("opacity",0,"")}catch(e){var f=this.Element.prototype,d=f.setAttribute,h=f.setAttributeNS,p=this.CSSStyleDeclaration.prototype,v=p.setProperty;f.setAttribute=function(e,t){d.call(this,e,t+"")},f.setAttributeNS=function(e,t,r){h.call(this,e,t,r+"")},p.setProperty=function(e,t,r){v.call(this,e,t+"",r)}}function g(e,t){return et?1:e>=t?0:NaN}function m(e){return null===e?NaN:+e}function y(e){return!isNaN(e)}function x(e){return{left:function(t,r,n,a){for(arguments.length<3&&(n=0),arguments.length<4&&(a=t.length);n>>1;e(t[i],r)<0?n=i+1:a=i}return n},right:function(t,r,n,a){for(arguments.length<3&&(n=0),arguments.length<4&&(a=t.length);n>>1;e(t[i],r)>0?a=i:n=i+1}return n}}}i.ascending=g,i.descending=function(e,t){return te?1:t>=e?0:NaN},i.min=function(e,t){var r,n,a=-1,i=e.length;if(1===arguments.length){for(;++a=n){r=n;break}for(;++an&&(r=n)}else{for(;++a=n){r=n;break}for(;++an&&(r=n)}return r},i.max=function(e,t){var r,n,a=-1,i=e.length;if(1===arguments.length){for(;++a=n){r=n;break}for(;++ar&&(r=n)}else{for(;++a=n){r=n;break}for(;++ar&&(r=n)}return r},i.extent=function(e,t){var r,n,a,i=-1,o=e.length;if(1===arguments.length){for(;++i=n){r=a=n;break}for(;++in&&(r=n),a=n){r=a=n;break}for(;++in&&(r=n),a1)return o/(s-1)},i.deviation=function(){var e=i.variance.apply(this,arguments);return e?Math.sqrt(e):e};var b=x(g);function _(e){return e.length}i.bisectLeft=b.left,i.bisect=i.bisectRight=b.right,i.bisector=function(e){return x(1===e.length?function(t,r){return g(e(t),r)}:e)},i.shuffle=function(e,t,r){(i=arguments.length)<3&&(r=e.length,i<2&&(t=0));for(var n,a,i=r-t;i;)a=Math.random()*i--|0,n=e[i+t],e[i+t]=e[a+t],e[a+t]=n;return e},i.permute=function(e,t){for(var r=t.length,n=new Array(r);r--;)n[r]=e[t[r]];return n},i.pairs=function(e){for(var t=0,r=e.length-1,n=e[0],a=new Array(r<0?0:r);t=0;)for(t=(n=e[a]).length;--t>=0;)r[--o]=n[t];return r};var w=Math.abs;function T(e,t){for(var r in t)Object.defineProperty(e.prototype,r,{value:t[r],enumerable:!1})}function M(){this._=Object.create(null)}i.range=function(e,t,r){if(arguments.length<3&&(r=1,arguments.length<2&&(t=e,e=0)),(t-e)/r==1/0)throw new Error("infinite range");var n,a=[],i=function(e){for(var t=1;e*t%1;)t*=10;return t}(w(r)),o=-1;if(e*=i,t*=i,(r*=i)<0)for(;(n=e+r*++o)>t;)a.push(n/i);else for(;(n=e+r*++o)=n.length)return t?t.call(r,i):e?i.sort(e):i;for(var s,c,u,f,d=-1,h=i.length,p=n[l++],v=new M;++d=n.length)return e;var r=[],i=a[t++];return e.forEach((function(e,n){r.push({key:e,values:l(n,t)})})),i?r.sort((function(e,t){return i(e.key,t.key)})):r}return r.map=function(e,t){return o(t,e,0)},r.entries=function(e){return l(o(i.map,e,0),0)},r.key=function(e){return n.push(e),r},r.sortKeys=function(e){return a[n.length-1]=e,r},r.sortValues=function(t){return e=t,r},r.rollup=function(e){return t=e,r},r},i.set=function(e){var t=new I;if(e)for(var r=0,n=e.length;r=0&&(n=e.slice(r+1),e=e.slice(0,r)),e)return arguments.length<2?this[e].on(n):this[e].on(n,t);if(2===arguments.length){if(null==t)for(e in this)this.hasOwnProperty(e)&&this[e].on(n,null);return this}},i.event=null,i.requote=function(e){return e.replace(q,"\\$&")};var q=/[\\\^\$\*\+\?\|\[\]\(\)\.\{\}]/g,G={}.__proto__?function(e,t){e.__proto__=t}:function(e,t){for(var r in t)e[r]=t[r]};function Z(e){return G(e,K),e}var W=function(e,t){return t.querySelector(e)},J=function(e,t){return t.querySelectorAll(e)},X=function(e,t){var r=e.matches||e[N(e,"matchesSelector")];return X=function(e,t){return r.call(e,t)},X(e,t)};"function"==typeof Sizzle&&(W=function(e,t){return Sizzle(e,t)[0]||null},J=Sizzle,X=Sizzle.matchesSelector),i.selection=function(){return i.select(s.documentElement)};var K=i.selection.prototype=[];function Q(e){return"function"==typeof e?e:function(){return W(e,this)}}function $(e){return"function"==typeof e?e:function(){return J(e,this)}}K.select=function(e){var t,r,n,a,i=[];e=Q(e);for(var o=-1,l=this.length;++o=0&&"xmlns"!==(r=e.slice(0,t))&&(e=e.slice(t+1)),te.hasOwnProperty(r)?{space:te[r],local:e}:e}},K.attr=function(e,t){if(arguments.length<2){if("string"==typeof e){var r=this.node();return(e=i.ns.qualify(e)).local?r.getAttributeNS(e.space,e.local):r.getAttribute(e)}for(t in e)this.each(re(t,e[t]));return this}return this.each(re(e,t))},K.classed=function(e,t){if(arguments.length<2){if("string"==typeof e){var r=this.node(),n=(e=ie(e)).length,a=-1;if(t=r.classList){for(;++a=0;)(r=n[a])&&(i&&i!==r.nextSibling&&i.parentNode.insertBefore(r,i),i=r);return this},K.sort=function(e){e=pe.apply(this,arguments);for(var t=-1,r=this.length;++t0&&(e=e.slice(0,a));var s=xe.get(e);function c(){var t=this[n];t&&(this.removeEventListener(e,t,t.$),delete this[n])}return s&&(e=s,o=_e),a?t?function(){var a=o(t,l(arguments));c.call(this),this.addEventListener(e,this[n]=a,a.$=r),a._=t}:c:t?H:function(){var t,r=new RegExp("^__on([^.]+)"+i.requote(e)+"$");for(var n in this)if(t=n.match(r)){var a=this[n];this.removeEventListener(t[1],a,a.$),delete this[n]}}}i.selection.enter=ge,i.selection.enter.prototype=me,me.append=K.append,me.empty=K.empty,me.node=K.node,me.call=K.call,me.size=K.size,me.select=function(e){for(var t,r,n,a,i,o=[],l=-1,s=this.length;++l=n&&(n=t+1);!(o=l[n])&&++n1?ze:e<-1?-ze:Math.asin(e)}function Ne(e){return((e=Math.exp(e))+1/e)/2}var Fe=Math.SQRT2;i.interpolateZoom=function(e,t){var r,n,a=e[0],i=e[1],o=e[2],l=t[0],s=t[1],c=t[2],u=l-a,f=s-i,d=u*u+f*f;if(d0&&(e=e.transition().duration(v)),e.call(w.event)}function L(){l&&l.domain(o.range().map((function(e){return(e-d.x)/d.k})).map(o.invert)),f&&f.domain(c.range().map((function(e){return(e-d.y)/d.k})).map(c.invert))}function S(e){g++||e({type:"zoomstart"})}function C(e){L(),e({type:"zoom",scale:d.k,translate:[d.x,d.y]})}function O(e){--g||(e({type:"zoomend"}),t=null)}function D(){var e=this,t=_.of(e,arguments),r=0,n=i.select(u(e)).on(y,(function(){r=1,k(i.mouse(e),a),C(t)})).on(x,(function(){n.on(y,null).on(x,null),o(r),O(t)})),a=T(i.mouse(e)),o=Me(e);Xa.call(e),S(t)}function P(){var e,t=this,r=_.of(t,arguments),n={},o=0,l=".zoom-"+i.event.changedTouches[0].identifier,s="touchmove"+l,c="touchend"+l,u=[],f=i.select(t),h=Me(t);function p(){var r=i.touches(t);return e=d.k,r.forEach((function(e){e.identifier in n&&(n[e.identifier]=T(e))})),r}function v(){var e=i.event.target;i.select(e).on(s,g).on(c,y),u.push(e);for(var r=i.event.changedTouches,l=0,f=r.length;l1){m=h[0];var x=h[1],b=m[0]-x[0],_=m[1]-x[1];o=b*b+_*_}}function g(){var l,s,c,u,f=i.touches(t);Xa.call(t);for(var d=0,h=f.length;d360?e-=360:e<0&&(e+=360),e<60?n+(a-n)*e/60:e<180?a:e<240?n+(a-n)*(240-e)/60:n}(e))}return e=isNaN(e)?0:(e%=360)<0?e+360:e,t=isNaN(t)||t<0?0:t>1?1:t,n=2*(r=r<0?0:r>1?1:r)-(a=r<=.5?r*(1+t):r+t-r*t),new it(i(e+120),i(e),i(e-120))}function Ge(e,t,r){return this instanceof Ge?(this.h=+e,this.c=+t,void(this.l=+r)):arguments.length<2?e instanceof Ge?new Ge(e.h,e.c,e.l):function(e,t,r){return e>0?new Ge(Math.atan2(r,t)*Ee,Math.sqrt(t*t+r*r),e):new Ge(NaN,NaN,e)}(e instanceof Je?e.l:(e=dt((e=i.rgb(e)).r,e.g,e.b)).l,e.a,e.b):new Ge(e,t,r)}Ue.brighter=function(e){return e=Math.pow(.7,arguments.length?e:1),new Ve(this.h,this.s,this.l/e)},Ue.darker=function(e){return e=Math.pow(.7,arguments.length?e:1),new Ve(this.h,this.s,e*this.l)},Ue.rgb=function(){return qe(this.h,this.s,this.l)},i.hcl=Ge;var Ze=Ge.prototype=new Ye;function We(e,t,r){return isNaN(e)&&(e=0),isNaN(t)&&(t=0),new Je(r,Math.cos(e*=Ie)*t,Math.sin(e)*t)}function Je(e,t,r){return this instanceof Je?(this.l=+e,this.a=+t,void(this.b=+r)):arguments.length<2?e instanceof Je?new Je(e.l,e.a,e.b):e instanceof Ge?We(e.h,e.c,e.l):dt((e=it(e)).r,e.g,e.b):new Je(e,t,r)}Ze.brighter=function(e){return new Ge(this.h,this.c,Math.min(100,this.l+Xe*(arguments.length?e:1)))},Ze.darker=function(e){return new Ge(this.h,this.c,Math.max(0,this.l-Xe*(arguments.length?e:1)))},Ze.rgb=function(){return We(this.h,this.c,this.l).rgb()},i.lab=Je;var Xe=18,Ke=.95047,Qe=1,$e=1.08883,et=Je.prototype=new Ye;function tt(e,t,r){var n=(e+16)/116,a=n+t/500,i=n-r/200;return new it(at(3.2404542*(a=rt(a)*Ke)-1.5371385*(n=rt(n)*Qe)-.4985314*(i=rt(i)*$e)),at(-.969266*a+1.8760108*n+.041556*i),at(.0556434*a-.2040259*n+1.0572252*i))}function rt(e){return e>.206893034?e*e*e:(e-4/29)/7.787037}function nt(e){return e>.008856?Math.pow(e,1/3):7.787037*e+4/29}function at(e){return Math.round(255*(e<=.00304?12.92*e:1.055*Math.pow(e,1/2.4)-.055))}function it(e,t,r){return this instanceof it?(this.r=~~e,this.g=~~t,void(this.b=~~r)):arguments.length<2?e instanceof it?new it(e.r,e.g,e.b):ut(""+e,it,qe):new it(e,t,r)}function ot(e){return new it(e>>16,e>>8&255,255&e)}function lt(e){return ot(e)+""}et.brighter=function(e){return new Je(Math.min(100,this.l+Xe*(arguments.length?e:1)),this.a,this.b)},et.darker=function(e){return new Je(Math.max(0,this.l-Xe*(arguments.length?e:1)),this.a,this.b)},et.rgb=function(){return tt(this.l,this.a,this.b)},i.rgb=it;var st=it.prototype=new Ye;function ct(e){return e<16?"0"+Math.max(0,e).toString(16):Math.min(255,e).toString(16)}function ut(e,t,r){var n,a,i,o=0,l=0,s=0;if(n=/([a-z]+)\((.*)\)/.exec(e=e.toLowerCase()))switch(a=n[2].split(","),n[1]){case"hsl":return r(parseFloat(a[0]),parseFloat(a[1])/100,parseFloat(a[2])/100);case"rgb":return t(pt(a[0]),pt(a[1]),pt(a[2]))}return(i=vt.get(e))?t(i.r,i.g,i.b):(null==e||"#"!==e.charAt(0)||isNaN(i=parseInt(e.slice(1),16))||(4===e.length?(o=(3840&i)>>4,o|=o>>4,l=240&i,l|=l>>4,s=15&i,s|=s<<4):7===e.length&&(o=(16711680&i)>>16,l=(65280&i)>>8,s=255&i)),t(o,l,s))}function ft(e,t,r){var n,a,i=Math.min(e/=255,t/=255,r/=255),o=Math.max(e,t,r),l=o-i,s=(o+i)/2;return l?(a=s<.5?l/(o+i):l/(2-o-i),n=e==o?(t-r)/l+(t0&&s<1?0:n),new Ve(n,a,s)}function dt(e,t,r){var n=nt((.4124564*(e=ht(e))+.3575761*(t=ht(t))+.1804375*(r=ht(r)))/Ke),a=nt((.2126729*e+.7151522*t+.072175*r)/Qe);return Je(116*a-16,500*(n-a),200*(a-nt((.0193339*e+.119192*t+.9503041*r)/$e)))}function ht(e){return(e/=255)<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4)}function pt(e){var t=parseFloat(e);return"%"===e.charAt(e.length-1)?Math.round(2.55*t):t}st.brighter=function(e){e=Math.pow(.7,arguments.length?e:1);var t=this.r,r=this.g,n=this.b,a=30;return t||r||n?(t&&t=200&&t<300||304===t){try{e=r.call(a,c)}catch(e){return void o.error.call(a,e)}o.load.call(a,e)}else o.error.call(a,c)}return self.XDomainRequest&&!("withCredentials"in c)&&/^(http(s)?:)?\/\//.test(e)&&(c=new XDomainRequest),"onload"in c?c.onload=c.onerror=f:c.onreadystatechange=function(){c.readyState>3&&f()},c.onprogress=function(e){var t=i.event;i.event=e;try{o.progress.call(a,c)}finally{i.event=t}},a.header=function(e,t){return e=(e+"").toLowerCase(),arguments.length<2?s[e]:(null==t?delete s[e]:s[e]=t+"",a)},a.mimeType=function(e){return arguments.length?(t=null==e?null:e+"",a):t},a.responseType=function(e){return arguments.length?(u=e,a):u},a.response=function(e){return r=e,a},["get","post"].forEach((function(e){a[e]=function(){return a.send.apply(a,[e].concat(l(arguments)))}})),a.send=function(r,n,i){if(2===arguments.length&&"function"==typeof n&&(i=n,n=null),c.open(r,e,!0),null==t||"accept"in s||(s.accept=t+",*/*"),c.setRequestHeader)for(var l in s)c.setRequestHeader(l,s[l]);return null!=t&&c.overrideMimeType&&c.overrideMimeType(t),null!=u&&(c.responseType=u),null!=i&&a.on("error",i).on("load",(function(e){i(null,e)})),o.beforesend.call(a,c),c.send(null==n?null:n),a},a.abort=function(){return c.abort(),a},i.rebind(a,o,"on"),null==n?a:a.get(function(e){return 1===e.length?function(t,r){e(null==t?r:null)}:e}(n))}vt.forEach((function(e,t){vt.set(e,ot(t))})),i.functor=gt,i.xhr=mt(E),i.dsv=function(e,t){var r=new RegExp('["'+e+"\n]"),n=e.charCodeAt(0);function a(e,r,n){arguments.length<3&&(n=r,r=null);var a=yt(e,t,null==r?i:o(r),n);return a.row=function(e){return arguments.length?a.response(null==(r=e)?i:o(e)):r},a}function i(e){return a.parse(e.responseText)}function o(e){return function(t){return a.parse(t.responseText,e)}}function l(t){return t.map(s).join(e)}function s(e){return r.test(e)?'"'+e.replace(/\"/g,'""')+'"':e}return a.parse=function(e,t){var r;return a.parseRows(e,(function(e,n){if(r)return r(e,n-1);var a=function(t){for(var r={},n=e.length,a=0;a=s)return o;if(a)return a=!1,i;var t=c;if(34===e.charCodeAt(t)){for(var r=t;r++24?(isFinite(t)&&(clearTimeout(wt),wt=setTimeout(kt,t)),_t=0):(_t=1,Tt(kt))}function At(){for(var e=Date.now(),t=xt;t;)e>=t.t&&t.c(e-t.t)&&(t.c=null),t=t.n;return e}function Lt(){for(var e,t=xt,r=1/0;t;)t.c?(t.t1&&(t=e[i[o-2]],r=e[i[o-1]],n=e[l],(r[0]-t[0])*(n[1]-t[1])-(r[1]-t[1])*(n[0]-t[0])<=0);)--o;i[o++]=l}return i.slice(0,o)}function Dt(e,t){return e[0]-t[0]||e[1]-t[1]}i.timer=function(){Mt.apply(this,arguments)},i.timer.flush=function(){At(),Lt()},i.round=function(e,t){return t?Math.round(e*(t=Math.pow(10,t)))/t:Math.round(e)},i.geom={},i.geom.hull=function(e){var t=St,r=Ct;if(arguments.length)return n(e);function n(e){if(e.length<3)return[];var n,a=gt(t),i=gt(r),o=e.length,l=[],s=[];for(n=0;n=0;--n)h.push(e[l[c[n]][2]]);for(n=+f;nSe)l=l.L;else{if(!((a=i-Jt(l,o))>Se)){n>-Se?(t=l.P,r=l):a>-Se?(t=l,r=l.N):t=r=l;break}if(!l.R){t=l;break}l=l.R}var s=Ut(e);if(Ft.insert(t,s),t||r){if(t===r)return er(t),r=Ut(t.site),Ft.insert(s,r),s.edge=r.edge=nr(t.site,s.site),$t(t),void $t(r);if(r){er(t),er(r);var c=t.site,u=c.x,f=c.y,d=e.x-u,h=e.y-f,p=r.site,v=p.x-u,g=p.y-f,m=2*(d*g-h*v),y=d*d+h*h,x=v*v+g*g,b={x:(g*y-h*x)/m+u,y:(d*x-v*y)/m+f};ar(r.edge,c,p,b),s.edge=nr(c,e,null,b),r.edge=nr(e,p,null,b),$t(t),$t(r)}else s.edge=nr(t.site,s.site)}}function Wt(e,t){var r=e.site,n=r.x,a=r.y,i=a-t;if(!i)return n;var o=e.P;if(!o)return-1/0;var l=(r=o.site).x,s=r.y,c=s-t;if(!c)return l;var u=l-n,f=1/i-1/c,d=u/c;return f?(-d+Math.sqrt(d*d-2*f*(u*u/(-2*c)-s+c/2+a-i/2)))/f+n:(n+l)/2}function Jt(e,t){var r=e.N;if(r)return Wt(r,t);var n=e.site;return n.y===t?n.x:1/0}function Xt(e){this.site=e,this.edges=[]}function Kt(e,t){return t.angle-e.angle}function Qt(){lr(this),this.x=this.y=this.arc=this.site=this.cy=null}function $t(e){var t=e.P,r=e.N;if(t&&r){var n=t.site,a=e.site,i=r.site;if(n!==i){var o=a.x,l=a.y,s=n.x-o,c=n.y-l,u=i.x-o,f=2*(s*(g=i.y-l)-c*u);if(!(f>=-Ce)){var d=s*s+c*c,h=u*u+g*g,p=(g*d-c*h)/f,v=(s*h-u*d)/f,g=v+l,m=Yt.pop()||new Qt;m.arc=e,m.site=a,m.x=p+o,m.y=g+Math.sqrt(p*p+v*v),m.cy=g,e.circle=m;for(var y=null,x=Bt._;x;)if(m.y=l)return;if(d>p){if(i){if(i.y>=c)return}else i={x:g,y:s};r={x:g,y:c}}else{if(i){if(i.y1)if(d>p){if(i){if(i.y>=c)return}else i={x:(s-a)/n,y:s};r={x:(c-a)/n,y:c}}else{if(i){if(i.y=l)return}else i={x:o,y:n*o+a};r={x:l,y:n*l+a}}else{if(i){if(i.x0)){if(t/=d,d<0){if(t0){if(t>f)return;t>u&&(u=t)}if(t=a-s,d||!(t<0)){if(t/=d,d<0){if(t>f)return;t>u&&(u=t)}else if(d>0){if(t0)){if(t/=h,h<0){if(t0){if(t>f)return;t>u&&(u=t)}if(t=i-c,h||!(t<0)){if(t/=h,h<0){if(t>f)return;t>u&&(u=t)}else if(h>0){if(t0&&(e.a={x:s+u*d,y:c+u*h}),f<1&&(e.b={x:s+f*d,y:c+f*h}),e}}}}}),s=o.length;s--;)(!tr(t=o[s],e)||!l(t)||w(t.a.x-t.b.x)Se||w(a-r)>Se)&&(l.splice(o,0,new ir((m=i.site,y=u,x=w(n-f)Se?{x:f,y:w(t-f)Se?{x:w(r-p)Se?{x:d,y:w(t-d)Se?{x:w(r-h)=r&&c.x<=a&&c.y>=n&&c.y<=o?[[r,o],[a,o],[a,n],[r,n]]:[]).point=e[l]})),t}function l(e){return e.map((function(e,t){return{x:Math.round(n(e,t)/Se)*Se,y:Math.round(a(e,t)/Se)*Se,i:t}}))}return o.links=function(e){return fr(l(e)).edges.filter((function(e){return e.l&&e.r})).map((function(t){return{source:e[t.l.i],target:e[t.r.i]}}))},o.triangles=function(e){var t=[];return fr(l(e)).cells.forEach((function(r,n){for(var a,i,o,l,s=r.site,c=r.edges.sort(Kt),u=-1,f=c.length,d=c[f-1].edge,h=d.l===s?d.r:d.l;++ui&&(a=t.slice(i,a),l[o]?l[o]+=a:l[++o]=a),(r=r[0])===(n=n[0])?l[o]?l[o]+=n:l[++o]=n:(l[++o]=null,s.push({i:o,x:xr(r,n)})),i=wr.lastIndex;return iv&&(v=s.x),s.y>g&&(g=s.y),c.push(s.x),u.push(s.y);else for(f=0;fv&&(v=x),b>g&&(g=b),c.push(x),u.push(b)}var _=v-h,T=g-p;function M(e,t,r,n,a,i,o,l){if(!isNaN(r)&&!isNaN(n))if(e.leaf){var s=e.x,c=e.y;if(null!=s)if(w(s-r)+w(c-n)<.01)k(e,t,r,n,a,i,o,l);else{var u=e.point;e.x=e.y=e.point=null,k(e,u,s,c,a,i,o,l),k(e,t,r,n,a,i,o,l)}else e.x=r,e.y=n,e.point=t}else k(e,t,r,n,a,i,o,l)}function k(e,t,r,n,a,i,o,l){var s=.5*(a+o),c=.5*(i+l),u=r>=s,f=n>=c,d=f<<1|u;e.leaf=!1,u?a=s:o=s,f?i=c:l=c,M(e=e.nodes[d]||(e.nodes[d]={leaf:!0,nodes:[],point:null,x:null,y:null}),t,r,n,a,i,o,l)}_>T?g=p+_:v=h+T;var A={leaf:!0,nodes:[],point:null,x:null,y:null,add:function(e){M(A,e,+m(e,++f),+y(e,f),h,p,v,g)}};if(A.visit=function(e){gr(e,A,h,p,v,g)},A.find=function(e){return function(e,t,r,n,a,i,o){var l,s=1/0;return function e(c,u,f,d,h){if(!(u>i||f>o||d=_)<<1|t>=b,T=w+4;w=0&&!(r=i.interpolators[n](e,t)););return r}function Mr(e,t){var r,n=[],a=[],i=e.length,o=t.length,l=Math.min(e.length,t.length);for(r=0;r=1)return 1;var t=e*e,r=t*e;return 4*(e<.5?r:3*(e-t)+r-.75)}function zr(e){return 1-Math.cos(e*ze)}function Ir(e){return Math.pow(2,10*(e-1))}function Er(e){return 1-Math.sqrt(1-e*e)}function Rr(e){return e<1/2.75?7.5625*e*e:e<2/2.75?7.5625*(e-=1.5/2.75)*e+.75:e<2.5/2.75?7.5625*(e-=2.25/2.75)*e+.9375:7.5625*(e-=2.625/2.75)*e+.984375}function Nr(e,t){return t-=e,function(r){return Math.round(e+t*r)}}function Fr(e){var t,r,n,a=[e.a,e.b],i=[e.c,e.d],o=Br(a),l=Hr(a,i),s=Br(((t=i)[0]+=(n=-l)*(r=a)[0],t[1]+=n*r[1],t))||0;a[0]*i[1]=0?e.slice(0,r):e,a=r>=0?e.slice(r+1):"in";return n=Ar.get(n)||kr,a=Lr.get(a)||E,t=a(n.apply(null,o.call(arguments,1))),function(e){return e<=0?0:e>=1?1:t(e)}},i.interpolateHcl=function(e,t){e=i.hcl(e),t=i.hcl(t);var r=e.h,n=e.c,a=e.l,o=t.h-r,l=t.c-n,s=t.l-a;return isNaN(l)&&(l=0,n=isNaN(n)?t.c:n),isNaN(o)?(o=0,r=isNaN(r)?t.h:r):o>180?o-=360:o<-180&&(o+=360),function(e){return We(r+o*e,n+l*e,a+s*e)+""}},i.interpolateHsl=function(e,t){e=i.hsl(e),t=i.hsl(t);var r=e.h,n=e.s,a=e.l,o=t.h-r,l=t.s-n,s=t.l-a;return isNaN(l)&&(l=0,n=isNaN(n)?t.s:n),isNaN(o)?(o=0,r=isNaN(r)?t.h:r):o>180?o-=360:o<-180&&(o+=360),function(e){return qe(r+o*e,n+l*e,a+s*e)+""}},i.interpolateLab=function(e,t){e=i.lab(e),t=i.lab(t);var r=e.l,n=e.a,a=e.b,o=t.l-r,l=t.a-n,s=t.b-a;return function(e){return tt(r+o*e,n+l*e,a+s*e)+""}},i.interpolateRound=Nr,i.transform=function(e){var t=s.createElementNS(i.ns.prefix.svg,"g");return(i.transform=function(e){if(null!=e){t.setAttribute("transform",e);var r=t.transform.baseVal.consolidate()}return new Fr(r?r.matrix:jr)})(e)},Fr.prototype.toString=function(){return"translate("+this.translate+")rotate("+this.rotate+")skewX("+this.skew+")scale("+this.scale+")"};var jr={a:1,b:0,c:0,d:1,e:0,f:0};function Yr(e){return e.length?e.pop()+",":""}function Vr(e,t){var r=[],n=[];return e=i.transform(e),t=i.transform(t),function(e,t,r,n){if(e[0]!==t[0]||e[1]!==t[1]){var a=r.push("translate(",null,",",null,")");n.push({i:a-4,x:xr(e[0],t[0])},{i:a-2,x:xr(e[1],t[1])})}else(t[0]||t[1])&&r.push("translate("+t+")")}(e.translate,t.translate,r,n),function(e,t,r,n){e!==t?(e-t>180?t+=360:t-e>180&&(e+=360),n.push({i:r.push(Yr(r)+"rotate(",null,")")-2,x:xr(e,t)})):t&&r.push(Yr(r)+"rotate("+t+")")}(e.rotate,t.rotate,r,n),function(e,t,r,n){e!==t?n.push({i:r.push(Yr(r)+"skewX(",null,")")-2,x:xr(e,t)}):t&&r.push(Yr(r)+"skewX("+t+")")}(e.skew,t.skew,r,n),function(e,t,r,n){if(e[0]!==t[0]||e[1]!==t[1]){var a=r.push(Yr(r)+"scale(",null,",",null,")");n.push({i:a-4,x:xr(e[0],t[0])},{i:a-2,x:xr(e[1],t[1])})}else 1===t[0]&&1===t[1]||r.push(Yr(r)+"scale("+t+")")}(e.scale,t.scale,r,n),e=t=null,function(e){for(var t,a=-1,i=n.length;++a0?r=t:(e.c=null,e.t=NaN,e=null,s.end({type:"end",alpha:r=0})):t>0&&(s.start({type:"start",alpha:r=t}),e=Mt(l.tick)),l):r},l.start=function(){var e,t,r,i=m.length,s=y.length,u=c[0],p=c[1];for(e=0;e=0;)r.push(a[n])}function an(e,t){for(var r=[e],n=[];null!=(e=r.pop());)if(n.push(e),(i=e.children)&&(a=i.length))for(var a,i,o=-1;++o=0;)o.push(u=c[s]),u.parent=i,u.depth=i.depth+1;r&&(i.value=0),i.children=c}else r&&(i.value=+r.call(n,i,i.depth)||0),delete i.children;return an(a,(function(t){var n,a;e&&(n=t.children)&&n.sort(e),r&&(a=t.parent)&&(a.value+=t.value)})),l}return n.sort=function(t){return arguments.length?(e=t,n):e},n.children=function(e){return arguments.length?(t=e,n):t},n.value=function(e){return arguments.length?(r=e,n):r},n.revalue=function(e){return r&&(nn(e,(function(e){e.children&&(e.value=0)})),an(e,(function(e){var t;e.children||(e.value=+r.call(n,e,e.depth)||0),(t=e.parent)&&(t.value+=e.value)}))),e},n},i.layout.partition=function(){var e=i.layout.hierarchy(),t=[1,1];function r(e,t,n,a){var i=e.children;if(e.x=t,e.y=e.depth*a,e.dx=n,e.dy=a,i&&(o=i.length)){var o,l,s,c=-1;for(n=e.value?n/e.value:0;++cl&&(l=n),o.push(n)}for(r=0;ra&&(n=r,a=t);return n}function xn(e){return e.reduce(bn,0)}function bn(e,t){return e+t[1]}function _n(e,t){return wn(e,Math.ceil(Math.log(t.length)/Math.LN2+1))}function wn(e,t){for(var r=-1,n=+e[0],a=(e[1]-n)/t,i=[];++r<=t;)i[r]=a*r+n;return i}function Tn(e){return[i.min(e),i.max(e)]}function Mn(e,t){return e.value-t.value}function kn(e,t){var r=e._pack_next;e._pack_next=t,t._pack_prev=e,t._pack_next=r,r._pack_prev=t}function An(e,t){e._pack_next=t,t._pack_prev=e}function Ln(e,t){var r=t.x-e.x,n=t.y-e.y,a=e.r+t.r;return.999*a*a>r*r+n*n}function Sn(e){if((t=e.children)&&(s=t.length)){var t,r,n,a,i,o,l,s,c=1/0,u=-1/0,f=1/0,d=-1/0;if(t.forEach(Cn),(r=t[0]).x=-r.r,r.y=0,x(r),s>1&&((n=t[1]).x=n.r,n.y=0,x(n),s>2))for(Pn(r,n,a=t[2]),x(a),kn(r,a),r._pack_prev=a,kn(a,n),n=r._pack_next,i=3;i0)for(o=-1;++o=f[0]&&s<=f[1]&&((l=c[i.bisect(d,s,1,p)-1]).y+=v,l.push(a[o]));return c}return a.value=function(e){return arguments.length?(t=e,a):t},a.range=function(e){return arguments.length?(r=gt(e),a):r},a.bins=function(e){return arguments.length?(n="number"==typeof e?function(t){return wn(t,e)}:gt(e),a):n},a.frequency=function(t){return arguments.length?(e=!!t,a):e},a},i.layout.pack=function(){var e,t=i.layout.hierarchy().sort(Mn),r=0,n=[1,1];function a(a,i){var o=t.call(this,a,i),l=o[0],s=n[0],c=n[1],u=null==e?Math.sqrt:"function"==typeof e?e:function(){return e};if(l.x=l.y=0,an(l,(function(e){e.r=+u(e.value)})),an(l,Sn),r){var f=r*(e?1:Math.max(2*l.r/s,2*l.r/c))/2;an(l,(function(e){e.r+=f})),an(l,Sn),an(l,(function(e){e.r-=f}))}return Dn(l,s/2,c/2,e?1:1/Math.max(2*l.r/s,2*l.r/c)),o}return a.size=function(e){return arguments.length?(n=e,a):n},a.radius=function(t){return arguments.length?(e=null==t||"function"==typeof t?t:+t,a):e},a.padding=function(e){return arguments.length?(r=+e,a):r},rn(a,t)},i.layout.tree=function(){var e=i.layout.hierarchy().sort(null).value(null),t=zn,r=[1,1],n=null;function a(a,i){var c=e.call(this,a,i),u=c[0],f=function(e){for(var t,r={A:null,children:[e]},n=[r];null!=(t=n.pop());)for(var a,i=t.children,o=0,l=i.length;oh.x&&(h=e),e.depth>p.depth&&(p=e)}));var v=t(d,h)/2-d.x,g=r[0]/(h.x+t(h,d)/2+v),m=r[1]/(p.depth||1);nn(u,(function(e){e.x=(e.x+v)*g,e.y=e.depth*m}))}return c}function o(e){var r=e.children,n=e.parent.children,a=e.i?n[e.i-1]:null;if(r.length){!function(e){for(var t,r=0,n=0,a=e.children,i=a.length;--i>=0;)(t=a[i]).z+=r,t.m+=r,r+=t.s+(n+=t.c)}(e);var i=(r[0].z+r[r.length-1].z)/2;a?(e.z=a.z+t(e._,a._),e.m=e.z-i):e.z=i}else a&&(e.z=a.z+t(e._,a._));e.parent.A=function(e,r,n){if(r){for(var a,i=e,o=e,l=r,s=i.parent.children[0],c=i.m,u=o.m,f=l.m,d=s.m;l=En(l),i=In(i),l&&i;)s=In(s),(o=En(o)).a=e,(a=l.z+f-i.z-c+t(l._,i._))>0&&(Rn(Nn(l,e,n),e,a),c+=a,u+=a),f+=l.m,c+=i.m,d+=s.m,u+=o.m;l&&!En(o)&&(o.t=l,o.m+=f-u),i&&!In(s)&&(s.t=i,s.m+=c-d,n=e)}return n}(e,a,e.parent.A||n[0])}function l(e){e._.x=e.z+e.parent.m,e.m+=e.parent.m}function s(e){e.x*=r[0],e.y=e.depth*r[1]}return a.separation=function(e){return arguments.length?(t=e,a):t},a.size=function(e){return arguments.length?(n=null==(r=e)?s:null,a):n?null:r},a.nodeSize=function(e){return arguments.length?(n=null==(r=e)?null:s,a):n?r:null},rn(a,e)},i.layout.cluster=function(){var e=i.layout.hierarchy().sort(null).value(null),t=zn,r=[1,1],n=!1;function a(a,o){var l,s=e.call(this,a,o),c=s[0],u=0;an(c,(function(e){var r=e.children;r&&r.length?(e.x=function(e){return e.reduce((function(e,t){return e+t.x}),0)/e.length}(r),e.y=function(e){return 1+i.max(e,(function(e){return e.y}))}(r)):(e.x=l?u+=t(e,l):0,e.y=0,l=e)}));var f=Fn(c),d=Hn(c),h=f.x-t(f,d)/2,p=d.x+t(d,f)/2;return an(c,n?function(e){e.x=(e.x-c.x)*r[0],e.y=(c.y-e.y)*r[1]}:function(e){e.x=(e.x-h)/(p-h)*r[0],e.y=(1-(c.y?e.y/c.y:1))*r[1]}),s}return a.separation=function(e){return arguments.length?(t=e,a):t},a.size=function(e){return arguments.length?(n=null==(r=e),a):n?null:r},a.nodeSize=function(e){return arguments.length?(n=null!=(r=e),a):n?r:null},rn(a,e)},i.layout.treemap=function(){var e,t=i.layout.hierarchy(),r=Math.round,n=[1,1],a=null,o=Bn,l=!1,s="squarify",c=.5*(1+Math.sqrt(5));function u(e,t){for(var r,n,a=-1,i=e.length;++a0;)l.push(r=c[a-1]),l.area+=r.area,"squarify"!==s||(n=h(l,v))<=d?(c.pop(),d=n):(l.area-=l.pop().area,p(l,v,i,!1),v=Math.min(i.dx,i.dy),l.length=l.area=0,d=1/0);l.length&&(p(l,v,i,!0),l.length=l.area=0),t.forEach(f)}}function d(e){var t=e.children;if(t&&t.length){var r,n=o(e),a=t.slice(),i=[];for(u(a,n.dx*n.dy/e.value),i.area=0;r=a.pop();)i.push(r),i.area+=r.area,null!=r.z&&(p(i,r.z?n.dx:n.dy,n,!a.length),i.length=i.area=0);t.forEach(d)}}function h(e,t){for(var r,n=e.area,a=0,i=1/0,o=-1,l=e.length;++oa&&(a=r));return t*=t,(n*=n)?Math.max(t*a*c/n,n/(t*i*c)):1/0}function p(e,t,n,a){var i,o=-1,l=e.length,s=n.x,c=n.y,u=t?r(e.area/t):0;if(t==n.dx){for((a||u>n.dy)&&(u=n.dy);++on.dx)&&(u=n.dx);++o1);return e+t*r*Math.sqrt(-2*Math.log(a)/a)}},logNormal:function(){var e=i.random.normal.apply(i,arguments);return function(){return Math.exp(e())}},bates:function(e){var t=i.random.irwinHall(e);return function(){return t()/e}},irwinHall:function(e){return function(){for(var t=0,r=0;r2?Wn:Un,s=n?qr:Ur;return a=o(e,t,s,r),i=o(t,e,s,Tr),l}function l(e){return a(e)}return l.invert=function(e){return i(e)},l.domain=function(t){return arguments.length?(e=t.map(Number),o()):e},l.range=function(e){return arguments.length?(t=e,o()):t},l.rangeRound=function(e){return l.range(e).interpolate(Nr)},l.clamp=function(e){return arguments.length?(n=e,o()):n},l.interpolate=function(e){return arguments.length?(r=e,o()):r},l.ticks=function(t){return $n(e,t)},l.tickFormat=function(t,r){return d3_scale_linearTickFormat(e,t,r)},l.nice=function(t){return Kn(e,t),o()},l.copy=function(){return Jn(e,t,r,n)},o()}function Xn(e,t){return i.rebind(e,t,"range","rangeRound","interpolate","clamp")}function Kn(e,t){return qn(e,Gn(Qn(e,t)[2])),qn(e,Gn(Qn(e,t)[2])),e}function Qn(e,t){null==t&&(t=10);var r=Yn(e),n=r[1]-r[0],a=Math.pow(10,Math.floor(Math.log(n/t)/Math.LN10)),i=t/n*a;return i<=.15?a*=10:i<=.35?a*=5:i<=.75&&(a*=2),r[0]=Math.ceil(r[0]/a)*a,r[1]=Math.floor(r[1]/a)*a+.5*a,r[2]=a,r}function $n(e,t){return i.range.apply(i,Qn(e,t))}function ea(e,t,r,n){function a(e){return(r?Math.log(e<0?0:e):-Math.log(e>0?0:-e))/Math.log(t)}function i(e){return r?Math.pow(t,e):-Math.pow(t,-e)}function o(t){return e(a(t))}return o.invert=function(t){return i(e.invert(t))},o.domain=function(t){return arguments.length?(r=t[0]>=0,e.domain((n=t.map(Number)).map(a)),o):n},o.base=function(r){return arguments.length?(t=+r,e.domain(n.map(a)),o):t},o.nice=function(){var t=qn(n.map(a),r?Math:ta);return e.domain(t),n=t.map(i),o},o.ticks=function(){var e=Yn(n),o=[],l=e[0],s=e[1],c=Math.floor(a(l)),u=Math.ceil(a(s)),f=t%1?2:t;if(isFinite(u-c)){if(r){for(;c0;d--)o.push(i(c)*d);for(c=0;o[c]s;u--);o=o.slice(c,u)}return o},o.copy=function(){return ea(e.copy(),t,r,n)},Xn(o,e)}i.scale.linear=function(){return Jn([0,1],[0,1],Tr,!1)},i.scale.log=function(){return ea(i.scale.linear().domain([0,1]),10,!0,[1,10])};var ta={floor:function(e){return-Math.ceil(-e)},ceil:function(e){return-Math.floor(-e)}};function ra(e,t,r){var n=na(t),a=na(1/t);function i(t){return e(n(t))}return i.invert=function(t){return a(e.invert(t))},i.domain=function(t){return arguments.length?(e.domain((r=t.map(Number)).map(n)),i):r},i.ticks=function(e){return $n(r,e)},i.tickFormat=function(e,t){return d3_scale_linearTickFormat(r,e,t)},i.nice=function(e){return i.domain(Kn(r,e))},i.exponent=function(o){return arguments.length?(n=na(t=o),a=na(1/t),e.domain(r.map(n)),i):t},i.copy=function(){return ra(e.copy(),t,r)},Xn(i,e)}function na(e){return function(t){return t<0?-Math.pow(-t,e):Math.pow(t,e)}}function aa(e,t){var r,n,a;function o(a){return n[((r.get(a)||("range"===t.t?r.set(a,e.push(a)):NaN))-1)%n.length]}function l(t,r){return i.range(e.length).map((function(e){return t+r*e}))}return o.domain=function(n){if(!arguments.length)return e;e=[],r=new M;for(var a,i=-1,l=n.length;++i0?r[n-1]:e[0],nf?0:1;if(c=Pe)return s(c,h)+(l?s(l,1-h):"")+"Z";var p,v,g,m,y,x,b,_,w,T,M,k,A=0,L=0,S=[];if((m=(+o.apply(this,arguments)||0)/2)&&(g=n===pa?Math.sqrt(l*l+c*c):+n.apply(this,arguments),h||(L*=-1),c&&(L=Re(g/c*Math.sin(m))),l&&(A=Re(g/l*Math.sin(m)))),c){y=c*Math.cos(u+L),x=c*Math.sin(u+L),b=c*Math.cos(f-L),_=c*Math.sin(f-L);var C=Math.abs(f-u-2*L)<=Oe?0:1;if(L&&ba(y,x,b,_)===h^C){var O=(u+f)/2;y=c*Math.cos(O),x=c*Math.sin(O),b=_=null}}else y=x=0;if(l){w=l*Math.cos(f-A),T=l*Math.sin(f-A),M=l*Math.cos(u+A),k=l*Math.sin(u+A);var D=Math.abs(u-f+2*A)<=Oe?0:1;if(A&&ba(w,T,M,k)===1-h^D){var P=(u+f)/2;w=l*Math.cos(P),T=l*Math.sin(P),M=k=null}}else w=T=0;if(d>Se&&(p=Math.min(Math.abs(c-l)/2,+r.apply(this,arguments)))>.001){v=l0?0:1}function _a(e,t,r,n,a){var i=e[0]-t[0],o=e[1]-t[1],l=(a?n:-n)/Math.sqrt(i*i+o*o),s=l*o,c=-l*i,u=e[0]+s,f=e[1]+c,d=t[0]+s,h=t[1]+c,p=(u+d)/2,v=(f+h)/2,g=d-u,m=h-f,y=g*g+m*m,x=r-n,b=u*h-d*f,_=(m<0?-1:1)*Math.sqrt(Math.max(0,x*x*y-b*b)),w=(b*m-g*_)/y,T=(-b*g-m*_)/y,M=(b*m+g*_)/y,k=(-b*g+m*_)/y,A=w-p,L=T-v,S=M-p,C=k-v;return A*A+L*L>S*S+C*C&&(w=M,T=k),[[w-s,T-c],[w*r/x,T*r/x]]}function wa(){return!0}function Ta(e){var t=St,r=Ct,n=wa,a=ka,i=a.key,o=.7;function l(i){var l,s=[],c=[],u=-1,f=i.length,d=gt(t),h=gt(r);function p(){s.push("M",a(e(c),o))}for(;++u1&&a.push("H",n[0]),a.join("")},"step-before":La,"step-after":Sa,basis:Da,"basis-open":function(e){if(e.length<4)return ka(e);for(var t,r=[],n=-1,a=e.length,i=[0],o=[0];++n<3;)t=e[n],i.push(t[0]),o.push(t[1]);for(r.push(Pa(Ea,i)+","+Pa(Ea,o)),--n;++n9&&(a=3*t/Math.sqrt(a),o[l]=a*r,o[l+1]=a*n);for(l=-1;++l<=s;)a=(e[Math.min(s,l+1)][0]-e[Math.max(0,l-1)][0])/(6*(1+o[l]*o[l])),i.push([a||0,o[l]*a||0]);return i}(e))}});function ka(e){return e.length>1?e.join("L"):e+"Z"}function Aa(e){return e.join("L")+"Z"}function La(e){for(var t=0,r=e.length,n=e[0],a=[n[0],",",n[1]];++t1){l=t[1],i=e[s],s++,n+="C"+(a[0]+o[0])+","+(a[1]+o[1])+","+(i[0]-l[0])+","+(i[1]-l[1])+","+i[0]+","+i[1];for(var c=2;cOe)+",1 "+t}function s(e,t,r,n){return"Q 0,0 "+n}return i.radius=function(e){return arguments.length?(r=gt(e),i):r},i.source=function(t){return arguments.length?(e=gt(t),i):e},i.target=function(e){return arguments.length?(t=gt(e),i):t},i.startAngle=function(e){return arguments.length?(n=gt(e),i):n},i.endAngle=function(e){return arguments.length?(a=gt(e),i):a},i},i.svg.diagonal=function(){var e=Ba,t=ja,r=Va;function n(n,a){var i=e.call(this,n,a),o=t.call(this,n,a),l=(i.y+o.y)/2,s=[i,{x:i.x,y:l},{x:o.x,y:l},o];return"M"+(s=s.map(r))[0]+"C"+s[1]+" "+s[2]+" "+s[3]}return n.source=function(t){return arguments.length?(e=gt(t),n):e},n.target=function(e){return arguments.length?(t=gt(e),n):t},n.projection=function(e){return arguments.length?(r=e,n):r},n},i.svg.diagonal.radial=function(){var e=i.svg.diagonal(),t=Va,r=e.projection;return e.projection=function(e){return arguments.length?r(function(e){return function(){var t=e.apply(this,arguments),r=t[0],n=t[1]-ze;return[r*Math.cos(n),r*Math.sin(n)]}}(t=e)):t},e},i.svg.symbol=function(){var e=qa,t=Ua;function r(r,n){return(Za.get(e.call(this,r,n))||Ga)(t.call(this,r,n))}return r.type=function(t){return arguments.length?(e=gt(t),r):e},r.size=function(e){return arguments.length?(t=gt(e),r):t},r};var Za=i.map({circle:Ga,cross:function(e){var t=Math.sqrt(e/5)/2;return"M"+-3*t+","+-t+"H"+-t+"V"+-3*t+"H"+t+"V"+-t+"H"+3*t+"V"+t+"H"+t+"V"+3*t+"H"+-t+"V"+t+"H"+-3*t+"Z"},diamond:function(e){var t=Math.sqrt(e/(2*Ja)),r=t*Ja;return"M0,"+-t+"L"+r+",0 0,"+t+" "+-r+",0Z"},square:function(e){var t=Math.sqrt(e)/2;return"M"+-t+","+-t+"L"+t+","+-t+" "+t+","+t+" "+-t+","+t+"Z"},"triangle-down":function(e){var t=Math.sqrt(e/Wa),r=t*Wa/2;return"M0,"+r+"L"+t+","+-r+" "+-t+","+-r+"Z"},"triangle-up":function(e){var t=Math.sqrt(e/Wa),r=t*Wa/2;return"M0,"+-r+"L"+t+","+r+" "+-t+","+r+"Z"}});i.svg.symbolTypes=Za.keys();var Wa=Math.sqrt(3),Ja=Math.tan(30*Ie);K.transition=function(e){for(var t,r,n=$a||++ri,a=ii(e),i=[],o=ei||{time:Date.now(),ease:Pr,delay:0,duration:250},l=-1,s=this.length;++l0;)c[--d].call(e,o);if(i>=1)return f.event&&f.event.end.call(e,e.__data__,t),--u.count?delete u[n]:delete e[r],1}f||(i=a.time,o=Mt((function(e){var t=f.delay;if(o.t=t+i,t<=e)return d(e-t);o.c=d}),0,i),f=u[n]={tween:new M,time:i,timer:o,delay:a.delay,duration:a.duration,ease:a.ease,index:t},a=null,++u.count)}ti.call=K.call,ti.empty=K.empty,ti.node=K.node,ti.size=K.size,i.transition=function(e,t){return e&&e.transition?$a?e.transition(t):e:i.selection().transition(e)},i.transition.prototype=ti,ti.select=function(e){var t,r,n,a=this.id,i=this.namespace,o=[];e=Q(e);for(var l=-1,s=this.length;++lrect,.s>rect").attr("width",o[1]-o[0])}function v(e){e.select(".extent").attr("y",l[0]),e.selectAll(".extent,.e>rect,.w>rect").attr("height",l[1]-l[0])}function g(){var f,g,m=this,y=i.select(i.event.target),x=r.of(m,arguments),b=i.select(m),_=y.datum(),w=!/^(n|s)$/.test(_)&&n,T=!/^(e|w)$/.test(_)&&a,M=y.classed("extent"),k=Me(m),A=i.mouse(m),L=i.select(u(m)).on("keydown.brush",(function(){32==i.event.keyCode&&(M||(f=null,A[0]-=o[1],A[1]-=l[1],M=2),Y())})).on("keyup.brush",(function(){32==i.event.keyCode&&2==M&&(A[0]+=o[1],A[1]+=l[1],M=0,Y())}));if(i.event.changedTouches?L.on("touchmove.brush",O).on("touchend.brush",P):L.on("mousemove.brush",O).on("mouseup.brush",P),b.interrupt().selectAll("*").interrupt(),M)A[0]=o[0]-A[0],A[1]=l[0]-A[1];else if(_){var S=+/w$/.test(_),C=+/^n/.test(_);g=[o[1-S]-A[0],l[1-C]-A[1]],A[0]=o[S],A[1]=l[C]}else i.event.altKey&&(f=A.slice());function O(){var e=i.mouse(m),t=!1;g&&(e[0]+=g[0],e[1]+=g[1]),M||(i.event.altKey?(f||(f=[(o[0]+o[1])/2,(l[0]+l[1])/2]),A[0]=o[+(e[0]>2],i+=n[(3&r[t])<<4|r[t+1]>>4],i+=n[(15&r[t+1])<<2|r[t+2]>>6],i+=n[63&r[t+2]];return a%3==2?i=i.substring(0,i.length-1)+"=":a%3==1&&(i=i.substring(0,i.length-2)+"=="),i},l=function(e){var t,r,n,i,o,l=.75*e.length,s=e.length,c=0;"="===e[e.length-1]&&(l--,"="===e[e.length-2]&&l--);var u=new ArrayBuffer(l),f=new Uint8Array(u);for(t=0;t>4,f[c++]=(15&n)<<4|i>>2,f[c++]=(3&i)<<6|63&o;return u}},1070:function(e){"use strict";e.exports=function(e){var t=[];return t.toString=function(){return this.map((function(t){var r="",n=void 0!==t[5];return t[4]&&(r+="@supports (".concat(t[4],") {")),t[2]&&(r+="@media ".concat(t[2]," {")),n&&(r+="@layer".concat(t[5].length>0?" ".concat(t[5]):""," {")),r+=e(t),n&&(r+="}"),t[2]&&(r+="}"),t[4]&&(r+="}"),r})).join("")},t.i=function(e,r,n,a,i){"string"==typeof e&&(e=[[null,e,void 0]]);var o={};if(n)for(var l=0;l0?" ".concat(u[5]):""," {").concat(u[1],"}")),u[5]=i),r&&(u[2]?(u[1]="@media ".concat(u[2]," {").concat(u[1],"}"),u[2]=r):u[2]=r),a&&(u[4]?(u[1]="@supports (".concat(u[4],") {").concat(u[1],"}"),u[4]=a):u[4]="".concat(a)),t.push(u))}},t}},2133:function(e){"use strict";e.exports=function(e,t){return t||(t={}),e?(e=String(e.__esModule?e.default:e),/^['"].*['"]$/.test(e)&&(e=e.slice(1,-1)),t.hash&&(e+=t.hash),/["'() \t\n]|(%20)/.test(e)||t.needQuotes?'"'.concat(e.replace(/"/g,'\\"').replace(/\n/g,"\\n"),'"'):e):e}},2413:function(e){"use strict";e.exports=function(e){return e[1]}},6464:function(e,t,r){"use strict";function n(e,t){if((r=(e=t?e.toExponential(t-1):e.toExponential()).indexOf("e"))<0)return null;var r,n=e.slice(0,r);return[n.length>1?n[0]+n.slice(2):n,+e.slice(r+1)]}r.d(t,{GP:function(){return d},OE:function(){return v}});var a,i=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function o(e){if(!(t=i.exec(e)))throw new Error("invalid format: "+e);var t;return new l({fill:t[1],align:t[2],sign:t[3],symbol:t[4],zero:t[5],width:t[6],comma:t[7],precision:t[8]&&t[8].slice(1),trim:t[9],type:t[10]})}function l(e){this.fill=void 0===e.fill?" ":e.fill+"",this.align=void 0===e.align?">":e.align+"",this.sign=void 0===e.sign?"-":e.sign+"",this.symbol=void 0===e.symbol?"":e.symbol+"",this.zero=!!e.zero,this.width=void 0===e.width?void 0:+e.width,this.comma=!!e.comma,this.precision=void 0===e.precision?void 0:+e.precision,this.trim=!!e.trim,this.type=void 0===e.type?"":e.type+""}function s(e,t){var r=n(e,t);if(!r)return e+"";var a=r[0],i=r[1];return i<0?"0."+new Array(-i).join("0")+a:a.length>i+1?a.slice(0,i+1)+"."+a.slice(i+1):a+new Array(i-a.length+2).join("0")}o.prototype=l.prototype,l.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(void 0===this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(void 0===this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};var c={"%":function(e,t){return(100*e).toFixed(t)},b:function(e){return Math.round(e).toString(2)},c:function(e){return e+""},d:function(e){return Math.abs(e=Math.round(e))>=1e21?e.toLocaleString("en").replace(/,/g,""):e.toString(10)},e:function(e,t){return e.toExponential(t)},f:function(e,t){return e.toFixed(t)},g:function(e,t){return e.toPrecision(t)},o:function(e){return Math.round(e).toString(8)},p:function(e,t){return s(100*e,t)},r:s,s:function(e,t){var r=n(e,t);if(!r)return e+"";var i=r[0],o=r[1],l=o-(a=3*Math.max(-8,Math.min(8,Math.floor(o/3))))+1,s=i.length;return l===s?i:l>s?i+new Array(l-s+1).join("0"):l>0?i.slice(0,l)+"."+i.slice(l):"0."+new Array(1-l).join("0")+n(e,Math.max(0,t+l-1))[0]},X:function(e){return Math.round(e).toString(16).toUpperCase()},x:function(e){return Math.round(e).toString(16)}};function u(e){return e}var f,d,h=Array.prototype.map,p=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function v(e){var t,r,i=void 0===e.grouping||void 0===e.thousands?u:(t=h.call(e.grouping,Number),r=e.thousands+"",function(e,n){for(var a=e.length,i=[],o=0,l=t[0],s=0;a>0&&l>0&&(s+l+1>n&&(l=Math.max(1,n-s)),i.push(e.substring(a-=l,a+l)),!((s+=l+1)>n));)l=t[o=(o+1)%t.length];return i.reverse().join(r)}),l=void 0===e.currency?"":e.currency[0]+"",s=void 0===e.currency?"":e.currency[1]+"",f=void 0===e.decimal?".":e.decimal+"",d=void 0===e.numerals?u:function(e){return function(t){return t.replace(/[0-9]/g,(function(t){return e[+t]}))}}(h.call(e.numerals,String)),v=void 0===e.percent?"%":e.percent+"",g=void 0===e.minus?"-":e.minus+"",m=void 0===e.nan?"NaN":e.nan+"";function y(e){var t=(e=o(e)).fill,r=e.align,n=e.sign,u=e.symbol,h=e.zero,y=e.width,x=e.comma,b=e.precision,_=e.trim,w=e.type;"n"===w?(x=!0,w="g"):c[w]||(void 0===b&&(b=12),_=!0,w="g"),(h||"0"===t&&"="===r)&&(h=!0,t="0",r="=");var T="$"===u?l:"#"===u&&/[boxX]/.test(w)?"0"+w.toLowerCase():"",M="$"===u?s:/[%p]/.test(w)?v:"",k=c[w],A=/[defgprs%]/.test(w);function L(e){var o,l,s,c=T,u=M;if("c"===w)u=k(e)+u,e="";else{var v=(e=+e)<0||1/e<0;if(e=isNaN(e)?m:k(Math.abs(e),b),_&&(e=function(e){e:for(var t,r=e.length,n=1,a=-1;n0&&(a=0)}return a>0?e.slice(0,a)+e.slice(t+1):e}(e)),v&&0==+e&&"+"!==n&&(v=!1),c=(v?"("===n?n:g:"-"===n||"("===n?"":n)+c,u=("s"===w?p[8+a/3]:"")+u+(v&&"("===n?")":""),A)for(o=-1,l=e.length;++o(s=e.charCodeAt(o))||s>57){u=(46===s?f+e.slice(o+1):e.slice(o))+u,e=e.slice(0,o);break}}x&&!h&&(e=i(e,1/0));var L=c.length+e.length+u.length,S=L>1)+c+e+u+S.slice(L);break;default:e=S+c+e+u}return d(e)}return b=void 0===b?6:/[gprs]/.test(w)?Math.max(1,Math.min(21,b)):Math.max(0,Math.min(20,b)),L.toString=function(){return e+""},L}return{format:y,formatPrefix:function(e,t){var r,a=y(((e=o(e)).type="f",e)),i=3*Math.max(-8,Math.min(8,Math.floor((r=t,((r=n(Math.abs(r)))?r[1]:NaN)/3)))),l=Math.pow(10,-i),s=p[8+i/3];return function(e){return a(l*e)+s}}}}f=v({decimal:".",thousands:",",grouping:[3],currency:["$",""],minus:"-"}),d=f.format,f.formatPrefix},2696:function(e,t,r){"use strict";r.d(t,{DC:function(){return p},de:function(){return d},aL:function(){return v}});var n=r(1681),a=r(2543),i=r(5735),o=r(7265),l=r(9830),s=r(9764);function c(e){if(0<=e.y&&e.y<100){var t=new Date(-1,e.m,e.d,e.H,e.M,e.S,e.L);return t.setFullYear(e.y),t}return new Date(e.y,e.m,e.d,e.H,e.M,e.S,e.L)}function u(e){if(0<=e.y&&e.y<100){var t=new Date(Date.UTC(-1,e.m,e.d,e.H,e.M,e.S,e.L));return t.setUTCFullYear(e.y),t}return new Date(Date.UTC(e.y,e.m,e.d,e.H,e.M,e.S,e.L))}function f(e,t,r){return{y:e,m:t,d:r,H:0,M:0,S:0,L:0}}function d(e){var t=e.dateTime,r=e.date,l=e.time,s=e.periods,d=e.days,h=e.shortDays,p=e.months,v=e.shortMonths,m=w(s),y=T(s),x=w(d),b=T(d),_=w(h),Le=T(h),Se=w(p),Ce=T(p),Oe=w(v),De=T(v),Pe={a:function(e){return h[e.getDay()]},A:function(e){return d[e.getDay()]},b:function(e){return v[e.getMonth()]},B:function(e){return p[e.getMonth()]},c:null,d:U,e:U,f:J,H:q,I:G,j:Z,L:W,m:X,M:K,p:function(e){return s[+(e.getHours()>=12)]},q:function(e){return 1+~~(e.getMonth()/3)},Q:ke,s:Ae,S:Q,u:$,U:ee,V:te,w:re,W:ne,x:null,X:null,y:ae,Y:ie,Z:oe,"%":Me},ze={a:function(e){return h[e.getUTCDay()]},A:function(e){return d[e.getUTCDay()]},b:function(e){return v[e.getUTCMonth()]},B:function(e){return p[e.getUTCMonth()]},c:null,d:le,e:le,f:de,H:se,I:ce,j:ue,L:fe,m:he,M:pe,p:function(e){return s[+(e.getUTCHours()>=12)]},q:function(e){return 1+~~(e.getUTCMonth()/3)},Q:ke,s:Ae,S:ve,u:ge,U:me,V:ye,w:xe,W:be,x:null,X:null,y:_e,Y:we,Z:Te,"%":Me},Ie={a:function(e,t,r){var n=_.exec(t.slice(r));return n?(e.w=Le[n[0].toLowerCase()],r+n[0].length):-1},A:function(e,t,r){var n=x.exec(t.slice(r));return n?(e.w=b[n[0].toLowerCase()],r+n[0].length):-1},b:function(e,t,r){var n=Oe.exec(t.slice(r));return n?(e.m=De[n[0].toLowerCase()],r+n[0].length):-1},B:function(e,t,r){var n=Se.exec(t.slice(r));return n?(e.m=Ce[n[0].toLowerCase()],r+n[0].length):-1},c:function(e,r,n){return Ne(e,t,r,n)},d:I,e:I,f:B,H:R,I:R,j:E,L:H,m:z,M:N,p:function(e,t,r){var n=m.exec(t.slice(r));return n?(e.p=y[n[0].toLowerCase()],r+n[0].length):-1},q:P,Q:Y,s:V,S:F,u:k,U:A,V:L,w:M,W:S,x:function(e,t,n){return Ne(e,r,t,n)},X:function(e,t,r){return Ne(e,l,t,r)},y:O,Y:C,Z:D,"%":j};function Ee(e,t){return function(r){var n,a,i,o=[],l=-1,s=0,c=e.length;for(r instanceof Date||(r=new Date(+r));++l53)return null;"w"in d||(d.w=1),"Z"in d?(s=(l=u(f(d.y,0,1))).getUTCDay(),l=s>4||0===s?n.rt.ceil(l):(0,n.rt)(l),l=a.A.offset(l,7*(d.V-1)),d.y=l.getUTCFullYear(),d.m=l.getUTCMonth(),d.d=l.getUTCDate()+(d.w+6)%7):(s=(l=c(f(d.y,0,1))).getDay(),l=s>4||0===s?i.By.ceil(l):(0,i.By)(l),l=o.A.offset(l,7*(d.V-1)),d.y=l.getFullYear(),d.m=l.getMonth(),d.d=l.getDate()+(d.w+6)%7)}else("W"in d||"U"in d)&&("w"in d||(d.w="u"in d?d.u%7:"W"in d?1:0),s="Z"in d?u(f(d.y,0,1)).getUTCDay():c(f(d.y,0,1)).getDay(),d.m=0,d.d="W"in d?(d.w+6)%7+7*d.W-(s+5)%7:d.w+7*d.U-(s+6)%7);return"Z"in d?(d.H+=d.Z/100|0,d.M+=d.Z%100,u(d)):c(d)}}function Ne(e,t,r,n){for(var a,i,o=0,l=t.length,s=r.length;o=s)return-1;if(37===(a=t.charCodeAt(o++))){if(a=t.charAt(o++),!(i=Ie[a in g?t.charAt(o++):a])||(n=i(e,r,n))<0)return-1}else if(a!=r.charCodeAt(n++))return-1}return n}return Pe.x=Ee(r,Pe),Pe.X=Ee(l,Pe),Pe.c=Ee(t,Pe),ze.x=Ee(r,ze),ze.X=Ee(l,ze),ze.c=Ee(t,ze),{format:function(e){var t=Ee(e+="",Pe);return t.toString=function(){return e},t},parse:function(e){var t=Re(e+="",!1);return t.toString=function(){return e},t},utcFormat:function(e){var t=Ee(e+="",ze);return t.toString=function(){return e},t},utcParse:function(e){var t=Re(e+="",!0);return t.toString=function(){return e},t}}}var h,p,v,g={"-":"",_:" ",0:"0"},m=/^\s*\d+/,y=/^%/,x=/[\\^$*+?|[\]().{}]/g;function b(e,t,r){var n=e<0?"-":"",a=(n?-e:e)+"",i=a.length;return n+(i68?1900:2e3),r+n[0].length):-1}function D(e,t,r){var n=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(t.slice(r,r+6));return n?(e.Z=n[1]?0:-(n[2]+(n[3]||"00")),r+n[0].length):-1}function P(e,t,r){var n=m.exec(t.slice(r,r+1));return n?(e.q=3*n[0]-3,r+n[0].length):-1}function z(e,t,r){var n=m.exec(t.slice(r,r+2));return n?(e.m=n[0]-1,r+n[0].length):-1}function I(e,t,r){var n=m.exec(t.slice(r,r+2));return n?(e.d=+n[0],r+n[0].length):-1}function E(e,t,r){var n=m.exec(t.slice(r,r+3));return n?(e.m=0,e.d=+n[0],r+n[0].length):-1}function R(e,t,r){var n=m.exec(t.slice(r,r+2));return n?(e.H=+n[0],r+n[0].length):-1}function N(e,t,r){var n=m.exec(t.slice(r,r+2));return n?(e.M=+n[0],r+n[0].length):-1}function F(e,t,r){var n=m.exec(t.slice(r,r+2));return n?(e.S=+n[0],r+n[0].length):-1}function H(e,t,r){var n=m.exec(t.slice(r,r+3));return n?(e.L=+n[0],r+n[0].length):-1}function B(e,t,r){var n=m.exec(t.slice(r,r+6));return n?(e.L=Math.floor(n[0]/1e3),r+n[0].length):-1}function j(e,t,r){var n=y.exec(t.slice(r,r+1));return n?r+n[0].length:-1}function Y(e,t,r){var n=m.exec(t.slice(r));return n?(e.Q=+n[0],r+n[0].length):-1}function V(e,t,r){var n=m.exec(t.slice(r));return n?(e.s=+n[0],r+n[0].length):-1}function U(e,t){return b(e.getDate(),t,2)}function q(e,t){return b(e.getHours(),t,2)}function G(e,t){return b(e.getHours()%12||12,t,2)}function Z(e,t){return b(1+o.A.count((0,l.A)(e),e),t,3)}function W(e,t){return b(e.getMilliseconds(),t,3)}function J(e,t){return W(e,t)+"000"}function X(e,t){return b(e.getMonth()+1,t,2)}function K(e,t){return b(e.getMinutes(),t,2)}function Q(e,t){return b(e.getSeconds(),t,2)}function $(e){var t=e.getDay();return 0===t?7:t}function ee(e,t){return b(i.fz.count((0,l.A)(e)-1,e),t,2)}function te(e,t){var r=e.getDay();return e=r>=4||0===r?(0,i.dt)(e):i.dt.ceil(e),b(i.dt.count((0,l.A)(e),e)+(4===(0,l.A)(e).getDay()),t,2)}function re(e){return e.getDay()}function ne(e,t){return b(i.By.count((0,l.A)(e)-1,e),t,2)}function ae(e,t){return b(e.getFullYear()%100,t,2)}function ie(e,t){return b(e.getFullYear()%1e4,t,4)}function oe(e){var t=e.getTimezoneOffset();return(t>0?"-":(t*=-1,"+"))+b(t/60|0,"0",2)+b(t%60,"0",2)}function le(e,t){return b(e.getUTCDate(),t,2)}function se(e,t){return b(e.getUTCHours(),t,2)}function ce(e,t){return b(e.getUTCHours()%12||12,t,2)}function ue(e,t){return b(1+a.A.count((0,s.A)(e),e),t,3)}function fe(e,t){return b(e.getUTCMilliseconds(),t,3)}function de(e,t){return fe(e,t)+"000"}function he(e,t){return b(e.getUTCMonth()+1,t,2)}function pe(e,t){return b(e.getUTCMinutes(),t,2)}function ve(e,t){return b(e.getUTCSeconds(),t,2)}function ge(e){var t=e.getUTCDay();return 0===t?7:t}function me(e,t){return b(n.Hl.count((0,s.A)(e)-1,e),t,2)}function ye(e,t){var r=e.getUTCDay();return e=r>=4||0===r?(0,n.pT)(e):n.pT.ceil(e),b(n.pT.count((0,s.A)(e),e)+(4===(0,s.A)(e).getUTCDay()),t,2)}function xe(e){return e.getUTCDay()}function be(e,t){return b(n.rt.count((0,s.A)(e)-1,e),t,2)}function _e(e,t){return b(e.getUTCFullYear()%100,t,2)}function we(e,t){return b(e.getUTCFullYear()%1e4,t,4)}function Te(){return"+0000"}function Me(){return"%"}function ke(e){return+e}function Ae(e){return Math.floor(+e/1e3)}h=d({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]}),p=h.format,h.parse,v=h.utcFormat,h.utcParse},7265:function(e,t,r){"use strict";r.d(t,{_:function(){return o}});var n=r(3398),a=r(6291),i=(0,n.A)((function(e){e.setHours(0,0,0,0)}),(function(e,t){e.setDate(e.getDate()+t)}),(function(e,t){return(t-e-(t.getTimezoneOffset()-e.getTimezoneOffset())*a.rR)/a.Nm}),(function(e){return e.getDate()-1}));t.A=i;var o=i.range},6291:function(e,t,r){"use strict";r.d(t,{Fq:function(){return l},JJ:function(){return i},Nm:function(){return o},Tt:function(){return n},rR:function(){return a}});var n=1e3,a=6e4,i=36e5,o=864e5,l=6048e5},936:function(e,t,r){"use strict";r.r(t),r.d(t,{timeDay:function(){return m.A},timeDays:function(){return m._},timeFriday:function(){return y.Sh},timeFridays:function(){return y.tz},timeHour:function(){return v},timeHours:function(){return g},timeInterval:function(){return n.A},timeMillisecond:function(){return i},timeMilliseconds:function(){return o},timeMinute:function(){return d},timeMinutes:function(){return h},timeMonday:function(){return y.By},timeMondays:function(){return y.KP},timeMonth:function(){return b},timeMonths:function(){return _},timeSaturday:function(){return y.kS},timeSaturdays:function(){return y.t$},timeSecond:function(){return c},timeSeconds:function(){return u},timeSunday:function(){return y.fz},timeSundays:function(){return y.se},timeThursday:function(){return y.dt},timeThursdays:function(){return y.Q$},timeTuesday:function(){return y.eQ},timeTuesdays:function(){return y.yW},timeWednesday:function(){return y.l3},timeWednesdays:function(){return y.gf},timeWeek:function(){return y.fz},timeWeeks:function(){return y.se},timeYear:function(){return w.A},timeYears:function(){return w.V},utcDay:function(){return C.A},utcDays:function(){return C.o},utcFriday:function(){return O.a1},utcFridays:function(){return O.Zn},utcHour:function(){return L},utcHours:function(){return S},utcMillisecond:function(){return i},utcMilliseconds:function(){return o},utcMinute:function(){return M},utcMinutes:function(){return k},utcMonday:function(){return O.rt},utcMondays:function(){return O.ON},utcMonth:function(){return P},utcMonths:function(){return z},utcSaturday:function(){return O.c8},utcSaturdays:function(){return O.Xo},utcSecond:function(){return c},utcSeconds:function(){return u},utcSunday:function(){return O.Hl},utcSundays:function(){return O.aZ},utcThursday:function(){return O.pT},utcThursdays:function(){return O.wr},utcTuesday:function(){return O.sr},utcTuesdays:function(){return O.jN},utcWednesday:function(){return O.z2},utcWednesdays:function(){return O.G6},utcWeek:function(){return O.Hl},utcWeeks:function(){return O.aZ},utcYear:function(){return I.A},utcYears:function(){return I.j}});var n=r(3398),a=(0,n.A)((function(){}),(function(e,t){e.setTime(+e+t)}),(function(e,t){return t-e}));a.every=function(e){return e=Math.floor(e),isFinite(e)&&e>0?e>1?(0,n.A)((function(t){t.setTime(Math.floor(t/e)*e)}),(function(t,r){t.setTime(+t+r*e)}),(function(t,r){return(r-t)/e})):a:null};var i=a,o=a.range,l=r(6291),s=(0,n.A)((function(e){e.setTime(e-e.getMilliseconds())}),(function(e,t){e.setTime(+e+t*l.Tt)}),(function(e,t){return(t-e)/l.Tt}),(function(e){return e.getUTCSeconds()})),c=s,u=s.range,f=(0,n.A)((function(e){e.setTime(e-e.getMilliseconds()-e.getSeconds()*l.Tt)}),(function(e,t){e.setTime(+e+t*l.rR)}),(function(e,t){return(t-e)/l.rR}),(function(e){return e.getMinutes()})),d=f,h=f.range,p=(0,n.A)((function(e){e.setTime(e-e.getMilliseconds()-e.getSeconds()*l.Tt-e.getMinutes()*l.rR)}),(function(e,t){e.setTime(+e+t*l.JJ)}),(function(e,t){return(t-e)/l.JJ}),(function(e){return e.getHours()})),v=p,g=p.range,m=r(7265),y=r(5735),x=(0,n.A)((function(e){e.setDate(1),e.setHours(0,0,0,0)}),(function(e,t){e.setMonth(e.getMonth()+t)}),(function(e,t){return t.getMonth()-e.getMonth()+12*(t.getFullYear()-e.getFullYear())}),(function(e){return e.getMonth()})),b=x,_=x.range,w=r(9830),T=(0,n.A)((function(e){e.setUTCSeconds(0,0)}),(function(e,t){e.setTime(+e+t*l.rR)}),(function(e,t){return(t-e)/l.rR}),(function(e){return e.getUTCMinutes()})),M=T,k=T.range,A=(0,n.A)((function(e){e.setUTCMinutes(0,0,0)}),(function(e,t){e.setTime(+e+t*l.JJ)}),(function(e,t){return(t-e)/l.JJ}),(function(e){return e.getUTCHours()})),L=A,S=A.range,C=r(2543),O=r(1681),D=(0,n.A)((function(e){e.setUTCDate(1),e.setUTCHours(0,0,0,0)}),(function(e,t){e.setUTCMonth(e.getUTCMonth()+t)}),(function(e,t){return t.getUTCMonth()-e.getUTCMonth()+12*(t.getUTCFullYear()-e.getUTCFullYear())}),(function(e){return e.getUTCMonth()})),P=D,z=D.range,I=r(9764)},3398:function(e,t,r){"use strict";r.d(t,{A:function(){return i}});var n=new Date,a=new Date;function i(e,t,r,o){function l(t){return e(t=0===arguments.length?new Date:new Date(+t)),t}return l.floor=function(t){return e(t=new Date(+t)),t},l.ceil=function(r){return e(r=new Date(r-1)),t(r,1),e(r),r},l.round=function(e){var t=l(e),r=l.ceil(e);return e-t0))return o;do{o.push(i=new Date(+r)),t(r,a),e(r)}while(i=t)for(;e(t),!r(t);)t.setTime(t-1)}),(function(e,n){if(e>=e)if(n<0)for(;++n<=0;)for(;t(e,-1),!r(e););else for(;--n>=0;)for(;t(e,1),!r(e););}))},r&&(l.count=function(t,i){return n.setTime(+t),a.setTime(+i),e(n),e(a),Math.floor(r(n,a))},l.every=function(e){return e=Math.floor(e),isFinite(e)&&e>0?e>1?l.filter(o?function(t){return o(t)%e==0}:function(t){return l.count(0,t)%e==0}):l:null}),l}},2543:function(e,t,r){"use strict";r.d(t,{o:function(){return o}});var n=r(3398),a=r(6291),i=(0,n.A)((function(e){e.setUTCHours(0,0,0,0)}),(function(e,t){e.setUTCDate(e.getUTCDate()+t)}),(function(e,t){return(t-e)/a.Nm}),(function(e){return e.getUTCDate()-1}));t.A=i;var o=i.range},1681:function(e,t,r){"use strict";r.d(t,{G6:function(){return g},Hl:function(){return o},ON:function(){return p},Xo:function(){return x},Zn:function(){return y},a1:function(){return f},aZ:function(){return h},c8:function(){return d},jN:function(){return v},pT:function(){return u},rt:function(){return l},sr:function(){return s},wr:function(){return m},z2:function(){return c}});var n=r(3398),a=r(6291);function i(e){return(0,n.A)((function(t){t.setUTCDate(t.getUTCDate()-(t.getUTCDay()+7-e)%7),t.setUTCHours(0,0,0,0)}),(function(e,t){e.setUTCDate(e.getUTCDate()+7*t)}),(function(e,t){return(t-e)/a.Fq}))}var o=i(0),l=i(1),s=i(2),c=i(3),u=i(4),f=i(5),d=i(6),h=o.range,p=l.range,v=s.range,g=c.range,m=u.range,y=f.range,x=d.range},9764:function(e,t,r){"use strict";r.d(t,{j:function(){return i}});var n=r(3398),a=(0,n.A)((function(e){e.setUTCMonth(0,1),e.setUTCHours(0,0,0,0)}),(function(e,t){e.setUTCFullYear(e.getUTCFullYear()+t)}),(function(e,t){return t.getUTCFullYear()-e.getUTCFullYear()}),(function(e){return e.getUTCFullYear()}));a.every=function(e){return isFinite(e=Math.floor(e))&&e>0?(0,n.A)((function(t){t.setUTCFullYear(Math.floor(t.getUTCFullYear()/e)*e),t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)}),(function(t,r){t.setUTCFullYear(t.getUTCFullYear()+r*e)})):null},t.A=a;var i=a.range},5735:function(e,t,r){"use strict";r.d(t,{By:function(){return l},KP:function(){return p},Q$:function(){return m},Sh:function(){return f},dt:function(){return u},eQ:function(){return s},fz:function(){return o},gf:function(){return g},kS:function(){return d},l3:function(){return c},se:function(){return h},t$:function(){return x},tz:function(){return y},yW:function(){return v}});var n=r(3398),a=r(6291);function i(e){return(0,n.A)((function(t){t.setDate(t.getDate()-(t.getDay()+7-e)%7),t.setHours(0,0,0,0)}),(function(e,t){e.setDate(e.getDate()+7*t)}),(function(e,t){return(t-e-(t.getTimezoneOffset()-e.getTimezoneOffset())*a.rR)/a.Fq}))}var o=i(0),l=i(1),s=i(2),c=i(3),u=i(4),f=i(5),d=i(6),h=o.range,p=l.range,v=s.range,g=c.range,m=u.range,y=f.range,x=d.range},9830:function(e,t,r){"use strict";r.d(t,{V:function(){return i}});var n=r(3398),a=(0,n.A)((function(e){e.setMonth(0,1),e.setHours(0,0,0,0)}),(function(e,t){e.setFullYear(e.getFullYear()+t)}),(function(e,t){return t.getFullYear()-e.getFullYear()}),(function(e){return e.getFullYear()}));a.every=function(e){return isFinite(e=Math.floor(e))&&e>0?(0,n.A)((function(t){t.setFullYear(Math.floor(t.getFullYear()/e)*e),t.setMonth(0,1),t.setHours(0,0,0,0)}),(function(t,r){t.setFullYear(t.getFullYear()+r*e)})):null},t.A=a;var i=a.range},7683:function(e){"use strict";var t,r="object"==typeof Reflect?Reflect:null,n=r&&"function"==typeof r.apply?r.apply:function(e,t,r){return Function.prototype.apply.call(e,t,r)};t=r&&"function"==typeof r.ownKeys?r.ownKeys:Object.getOwnPropertySymbols?function(e){return Object.getOwnPropertyNames(e).concat(Object.getOwnPropertySymbols(e))}:function(e){return Object.getOwnPropertyNames(e)};var a=Number.isNaN||function(e){return e!=e};function i(){i.init.call(this)}e.exports=i,e.exports.once=function(e,t){return new Promise((function(r,n){function a(r){e.removeListener(t,i),n(r)}function i(){"function"==typeof e.removeListener&&e.removeListener("error",a),r([].slice.call(arguments))}v(e,t,i,{once:!0}),"error"!==t&&function(e,t,r){"function"==typeof e.on&&v(e,"error",t,{once:!0})}(e,a)}))},i.EventEmitter=i,i.prototype._events=void 0,i.prototype._eventsCount=0,i.prototype._maxListeners=void 0;var o=10;function l(e){if("function"!=typeof e)throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof e)}function s(e){return void 0===e._maxListeners?i.defaultMaxListeners:e._maxListeners}function c(e,t,r,n){var a,i,o,c;if(l(r),void 0===(i=e._events)?(i=e._events=Object.create(null),e._eventsCount=0):(void 0!==i.newListener&&(e.emit("newListener",t,r.listener?r.listener:r),i=e._events),o=i[t]),void 0===o)o=i[t]=r,++e._eventsCount;else if("function"==typeof o?o=i[t]=n?[r,o]:[o,r]:n?o.unshift(r):o.push(r),(a=s(e))>0&&o.length>a&&!o.warned){o.warned=!0;var u=new Error("Possible EventEmitter memory leak detected. "+o.length+" "+String(t)+" listeners added. Use emitter.setMaxListeners() to increase limit");u.name="MaxListenersExceededWarning",u.emitter=e,u.type=t,u.count=o.length,c=u,console&&console.warn&&console.warn(c)}return e}function u(){if(!this.fired)return this.target.removeListener(this.type,this.wrapFn),this.fired=!0,0===arguments.length?this.listener.call(this.target):this.listener.apply(this.target,arguments)}function f(e,t,r){var n={fired:!1,wrapFn:void 0,target:e,type:t,listener:r},a=u.bind(n);return a.listener=r,n.wrapFn=a,a}function d(e,t,r){var n=e._events;if(void 0===n)return[];var a=n[t];return void 0===a?[]:"function"==typeof a?r?[a.listener||a]:[a]:r?function(e){for(var t=new Array(e.length),r=0;r0&&(o=t[0]),o instanceof Error)throw o;var l=new Error("Unhandled error."+(o?" ("+o.message+")":""));throw l.context=o,l}var s=i[e];if(void 0===s)return!1;if("function"==typeof s)n(s,this,t);else{var c=s.length,u=p(s,c);for(r=0;r=0;i--)if(r[i]===t||r[i].listener===t){o=r[i].listener,a=i;break}if(a<0)return this;0===a?r.shift():function(e,t){for(;t+1=0;n--)this.removeListener(e,t[n]);return this},i.prototype.listeners=function(e){return d(this,e,!0)},i.prototype.rawListeners=function(e){return d(this,e,!1)},i.listenerCount=function(e,t){return"function"==typeof e.listenerCount?e.listenerCount(t):h.call(e,t)},i.prototype.listenerCount=h,i.prototype.eventNames=function(){return this._eventsCount>0?t(this._events):[]}},721:function(e,t,r){"use strict";var n=r(9914);e.exports=function(e){var t=typeof e;if("string"===t){var r=e;if(0==(e=+e)&&n(r))return!1}else if("number"!==t)return!1;return e-e<1}},4840:function(e){e.exports=function(e,t){var r=t[0],n=t[1],a=t[2],i=t[3],o=t[4],l=t[5],s=t[6],c=t[7],u=t[8],f=t[9],d=t[10],h=t[11],p=t[12],v=t[13],g=t[14],m=t[15];return e[0]=l*(d*m-h*g)-f*(s*m-c*g)+v*(s*h-c*d),e[1]=-(n*(d*m-h*g)-f*(a*m-i*g)+v*(a*h-i*d)),e[2]=n*(s*m-c*g)-l*(a*m-i*g)+v*(a*c-i*s),e[3]=-(n*(s*h-c*d)-l*(a*h-i*d)+f*(a*c-i*s)),e[4]=-(o*(d*m-h*g)-u*(s*m-c*g)+p*(s*h-c*d)),e[5]=r*(d*m-h*g)-u*(a*m-i*g)+p*(a*h-i*d),e[6]=-(r*(s*m-c*g)-o*(a*m-i*g)+p*(a*c-i*s)),e[7]=r*(s*h-c*d)-o*(a*h-i*d)+u*(a*c-i*s),e[8]=o*(f*m-h*v)-u*(l*m-c*v)+p*(l*h-c*f),e[9]=-(r*(f*m-h*v)-u*(n*m-i*v)+p*(n*h-i*f)),e[10]=r*(l*m-c*v)-o*(n*m-i*v)+p*(n*c-i*l),e[11]=-(r*(l*h-c*f)-o*(n*h-i*f)+u*(n*c-i*l)),e[12]=-(o*(f*g-d*v)-u*(l*g-s*v)+p*(l*d-s*f)),e[13]=r*(f*g-d*v)-u*(n*g-a*v)+p*(n*d-a*f),e[14]=-(r*(l*g-s*v)-o*(n*g-a*v)+p*(n*s-a*l)),e[15]=r*(l*d-s*f)-o*(n*d-a*f)+u*(n*s-a*l),e}},9698:function(e){e.exports=function(e){var t=new Float32Array(16);return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t[6]=e[6],t[7]=e[7],t[8]=e[8],t[9]=e[9],t[10]=e[10],t[11]=e[11],t[12]=e[12],t[13]=e[13],t[14]=e[14],t[15]=e[15],t}},7938:function(e){e.exports=function(e,t){return e[0]=t[0],e[1]=t[1],e[2]=t[2],e[3]=t[3],e[4]=t[4],e[5]=t[5],e[6]=t[6],e[7]=t[7],e[8]=t[8],e[9]=t[9],e[10]=t[10],e[11]=t[11],e[12]=t[12],e[13]=t[13],e[14]=t[14],e[15]=t[15],e}},7519:function(e){e.exports=function(){var e=new Float32Array(16);return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}},6900:function(e){e.exports=function(e){var t=e[0],r=e[1],n=e[2],a=e[3],i=e[4],o=e[5],l=e[6],s=e[7],c=e[8],u=e[9],f=e[10],d=e[11],h=e[12],p=e[13],v=e[14],g=e[15];return(t*o-r*i)*(f*g-d*v)-(t*l-n*i)*(u*g-d*p)+(t*s-a*i)*(u*v-f*p)+(r*l-n*o)*(c*g-d*h)-(r*s-a*o)*(c*v-f*h)+(n*s-a*l)*(c*p-u*h)}},6472:function(e){e.exports=function(e,t){var r=t[0],n=t[1],a=t[2],i=t[3],o=r+r,l=n+n,s=a+a,c=r*o,u=n*o,f=n*l,d=a*o,h=a*l,p=a*s,v=i*o,g=i*l,m=i*s;return e[0]=1-f-p,e[1]=u+m,e[2]=d-g,e[3]=0,e[4]=u-m,e[5]=1-c-p,e[6]=h+v,e[7]=0,e[8]=d+g,e[9]=h-v,e[10]=1-c-f,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}},3061:function(e){e.exports=function(e,t,r){var n,a,i,o=r[0],l=r[1],s=r[2],c=Math.sqrt(o*o+l*l+s*s);return Math.abs(c)<1e-6?null:(o*=c=1/c,l*=c,s*=c,n=Math.sin(t),i=1-(a=Math.cos(t)),e[0]=o*o*i+a,e[1]=l*o*i+s*n,e[2]=s*o*i-l*n,e[3]=0,e[4]=o*l*i-s*n,e[5]=l*l*i+a,e[6]=s*l*i+o*n,e[7]=0,e[8]=o*s*i+l*n,e[9]=l*s*i-o*n,e[10]=s*s*i+a,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e)}},3606:function(e){e.exports=function(e,t,r){var n=t[0],a=t[1],i=t[2],o=t[3],l=n+n,s=a+a,c=i+i,u=n*l,f=n*s,d=n*c,h=a*s,p=a*c,v=i*c,g=o*l,m=o*s,y=o*c;return e[0]=1-(h+v),e[1]=f+y,e[2]=d-m,e[3]=0,e[4]=f-y,e[5]=1-(u+v),e[6]=p+g,e[7]=0,e[8]=d+m,e[9]=p-g,e[10]=1-(u+h),e[11]=0,e[12]=r[0],e[13]=r[1],e[14]=r[2],e[15]=1,e}},8698:function(e){e.exports=function(e,t){return e[0]=t[0],e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=t[1],e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=t[2],e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}},6924:function(e){e.exports=function(e,t){return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=t[0],e[13]=t[1],e[14]=t[2],e[15]=1,e}},1181:function(e){e.exports=function(e,t){var r=Math.sin(t),n=Math.cos(t);return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=n,e[6]=r,e[7]=0,e[8]=0,e[9]=-r,e[10]=n,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}},5258:function(e){e.exports=function(e,t){var r=Math.sin(t),n=Math.cos(t);return e[0]=n,e[1]=0,e[2]=-r,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=r,e[9]=0,e[10]=n,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}},4815:function(e){e.exports=function(e,t){var r=Math.sin(t),n=Math.cos(t);return e[0]=n,e[1]=r,e[2]=0,e[3]=0,e[4]=-r,e[5]=n,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}},7301:function(e){e.exports=function(e,t,r,n,a,i,o){var l=1/(r-t),s=1/(a-n),c=1/(i-o);return e[0]=2*i*l,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=2*i*s,e[6]=0,e[7]=0,e[8]=(r+t)*l,e[9]=(a+n)*s,e[10]=(o+i)*c,e[11]=-1,e[12]=0,e[13]=0,e[14]=o*i*2*c,e[15]=0,e}},7193:function(e){e.exports=function(e){return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}},1191:function(e,t,r){e.exports={create:r(7519),clone:r(9698),copy:r(7938),identity:r(7193),transpose:r(256),invert:r(6559),adjoint:r(4840),determinant:r(6900),multiply:r(4787),translate:r(4165),scale:r(8697),rotate:r(2416),rotateX:r(1066),rotateY:r(4201),rotateZ:r(3920),fromRotation:r(3061),fromRotationTranslation:r(3606),fromScaling:r(8698),fromTranslation:r(6924),fromXRotation:r(1181),fromYRotation:r(5258),fromZRotation:r(4815),fromQuat:r(6472),frustum:r(7301),perspective:r(5313),perspectiveFromFieldOfView:r(2253),ortho:r(4633),lookAt:r(6645),str:r(6992)}},6559:function(e){e.exports=function(e,t){var r=t[0],n=t[1],a=t[2],i=t[3],o=t[4],l=t[5],s=t[6],c=t[7],u=t[8],f=t[9],d=t[10],h=t[11],p=t[12],v=t[13],g=t[14],m=t[15],y=r*l-n*o,x=r*s-a*o,b=r*c-i*o,_=n*s-a*l,w=n*c-i*l,T=a*c-i*s,M=u*v-f*p,k=u*g-d*p,A=u*m-h*p,L=f*g-d*v,S=f*m-h*v,C=d*m-h*g,O=y*C-x*S+b*L+_*A-w*k+T*M;return O?(O=1/O,e[0]=(l*C-s*S+c*L)*O,e[1]=(a*S-n*C-i*L)*O,e[2]=(v*T-g*w+m*_)*O,e[3]=(d*w-f*T-h*_)*O,e[4]=(s*A-o*C-c*k)*O,e[5]=(r*C-a*A+i*k)*O,e[6]=(g*b-p*T-m*x)*O,e[7]=(u*T-d*b+h*x)*O,e[8]=(o*S-l*A+c*M)*O,e[9]=(n*A-r*S-i*M)*O,e[10]=(p*w-v*b+m*y)*O,e[11]=(f*b-u*w-h*y)*O,e[12]=(l*k-o*L-s*M)*O,e[13]=(r*L-n*k+a*M)*O,e[14]=(v*x-p*_-g*y)*O,e[15]=(u*_-f*x+d*y)*O,e):null}},6645:function(e,t,r){var n=r(7193);e.exports=function(e,t,r,a){var i,o,l,s,c,u,f,d,h,p,v=t[0],g=t[1],m=t[2],y=a[0],x=a[1],b=a[2],_=r[0],w=r[1],T=r[2];return Math.abs(v-_)<1e-6&&Math.abs(g-w)<1e-6&&Math.abs(m-T)<1e-6?n(e):(f=v-_,d=g-w,h=m-T,i=x*(h*=p=1/Math.sqrt(f*f+d*d+h*h))-b*(d*=p),o=b*(f*=p)-y*h,l=y*d-x*f,(p=Math.sqrt(i*i+o*o+l*l))?(i*=p=1/p,o*=p,l*=p):(i=0,o=0,l=0),s=d*l-h*o,c=h*i-f*l,u=f*o-d*i,(p=Math.sqrt(s*s+c*c+u*u))?(s*=p=1/p,c*=p,u*=p):(s=0,c=0,u=0),e[0]=i,e[1]=s,e[2]=f,e[3]=0,e[4]=o,e[5]=c,e[6]=d,e[7]=0,e[8]=l,e[9]=u,e[10]=h,e[11]=0,e[12]=-(i*v+o*g+l*m),e[13]=-(s*v+c*g+u*m),e[14]=-(f*v+d*g+h*m),e[15]=1,e)}},4787:function(e){e.exports=function(e,t,r){var n=t[0],a=t[1],i=t[2],o=t[3],l=t[4],s=t[5],c=t[6],u=t[7],f=t[8],d=t[9],h=t[10],p=t[11],v=t[12],g=t[13],m=t[14],y=t[15],x=r[0],b=r[1],_=r[2],w=r[3];return e[0]=x*n+b*l+_*f+w*v,e[1]=x*a+b*s+_*d+w*g,e[2]=x*i+b*c+_*h+w*m,e[3]=x*o+b*u+_*p+w*y,x=r[4],b=r[5],_=r[6],w=r[7],e[4]=x*n+b*l+_*f+w*v,e[5]=x*a+b*s+_*d+w*g,e[6]=x*i+b*c+_*h+w*m,e[7]=x*o+b*u+_*p+w*y,x=r[8],b=r[9],_=r[10],w=r[11],e[8]=x*n+b*l+_*f+w*v,e[9]=x*a+b*s+_*d+w*g,e[10]=x*i+b*c+_*h+w*m,e[11]=x*o+b*u+_*p+w*y,x=r[12],b=r[13],_=r[14],w=r[15],e[12]=x*n+b*l+_*f+w*v,e[13]=x*a+b*s+_*d+w*g,e[14]=x*i+b*c+_*h+w*m,e[15]=x*o+b*u+_*p+w*y,e}},4633:function(e){e.exports=function(e,t,r,n,a,i,o){var l=1/(t-r),s=1/(n-a),c=1/(i-o);return e[0]=-2*l,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=-2*s,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=2*c,e[11]=0,e[12]=(t+r)*l,e[13]=(a+n)*s,e[14]=(o+i)*c,e[15]=1,e}},5313:function(e){e.exports=function(e,t,r,n,a){var i=1/Math.tan(t/2),o=1/(n-a);return e[0]=i/r,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=i,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=(a+n)*o,e[11]=-1,e[12]=0,e[13]=0,e[14]=2*a*n*o,e[15]=0,e}},2253:function(e){e.exports=function(e,t,r,n){var a=Math.tan(t.upDegrees*Math.PI/180),i=Math.tan(t.downDegrees*Math.PI/180),o=Math.tan(t.leftDegrees*Math.PI/180),l=Math.tan(t.rightDegrees*Math.PI/180),s=2/(o+l),c=2/(a+i);return e[0]=s,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=c,e[6]=0,e[7]=0,e[8]=-(o-l)*s*.5,e[9]=(a-i)*c*.5,e[10]=n/(r-n),e[11]=-1,e[12]=0,e[13]=0,e[14]=n*r/(r-n),e[15]=0,e}},2416:function(e){e.exports=function(e,t,r,n){var a,i,o,l,s,c,u,f,d,h,p,v,g,m,y,x,b,_,w,T,M,k,A,L,S=n[0],C=n[1],O=n[2],D=Math.sqrt(S*S+C*C+O*O);return Math.abs(D)<1e-6?null:(S*=D=1/D,C*=D,O*=D,a=Math.sin(r),o=1-(i=Math.cos(r)),l=t[0],s=t[1],c=t[2],u=t[3],f=t[4],d=t[5],h=t[6],p=t[7],v=t[8],g=t[9],m=t[10],y=t[11],x=S*S*o+i,b=C*S*o+O*a,_=O*S*o-C*a,w=S*C*o-O*a,T=C*C*o+i,M=O*C*o+S*a,k=S*O*o+C*a,A=C*O*o-S*a,L=O*O*o+i,e[0]=l*x+f*b+v*_,e[1]=s*x+d*b+g*_,e[2]=c*x+h*b+m*_,e[3]=u*x+p*b+y*_,e[4]=l*w+f*T+v*M,e[5]=s*w+d*T+g*M,e[6]=c*w+h*T+m*M,e[7]=u*w+p*T+y*M,e[8]=l*k+f*A+v*L,e[9]=s*k+d*A+g*L,e[10]=c*k+h*A+m*L,e[11]=u*k+p*A+y*L,t!==e&&(e[12]=t[12],e[13]=t[13],e[14]=t[14],e[15]=t[15]),e)}},1066:function(e){e.exports=function(e,t,r){var n=Math.sin(r),a=Math.cos(r),i=t[4],o=t[5],l=t[6],s=t[7],c=t[8],u=t[9],f=t[10],d=t[11];return t!==e&&(e[0]=t[0],e[1]=t[1],e[2]=t[2],e[3]=t[3],e[12]=t[12],e[13]=t[13],e[14]=t[14],e[15]=t[15]),e[4]=i*a+c*n,e[5]=o*a+u*n,e[6]=l*a+f*n,e[7]=s*a+d*n,e[8]=c*a-i*n,e[9]=u*a-o*n,e[10]=f*a-l*n,e[11]=d*a-s*n,e}},4201:function(e){e.exports=function(e,t,r){var n=Math.sin(r),a=Math.cos(r),i=t[0],o=t[1],l=t[2],s=t[3],c=t[8],u=t[9],f=t[10],d=t[11];return t!==e&&(e[4]=t[4],e[5]=t[5],e[6]=t[6],e[7]=t[7],e[12]=t[12],e[13]=t[13],e[14]=t[14],e[15]=t[15]),e[0]=i*a-c*n,e[1]=o*a-u*n,e[2]=l*a-f*n,e[3]=s*a-d*n,e[8]=i*n+c*a,e[9]=o*n+u*a,e[10]=l*n+f*a,e[11]=s*n+d*a,e}},3920:function(e){e.exports=function(e,t,r){var n=Math.sin(r),a=Math.cos(r),i=t[0],o=t[1],l=t[2],s=t[3],c=t[4],u=t[5],f=t[6],d=t[7];return t!==e&&(e[8]=t[8],e[9]=t[9],e[10]=t[10],e[11]=t[11],e[12]=t[12],e[13]=t[13],e[14]=t[14],e[15]=t[15]),e[0]=i*a+c*n,e[1]=o*a+u*n,e[2]=l*a+f*n,e[3]=s*a+d*n,e[4]=c*a-i*n,e[5]=u*a-o*n,e[6]=f*a-l*n,e[7]=d*a-s*n,e}},8697:function(e){e.exports=function(e,t,r){var n=r[0],a=r[1],i=r[2];return e[0]=t[0]*n,e[1]=t[1]*n,e[2]=t[2]*n,e[3]=t[3]*n,e[4]=t[4]*a,e[5]=t[5]*a,e[6]=t[6]*a,e[7]=t[7]*a,e[8]=t[8]*i,e[9]=t[9]*i,e[10]=t[10]*i,e[11]=t[11]*i,e[12]=t[12],e[13]=t[13],e[14]=t[14],e[15]=t[15],e}},6992:function(e){e.exports=function(e){return"mat4("+e[0]+", "+e[1]+", "+e[2]+", "+e[3]+", "+e[4]+", "+e[5]+", "+e[6]+", "+e[7]+", "+e[8]+", "+e[9]+", "+e[10]+", "+e[11]+", "+e[12]+", "+e[13]+", "+e[14]+", "+e[15]+")"}},4165:function(e){e.exports=function(e,t,r){var n,a,i,o,l,s,c,u,f,d,h,p,v=r[0],g=r[1],m=r[2];return t===e?(e[12]=t[0]*v+t[4]*g+t[8]*m+t[12],e[13]=t[1]*v+t[5]*g+t[9]*m+t[13],e[14]=t[2]*v+t[6]*g+t[10]*m+t[14],e[15]=t[3]*v+t[7]*g+t[11]*m+t[15]):(n=t[0],a=t[1],i=t[2],o=t[3],l=t[4],s=t[5],c=t[6],u=t[7],f=t[8],d=t[9],h=t[10],p=t[11],e[0]=n,e[1]=a,e[2]=i,e[3]=o,e[4]=l,e[5]=s,e[6]=c,e[7]=u,e[8]=f,e[9]=d,e[10]=h,e[11]=p,e[12]=n*v+l*g+f*m+t[12],e[13]=a*v+s*g+d*m+t[13],e[14]=i*v+c*g+h*m+t[14],e[15]=o*v+u*g+p*m+t[15]),e}},256:function(e){e.exports=function(e,t){if(e===t){var r=t[1],n=t[2],a=t[3],i=t[6],o=t[7],l=t[11];e[1]=t[4],e[2]=t[8],e[3]=t[12],e[4]=r,e[6]=t[9],e[7]=t[13],e[8]=n,e[9]=i,e[11]=t[14],e[12]=a,e[13]=o,e[14]=l}else e[0]=t[0],e[1]=t[4],e[2]=t[8],e[3]=t[12],e[4]=t[1],e[5]=t[5],e[6]=t[9],e[7]=t[13],e[8]=t[2],e[9]=t[6],e[10]=t[10],e[11]=t[14],e[12]=t[3],e[13]=t[7],e[14]=t[11],e[15]=t[15];return e}},9784:function(e,t,r){"use strict";var n,a=r(8253);n="function"==typeof r.g.matchMedia?!r.g.matchMedia("(hover: none)").matches:a,e.exports=n},4043:function(e,t,r){"use strict";var n=r(8253);e.exports=n&&function(){var e=!1;try{var t=Object.defineProperty({},"passive",{get:function(){e=!0}});window.addEventListener("test",null,t),window.removeEventListener("test",null,t)}catch(t){e=!1}return e}()},8253:function(e){e.exports=!0},9914:function(e){"use strict";e.exports=function(e){for(var t,r=e.length,n=0;n13)&&32!==t&&133!==t&&160!==t&&5760!==t&&6158!==t&&(t<8192||t>8205)&&8232!==t&&8233!==t&&8239!==t&&8287!==t&&8288!==t&&12288!==t&&65279!==t)return!1;return!0}},4039:function(e){var t={left:0,top:0};e.exports=function(e,r,n){r=r||e.currentTarget||e.srcElement,Array.isArray(n)||(n=[0,0]);var a,i=e.clientX||0,o=e.clientY||0,l=(a=r)===window||a===document||a===document.body?t:a.getBoundingClientRect();return n[0]=i-l.left,n[1]=o-l.top,n}},1116:function(e,t,r){var n,a,i,o;a="Promise",o=function(){"use strict";var e,t,r,n=Object.prototype.toString,a="undefined"!=typeof setImmediate?function(e){return setImmediate(e)}:setTimeout;try{Object.defineProperty({},"x",{}),e=function(e,t,r,n){return Object.defineProperty(e,t,{value:r,writable:!0,configurable:!1!==n})}}catch(t){e=function(e,t,r){return e[t]=r,e}}function i(e,n){r.add(e,n),t||(t=a(r.drain))}function o(e){var t,r=typeof e;return null==e||"object"!=r&&"function"!=r||(t=e.then),"function"==typeof t&&t}function l(){for(var e=0;e0&&i(l,r))}catch(e){u.call(new d(r),e)}}}function u(e){var t=this;t.triggered||(t.triggered=!0,t.def&&(t=t.def),t.msg=e,t.state=2,t.chain.length>0&&i(l,t))}function f(e,t,r,n){for(var a=0;a2&&(a.push([r].concat(i.splice(0,2))),o="l",r="m"==r?"l":"L");;){if(i.length==t[o])return i.unshift(r),a.push(i);if(i.lengthi!=h>i&&a<(d-u)*(i-f)/(h-f)+u&&(o=!o)}return o}},1516:function(e,t,r){var n,a=r(2391),i=r(2990),o=r(6202),l=r(2222),s=r(7527),c=r(4491),u=!1,f=i();function d(e,t,r){var a=n.segments(e),i=n.segments(t),o=r(n.combine(a,i));return n.polygon(o)}n={buildLog:function(e){return!0===e?u=a():!1===e&&(u=!1),!1!==u&&u.list},epsilon:function(e){return f.epsilon(e)},segments:function(e){var t=o(!0,f,u);return e.regions.forEach(t.addRegion),{segments:t.calculate(e.inverted),inverted:e.inverted}},combine:function(e,t){return{combined:o(!1,f,u).calculate(e.segments,e.inverted,t.segments,t.inverted),inverted1:e.inverted,inverted2:t.inverted}},selectUnion:function(e){return{segments:s.union(e.combined,u),inverted:e.inverted1||e.inverted2}},selectIntersect:function(e){return{segments:s.intersect(e.combined,u),inverted:e.inverted1&&e.inverted2}},selectDifference:function(e){return{segments:s.difference(e.combined,u),inverted:e.inverted1&&!e.inverted2}},selectDifferenceRev:function(e){return{segments:s.differenceRev(e.combined,u),inverted:!e.inverted1&&e.inverted2}},selectXor:function(e){return{segments:s.xor(e.combined,u),inverted:e.inverted1!==e.inverted2}},polygon:function(e){return{regions:l(e.segments,f,u),inverted:e.inverted}},polygonFromGeoJSON:function(e){return c.toPolygon(n,e)},polygonToGeoJSON:function(e){return c.fromPolygon(n,f,e)},union:function(e,t){return d(e,t,n.selectUnion)},intersect:function(e,t){return d(e,t,n.selectIntersect)},difference:function(e,t){return d(e,t,n.selectDifference)},differenceRev:function(e,t){return d(e,t,n.selectDifferenceRev)},xor:function(e,t){return d(e,t,n.selectXor)}},"object"==typeof window&&(window.PolyBool=n),e.exports=n},2391:function(e){e.exports=function(){var e,t=0,r=!1;function n(t,r){return e.list.push({type:t,data:r?JSON.parse(JSON.stringify(r)):void 0}),e}return e={list:[],segmentId:function(){return t++},checkIntersection:function(e,t){return n("check",{seg1:e,seg2:t})},segmentChop:function(e,t){return n("div_seg",{seg:e,pt:t}),n("chop",{seg:e,pt:t})},statusRemove:function(e){return n("pop_seg",{seg:e})},segmentUpdate:function(e){return n("seg_update",{seg:e})},segmentNew:function(e,t){return n("new_seg",{seg:e,primary:t})},segmentRemove:function(e){return n("rem_seg",{seg:e})},tempStatus:function(e,t,r){return n("temp_status",{seg:e,above:t,below:r})},rewind:function(e){return n("rewind",{seg:e})},status:function(e,t,r){return n("status",{seg:e,above:t,below:r})},vert:function(t){return t===r?e:(r=t,n("vert",{x:t}))},log:function(e){return"string"!=typeof e&&(e=JSON.stringify(e,!1," ")),n("log",{txt:e})},reset:function(){return n("reset")},selected:function(e){return n("selected",{segs:e})},chainStart:function(e){return n("chain_start",{seg:e})},chainRemoveHead:function(e,t){return n("chain_rem_head",{index:e,pt:t})},chainRemoveTail:function(e,t){return n("chain_rem_tail",{index:e,pt:t})},chainNew:function(e,t){return n("chain_new",{pt1:e,pt2:t})},chainMatch:function(e){return n("chain_match",{index:e})},chainClose:function(e){return n("chain_close",{index:e})},chainAddHead:function(e,t){return n("chain_add_head",{index:e,pt:t})},chainAddTail:function(e,t){return n("chain_add_tail",{index:e,pt:t})},chainConnect:function(e,t){return n("chain_con",{index1:e,index2:t})},chainReverse:function(e){return n("chain_rev",{index:e})},chainJoin:function(e,t){return n("chain_join",{index1:e,index2:t})},done:function(){return n("done")}}}},2990:function(e){e.exports=function(e){"number"!=typeof e&&(e=1e-10);var t={epsilon:function(t){return"number"==typeof t&&(e=t),e},pointAboveOrOnLine:function(t,r,n){var a=r[0],i=r[1],o=n[0],l=n[1],s=t[0];return(o-a)*(t[1]-i)-(l-i)*(s-a)>=-e},pointBetween:function(t,r,n){var a=t[1]-r[1],i=n[0]-r[0],o=t[0]-r[0],l=n[1]-r[1],s=o*i+a*l;return!(s-e)},pointsSameX:function(t,r){return Math.abs(t[0]-r[0])e!=o-a>e&&(i-c)*(a-u)/(o-u)+c-n>e&&(l=!l),i=c,o=u}return l}};return t}},4491:function(e){var t={toPolygon:function(e,t){function r(t){if(t.length<=0)return e.segments({inverted:!1,regions:[]});function r(t){var r=t.slice(0,t.length-1);return e.segments({inverted:!1,regions:[r]})}for(var n=r(t[0]),a=1;a0}))}function u(e,n){var a=e.seg,i=n.seg,o=a.start,l=a.end,c=i.start,u=i.end;r&&r.checkIntersection(a,i);var f=t.linesIntersect(o,l,c,u);if(!1===f){if(!t.pointsCollinear(o,l,c))return!1;if(t.pointsSame(o,u)||t.pointsSame(l,c))return!1;var d=t.pointsSame(o,c),h=t.pointsSame(l,u);if(d&&h)return n;var p=!d&&t.pointBetween(o,c,u),v=!h&&t.pointBetween(l,c,u);if(d)return v?s(n,l):s(e,u),n;p&&(h||(v?s(n,l):s(e,u)),s(n,o))}else 0===f.alongA&&(-1===f.alongB?s(e,c):0===f.alongB?s(e,f.pt):1===f.alongB&&s(e,u)),0===f.alongB&&(-1===f.alongA?s(n,o):0===f.alongA?s(n,f.pt):1===f.alongA&&s(n,l));return!1}for(var f=[];!i.isEmpty();){var d=i.getHead();if(r&&r.vert(d.pt[0]),d.isStart){r&&r.segmentNew(d.seg,d.primary);var h=c(d),p=h.before?h.before.ev:null,v=h.after?h.after.ev:null;function g(){if(p){var e=u(d,p);if(e)return e}return!!v&&u(d,v)}r&&r.tempStatus(d.seg,!!p&&p.seg,!!v&&v.seg);var m,y,x=g();if(x)e?(y=null===d.seg.myFill.below||d.seg.myFill.above!==d.seg.myFill.below)&&(x.seg.myFill.above=!x.seg.myFill.above):x.seg.otherFill=d.seg.myFill,r&&r.segmentUpdate(x.seg),d.other.remove(),d.remove();if(i.getHead()!==d){r&&r.rewind(d.seg);continue}e?(y=null===d.seg.myFill.below||d.seg.myFill.above!==d.seg.myFill.below,d.seg.myFill.below=v?v.seg.myFill.above:a,d.seg.myFill.above=y?!d.seg.myFill.below:d.seg.myFill.below):null===d.seg.otherFill&&(m=v?d.primary===v.primary?v.seg.otherFill.above:v.seg.myFill.above:d.primary?o:a,d.seg.otherFill={above:m,below:m}),r&&r.status(d.seg,!!p&&p.seg,!!v&&v.seg),d.other.status=h.insert(n.node({ev:d}))}else{var b=d.status;if(null===b)throw new Error("PolyBool: Zero-length segment detected; your epsilon is probably too small or too large");if(l.exists(b.prev)&&l.exists(b.next)&&u(b.prev.ev,b.next.ev),r&&r.statusRemove(b.ev.seg),b.remove(),!d.primary){var _=d.seg.myFill;d.seg.myFill=d.seg.otherFill,d.seg.otherFill=_}f.push(d.seg)}i.getHead().remove()}return r&&r.done(),f}return e?{addRegion:function(e){for(var n,a,i,o=e[e.length-1],s=0;s1&&(r-=1),r<1/6?e+6*(t-e)*r:r<.5?t:r<2/3?e+(t-e)*(2/3-r)*6:e}if(e=z(e,360),t=z(t,100),r=z(r,100),0===t)n=a=i=r;else{var l=r<.5?r*(1+t):r+t-r*t,s=2*r-l;n=o(s,l,e+1/3),a=o(s,l,e),i=o(s,l,e-1/3)}return{r:255*n,g:255*a,b:255*i}}(e.h,f,h),p=!0,v="hsl"),e.hasOwnProperty("a")&&(s=e.a)),s=P(s),{ok:p,format:e.format||v,r:c(255,u(l.r,0)),g:c(255,u(l.g,0)),b:c(255,u(l.b,0)),a:s}}(e);this._originalInput=e,this._r=r.r,this._g=r.g,this._b=r.b,this._a=r.a,this._roundA=s(100*this._a)/100,this._format=t.format||r.format,this._gradientType=t.gradientType,this._r<1&&(this._r=s(this._r)),this._g<1&&(this._g=s(this._g)),this._b<1&&(this._b=s(this._b)),this._ok=r.ok,this._tc_id=l++}function h(e,t,r){e=z(e,255),t=z(t,255),r=z(r,255);var n,a,i=u(e,t,r),o=c(e,t,r),l=(i+o)/2;if(i==o)n=a=0;else{var s=i-o;switch(a=l>.5?s/(2-i-o):s/(i+o),i){case e:n=(t-r)/s+(t>1)+720)%360;--t;)n.h=(n.h+a)%360,i.push(d(n));return i}function C(e,t){t=t||6;for(var r=d(e).toHsv(),n=r.h,a=r.s,i=r.v,o=[],l=1/t;t--;)o.push(d({h:n,s:a,v:i})),i=(i+l)%1;return o}d.prototype={isDark:function(){return this.getBrightness()<128},isLight:function(){return!this.isDark()},isValid:function(){return this._ok},getOriginalInput:function(){return this._originalInput},getFormat:function(){return this._format},getAlpha:function(){return this._a},getBrightness:function(){var e=this.toRgb();return(299*e.r+587*e.g+114*e.b)/1e3},getLuminance:function(){var e,t,r,n=this.toRgb();return e=n.r/255,t=n.g/255,r=n.b/255,.2126*(e<=.03928?e/12.92:a.pow((e+.055)/1.055,2.4))+.7152*(t<=.03928?t/12.92:a.pow((t+.055)/1.055,2.4))+.0722*(r<=.03928?r/12.92:a.pow((r+.055)/1.055,2.4))},setAlpha:function(e){return this._a=P(e),this._roundA=s(100*this._a)/100,this},toHsv:function(){var e=p(this._r,this._g,this._b);return{h:360*e.h,s:e.s,v:e.v,a:this._a}},toHsvString:function(){var e=p(this._r,this._g,this._b),t=s(360*e.h),r=s(100*e.s),n=s(100*e.v);return 1==this._a?"hsv("+t+", "+r+"%, "+n+"%)":"hsva("+t+", "+r+"%, "+n+"%, "+this._roundA+")"},toHsl:function(){var e=h(this._r,this._g,this._b);return{h:360*e.h,s:e.s,l:e.l,a:this._a}},toHslString:function(){var e=h(this._r,this._g,this._b),t=s(360*e.h),r=s(100*e.s),n=s(100*e.l);return 1==this._a?"hsl("+t+", "+r+"%, "+n+"%)":"hsla("+t+", "+r+"%, "+n+"%, "+this._roundA+")"},toHex:function(e){return v(this._r,this._g,this._b,e)},toHexString:function(e){return"#"+this.toHex(e)},toHex8:function(e){return function(e,t,r,n,a){var i=[R(s(e).toString(16)),R(s(t).toString(16)),R(s(r).toString(16)),R(F(n))];return a&&i[0].charAt(0)==i[0].charAt(1)&&i[1].charAt(0)==i[1].charAt(1)&&i[2].charAt(0)==i[2].charAt(1)&&i[3].charAt(0)==i[3].charAt(1)?i[0].charAt(0)+i[1].charAt(0)+i[2].charAt(0)+i[3].charAt(0):i.join("")}(this._r,this._g,this._b,this._a,e)},toHex8String:function(e){return"#"+this.toHex8(e)},toRgb:function(){return{r:s(this._r),g:s(this._g),b:s(this._b),a:this._a}},toRgbString:function(){return 1==this._a?"rgb("+s(this._r)+", "+s(this._g)+", "+s(this._b)+")":"rgba("+s(this._r)+", "+s(this._g)+", "+s(this._b)+", "+this._roundA+")"},toPercentageRgb:function(){return{r:s(100*z(this._r,255))+"%",g:s(100*z(this._g,255))+"%",b:s(100*z(this._b,255))+"%",a:this._a}},toPercentageRgbString:function(){return 1==this._a?"rgb("+s(100*z(this._r,255))+"%, "+s(100*z(this._g,255))+"%, "+s(100*z(this._b,255))+"%)":"rgba("+s(100*z(this._r,255))+"%, "+s(100*z(this._g,255))+"%, "+s(100*z(this._b,255))+"%, "+this._roundA+")"},toName:function(){return 0===this._a?"transparent":!(this._a<1)&&(D[v(this._r,this._g,this._b,!0)]||!1)},toFilter:function(e){var t="#"+g(this._r,this._g,this._b,this._a),r=t,n=this._gradientType?"GradientType = 1, ":"";if(e){var a=d(e);r="#"+g(a._r,a._g,a._b,a._a)}return"progid:DXImageTransform.Microsoft.gradient("+n+"startColorstr="+t+",endColorstr="+r+")"},toString:function(e){var t=!!e;e=e||this._format;var r=!1,n=this._a<1&&this._a>=0;return t||!n||"hex"!==e&&"hex6"!==e&&"hex3"!==e&&"hex4"!==e&&"hex8"!==e&&"name"!==e?("rgb"===e&&(r=this.toRgbString()),"prgb"===e&&(r=this.toPercentageRgbString()),"hex"!==e&&"hex6"!==e||(r=this.toHexString()),"hex3"===e&&(r=this.toHexString(!0)),"hex4"===e&&(r=this.toHex8String(!0)),"hex8"===e&&(r=this.toHex8String()),"name"===e&&(r=this.toName()),"hsl"===e&&(r=this.toHslString()),"hsv"===e&&(r=this.toHsvString()),r||this.toHexString()):"name"===e&&0===this._a?this.toName():this.toRgbString()},clone:function(){return d(this.toString())},_applyModification:function(e,t){var r=e.apply(null,[this].concat([].slice.call(t)));return this._r=r._r,this._g=r._g,this._b=r._b,this.setAlpha(r._a),this},lighten:function(){return this._applyModification(b,arguments)},brighten:function(){return this._applyModification(_,arguments)},darken:function(){return this._applyModification(w,arguments)},desaturate:function(){return this._applyModification(m,arguments)},saturate:function(){return this._applyModification(y,arguments)},greyscale:function(){return this._applyModification(x,arguments)},spin:function(){return this._applyModification(T,arguments)},_applyCombination:function(e,t){return e.apply(null,[this].concat([].slice.call(t)))},analogous:function(){return this._applyCombination(S,arguments)},complement:function(){return this._applyCombination(M,arguments)},monochromatic:function(){return this._applyCombination(C,arguments)},splitcomplement:function(){return this._applyCombination(L,arguments)},triad:function(){return this._applyCombination(k,arguments)},tetrad:function(){return this._applyCombination(A,arguments)}},d.fromRatio=function(e,t){if("object"==typeof e){var r={};for(var n in e)e.hasOwnProperty(n)&&(r[n]="a"===n?e[n]:N(e[n]));e=r}return d(e,t)},d.equals=function(e,t){return!(!e||!t)&&d(e).toRgbString()==d(t).toRgbString()},d.random=function(){return d.fromRatio({r:f(),g:f(),b:f()})},d.mix=function(e,t,r){r=0===r?0:r||50;var n=d(e).toRgb(),a=d(t).toRgb(),i=r/100;return d({r:(a.r-n.r)*i+n.r,g:(a.g-n.g)*i+n.g,b:(a.b-n.b)*i+n.b,a:(a.a-n.a)*i+n.a})},d.readability=function(e,t){var r=d(e),n=d(t);return(a.max(r.getLuminance(),n.getLuminance())+.05)/(a.min(r.getLuminance(),n.getLuminance())+.05)},d.isReadable=function(e,t,r){var n,a,i,o,l,s=d.readability(e,t);switch(a=!1,(i=r,"AA"!==(o=((i=i||{level:"AA",size:"small"}).level||"AA").toUpperCase())&&"AAA"!==o&&(o="AA"),"small"!==(l=(i.size||"small").toLowerCase())&&"large"!==l&&(l="small"),n={level:o,size:l}).level+n.size){case"AAsmall":case"AAAlarge":a=s>=4.5;break;case"AAlarge":a=s>=3;break;case"AAAsmall":a=s>=7}return a},d.mostReadable=function(e,t,r){var n,a,i,o,l=null,s=0;a=(r=r||{}).includeFallbackColors,i=r.level,o=r.size;for(var c=0;cs&&(s=n,l=d(t[c]));return d.isReadable(e,l,{level:i,size:o})||!a?l:(r.includeFallbackColors=!1,d.mostReadable(e,["#fff","#000"],r))};var O=d.names={aliceblue:"f0f8ff",antiquewhite:"faebd7",aqua:"0ff",aquamarine:"7fffd4",azure:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"000",blanchedalmond:"ffebcd",blue:"00f",blueviolet:"8a2be2",brown:"a52a2a",burlywood:"deb887",burntsienna:"ea7e5d",cadetblue:"5f9ea0",chartreuse:"7fff00",chocolate:"d2691e",coral:"ff7f50",cornflowerblue:"6495ed",cornsilk:"fff8dc",crimson:"dc143c",cyan:"0ff",darkblue:"00008b",darkcyan:"008b8b",darkgoldenrod:"b8860b",darkgray:"a9a9a9",darkgreen:"006400",darkgrey:"a9a9a9",darkkhaki:"bdb76b",darkmagenta:"8b008b",darkolivegreen:"556b2f",darkorange:"ff8c00",darkorchid:"9932cc",darkred:"8b0000",darksalmon:"e9967a",darkseagreen:"8fbc8f",darkslateblue:"483d8b",darkslategray:"2f4f4f",darkslategrey:"2f4f4f",darkturquoise:"00ced1",darkviolet:"9400d3",deeppink:"ff1493",deepskyblue:"00bfff",dimgray:"696969",dimgrey:"696969",dodgerblue:"1e90ff",firebrick:"b22222",floralwhite:"fffaf0",forestgreen:"228b22",fuchsia:"f0f",gainsboro:"dcdcdc",ghostwhite:"f8f8ff",gold:"ffd700",goldenrod:"daa520",gray:"808080",green:"008000",greenyellow:"adff2f",grey:"808080",honeydew:"f0fff0",hotpink:"ff69b4",indianred:"cd5c5c",indigo:"4b0082",ivory:"fffff0",khaki:"f0e68c",lavender:"e6e6fa",lavenderblush:"fff0f5",lawngreen:"7cfc00",lemonchiffon:"fffacd",lightblue:"add8e6",lightcoral:"f08080",lightcyan:"e0ffff",lightgoldenrodyellow:"fafad2",lightgray:"d3d3d3",lightgreen:"90ee90",lightgrey:"d3d3d3",lightpink:"ffb6c1",lightsalmon:"ffa07a",lightseagreen:"20b2aa",lightskyblue:"87cefa",lightslategray:"789",lightslategrey:"789",lightsteelblue:"b0c4de",lightyellow:"ffffe0",lime:"0f0",limegreen:"32cd32",linen:"faf0e6",magenta:"f0f",maroon:"800000",mediumaquamarine:"66cdaa",mediumblue:"0000cd",mediumorchid:"ba55d3",mediumpurple:"9370db",mediumseagreen:"3cb371",mediumslateblue:"7b68ee",mediumspringgreen:"00fa9a",mediumturquoise:"48d1cc",mediumvioletred:"c71585",midnightblue:"191970",mintcream:"f5fffa",mistyrose:"ffe4e1",moccasin:"ffe4b5",navajowhite:"ffdead",navy:"000080",oldlace:"fdf5e6",olive:"808000",olivedrab:"6b8e23",orange:"ffa500",orangered:"ff4500",orchid:"da70d6",palegoldenrod:"eee8aa",palegreen:"98fb98",paleturquoise:"afeeee",palevioletred:"db7093",papayawhip:"ffefd5",peachpuff:"ffdab9",peru:"cd853f",pink:"ffc0cb",plum:"dda0dd",powderblue:"b0e0e6",purple:"800080",rebeccapurple:"663399",red:"f00",rosybrown:"bc8f8f",royalblue:"4169e1",saddlebrown:"8b4513",salmon:"fa8072",sandybrown:"f4a460",seagreen:"2e8b57",seashell:"fff5ee",sienna:"a0522d",silver:"c0c0c0",skyblue:"87ceeb",slateblue:"6a5acd",slategray:"708090",slategrey:"708090",snow:"fffafa",springgreen:"00ff7f",steelblue:"4682b4",tan:"d2b48c",teal:"008080",thistle:"d8bfd8",tomato:"ff6347",turquoise:"40e0d0",violet:"ee82ee",wheat:"f5deb3",white:"fff",whitesmoke:"f5f5f5",yellow:"ff0",yellowgreen:"9acd32"},D=d.hexNames=function(e){var t={};for(var r in e)e.hasOwnProperty(r)&&(t[e[r]]=r);return t}(O);function P(e){return e=parseFloat(e),(isNaN(e)||e<0||e>1)&&(e=1),e}function z(e,t){(function(e){return"string"==typeof e&&-1!=e.indexOf(".")&&1===parseFloat(e)})(e)&&(e="100%");var r=function(e){return"string"==typeof e&&-1!=e.indexOf("%")}(e);return e=c(t,u(0,parseFloat(e))),r&&(e=parseInt(e*t,10)/100),a.abs(e-t)<1e-6?1:e%t/parseFloat(t)}function I(e){return c(1,u(0,e))}function E(e){return parseInt(e,16)}function R(e){return 1==e.length?"0"+e:""+e}function N(e){return e<=1&&(e=100*e+"%"),e}function F(e){return a.round(255*parseFloat(e)).toString(16)}function H(e){return E(e)/255}var B,j,Y,V=(j="[\\s|\\(]+("+(B="(?:[-\\+]?\\d*\\.\\d+%?)|(?:[-\\+]?\\d+%?)")+")[,|\\s]+("+B+")[,|\\s]+("+B+")\\s*\\)?",Y="[\\s|\\(]+("+B+")[,|\\s]+("+B+")[,|\\s]+("+B+")[,|\\s]+("+B+")\\s*\\)?",{CSS_UNIT:new RegExp(B),rgb:new RegExp("rgb"+j),rgba:new RegExp("rgba"+Y),hsl:new RegExp("hsl"+j),hsla:new RegExp("hsla"+Y),hsv:new RegExp("hsv"+j),hsva:new RegExp("hsva"+Y),hex3:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex6:/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/,hex4:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex8:/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/});function U(e){return!!V.CSS_UNIT.exec(e)}e.exports?e.exports=d:void 0===(n=function(){return d}.call(t,r,t,e))||(e.exports=n)}(Math)},1401:function(e,t,r){var n=r(6834),a=r(7976),i=n.instance();function o(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}o.prototype=new n.baseCalendar,a(o.prototype,{name:"Chinese",jdEpoch:1721425.5,hasYearZero:!1,minMonth:0,firstMonth:0,minDay:1,regionalOptions:{"":{name:"Chinese",epochs:["BEC","EC"],monthNumbers:function(e,t){if("string"==typeof e){var r=e.match(s);return r?r[0]:""}var n=this._validateYear(e),a=e.month(),i=""+this.toChineseMonth(n,a);return t&&i.length<2&&(i="0"+i),this.isIntercalaryMonth(n,a)&&(i+="i"),i},monthNames:function(e){if("string"==typeof e){var t=e.match(c);return t?t[0]:""}var r=this._validateYear(e),n=e.month(),a=["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"][this.toChineseMonth(r,n)-1];return this.isIntercalaryMonth(r,n)&&(a="闰"+a),a},monthNamesShort:function(e){if("string"==typeof e){var t=e.match(u);return t?t[0]:""}var r=this._validateYear(e),n=e.month(),a=["一","二","三","四","五","六","七","八","九","十","十一","十二"][this.toChineseMonth(r,n)-1];return this.isIntercalaryMonth(r,n)&&(a="闰"+a),a},parseMonth:function(e,t){e=this._validateYear(e);var r,n=parseInt(t);if(isNaN(n))"闰"===t[0]&&(r=!0,t=t.substring(1)),"月"===t[t.length-1]&&(t=t.substring(0,t.length-1)),n=1+["一","二","三","四","五","六","七","八","九","十","十一","十二"].indexOf(t);else{var a=t[t.length-1];r="i"===a||"I"===a}return this.toMonthIndex(e,n,r)},dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],digits:null,dateFormat:"yyyy/mm/dd",firstDay:1,isRTL:!1}},_validateYear:function(e,t){if(e.year&&(e=e.year()),"number"!=typeof e||e<1888||e>2111)throw t.replace(/\{0\}/,this.local.name);return e},toMonthIndex:function(e,t,r){var a=this.intercalaryMonth(e);if(r&&t!==a||t<1||t>12)throw n.local.invalidMonth.replace(/\{0\}/,this.local.name);return a?!r&&t<=a?t-1:t:t-1},toChineseMonth:function(e,t){e.year&&(t=(e=e.year()).month());var r=this.intercalaryMonth(e);if(t<0||t>(r?12:11))throw n.local.invalidMonth.replace(/\{0\}/,this.local.name);return r?t>13},isIntercalaryMonth:function(e,t){e.year&&(t=(e=e.year()).month());var r=this.intercalaryMonth(e);return!!r&&r===t},leapYear:function(e){return 0!==this.intercalaryMonth(e)},weekOfYear:function(e,t,r){var a,o=this._validateYear(e,n.local.invalidyear),l=d[o-d[0]],s=l>>9&4095,c=l>>5&15,u=31&l;(a=i.newDate(s,c,u)).add(4-(a.dayOfWeek()||7),"d");var f=this.toJD(e,t,r)-a.toJD();return 1+Math.floor(f/7)},monthsInYear:function(e){return this.leapYear(e)?13:12},daysInMonth:function(e,t){e.year&&(t=e.month(),e=e.year()),e=this._validateYear(e);var r=f[e-f[0]];if(t>(r>>13?12:11))throw n.local.invalidMonth.replace(/\{0\}/,this.local.name);return r&1<<12-t?30:29},weekDay:function(e,t,r){return(this.dayOfWeek(e,t,r)||7)<6},toJD:function(e,t,r){var a=this._validate(e,l,r,n.local.invalidDate);e=this._validateYear(a.year()),t=a.month(),r=a.day();var o=this.isIntercalaryMonth(e,t),l=this.toChineseMonth(e,t),s=function(e,t,r,n,a){var i,o,l;if("object"==typeof e)o=e,i=t||{};else{var s;if(!("number"==typeof e&&e>=1888&&e<=2111))throw new Error("Lunar year outside range 1888-2111");if(!("number"==typeof t&&t>=1&&t<=12))throw new Error("Lunar month outside range 1 - 12");if(!("number"==typeof r&&r>=1&&r<=30))throw new Error("Lunar day outside range 1 - 30");"object"==typeof n?(s=!1,i=n):(s=!!n,i={}),o={year:e,month:t,day:r,isIntercalary:s}}l=o.day-1;var c,u=f[o.year-f[0]],h=u>>13;c=h&&(o.month>h||o.isIntercalary)?o.month:o.month-1;for(var p=0;p>9&4095,(v>>5&15)-1,(31&v)+l);return i.year=g.getFullYear(),i.month=1+g.getMonth(),i.day=g.getDate(),i}(e,l,r,o);return i.toJD(s.year,s.month,s.day)},fromJD:function(e){var t=i.fromJD(e),r=function(e,t,r,n){var a,i;if("object"==typeof e)a=e,i=t||{};else{if(!("number"==typeof e&&e>=1888&&e<=2111))throw new Error("Solar year outside range 1888-2111");if(!("number"==typeof t&&t>=1&&t<=12))throw new Error("Solar month outside range 1 - 12");if(!("number"==typeof r&&r>=1&&r<=31))throw new Error("Solar day outside range 1 - 31");a={year:e,month:t,day:r},i={}}var o=d[a.year-d[0]],l=a.year<<9|a.month<<5|a.day;i.year=l>=o?a.year:a.year-1,o=d[i.year-d[0]];var s,c=new Date(o>>9&4095,(o>>5&15)-1,31&o),u=new Date(a.year,a.month-1,a.day);s=Math.round((u-c)/864e5);var h,p=f[i.year-f[0]];for(h=0;h<13;h++){var v=p&1<<12-h?30:29;if(s>13;return!g||h=2&&n<=6},extraInfo:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);return{century:o[Math.floor((a.year()-1)/100)+1]||""}},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);return e=a.year()+(a.year()<0?1:0),t=a.month(),(r=a.day())+(t>1?16:0)+(t>2?32*(t-2):0)+400*(e-1)+this.jdEpoch-1},fromJD:function(e){e=Math.floor(e+.5)-Math.floor(this.jdEpoch)-1;var t=Math.floor(e/400)+1;e-=400*(t-1),e+=e>15?16:0;var r=Math.floor(e/32)+1,n=e-32*(r-1)+1;return this.newDate(t<=0?t-1:t,r,n)}});var o={20:"Fruitbat",21:"Anchovy"};n.calendars.discworld=i},1133:function(e,t,r){var n=r(6834),a=r(7976);function i(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}i.prototype=new n.baseCalendar,a(i.prototype,{name:"Ethiopian",jdEpoch:1724220.5,daysPerMonth:[30,30,30,30,30,30,30,30,30,30,30,30,5],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Ethiopian",epochs:["BEE","EE"],monthNames:["Meskerem","Tikemet","Hidar","Tahesas","Tir","Yekatit","Megabit","Miazia","Genbot","Sene","Hamle","Nehase","Pagume"],monthNamesShort:["Mes","Tik","Hid","Tah","Tir","Yek","Meg","Mia","Gen","Sen","Ham","Neh","Pag"],dayNames:["Ehud","Segno","Maksegno","Irob","Hamus","Arb","Kidame"],dayNamesShort:["Ehu","Seg","Mak","Iro","Ham","Arb","Kid"],dayNamesMin:["Eh","Se","Ma","Ir","Ha","Ar","Ki"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:0,isRTL:!1}},leapYear:function(e){var t=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear);return(e=t.year()+(t.year()<0?1:0))%4==3||e%4==-1},monthsInYear:function(e){return this._validate(e,this.minMonth,this.minDay,n.local.invalidYear||n.regionalOptions[""].invalidYear),13},weekOfYear:function(e,t,r){var n=this.newDate(e,t,r);return n.add(-n.dayOfWeek(),"d"),Math.floor((n.dayOfYear()-1)/7)+1},daysInMonth:function(e,t){var r=this._validate(e,t,this.minDay,n.local.invalidMonth);return this.daysPerMonth[r.month()-1]+(13===r.month()&&this.leapYear(r.year())?1:0)},weekDay:function(e,t,r){return(this.dayOfWeek(e,t,r)||7)<6},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);return(e=a.year())<0&&e++,a.day()+30*(a.month()-1)+365*(e-1)+Math.floor(e/4)+this.jdEpoch-1},fromJD:function(e){var t=Math.floor(e)+.5-this.jdEpoch,r=Math.floor((t-Math.floor((t+366)/1461))/365)+1;r<=0&&r--,t=Math.floor(e)+.5-this.newDate(r,1,1).toJD();var n=Math.floor(t/30)+1,a=t-30*(n-1)+1;return this.newDate(r,n,a)}}),n.calendars.ethiopian=i},8295:function(e,t,r){var n=r(6834),a=r(7976);function i(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}function o(e,t){return e-t*Math.floor(e/t)}i.prototype=new n.baseCalendar,a(i.prototype,{name:"Hebrew",jdEpoch:347995.5,daysPerMonth:[30,29,30,29,30,29,30,29,30,29,30,29,29],hasYearZero:!1,minMonth:1,firstMonth:7,minDay:1,regionalOptions:{"":{name:"Hebrew",epochs:["BAM","AM"],monthNames:["Nisan","Iyar","Sivan","Tammuz","Av","Elul","Tishrei","Cheshvan","Kislev","Tevet","Shevat","Adar","Adar II"],monthNamesShort:["Nis","Iya","Siv","Tam","Av","Elu","Tis","Che","Kis","Tev","She","Ada","Ad2"],dayNames:["Yom Rishon","Yom Sheni","Yom Shlishi","Yom Revi'i","Yom Chamishi","Yom Shishi","Yom Shabbat"],dayNamesShort:["Ris","She","Shl","Rev","Cha","Shi","Sha"],dayNamesMin:["Ri","She","Shl","Re","Ch","Shi","Sha"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:0,isRTL:!1}},leapYear:function(e){var t=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear);return this._leapYear(t.year())},_leapYear:function(e){return o(7*(e=e<0?e+1:e)+1,19)<7},monthsInYear:function(e){return this._validate(e,this.minMonth,this.minDay,n.local.invalidYear),this._leapYear(e.year?e.year():e)?13:12},weekOfYear:function(e,t,r){var n=this.newDate(e,t,r);return n.add(-n.dayOfWeek(),"d"),Math.floor((n.dayOfYear()-1)/7)+1},daysInYear:function(e){return e=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear).year(),this.toJD(-1===e?1:e+1,7,1)-this.toJD(e,7,1)},daysInMonth:function(e,t){return e.year&&(t=e.month(),e=e.year()),this._validate(e,t,this.minDay,n.local.invalidMonth),12===t&&this.leapYear(e)||8===t&&5===o(this.daysInYear(e),10)?30:9===t&&3===o(this.daysInYear(e),10)?29:this.daysPerMonth[t-1]},weekDay:function(e,t,r){return 6!==this.dayOfWeek(e,t,r)},extraInfo:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);return{yearType:(this.leapYear(a)?"embolismic":"common")+" "+["deficient","regular","complete"][this.daysInYear(a)%10-3]}},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);e=a.year(),t=a.month(),r=a.day();var i=e<=0?e+1:e,o=this.jdEpoch+this._delay1(i)+this._delay2(i)+r+1;if(t<7){for(var l=7;l<=this.monthsInYear(e);l++)o+=this.daysInMonth(e,l);for(l=1;l=this.toJD(-1===t?1:t+1,7,1);)t++;for(var r=ethis.toJD(t,r,this.daysInMonth(t,r));)r++;var n=e-this.toJD(t,r,1)+1;return this.newDate(t,r,n)}}),n.calendars.hebrew=i},5512:function(e,t,r){var n=r(6834),a=r(7976);function i(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}i.prototype=new n.baseCalendar,a(i.prototype,{name:"Islamic",jdEpoch:1948439.5,daysPerMonth:[30,29,30,29,30,29,30,29,30,29,30,29],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Islamic",epochs:["BH","AH"],monthNames:["Muharram","Safar","Rabi' al-awwal","Rabi' al-thani","Jumada al-awwal","Jumada al-thani","Rajab","Sha'aban","Ramadan","Shawwal","Dhu al-Qi'dah","Dhu al-Hijjah"],monthNamesShort:["Muh","Saf","Rab1","Rab2","Jum1","Jum2","Raj","Sha'","Ram","Shaw","DhuQ","DhuH"],dayNames:["Yawm al-ahad","Yawm al-ithnayn","Yawm ath-thulaathaa'","Yawm al-arbi'aa'","Yawm al-khamīs","Yawm al-jum'a","Yawm as-sabt"],dayNamesShort:["Aha","Ith","Thu","Arb","Kha","Jum","Sab"],dayNamesMin:["Ah","It","Th","Ar","Kh","Ju","Sa"],digits:null,dateFormat:"yyyy/mm/dd",firstDay:6,isRTL:!1}},leapYear:function(e){return(11*this._validate(e,this.minMonth,this.minDay,n.local.invalidYear).year()+14)%30<11},weekOfYear:function(e,t,r){var n=this.newDate(e,t,r);return n.add(-n.dayOfWeek(),"d"),Math.floor((n.dayOfYear()-1)/7)+1},daysInYear:function(e){return this.leapYear(e)?355:354},daysInMonth:function(e,t){var r=this._validate(e,t,this.minDay,n.local.invalidMonth);return this.daysPerMonth[r.month()-1]+(12===r.month()&&this.leapYear(r.year())?1:0)},weekDay:function(e,t,r){return 5!==this.dayOfWeek(e,t,r)},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);return e=a.year(),t=a.month(),e=e<=0?e+1:e,(r=a.day())+Math.ceil(29.5*(t-1))+354*(e-1)+Math.floor((3+11*e)/30)+this.jdEpoch-1},fromJD:function(e){e=Math.floor(e)+.5;var t=Math.floor((30*(e-this.jdEpoch)+10646)/10631);t=t<=0?t-1:t;var r=Math.min(12,Math.ceil((e-29-this.toJD(t,1,1))/29.5)+1),n=e-this.toJD(t,r,1)+1;return this.newDate(t,r,n)}}),n.calendars.islamic=i},2645:function(e,t,r){var n=r(6834),a=r(7976);function i(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}i.prototype=new n.baseCalendar,a(i.prototype,{name:"Julian",jdEpoch:1721423.5,daysPerMonth:[31,28,31,30,31,30,31,31,30,31,30,31],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Julian",epochs:["BC","AD"],monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],digits:null,dateFormat:"mm/dd/yyyy",firstDay:0,isRTL:!1}},leapYear:function(e){var t=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear);return(e=t.year()<0?t.year()+1:t.year())%4==0},weekOfYear:function(e,t,r){var n=this.newDate(e,t,r);return n.add(4-(n.dayOfWeek()||7),"d"),Math.floor((n.dayOfYear()-1)/7)+1},daysInMonth:function(e,t){var r=this._validate(e,t,this.minDay,n.local.invalidMonth);return this.daysPerMonth[r.month()-1]+(2===r.month()&&this.leapYear(r.year())?1:0)},weekDay:function(e,t,r){return(this.dayOfWeek(e,t,r)||7)<6},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);return e=a.year(),t=a.month(),r=a.day(),e<0&&e++,t<=2&&(e--,t+=12),Math.floor(365.25*(e+4716))+Math.floor(30.6001*(t+1))+r-1524.5},fromJD:function(e){var t=Math.floor(e+.5)+1524,r=Math.floor((t-122.1)/365.25),n=Math.floor(365.25*r),a=Math.floor((t-n)/30.6001),i=a-Math.floor(a<14?1:13),o=r-Math.floor(i>2?4716:4715),l=t-n-Math.floor(30.6001*a);return o<=0&&o--,this.newDate(o,i,l)}}),n.calendars.julian=i},2324:function(e,t,r){var n=r(6834),a=r(7976);function i(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}function o(e,t){return e-t*Math.floor(e/t)}function l(e,t){return o(e-1,t)+1}i.prototype=new n.baseCalendar,a(i.prototype,{name:"Mayan",jdEpoch:584282.5,hasYearZero:!0,minMonth:0,firstMonth:0,minDay:0,regionalOptions:{"":{name:"Mayan",epochs:["",""],monthNames:["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17"],monthNamesShort:["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17"],dayNames:["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19"],dayNamesShort:["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19"],dayNamesMin:["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19"],digits:null,dateFormat:"YYYY.m.d",firstDay:0,isRTL:!1,haabMonths:["Pop","Uo","Zip","Zotz","Tzec","Xul","Yaxkin","Mol","Chen","Yax","Zac","Ceh","Mac","Kankin","Muan","Pax","Kayab","Cumku","Uayeb"],tzolkinMonths:["Imix","Ik","Akbal","Kan","Chicchan","Cimi","Manik","Lamat","Muluc","Oc","Chuen","Eb","Ben","Ix","Men","Cib","Caban","Etznab","Cauac","Ahau"]}},leapYear:function(e){return this._validate(e,this.minMonth,this.minDay,n.local.invalidYear),!1},formatYear:function(e){e=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear).year();var t=Math.floor(e/400);return e%=400,e+=e<0?400:0,t+"."+Math.floor(e/20)+"."+e%20},forYear:function(e){if((e=e.split(".")).length<3)throw"Invalid Mayan year";for(var t=0,r=0;r19||r>0&&n<0)throw"Invalid Mayan year";t=20*t+n}return t},monthsInYear:function(e){return this._validate(e,this.minMonth,this.minDay,n.local.invalidYear),18},weekOfYear:function(e,t,r){return this._validate(e,t,r,n.local.invalidDate),0},daysInYear:function(e){return this._validate(e,this.minMonth,this.minDay,n.local.invalidYear),360},daysInMonth:function(e,t){return this._validate(e,t,this.minDay,n.local.invalidMonth),20},daysInWeek:function(){return 5},dayOfWeek:function(e,t,r){return this._validate(e,t,r,n.local.invalidDate).day()},weekDay:function(e,t,r){return this._validate(e,t,r,n.local.invalidDate),!0},extraInfo:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate).toJD(),i=this._toHaab(a),o=this._toTzolkin(a);return{haabMonthName:this.local.haabMonths[i[0]-1],haabMonth:i[0],haabDay:i[1],tzolkinDayName:this.local.tzolkinMonths[o[0]-1],tzolkinDay:o[0],tzolkinTrecena:o[1]}},_toHaab:function(e){var t=o(8+(e-=this.jdEpoch)+340,365);return[Math.floor(t/20)+1,o(t,20)]},_toTzolkin:function(e){return[l(20+(e-=this.jdEpoch),20),l(e+4,13)]},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);return a.day()+20*a.month()+360*a.year()+this.jdEpoch},fromJD:function(e){e=Math.floor(e)+.5-this.jdEpoch;var t=Math.floor(e/360);e%=360,e+=e<0?360:0;var r=Math.floor(e/20),n=e%20;return this.newDate(t,r,n)}}),n.calendars.mayan=i},1662:function(e,t,r){var n=r(6834),a=r(7976);function i(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}i.prototype=new n.baseCalendar;var o=n.instance("gregorian");a(i.prototype,{name:"Nanakshahi",jdEpoch:2257673.5,daysPerMonth:[31,31,31,31,31,30,30,30,30,30,30,30],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Nanakshahi",epochs:["BN","AN"],monthNames:["Chet","Vaisakh","Jeth","Harh","Sawan","Bhadon","Assu","Katak","Maghar","Poh","Magh","Phagun"],monthNamesShort:["Che","Vai","Jet","Har","Saw","Bha","Ass","Kat","Mgr","Poh","Mgh","Pha"],dayNames:["Somvaar","Mangalvar","Budhvaar","Veervaar","Shukarvaar","Sanicharvaar","Etvaar"],dayNamesShort:["Som","Mangal","Budh","Veer","Shukar","Sanichar","Et"],dayNamesMin:["So","Ma","Bu","Ve","Sh","Sa","Et"],digits:null,dateFormat:"dd-mm-yyyy",firstDay:0,isRTL:!1}},leapYear:function(e){var t=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear||n.regionalOptions[""].invalidYear);return o.leapYear(t.year()+(t.year()<1?1:0)+1469)},weekOfYear:function(e,t,r){var n=this.newDate(e,t,r);return n.add(1-(n.dayOfWeek()||7),"d"),Math.floor((n.dayOfYear()-1)/7)+1},daysInMonth:function(e,t){var r=this._validate(e,t,this.minDay,n.local.invalidMonth);return this.daysPerMonth[r.month()-1]+(12===r.month()&&this.leapYear(r.year())?1:0)},weekDay:function(e,t,r){return(this.dayOfWeek(e,t,r)||7)<6},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidMonth);(e=a.year())<0&&e++;for(var i=a.day(),l=1;l=this.toJD(t+1,1,1);)t++;for(var r=e-Math.floor(this.toJD(t,1,1)+.5)+1,n=1;r>this.daysInMonth(t,n);)r-=this.daysInMonth(t,n),n++;return this.newDate(t,n,r)}}),n.calendars.nanakshahi=i},6445:function(e,t,r){var n=r(6834),a=r(7976);function i(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}i.prototype=new n.baseCalendar,a(i.prototype,{name:"Nepali",jdEpoch:1700709.5,daysPerMonth:[31,31,32,32,31,30,30,29,30,29,30,30],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,daysPerYear:365,regionalOptions:{"":{name:"Nepali",epochs:["BBS","ABS"],monthNames:["Baisakh","Jestha","Ashadh","Shrawan","Bhadra","Ashwin","Kartik","Mangsir","Paush","Mangh","Falgun","Chaitra"],monthNamesShort:["Bai","Je","As","Shra","Bha","Ash","Kar","Mang","Pau","Ma","Fal","Chai"],dayNames:["Aaitabaar","Sombaar","Manglbaar","Budhabaar","Bihibaar","Shukrabaar","Shanibaar"],dayNamesShort:["Aaita","Som","Mangl","Budha","Bihi","Shukra","Shani"],dayNamesMin:["Aai","So","Man","Bu","Bi","Shu","Sha"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:1,isRTL:!1}},leapYear:function(e){return this.daysInYear(e)!==this.daysPerYear},weekOfYear:function(e,t,r){var n=this.newDate(e,t,r);return n.add(-n.dayOfWeek(),"d"),Math.floor((n.dayOfYear()-1)/7)+1},daysInYear:function(e){if(e=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear).year(),void 0===this.NEPALI_CALENDAR_DATA[e])return this.daysPerYear;for(var t=0,r=this.minMonth;r<=12;r++)t+=this.NEPALI_CALENDAR_DATA[e][r];return t},daysInMonth:function(e,t){return e.year&&(t=e.month(),e=e.year()),this._validate(e,t,this.minDay,n.local.invalidMonth),void 0===this.NEPALI_CALENDAR_DATA[e]?this.daysPerMonth[t-1]:this.NEPALI_CALENDAR_DATA[e][t]},weekDay:function(e,t,r){return 6!==this.dayOfWeek(e,t,r)},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);e=a.year(),t=a.month(),r=a.day();var i=n.instance(),o=0,l=t,s=e;this._createMissingCalendarData(e);var c=e-(l>9||9===l&&r>=this.NEPALI_CALENDAR_DATA[s][0]?56:57);for(9!==t&&(o=r,l--);9!==l;)l<=0&&(l=12,s--),o+=this.NEPALI_CALENDAR_DATA[s][l],l--;return 9===t?(o+=r-this.NEPALI_CALENDAR_DATA[s][0])<0&&(o+=i.daysInYear(c)):o+=this.NEPALI_CALENDAR_DATA[s][9]-this.NEPALI_CALENDAR_DATA[s][0],i.newDate(c,1,1).add(o,"d").toJD()},fromJD:function(e){var t=n.instance().fromJD(e),r=t.year(),a=t.dayOfYear(),i=r+56;this._createMissingCalendarData(i);for(var o=9,l=this.NEPALI_CALENDAR_DATA[i][0],s=this.NEPALI_CALENDAR_DATA[i][o]-l+1;a>s;)++o>12&&(o=1,i++),s+=this.NEPALI_CALENDAR_DATA[i][o];var c=this.NEPALI_CALENDAR_DATA[i][o]-(s-a);return this.newDate(i,o,c)},_createMissingCalendarData:function(e){var t=this.daysPerMonth.slice(0);t.unshift(17);for(var r=e-1;r0?474:473))%2820+474+38)%2816<682},weekOfYear:function(e,t,r){var n=this.newDate(e,t,r);return n.add(-(n.dayOfWeek()+1)%7,"d"),Math.floor((n.dayOfYear()-1)/7)+1},daysInMonth:function(e,t){var r=this._validate(e,t,this.minDay,n.local.invalidMonth);return this.daysPerMonth[r.month()-1]+(12===r.month()&&this.leapYear(r.year())?1:0)},weekDay:function(e,t,r){return 5!==this.dayOfWeek(e,t,r)},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);e=a.year(),t=a.month(),r=a.day();var i=e-(e>=0?474:473),l=474+o(i,2820);return r+(t<=7?31*(t-1):30*(t-1)+6)+Math.floor((682*l-110)/2816)+365*(l-1)+1029983*Math.floor(i/2820)+this.jdEpoch-1},fromJD:function(e){var t=(e=Math.floor(e)+.5)-this.toJD(475,1,1),r=Math.floor(t/1029983),n=o(t,1029983),a=2820;if(1029982!==n){var i=Math.floor(n/366),l=o(n,366);a=Math.floor((2134*i+2816*l+2815)/1028522)+i+1}var s=a+2820*r+474;s=s<=0?s-1:s;var c=e-this.toJD(s,1,1)+1,u=c<=186?Math.ceil(c/31):Math.ceil((c-6)/30),f=e-this.toJD(s,u,1)+1;return this.newDate(s,u,f)}}),n.calendars.persian=i,n.calendars.jalali=i},4756:function(e,t,r){var n=r(6834),a=r(7976),i=n.instance();function o(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}o.prototype=new n.baseCalendar,a(o.prototype,{name:"Taiwan",jdEpoch:2419402.5,yearsOffset:1911,daysPerMonth:[31,28,31,30,31,30,31,31,30,31,30,31],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Taiwan",epochs:["BROC","ROC"],monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],digits:null,dateFormat:"yyyy/mm/dd",firstDay:1,isRTL:!1}},leapYear:function(e){var t=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear);return e=this._t2gYear(t.year()),i.leapYear(e)},weekOfYear:function(e,t,r){var a=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear);return e=this._t2gYear(a.year()),i.weekOfYear(e,a.month(),a.day())},daysInMonth:function(e,t){var r=this._validate(e,t,this.minDay,n.local.invalidMonth);return this.daysPerMonth[r.month()-1]+(2===r.month()&&this.leapYear(r.year())?1:0)},weekDay:function(e,t,r){return(this.dayOfWeek(e,t,r)||7)<6},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);return e=this._t2gYear(a.year()),i.toJD(e,a.month(),a.day())},fromJD:function(e){var t=i.fromJD(e),r=this._g2tYear(t.year());return this.newDate(r,t.month(),t.day())},_t2gYear:function(e){return e+this.yearsOffset+(e>=-this.yearsOffset&&e<=-1?1:0)},_g2tYear:function(e){return e-this.yearsOffset-(e>=1&&e<=this.yearsOffset?1:0)}}),n.calendars.taiwan=o},1858:function(e,t,r){var n=r(6834),a=r(7976),i=n.instance();function o(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}o.prototype=new n.baseCalendar,a(o.prototype,{name:"Thai",jdEpoch:1523098.5,yearsOffset:543,daysPerMonth:[31,28,31,30,31,30,31,31,30,31,30,31],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Thai",epochs:["BBE","BE"],monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:0,isRTL:!1}},leapYear:function(e){var t=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear);return e=this._t2gYear(t.year()),i.leapYear(e)},weekOfYear:function(e,t,r){var a=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear);return e=this._t2gYear(a.year()),i.weekOfYear(e,a.month(),a.day())},daysInMonth:function(e,t){var r=this._validate(e,t,this.minDay,n.local.invalidMonth);return this.daysPerMonth[r.month()-1]+(2===r.month()&&this.leapYear(r.year())?1:0)},weekDay:function(e,t,r){return(this.dayOfWeek(e,t,r)||7)<6},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate);return e=this._t2gYear(a.year()),i.toJD(e,a.month(),a.day())},fromJD:function(e){var t=i.fromJD(e),r=this._g2tYear(t.year());return this.newDate(r,t.month(),t.day())},_t2gYear:function(e){return e-this.yearsOffset-(e>=1&&e<=this.yearsOffset?1:0)},_g2tYear:function(e){return e+this.yearsOffset+(e>=-this.yearsOffset&&e<=-1?1:0)}}),n.calendars.thai=o},7985:function(e,t,r){var n=r(6834),a=r(7976);function i(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}i.prototype=new n.baseCalendar,a(i.prototype,{name:"UmmAlQura",hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Umm al-Qura",epochs:["BH","AH"],monthNames:["Al-Muharram","Safar","Rabi' al-awwal","Rabi' Al-Thani","Jumada Al-Awwal","Jumada Al-Thani","Rajab","Sha'aban","Ramadan","Shawwal","Dhu al-Qi'dah","Dhu al-Hijjah"],monthNamesShort:["Muh","Saf","Rab1","Rab2","Jum1","Jum2","Raj","Sha'","Ram","Shaw","DhuQ","DhuH"],dayNames:["Yawm al-Ahad","Yawm al-Ithnain","Yawm al-Thalāthā’","Yawm al-Arba‘ā’","Yawm al-Khamīs","Yawm al-Jum‘a","Yawm al-Sabt"],dayNamesMin:["Ah","Ith","Th","Ar","Kh","Ju","Sa"],digits:null,dateFormat:"yyyy/mm/dd",firstDay:6,isRTL:!0}},leapYear:function(e){var t=this._validate(e,this.minMonth,this.minDay,n.local.invalidYear);return 355===this.daysInYear(t.year())},weekOfYear:function(e,t,r){var n=this.newDate(e,t,r);return n.add(-n.dayOfWeek(),"d"),Math.floor((n.dayOfYear()-1)/7)+1},daysInYear:function(e){for(var t=0,r=1;r<=12;r++)t+=this.daysInMonth(e,r);return t},daysInMonth:function(e,t){for(var r=this._validate(e,t,this.minDay,n.local.invalidMonth).toJD()-24e5+.5,a=0,i=0;ir)return o[a]-o[a-1];a++}return 30},weekDay:function(e,t,r){return 5!==this.dayOfWeek(e,t,r)},toJD:function(e,t,r){var a=this._validate(e,t,r,n.local.invalidDate),i=12*(a.year()-1)+a.month()-15292;return a.day()+o[i-1]-1+24e5-.5},fromJD:function(e){for(var t=e-24e5+.5,r=0,n=0;nt);n++)r++;var a=r+15292,i=Math.floor((a-1)/12),l=i+1,s=a-12*i,c=t-o[r-1]+1;return this.newDate(l,s,c)},isValid:function(e,t,r){var a=n.baseCalendar.prototype.isValid.apply(this,arguments);return a&&(a=(e=null!=e.year?e.year:e)>=1276&&e<=1500),a},_validate:function(e,t,r,a){var i=n.baseCalendar.prototype._validate.apply(this,arguments);if(i.year<1276||i.year>1500)throw a.replace(/\{0\}/,this.local.name);return i}}),n.calendars.ummalqura=i;var o=[20,50,79,109,138,168,197,227,256,286,315,345,374,404,433,463,492,522,551,581,611,641,670,700,729,759,788,818,847,877,906,936,965,995,1024,1054,1083,1113,1142,1172,1201,1231,1260,1290,1320,1350,1379,1409,1438,1468,1497,1527,1556,1586,1615,1645,1674,1704,1733,1763,1792,1822,1851,1881,1910,1940,1969,1999,2028,2058,2087,2117,2146,2176,2205,2235,2264,2294,2323,2353,2383,2413,2442,2472,2501,2531,2560,2590,2619,2649,2678,2708,2737,2767,2796,2826,2855,2885,2914,2944,2973,3003,3032,3062,3091,3121,3150,3180,3209,3239,3268,3298,3327,3357,3386,3416,3446,3476,3505,3535,3564,3594,3623,3653,3682,3712,3741,3771,3800,3830,3859,3889,3918,3948,3977,4007,4036,4066,4095,4125,4155,4185,4214,4244,4273,4303,4332,4362,4391,4421,4450,4480,4509,4539,4568,4598,4627,4657,4686,4716,4745,4775,4804,4834,4863,4893,4922,4952,4981,5011,5040,5070,5099,5129,5158,5188,5218,5248,5277,5307,5336,5366,5395,5425,5454,5484,5513,5543,5572,5602,5631,5661,5690,5720,5749,5779,5808,5838,5867,5897,5926,5956,5985,6015,6044,6074,6103,6133,6162,6192,6221,6251,6281,6311,6340,6370,6399,6429,6458,6488,6517,6547,6576,6606,6635,6665,6694,6724,6753,6783,6812,6842,6871,6901,6930,6960,6989,7019,7048,7078,7107,7137,7166,7196,7225,7255,7284,7314,7344,7374,7403,7433,7462,7492,7521,7551,7580,7610,7639,7669,7698,7728,7757,7787,7816,7846,7875,7905,7934,7964,7993,8023,8053,8083,8112,8142,8171,8201,8230,8260,8289,8319,8348,8378,8407,8437,8466,8496,8525,8555,8584,8614,8643,8673,8702,8732,8761,8791,8821,8850,8880,8909,8938,8968,8997,9027,9056,9086,9115,9145,9175,9205,9234,9264,9293,9322,9352,9381,9410,9440,9470,9499,9529,9559,9589,9618,9648,9677,9706,9736,9765,9794,9824,9853,9883,9913,9943,9972,10002,10032,10061,10090,10120,10149,10178,10208,10237,10267,10297,10326,10356,10386,10415,10445,10474,10504,10533,10562,10592,10621,10651,10680,10710,10740,10770,10799,10829,10858,10888,10917,10947,10976,11005,11035,11064,11094,11124,11153,11183,11213,11242,11272,11301,11331,11360,11389,11419,11448,11478,11507,11537,11567,11596,11626,11655,11685,11715,11744,11774,11803,11832,11862,11891,11921,11950,11980,12010,12039,12069,12099,12128,12158,12187,12216,12246,12275,12304,12334,12364,12393,12423,12453,12483,12512,12542,12571,12600,12630,12659,12688,12718,12747,12777,12807,12837,12866,12896,12926,12955,12984,13014,13043,13072,13102,13131,13161,13191,13220,13250,13280,13310,13339,13368,13398,13427,13456,13486,13515,13545,13574,13604,13634,13664,13693,13723,13752,13782,13811,13840,13870,13899,13929,13958,13988,14018,14047,14077,14107,14136,14166,14195,14224,14254,14283,14313,14342,14372,14401,14431,14461,14490,14520,14550,14579,14609,14638,14667,14697,14726,14756,14785,14815,14844,14874,14904,14933,14963,14993,15021,15051,15081,15110,15140,15169,15199,15228,15258,15287,15317,15347,15377,15406,15436,15465,15494,15524,15553,15582,15612,15641,15671,15701,15731,15760,15790,15820,15849,15878,15908,15937,15966,15996,16025,16055,16085,16114,16144,16174,16204,16233,16262,16292,16321,16350,16380,16409,16439,16468,16498,16528,16558,16587,16617,16646,16676,16705,16734,16764,16793,16823,16852,16882,16912,16941,16971,17001,17030,17060,17089,17118,17148,17177,17207,17236,17266,17295,17325,17355,17384,17414,17444,17473,17502,17532,17561,17591,17620,17650,17679,17709,17738,17768,17798,17827,17857,17886,17916,17945,17975,18004,18034,18063,18093,18122,18152,18181,18211,18241,18270,18300,18330,18359,18388,18418,18447,18476,18506,18535,18565,18595,18625,18654,18684,18714,18743,18772,18802,18831,18860,18890,18919,18949,18979,19008,19038,19068,19098,19127,19156,19186,19215,19244,19274,19303,19333,19362,19392,19422,19452,19481,19511,19540,19570,19599,19628,19658,19687,19717,19746,19776,19806,19836,19865,19895,19924,19954,19983,20012,20042,20071,20101,20130,20160,20190,20219,20249,20279,20308,20338,20367,20396,20426,20455,20485,20514,20544,20573,20603,20633,20662,20692,20721,20751,20780,20810,20839,20869,20898,20928,20957,20987,21016,21046,21076,21105,21135,21164,21194,21223,21253,21282,21312,21341,21371,21400,21430,21459,21489,21519,21548,21578,21607,21637,21666,21696,21725,21754,21784,21813,21843,21873,21902,21932,21962,21991,22021,22050,22080,22109,22138,22168,22197,22227,22256,22286,22316,22346,22375,22405,22434,22464,22493,22522,22552,22581,22611,22640,22670,22700,22730,22759,22789,22818,22848,22877,22906,22936,22965,22994,23024,23054,23083,23113,23143,23173,23202,23232,23261,23290,23320,23349,23379,23408,23438,23467,23497,23527,23556,23586,23616,23645,23674,23704,23733,23763,23792,23822,23851,23881,23910,23940,23970,23999,24029,24058,24088,24117,24147,24176,24206,24235,24265,24294,24324,24353,24383,24413,24442,24472,24501,24531,24560,24590,24619,24648,24678,24707,24737,24767,24796,24826,24856,24885,24915,24944,24974,25003,25032,25062,25091,25121,25150,25180,25210,25240,25269,25299,25328,25358,25387,25416,25446,25475,25505,25534,25564,25594,25624,25653,25683,25712,25742,25771,25800,25830,25859,25888,25918,25948,25977,26007,26037,26067,26096,26126,26155,26184,26214,26243,26272,26302,26332,26361,26391,26421,26451,26480,26510,26539,26568,26598,26627,26656,26686,26715,26745,26775,26805,26834,26864,26893,26923,26952,26982,27011,27041,27070,27099,27129,27159,27188,27218,27248,27277,27307,27336,27366,27395,27425,27454,27484,27513,27542,27572,27602,27631,27661,27691,27720,27750,27779,27809,27838,27868,27897,27926,27956,27985,28015,28045,28074,28104,28134,28163,28193,28222,28252,28281,28310,28340,28369,28399,28428,28458,28488,28517,28547,28577,28607,28636,28665,28695,28724,28754,28783,28813,28843,28872,28901,28931,28960,28990,29019,29049,29078,29108,29137,29167,29196,29226,29255,29285,29315,29345,29375,29404,29434,29463,29492,29522,29551,29580,29610,29640,29669,29699,29729,29759,29788,29818,29847,29876,29906,29935,29964,29994,30023,30053,30082,30112,30141,30171,30200,30230,30259,30289,30318,30348,30378,30408,30437,30467,30496,30526,30555,30585,30614,30644,30673,30703,30732,30762,30791,30821,30850,30880,30909,30939,30968,30998,31027,31057,31086,31116,31145,31175,31204,31234,31263,31293,31322,31352,31381,31411,31441,31471,31500,31530,31559,31589,31618,31648,31676,31706,31736,31766,31795,31825,31854,31884,31913,31943,31972,32002,32031,32061,32090,32120,32150,32180,32209,32239,32268,32298,32327,32357,32386,32416,32445,32475,32504,32534,32563,32593,32622,32652,32681,32711,32740,32770,32799,32829,32858,32888,32917,32947,32976,33006,33035,33065,33094,33124,33153,33183,33213,33243,33272,33302,33331,33361,33390,33420,33450,33479,33509,33539,33568,33598,33627,33657,33686,33716,33745,33775,33804,33834,33863,33893,33922,33952,33981,34011,34040,34069,34099,34128,34158,34187,34217,34247,34277,34306,34336,34365,34395,34424,34454,34483,34512,34542,34571,34601,34631,34660,34690,34719,34749,34778,34808,34837,34867,34896,34926,34955,34985,35015,35044,35074,35103,35133,35162,35192,35222,35251,35280,35310,35340,35370,35399,35429,35458,35488,35517,35547,35576,35605,35635,35665,35694,35723,35753,35782,35811,35841,35871,35901,35930,35960,35989,36019,36048,36078,36107,36136,36166,36195,36225,36254,36284,36314,36343,36373,36403,36433,36462,36492,36521,36551,36580,36610,36639,36669,36698,36728,36757,36786,36816,36845,36875,36904,36934,36963,36993,37022,37052,37081,37111,37141,37170,37200,37229,37259,37288,37318,37347,37377,37406,37436,37465,37495,37524,37554,37584,37613,37643,37672,37701,37731,37760,37790,37819,37849,37878,37908,37938,37967,37997,38027,38056,38085,38115,38144,38174,38203,38233,38262,38292,38322,38351,38381,38410,38440,38469,38499,38528,38558,38587,38617,38646,38676,38705,38735,38764,38794,38823,38853,38882,38912,38941,38971,39001,39030,39059,39089,39118,39148,39178,39208,39237,39267,39297,39326,39355,39385,39414,39444,39473,39503,39532,39562,39592,39621,39650,39680,39709,39739,39768,39798,39827,39857,39886,39916,39946,39975,40005,40035,40064,40094,40123,40153,40182,40212,40241,40271,40300,40330,40359,40389,40418,40448,40477,40507,40536,40566,40595,40625,40655,40685,40714,40744,40773,40803,40832,40862,40892,40921,40951,40980,41009,41039,41068,41098,41127,41157,41186,41216,41245,41275,41304,41334,41364,41393,41422,41452,41481,41511,41540,41570,41599,41629,41658,41688,41718,41748,41777,41807,41836,41865,41894,41924,41953,41983,42012,42042,42072,42102,42131,42161,42190,42220,42249,42279,42308,42337,42367,42397,42426,42456,42485,42515,42545,42574,42604,42633,42662,42692,42721,42751,42780,42810,42839,42869,42899,42929,42958,42988,43017,43046,43076,43105,43135,43164,43194,43223,43253,43283,43312,43342,43371,43401,43430,43460,43489,43519,43548,43578,43607,43637,43666,43696,43726,43755,43785,43814,43844,43873,43903,43932,43962,43991,44021,44050,44080,44109,44139,44169,44198,44228,44258,44287,44317,44346,44375,44405,44434,44464,44493,44523,44553,44582,44612,44641,44671,44700,44730,44759,44788,44818,44847,44877,44906,44936,44966,44996,45025,45055,45084,45114,45143,45172,45202,45231,45261,45290,45320,45350,45380,45409,45439,45468,45498,45527,45556,45586,45615,45644,45674,45704,45733,45763,45793,45823,45852,45882,45911,45940,45970,45999,46028,46058,46088,46117,46147,46177,46206,46236,46265,46295,46324,46354,46383,46413,46442,46472,46501,46531,46560,46590,46620,46649,46679,46708,46738,46767,46797,46826,46856,46885,46915,46944,46974,47003,47033,47063,47092,47122,47151,47181,47210,47240,47269,47298,47328,47357,47387,47417,47446,47476,47506,47535,47565,47594,47624,47653,47682,47712,47741,47771,47800,47830,47860,47890,47919,47949,47978,48008,48037,48066,48096,48125,48155,48184,48214,48244,48273,48303,48333,48362,48392,48421,48450,48480,48509,48538,48568,48598,48627,48657,48687,48717,48746,48776,48805,48834,48864,48893,48922,48952,48982,49011,49041,49071,49100,49130,49160,49189,49218,49248,49277,49306,49336,49365,49395,49425,49455,49484,49514,49543,49573,49602,49632,49661,49690,49720,49749,49779,49809,49838,49868,49898,49927,49957,49986,50016,50045,50075,50104,50133,50163,50192,50222,50252,50281,50311,50340,50370,50400,50429,50459,50488,50518,50547,50576,50606,50635,50665,50694,50724,50754,50784,50813,50843,50872,50902,50931,50960,50990,51019,51049,51078,51108,51138,51167,51197,51227,51256,51286,51315,51345,51374,51403,51433,51462,51492,51522,51552,51582,51611,51641,51670,51699,51729,51758,51787,51816,51846,51876,51906,51936,51965,51995,52025,52054,52083,52113,52142,52171,52200,52230,52260,52290,52319,52349,52379,52408,52438,52467,52497,52526,52555,52585,52614,52644,52673,52703,52733,52762,52792,52822,52851,52881,52910,52939,52969,52998,53028,53057,53087,53116,53146,53176,53205,53235,53264,53294,53324,53353,53383,53412,53441,53471,53500,53530,53559,53589,53619,53648,53678,53708,53737,53767,53796,53825,53855,53884,53913,53943,53973,54003,54032,54062,54092,54121,54151,54180,54209,54239,54268,54297,54327,54357,54387,54416,54446,54476,54505,54535,54564,54593,54623,54652,54681,54711,54741,54770,54800,54830,54859,54889,54919,54948,54977,55007,55036,55066,55095,55125,55154,55184,55213,55243,55273,55302,55332,55361,55391,55420,55450,55479,55508,55538,55567,55597,55627,55657,55686,55716,55745,55775,55804,55834,55863,55892,55922,55951,55981,56011,56040,56070,56100,56129,56159,56188,56218,56247,56276,56306,56335,56365,56394,56424,56454,56483,56513,56543,56572,56601,56631,56660,56690,56719,56749,56778,56808,56837,56867,56897,56926,56956,56985,57015,57044,57074,57103,57133,57162,57192,57221,57251,57280,57310,57340,57369,57399,57429,57458,57487,57517,57546,57576,57605,57634,57664,57694,57723,57753,57783,57813,57842,57871,57901,57930,57959,57989,58018,58048,58077,58107,58137,58167,58196,58226,58255,58285,58314,58343,58373,58402,58432,58461,58491,58521,58551,58580,58610,58639,58669,58698,58727,58757,58786,58816,58845,58875,58905,58934,58964,58994,59023,59053,59082,59111,59141,59170,59200,59229,59259,59288,59318,59348,59377,59407,59436,59466,59495,59525,59554,59584,59613,59643,59672,59702,59731,59761,59791,59820,59850,59879,59909,59939,59968,59997,60027,60056,60086,60115,60145,60174,60204,60234,60264,60293,60323,60352,60381,60411,60440,60469,60499,60528,60558,60588,60618,60648,60677,60707,60736,60765,60795,60824,60853,60883,60912,60942,60972,61002,61031,61061,61090,61120,61149,61179,61208,61237,61267,61296,61326,61356,61385,61415,61445,61474,61504,61533,61563,61592,61621,61651,61680,61710,61739,61769,61799,61828,61858,61888,61917,61947,61976,62006,62035,62064,62094,62123,62153,62182,62212,62242,62271,62301,62331,62360,62390,62419,62448,62478,62507,62537,62566,62596,62625,62655,62685,62715,62744,62774,62803,62832,62862,62891,62921,62950,62980,63009,63039,63069,63099,63128,63157,63187,63216,63246,63275,63305,63334,63363,63393,63423,63453,63482,63512,63541,63571,63600,63630,63659,63689,63718,63747,63777,63807,63836,63866,63895,63925,63955,63984,64014,64043,64073,64102,64131,64161,64190,64220,64249,64279,64309,64339,64368,64398,64427,64457,64486,64515,64545,64574,64603,64633,64663,64692,64722,64752,64782,64811,64841,64870,64899,64929,64958,64987,65017,65047,65076,65106,65136,65166,65195,65225,65254,65283,65313,65342,65371,65401,65431,65460,65490,65520,65549,65579,65608,65638,65667,65697,65726,65755,65785,65815,65844,65874,65903,65933,65963,65992,66022,66051,66081,66110,66140,66169,66199,66228,66258,66287,66317,66346,66376,66405,66435,66465,66494,66524,66553,66583,66612,66641,66671,66700,66730,66760,66789,66819,66849,66878,66908,66937,66967,66996,67025,67055,67084,67114,67143,67173,67203,67233,67262,67292,67321,67351,67380,67409,67439,67468,67497,67527,67557,67587,67617,67646,67676,67705,67735,67764,67793,67823,67852,67882,67911,67941,67971,68e3,68030,68060,68089,68119,68148,68177,68207,68236,68266,68295,68325,68354,68384,68414,68443,68473,68502,68532,68561,68591,68620,68650,68679,68708,68738,68768,68797,68827,68857,68886,68916,68946,68975,69004,69034,69063,69092,69122,69152,69181,69211,69240,69270,69300,69330,69359,69388,69418,69447,69476,69506,69535,69565,69595,69624,69654,69684,69713,69743,69772,69802,69831,69861,69890,69919,69949,69978,70008,70038,70067,70097,70126,70156,70186,70215,70245,70274,70303,70333,70362,70392,70421,70451,70481,70510,70540,70570,70599,70629,70658,70687,70717,70746,70776,70805,70835,70864,70894,70924,70954,70983,71013,71042,71071,71101,71130,71159,71189,71218,71248,71278,71308,71337,71367,71397,71426,71455,71485,71514,71543,71573,71602,71632,71662,71691,71721,71751,71781,71810,71839,71869,71898,71927,71957,71986,72016,72046,72075,72105,72135,72164,72194,72223,72253,72282,72311,72341,72370,72400,72429,72459,72489,72518,72548,72577,72607,72637,72666,72695,72725,72754,72784,72813,72843,72872,72902,72931,72961,72991,73020,73050,73080,73109,73139,73168,73197,73227,73256,73286,73315,73345,73375,73404,73434,73464,73493,73523,73552,73581,73611,73640,73669,73699,73729,73758,73788,73818,73848,73877,73907,73936,73965,73995,74024,74053,74083,74113,74142,74172,74202,74231,74261,74291,74320,74349,74379,74408,74437,74467,74497,74526,74556,74586,74615,74645,74675,74704,74733,74763,74792,74822,74851,74881,74910,74940,74969,74999,75029,75058,75088,75117,75147,75176,75206,75235,75264,75294,75323,75353,75383,75412,75442,75472,75501,75531,75560,75590,75619,75648,75678,75707,75737,75766,75796,75826,75856,75885,75915,75944,75974,76003,76032,76062,76091,76121,76150,76180,76210,76239,76269,76299,76328,76358,76387,76416,76446,76475,76505,76534,76564,76593,76623,76653,76682,76712,76741,76771,76801,76830,76859,76889,76918,76948,76977,77007,77036,77066,77096,77125,77155,77185,77214,77243,77273,77302,77332,77361,77390,77420,77450,77479,77509,77539,77569,77598,77627,77657,77686,77715,77745,77774,77804,77833,77863,77893,77923,77952,77982,78011,78041,78070,78099,78129,78158,78188,78217,78247,78277,78307,78336,78366,78395,78425,78454,78483,78513,78542,78572,78601,78631,78661,78690,78720,78750,78779,78808,78838,78867,78897,78926,78956,78985,79015,79044,79074,79104,79133,79163,79192,79222,79251,79281,79310,79340,79369,79399,79428,79458,79487,79517,79546,79576,79606,79635,79665,79695,79724,79753,79783,79812,79841,79871,79900,79930,79960,79990]},6834:function(e,t,r){var n=r(7976);function a(){this.regionalOptions=[],this.regionalOptions[""]={invalidCalendar:"Calendar {0} not found",invalidDate:"Invalid {0} date",invalidMonth:"Invalid {0} month",invalidYear:"Invalid {0} year",differentCalendars:"Cannot mix {0} and {1} dates"},this.local=this.regionalOptions[""],this.calendars={},this._localCals={}}function i(e,t,r,n){if(this._calendar=e,this._year=t,this._month=r,this._day=n,0===this._calendar._validateLevel&&!this._calendar.isValid(this._year,this._month,this._day))throw(c.local.invalidDate||c.regionalOptions[""].invalidDate).replace(/\{0\}/,this._calendar.local.name)}function o(e,t){return"000000".substring(0,t-(e=""+e).length)+e}function l(){this.shortYearCutoff="+10"}function s(e){this.local=this.regionalOptions[e]||this.regionalOptions[""]}n(a.prototype,{instance:function(e,t){e=(e||"gregorian").toLowerCase(),t=t||"";var r=this._localCals[e+"-"+t];if(!r&&this.calendars[e]&&(r=new this.calendars[e](t),this._localCals[e+"-"+t]=r),!r)throw(this.local.invalidCalendar||this.regionalOptions[""].invalidCalendar).replace(/\{0\}/,e);return r},newDate:function(e,t,r,n,a){return(n=(null!=e&&e.year?e.calendar():"string"==typeof n?this.instance(n,a):n)||this.instance()).newDate(e,t,r)},substituteDigits:function(e){return function(t){return(t+"").replace(/[0-9]/g,(function(t){return e[t]}))}},substituteChineseDigits:function(e,t){return function(r){for(var n="",a=0;r>0;){var i=r%10;n=(0===i?"":e[i]+t[a])+n,a++,r=Math.floor(r/10)}return 0===n.indexOf(e[1]+t[1])&&(n=n.substr(1)),n||e[0]}}}),n(i.prototype,{newDate:function(e,t,r){return this._calendar.newDate(null==e?this:e,t,r)},year:function(e){return 0===arguments.length?this._year:this.set(e,"y")},month:function(e){return 0===arguments.length?this._month:this.set(e,"m")},day:function(e){return 0===arguments.length?this._day:this.set(e,"d")},date:function(e,t,r){if(!this._calendar.isValid(e,t,r))throw(c.local.invalidDate||c.regionalOptions[""].invalidDate).replace(/\{0\}/,this._calendar.local.name);return this._year=e,this._month=t,this._day=r,this},leapYear:function(){return this._calendar.leapYear(this)},epoch:function(){return this._calendar.epoch(this)},formatYear:function(){return this._calendar.formatYear(this)},monthOfYear:function(){return this._calendar.monthOfYear(this)},weekOfYear:function(){return this._calendar.weekOfYear(this)},daysInYear:function(){return this._calendar.daysInYear(this)},dayOfYear:function(){return this._calendar.dayOfYear(this)},daysInMonth:function(){return this._calendar.daysInMonth(this)},dayOfWeek:function(){return this._calendar.dayOfWeek(this)},weekDay:function(){return this._calendar.weekDay(this)},extraInfo:function(){return this._calendar.extraInfo(this)},add:function(e,t){return this._calendar.add(this,e,t)},set:function(e,t){return this._calendar.set(this,e,t)},compareTo:function(e){if(this._calendar.name!==e._calendar.name)throw(c.local.differentCalendars||c.regionalOptions[""].differentCalendars).replace(/\{0\}/,this._calendar.local.name).replace(/\{1\}/,e._calendar.local.name);var t=this._year!==e._year?this._year-e._year:this._month!==e._month?this.monthOfYear()-e.monthOfYear():this._day-e._day;return 0===t?0:t<0?-1:1},calendar:function(){return this._calendar},toJD:function(){return this._calendar.toJD(this)},fromJD:function(e){return this._calendar.fromJD(e)},toJSDate:function(){return this._calendar.toJSDate(this)},fromJSDate:function(e){return this._calendar.fromJSDate(e)},toString:function(){return(this.year()<0?"-":"")+o(Math.abs(this.year()),4)+"-"+o(this.month(),2)+"-"+o(this.day(),2)}}),n(l.prototype,{_validateLevel:0,newDate:function(e,t,r){return null==e?this.today():(e.year&&(this._validate(e,t,r,c.local.invalidDate||c.regionalOptions[""].invalidDate),r=e.day(),t=e.month(),e=e.year()),new i(this,e,t,r))},today:function(){return this.fromJSDate(new Date)},epoch:function(e){return this._validate(e,this.minMonth,this.minDay,c.local.invalidYear||c.regionalOptions[""].invalidYear).year()<0?this.local.epochs[0]:this.local.epochs[1]},formatYear:function(e){var t=this._validate(e,this.minMonth,this.minDay,c.local.invalidYear||c.regionalOptions[""].invalidYear);return(t.year()<0?"-":"")+o(Math.abs(t.year()),4)},monthsInYear:function(e){return this._validate(e,this.minMonth,this.minDay,c.local.invalidYear||c.regionalOptions[""].invalidYear),12},monthOfYear:function(e,t){var r=this._validate(e,t,this.minDay,c.local.invalidMonth||c.regionalOptions[""].invalidMonth);return(r.month()+this.monthsInYear(r)-this.firstMonth)%this.monthsInYear(r)+this.minMonth},fromMonthOfYear:function(e,t){var r=(t+this.firstMonth-2*this.minMonth)%this.monthsInYear(e)+this.minMonth;return this._validate(e,r,this.minDay,c.local.invalidMonth||c.regionalOptions[""].invalidMonth),r},daysInYear:function(e){var t=this._validate(e,this.minMonth,this.minDay,c.local.invalidYear||c.regionalOptions[""].invalidYear);return this.leapYear(t)?366:365},dayOfYear:function(e,t,r){var n=this._validate(e,t,r,c.local.invalidDate||c.regionalOptions[""].invalidDate);return n.toJD()-this.newDate(n.year(),this.fromMonthOfYear(n.year(),this.minMonth),this.minDay).toJD()+1},daysInWeek:function(){return 7},dayOfWeek:function(e,t,r){var n=this._validate(e,t,r,c.local.invalidDate||c.regionalOptions[""].invalidDate);return(Math.floor(this.toJD(n))+2)%this.daysInWeek()},extraInfo:function(e,t,r){return this._validate(e,t,r,c.local.invalidDate||c.regionalOptions[""].invalidDate),{}},add:function(e,t,r){return this._validate(e,this.minMonth,this.minDay,c.local.invalidDate||c.regionalOptions[""].invalidDate),this._correctAdd(e,this._add(e,t,r),t,r)},_add:function(e,t,r){if(this._validateLevel++,"d"===r||"w"===r){var n=e.toJD()+t*("w"===r?this.daysInWeek():1),a=e.calendar().fromJD(n);return this._validateLevel--,[a.year(),a.month(),a.day()]}try{var i=e.year()+("y"===r?t:0),o=e.monthOfYear()+("m"===r?t:0);a=e.day(),"y"===r?(e.month()!==this.fromMonthOfYear(i,o)&&(o=this.newDate(i,e.month(),this.minDay).monthOfYear()),o=Math.min(o,this.monthsInYear(i)),a=Math.min(a,this.daysInMonth(i,this.fromMonthOfYear(i,o)))):"m"===r&&(function(e){for(;ot-1+e.minMonth;)i++,o-=t,t=e.monthsInYear(i)}(this),a=Math.min(a,this.daysInMonth(i,this.fromMonthOfYear(i,o))));var l=[i,this.fromMonthOfYear(i,o),a];return this._validateLevel--,l}catch(e){throw this._validateLevel--,e}},_correctAdd:function(e,t,r,n){if(!(this.hasYearZero||"y"!==n&&"m"!==n||0!==t[0]&&e.year()>0==t[0]>0)){var a={y:[1,1,"y"],m:[1,this.monthsInYear(-1),"m"],w:[this.daysInWeek(),this.daysInYear(-1),"d"],d:[1,this.daysInYear(-1),"d"]}[n],i=r<0?-1:1;t=this._add(e,r*a[0]+i*a[1],a[2])}return e.date(t[0],t[1],t[2])},set:function(e,t,r){this._validate(e,this.minMonth,this.minDay,c.local.invalidDate||c.regionalOptions[""].invalidDate);var n="y"===r?t:e.year(),a="m"===r?t:e.month(),i="d"===r?t:e.day();return"y"!==r&&"m"!==r||(i=Math.min(i,this.daysInMonth(n,a))),e.date(n,a,i)},isValid:function(e,t,r){this._validateLevel++;var n=this.hasYearZero||0!==e;if(n){var a=this.newDate(e,t,this.minDay);n=t>=this.minMonth&&t-this.minMonth=this.minDay&&r-this.minDay13.5?13:1),c=a-(s>2.5?4716:4715);return c<=0&&c--,this.newDate(c,s,l)},toJSDate:function(e,t,r){var n=this._validate(e,t,r,c.local.invalidDate||c.regionalOptions[""].invalidDate),a=new Date(n.year(),n.month()-1,n.day());return a.setHours(0),a.setMinutes(0),a.setSeconds(0),a.setMilliseconds(0),a.setHours(a.getHours()>12?a.getHours()+2:0),a},fromJSDate:function(e){return this.newDate(e.getFullYear(),e.getMonth()+1,e.getDate())}});var c=e.exports=new a;c.cdate=i,c.baseCalendar=l,c.calendars.gregorian=s},3428:function(e,t,r){var n=r(7976),a=r(6834);n(a.regionalOptions[""],{invalidArguments:"Invalid arguments",invalidFormat:"Cannot format a date from another calendar",missingNumberAt:"Missing number at position {0}",unknownNameAt:"Unknown name at position {0}",unexpectedLiteralAt:"Unexpected literal at position {0}",unexpectedText:"Additional text found at end"}),a.local=a.regionalOptions[""],n(a.cdate.prototype,{formatDate:function(e,t){return"string"!=typeof e&&(t=e,e=""),this._calendar.formatDate(e||"",this,t)}}),n(a.baseCalendar.prototype,{UNIX_EPOCH:a.instance().newDate(1970,1,1).toJD(),SECS_PER_DAY:86400,TICKS_EPOCH:a.instance().jdEpoch,TICKS_PER_DAY:864e9,ATOM:"yyyy-mm-dd",COOKIE:"D, dd M yyyy",FULL:"DD, MM d, yyyy",ISO_8601:"yyyy-mm-dd",JULIAN:"J",RFC_822:"D, d M yy",RFC_850:"DD, dd-M-yy",RFC_1036:"D, d M yy",RFC_1123:"D, d M yyyy",RFC_2822:"D, d M yyyy",RSS:"D, d M yy",TICKS:"!",TIMESTAMP:"@",W3C:"yyyy-mm-dd",formatDate:function(e,t,r){if("string"!=typeof e&&(r=t,t=e,e=""),!t)return"";if(t.calendar()!==this)throw a.local.invalidFormat||a.regionalOptions[""].invalidFormat;e=e||this.local.dateFormat;for(var n,i,o,l=(r=r||{}).dayNamesShort||this.local.dayNamesShort,s=r.dayNames||this.local.dayNames,c=r.monthNumbers||this.local.monthNumbers,u=r.monthNamesShort||this.local.monthNamesShort,f=r.monthNames||this.local.monthNames,d=(r.calculateWeek||this.local.calculateWeek,function(t,r){for(var n=1;_+n1}),h=function(e,t,r,n){var a=""+t;if(d(e,n))for(;a.length1},x=function(e,r){var n=y(e,r),i=[2,3,n?4:2,n?4:2,10,11,20]["oyYJ@!".indexOf(e)+1],o=new RegExp("^-?\\d{1,"+i+"}"),l=t.substring(k).match(o);if(!l)throw(a.local.missingNumberAt||a.regionalOptions[""].missingNumberAt).replace(/\{0\}/,k);return k+=l[0].length,parseInt(l[0],10)},b=this,_=function(){if("function"==typeof s){y("m");var e=s.call(b,t.substring(k));return k+=e.length,e}return x("m")},w=function(e,r,n,i){for(var o=y(e,i)?n:r,l=0;l-1){h=1,p=v;for(var S=this.daysInMonth(d,h);p>S;S=this.daysInMonth(d,h))h++,p-=S}return f>-1?this.fromJD(f):this.newDate(d,h,p)},determineDate:function(e,t,r,n,a){r&&"object"!=typeof r&&(a=n,n=r,r=null),"string"!=typeof n&&(a=n,n="");var i=this;return t=t?t.newDate():null,null==e?t:"string"==typeof e?function(e){try{return i.parseDate(n,e,a)}catch(e){}for(var t=((e=e.toLowerCase()).match(/^c/)&&r?r.newDate():null)||i.today(),o=/([+-]?[0-9]+)\s*(d|w|m|y)?/g,l=o.exec(e);l;)t.add(parseInt(l[1],10),l[2]||"d"),l=o.exec(e);return t}(e):"number"==typeof e?isNaN(e)||e===1/0||e===-1/0?t:i.today().add(e,"d"):i.newDate(e)}})},6144:function(e,t,r){"use strict";r.r(t);var n=r(5072),a=r.n(n),i=r(7825),o=r.n(i),l=r(7659),s=r.n(l),c=r(5056),u=r.n(c),f=r(540),d=r.n(f),h=r(1113),p=r.n(h),v=r(5955),g={};g.styleTagTransform=p(),g.setAttributes=u(),g.insert=s().bind(null,"head"),g.domAPI=o(),g.insertStyleElement=d(),a()(v.A,g),t.default=v.A&&v.A.locals?v.A.locals:void 0},5072:function(e){"use strict";var t=[];function r(e){for(var r=-1,n=0;n0?" ".concat(r.layer):""," {")),n+=r.css,a&&(n+="}"),r.media&&(n+="}"),r.supports&&(n+="}");var i=r.sourceMap;i&&"undefined"!=typeof btoa&&(n+="\n/*# sourceMappingURL=data:application/json;base64,".concat(btoa(unescape(encodeURIComponent(JSON.stringify(i))))," */")),t.styleTagTransform(n,e,t.options)}(t,e,r)},remove:function(){!function(e){if(null===e.parentNode)return!1;e.parentNode.removeChild(e)}(t)}}}},1113:function(e){"use strict";e.exports=function(e,t){if(t.styleSheet)t.styleSheet.cssText=e;else{for(;t.firstChild;)t.removeChild(t.firstChild);t.appendChild(document.createTextNode(e))}}},5446:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2722%27 height=%2722%27 fill=%27%23333%27 viewBox=%270 0 22 22%27%3E%3Cpath d=%27m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0%27/%3E%3C/svg%3E"},6694:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2722%27 height=%2722%27 fill=%27%2333b5e5%27 viewBox=%270 0 22 22%27%3E%3Cpath d=%27m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0%27/%3E%3C/svg%3E"},6117:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2724%27 height=%2724%27 fill-rule=%27evenodd%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0%27/%3E%3C/svg%3E"},6311:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2724%27 height=%2724%27 fill=%27%23fff%27 fill-rule=%27evenodd%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0%27/%3E%3C/svg%3E"},4420:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23333%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7%27/%3E%3Ccircle cx=%2710%27 cy=%2710%27 r=%272%27/%3E%3C/svg%3E"},7035:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23333%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z%27/%3E%3C/svg%3E"},3470:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23333%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5%27/%3E%3C/svg%3E"},3490:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23333%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z%27/%3E%3C/svg%3E"},216:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23333%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27m10.5 14 4-8 4 8z%27/%3E%3Cpath fill=%27%23ccc%27 d=%27m10.5 16 4 8 4-8z%27/%3E%3C/svg%3E"},7695:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%2333b5e5%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7%27/%3E%3C/svg%3E"},2228:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%2333b5e5%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7%27/%3E%3Ccircle cx=%2710%27 cy=%2710%27 r=%272%27/%3E%3C/svg%3E"},3737:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23666%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7%27/%3E%3Ccircle cx=%2710%27 cy=%2710%27 r=%272%27/%3E%3Cpath fill=%27red%27 d=%27m14 5 1 1-9 9-1-1z%27/%3E%3C/svg%3E"},8460:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23999%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7%27/%3E%3Ccircle cx=%2710%27 cy=%2710%27 r=%272%27/%3E%3Cpath fill=%27red%27 d=%27m14 5 1 1-9 9-1-1z%27/%3E%3C/svg%3E"},5796:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23aaa%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7%27/%3E%3Ccircle cx=%2710%27 cy=%2710%27 r=%272%27/%3E%3Cpath fill=%27red%27 d=%27m14 5 1 1-9 9-1-1z%27/%3E%3C/svg%3E"},8869:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23e54e33%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7%27/%3E%3C/svg%3E"},9819:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23e58978%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7%27/%3E%3Ccircle cx=%2710%27 cy=%2710%27 r=%272%27/%3E%3C/svg%3E"},557:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23fff%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7%27/%3E%3Ccircle cx=%2710%27 cy=%2710%27 r=%272%27/%3E%3C/svg%3E"},8164:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23fff%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z%27/%3E%3C/svg%3E"},4665:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23fff%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5%27/%3E%3C/svg%3E"},1413:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23fff%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z%27/%3E%3C/svg%3E"},3913:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23fff%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z%27/%3E%3C/svg%3E"},1907:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 fill=%27%23fff%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27m10.5 14 4-8 4 8z%27/%3E%3Cpath fill=%27%23ccc%27 d=%27m10.5 16 4 8 4-8z%27/%3E%3C/svg%3E"},8920:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 viewBox=%270 0 20 20%27%3E%3Cpath d=%27M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7%27/%3E%3Ccircle cx=%2710%27 cy=%2710%27 r=%272%27/%3E%3C/svg%3E"},4890:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z%27/%3E%3C/svg%3E"},3363:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5%27/%3E%3C/svg%3E"},7603:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z%27/%3E%3C/svg%3E"},4643:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z%27/%3E%3C/svg%3E"},8605:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2729%27 height=%2729%27 viewBox=%270 0 29 29%27%3E%3Cpath d=%27m10.5 14 4-8 4 8z%27/%3E%3Cpath fill=%27%23ccc%27 d=%27m10.5 16 4 8 4-8z%27/%3E%3C/svg%3E"},7914:function(e){"use strict";e.exports="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2788%27 height=%2723%27 fill=%27none%27%3E%3Cpath fill=%27%23000%27 fill-opacity=%27.4%27 fill-rule=%27evenodd%27 d=%27M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z%27/%3E%3Cpath fill=%27%23fff%27 d=%27m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z%27/%3E%3Cpath fill=%27%23e1e3e9%27 d=%27M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z%27/%3E%3Cpath d=%27M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z%27 style=%27fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001%27/%3E%3Cg style=%27stroke-width:1.12603545%27%3E%3Cpath d=%27M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668%27 style=%27color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto%27 transform=%27translate%2815.553 2.85%29scale%28.88807%29%27/%3E%3Cpath d=%27M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3%27 style=%27clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4%27 transform=%27translate%2815.553 2.85%29scale%28.88807%29%27/%3E%3Cpath d=%27M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z%27 style=%27clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4%27 transform=%27translate%2815.553 2.85%29scale%28.88807%29%27/%3E%3C/g%3E%3C/svg%3E"}},t={};function r(n){var a=t[n];if(void 0!==a)return a.exports;var i=t[n]={id:n,exports:{}};return e[n].call(i.exports,i,i.exports,r),i.exports}return r.m=e,r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,{a:t}),t},r.d=function(e,t){for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.b=document.baseURI||self.location.href,r.nc=void 0,r(5611)}()})); \ No newline at end of file diff --git a/src/Aspire.Dashboard/wwwroot/js/plotly-basic-4.0.0-rc.0.min.js b/src/Aspire.Dashboard/wwwroot/js/plotly-basic-4.0.0-rc.0.min.js new file mode 100644 index 00000000000..826faff8c30 --- /dev/null +++ b/src/Aspire.Dashboard/wwwroot/js/plotly-basic-4.0.0-rc.0.min.js @@ -0,0 +1,47 @@ +/** +* plotly.js (basic - minified) v4.0.0-rc.0 +* Copyright 2012-2026, Plotly, Inc. +* All rights reserved. +* Licensed under the MIT license +*/ +( + function(root, factory) { + if (typeof module === "object" && module.exports) { + module.exports = factory(); + } else { + root.moduleName = factory(); + } +} (typeof self !== "undefined" ? self : this, () => { +"use strict";var Plotly=(()=>{var eH=Object.create;var hf=Object.defineProperty,rH=Object.defineProperties,tH=Object.getOwnPropertyDescriptor,aH=Object.getOwnPropertyDescriptors,nH=Object.getOwnPropertyNames,Jb=Object.getOwnPropertySymbols,iH=Object.getPrototypeOf,$b=Object.prototype.hasOwnProperty,lH=Object.prototype.propertyIsEnumerable;var Kb=(e,r,t)=>r in e?hf(e,r,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[r]=t,mo=(e,r)=>{for(var t in r||(r={}))$b.call(r,t)&&Kb(e,t,r[t]);if(Jb)for(var t of Jb(r))lH.call(r,t)&&Kb(e,t,r[t]);return e},F0=(e,r)=>rH(e,aH(r));var Ln=(e,r,t)=>()=>{if(t)throw t[0];try{return e&&(r=e(e=0)),r}catch(a){throw t=[a],a}};var X=(e,r)=>()=>{try{return r||e((r={exports:{}}).exports,r),r.exports}catch(t){throw r=0,t}},qs=(e,r)=>{for(var t in r)hf(e,t,{get:r[t],enumerable:!0})},Qb=(e,r,t,a)=>{if(r&&typeof r=="object"||typeof r=="function")for(let n of nH(r))!$b.call(e,n)&&n!==t&&hf(e,n,{get:()=>r[n],enumerable:!(a=tH(r,n))||a.enumerable});return e};var _p=(e,r,t)=>(t=e!=null?eH(iH(e)):{},Qb(r||!e||!e.__esModule?hf(t,"default",{value:e,enumerable:!0}):t,e)),Ia=e=>Qb(hf({},"__esModule",{value:!0}),e);var df=X(jb=>{"use strict";jb.version="4.0.0-rc.0"});var r_=X((e_,I0)=>{(function(r,t,a){t[r]=t[r]||a(),typeof I0!="undefined"&&I0.exports&&(I0.exports=t[r])})("Promise",typeof window!="undefined"?window:e_,function(){"use strict";var r,t,a,n=Object.prototype.toString,i=typeof setImmediate!="undefined"?function(T){return setImmediate(T)}:setTimeout;try{Object.defineProperty({},"x",{}),r=function(T,M,b,w){return Object.defineProperty(T,M,{value:b,writable:!0,configurable:w!==!1})}}catch(x){r=function(M,b,w){return M[b]=w,M}}a=(function(){var T,M,b;function w(S,k){this.fn=S,this.self=k,this.next=void 0}return{add:function(k,q){b=new w(k,q),M?M.next=b:T=b,M=b,b=void 0},drain:function(){var k=T;for(T=M=t=void 0;k;)k.fn.call(k.self),k=k.next}}})();function l(x,T){a.add(x,T),t||(t=i(a.drain))}function o(x){var T,M=typeof x;return x!=null&&(M=="object"||M=="function")&&(T=x.then),typeof T=="function"?T:!1}function s(){for(var x=0;x0&&l(s,M))}catch(b){v.call(new d(M),b)}}}function v(x){var T=this;T.triggered||(T.triggered=!0,T.def&&(T=T.def),T.msg=x,T.state=2,T.chain.length>0&&l(s,T))}function h(x,T,M,b){for(var w=0;w{(function(){var e={version:"3.8.2"},r=[].slice,t=function(c){return r.call(c)},a=self.document;function n(c){return c&&(c.ownerDocument||c.document||c).documentElement}function i(c){return c&&(c.ownerDocument&&c.ownerDocument.defaultView||c.document&&c||c.defaultView)}if(a)try{t(a.documentElement.childNodes)[0].nodeType}catch(c){t=function(p){for(var _=p.length,A=new Array(_);_--;)A[_]=p[_];return A}}if(Date.now||(Date.now=function(){return+new Date}),a)try{a.createElement("DIV").style.setProperty("opacity",0,"")}catch(c){var l=this.Element.prototype,o=l.setAttribute,s=l.setAttributeNS,u=this.CSSStyleDeclaration.prototype,f=u.setProperty;l.setAttribute=function(p,_){o.call(this,p,_+"")},l.setAttributeNS=function(p,_,A){s.call(this,p,_,A+"")},u.setProperty=function(p,_,A){f.call(this,p,_+"",A)}}e.ascending=v;function v(c,p){return cp?1:c>=p?0:NaN}e.descending=function(c,p){return pc?1:p>=c?0:NaN},e.min=function(c,p){var _=-1,A=c.length,C,L;if(arguments.length===1){for(;++_=L){C=L;break}for(;++_L&&(C=L)}else{for(;++_=L){C=L;break}for(;++_L&&(C=L)}return C},e.max=function(c,p){var _=-1,A=c.length,C,L;if(arguments.length===1){for(;++_=L){C=L;break}for(;++_C&&(C=L)}else{for(;++_=L){C=L;break}for(;++_C&&(C=L)}return C},e.extent=function(c,p){var _=-1,A=c.length,C,L,R;if(arguments.length===1){for(;++_=L){C=R=L;break}for(;++_L&&(C=L),R=L){C=R=L;break}for(;++_L&&(C=L),R1)return R/(N-1)},e.deviation=function(){var c=e.variance.apply(this,arguments);return c&&Math.sqrt(c)};function m(c){return{left:function(p,_,A,C){for(arguments.length<3&&(A=0),arguments.length<4&&(C=p.length);A>>1;c(p[L],_)<0?A=L+1:C=L}return A},right:function(p,_,A,C){for(arguments.length<3&&(A=0),arguments.length<4&&(C=p.length);A>>1;c(p[L],_)>0?C=L:A=L+1}return A}}}var y=m(v);e.bisectLeft=y.left,e.bisect=e.bisectRight=y.right,e.bisector=function(c){return m(c.length===1?function(p,_){return v(c(p),_)}:c)},e.shuffle=function(c,p,_){(A=arguments.length)<3&&(_=c.length,A<2&&(p=0));for(var A=_-p,C,L;A;)L=Math.random()*A--|0,C=c[A+p],c[A+p]=c[L+p],c[L+p]=C;return c},e.permute=function(c,p){for(var _=p.length,A=new Array(_);_--;)A[_]=c[p[_]];return A},e.pairs=function(c){for(var p=0,_=c.length-1,A,C=c[0],L=new Array(_<0?0:_);p<_;)L[p]=[A=C,C=c[++p]];return L},e.transpose=function(c){if(!(L=c.length))return[];for(var p=-1,_=e.min(c,g),A=new Array(_);++p<_;)for(var C=-1,L,R=A[p]=new Array(L);++C=0;)for(R=c[p],_=R.length;--_>=0;)L[--C]=R[_];return L};var x=Math.abs;e.range=function(c,p,_){if(arguments.length<3&&(_=1,arguments.length<2&&(p=c,c=0)),(p-c)/_===1/0)throw new Error("infinite range");var A=[],C=T(x(_)),L=-1,R;if(c*=C,p*=C,_*=C,_<0)for(;(R=c+_*++L)>p;)A.push(R/C);else for(;(R=c+_*++L)=p.length)return C?C.call(c,N):A?N.sort(A):N;for(var W=-1,K=N.length,$=p[U++],ue,pe,j,ne=new b,ie;++W=p.length)return E;var U=[],W=_[N++];return E.forEach(function(K,$){U.push({key:K,values:R($,N)})}),W?U.sort(function(K,$){return W(K.key,$.key)}):U}return c.map=function(E,N){return L(N,E,0)},c.entries=function(E){return R(L(e.map,E,0),0)},c.key=function(E){return p.push(E),c},c.sortKeys=function(E){return _[p.length-1]=E,c},c.sortValues=function(E){return A=E,c},c.rollup=function(E){return C=E,c},c},e.set=function(c){var p=new H;if(c)for(var _=0,A=c.length;_=0&&(A=c.slice(_+1),c=c.slice(0,_)),c)return arguments.length<2?this[c].on(A):this[c].on(A,p);if(arguments.length===2){if(p==null)for(c in this)this.hasOwnProperty(c)&&this[c].on(A,null);return this}};function Z(c){var p=[],_=new b;function A(){for(var C=p,L=-1,R=C.length,E;++L=0&&(_=c.slice(0,p))!=="xmlns"&&(c=c.slice(p+1)),Pe.hasOwnProperty(_)?{space:Pe[_],local:c}:c}},ce.attr=function(c,p){if(arguments.length<2){if(typeof c=="string"){var _=this.node();return c=e.ns.qualify(c),c.local?_.getAttributeNS(c.space,c.local):_.getAttribute(c)}for(p in c)this.each(Le(p,c[p]));return this}return this.each(Le(c,p))};function Le(c,p){c=e.ns.qualify(c);function _(){this.removeAttribute(c)}function A(){this.removeAttributeNS(c.space,c.local)}function C(){this.setAttribute(c,p)}function L(){this.setAttributeNS(c.space,c.local,p)}function R(){var N=p.apply(this,arguments);N==null?this.removeAttribute(c):this.setAttribute(c,N)}function E(){var N=p.apply(this,arguments);N==null?this.removeAttributeNS(c.space,c.local):this.setAttributeNS(c.space,c.local,N)}return p==null?c.local?A:_:typeof p=="function"?c.local?E:R:c.local?L:C}function Ce(c){return c.trim().replace(/\s+/g," ")}ce.classed=function(c,p){if(arguments.length<2){if(typeof c=="string"){var _=this.node(),A=(c=Oe(c)).length,C=-1;if(p=_.classList){for(;++C=0;)(L=_[A])&&(C&&C!==L.nextSibling&&C.parentNode.insertBefore(L,C),C=L);return this},ce.sort=function(c){c=he.apply(this,arguments);for(var p=-1,_=this.length;++p<_;)this[p].sort(c);return this.order()};function he(c){return arguments.length||(c=v),function(p,_){return p&&_?c(p.__data__,_.__data__):!p-!_}}ce.each=function(c){return Ze(this,function(p,_,A){c.call(p,p.__data__,_,A)})};function Ze(c,p){for(var _=0,A=c.length;_=p&&(p=C+1);!(N=R[p])&&++p0&&(c=c.slice(0,C));var R=Fe.get(c);R&&(c=R,L=Ue);function E(){var W=this[A];W&&(this.removeEventListener(c,W,W.$),delete this[A])}function N(){var W=L(p,t(arguments));E.call(this),this.addEventListener(c,this[A]=W,W.$=_),W._=p}function U(){var W=new RegExp("^__on([^.]+)"+e.requote(c)+"$"),K;for(var $ in this)if(K=$.match(W)){var ue=this[$];this.removeEventListener(K[1],ue,ue.$),delete this[$]}}return C?p?N:E:p?V:U}var Fe=e.map({mouseenter:"mouseover",mouseleave:"mouseout"});a&&Fe.forEach(function(c){"on"+c in a&&Fe.remove(c)});function Ge(c,p){return function(_){var A=e.event;e.event=_,p[0]=this.__data__;try{c.apply(this,p)}finally{e.event=A}}}function Ue(c,p){var _=Ge(c,p);return function(A){var C=this,L=A.relatedTarget;(!L||L!==C&&!(L.compareDocumentPosition(C)&8))&&_.call(C,A)}}var Ve,ir=0;function ar(c){var p=".dragsuppress-"+ ++ir,_="click"+p,A=e.select(i(c)).on("touchmove"+p,re).on("dragstart"+p,re).on("selectstart"+p,re);if(Ve==null&&(Ve="onselectstart"in c?!1:Y(c.style,"userSelect")),Ve){var C=n(c).style,L=C[Ve];C[Ve]="none"}return function(R){if(A.on(p,null),Ve&&(C[Ve]=L),R){var E=function(){A.on(_,null)};A.on(_,function(){re(),E()},!0),setTimeout(E,0)}}}e.mouse=function(c){return Ee(c,se())};var er=this.navigator&&/WebKit/.test(this.navigator.userAgent)?-1:0;function Ee(c,p){p.changedTouches&&(p=p.changedTouches[0]);var _=c.ownerSVGElement||c;if(_.createSVGPoint){var A=_.createSVGPoint();if(er<0){var C=i(c);if(C.scrollX||C.scrollY){_=e.select("body").append("svg").style({position:"absolute",top:0,left:0,margin:0,padding:0,border:"none"},"important");var L=_[0][0].getScreenCTM();er=!(L.f||L.e),_.remove()}}return er?(A.x=p.pageX,A.y=p.pageY):(A.x=p.clientX,A.y=p.clientY),A=A.matrixTransform(c.getScreenCTM().inverse()),[A.x,A.y]}var R=c.getBoundingClientRect();return[p.clientX-R.left-c.clientLeft,p.clientY-R.top-c.clientTop]}e.touch=function(c,p,_){if(arguments.length<3&&(_=p,p=se().changedTouches),p){for(var A=0,C=p.length,L;A0?1:c<0?-1:0}function ot(c,p,_){return(p[0]-c[0])*(_[1]-c[1])-(p[1]-c[1])*(_[0]-c[0])}function Tt(c){return c>1?0:c<-1?Se:Math.acos(c)}function bt(c){return c>1?sr:c<-1?-sr:Math.asin(c)}function Gt(c){return((c=Math.exp(c))-1/c)/2}function Ht(c){return((c=Math.exp(c))+1/c)/2}function Wa(c){return((c=Math.exp(2*c))-1)/(c+1)}function ga(c){return(c=Math.sin(c/2))*c}var oa=Math.SQRT2,Xa=2,sa=4;e.interpolateZoom=function(c,p){var _=c[0],A=c[1],C=c[2],L=p[0],R=p[1],E=p[2],N=L-_,U=R-A,W=N*N+U*U,K,$;if(W0&&(rr=rr.transition().duration(R)),rr.call(ye.event)}function dr(){ne&&ne.domain(j.range().map(function(rr){return(rr-c.x)/c.k}).map(j.invert)),de&&de.domain(ie.range().map(function(rr){return(rr-c.y)/c.k}).map(ie.invert))}function pr(rr){E++||rr({type:"zoomstart"})}function Lr(rr){dr(),rr({type:"zoom",scale:c.k,translate:[c.x,c.y]})}function mr(rr){--E||(rr({type:"zoomend"}),_=null)}function Mr(){var rr=this,qr=pe.of(rr,arguments),Dr=0,Kr=e.select(i(rr)).on(U,xa).on(W,Na),Mt=le(e.mouse(rr)),Xt=ar(rr);P0.call(rr),pr(qr);function xa(){Dr=1,ur(e.mouse(rr),Mt),Lr(qr)}function Na(){Kr.on(U,null).on(W,null),Xt(Dr),mr(qr)}}function Jr(){var rr=this,qr=pe.of(rr,arguments),Dr={},Kr=0,Mt,Xt=".zoom-"+e.event.changedTouches[0].identifier,xa="touchmove"+Xt,Na="touchend"+Xt,Za=[],Fa=e.select(rr),ja=ar(rr);en(),pr(qr),Fa.on(N,null).on($,en);function Sa(){var jn=e.touches(rr);return Mt=c.k,jn.forEach(function(wa){wa.identifier in Dr&&(Dr[wa.identifier]=le(wa))}),jn}function en(){var jn=e.event.target;e.select(jn).on(xa,ho).on(Na,jI),Za.push(jn);for(var wa=e.event.changedTouches,Cn=0,Ai=wa.length;Cn1){var po=Sn[0],gl=Sn[1],N0=po[0]-gl[0],Zb=po[1]-gl[1];Kr=N0*N0+Zb*Zb}}function ho(){var jn=e.touches(rr),wa,Cn,Ai,Sn;P0.call(rr);for(var Ls=0,po=jn.length;Ls1?1:p,_=_<0?0:_>1?1:_,C=_<=.5?_*(1+p):_+p-_*p,A=2*_-C;function L(E){return E>360?E-=360:E<0&&(E+=360),E<60?A+(C-A)*E/60:E<180?C:E<240?A+(C-A)*(240-E)/60:A}function R(E){return Math.round(L(E)*255)}return new za(R(c+120),R(c),R(c-120))}e.hcl=nt;function nt(c,p,_){return this instanceof nt?(this.h=+c,this.c=+p,void(this.l=+_)):arguments.length<2?c instanceof nt?new nt(c.h,c.c,c.l):c instanceof ra?v0(c.l,c.a,c.b):v0((c=or((c=e.rgb(c)).r,c.g,c.b)).l,c.a,c.b):new nt(c,p,_)}var Qa=nt.prototype=new Wr;Qa.brighter=function(c){return new nt(this.h,this.c,Math.min(100,this.l+ht*(arguments.length?c:1)))},Qa.darker=function(c){return new nt(this.h,this.c,Math.max(0,this.l-ht*(arguments.length?c:1)))},Qa.rgb=function(){return Kn(this.h,this.c,this.l).rgb()};function Kn(c,p,_){return isNaN(c)&&(c=0),isNaN(p)&&(p=0),new ra(_,Math.cos(c*=Ar)*p,Math.sin(c)*p)}e.lab=ra;function ra(c,p,_){return this instanceof ra?(this.l=+c,this.a=+p,void(this.b=+_)):arguments.length<2?c instanceof ra?new ra(c.l,c.a,c.b):c instanceof nt?Kn(c.h,c.c,c.l):or((c=za(c)).r,c.g,c.b):new ra(c,p,_)}var ht=18,Ot=.95047,Bt=1,so=1.08883,ml=ra.prototype=new Wr;ml.brighter=function(c){return new ra(Math.min(100,this.l+ht*(arguments.length?c:1)),this.a,this.b)},ml.darker=function(c){return new ra(Math.max(0,this.l-ht*(arguments.length?c:1)),this.a,this.b)},ml.rgb=function(){return c0(this.l,this.a,this.b)};function c0(c,p,_){var A=(c+16)/116,C=A+p/500,L=A-_/200;return C=ef(C)*Ot,A=ef(A)*Bt,L=ef(L)*so,new za(tf(3.2404542*C-1.5371385*A-.4985314*L),tf(-.969266*C+1.8760108*A+.041556*L),tf(.0556434*C-.2040259*A+1.0572252*L))}function v0(c,p,_){return c>0?new nt(Math.atan2(_,p)*Hr,Math.sqrt(p*p+_*_),c):new nt(NaN,NaN,c)}function ef(c){return c>.206893034?c*c*c:(c-4/29)/7.787037}function rf(c){return c>.008856?Math.pow(c,1/3):7.787037*c+4/29}function tf(c){return Math.round(255*(c<=.00304?12.92*c:1.055*Math.pow(c,1/2.4)-.055))}e.rgb=za;function za(c,p,_){return this instanceof za?(this.r=~~c,this.g=~~p,void(this.b=~~_)):arguments.length<2?c instanceof za?new za(c.r,c.g,c.b):Vr(""+c,za,ca):new za(c,p,_)}function h0(c){return new za(c>>16,c>>8&255,c&255)}function xs(c){return h0(c)+""}var ws=za.prototype=new Wr;ws.brighter=function(c){c=Math.pow(.7,arguments.length?c:1);var p=this.r,_=this.g,A=this.b,C=30;return!p&&!_&&!A?new za(C,C,C):(p&&p>4,A=A>>4|A,C=N&240,C=C>>4|C,L=N&15,L=L<<4|L):c.length===7&&(A=(N&16711680)>>16,C=(N&65280)>>8,L=N&255)),p(A,C,L))}function jr(c,p,_){var A=Math.min(c/=255,p/=255,_/=255),C=Math.max(c,p,_),L=C-A,R,E,N=(C+A)/2;return L?(E=N<.5?L/(C+A):L/(2-C-A),c==C?R=(p-_)/L+(p<_?6:0):p==C?R=(_-c)/L+2:R=(c-p)/L+4,R*=60):(R=NaN,E=N>0&&N<1?0:R),new it(R,E,N)}function or(c,p,_){c=ba(c),p=ba(p),_=ba(_);var A=rf((.4124564*c+.3575761*p+.1804375*_)/Ot),C=rf((.2126729*c+.7151522*p+.072175*_)/Bt),L=rf((.0193339*c+.119192*p+.9503041*_)/so);return ra(116*C-16,500*(A-C),200*(C-L))}function ba(c){return(c/=255)<=.04045?c/12.92:Math.pow((c+.055)/1.055,2.4)}function ut(c){var p=parseFloat(c);return c.charAt(c.length-1)==="%"?Math.round(p*2.55):p}var _a=e.map({aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074});_a.forEach(function(c,p){_a.set(c,h0(p))});function zr(c){return typeof c=="function"?c:function(){return c}}e.functor=zr,e.xhr=$n(O);function $n(c){return function(p,_,A){return arguments.length===2&&typeof _=="function"&&(A=_,_=null),uo(p,_,c,A)}}function uo(c,p,_,A){var C={},L=e.dispatch("beforesend","progress","load","error"),R={},E=new XMLHttpRequest,N=null;self.XDomainRequest&&!("withCredentials"in E)&&/^(http(s)?:)?\/\//.test(c)&&(E=new XDomainRequest),"onload"in E?E.onload=E.onerror=U:E.onreadystatechange=function(){E.readyState>3&&U()};function U(){var W=E.status,K;if(!W&&rF(E)||W>=200&&W<300||W===304){try{K=_.call(C,E)}catch($){L.error.call(C,$);return}L.load.call(C,K)}else L.error.call(C,E)}return E.onprogress=function(W){var K=e.event;e.event=W;try{L.progress.call(C,E)}finally{e.event=K}},C.header=function(W,K){return W=(W+"").toLowerCase(),arguments.length<2?R[W]:(K==null?delete R[W]:R[W]=K+"",C)},C.mimeType=function(W){return arguments.length?(p=W==null?null:W+"",C):p},C.responseType=function(W){return arguments.length?(N=W,C):N},C.response=function(W){return _=W,C},["get","post"].forEach(function(W){C[W]=function(){return C.send.apply(C,[W].concat(t(arguments)))}}),C.send=function(W,K,$){if(arguments.length===2&&typeof K=="function"&&($=K,K=null),E.open(W,c,!0),p!=null&&!("accept"in R)&&(R.accept=p+",*/*"),E.setRequestHeader)for(var ue in R)E.setRequestHeader(ue,R[ue]);return p!=null&&E.overrideMimeType&&E.overrideMimeType(p),N!=null&&(E.responseType=N),$!=null&&C.on("error",$).on("load",function(pe){$(null,pe)}),L.beforesend.call(C,E),E.send(K==null?null:K),C},C.abort=function(){return E.abort(),C},e.rebind(C,L,"on"),A==null?C:C.get(eF(A))}function eF(c){return c.length===1?function(p,_){c(p==null?_:null)}:c}function rF(c){var p=c.responseType;return p&&p!=="text"?c.response:c.responseText}e.dsv=function(c,p){var _=new RegExp('["'+c+` +]`),A=c.charCodeAt(0);function C(U,W,K){arguments.length<3&&(K=W,W=null);var $=uo(U,p,W==null?L:R(W),K);return $.row=function(ue){return arguments.length?$.response((W=ue)==null?L:R(ue)):W},$}function L(U){return C.parse(U.responseText)}function R(U){return function(W){return C.parse(W.responseText,U)}}C.parse=function(U,W){var K;return C.parseRows(U,function($,ue){if(K)return K($,ue-1);var pe=function(j){for(var ne={},ie=$.length,de=0;de=pe)return $;if(de)return de=!1,K;var Ye=j;if(U.charCodeAt(Ye)===34){for(var nr=Ye;nr++24?(isFinite(p)&&(clearTimeout(y0),y0=setTimeout(Wd,p)),m0=0):(m0=1,R5(Wd))}e.timer.flush=function(){z5(),N5()};function z5(){for(var c=Date.now(),p=d0;p;)c>=p.t&&p.c(c-p.t)&&(p.c=null),p=p.n;return c}function N5(){for(var c,p=d0,_=1/0;p;)p.c?(p.t<_&&(_=p.t),p=(c=p).n):p=c?c.n=p.n:d0=p.n;return p0=c,_}e.round=function(c,p){return p?Math.round(c*(p=Math.pow(10,p)))/p:Math.round(c)},e.geom={};function Ts(c){return c[0]}function af(c){return c[1]}e.geom.hull=function(c){var p=Ts,_=af;if(arguments.length)return A(c);function A(C){if(C.length<3)return[];var L=zr(p),R=zr(_),E,N=C.length,U=[],W=[];for(E=0;E=0;--E)j.push(C[U[K[E]][2]]);for(E=+ue;E<$.length-pe;++E)j.push(C[U[$[E]][2]]);return j}return A.x=function(C){return arguments.length?(p=C,A):p},A.y=function(C){return arguments.length?(_=C,A):_},A};function F5(c){for(var p=c.length,_=[0,1],A=2,C=2;C1&&ot(c[_[A-2]],c[_[A-1]],c[C])<=0;)--A;_[A++]=C}return _.slice(0,A)}function tF(c,p){return c[0]-p[0]||c[1]-p[1]}e.geom.polygon=function(c){return Q(c,b0),c};var b0=e.geom.polygon.prototype=[];b0.area=function(){for(var c=-1,p=this.length,_,A=this[p-1],C=0;++cae)E=E.L;else if(R=p-lF(E,_),R>ae){if(!E.R){A=E;break}E=E.R}else{L>-ae?(A=E.P,C=E):R>-ae?(A=E,C=E.N):A=C=E;break}var N=B5(c);if(As.insert(A,N),!(!A&&!C)){if(A===C){Cs(A),C=B5(A.site),As.insert(N,C),N.edge=C.edge=lf(A.site,N.site),ks(A),ks(C);return}if(!C){N.edge=lf(A.site,N.site);return}Cs(A),Cs(C);var U=A.site,W=U.x,K=U.y,$=c.x-W,ue=c.y-K,pe=C.site,j=pe.x-W,ne=pe.y-K,ie=2*($*ne-ue*j),de=$*$+ue*ue,ye=j*j+ne*ne,le={x:(ne*de-ue*ye)/ie+W,y:($*ye-j*de)/ie+K};_0(C.edge,U,pe,le),N.edge=lf(U,c,null,le),C.edge=lf(c,pe,null,le),ks(A),ks(C)}}function Y5(c,p){var _=c.site,A=_.x,C=_.y,L=C-p;if(!L)return A;var R=c.P;if(!R)return-1/0;_=R.site;var E=_.x,N=_.y,U=N-p;if(!U)return E;var W=E-A,K=1/L-1/U,$=W/U;return K?(-$+Math.sqrt($*$-2*K*(W*W/(-2*U)-N+U/2+C-L/2)))/K+A:(A+E)/2}function lF(c,p){var _=c.N;if(_)return Y5(_,p);var A=c.site;return A.y===p?A.x:1/0}function U5(c){this.site=c,this.edges=[]}U5.prototype.prepare=function(){for(var c=this.edges,p=c.length,_;p--;)_=c[p].edge,(!_.b||!_.a)&&c.splice(p,1);return c.sort(V5),c.length};function oF(c){for(var p=c[0][0],_=c[1][0],A=c[0][1],C=c[1][1],L,R,E,N,U=fo,W=U.length,K,$,ue,pe,j,ne;W--;)if(K=U[W],!(!K||!K.prepare()))for(ue=K.edges,pe=ue.length,$=0;$ae||x(N-R)>ae)&&(ue.splice($,0,new x0(vF(K.site,ne,x(E-p)ae?{x:p,y:x(L-p)ae?{x:x(R-C)ae?{x:_,y:x(L-_)ae?{x:x(R-A)=-Ae)){var $=N*N+U*U,ue=W*W+ne*ne,pe=(ne*$-U*ue)/K,j=(N*ue-W*$)/K,ne=j+E,ie=O5.pop()||new sF;ie.arc=c,ie.site=C,ie.x=pe+R,ie.y=ne+Math.sqrt(pe*pe+j*j),ie.cy=ne,c.circle=ie;for(var de=null,ye=nf._;ye;)if(ie.y0)){if(j/=ue,ue<0){if(j0){if(j>$)return;j>K&&(K=j)}if(j=_-E,!(!ue&&j<0)){if(j/=ue,ue<0){if(j>$)return;j>K&&(K=j)}else if(ue>0){if(j0)){if(j/=pe,pe<0){if(j0){if(j>$)return;j>K&&(K=j)}if(j=A-N,!(!pe&&j<0)){if(j/=pe,pe<0){if(j>$)return;j>K&&(K=j)}else if(pe>0){if(j0&&(C.a={x:E+K*ue,y:N+K*pe}),$<1&&(C.b={x:E+$*ue,y:N+$*pe}),C}}}}}}function fF(c){for(var p=Ms,_=uF(c[0][0],c[0][1],c[1][0],c[1][1]),A=p.length,C;A--;)C=p[A],(!cF(C,c)||!_(C)||x(C.a.x-C.b.x)=L)return;if(W>$){if(!A)A={x:pe,y:R};else if(A.y>=E)return;_={x:pe,y:E}}else{if(!A)A={x:pe,y:E};else if(A.y1)if(W>$){if(!A)A={x:(R-ie)/ne,y:R};else if(A.y>=E)return;_={x:(E-ie)/ne,y:E}}else{if(!A)A={x:(E-ie)/ne,y:E};else if(A.y=L)return;_={x:L,y:ne*L+ie}}else{if(!A)A={x:L,y:ne*L+ie};else if(A.x=W&&ie.x<=$&&ie.y>=K&&ie.y<=ue?[[W,ue],[$,ue],[$,K],[W,K]]:[];de.point=N[j]}),U}function E(N){return N.map(function(U,W){return{x:Math.round(A(U,W)/ae)*ae,y:Math.round(C(U,W)/ae)*ae,i:W}})}return R.links=function(N){return Qd(E(N)).edges.filter(function(U){return U.l&&U.r}).map(function(U){return{source:N[U.l.i],target:N[U.r.i]}})},R.triangles=function(N){var U=[];return Qd(E(N)).cells.forEach(function(W,K){for(var $=W.site,ue=W.edges.sort(V5),pe=-1,j=ue.length,ne,ie,de=ue[j-1].edge,ye=de.l===$?de.r:de.l;++peye&&(ye=W.x),W.y>le&&(le=W.y),ue.push(W.x),pe.push(W.y);else for(j=0;jye&&(ye=Ye),nr>le&&(le=nr),ue.push(Ye),pe.push(nr)}var ur=ye-ie,lr=le-de;ur>lr?le=de+ur:ye=ie+lr;function dr(mr,Mr,Jr,Dt,rt,rr,qr,Dr){if(!(isNaN(Jr)||isNaN(Dt)))if(mr.leaf){var Kr=mr.x,Mt=mr.y;if(Kr!=null)if(x(Kr-Jr)+x(Mt-Dt)<.01)pr(mr,Mr,Jr,Dt,rt,rr,qr,Dr);else{var Xt=mr.point;mr.x=mr.y=mr.point=null,pr(mr,Xt,Kr,Mt,rt,rr,qr,Dr),pr(mr,Mr,Jr,Dt,rt,rr,qr,Dr)}else mr.x=Jr,mr.y=Dt,mr.point=Mr}else pr(mr,Mr,Jr,Dt,rt,rr,qr,Dr)}function pr(mr,Mr,Jr,Dt,rt,rr,qr,Dr){var Kr=(rt+qr)*.5,Mt=(rr+Dr)*.5,Xt=Jr>=Kr,xa=Dt>=Mt,Na=xa<<1|Xt;mr.leaf=!1,mr=mr.nodes[Na]||(mr.nodes[Na]=X5()),Xt?rt=Kr:qr=Kr,xa?rr=Mt:Dr=Mt,dr(mr,Mr,Jr,Dt,rt,rr,qr,Dr)}var Lr=X5();if(Lr.add=function(mr){dr(Lr,mr,+K(mr,++j),+$(mr,j),ie,de,ye,le)},Lr.visit=function(mr){uf(mr,Lr,ie,de,ye,le)},Lr.find=function(mr){return yF(Lr,mr[0],mr[1],ie,de,ye,le)},j=-1,p==null){for(;++jL||$>R||ue=Ye,lr=_>=nr,dr=lr<<1|ur,pr=dr+4;dr_&&(L=p.slice(_,L),E[R]?E[R]+=L:E[++R]=L),(A=A[0])===(C=C[0])?E[R]?E[R]+=C:E[++R]=C:(E[++R]=null,N.push({i:R,x:Ji(A,C)})),_=rp.lastIndex;return _=0&&!(A=e.interpolators[_](c,p)););return A}e.interpolators=[function(c,p){var _=typeof p;return(_==="string"?_a.has(p.toLowerCase())||/^(#|rgb\(|hsl\()/i.test(p)?jd:J5:p instanceof Wr?jd:Array.isArray(p)?M0:_==="object"&&isNaN(p)?Z5:Ji)(c,p)}],e.interpolateArray=M0;function M0(c,p){var _=[],A=[],C=c.length,L=p.length,R=Math.min(c.length,p.length),E;for(E=0;E=0?c.slice(0,p):c,A=p>=0?c.slice(p+1):"in";return _=gF.get(_)||K5,A=bF.get(A)||O,_F(A(_.apply(null,r.call(arguments,1))))};function _F(c){return function(p){return p<=0?0:p>=1?1:c(p)}}function $5(c){return function(p){return 1-c(1-p)}}function Q5(c){return function(p){return .5*(p<.5?c(2*p):2-c(2-2*p))}}function xF(c){return c*c}function wF(c){return c*c*c}function TF(c){if(c<=0)return 0;if(c>=1)return 1;var p=c*c,_=p*c;return 4*(c<.5?_:3*(c-p)+_-.75)}function MF(c){return function(p){return Math.pow(p,c)}}function AF(c){return 1-Math.cos(c*sr)}function kF(c){return Math.pow(2,10*(c-1))}function CF(c){return 1-Math.sqrt(1-c*c)}function SF(c,p){var _;return arguments.length<2&&(p=.45),arguments.length?_=p/$e*Math.asin(1/c):(c=1,_=p/4),function(A){return 1+c*Math.pow(2,-10*A)*Math.sin((A-_)*$e/p)}}function LF(c){return c||(c=1.70158),function(p){return p*p*((c+1)*p-c)}}function qF(c){return c<1/2.75?7.5625*c*c:c<2/2.75?7.5625*(c-=1.5/2.75)*c+.75:c<2.5/2.75?7.5625*(c-=2.25/2.75)*c+.9375:7.5625*(c-=2.625/2.75)*c+.984375}e.interpolateHcl=DF;function DF(c,p){c=e.hcl(c),p=e.hcl(p);var _=c.h,A=c.c,C=c.l,L=p.h-_,R=p.c-A,E=p.l-C;return isNaN(R)&&(R=0,A=isNaN(A)?p.c:A),isNaN(L)?(L=0,_=isNaN(_)?p.h:_):L>180?L-=360:L<-180&&(L+=360),function(N){return Kn(_+L*N,A+R*N,C+E*N)+""}}e.interpolateHsl=EF;function EF(c,p){c=e.hsl(c),p=e.hsl(p);var _=c.h,A=c.s,C=c.l,L=p.h-_,R=p.s-A,E=p.l-C;return isNaN(R)&&(R=0,A=isNaN(A)?p.s:A),isNaN(L)?(L=0,_=isNaN(_)?p.h:_):L>180?L-=360:L<-180&&(L+=360),function(N){return ca(_+L*N,A+R*N,C+E*N)+""}}e.interpolateLab=PF;function PF(c,p){c=e.lab(c),p=e.lab(p);var _=c.l,A=c.a,C=c.b,L=p.l-_,R=p.a-A,E=p.b-C;return function(N){return c0(_+L*N,A+R*N,C+E*N)+""}}e.interpolateRound=j5;function j5(c,p){return p-=c,function(_){return Math.round(c+p*_)}}e.transform=function(c){var p=a.createElementNS(e.ns.prefix.svg,"g");return(e.transform=function(_){if(_!=null){p.setAttribute("transform",_);var A=p.transform.baseVal.consolidate()}return new eb(A?A.matrix:zF)})(c)};function eb(c){var p=[c.a,c.b],_=[c.c,c.d],A=tb(p),C=rb(p,_),L=tb(RF(_,p,-C))||0;p[0]*_[1]<_[0]*p[1]&&(p[0]*=-1,p[1]*=-1,A*=-1,C*=-1),this.rotate=(A?Math.atan2(p[1],p[0]):Math.atan2(-_[0],_[1]))*Hr,this.translate=[c.e,c.f],this.scale=[A,L],this.skew=L?Math.atan2(C,L)*Hr:0}eb.prototype.toString=function(){return"translate("+this.translate+")rotate("+this.rotate+")skewX("+this.skew+")scale("+this.scale+")"};function rb(c,p){return c[0]*p[0]+c[1]*p[1]}function tb(c){var p=Math.sqrt(rb(c,c));return p&&(c[0]/=p,c[1]/=p),p}function RF(c,p,_){return c[0]+=_*p[0],c[1]+=_*p[1],c}var zF={a:1,b:0,c:0,d:1,e:0,f:0};e.interpolateTransform=ab;function Ss(c){return c.length?c.pop()+",":""}function NF(c,p,_,A){if(c[0]!==p[0]||c[1]!==p[1]){var C=_.push("translate(",null,",",null,")");A.push({i:C-4,x:Ji(c[0],p[0])},{i:C-2,x:Ji(c[1],p[1])})}else(p[0]||p[1])&&_.push("translate("+p+")")}function FF(c,p,_,A){c!==p?(c-p>180?p+=360:p-c>180&&(c+=360),A.push({i:_.push(Ss(_)+"rotate(",null,")")-2,x:Ji(c,p)})):p&&_.push(Ss(_)+"rotate("+p+")")}function IF(c,p,_,A){c!==p?A.push({i:_.push(Ss(_)+"skewX(",null,")")-2,x:Ji(c,p)}):p&&_.push(Ss(_)+"skewX("+p+")")}function HF(c,p,_,A){if(c[0]!==p[0]||c[1]!==p[1]){var C=_.push(Ss(_)+"scale(",null,",",null,")");A.push({i:C-4,x:Ji(c[0],p[0])},{i:C-2,x:Ji(c[1],p[1])})}else(p[0]!==1||p[1]!==1)&&_.push(Ss(_)+"scale("+p+")")}function ab(c,p){var _=[],A=[];return c=e.transform(c),p=e.transform(p),NF(c.translate,p.translate,_,A),FF(c.rotate,p.rotate,_,A),IF(c.skew,p.skew,_,A),HF(c.scale,p.scale,_,A),c=p=null,function(C){for(var L=-1,R=A.length,E;++L0?L=le:(_.c=null,_.t=NaN,_=null,p.end({type:"end",alpha:L=0})):le>0&&(p.start({type:"start",alpha:L=le}),_=g0(c.tick)),c):L},c.start=function(){var le,Ye=ue.length,nr=pe.length,ur=A[0],lr=A[1],dr,pr;for(le=0;le=0;)L.push(W=U[N]),W.parent=E,W.depth=E.depth+1;_&&(E.value=0),E.children=U}else _&&(E.value=+_.call(A,E,E.depth)||0),delete E.children;return Mi(C,function(K){var $,ue;c&&($=K.children)&&$.sort(c),_&&(ue=K.parent)&&(ue.value+=K.value)}),R}return A.sort=function(C){return arguments.length?(c=C,A):c},A.children=function(C){return arguments.length?(p=C,A):p},A.value=function(C){return arguments.length?(_=C,A):_},A.revalue=function(C){return _&&(cf(C,function(L){L.children&&(L.value=0)}),Mi(C,function(L){var R;L.children||(L.value=+_.call(A,L,L.depth)||0),(R=L.parent)&&(R.value+=L.value)})),C},A};function ff(c,p){return e.rebind(c,p,"sort","children","value"),c.nodes=c,c.links=eI,c}function cf(c,p){for(var _=[c];(c=_.pop())!=null;)if(p(c),(C=c.children)&&(A=C.length))for(var A,C;--A>=0;)_.push(C[A])}function Mi(c,p){for(var _=[c],A=[];(c=_.pop())!=null;)if(A.push(c),(R=c.children)&&(L=R.length))for(var C=-1,L,R;++CC&&(C=E),A.push(E)}for(R=0;R<_;++R)N[R]=(C-A[R])/2;return N},wiggle:function(c){var p=c.length,_=c[0],A=_.length,C,L,R,E,N,U,W,K,$,ue=[];for(ue[0]=K=$=0,L=1;LA&&(_=p,A=C);return _}function oI(c){return c.reduce(sI,0)}function sI(c,p){return c+p[1]}e.layout.histogram=function(){var c=!0,p=Number,_=fI,A=uI;function C(L,$){for(var E=[],N=L.map(p,this),U=_.call(this,N,$),W=A.call(this,U,N,$),K,$=-1,ue=N.length,pe=W.length-1,j=c?1:1/ue,ne;++$0)for($=-1;++$=U[0]&&ne<=U[1]&&(K=E[e.bisect(W,ne,1,pe)-1],K.y+=j,K.push(L[$]));return E}return C.value=function(L){return arguments.length?(p=L,C):p},C.range=function(L){return arguments.length?(_=zr(L),C):_},C.bins=function(L){return arguments.length?(A=typeof L=="number"?function(R){return ob(R,L)}:zr(L),C):A},C.frequency=function(L){return arguments.length?(c=!!L,C):c},C};function uI(c,p){return ob(c,Math.ceil(Math.log(p.length)/Math.LN2+1))}function ob(c,p){for(var _=-1,A=+c[0],C=(c[1]-A)/p,L=[];++_<=p;)L[_]=C*_+A;return L}function fI(c){return[e.min(c),e.max(c)]}e.layout.pack=function(){var c=e.layout.hierarchy().sort(cI),p=0,_=[1,1],A;function C(L,R){var E=c.call(this,L,R),N=E[0],U=_[0],W=_[1],K=A==null?Math.sqrt:typeof A=="function"?A:function(){return A};if(N.x=N.y=0,Mi(N,function(ue){ue.r=+K(ue.value)}),Mi(N,fb),p){var $=p*(A?1:Math.max(2*N.r/U,2*N.r/W))/2;Mi(N,function(ue){ue.r+=$}),Mi(N,fb),Mi(N,function(ue){ue.r-=$})}return cb(N,U/2,W/2,A?1:1/Math.max(2*N.r/U,2*N.r/W)),E}return C.size=function(L){return arguments.length?(_=L,C):_},C.radius=function(L){return arguments.length?(A=L==null||typeof L=="function"?L:+L,C):A},C.padding=function(L){return arguments.length?(p=+L,C):p},ff(C,c)};function cI(c,p){return c.value-p.value}function np(c,p){var _=c._pack_next;c._pack_next=p,p._pack_prev=c,p._pack_next=_,_._pack_prev=p}function sb(c,p){c._pack_next=p,p._pack_prev=c}function ub(c,p){var _=p.x-c.x,A=p.y-c.y,C=c.r+p.r;return .999*C*C>_*_+A*A}function fb(c){if(!(p=c.children)||!($=p.length))return;var p,_=1/0,A=-1/0,C=1/0,L=-1/0,R,E,N,U,W,K,$;function ue(le){_=Math.min(le.x-le.r,_),A=Math.max(le.x+le.r,A),C=Math.min(le.y-le.r,C),L=Math.max(le.y+le.r,L)}if(p.forEach(vI),R=p[0],R.x=-R.r,R.y=0,ue(R),$>1&&(E=p[1],E.x=E.r,E.y=0,ue(E),$>2))for(N=p[2],vb(R,E,N),ue(N),np(R,N),R._pack_prev=N,np(N,E),E=R._pack_next,U=3;U<$;U++){vb(R,E,N=p[U]);var pe=0,j=1,ne=1;for(W=E._pack_next;W!==E;W=W._pack_next,j++)if(ub(W,N)){pe=1;break}if(pe==1)for(K=R._pack_prev;K!==W._pack_prev&&!ub(K,N);K=K._pack_prev,ne++);pe?(jne.x&&(ne=Ye),Ye.depth>ie.depth&&(ie=Ye)});var de=p(j,ne)/2-j.x,ye=_[0]/(ne.x+p(ne,j)/2+de),le=_[1]/(ie.depth||1);cf(ue,function(Ye){Ye.x=(Ye.x+de)*ye,Ye.y=Ye.depth*le})}return $}function L(W){for(var K={A:null,children:[W]},$=[K],ue;(ue=$.pop())!=null;)for(var pe=ue.children,j,ne=0,ie=pe.length;ne0&&(dI(mI(j,W,$),W,Ye),ie+=Ye,de+=Ye),ye+=j.m,ie+=ue.m,le+=ne.m,de+=pe.m;j&&!lp(pe)&&(pe.t=j,pe.m+=ye-de),ue&&!ip(ne)&&(ne.t=ue,ne.m+=ie-le,$=W)}return $}function U(W){W.x*=_[0],W.y=W.depth*_[1]}return C.separation=function(W){return arguments.length?(p=W,C):p},C.size=function(W){return arguments.length?(A=(_=W)==null?U:null,C):A?null:_},C.nodeSize=function(W){return arguments.length?(A=(_=W)==null?null:U,C):A?_:null},ff(C,c)};function hb(c,p){return c.parent==p.parent?1:2}function ip(c){var p=c.children;return p.length?p[0]:c.t}function lp(c){var p=c.children,_;return(_=p.length)?p[_-1]:c.t}function dI(c,p,_){var A=_/(p.i-c.i);p.c-=A,p.s+=_,c.c+=A,p.z+=_,p.m+=_}function pI(c){for(var p=0,_=0,A=c.children,C=A.length,L;--C>=0;)L=A[C],L.z+=p,L.m+=p,p+=L.s+(_+=L.c)}function mI(c,p,_){return c.a.parent===p.parent?c.a:_}e.layout.cluster=function(){var c=e.layout.hierarchy().sort(null).value(null),p=hb,_=[1,1],A=!1;function C(L,R){var E=c.call(this,L,R),N=E[0],U,W=0;Mi(N,function(j){var ne=j.children;ne&&ne.length?(j.x=gI(ne),j.y=yI(ne)):(j.x=U?W+=p(j,U):0,j.y=0,U=j)});var K=db(N),$=pb(N),ue=K.x-p(K,$)/2,pe=$.x+p($,K)/2;return Mi(N,A?function(j){j.x=(j.x-N.x)*_[0],j.y=(N.y-j.y)*_[1]}:function(j){j.x=(j.x-ue)/(pe-ue)*_[0],j.y=(1-(N.y?j.y/N.y:1))*_[1]}),E}return C.separation=function(L){return arguments.length?(p=L,C):p},C.size=function(L){return arguments.length?(A=(_=L)==null,C):A?null:_},C.nodeSize=function(L){return arguments.length?(A=(_=L)!=null,C):A?_:null},ff(C,c)};function yI(c){return 1+e.max(c,function(p){return p.y})}function gI(c){return c.reduce(function(p,_){return p+_.x},0)/c.length}function db(c){var p=c.children;return p&&p.length?db(p[0]):c}function pb(c){var p=c.children,_;return p&&(_=p.length)?pb(p[_-1]):c}e.layout.treemap=function(){var c=e.layout.hierarchy(),p=Math.round,_=[1,1],A=null,C=op,L=!1,R,E="squarify",N=.5*(1+Math.sqrt(5));function U(j,ne){for(var ie=-1,de=j.length,ye,le;++ie0;)de.push(le=ye[lr-1]),de.area+=le.area,E!=="squarify"||(nr=$(de,ur))<=Ye?(ye.pop(),Ye=nr):(de.area-=de.pop().area,ue(de,ur,ie,!1),ur=Math.min(ie.dx,ie.dy),de.length=de.area=0,Ye=1/0);de.length&&(ue(de,ur,ie,!0),de.length=de.area=0),ne.forEach(W)}}function K(j){var ne=j.children;if(ne&&ne.length){var ie=C(j),de=ne.slice(),ye,le=[];for(U(de,ie.dx*ie.dy/j.value),le.area=0;ye=de.pop();)le.push(ye),le.area+=ye.area,ye.z!=null&&(ue(le,ye.z?ie.dx:ie.dy,ie,!de.length),le.length=le.area=0);ne.forEach(K)}}function $(j,ne){for(var ie=j.area,de,ye=0,le=1/0,Ye=-1,nr=j.length;++Yeye&&(ye=de));return ie*=ie,ne*=ne,ie?Math.max(ne*ye*N/ie,ie/(ne*le*N)):1/0}function ue(j,ne,ie,de){var ye=-1,le=j.length,Ye=ie.x,nr=ie.y,ur=ne?p(j.area/ne):0,lr;if(ne==ie.dx){for((de||ur>ie.dy)&&(ur=ie.dy);++yeie.dx)&&(ur=ie.dx);++ye1);return c+p*A*Math.sqrt(-2*Math.log(L)/L)}},logNormal:function(){var c=e.random.normal.apply(e,arguments);return function(){return Math.exp(c())}},bates:function(c){var p=e.random.irwinHall(c);return function(){return p()/c}},irwinHall:function(c){return function(){for(var p=0,_=0;_2?xI:bI,U=A?BF:OF;return C=N(c,p,U,_),L=N(p,c,U,co),E}function E(N){return C(N)}return E.invert=function(N){return L(N)},E.domain=function(N){return arguments.length?(c=N.map(Number),R()):c},E.range=function(N){return arguments.length?(p=N,R()):p},E.rangeRound=function(N){return E.range(N).interpolate(j5)},E.clamp=function(N){return arguments.length?(A=N,R()):A},E.interpolate=function(N){return arguments.length?(_=N,R()):_},E.ticks=function(N){return fp(c,N)},E.tickFormat=function(N,U){return d3_scale_linearTickFormat(c,N,U)},E.nice=function(N){return _b(c,N),R()},E.copy=function(){return gb(c,p,_,A)},R()}function bb(c,p){return e.rebind(c,p,"range","rangeRound","interpolate","clamp")}function _b(c,p){return sp(c,yb(up(c,p)[2])),sp(c,yb(up(c,p)[2])),c}function up(c,p){p==null&&(p=10);var _=A0(c),A=_[1]-_[0],C=Math.pow(10,Math.floor(Math.log(A/p)/Math.LN10)),L=p/A*C;return L<=.15?C*=10:L<=.35?C*=5:L<=.75&&(C*=2),_[0]=Math.ceil(_[0]/C)*C,_[1]=Math.floor(_[1]/C)*C+C*.5,_[2]=C,_}function fp(c,p){return e.range.apply(e,up(c,p))}var wI={s:1,g:1,p:1,r:1,e:1};function xb(c){return-Math.floor(Math.log(c)/Math.LN10+.01)}function ooe(c,p){var _=xb(p[2]);return c in wI?Math.abs(_-xb(Math.max(x(p[0]),x(p[1]))))+ +(c!=="e"):_-(c==="%")*2}e.scale.log=function(){return wb(e.scale.linear().domain([0,1]),10,!0,[1,10])};function wb(c,p,_,A){function C(E){return(_?Math.log(E<0?0:E):-Math.log(E>0?0:-E))/Math.log(p)}function L(E){return _?Math.pow(p,E):-Math.pow(p,-E)}function R(E){return c(C(E))}return R.invert=function(E){return L(c.invert(E))},R.domain=function(E){return arguments.length?(_=E[0]>=0,c.domain((A=E.map(Number)).map(C)),R):A},R.base=function(E){return arguments.length?(p=+E,c.domain(A.map(C)),R):p},R.nice=function(){var E=sp(A.map(C),_?Math:TI);return c.domain(E),A=E.map(L),R},R.ticks=function(){var E=A0(A),N=[],U=E[0],W=E[1],K=Math.floor(C(U)),$=Math.ceil(C(W)),ue=p%1?2:p;if(isFinite($-K)){if(_){for(;K<$;K++)for(var pe=1;pe0;pe--)N.push(L(K)*pe);for(K=0;N[K]W;$--);N=N.slice(K,$)}return N},R.copy=function(){return wb(c.copy(),p,_,A)},bb(R,c)}var TI={floor:function(c){return-Math.ceil(-c)},ceil:function(c){return-Math.floor(-c)}};e.scale.pow=function(){return Tb(e.scale.linear(),1,[0,1])};function Tb(c,p,_){var A=C0(p),C=C0(1/p);function L(R){return c(A(R))}return L.invert=function(R){return C(c.invert(R))},L.domain=function(R){return arguments.length?(c.domain((_=R.map(Number)).map(A)),L):_},L.ticks=function(R){return fp(_,R)},L.tickFormat=function(R,E){return d3_scale_linearTickFormat(_,R,E)},L.nice=function(R){return L.domain(_b(_,R))},L.exponent=function(R){return arguments.length?(A=C0(p=R),C=C0(1/p),c.domain(_.map(A)),L):p},L.copy=function(){return Tb(c.copy(),p,_)},bb(L,c)}function C0(c){return function(p){return p<0?-Math.pow(-p,c):Math.pow(p,c)}}e.scale.sqrt=function(){return e.scale.pow().exponent(.5)},e.scale.ordinal=function(){return Mb([],{t:"range",a:[[]]})};function Mb(c,p){var _,A,C;function L(E){return A[((_.get(E)||(p.t==="range"?_.set(E,c.push(E)):NaN))-1)%A.length]}function R(E,N){return e.range(c.length).map(function(U){return E+N*U})}return L.domain=function(E){if(!arguments.length)return c;c=[],_=new b;for(var N=-1,U=E.length,W;++N0?_[L-1]:c[0],L<_.length?_[L]:c[c.length-1]]},C.copy=function(){return Ab(c,p)},A()}e.scale.quantize=function(){return kb(0,1,[0,1])};function kb(c,p,_){var A,C;function L(E){return _[Math.max(0,Math.min(C,Math.floor(A*(E-c))))]}function R(){return A=_.length/(p-c),C=_.length-1,L}return L.domain=function(E){return arguments.length?(c=+E[0],p=+E[E.length-1],R()):[c,p]},L.range=function(E){return arguments.length?(_=E,R()):_},L.invertExtent=function(E){return E=_.indexOf(E),E=E<0?NaN:E/A+c,[E,E+1/A]},L.copy=function(){return kb(c,p,_)},R()}e.scale.threshold=function(){return Cb([.5],[0,1])};function Cb(c,p){function _(A){if(A<=A)return p[e.bisect(c,A)]}return _.domain=function(A){return arguments.length?(c=A,_):c},_.range=function(A){return arguments.length?(p=A,_):p},_.invertExtent=function(A){return A=p.indexOf(A),[c[A-1],c[A]]},_.copy=function(){return Cb(c,p)},_}e.scale.identity=function(){return Sb([0,1])};function Sb(c){function p(_){return+_}return p.invert=p,p.domain=p.range=function(_){return arguments.length?(c=_.map(p),p):c},p.ticks=function(_){return fp(c,_)},p.tickFormat=function(_,A){return d3_scale_linearTickFormat(c,_,A)},p.copy=function(){return Sb(c)},p}e.svg={};function SI(){return 0}e.svg.arc=function(){var c=LI,p=qI,_=SI,A=S0,C=Lb,L=qb,R=DI;function E(){var U=Math.max(0,+c.apply(this,arguments)),W=Math.max(0,+p.apply(this,arguments)),K=C.apply(this,arguments)-sr,$=L.apply(this,arguments)-sr,ue=Math.abs($-K),pe=K>$?0:1;if(W=fr)return N(W,pe)+(U?N(U,1-pe):"")+"Z";var j,ne,ie,de,ye=0,le=0,Ye,nr,ur,lr,dr,pr,Lr,mr,Mr=[];if((de=(+R.apply(this,arguments)||0)/2)&&(ie=A===S0?Math.sqrt(U*U+W*W):+A.apply(this,arguments),pe||(le*=-1),W&&(le=bt(ie/W*Math.sin(de))),U&&(ye=bt(ie/U*Math.sin(de)))),W){Ye=W*Math.cos(K+le),nr=W*Math.sin(K+le),ur=W*Math.cos($-le),lr=W*Math.sin($-le);var Jr=Math.abs($-K-2*le)<=Se?0:1;if(le&&L0(Ye,nr,ur,lr)===pe^Jr){var Dt=(K+$)/2;Ye=W*Math.cos(Dt),nr=W*Math.sin(Dt),ur=lr=null}}else Ye=nr=0;if(U){dr=U*Math.cos($-ye),pr=U*Math.sin($-ye),Lr=U*Math.cos(K+ye),mr=U*Math.sin(K+ye);var rt=Math.abs(K-$+2*ye)<=Se?0:1;if(ye&&L0(dr,pr,Lr,mr)===1-pe^rt){var rr=(K+$)/2;dr=U*Math.cos(rr),pr=U*Math.sin(rr),Lr=mr=null}}else dr=pr=0;if(ue>ae&&(j=Math.min(Math.abs(W-U)/2,+_.apply(this,arguments)))>.001){ne=U0?0:1}function q0(c,p,_,A,C){var L=c[0]-p[0],R=c[1]-p[1],E=(C?A:-A)/Math.sqrt(L*L+R*R),N=E*R,U=-E*L,W=c[0]+N,K=c[1]+U,$=p[0]+N,ue=p[1]+U,pe=(W+$)/2,j=(K+ue)/2,ne=$-W,ie=ue-K,de=ne*ne+ie*ie,ye=_-A,le=W*ue-$*K,Ye=(ie<0?-1:1)*Math.sqrt(Math.max(0,ye*ye*de-le*le)),nr=(le*ie-ne*Ye)/de,ur=(-le*ne-ie*Ye)/de,lr=(le*ie+ne*Ye)/de,dr=(-le*ne+ie*Ye)/de,pr=nr-pe,Lr=ur-j,mr=lr-pe,Mr=dr-j;return pr*pr+Lr*Lr>mr*mr+Mr*Mr&&(nr=lr,ur=dr),[[nr-N,ur-U],[nr*_/ye,ur*_/ye]]}function Db(){return!0}function Eb(c){var p=Ts,_=af,A=Db,C=Qn,L=C.key,R=.7;function E(N){var U=[],W=[],K=-1,$=N.length,ue,pe=zr(p),j=zr(_);function ne(){U.push("M",C(c(W),R))}for(;++K<$;)A.call(this,ue=N[K],K)?W.push([+pe.call(this,ue,K),+j.call(this,ue,K)]):W.length&&(ne(),W=[]);return W.length&&ne(),U.length?U.join(""):null}return E.x=function(N){return arguments.length?(p=N,E):p},E.y=function(N){return arguments.length?(_=N,E):_},E.defined=function(N){return arguments.length?(A=N,E):A},E.interpolate=function(N){return arguments.length?(typeof N=="function"?L=C=N:L=(C=cp.get(N)||Qn).key,E):L},E.tension=function(N){return arguments.length?(R=N,E):R},E}e.svg.line=function(){return Eb(O)};var cp=e.map({linear:Qn,"linear-closed":Pb,step:EI,"step-before":vp,"step-after":hp,basis:Rb,"basis-open":NI,"basis-closed":FI,bundle:II,cardinal:zI,"cardinal-open":PI,"cardinal-closed":RI,monotone:BI});cp.forEach(function(c,p){p.key=c,p.closed=/-closed$/.test(c)});function Qn(c){return c.length>1?c.join("L"):c+"Z"}function Pb(c){return c.join("L")+"Z"}function EI(c){for(var p=0,_=c.length,A=c[0],C=[A[0],",",A[1]];++p<_;)C.push("H",(A[0]+(A=c[p])[0])/2,"V",A[1]);return _>1&&C.push("H",A[0]),C.join("")}function vp(c){for(var p=0,_=c.length,A=c[0],C=[A[0],",",A[1]];++p<_;)C.push("V",(A=c[p])[1],"H",A[0]);return C.join("")}function hp(c){for(var p=0,_=c.length,A=c[0],C=[A[0],",",A[1]];++p<_;)C.push("H",(A=c[p])[0],"V",A[1]);return C.join("")}function PI(c,p){return c.length<4?Qn(c):c[1]+D0(c.slice(1,-1),dp(c,p))}function RI(c,p){return c.length<3?Pb(c):c[0]+D0((c.push(c[0]),c),dp([c[c.length-2]].concat(c,[c[1]]),p))}function zI(c,p){return c.length<3?Qn(c):c[0]+D0(c,dp(c,p))}function D0(c,p){if(p.length<1||c.length!=p.length&&c.length!=p.length+2)return Qn(c);var _=c.length!=p.length,A="",C=c[0],L=c[1],R=p[0],E=R,N=1;if(_&&(A+="Q"+(L[0]-R[0]*2/3)+","+(L[1]-R[1]*2/3)+","+L[0]+","+L[1],C=c[1],N=2),p.length>1){E=p[1],L=c[N],N++,A+="C"+(C[0]+R[0])+","+(C[1]+R[1])+","+(L[0]-E[0])+","+(L[1]-E[1])+","+L[0]+","+L[1];for(var U=2;U9&&(L=_*3/Math.sqrt(L),R[E]=L*A,R[E+1]=L*C));for(E=-1;++E<=N;)L=(c[Math.min(N,E+1)][0]-c[Math.max(0,E-1)][0])/(6*(1+R[E]*R[E])),p.push([L||0,R[E]*L||0]);return p}function BI(c){return c.length<3?Qn(c):c[0]+D0(c,OI(c))}e.svg.line.radial=function(){var c=Eb(Fb);return c.radius=c.x,delete c.x,c.angle=c.y,delete c.y,c};function Fb(c){for(var p,_=-1,A=c.length,C,L;++_Se)+",1 "+K}function U(W,K,$,ue){return"Q 0,0 "+ue}return L.radius=function(W){return arguments.length?(_=zr(W),L):_},L.source=function(W){return arguments.length?(c=zr(W),L):c},L.target=function(W){return arguments.length?(p=zr(W),L):p},L.startAngle=function(W){return arguments.length?(A=zr(W),L):A},L.endAngle=function(W){return arguments.length?(C=zr(W),L):C},L};function YI(c){return c.radius}e.svg.diagonal=function(){var c=Hb,p=Ob,_=Bb;function A(C,L){var R=c.call(this,C,L),E=p.call(this,C,L),N=(R.y+E.y)/2,U=[R,{x:R.x,y:N},{x:E.x,y:N},E];return U=U.map(_),"M"+U[0]+"C"+U[1]+" "+U[2]+" "+U[3]}return A.source=function(C){return arguments.length?(c=zr(C),A):c},A.target=function(C){return arguments.length?(p=zr(C),A):p},A.projection=function(C){return arguments.length?(_=C,A):_},A};function Bb(c){return[c.x,c.y]}e.svg.diagonal.radial=function(){var c=e.svg.diagonal(),p=Bb,_=c.projection;return c.projection=function(A){return arguments.length?_(UI(p=A)):p},c};function UI(c){return function(){var p=c.apply(this,arguments),_=p[0],A=p[1]-sr;return[_*Math.cos(A),_*Math.sin(A)]}}e.svg.symbol=function(){var c=GI,p=VI;function _(A,C){return(Ub.get(c.call(this,A,C))||Yb)(p.call(this,A,C))}return _.type=function(A){return arguments.length?(c=zr(A),_):c},_.size=function(A){return arguments.length?(p=zr(A),_):p},_};function VI(){return 64}function GI(){return"circle"}function Yb(c){var p=Math.sqrt(c/Se);return"M0,"+p+"A"+p+","+p+" 0 1,1 0,"+-p+"A"+p+","+p+" 0 1,1 0,"+p+"Z"}var Ub=e.map({circle:Yb,cross:function(c){var p=Math.sqrt(c/5)/2;return"M"+-3*p+","+-p+"H"+-p+"V"+-3*p+"H"+p+"V"+-p+"H"+3*p+"V"+p+"H"+p+"V"+3*p+"H"+-p+"V"+p+"H"+-3*p+"Z"},diamond:function(c){var p=Math.sqrt(c/(2*Vb)),_=p*Vb;return"M0,"+-p+"L"+_+",0 0,"+p+" "+-_+",0Z"},square:function(c){var p=Math.sqrt(c)/2;return"M"+-p+","+-p+"L"+p+","+-p+" "+p+","+p+" "+-p+","+p+"Z"},"triangle-down":function(c){var p=Math.sqrt(c/E0),_=p*E0/2;return"M0,"+_+"L"+p+","+-_+" "+-p+","+-_+"Z"},"triangle-up":function(c){var p=Math.sqrt(c/E0),_=p*E0/2;return"M0,"+-_+"L"+p+","+_+" "+-p+","+_+"Z"}});e.svg.symbolTypes=Ub.keys();var E0=Math.sqrt(3),Vb=Math.tan(30*Ar);ce.transition=function(c){for(var p=vo||++Wb,_=gp(c),A=[],C,L,R=R0||{time:Date.now(),ease:TF,delay:0,duration:250},E=-1,N=this.length;++E0;)K[--de].call(c,ie);if(ne>=1)return R.event&&R.event.end.call(c,c.__data__,p),--L.count?delete L[A]:delete c[_],1}R||(E=C.time,N=g0($,0,E),R=L[A]={tween:new b,time:E,timer:N,delay:C.delay,duration:C.duration,ease:C.ease,index:p},C=null,++L.count)}e.svg.axis=function(){var c=e.scale.linear(),p=Xb,_=6,A=6,C=3,L=[10],R=null,E;function N(U){U.each(function(){var W=e.select(this),K=this.__chart__||c,$=this.__chart__=c.copy(),ue=R==null?$.ticks?$.ticks.apply($,L):$.domain():R,pe=E==null?$.tickFormat?$.tickFormat.apply($,L):O:E,j=W.selectAll(".tick").data(ue,$),ne=j.enter().insert("g",".domain").attr("class","tick").style("opacity",ae),ie=e.transition(j.exit()).style("opacity",ae).remove(),de=e.transition(j.order()).style("opacity",1),ye=Math.max(_,0)+C,le,Ye=k0($),nr=W.selectAll(".domain").data([0]),ur=(nr.enter().append("path").attr("class","domain"),e.transition(nr));ne.append("line"),ne.append("text");var lr=ne.select("line"),dr=de.select("line"),pr=j.select("text").text(pe),Lr=ne.select("text"),mr=de.select("text"),Mr=p==="top"||p==="left"?-1:1,Jr,Dt,rt,rr;if(p==="bottom"||p==="top"?(le=ZI,Jr="x",rt="y",Dt="x2",rr="y2",pr.attr("dy",Mr<0?"0em":".71em").style("text-anchor","middle"),ur.attr("d","M"+Ye[0]+","+Mr*A+"V0H"+Ye[1]+"V"+Mr*A)):(le=JI,Jr="y",rt="x",Dt="y2",rr="x2",pr.attr("dy",".32em").style("text-anchor",Mr<0?"end":"start"),ur.attr("d","M"+Mr*A+","+Ye[0]+"H0V"+Ye[1]+"H"+Mr*A)),lr.attr(rr,Mr*_),Lr.attr(rt,Mr*ye),dr.attr(Dt,0).attr(rr,Mr*_),mr.attr(Jr,0).attr(rt,Mr*ye),$.rangeBand){var qr=$,Dr=qr.rangeBand()/2;K=$=function(Kr){return qr(Kr)+Dr}}else K.rangeBand?K=$:ie.call(le,$,K);ne.call(le,K,$),de.call(le,$,$)})}return N.scale=function(U){return arguments.length?(c=U,N):c},N.orient=function(U){return arguments.length?(p=U in XI?U+"":Xb,N):p},N.ticks=function(){return arguments.length?(L=t(arguments),N):L},N.tickValues=function(U){return arguments.length?(R=U,N):R},N.tickFormat=function(U){return arguments.length?(E=U,N):E},N.tickSize=function(U){var W=arguments.length;return W?(_=+U,A=+arguments[W-1],N):_},N.innerTickSize=function(U){return arguments.length?(_=+U,N):_},N.outerTickSize=function(U){return arguments.length?(A=+U,N):A},N.tickPadding=function(U){return arguments.length?(C=+U,N):C},N.tickSubdivide=function(){return arguments.length&&N},N};var Xb="bottom",XI={top:1,right:1,bottom:1,left:1};function ZI(c,p,_){c.attr("transform",function(A){var C=p(A);return"translate("+(isFinite(C)?C:_(A))+",0)"})}function JI(c,p,_){c.attr("transform",function(A){var C=p(A);return"translate(0,"+(isFinite(C)?C:_(A))+")"})}e.svg.brush=function(){var c=ve(W,"brushstart","brush","brushend"),p=null,_=null,A=[0,0],C=[0,0],L,R,E=!0,N=!0,U=bp[0];function W(j){j.each(function(){var ne=e.select(this).style("pointer-events","all").style("-webkit-tap-highlight-color","rgba(0,0,0,0)").on("mousedown.brush",pe).on("touchstart.brush",pe),ie=ne.selectAll(".background").data([0]);ie.enter().append("rect").attr("class","background").style("visibility","hidden").style("cursor","crosshair"),ne.selectAll(".extent").data([0]).enter().append("rect").attr("class","extent").style("cursor","move");var de=ne.selectAll(".resize").data(U,O);de.exit().remove(),de.enter().append("g").attr("class",function(nr){return"resize "+nr}).style("cursor",function(nr){return KI[nr]}).append("rect").attr("x",function(nr){return/[ew]$/.test(nr)?-3:null}).attr("y",function(nr){return/^[ns]/.test(nr)?-3:null}).attr("width",6).attr("height",6).style("visibility","hidden"),de.style("display",W.empty()?"none":null);var ye=e.transition(ne),le=e.transition(ie),Ye;p&&(Ye=k0(p),le.attr("x",Ye[0]).attr("width",Ye[1]-Ye[0]),$(ye)),_&&(Ye=k0(_),le.attr("y",Ye[0]).attr("height",Ye[1]-Ye[0]),ue(ye)),K(ye)})}W.event=function(j){j.each(function(){var ne=c.of(this,arguments),ie={x:A,y:C,i:L,j:R},de=this.__chart__||ie;this.__chart__=ie,vo?e.select(this).transition().each("start.brush",function(){L=de.i,R=de.j,A=de.x,C=de.y,ne({type:"brushstart"})}).tween("brush:brush",function(){var ye=M0(A,ie.x),le=M0(C,ie.y);return L=R=null,function(Ye){A=ie.x=ye(Ye),C=ie.y=le(Ye),ne({type:"brush",mode:"resize"})}}).each("end.brush",function(){L=ie.i,R=ie.j,ne({type:"brush",mode:"resize"}),ne({type:"brushend"})}):(ne({type:"brushstart"}),ne({type:"brush",mode:"resize"}),ne({type:"brushend"}))})};function K(j){j.selectAll(".resize").attr("transform",function(ne){return"translate("+A[+/e$/.test(ne)]+","+C[+/^s/.test(ne)]+")"})}function $(j){j.select(".extent").attr("x",A[0]),j.selectAll(".extent,.n>rect,.s>rect").attr("width",A[1]-A[0])}function ue(j){j.select(".extent").attr("y",C[0]),j.selectAll(".extent,.e>rect,.w>rect").attr("height",C[1]-C[0])}function pe(){var j=this,ne=e.select(e.event.target),ie=c.of(j,arguments),de=e.select(j),ye=ne.datum(),le=!/^(n|s)$/.test(ye)&&p,Ye=!/^(e|w)$/.test(ye)&&_,nr=ne.classed("extent"),ur=ar(j),lr,dr=e.mouse(j),pr,Lr=e.select(i(j)).on("keydown.brush",Jr).on("keyup.brush",Dt);if(e.event.changedTouches?Lr.on("touchmove.brush",rt).on("touchend.brush",qr):Lr.on("mousemove.brush",rt).on("mouseup.brush",qr),de.interrupt().selectAll("*").interrupt(),nr)dr[0]=A[0]-dr[0],dr[1]=C[0]-dr[1];else if(ye){var mr=+/w$/.test(ye),Mr=+/^n/.test(ye);pr=[A[1-mr]-dr[0],C[1-Mr]-dr[1]],dr[0]=A[mr],dr[1]=C[Mr]}else e.event.altKey&&(lr=dr.slice());de.style("pointer-events","none").selectAll(".resize").style("display",null),e.select("body").style("cursor",ne.style("cursor")),ie({type:"brushstart"}),rt();function Jr(){e.event.keyCode==32&&(nr||(lr=null,dr[0]-=A[1],dr[1]-=C[1],nr=2),re())}function Dt(){e.event.keyCode==32&&nr==2&&(dr[0]+=A[1],dr[1]+=C[1],nr=0,re())}function rt(){var Dr=e.mouse(j),Kr=!1;pr&&(Dr[0]+=pr[0],Dr[1]+=pr[1]),nr||(e.event.altKey?(lr||(lr=[(A[0]+A[1])/2,(C[0]+C[1])/2]),dr[0]=A[+(Dr[0]{(function(e,r){typeof O0=="object"&&typeof t_!="undefined"?r(O0):(e=e||self,r(e.d3=e.d3||{}))})(O0,function(e){"use strict";var r=new Date,t=new Date;function a(te,he,Ze,hr){function De(me){return te(me=arguments.length===0?new Date:new Date(+me)),me}return De.floor=function(me){return te(me=new Date(+me)),me},De.ceil=function(me){return te(me=new Date(me-1)),he(me,1),te(me),me},De.round=function(me){var be=De(me),Fe=De.ceil(me);return me-be0))return Ge;do Ge.push(Ue=new Date(+me)),he(me,Fe),te(me);while(Ue=be)for(;te(be),!me(be);)be.setTime(be-1)},function(be,Fe){if(be>=be)if(Fe<0)for(;++Fe<=0;)for(;he(be,-1),!me(be););else for(;--Fe>=0;)for(;he(be,1),!me(be););})},Ze&&(De.count=function(me,be){return r.setTime(+me),t.setTime(+be),te(r),te(t),Math.floor(Ze(r,t))},De.every=function(me){return me=Math.floor(me),!isFinite(me)||!(me>0)?null:me>1?De.filter(hr?function(be){return hr(be)%me===0}:function(be){return De.count(0,be)%me===0}):De}),De}var n=a(function(){},function(te,he){te.setTime(+te+he)},function(te,he){return he-te});n.every=function(te){return te=Math.floor(te),!isFinite(te)||!(te>0)?null:te>1?a(function(he){he.setTime(Math.floor(he/te)*te)},function(he,Ze){he.setTime(+he+Ze*te)},function(he,Ze){return(Ze-he)/te}):n};var i=n.range,l=1e3,o=6e4,s=36e5,u=864e5,f=6048e5,v=a(function(te){te.setTime(te-te.getMilliseconds())},function(te,he){te.setTime(+te+he*l)},function(te,he){return(he-te)/l},function(te){return te.getUTCSeconds()}),h=v.range,d=a(function(te){te.setTime(te-te.getMilliseconds()-te.getSeconds()*l)},function(te,he){te.setTime(+te+he*o)},function(te,he){return(he-te)/o},function(te){return te.getMinutes()}),m=d.range,y=a(function(te){te.setTime(te-te.getMilliseconds()-te.getSeconds()*l-te.getMinutes()*o)},function(te,he){te.setTime(+te+he*s)},function(te,he){return(he-te)/s},function(te){return te.getHours()}),g=y.range,x=a(function(te){te.setHours(0,0,0,0)},function(te,he){te.setDate(te.getDate()+he)},function(te,he){return(he-te-(he.getTimezoneOffset()-te.getTimezoneOffset())*o)/u},function(te){return te.getDate()-1}),T=x.range;function M(te){return a(function(he){he.setDate(he.getDate()-(he.getDay()+7-te)%7),he.setHours(0,0,0,0)},function(he,Ze){he.setDate(he.getDate()+Ze*7)},function(he,Ze){return(Ze-he-(Ze.getTimezoneOffset()-he.getTimezoneOffset())*o)/f})}var b=M(0),w=M(1),S=M(2),k=M(3),q=M(4),D=M(5),P=M(6),z=b.range,F=w.range,I=S.range,H=k.range,O=q.range,G=D.range,Y=P.range,B=a(function(te){te.setDate(1),te.setHours(0,0,0,0)},function(te,he){te.setMonth(te.getMonth()+he)},function(te,he){return he.getMonth()-te.getMonth()+(he.getFullYear()-te.getFullYear())*12},function(te){return te.getMonth()}),V=B.range,J=a(function(te){te.setMonth(0,1),te.setHours(0,0,0,0)},function(te,he){te.setFullYear(te.getFullYear()+he)},function(te,he){return he.getFullYear()-te.getFullYear()},function(te){return te.getFullYear()});J.every=function(te){return!isFinite(te=Math.floor(te))||!(te>0)?null:a(function(he){he.setFullYear(Math.floor(he.getFullYear()/te)*te),he.setMonth(0,1),he.setHours(0,0,0,0)},function(he,Ze){he.setFullYear(he.getFullYear()+Ze*te)})};var Z=J.range,re=a(function(te){te.setUTCSeconds(0,0)},function(te,he){te.setTime(+te+he*o)},function(te,he){return(he-te)/o},function(te){return te.getUTCMinutes()}),se=re.range,ve=a(function(te){te.setUTCMinutes(0,0,0)},function(te,he){te.setTime(+te+he*s)},function(te,he){return(he-te)/s},function(te){return te.getUTCHours()}),xe=ve.range,Q=a(function(te){te.setUTCHours(0,0,0,0)},function(te,he){te.setUTCDate(te.getUTCDate()+he)},function(te,he){return(he-te)/u},function(te){return te.getUTCDate()-1}),we=Q.range;function fe(te){return a(function(he){he.setUTCDate(he.getUTCDate()-(he.getUTCDay()+7-te)%7),he.setUTCHours(0,0,0,0)},function(he,Ze){he.setUTCDate(he.getUTCDate()+Ze*7)},function(he,Ze){return(Ze-he)/f})}var ge=fe(0),_e=fe(1),ce=fe(2),Me=fe(3),Re=fe(4),Te=fe(5),Pe=fe(6),Le=ge.range,Ce=_e.range,Ie=ce.range,Oe=Me.range,qe=Re.range,Je=Te.range,Ke=Pe.range,We=a(function(te){te.setUTCDate(1),te.setUTCHours(0,0,0,0)},function(te,he){te.setUTCMonth(te.getUTCMonth()+he)},function(te,he){return he.getUTCMonth()-te.getUTCMonth()+(he.getUTCFullYear()-te.getUTCFullYear())*12},function(te){return te.getUTCMonth()}),ke=We.range,ze=a(function(te){te.setUTCMonth(0,1),te.setUTCHours(0,0,0,0)},function(te,he){te.setUTCFullYear(te.getUTCFullYear()+he)},function(te,he){return he.getUTCFullYear()-te.getUTCFullYear()},function(te){return te.getUTCFullYear()});ze.every=function(te){return!isFinite(te=Math.floor(te))||!(te>0)?null:a(function(he){he.setUTCFullYear(Math.floor(he.getUTCFullYear()/te)*te),he.setUTCMonth(0,1),he.setUTCHours(0,0,0,0)},function(he,Ze){he.setUTCFullYear(he.getUTCFullYear()+Ze*te)})};var He=ze.range;e.timeDay=x,e.timeDays=T,e.timeFriday=D,e.timeFridays=G,e.timeHour=y,e.timeHours=g,e.timeInterval=a,e.timeMillisecond=n,e.timeMilliseconds=i,e.timeMinute=d,e.timeMinutes=m,e.timeMonday=w,e.timeMondays=F,e.timeMonth=B,e.timeMonths=V,e.timeSaturday=P,e.timeSaturdays=Y,e.timeSecond=v,e.timeSeconds=h,e.timeSunday=b,e.timeSundays=z,e.timeThursday=q,e.timeThursdays=O,e.timeTuesday=S,e.timeTuesdays=I,e.timeWednesday=k,e.timeWednesdays=H,e.timeWeek=b,e.timeWeeks=z,e.timeYear=J,e.timeYears=Z,e.utcDay=Q,e.utcDays=we,e.utcFriday=Te,e.utcFridays=Je,e.utcHour=ve,e.utcHours=xe,e.utcMillisecond=n,e.utcMilliseconds=i,e.utcMinute=re,e.utcMinutes=se,e.utcMonday=_e,e.utcMondays=Ce,e.utcMonth=We,e.utcMonths=ke,e.utcSaturday=Pe,e.utcSaturdays=Ke,e.utcSecond=v,e.utcSeconds=h,e.utcSunday=ge,e.utcSundays=Le,e.utcThursday=Re,e.utcThursdays=qe,e.utcTuesday=ce,e.utcTuesdays=Ie,e.utcWednesday=Me,e.utcWednesdays=Oe,e.utcWeek=ge,e.utcWeeks=Le,e.utcYear=ze,e.utcYears=He,Object.defineProperty(e,"__esModule",{value:!0})})});var Ds=X((B0,a_)=>{(function(e,r){typeof B0=="object"&&typeof a_!="undefined"?r(B0,xp()):(e=e||self,r(e.d3=e.d3||{},e.d3))})(B0,function(e,r){"use strict";function t(ee){if(0<=ee.y&&ee.y<100){var ae=new Date(-1,ee.m,ee.d,ee.H,ee.M,ee.S,ee.L);return ae.setFullYear(ee.y),ae}return new Date(ee.y,ee.m,ee.d,ee.H,ee.M,ee.S,ee.L)}function a(ee){if(0<=ee.y&&ee.y<100){var ae=new Date(Date.UTC(-1,ee.m,ee.d,ee.H,ee.M,ee.S,ee.L));return ae.setUTCFullYear(ee.y),ae}return new Date(Date.UTC(ee.y,ee.m,ee.d,ee.H,ee.M,ee.S,ee.L))}function n(ee,ae,Ae){return{y:ee,m:ae,d:Ae,H:0,M:0,S:0,L:0}}function i(ee){var ae=ee.dateTime,Ae=ee.date,Se=ee.time,$e=ee.periods,fr=ee.days,sr=ee.shortDays,Ar=ee.months,Hr=ee.shortMonths,Zr=h($e),ot=d($e),Tt=h(fr),bt=d(fr),Gt=h(sr),Ht=d(sr),Wa=h(Ar),ga=d(Ar),oa=h(Hr),Xa=d(Hr),sa={a:Bt,A:so,b:ml,B:c0,c:null,d:B,e:B,f:se,H:V,I:J,j:Z,L:re,m:ve,M:xe,p:v0,q:ef,Q:be,s:Fe,S:Q,u:we,U:fe,V:ge,w:_e,W:ce,x:null,X:null,y:Me,Y:Re,Z:Te,"%":me},ua={a:rf,A:tf,b:za,B:h0,c:null,d:Pe,e:Pe,f:qe,H:Le,I:Ce,j:Ie,L:Oe,m:Je,M:Ke,p:xs,q:ws,Q:be,s:Fe,S:We,u:ke,U:ze,V:He,w:te,W:he,x:null,X:null,y:Ze,Y:hr,Z:De,"%":me},cr={a:ca,A:nt,b:Qa,B:Kn,c:ra,d:q,e:q,f:H,H:P,I:P,j:D,L:I,m:k,M:z,p:fa,q:S,Q:G,s:Y,S:F,u:y,U:g,V:x,w:m,W:T,x:ht,X:Ot,y:b,Y:M,Z:w,"%":O};sa.x=st(Ae,sa),sa.X=st(Se,sa),sa.c=st(ae,sa),ua.x=st(Ae,ua),ua.X=st(Se,ua),ua.c=st(ae,ua);function st(yr,Vr){return function(jr){var or=[],ba=-1,ut=0,_a=yr.length,zr,$n,uo;for(jr instanceof Date||(jr=new Date(+jr));++ba<_a;)yr.charCodeAt(ba)===37&&(or.push(yr.slice(ut,ba)),($n=l[zr=yr.charAt(++ba)])!=null?zr=yr.charAt(++ba):$n=zr==="e"?" ":"0",(uo=Vr[zr])&&(zr=uo(jr,$n)),or.push(zr),ut=ba+1);return or.push(yr.slice(ut,ba)),or.join("")}}function Wr(yr,Vr){return function(jr){var or=n(1900,void 0,1),ba=it(or,yr,jr+="",0),ut,_a;if(ba!=jr.length)return null;if("Q"in or)return new Date(or.Q);if("s"in or)return new Date(or.s*1e3+("L"in or?or.L:0));if(Vr&&!("Z"in or)&&(or.Z=0),"p"in or&&(or.H=or.H%12+or.p*12),or.m===void 0&&(or.m="q"in or?or.q:0),"V"in or){if(or.V<1||or.V>53)return null;"w"in or||(or.w=1),"Z"in or?(ut=a(n(or.y,0,1)),_a=ut.getUTCDay(),ut=_a>4||_a===0?r.utcMonday.ceil(ut):r.utcMonday(ut),ut=r.utcDay.offset(ut,(or.V-1)*7),or.y=ut.getUTCFullYear(),or.m=ut.getUTCMonth(),or.d=ut.getUTCDate()+(or.w+6)%7):(ut=t(n(or.y,0,1)),_a=ut.getDay(),ut=_a>4||_a===0?r.timeMonday.ceil(ut):r.timeMonday(ut),ut=r.timeDay.offset(ut,(or.V-1)*7),or.y=ut.getFullYear(),or.m=ut.getMonth(),or.d=ut.getDate()+(or.w+6)%7)}else("W"in or||"U"in or)&&("w"in or||(or.w="u"in or?or.u%7:"W"in or?1:0),_a="Z"in or?a(n(or.y,0,1)).getUTCDay():t(n(or.y,0,1)).getDay(),or.m=0,or.d="W"in or?(or.w+6)%7+or.W*7-(_a+5)%7:or.w+or.U*7-(_a+6)%7);return"Z"in or?(or.H+=or.Z/100|0,or.M+=or.Z%100,a(or)):t(or)}}function it(yr,Vr,jr,or){for(var ba=0,ut=Vr.length,_a=jr.length,zr,$n;ba=_a)return-1;if(zr=Vr.charCodeAt(ba++),zr===37){if(zr=Vr.charAt(ba++),$n=cr[zr in l?Vr.charAt(ba++):zr],!$n||(or=$n(yr,jr,or))<0)return-1}else if(zr!=jr.charCodeAt(or++))return-1}return or}function fa(yr,Vr,jr){var or=Zr.exec(Vr.slice(jr));return or?(yr.p=ot[or[0].toLowerCase()],jr+or[0].length):-1}function ca(yr,Vr,jr){var or=Gt.exec(Vr.slice(jr));return or?(yr.w=Ht[or[0].toLowerCase()],jr+or[0].length):-1}function nt(yr,Vr,jr){var or=Tt.exec(Vr.slice(jr));return or?(yr.w=bt[or[0].toLowerCase()],jr+or[0].length):-1}function Qa(yr,Vr,jr){var or=oa.exec(Vr.slice(jr));return or?(yr.m=Xa[or[0].toLowerCase()],jr+or[0].length):-1}function Kn(yr,Vr,jr){var or=Wa.exec(Vr.slice(jr));return or?(yr.m=ga[or[0].toLowerCase()],jr+or[0].length):-1}function ra(yr,Vr,jr){return it(yr,ae,Vr,jr)}function ht(yr,Vr,jr){return it(yr,Ae,Vr,jr)}function Ot(yr,Vr,jr){return it(yr,Se,Vr,jr)}function Bt(yr){return sr[yr.getDay()]}function so(yr){return fr[yr.getDay()]}function ml(yr){return Hr[yr.getMonth()]}function c0(yr){return Ar[yr.getMonth()]}function v0(yr){return $e[+(yr.getHours()>=12)]}function ef(yr){return 1+~~(yr.getMonth()/3)}function rf(yr){return sr[yr.getUTCDay()]}function tf(yr){return fr[yr.getUTCDay()]}function za(yr){return Hr[yr.getUTCMonth()]}function h0(yr){return Ar[yr.getUTCMonth()]}function xs(yr){return $e[+(yr.getUTCHours()>=12)]}function ws(yr){return 1+~~(yr.getUTCMonth()/3)}return{format:function(yr){var Vr=st(yr+="",sa);return Vr.toString=function(){return yr},Vr},parse:function(yr){var Vr=Wr(yr+="",!1);return Vr.toString=function(){return yr},Vr},utcFormat:function(yr){var Vr=st(yr+="",ua);return Vr.toString=function(){return yr},Vr},utcParse:function(yr){var Vr=Wr(yr+="",!0);return Vr.toString=function(){return yr},Vr}}}var l={"-":"",_:" ",0:"0"},o=/^\s*\d+/,s=/^%/,u=/[\\^$*+?|[\]().{}]/g;function f(ee,ae,Ae){var Se=ee<0?"-":"",$e=(Se?-ee:ee)+"",fr=$e.length;return Se+(fr68?1900:2e3),Ae+Se[0].length):-1}function w(ee,ae,Ae){var Se=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(ae.slice(Ae,Ae+6));return Se?(ee.Z=Se[1]?0:-(Se[2]+(Se[3]||"00")),Ae+Se[0].length):-1}function S(ee,ae,Ae){var Se=o.exec(ae.slice(Ae,Ae+1));return Se?(ee.q=Se[0]*3-3,Ae+Se[0].length):-1}function k(ee,ae,Ae){var Se=o.exec(ae.slice(Ae,Ae+2));return Se?(ee.m=Se[0]-1,Ae+Se[0].length):-1}function q(ee,ae,Ae){var Se=o.exec(ae.slice(Ae,Ae+2));return Se?(ee.d=+Se[0],Ae+Se[0].length):-1}function D(ee,ae,Ae){var Se=o.exec(ae.slice(Ae,Ae+3));return Se?(ee.m=0,ee.d=+Se[0],Ae+Se[0].length):-1}function P(ee,ae,Ae){var Se=o.exec(ae.slice(Ae,Ae+2));return Se?(ee.H=+Se[0],Ae+Se[0].length):-1}function z(ee,ae,Ae){var Se=o.exec(ae.slice(Ae,Ae+2));return Se?(ee.M=+Se[0],Ae+Se[0].length):-1}function F(ee,ae,Ae){var Se=o.exec(ae.slice(Ae,Ae+2));return Se?(ee.S=+Se[0],Ae+Se[0].length):-1}function I(ee,ae,Ae){var Se=o.exec(ae.slice(Ae,Ae+3));return Se?(ee.L=+Se[0],Ae+Se[0].length):-1}function H(ee,ae,Ae){var Se=o.exec(ae.slice(Ae,Ae+6));return Se?(ee.L=Math.floor(Se[0]/1e3),Ae+Se[0].length):-1}function O(ee,ae,Ae){var Se=s.exec(ae.slice(Ae,Ae+1));return Se?Ae+Se[0].length:-1}function G(ee,ae,Ae){var Se=o.exec(ae.slice(Ae));return Se?(ee.Q=+Se[0],Ae+Se[0].length):-1}function Y(ee,ae,Ae){var Se=o.exec(ae.slice(Ae));return Se?(ee.s=+Se[0],Ae+Se[0].length):-1}function B(ee,ae){return f(ee.getDate(),ae,2)}function V(ee,ae){return f(ee.getHours(),ae,2)}function J(ee,ae){return f(ee.getHours()%12||12,ae,2)}function Z(ee,ae){return f(1+r.timeDay.count(r.timeYear(ee),ee),ae,3)}function re(ee,ae){return f(ee.getMilliseconds(),ae,3)}function se(ee,ae){return re(ee,ae)+"000"}function ve(ee,ae){return f(ee.getMonth()+1,ae,2)}function xe(ee,ae){return f(ee.getMinutes(),ae,2)}function Q(ee,ae){return f(ee.getSeconds(),ae,2)}function we(ee){var ae=ee.getDay();return ae===0?7:ae}function fe(ee,ae){return f(r.timeSunday.count(r.timeYear(ee)-1,ee),ae,2)}function ge(ee,ae){var Ae=ee.getDay();return ee=Ae>=4||Ae===0?r.timeThursday(ee):r.timeThursday.ceil(ee),f(r.timeThursday.count(r.timeYear(ee),ee)+(r.timeYear(ee).getDay()===4),ae,2)}function _e(ee){return ee.getDay()}function ce(ee,ae){return f(r.timeMonday.count(r.timeYear(ee)-1,ee),ae,2)}function Me(ee,ae){return f(ee.getFullYear()%100,ae,2)}function Re(ee,ae){return f(ee.getFullYear()%1e4,ae,4)}function Te(ee){var ae=ee.getTimezoneOffset();return(ae>0?"-":(ae*=-1,"+"))+f(ae/60|0,"0",2)+f(ae%60,"0",2)}function Pe(ee,ae){return f(ee.getUTCDate(),ae,2)}function Le(ee,ae){return f(ee.getUTCHours(),ae,2)}function Ce(ee,ae){return f(ee.getUTCHours()%12||12,ae,2)}function Ie(ee,ae){return f(1+r.utcDay.count(r.utcYear(ee),ee),ae,3)}function Oe(ee,ae){return f(ee.getUTCMilliseconds(),ae,3)}function qe(ee,ae){return Oe(ee,ae)+"000"}function Je(ee,ae){return f(ee.getUTCMonth()+1,ae,2)}function Ke(ee,ae){return f(ee.getUTCMinutes(),ae,2)}function We(ee,ae){return f(ee.getUTCSeconds(),ae,2)}function ke(ee){var ae=ee.getUTCDay();return ae===0?7:ae}function ze(ee,ae){return f(r.utcSunday.count(r.utcYear(ee)-1,ee),ae,2)}function He(ee,ae){var Ae=ee.getUTCDay();return ee=Ae>=4||Ae===0?r.utcThursday(ee):r.utcThursday.ceil(ee),f(r.utcThursday.count(r.utcYear(ee),ee)+(r.utcYear(ee).getUTCDay()===4),ae,2)}function te(ee){return ee.getUTCDay()}function he(ee,ae){return f(r.utcMonday.count(r.utcYear(ee)-1,ee),ae,2)}function Ze(ee,ae){return f(ee.getUTCFullYear()%100,ae,2)}function hr(ee,ae){return f(ee.getUTCFullYear()%1e4,ae,4)}function De(){return"+0000"}function me(){return"%"}function be(ee){return+ee}function Fe(ee){return Math.floor(+ee/1e3)}var Ge;Ue({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});function Ue(ee){return Ge=i(ee),e.timeFormat=Ge.format,e.timeParse=Ge.parse,e.utcFormat=Ge.utcFormat,e.utcParse=Ge.utcParse,Ge}var Ve="%Y-%m-%dT%H:%M:%S.%LZ";function ir(ee){return ee.toISOString()}var ar=Date.prototype.toISOString?ir:e.utcFormat(Ve);function er(ee){var ae=new Date(ee);return isNaN(ae)?null:ae}var Ee=+new Date("2000-01-01T00:00:00.000Z")?er:e.utcParse(Ve);e.isoFormat=ar,e.isoParse=Ee,e.timeFormatDefaultLocale=Ue,e.timeFormatLocale=i,Object.defineProperty(e,"__esModule",{value:!0})})});var wp=X((Y0,n_)=>{(function(e,r){typeof Y0=="object"&&typeof n_!="undefined"?r(Y0):(e=typeof globalThis!="undefined"?globalThis:e||self,r(e.d3=e.d3||{}))})(Y0,(function(e){"use strict";function r(k){return Math.abs(k=Math.round(k))>=1e21?k.toLocaleString("en").replace(/,/g,""):k.toString(10)}function t(k,q){if((D=(k=q?k.toExponential(q-1):k.toExponential()).indexOf("e"))<0)return null;var D,P=k.slice(0,D);return[P.length>1?P[0]+P.slice(2):P,+k.slice(D+1)]}function a(k){return k=t(Math.abs(k)),k?k[1]:NaN}function n(k,q){return function(D,P){for(var z=D.length,F=[],I=0,H=k[0],O=0;z>0&&H>0&&(O+H+1>P&&(H=Math.max(1,P-O)),F.push(D.substring(z-=H,z+H)),!((O+=H+1)>P));)H=k[I=(I+1)%k.length];return F.reverse().join(q)}}function i(k){return function(q){return q.replace(/[0-9]/g,function(D){return k[+D]})}}var l=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function o(k){if(!(q=l.exec(k)))throw new Error("invalid format: "+k);var q;return new s({fill:q[1],align:q[2],sign:q[3],symbol:q[4],zero:q[5],width:q[6],comma:q[7],precision:q[8]&&q[8].slice(1),trim:q[9],type:q[10]})}o.prototype=s.prototype;function s(k){this.fill=k.fill===void 0?" ":k.fill+"",this.align=k.align===void 0?">":k.align+"",this.sign=k.sign===void 0?"-":k.sign+"",this.symbol=k.symbol===void 0?"":k.symbol+"",this.zero=!!k.zero,this.width=k.width===void 0?void 0:+k.width,this.comma=!!k.comma,this.precision=k.precision===void 0?void 0:+k.precision,this.trim=!!k.trim,this.type=k.type===void 0?"":k.type+""}s.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(this.width===void 0?"":Math.max(1,this.width|0))+(this.comma?",":"")+(this.precision===void 0?"":"."+Math.max(0,this.precision|0))+(this.trim?"~":"")+this.type};function u(k){e:for(var q=k.length,D=1,P=-1,z;D0&&(P=0);break}return P>0?k.slice(0,P)+k.slice(z+1):k}var f;function v(k,q){var D=t(k,q);if(!D)return k+"";var P=D[0],z=D[1],F=z-(f=Math.max(-8,Math.min(8,Math.floor(z/3)))*3)+1,I=P.length;return F===I?P:F>I?P+new Array(F-I+1).join("0"):F>0?P.slice(0,F)+"."+P.slice(F):"0."+new Array(1-F).join("0")+t(k,Math.max(0,q+F-1))[0]}function h(k,q){var D=t(k,q);if(!D)return k+"";var P=D[0],z=D[1];return z<0?"0."+new Array(-z).join("0")+P:P.length>z+1?P.slice(0,z+1)+"."+P.slice(z+1):P+new Array(z-P.length+2).join("0")}var d={"%":function(k,q){return(k*100).toFixed(q)},b:function(k){return Math.round(k).toString(2)},c:function(k){return k+""},d:r,e:function(k,q){return k.toExponential(q)},f:function(k,q){return k.toFixed(q)},g:function(k,q){return k.toPrecision(q)},o:function(k){return Math.round(k).toString(8)},p:function(k,q){return h(k*100,q)},r:h,s:v,X:function(k){return Math.round(k).toString(16).toUpperCase()},x:function(k){return Math.round(k).toString(16)}};function m(k){return k}var y=Array.prototype.map,g=["y","z","a","f","p","n","\xB5","m","","k","M","G","T","P","E","Z","Y"];function x(k){var q=k.grouping===void 0||k.thousands===void 0?m:n(y.call(k.grouping,Number),k.thousands+""),D=k.currency===void 0?"":k.currency[0]+"",P=k.currency===void 0?"":k.currency[1]+"",z=k.decimal===void 0?".":k.decimal+"",F=k.numerals===void 0?m:i(y.call(k.numerals,String)),I=k.percent===void 0?"%":k.percent+"",H=k.minus===void 0?"-":k.minus+"",O=k.nan===void 0?"NaN":k.nan+"";function G(B){B=o(B);var V=B.fill,J=B.align,Z=B.sign,re=B.symbol,se=B.zero,ve=B.width,xe=B.comma,Q=B.precision,we=B.trim,fe=B.type;fe==="n"?(xe=!0,fe="g"):d[fe]||(Q===void 0&&(Q=12),we=!0,fe="g"),(se||V==="0"&&J==="=")&&(se=!0,V="0",J="=");var ge=re==="$"?D:re==="#"&&/[boxX]/.test(fe)?"0"+fe.toLowerCase():"",_e=re==="$"?P:/[%p]/.test(fe)?I:"",ce=d[fe],Me=/[defgprs%]/.test(fe);Q=Q===void 0?6:/[gprs]/.test(fe)?Math.max(1,Math.min(21,Q)):Math.max(0,Math.min(20,Q));function Re(Te){var Pe=ge,Le=_e,Ce,Ie,Oe;if(fe==="c")Le=ce(Te)+Le,Te="";else{Te=+Te;var qe=Te<0||1/Te<0;if(Te=isNaN(Te)?O:ce(Math.abs(Te),Q),we&&(Te=u(Te)),qe&&+Te==0&&Z!=="+"&&(qe=!1),Pe=(qe?Z==="("?Z:H:Z==="-"||Z==="("?"":Z)+Pe,Le=(fe==="s"?g[8+f/3]:"")+Le+(qe&&Z==="("?")":""),Me){for(Ce=-1,Ie=Te.length;++CeOe||Oe>57){Le=(Oe===46?z+Te.slice(Ce+1):Te.slice(Ce))+Le,Te=Te.slice(0,Ce);break}}}xe&&!se&&(Te=q(Te,1/0));var Je=Pe.length+Te.length+Le.length,Ke=Je>1)+Pe+Te+Le+Ke.slice(Je);break;default:Te=Ke+Pe+Te+Le;break}return F(Te)}return Re.toString=function(){return B+""},Re}function Y(B,V){var J=G((B=o(B),B.type="f",B)),Z=Math.max(-8,Math.min(8,Math.floor(a(V)/3)))*3,re=Math.pow(10,-Z),se=g[8+Z/3];return function(ve){return J(re*ve)+se}}return{format:G,formatPrefix:Y}}var T;M({decimal:".",thousands:",",grouping:[3],currency:["$",""],minus:"-"});function M(k){return T=x(k),e.format=T.format,e.formatPrefix=T.formatPrefix,T}function b(k){return Math.max(0,-a(Math.abs(k)))}function w(k,q){return Math.max(0,Math.max(-8,Math.min(8,Math.floor(a(q)/3)))*3-a(Math.abs(k)))}function S(k,q){return k=Math.abs(k),q=Math.abs(q)-k,Math.max(0,a(q)-a(k))+1}e.FormatSpecifier=s,e.formatDefaultLocale=M,e.formatLocale=x,e.formatSpecifier=o,e.precisionFixed=b,e.precisionPrefix=w,e.precisionRound=S,Object.defineProperty(e,"__esModule",{value:!0})}))});var l_=X((coe,i_)=>{"use strict";i_.exports=function(e){for(var r=e.length,t,a=0;a13)&&t!==32&&t!==133&&t!==160&&t!==5760&&t!==6158&&(t<8192||t>8205)&&t!==8232&&t!==8233&&t!==8239&&t!==8287&&t!==8288&&t!==12288&&t!==65279)return!1;return!0}});var Br=X((voe,o_)=>{"use strict";var oH=l_();o_.exports=function(e){var r=typeof e;if(r==="string"){var t=e;if(e=+e,e===0&&oH(t))return!1}else if(r!=="number")return!1;return e-e<1}});var Zt=X((hoe,s_)=>{"use strict";s_.exports={BADNUM:void 0,FP_SAFE:Number.MAX_VALUE*1e-4,ONEMAXYEAR:316224e5,ONEAVGYEAR:315576e5,ONEMINYEAR:31536e6,ONEMAXQUARTER:79488e5,ONEAVGQUARTER:78894e5,ONEMINQUARTER:76896e5,ONEMAXMONTH:26784e5,ONEAVGMONTH:26298e5,ONEMINMONTH:24192e5,ONEWEEK:6048e5,ONEDAY:864e5,ONEHOUR:36e5,ONEMIN:6e4,ONESEC:1e3,ONEMILLI:1,ONEMICROSEC:.001,EPOCHJD:24405875e-1,ALMOST_EQUAL:1-1e-6,LOG_CLIP:10,MINUS_SIGN:"\u2212"}});var Tp=X((U0,u_)=>{(function(e,r){typeof U0=="object"&&typeof u_!="undefined"?r(U0):(e=typeof globalThis!="undefined"?globalThis:e||self,r(e["base64-arraybuffer"]={}))})(U0,(function(e){"use strict";for(var r="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",t=typeof Uint8Array=="undefined"?[]:new Uint8Array(256),a=0;a>2],f+=r[(o[s]&3)<<4|o[s+1]>>4],f+=r[(o[s+1]&15)<<2|o[s+2]>>6],f+=r[o[s+2]&63];return u%3===2?f=f.substring(0,f.length-1)+"=":u%3===1&&(f=f.substring(0,f.length-2)+"=="),f},i=function(l){var o=l.length*.75,s=l.length,u,f=0,v,h,d,m;l[l.length-1]==="="&&(o--,l[l.length-2]==="="&&o--);var y=new ArrayBuffer(o),g=new Uint8Array(y);for(u=0;u>4,g[f++]=(h&15)<<4|d>>2,g[f++]=(d&3)<<6|m&63;return y};e.decode=i,e.encode=n,Object.defineProperty(e,"__esModule",{value:!0})}))});var yo=X((doe,f_)=>{"use strict";f_.exports=function(r){return window&&window.process&&window.process.versions?Object.prototype.toString.call(r)==="[object Object]":Object.prototype.toString.call(r)==="[object Object]"&&Object.getPrototypeOf(r).hasOwnProperty("hasOwnProperty")}});var hn=X(ei=>{"use strict";var sH=Tp().decode,uH=yo(),Mp=Array.isArray,fH=ArrayBuffer,cH=DataView;function c_(e){return fH.isView(e)&&!(e instanceof cH)}ei.isTypedArray=c_;function V0(e){return Mp(e)||c_(e)}ei.isArrayOrTypedArray=V0;function vH(e){return!V0(e[0])}ei.isArray1D=vH;ei.ensureArray=function(e,r){return Mp(e)||(e=[]),e.length=r,e};var va={u1c:typeof Uint8ClampedArray=="undefined"?void 0:Uint8ClampedArray,i1:typeof Int8Array=="undefined"?void 0:Int8Array,u1:typeof Uint8Array=="undefined"?void 0:Uint8Array,i2:typeof Int16Array=="undefined"?void 0:Int16Array,u2:typeof Uint16Array=="undefined"?void 0:Uint16Array,i4:typeof Int32Array=="undefined"?void 0:Int32Array,u4:typeof Uint32Array=="undefined"?void 0:Uint32Array,f4:typeof Float32Array=="undefined"?void 0:Float32Array,f8:typeof Float64Array=="undefined"?void 0:Float64Array};va.uint8c=va.u1c;va.uint8=va.u1;va.int8=va.i1;va.uint16=va.u2;va.int16=va.i2;va.uint32=va.u4;va.int32=va.i4;va.float32=va.f4;va.float64=va.f8;function Ap(e){return e.constructor===ArrayBuffer}ei.isArrayBuffer=Ap;ei.decodeTypedArraySpec=function(e){var r=[],t=hH(e),a=t.dtype,n=va[a];if(!n)throw new Error('Error in dtype: "'+a+'"');var i=n.BYTES_PER_ELEMENT,l=t.bdata;Ap(l)||(l=sH(l));var o=t.shape===void 0?[l.byteLength/i]:(""+t.shape).split(",");o.reverse();var s=o.length,u,f,v=+o[0],h=i*v,d=0;if(s===1)r=new n(l);else if(s===2)for(u=+o[1],f=0;f{"use strict";var h_=Br(),Cp=hn().isArrayOrTypedArray;y_.exports=function(r,t){if(h_(t))t=String(t);else if(typeof t!="string"||t.slice(-4)==="[-1]")throw"bad property string";var a=t.split("."),n,i,l,o;for(o=0;o{"use strict";var Es=G0(),gH=/^\w*$/,bH=0,g_=1,W0=2,b_=3,go=4;__.exports=function(r,t,a,n){a=a||"name",n=n||"value";var i,l,o,s={};t&&t.length?(o=Es(r,t),l=o.get()):l=r,t=t||"";var u={};if(l)for(i=0;i2)return s[d]=s[d]|W0,v.set(h,null);if(f){for(i=d;i{"use strict";var _H=/^(.*)(\.[^\.\[\]]+|\[\d\])$/,xH=/^[^\.\[\]]+$/;w_.exports=function(e,r){for(;r;){var t=e.match(_H);if(t)e=t[1];else if(e.match(xH))e="";else throw new Error("bad relativeAttr call:"+[e,r]);if(r.charAt(0)==="^")r=r.slice(1);else break}return e&&r.charAt(0)!=="["?e+"."+r:e+r}});var X0=X((boe,M_)=>{"use strict";var wH=Br();M_.exports=function(r,t){if(r>0)return Math.log(r)/Math.LN10;var a=Math.log(Math.min(t[0],t[1]))/Math.LN10;return wH(a)||(a=Math.log(Math.max(t[0],t[1]))/Math.LN10-6),a}});var C_=X((_oe,k_)=>{"use strict";var A_=hn().isArrayOrTypedArray,pf=yo();k_.exports=function e(r,t){for(var a in t){var n=t[a],i=r[a];if(i!==n)if(a.charAt(0)==="_"||typeof n=="function"){if(a in r)continue;r[a]=n}else if(A_(n)&&A_(i)&&pf(n[0])){if(a==="customdata"||a==="ids")continue;for(var l=Math.min(n.length,i.length),o=0;oTH,modHalf:()=>MH});function TH(e,r){let t=e%r;return t<0?t+r:t}function MH(e,r){return Math.abs(e)>r/2?e-Math.round(e/r)*r:e}var Rs=Ln(()=>{"use strict"});var Jt=X(gf=>{"use strict";var S_=yo(),mf=Array.isArray;function AH(e,r){var t,a;for(t=0;t{"use strict";L_.exports=function(e){var r=e.variantValues,t=e.editType,a=e.colorEditType;a===void 0&&(a=t);var n={editType:t,valType:"integer",min:1,max:1e3,extras:["normal","bold"],dflt:"normal"};e.noNumericWeightValues&&(n.valType="enumerated",n.values=n.extras,n.extras=void 0,n.min=void 0,n.max=void 0);var i={family:{valType:"string",noBlank:!0,strict:!0,editType:t},size:{valType:"number",min:1,editType:t},color:{valType:"color",editType:a},weight:n,style:{editType:t,valType:"enumerated",values:["normal","italic"],dflt:"normal"},variant:e.noFontVariant?void 0:{editType:t,valType:"enumerated",values:r||["normal","small-caps","all-small-caps","all-petite-caps","petite-caps","unicase"],dflt:"normal"},textcase:e.noFontTextcase?void 0:{editType:t,valType:"enumerated",values:["normal","word caps","upper","lower"],dflt:"normal"},lineposition:e.noFontLineposition?void 0:{editType:t,valType:"flaglist",flags:["under","over","through"],extras:["none"],dflt:"none"},shadow:e.noFontShadow?void 0:{editType:t,valType:"string",dflt:e.autoShadowDflt?"auto":"none"},editType:t};return e.autoSize&&(i.size.dflt="auto"),e.autoColor&&(i.color.dflt="auto"),e.arrayOk&&(i.family.arrayOk=!0,i.weight.arrayOk=!0,i.style.arrayOk=!0,e.noFontVariant||(i.variant.arrayOk=!0),e.noFontTextcase||(i.textcase.arrayOk=!0),e.noFontLineposition||(i.lineposition.arrayOk=!0),e.noFontShadow||(i.shadow.arrayOk=!0),i.size.arrayOk=!0,i.color.arrayOk=!0),i}});var bf=X((Toe,q_)=>{"use strict";q_.exports={YANGLE:60,HOVERARROWSIZE:6,HOVERTEXTPAD:3,HOVERFONTSIZE:13,HOVERFONT:"Arial, sans-serif",HOVERMINTIME:50,HOVERID:"-hover"}});var zs=X((Moe,P_)=>{"use strict";var D_=bf(),E_=Ta(),Sp=E_({editType:"none"});Sp.family.dflt=D_.HOVERFONT;Sp.size.dflt=D_.HOVERFONTSIZE;P_.exports={clickmode:{valType:"flaglist",flags:["event","select"],dflt:"event",editType:"plot",extras:["none"]},dragmode:{valType:"enumerated",values:["zoom","pan","select","lasso","drawclosedpath","drawopenpath","drawline","drawrect","drawcircle","orbit","turntable",!1],dflt:"zoom",editType:"modebar"},hovermode:{valType:"enumerated",values:["x","y","closest",!1,"x unified","y unified"],dflt:"closest",editType:"modebar"},hoversort:{valType:"enumerated",values:["trace","value descending","value ascending"],dflt:"trace",editType:"none"},hoversubplots:{valType:"enumerated",values:["single","overlaying","axis"],dflt:"overlaying",editType:"none"},hoveranywhere:{valType:"boolean",dflt:!1,editType:"none"},clickanywhere:{valType:"boolean",dflt:!1,editType:"none"},hoverdistance:{valType:"integer",min:-1,dflt:20,editType:"none"},spikedistance:{valType:"integer",min:-1,dflt:-1,editType:"none"},hoverlabel:{bgcolor:{valType:"color",editType:"none"},bordercolor:{valType:"color",editType:"none"},font:Sp,grouptitlefont:E_({editType:"none"}),align:{valType:"enumerated",values:["left","right","auto"],dflt:"auto",editType:"none"},namelength:{valType:"integer",min:-1,dflt:15,editType:"none"},showarrow:{valType:"boolean",dflt:!0,editType:"none"},editType:"none"},selectdirection:{valType:"enumerated",values:["h","v","d","any"],dflt:"any",editType:"none"}}});var Z0=X((Aoe,R_)=>{"use strict";var kH=Ta(),_f=zs().hoverlabel,xf=Jt().extendFlat;R_.exports={hoverlabel:{bgcolor:xf({},_f.bgcolor,{arrayOk:!0}),bordercolor:xf({},_f.bordercolor,{arrayOk:!0}),font:kH({arrayOk:!0,editType:"none"}),align:xf({},_f.align,{arrayOk:!0}),namelength:xf({},_f.namelength,{arrayOk:!0}),showarrow:xf({},_f.showarrow),editType:"none"}}});var ri=X((koe,z_)=>{"use strict";var CH=Ta(),SH=Z0();z_.exports={type:{valType:"enumerated",values:[],dflt:"scatter",editType:"calc+clearAxisTypes",_noTemplating:!0},visible:{valType:"enumerated",values:[!0,!1,"legendonly"],dflt:!0,editType:"calc"},showlegend:{valType:"boolean",dflt:!0,editType:"style"},legend:{valType:"subplotid",dflt:"legend",editType:"style"},legendgroup:{valType:"string",dflt:"",editType:"style"},legendgrouptitle:{text:{valType:"string",dflt:"",editType:"style"},font:CH({editType:"style"}),editType:"style"},legendrank:{valType:"number",dflt:1e3,editType:"style"},legendwidth:{valType:"number",min:0,editType:"style"},opacity:{valType:"number",min:0,max:1,dflt:1,editType:"style"},name:{valType:"string",editType:"style"},uid:{valType:"string",editType:"plot",anim:!0},ids:{valType:"data_array",editType:"calc",anim:!0},customdata:{valType:"data_array",editType:"calc"},meta:{valType:"any",arrayOk:!0,editType:"plot"},selectedpoints:{valType:"any",editType:"calc"},hoverinfo:{valType:"flaglist",flags:["x","y","z","text","name"],extras:["all","none","skip"],arrayOk:!0,dflt:"all",editType:"none"},hoverlabel:SH.hoverlabel,uirevision:{valType:"any",editType:"none"}}});var Ns,N_=Ln(()=>{Ns={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]}});function bl(e,r,t){return Math.min(Math.max(r,e),t)}function J0(e){let r=Math.round(e).toString(16).toUpperCase();return r.length<2?"0"+r:r}var F_,rn,bo,I_=Ln(()=>{N_();F_=Object.create(null);for(let e in Ns)Object.hasOwn(Ns,e)&&(F_[Ns[e]]=e);rn={to:{},get:{}};rn.get=function(e){let r=e.slice(0,3).toLowerCase(),t,a;switch(r){case"hsl":{t=rn.get.hsl(e),a="hsl";break}case"hwb":{t=rn.get.hwb(e),a="hwb";break}default:{t=rn.get.rgb(e),a="rgb";break}}return t?{model:a,value:t}:null};rn.get.rgb=function(e){if(!e)return null;let r=/^#([a-f\d]{3,4})$/i,t=/^#([a-f\d]{6})([a-f\d]{2})?$/i,a=/^rgba?\(\s*([+-]?(?:\d*\.)?\d+(?:e\d+)?)(?=[\s,])\s*(?:,\s*)?([+-]?(?:\d*\.)?\d+(?:e\d+)?)(?=[\s,])\s*(?:,\s*)?([+-]?(?:\d*\.)?\d+(?:e\d+)?)\s*(?:[\s,|/]\s*([+-]?(?:\d*\.)?\d+(?:e\d+)?)(%?)\s*)?\)$/i,n=/^rgba?\(\s*([+-]?[\d.]+)%\s*,?\s*([+-]?[\d.]+)%\s*,?\s*([+-]?[\d.]+)%\s*(?:[\s,|/]\s*([+-]?[\d.]+)(%?)\s*)?\)$/i,i=/^(\w+)$/,l=[0,0,0,1],o,s,u;if(o=e.match(t)){for(u=o[2],o=o[1],s=0;s<3;s++){let f=s*2;l[s]=Number.parseInt(o.slice(f,f+2),16)}u&&(l[3]=Number.parseInt(u,16)/255)}else if(o=e.match(r)){for(o=o[1],u=o[3],s=0;s<3;s++)l[s]=Number.parseInt(o[s]+o[s],16);u&&(l[3]=Number.parseInt(u+u,16)/255)}else if(o=e.match(a)){for(s=0;s<3;s++)l[s]=Number.parseFloat(o[s+1]);o[4]&&(l[3]=o[5]?Number.parseFloat(o[4])*.01:Number.parseFloat(o[4]))}else if(o=e.match(n)){for(s=0;s<3;s++)l[s]=Math.round(Number.parseFloat(o[s+1])*2.55);o[4]&&(l[3]=o[5]?Number.parseFloat(o[4])*.01:Number.parseFloat(o[4]))}else return(o=e.toLowerCase().match(i))?o[1]==="transparent"?[0,0,0,0]:Object.hasOwn(Ns,o[1])?(l=Ns[o[1]].slice(),l[3]=1,l):null:null;for(s=0;s<3;s++)l[s]=bl(l[s],0,255);return l[3]=bl(l[3],0,1),l};rn.get.hsl=function(e){if(!e)return null;let r=/^hsla?\(\s*([+-]?(?:\d{0,3}\.)?\d+)(?:deg)?\s*,?\s*([+-]?[\d.]+)%\s*,?\s*([+-]?[\d.]+)%\s*(?:[,|/]\s*([+-]?(?=\.\d|\d)(?:0|[1-9]\d*)?(?:\.\d*)?(?:e[+-]?\d+)?)\s*)?\)$/i,t=e.match(r);if(t){let a=Number.parseFloat(t[4]),n=(Number.parseFloat(t[1])%360+360)%360,i=bl(Number.parseFloat(t[2]),0,100),l=bl(Number.parseFloat(t[3]),0,100),o=bl(Number.isNaN(a)?1:a,0,1);return[n,i,l,o]}return null};rn.get.hwb=function(e){if(!e)return null;let r=/^hwb\(\s*([+-]?\d{0,3}(?:\.\d+)?)(?:deg)?\s*[\s,]\s*([+-]?[\d.]+)%\s*[\s,]\s*([+-]?[\d.]+)%\s*(?:[\s,]\s*([+-]?(?=\.\d|\d)(?:0|[1-9]\d*)?(?:\.\d*)?(?:e[+-]?\d+)?)\s*)?\)$/i,t=e.match(r);if(t){let a=Number.parseFloat(t[4]),n=(Number.parseFloat(t[1])%360+360)%360,i=bl(Number.parseFloat(t[2]),0,100),l=bl(Number.parseFloat(t[3]),0,100),o=bl(Number.isNaN(a)?1:a,0,1);return[n,i,l,o]}return null};rn.to.hex=function(...e){return"#"+J0(e[0])+J0(e[1])+J0(e[2])+(e[3]<1?J0(Math.round(e[3]*255)):"")};rn.to.rgb=function(...e){return e.length<4||e[3]===1?"rgb("+Math.round(e[0])+", "+Math.round(e[1])+", "+Math.round(e[2])+")":"rgba("+Math.round(e[0])+", "+Math.round(e[1])+", "+Math.round(e[2])+", "+e[3]+")"};rn.to.rgb.percent=function(...e){let r=Math.round(e[0]/255*100),t=Math.round(e[1]/255*100),a=Math.round(e[2]/255*100);return e.length<4||e[3]===1?"rgb("+r+"%, "+t+"%, "+a+"%)":"rgba("+r+"%, "+t+"%, "+a+"%, "+e[3]+")"};rn.to.hsl=function(...e){return e.length<4||e[3]===1?"hsl("+e[0]+", "+e[1]+"%, "+e[2]+"%)":"hsla("+e[0]+", "+e[1]+"%, "+e[2]+"%, "+e[3]+")"};rn.to.hwb=function(...e){let r="";return e.length>=4&&e[3]!==1&&(r=", "+e[3]),"hwb("+e[0]+", "+e[1]+"%, "+e[2]+"%"+r+")"};rn.to.keyword=function(...e){return F_[e.slice(0,3)]};bo=rn});var Fs,H_=Ln(()=>{Fs={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]}});function Is(e){let r=e>.0031308?1.055*e**.4166666666666667-.055:e*12.92;return Math.min(Math.max(0,r),1)}function Hs(e){return e>.04045?((e+.055)/1.055)**2.4:e/12.92}function LH(e,r){return(e[0]-r[0])**2+(e[1]-r[1])**2+(e[2]-r[2])**2}var O_,gr,$i,Ki,Lp=Ln(()=>{H_();O_={};for(let e of Object.keys(Fs))O_[Fs[e]]=e;gr={rgb:{channels:3,labels:"rgb"},hsl:{channels:3,labels:"hsl"},hsv:{channels:3,labels:"hsv"},hwb:{channels:3,labels:"hwb"},cmyk:{channels:4,labels:"cmyk"},xyz:{channels:3,labels:"xyz"},lab:{channels:3,labels:"lab"},oklab:{channels:3,labels:["okl","oka","okb"]},lch:{channels:3,labels:"lch"},oklch:{channels:3,labels:["okl","okc","okh"]},hex:{channels:1,labels:["hex"]},keyword:{channels:1,labels:["keyword"]},ansi16:{channels:1,labels:["ansi16"]},ansi256:{channels:1,labels:["ansi256"]},hcg:{channels:3,labels:["h","c","g"]},apple:{channels:3,labels:["r16","g16","b16"]},gray:{channels:1,labels:["gray"]}},$i=gr,Ki=(6/29)**3;for(let e of Object.keys(gr)){if(!("channels"in gr[e]))throw new Error("missing channels property: "+e);if(!("labels"in gr[e]))throw new Error("missing channel labels property: "+e);if(gr[e].labels.length!==gr[e].channels)throw new Error("channel and label counts mismatch: "+e);let{channels:r,labels:t}=gr[e];delete gr[e].channels,delete gr[e].labels,Object.defineProperty(gr[e],"channels",{value:r}),Object.defineProperty(gr[e],"labels",{value:t})}gr.rgb.hsl=function(e){let r=e[0]/255,t=e[1]/255,a=e[2]/255,n=Math.min(r,t,a),i=Math.max(r,t,a),l=i-n,o,s;switch(i){case n:{o=0;break}case r:{o=(t-a)/l;break}case t:{o=2+(a-r)/l;break}case a:{o=4+(r-t)/l;break}}o=Math.min(o*60,360),o<0&&(o+=360);let u=(n+i)/2;return i===n?s=0:u<=.5?s=l/(i+n):s=l/(2-i-n),[o,s*100,u*100]};gr.rgb.hsv=function(e){let r,t,a,n,i,l=e[0]/255,o=e[1]/255,s=e[2]/255,u=Math.max(l,o,s),f=u-Math.min(l,o,s),v=function(h){return(u-h)/6/f+1/2};if(f===0)n=0,i=0;else{switch(i=f/u,r=v(l),t=v(o),a=v(s),u){case l:{n=a-t;break}case o:{n=1/3+r-a;break}case s:{n=2/3+t-r;break}}n<0?n+=1:n>1&&(n-=1)}return[n*360,i*100,u*100]};gr.rgb.hwb=function(e){let r=e[0],t=e[1],a=e[2],n=gr.rgb.hsl(e)[0],i=1/255*Math.min(r,Math.min(t,a));return a=1-1/255*Math.max(r,Math.max(t,a)),[n,i*100,a*100]};gr.rgb.oklab=function(e){let r=Hs(e[0]/255),t=Hs(e[1]/255),a=Hs(e[2]/255),n=Math.cbrt(.4122214708*r+.5363325363*t+.0514459929*a),i=Math.cbrt(.2119034982*r+.6806995451*t+.1073969566*a),l=Math.cbrt(.0883024619*r+.2817188376*t+.6299787005*a),o=.2104542553*n+.793617785*i-.0040720468*l,s=1.9779984951*n-2.428592205*i+.4505937099*l,u=.0259040371*n+.7827717662*i-.808675766*l;return[o*100,s*100,u*100]};gr.rgb.cmyk=function(e){let r=e[0]/255,t=e[1]/255,a=e[2]/255,n=Math.min(1-r,1-t,1-a),i=(1-r-n)/(1-n)||0,l=(1-t-n)/(1-n)||0,o=(1-a-n)/(1-n)||0;return[i*100,l*100,o*100,n*100]};gr.rgb.keyword=function(e){let r=O_[e];if(r)return r;let t=Number.POSITIVE_INFINITY,a;for(let n of Object.keys(Fs)){let i=Fs[n],l=LH(e,i);lKi?t**(1/3):7.787*t+16/116,a=a>Ki?a**(1/3):7.787*a+16/116,n=n>Ki?n**(1/3):7.787*n+16/116;let i=116*a-16,l=500*(t-a),o=200*(a-n);return[i,l,o]};gr.hsl.rgb=function(e){let r=e[0]/360,t=e[1]/100,a=e[2]/100,n,i;if(t===0)return i=a*255,[i,i,i];let l=a<.5?a*(1+t):a+t-a*t,o=2*a-l,s=[0,0,0];for(let u=0;u<3;u++)n=r+1/3*-(u-1),n<0&&n++,n>1&&n--,6*n<1?i=o+(l-o)*6*n:2*n<1?i=l:3*n<2?i=o+(l-o)*(2/3-n)*6:i=o,s[u]=i*255;return s};gr.hsl.hsv=function(e){let r=e[0],t=e[1]/100,a=e[2]/100,n=t,i=Math.max(a,.01);a*=2,t*=a<=1?a:2-a,n*=i<=1?i:2-i;let l=(a+t)/2,o=a===0?2*n/(i+n):2*t/(a+t);return[r,o*100,l*100]};gr.hsv.rgb=function(e){let r=e[0]/60,t=e[1]/100,a=e[2]/100,n=Math.floor(r)%6,i=r-Math.floor(r),l=255*a*(1-t),o=255*a*(1-t*i),s=255*a*(1-t*(1-i));switch(a*=255,n){case 0:return[a,s,l];case 1:return[o,a,l];case 2:return[l,a,s];case 3:return[l,o,a];case 4:return[s,l,a];case 5:return[a,l,o]}};gr.hsv.hsl=function(e){let r=e[0],t=e[1]/100,a=e[2]/100,n=Math.max(a,.01),i,l;l=(2-t)*a;let o=(2-t)*n;return i=t*n,i/=o<=1?o:2-o,i=i||0,l/=2,[r,i*100,l*100]};gr.hwb.rgb=function(e){let r=e[0]/360,t=e[1]/100,a=e[2]/100,n=t+a,i;n>1&&(t/=n,a/=n);let l=Math.floor(6*r),o=1-a;i=6*r-l,(l&1)!==0&&(i=1-i);let s=t+i*(o-t),u,f,v;switch(l){default:case 6:case 0:{u=o,f=s,v=t;break}case 1:{u=s,f=o,v=t;break}case 2:{u=t,f=o,v=s;break}case 3:{u=t,f=s,v=o;break}case 4:{u=s,f=t,v=o;break}case 5:{u=o,f=t,v=s;break}}return[u*255,f*255,v*255]};gr.cmyk.rgb=function(e){let r=e[0]/100,t=e[1]/100,a=e[2]/100,n=e[3]/100,i=1-Math.min(1,r*(1-n)+n),l=1-Math.min(1,t*(1-n)+n),o=1-Math.min(1,a*(1-n)+n);return[i*255,l*255,o*255]};gr.xyz.rgb=function(e){let r=e[0]/100,t=e[1]/100,a=e[2]/100,n,i,l;return n=r*3.2404542+t*-1.5371385+a*-.4985314,i=r*-.969266+t*1.8760108+a*.041556,l=r*.0556434+t*-.2040259+a*1.0572252,n=Is(n),i=Is(i),l=Is(l),[n*255,i*255,l*255]};gr.xyz.lab=function(e){let r=e[0],t=e[1],a=e[2];r/=95.047,t/=100,a/=108.883,r=r>Ki?r**(1/3):7.787*r+16/116,t=t>Ki?t**(1/3):7.787*t+16/116,a=a>Ki?a**(1/3):7.787*a+16/116;let n=116*t-16,i=500*(r-t),l=200*(t-a);return[n,i,l]};gr.xyz.oklab=function(e){let r=e[0]/100,t=e[1]/100,a=e[2]/100,n=Math.cbrt(.8189330101*r+.3618667424*t-.1288597137*a),i=Math.cbrt(.0329845436*r+.9293118715*t+.0361456387*a),l=Math.cbrt(.0482003018*r+.2643662691*t+.633851707*a),o=.2104542553*n+.793617785*i-.0040720468*l,s=1.9779984951*n-2.428592205*i+.4505937099*l,u=.0259040371*n+.7827717662*i-.808675766*l;return[o*100,s*100,u*100]};gr.oklab.oklch=function(e){return gr.lab.lch(e)};gr.oklab.xyz=function(e){let r=e[0]/100,t=e[1]/100,a=e[2]/100,n=(.999999998*r+.396337792*t+.215803758*a)**3,i=(1.000000008*r-.105561342*t-.063854175*a)**3,l=(1.000000055*r-.089484182*t-1.291485538*a)**3,o=1.227013851*n-.55779998*i+.281256149*l,s=-.040580178*n+1.11225687*i-.071676679*l,u=-.076381285*n-.421481978*i+1.58616322*l;return[o*100,s*100,u*100]};gr.oklab.rgb=function(e){let r=e[0]/100,t=e[1]/100,a=e[2]/100,n=(r+.3963377774*t+.2158037573*a)**3,i=(r-.1055613458*t-.0638541728*a)**3,l=(r-.0894841775*t-1.291485548*a)**3,o=Is(4.0767416621*n-3.3077115913*i+.2309699292*l),s=Is(-1.2684380046*n+2.6097574011*i-.3413193965*l),u=Is(-.0041960863*n-.7034186147*i+1.707614701*l);return[o*255,s*255,u*255]};gr.oklch.oklab=function(e){return gr.lch.lab(e)};gr.lab.xyz=function(e){let r=e[0],t=e[1],a=e[2],n,i,l;i=(r+16)/116,n=t/500+i,l=i-a/200;let o=i**3,s=n**3,u=l**3;return i=o>Ki?o:(i-16/116)/7.787,n=s>Ki?s:(n-16/116)/7.787,l=u>Ki?u:(l-16/116)/7.787,n*=95.047,i*=100,l*=108.883,[n,i,l]};gr.lab.lch=function(e){let r=e[0],t=e[1],a=e[2],n;n=Math.atan2(a,t)*360/2/Math.PI,n<0&&(n+=360);let l=Math.sqrt(t*t+a*a);return[r,l,n]};gr.lch.lab=function(e){let r=e[0],t=e[1],n=e[2]/360*2*Math.PI,i=t*Math.cos(n),l=t*Math.sin(n);return[r,i,l]};gr.rgb.ansi16=function(e,r=null){let[t,a,n]=e,i=r===null?gr.rgb.hsv(e)[2]:r;if(i=Math.round(i/50),i===0)return 30;let l=30+(Math.round(n/255)<<2|Math.round(a/255)<<1|Math.round(t/255));return i===2&&(l+=60),l};gr.hsv.ansi16=function(e){return gr.rgb.ansi16(gr.hsv.rgb(e),e[2])};gr.rgb.ansi256=function(e){let r=e[0],t=e[1],a=e[2];return r>>4===t>>4&&t>>4===a>>4?r<8?16:r>248?231:Math.round((r-8)/247*24)+232:16+36*Math.round(r/255*5)+6*Math.round(t/255*5)+Math.round(a/255*5)};gr.ansi16.rgb=function(e){e=e[0];let r=e%10;if(r===0||r===7)return e>50&&(r+=3.5),r=r/10.5*255,[r,r,r];let t=(Math.trunc(e>50)+1)*.5,a=(r&1)*t*255,n=(r>>1&1)*t*255,i=(r>>2&1)*t*255;return[a,n,i]};gr.ansi256.rgb=function(e){if(e=e[0],e>=232){let i=(e-232)*10+8;return[i,i,i]}e-=16;let r,t=Math.floor(e/36)/5*255,a=Math.floor((r=e%36)/6)/5*255,n=r%6/5*255;return[t,a,n]};gr.rgb.hex=function(e){let t=(((Math.round(e[0])&255)<<16)+((Math.round(e[1])&255)<<8)+(Math.round(e[2])&255)).toString(16).toUpperCase();return"000000".slice(t.length)+t};gr.hex.rgb=function(e){let r=e.toString(16).match(/[a-f\d]{6}|[a-f\d]{3}/i);if(!r)return[0,0,0];let t=r[0];r[0].length===3&&(t=[...t].map(o=>o+o).join(""));let a=Number.parseInt(t,16),n=a>>16&255,i=a>>8&255,l=a&255;return[n,i,l]};gr.rgb.hcg=function(e){let r=e[0]/255,t=e[1]/255,a=e[2]/255,n=Math.max(Math.max(r,t),a),i=Math.min(Math.min(r,t),a),l=n-i,o,s=l<1?i/(1-l):0;return l<=0?o=0:n===r?o=(t-a)/l%6:n===t?o=2+(a-r)/l:o=4+(r-t)/l,o/=6,o%=1,[o*360,l*100,s*100]};gr.hsl.hcg=function(e){let r=e[1]/100,t=e[2]/100,a=t<.5?2*r*t:2*r*(1-t),n=0;return a<1&&(n=(t-.5*a)/(1-a)),[e[0],a*100,n*100]};gr.hsv.hcg=function(e){let r=e[1]/100,t=e[2]/100,a=r*t,n=0;return a<1&&(n=(t-a)/(1-a)),[e[0],a*100,n*100]};gr.hcg.rgb=function(e){let r=e[0]/360,t=e[1]/100,a=e[2]/100;if(t===0)return[a*255,a*255,a*255];let n=[0,0,0],i=r%1*6,l=i%1,o=1-l,s=0;switch(Math.floor(i)){case 0:{n[0]=1,n[1]=l,n[2]=0;break}case 1:{n[0]=o,n[1]=1,n[2]=0;break}case 2:{n[0]=0,n[1]=1,n[2]=l;break}case 3:{n[0]=0,n[1]=o,n[2]=1;break}case 4:{n[0]=l,n[1]=0,n[2]=1;break}default:n[0]=1,n[1]=0,n[2]=o}return s=(1-t)*a,[(t*n[0]+s)*255,(t*n[1]+s)*255,(t*n[2]+s)*255]};gr.hcg.hsv=function(e){let r=e[1]/100,t=e[2]/100,a=r+t*(1-r),n=0;return a>0&&(n=r/a),[e[0],n*100,a*100]};gr.hcg.hsl=function(e){let r=e[1]/100,a=e[2]/100*(1-r)+.5*r,n=0;return a>0&&a<.5?n=r/(2*a):a>=.5&&a<1&&(n=r/(2*(1-a))),[e[0],n*100,a*100]};gr.hcg.hwb=function(e){let r=e[1]/100,t=e[2]/100,a=r+t*(1-r);return[e[0],(a-r)*100,(1-a)*100]};gr.hwb.hcg=function(e){let r=e[1]/100,a=1-e[2]/100,n=a-r,i=0;return n<1&&(i=(a-n)/(1-n)),[e[0],n*100,i*100]};gr.apple.rgb=function(e){return[e[0]/65535*255,e[1]/65535*255,e[2]/65535*255]};gr.rgb.apple=function(e){return[e[0]/255*65535,e[1]/255*65535,e[2]/255*65535]};gr.gray.rgb=function(e){return[e[0]/100*255,e[0]/100*255,e[0]/100*255]};gr.gray.hsl=function(e){return[0,0,e[0]]};gr.gray.hsv=gr.gray.hsl;gr.gray.hwb=function(e){return[0,100,e[0]]};gr.gray.cmyk=function(e){return[0,0,0,e[0]]};gr.gray.lab=function(e){return[e[0],0,0]};gr.gray.hex=function(e){let r=Math.round(e[0]/100*255)&255,a=((r<<16)+(r<<8)+r).toString(16).toUpperCase();return"000000".slice(a.length)+a};gr.rgb.gray=function(e){return[(e[0]+e[1]+e[2])/3/255*100]}});function qH(){let e={},r=Object.keys($i);for(let{length:t}=r,a=0;a0;){let a=t.pop(),n=Object.keys($i[a]);for(let{length:i}=n,l=0;l{Lp();B_=RH});function NH(e){let r=function(...t){let a=t[0];return a==null?a:(a.length>1&&(t=a),e(t))};return"conversion"in e&&(r.conversion=e.conversion),r}function FH(e){let r=function(...t){let a=t[0];if(a==null)return a;a.length>1&&(t=a);let n=e(t);if(typeof n=="object")for(let{length:i}=n,l=0;l{Lp();Y_();Os={},zH=Object.keys($i);for(let e of zH){Os[e]={},Object.defineProperty(Os[e],"channels",{value:$i[e].channels}),Object.defineProperty(Os[e],"labels",{value:$i[e].labels});let r=B_(e),t=Object.keys(r);for(let a of t){let n=r[a];Os[e][a]=FH(n),Os[e][a].raw=NH(n)}}tn=Os});var G_={};qs(G_,{default:()=>BH});function Ma(e,r){if(!(this instanceof Ma))return new Ma(e,r);if(r&&r in V_&&(r=null),r&&!(r in tn))throw new Error("Unknown model: "+r);let t,a;if(e==null)this.model="rgb",this.color=[0,0,0],this.valpha=1;else if(e instanceof Ma)this.model=e.model,this.color=[...e.color],this.valpha=e.valpha;else if(typeof e=="string"){let n=bo.get(e);if(n===null)throw new Error("Unable to parse color from string: "+e);this.model=n.model,a=tn[this.model].channels,this.color=n.value.slice(0,a),this.valpha=typeof n.value[a]=="number"?n.value[a]:1}else if(e.length>0){this.model=r||"rgb",a=tn[this.model].channels;let n=Array.prototype.slice.call(e,0,a);this.color=Dp(n,a),this.valpha=typeof e[a]=="number"?e[a]:1}else if(typeof e=="number")this.model="rgb",this.color=[e>>16&255,e>>8&255,e&255],this.valpha=1;else{this.valpha=1;let n=Object.keys(e);"alpha"in e&&(n.splice(n.indexOf("alpha"),1),this.valpha=typeof e.alpha=="number"?e.alpha:0);let i=n.sort().join("");if(!(i in qp))throw new Error("Unable to parse color from object: "+JSON.stringify(e));this.model=qp[i];let{labels:l}=tn[this.model],o=[];for(t=0;t{I_();U_();V_=["keyword","gray","hex"],qp={};for(let e of Object.keys(tn))qp[[...tn[e].labels].sort().join("")]=e;wf={};Ma.prototype={toString(){return this.string()},toJSON(){return this[this.model]()},string(e){let r=this.model in bo.to?this:this.rgb();r=r.round(typeof e=="number"?e:1);let t=r.valpha===1?r.color:[...r.color,this.valpha];return bo.to[r.model](...t)},percentString(e){let r=this.rgb().round(typeof e=="number"?e:1),t=r.valpha===1?r.color:[...r.color,this.valpha];return bo.to.rgb.percent(...t)},array(){return this.valpha===1?[...this.color]:[...this.color,this.valpha]},object(){let e={},{channels:r}=tn[this.model],{labels:t}=tn[this.model];for(let a=0;a(e%360+360)%360),saturationl:Yt("hsl",1,ha(100)),lightness:Yt("hsl",2,ha(100)),saturationv:Yt("hsv",1,ha(100)),value:Yt("hsv",2,ha(100)),chroma:Yt("hcg",1,ha(100)),gray:Yt("hcg",2,ha(100)),white:Yt("hwb",1,ha(100)),wblack:Yt("hwb",2,ha(100)),cyan:Yt("cmyk",0,ha(100)),magenta:Yt("cmyk",1,ha(100)),yellow:Yt("cmyk",2,ha(100)),black:Yt("cmyk",3,ha(100)),x:Yt("xyz",0,ha(95.047)),y:Yt("xyz",1,ha(100)),z:Yt("xyz",2,ha(108.833)),l:Yt("lab",0,ha(100)),a:Yt("lab",1),b:Yt("lab",2),keyword(e){return e!==void 0?new Ma(e):tn[this.model].keyword(this.color)},hex(e){return e!==void 0?new Ma(e):bo.to.hex(...this.rgb().round().color)},hexa(e){if(e!==void 0)return new Ma(e);let r=this.rgb().round().color,t=Math.round(this.valpha*255).toString(16).toUpperCase();return t.length===1&&(t="0"+t),bo.to.hex(...r)+t},rgbNumber(){let e=this.rgb().color;return(e[0]&255)<<16|(e[1]&255)<<8|e[2]&255},luminosity(){let e=this.rgb().color,r=[];for(let[t,a]of e.entries()){let n=a/255;r[t]=n<=.04045?n/12.92:((n+.055)/1.055)**2.4}return .2126*r[0]+.7152*r[1]+.0722*r[2]},contrast(e){let r=this.luminosity(),t=e.luminosity();return r>t?(r+.05)/(t+.05):(t+.05)/(r+.05)},level(e){let r=this.contrast(e);return r>=7?"AAA":r>=4.5?"AA":""},isDark(){let e=this.rgb().color;return(e[0]*2126+e[1]*7152+e[2]*722)/1e4<128},isLight(){return!this.isDark()},negate(){let e=this.rgb();for(let r=0;r<3;r++)e.color[r]=255-e.color[r];return e},lighten(e){let r=this.hsl();return r.color[2]+=r.color[2]*e,r},darken(e){let r=this.hsl();return r.color[2]-=r.color[2]*e,r},saturate(e){let r=this.hsl();return r.color[1]+=r.color[1]*e,r},desaturate(e){let r=this.hsl();return r.color[1]-=r.color[1]*e,r},whiten(e){let r=this.hwb();return r.color[1]+=r.color[1]*e,r},blacken(e){let r=this.hwb();return r.color[2]+=r.color[2]*e,r},grayscale(){let e=this.rgb().color,r=e[0]*.3+e[1]*.59+e[2]*.11;return Ma.rgb(r,r,r)},fade(e){return this.alpha(this.valpha-this.valpha*e)},opaquer(e){return this.alpha(this.valpha+this.valpha*e)},rotate(e){let r=this.hsl(),t=r.color[0];return t=(t+e)%360,t=t<0?360+t:t,r.color[0]=t,r},mix(e,r){if(!e||!e.rgb)throw new Error('Argument to "mix" was not a Color instance, but rather an instance of '+typeof e);let t=e.rgb(),a=this.rgb(),n=r===void 0?.5:r,i=2*n-1,l=t.alpha()-a.alpha(),o=((i*l===-1?i:(i+l)/(1+i*l))+1)/2,s=1-o;return Ma.rgb(o*t.red()+s*a.red(),o*t.green()+s*a.green(),o*t.blue()+s*a.blue(),t.alpha()*n+a.alpha()*(1-n))}};for(let e of Object.keys(tn)){if(V_.includes(e))continue;let{channels:r}=tn[e];Ma.prototype[e]=function(...t){return this.model===e?new Ma(this):t.length>0?new Ma(t,e):new Ma([...OH(tn[this.model][e].raw(this.color)),this.valpha],e)},Ma[e]=function(...t){let a=t[0];return typeof a=="number"&&(a=Dp(t,r)),new Ma(a,e)}}BH=Ma});var _o=X((Ooe,K_)=>{"use strict";var X_={staticPlot:{valType:"boolean",dflt:!1},typesetMath:{valType:"boolean",dflt:!0},plotlyServerURL:{valType:"string",dflt:"https://cloud.plotly.com/newchart"},editable:{valType:"boolean",dflt:!1},edits:{annotationPosition:{valType:"boolean",dflt:!1},annotationTail:{valType:"boolean",dflt:!1},annotationText:{valType:"boolean",dflt:!1},axisTitleText:{valType:"boolean",dflt:!1},colorbarPosition:{valType:"boolean",dflt:!1},colorbarTitleText:{valType:"boolean",dflt:!1},legendPosition:{valType:"boolean",dflt:!1},legendText:{valType:"boolean",dflt:!1},shapePosition:{valType:"boolean",dflt:!1},titleText:{valType:"boolean",dflt:!1}},editSelection:{valType:"boolean",dflt:!0},autosizable:{valType:"boolean",dflt:!1},responsive:{valType:"boolean",dflt:!1},fillFrame:{valType:"boolean",dflt:!1},frameMargins:{valType:"number",dflt:0,min:0,max:.5},scrollZoom:{valType:"flaglist",flags:["cartesian","gl3d","geo","map"],extras:[!0,!1],dflt:"gl3d+geo+map"},doubleClick:{valType:"enumerated",values:[!1,"reset","autosize","reset+autosize"],dflt:"reset+autosize"},doubleClickDelay:{valType:"number",dflt:300,min:0},showAxisDragHandles:{valType:"boolean",dflt:!0},showAxisRangeEntryBoxes:{valType:"boolean",dflt:!0},showTips:{valType:"boolean",dflt:!0},displayNotifier:{valType:"boolean",dflt:!0},displayModeBar:{valType:"enumerated",values:["hover",!0,!1],dflt:"hover"},showSendToCloud:{valType:"boolean",dflt:!0},modeBarButtonsToRemove:{valType:"any",dflt:[]},modeBarButtonsToAdd:{valType:"any",dflt:[]},modeBarButtons:{valType:"any",dflt:!1},toImageButtonOptions:{valType:"any",dflt:{}},displaylogo:{valType:"boolean",dflt:!0},watermark:{valType:"boolean",dflt:!1},plotGlPixelRatio:{valType:"number",dflt:2,min:1,max:4},setBackground:{valType:"any",dflt:"transparent"},topojsonURL:{valType:"string",noBlank:!0,dflt:"https://cdn.plot.ly/un/"},logging:{valType:"integer",min:0,max:2,dflt:1},notifyOnLogging:{valType:"integer",min:0,max:2,dflt:0},queueLength:{valType:"integer",min:0,dflt:0},locale:{valType:"string",dflt:"en-US"},locales:{valType:"any",dflt:{}}},Z_={};function J_(e,r){for(var t in e){var a=e[t];a.valType?r[t]=a.dflt:(r[t]||(r[t]={}),J_(a,r[t]))}}J_(X_,Z_);K_.exports={configAttributes:X_,dfltConfig:Z_}});var Pp=X((Boe,$_)=>{"use strict";var Ep=Nr(),YH=Br(),Tf=[];$_.exports=function(e,r,t){var o;if(((o=t==null?void 0:t._context)==null?void 0:o.displayNotifier)===!1||Tf.indexOf(e)!==-1)return;Tf.push(e);var a=1e3;YH(r)?a=r:r==="long"&&(a=3e3);var n=Ep.select("body").selectAll(".plotly-notifier").data([0]);n.enter().append("div").classed("plotly-notifier",!0);var i=n.selectAll(".notifier-note").data(Tf);function l(s){s.duration(700).style("opacity",0).each("end",function(u){var f=Tf.indexOf(u);f!==-1&&Tf.splice(f,1),Ep.select(this).remove()})}i.enter().append("div").classed("notifier-note",!0).style("opacity",0).each(function(s){var u=Ep.select(this);u.append("button").classed("notifier-close",!0).html("×").on("click",function(){u.transition().call(l)});for(var f=u.append("p"),v=s.split(//g),h=0;h{"use strict";var Bs=_o().dfltConfig,Rp=Pp(),zp=Q_.exports={};zp.log=function(){var e;if(Bs.logging>1){var r=["LOG:"];for(e=0;e1){var t=[];for(e=0;e"),"long")}};zp.warn=function(){var e;if(Bs.logging>0){var r=["WARN:"];for(e=0;e0){var t=[];for(e=0;e"),"stick")}};zp.error=function(){var e;if(Bs.logging>0){var r=["ERROR:"];for(e=0;e0){var t=[];for(e=0;e"),"stick")}}});var ki=X(xo=>{"use strict";xo.defaults=["#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd","#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf"];xo.defaultLine="#444";xo.lightLine="#eee";xo.background="#fff";xo.borderLine="#BEC8D9";xo.lightFraction=1e3/11});var Er=X((Voe,rx)=>{"use strict";var Np=(W_(),Ia(G_)).default,{warn:UH}=_l(),{background:K0,defaultLine:j_,defaults:VH,lightLine:GH}=ki(),_t=e=>{typeof e=="string"&&(e=e.trim());try{return Np(e)}catch(r){return UH(`Invalid color specifier: "${e}". Defaulting to "#000"`),Np("#000")}},Ip=e=>{let{r,g:t,b:a}=_t(e).rgb().object();return`rgb(${Math.round(r)}, ${Math.round(t)}, ${Math.round(a)})`},WH=e=>e?_t(e).alpha():0,XH=(e,r)=>{let t=_t(e).rgb().object();return`rgba(${Math.round(t.r)}, ${Math.round(t.g)}, ${Math.round(t.b)}, ${r})`},ex=(e,r)=>{var l,o;r||(r=K0);let t=_t(e).rgb().object();if((l=t.alpha)!=null||(t.alpha=1),t.alpha===1)return _t(e).rgb().string();let a=_t(r).rgb().object();(o=a.alpha)!=null||(a.alpha=1);let n=a.alpha===1?a:{r:255*(1-a.alpha)+a.r*a.alpha,g:255*(1-a.alpha)+a.g*a.alpha,b:255*(1-a.alpha)+a.b*a.alpha},i={r:n.r*(1-t.alpha)+t.r*t.alpha,g:n.g*(1-t.alpha)+t.g*t.alpha,b:n.b*(1-t.alpha)+t.b*t.alpha};return _t(i).rgb().string()},ZH=(e,r,t)=>{let a=_t(e).rgb().object(),n=_t(r).rgb().object(),i={r:t*a.r+(1-t)*n.r,g:t*a.g+(1-t)*n.g,b:t*a.b+(1-t)*n.b};return _t(i).rgb().string()},Fp=(e,r)=>{let t=_t(e);return t.lightness(t.lightness()+r)},JH=(e,r,t)=>{let a=_t(e);return a.alpha()!==1&&(a=_t(ex(e,K0))),(a.isDark()?r?Fp(a,r):_t(K0):t?Fp(a,-t):_t(j_)).rgb().string()},KH=(e,r)=>{let t=_t(r);e.style({stroke:Ip(r),"stroke-opacity":t.alpha()})},$H=(e,r)=>{let t=_t(r);e.style({fill:Ip(r),"fill-opacity":t.alpha()})},QH=(e,r)=>!!(e&&r&&_t(e).rgb().string()===_t(r).rgb().string()),jH=e=>{if(typeof e!="string")return!1;try{return!!Np(e.trim())}catch(r){return!1}},eO=(e,r)=>{var n;r=r===0?0:r||10;let t=_t(e).rgb().object(),a=Math.round(255*(r/100));return _t({r:Math.max(0,Math.min(255,t.r+a)),g:Math.max(0,Math.min(255,t.g+a)),b:Math.max(0,Math.min(255,t.b+a))}).alpha((n=t.alpha)!=null?n:1).rgb().string()},rO=(e,r,t)=>_t(e).mix(_t(r),t/100).rgb().string(),tO=(e,r=["#000","#fff"])=>{let t,a=-1/0;for(let n of r){let i=_t(e).contrast(_t(n));i>a&&(a=i,t=_t(n).rgb().string())}return t};rx.exports={addOpacity:XH,adjustLightness:Fp,background:K0,brighten:eO,color:_t,combine:ex,contrast:JH,defaultLine:j_,defaults:VH,equals:QH,fill:$H,interpolate:ZH,isValid:jH,lightLine:GH,mix:rO,mostReadable:tO,opacity:WH,rgb:Ip,stroke:KH}});var wo=X((Goe,nx)=>{"use strict";var aO=Er(),$0={Greys:[[0,"rgb(0,0,0)"],[1,"rgb(255,255,255)"]],YlGnBu:[[0,"rgb(8,29,88)"],[.125,"rgb(37,52,148)"],[.25,"rgb(34,94,168)"],[.375,"rgb(29,145,192)"],[.5,"rgb(65,182,196)"],[.625,"rgb(127,205,187)"],[.75,"rgb(199,233,180)"],[.875,"rgb(237,248,217)"],[1,"rgb(255,255,217)"]],Greens:[[0,"rgb(0,68,27)"],[.125,"rgb(0,109,44)"],[.25,"rgb(35,139,69)"],[.375,"rgb(65,171,93)"],[.5,"rgb(116,196,118)"],[.625,"rgb(161,217,155)"],[.75,"rgb(199,233,192)"],[.875,"rgb(229,245,224)"],[1,"rgb(247,252,245)"]],YlOrRd:[[0,"rgb(128,0,38)"],[.125,"rgb(189,0,38)"],[.25,"rgb(227,26,28)"],[.375,"rgb(252,78,42)"],[.5,"rgb(253,141,60)"],[.625,"rgb(254,178,76)"],[.75,"rgb(254,217,118)"],[.875,"rgb(255,237,160)"],[1,"rgb(255,255,204)"]],Bluered:[[0,"rgb(0,0,255)"],[1,"rgb(255,0,0)"]],RdBu:[[0,"rgb(5,10,172)"],[.35,"rgb(106,137,247)"],[.5,"rgb(190,190,190)"],[.6,"rgb(220,170,132)"],[.7,"rgb(230,145,90)"],[1,"rgb(178,10,28)"]],Reds:[[0,"rgb(220,220,220)"],[.2,"rgb(245,195,157)"],[.4,"rgb(245,160,105)"],[1,"rgb(178,10,28)"]],Blues:[[0,"rgb(5,10,172)"],[.35,"rgb(40,60,190)"],[.5,"rgb(70,100,245)"],[.6,"rgb(90,120,245)"],[.7,"rgb(106,137,247)"],[1,"rgb(220,220,220)"]],Picnic:[[0,"rgb(0,0,255)"],[.1,"rgb(51,153,255)"],[.2,"rgb(102,204,255)"],[.3,"rgb(153,204,255)"],[.4,"rgb(204,204,255)"],[.5,"rgb(255,255,255)"],[.6,"rgb(255,204,255)"],[.7,"rgb(255,153,255)"],[.8,"rgb(255,102,204)"],[.9,"rgb(255,102,102)"],[1,"rgb(255,0,0)"]],Rainbow:[[0,"rgb(150,0,90)"],[.125,"rgb(0,0,200)"],[.25,"rgb(0,25,255)"],[.375,"rgb(0,152,255)"],[.5,"rgb(44,255,150)"],[.625,"rgb(151,255,0)"],[.75,"rgb(255,234,0)"],[.875,"rgb(255,111,0)"],[1,"rgb(255,0,0)"]],Portland:[[0,"rgb(12,51,131)"],[.25,"rgb(10,136,186)"],[.5,"rgb(242,211,56)"],[.75,"rgb(242,143,56)"],[1,"rgb(217,30,30)"]],Jet:[[0,"rgb(0,0,131)"],[.125,"rgb(0,60,170)"],[.375,"rgb(5,255,255)"],[.625,"rgb(255,255,0)"],[.875,"rgb(250,0,0)"],[1,"rgb(128,0,0)"]],Hot:[[0,"rgb(0,0,0)"],[.3,"rgb(230,0,0)"],[.6,"rgb(255,210,0)"],[1,"rgb(255,255,255)"]],Blackbody:[[0,"rgb(0,0,0)"],[.2,"rgb(230,0,0)"],[.4,"rgb(230,210,0)"],[.7,"rgb(255,255,255)"],[1,"rgb(160,200,255)"]],Earth:[[0,"rgb(0,0,130)"],[.1,"rgb(0,180,180)"],[.2,"rgb(40,210,40)"],[.4,"rgb(230,230,50)"],[.6,"rgb(120,70,20)"],[1,"rgb(255,255,255)"]],Electric:[[0,"rgb(0,0,0)"],[.15,"rgb(30,0,100)"],[.4,"rgb(120,0,100)"],[.6,"rgb(160,90,0)"],[.8,"rgb(230,200,0)"],[1,"rgb(255,250,220)"]],Viridis:[[0,"#440154"],[.06274509803921569,"#48186a"],[.12549019607843137,"#472d7b"],[.18823529411764706,"#424086"],[.25098039215686274,"#3b528b"],[.3137254901960784,"#33638d"],[.3764705882352941,"#2c728e"],[.4392156862745098,"#26828e"],[.5019607843137255,"#21918c"],[.5647058823529412,"#1fa088"],[.6274509803921569,"#28ae80"],[.6901960784313725,"#3fbc73"],[.7529411764705882,"#5ec962"],[.8156862745098039,"#84d44b"],[.8784313725490196,"#addc30"],[.9411764705882353,"#d8e219"],[1,"#fde725"]],Cividis:[[0,"rgb(0,32,76)"],[.058824,"rgb(0,42,102)"],[.117647,"rgb(0,52,110)"],[.176471,"rgb(39,63,108)"],[.235294,"rgb(60,74,107)"],[.294118,"rgb(76,85,107)"],[.352941,"rgb(91,95,109)"],[.411765,"rgb(104,106,112)"],[.470588,"rgb(117,117,117)"],[.529412,"rgb(131,129,120)"],[.588235,"rgb(146,140,120)"],[.647059,"rgb(161,152,118)"],[.705882,"rgb(176,165,114)"],[.764706,"rgb(192,177,109)"],[.823529,"rgb(209,191,102)"],[.882353,"rgb(225,204,92)"],[.941176,"rgb(243,219,79)"],[1,"rgb(255,233,69)"]]},tx=$0.RdBu;function nO(e,r){if(r||(r=tx),!e)return r;function t(){try{e=$0[e]||JSON.parse(e)}catch(a){e=r}}return typeof e=="string"&&(t(),typeof e=="string"&&t()),ax(e)?e:r}function ax(e){var r=0;if(!Array.isArray(e)||e.length<2||!e[0]||!e[e.length-1]||+e[0][0]!=0||+e[e.length-1][0]!=1)return!1;for(var t=0;t{"use strict";ix.exports={SHOW_PLACEHOLDER:100,HIDE_PLACEHOLDER:1e3,DESELECTDIM:.2}});var Ys={};qs(Ys,{counter:()=>lO});function lO(e,r="",t,a){let n=r+(t?"":"$"),i=a===!1?"":"^";return e==="xy"?new RegExp(i+"x"+Hp+"y"+Hp+n):new RegExp(i+e+Hp+n)}var Hp,Us=Ln(()=>{"use strict";Hp="([2-9]|[1-9][0-9]+)?"});var sx=X(an=>{"use strict";var Op=Br(),lx=Jt().extendFlat,oO=ri(),sO=wo(),Bp=Er(),uO=Q0().DESELECTDIM,Vs=G0(),ox=(Us(),Ia(Ys)).counter,fO=(Rs(),Ia(Ps)).modHalf,ti=hn().isArrayOrTypedArray,xl=hn().isTypedArraySpec,wl=hn().decodeTypedArraySpec;an.valObjectMeta={data_array:{coerceFunction:function(e,r,t){r.set(ti(e)?e:xl(e)?wl(e):t)}},enumerated:{coerceFunction:function(e,r,t,a){a.coerceNumber&&(e=+e),a.values.indexOf(e)===-1?r.set(t):r.set(e)},validateFunction:function(e,r){r.coerceNumber&&(e=+e);for(var t=r.values,a=0;ai===!0||i===!1;n(e)||a.arrayOk&&Array.isArray(e)&&e.length>0&&e.every(n)?r.set(e):r.set(t)}},number:{coerceFunction:function(e,r,t,a){xl(e)&&(e=wl(e)),!Op(e)||a.min!==void 0&&ea.max?r.set(t):r.set(+e)}},integer:{coerceFunction:function(e,r,t,a){if((a.extras||[]).indexOf(e)!==-1){r.set(e);return}xl(e)&&(e=wl(e)),e%1||!Op(e)||a.min!==void 0&&ea.max?r.set(t):r.set(+e)}},string:{coerceFunction:function(e,r,t,a){if(typeof e!="string"){var n=typeof e=="number";a.strict===!0||!n?r.set(t):r.set(String(e))}else a.noBlank&&!e?r.set(t):r.set(e)}},color:{coerceFunction:function(e,r,t){xl(e)&&(e=wl(e)),Bp.isValid(e)?r.set(e):r.set(t)}},colorlist:{coerceFunction:function(e,r,t){!Array.isArray(e)||!e.length?r.set(t):e.every(a=>Bp.isValid(a))?r.set(e):r.set(t)}},colorscale:{coerceFunction:function(e,r,t){r.set(sO.get(e,t))}},angle:{coerceFunction:function(e,r,t){xl(e)&&(e=wl(e)),e==="auto"?r.set("auto"):Op(e)?r.set(fO(+e,360)):r.set(t)}},subplotid:{coerceFunction:function(e,r,t,a){var n=a.regex||ox(t);let i=l=>typeof l=="string"&&n.test(l);i(e)||a.arrayOk&&ti(e)&&e.length>0&&e.every(i)?r.set(e):r.set(t)},validateFunction:function(e,r){var t=r.dflt;return e===t?!0:typeof e!="string"?!1:!!ox(t).test(e)}},flaglist:{coerceFunction:function(e,r,t,a){if((a.extras||[]).indexOf(e)!==-1){r.set(e);return}if(typeof e!="string"){r.set(t);return}for(var n=e.split("+"),i=0;i{"use strict";ux.exports=function(){}});var Yp=X((Joe,fx)=>{"use strict";fx.exports=function(r,t){if(t instanceof RegExp){for(var a=t.toString(),n=0;n{cx.exports=cO;function cO(){var e=new Float32Array(16);return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var dx=X(($oe,hx)=>{hx.exports=vO;function vO(e){var r=new Float32Array(16);return r[0]=e[0],r[1]=e[1],r[2]=e[2],r[3]=e[3],r[4]=e[4],r[5]=e[5],r[6]=e[6],r[7]=e[7],r[8]=e[8],r[9]=e[9],r[10]=e[10],r[11]=e[11],r[12]=e[12],r[13]=e[13],r[14]=e[14],r[15]=e[15],r}});var mx=X((Qoe,px)=>{px.exports=hO;function hO(e,r){return e[0]=r[0],e[1]=r[1],e[2]=r[2],e[3]=r[3],e[4]=r[4],e[5]=r[5],e[6]=r[6],e[7]=r[7],e[8]=r[8],e[9]=r[9],e[10]=r[10],e[11]=r[11],e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15],e}});var Up=X((joe,yx)=>{yx.exports=dO;function dO(e){return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var bx=X((ese,gx)=>{gx.exports=pO;function pO(e,r){if(e===r){var t=r[1],a=r[2],n=r[3],i=r[6],l=r[7],o=r[11];e[1]=r[4],e[2]=r[8],e[3]=r[12],e[4]=t,e[6]=r[9],e[7]=r[13],e[8]=a,e[9]=i,e[11]=r[14],e[12]=n,e[13]=l,e[14]=o}else e[0]=r[0],e[1]=r[4],e[2]=r[8],e[3]=r[12],e[4]=r[1],e[5]=r[5],e[6]=r[9],e[7]=r[13],e[8]=r[2],e[9]=r[6],e[10]=r[10],e[11]=r[14],e[12]=r[3],e[13]=r[7],e[14]=r[11],e[15]=r[15];return e}});var xx=X((rse,_x)=>{_x.exports=mO;function mO(e,r){var t=r[0],a=r[1],n=r[2],i=r[3],l=r[4],o=r[5],s=r[6],u=r[7],f=r[8],v=r[9],h=r[10],d=r[11],m=r[12],y=r[13],g=r[14],x=r[15],T=t*o-a*l,M=t*s-n*l,b=t*u-i*l,w=a*s-n*o,S=a*u-i*o,k=n*u-i*s,q=f*y-v*m,D=f*g-h*m,P=f*x-d*m,z=v*g-h*y,F=v*x-d*y,I=h*x-d*g,H=T*I-M*F+b*z+w*P-S*D+k*q;return H?(H=1/H,e[0]=(o*I-s*F+u*z)*H,e[1]=(n*F-a*I-i*z)*H,e[2]=(y*k-g*S+x*w)*H,e[3]=(h*S-v*k-d*w)*H,e[4]=(s*P-l*I-u*D)*H,e[5]=(t*I-n*P+i*D)*H,e[6]=(g*b-m*k-x*M)*H,e[7]=(f*k-h*b+d*M)*H,e[8]=(l*F-o*P+u*q)*H,e[9]=(a*P-t*F-i*q)*H,e[10]=(m*S-y*b+x*T)*H,e[11]=(v*b-f*S-d*T)*H,e[12]=(o*D-l*z-s*q)*H,e[13]=(t*z-a*D+n*q)*H,e[14]=(y*M-m*w-g*T)*H,e[15]=(f*w-v*M+h*T)*H,e):null}});var Tx=X((tse,wx)=>{wx.exports=yO;function yO(e,r){var t=r[0],a=r[1],n=r[2],i=r[3],l=r[4],o=r[5],s=r[6],u=r[7],f=r[8],v=r[9],h=r[10],d=r[11],m=r[12],y=r[13],g=r[14],x=r[15];return e[0]=o*(h*x-d*g)-v*(s*x-u*g)+y*(s*d-u*h),e[1]=-(a*(h*x-d*g)-v*(n*x-i*g)+y*(n*d-i*h)),e[2]=a*(s*x-u*g)-o*(n*x-i*g)+y*(n*u-i*s),e[3]=-(a*(s*d-u*h)-o*(n*d-i*h)+v*(n*u-i*s)),e[4]=-(l*(h*x-d*g)-f*(s*x-u*g)+m*(s*d-u*h)),e[5]=t*(h*x-d*g)-f*(n*x-i*g)+m*(n*d-i*h),e[6]=-(t*(s*x-u*g)-l*(n*x-i*g)+m*(n*u-i*s)),e[7]=t*(s*d-u*h)-l*(n*d-i*h)+f*(n*u-i*s),e[8]=l*(v*x-d*y)-f*(o*x-u*y)+m*(o*d-u*v),e[9]=-(t*(v*x-d*y)-f*(a*x-i*y)+m*(a*d-i*v)),e[10]=t*(o*x-u*y)-l*(a*x-i*y)+m*(a*u-i*o),e[11]=-(t*(o*d-u*v)-l*(a*d-i*v)+f*(a*u-i*o)),e[12]=-(l*(v*g-h*y)-f*(o*g-s*y)+m*(o*h-s*v)),e[13]=t*(v*g-h*y)-f*(a*g-n*y)+m*(a*h-n*v),e[14]=-(t*(o*g-s*y)-l*(a*g-n*y)+m*(a*s-n*o)),e[15]=t*(o*h-s*v)-l*(a*h-n*v)+f*(a*s-n*o),e}});var Ax=X((ase,Mx)=>{Mx.exports=gO;function gO(e){var r=e[0],t=e[1],a=e[2],n=e[3],i=e[4],l=e[5],o=e[6],s=e[7],u=e[8],f=e[9],v=e[10],h=e[11],d=e[12],m=e[13],y=e[14],g=e[15],x=r*l-t*i,T=r*o-a*i,M=r*s-n*i,b=t*o-a*l,w=t*s-n*l,S=a*s-n*o,k=u*m-f*d,q=u*y-v*d,D=u*g-h*d,P=f*y-v*m,z=f*g-h*m,F=v*g-h*y;return x*F-T*z+M*P+b*D-w*q+S*k}});var Cx=X((nse,kx)=>{kx.exports=bO;function bO(e,r,t){var a=r[0],n=r[1],i=r[2],l=r[3],o=r[4],s=r[5],u=r[6],f=r[7],v=r[8],h=r[9],d=r[10],m=r[11],y=r[12],g=r[13],x=r[14],T=r[15],M=t[0],b=t[1],w=t[2],S=t[3];return e[0]=M*a+b*o+w*v+S*y,e[1]=M*n+b*s+w*h+S*g,e[2]=M*i+b*u+w*d+S*x,e[3]=M*l+b*f+w*m+S*T,M=t[4],b=t[5],w=t[6],S=t[7],e[4]=M*a+b*o+w*v+S*y,e[5]=M*n+b*s+w*h+S*g,e[6]=M*i+b*u+w*d+S*x,e[7]=M*l+b*f+w*m+S*T,M=t[8],b=t[9],w=t[10],S=t[11],e[8]=M*a+b*o+w*v+S*y,e[9]=M*n+b*s+w*h+S*g,e[10]=M*i+b*u+w*d+S*x,e[11]=M*l+b*f+w*m+S*T,M=t[12],b=t[13],w=t[14],S=t[15],e[12]=M*a+b*o+w*v+S*y,e[13]=M*n+b*s+w*h+S*g,e[14]=M*i+b*u+w*d+S*x,e[15]=M*l+b*f+w*m+S*T,e}});var Lx=X((ise,Sx)=>{Sx.exports=_O;function _O(e,r,t){var a=t[0],n=t[1],i=t[2],l,o,s,u,f,v,h,d,m,y,g,x;return r===e?(e[12]=r[0]*a+r[4]*n+r[8]*i+r[12],e[13]=r[1]*a+r[5]*n+r[9]*i+r[13],e[14]=r[2]*a+r[6]*n+r[10]*i+r[14],e[15]=r[3]*a+r[7]*n+r[11]*i+r[15]):(l=r[0],o=r[1],s=r[2],u=r[3],f=r[4],v=r[5],h=r[6],d=r[7],m=r[8],y=r[9],g=r[10],x=r[11],e[0]=l,e[1]=o,e[2]=s,e[3]=u,e[4]=f,e[5]=v,e[6]=h,e[7]=d,e[8]=m,e[9]=y,e[10]=g,e[11]=x,e[12]=l*a+f*n+m*i+r[12],e[13]=o*a+v*n+y*i+r[13],e[14]=s*a+h*n+g*i+r[14],e[15]=u*a+d*n+x*i+r[15]),e}});var Dx=X((lse,qx)=>{qx.exports=xO;function xO(e,r,t){var a=t[0],n=t[1],i=t[2];return e[0]=r[0]*a,e[1]=r[1]*a,e[2]=r[2]*a,e[3]=r[3]*a,e[4]=r[4]*n,e[5]=r[5]*n,e[6]=r[6]*n,e[7]=r[7]*n,e[8]=r[8]*i,e[9]=r[9]*i,e[10]=r[10]*i,e[11]=r[11]*i,e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15],e}});var Px=X((ose,Ex)=>{Ex.exports=wO;function wO(e,r,t,a){var n=a[0],i=a[1],l=a[2],o=Math.sqrt(n*n+i*i+l*l),s,u,f,v,h,d,m,y,g,x,T,M,b,w,S,k,q,D,P,z,F,I,H,O;return Math.abs(o)<1e-6?null:(o=1/o,n*=o,i*=o,l*=o,s=Math.sin(t),u=Math.cos(t),f=1-u,v=r[0],h=r[1],d=r[2],m=r[3],y=r[4],g=r[5],x=r[6],T=r[7],M=r[8],b=r[9],w=r[10],S=r[11],k=n*n*f+u,q=i*n*f+l*s,D=l*n*f-i*s,P=n*i*f-l*s,z=i*i*f+u,F=l*i*f+n*s,I=n*l*f+i*s,H=i*l*f-n*s,O=l*l*f+u,e[0]=v*k+y*q+M*D,e[1]=h*k+g*q+b*D,e[2]=d*k+x*q+w*D,e[3]=m*k+T*q+S*D,e[4]=v*P+y*z+M*F,e[5]=h*P+g*z+b*F,e[6]=d*P+x*z+w*F,e[7]=m*P+T*z+S*F,e[8]=v*I+y*H+M*O,e[9]=h*I+g*H+b*O,e[10]=d*I+x*H+w*O,e[11]=m*I+T*H+S*O,r!==e&&(e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15]),e)}});var zx=X((sse,Rx)=>{Rx.exports=TO;function TO(e,r,t){var a=Math.sin(t),n=Math.cos(t),i=r[4],l=r[5],o=r[6],s=r[7],u=r[8],f=r[9],v=r[10],h=r[11];return r!==e&&(e[0]=r[0],e[1]=r[1],e[2]=r[2],e[3]=r[3],e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15]),e[4]=i*n+u*a,e[5]=l*n+f*a,e[6]=o*n+v*a,e[7]=s*n+h*a,e[8]=u*n-i*a,e[9]=f*n-l*a,e[10]=v*n-o*a,e[11]=h*n-s*a,e}});var Fx=X((use,Nx)=>{Nx.exports=MO;function MO(e,r,t){var a=Math.sin(t),n=Math.cos(t),i=r[0],l=r[1],o=r[2],s=r[3],u=r[8],f=r[9],v=r[10],h=r[11];return r!==e&&(e[4]=r[4],e[5]=r[5],e[6]=r[6],e[7]=r[7],e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15]),e[0]=i*n-u*a,e[1]=l*n-f*a,e[2]=o*n-v*a,e[3]=s*n-h*a,e[8]=i*a+u*n,e[9]=l*a+f*n,e[10]=o*a+v*n,e[11]=s*a+h*n,e}});var Hx=X((fse,Ix)=>{Ix.exports=AO;function AO(e,r,t){var a=Math.sin(t),n=Math.cos(t),i=r[0],l=r[1],o=r[2],s=r[3],u=r[4],f=r[5],v=r[6],h=r[7];return r!==e&&(e[8]=r[8],e[9]=r[9],e[10]=r[10],e[11]=r[11],e[12]=r[12],e[13]=r[13],e[14]=r[14],e[15]=r[15]),e[0]=i*n+u*a,e[1]=l*n+f*a,e[2]=o*n+v*a,e[3]=s*n+h*a,e[4]=u*n-i*a,e[5]=f*n-l*a,e[6]=v*n-o*a,e[7]=h*n-s*a,e}});var Bx=X((cse,Ox)=>{Ox.exports=kO;function kO(e,r,t){var a,n,i,l=t[0],o=t[1],s=t[2],u=Math.sqrt(l*l+o*o+s*s);return Math.abs(u)<1e-6?null:(u=1/u,l*=u,o*=u,s*=u,a=Math.sin(r),n=Math.cos(r),i=1-n,e[0]=l*l*i+n,e[1]=o*l*i+s*a,e[2]=s*l*i-o*a,e[3]=0,e[4]=l*o*i-s*a,e[5]=o*o*i+n,e[6]=s*o*i+l*a,e[7]=0,e[8]=l*s*i+o*a,e[9]=o*s*i-l*a,e[10]=s*s*i+n,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e)}});var Ux=X((vse,Yx)=>{Yx.exports=CO;function CO(e,r,t){var a=r[0],n=r[1],i=r[2],l=r[3],o=a+a,s=n+n,u=i+i,f=a*o,v=a*s,h=a*u,d=n*s,m=n*u,y=i*u,g=l*o,x=l*s,T=l*u;return e[0]=1-(d+y),e[1]=v+T,e[2]=h-x,e[3]=0,e[4]=v-T,e[5]=1-(f+y),e[6]=m+g,e[7]=0,e[8]=h+x,e[9]=m-g,e[10]=1-(f+d),e[11]=0,e[12]=t[0],e[13]=t[1],e[14]=t[2],e[15]=1,e}});var Gx=X((hse,Vx)=>{Vx.exports=SO;function SO(e,r){return e[0]=r[0],e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=r[1],e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=r[2],e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var Xx=X((dse,Wx)=>{Wx.exports=LO;function LO(e,r){return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=r[0],e[13]=r[1],e[14]=r[2],e[15]=1,e}});var Jx=X((pse,Zx)=>{Zx.exports=qO;function qO(e,r){var t=Math.sin(r),a=Math.cos(r);return e[0]=1,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=a,e[6]=t,e[7]=0,e[8]=0,e[9]=-t,e[10]=a,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var $x=X((mse,Kx)=>{Kx.exports=DO;function DO(e,r){var t=Math.sin(r),a=Math.cos(r);return e[0]=a,e[1]=0,e[2]=-t,e[3]=0,e[4]=0,e[5]=1,e[6]=0,e[7]=0,e[8]=t,e[9]=0,e[10]=a,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var jx=X((yse,Qx)=>{Qx.exports=EO;function EO(e,r){var t=Math.sin(r),a=Math.cos(r);return e[0]=a,e[1]=t,e[2]=0,e[3]=0,e[4]=-t,e[5]=a,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=1,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var r4=X((gse,e4)=>{e4.exports=PO;function PO(e,r){var t=r[0],a=r[1],n=r[2],i=r[3],l=t+t,o=a+a,s=n+n,u=t*l,f=a*l,v=a*o,h=n*l,d=n*o,m=n*s,y=i*l,g=i*o,x=i*s;return e[0]=1-v-m,e[1]=f+x,e[2]=h-g,e[3]=0,e[4]=f-x,e[5]=1-u-m,e[6]=d+y,e[7]=0,e[8]=h+g,e[9]=d-y,e[10]=1-u-v,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,e}});var a4=X((bse,t4)=>{t4.exports=RO;function RO(e,r,t,a,n,i,l){var o=1/(t-r),s=1/(n-a),u=1/(i-l);return e[0]=i*2*o,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=i*2*s,e[6]=0,e[7]=0,e[8]=(t+r)*o,e[9]=(n+a)*s,e[10]=(l+i)*u,e[11]=-1,e[12]=0,e[13]=0,e[14]=l*i*2*u,e[15]=0,e}});var i4=X((_se,n4)=>{n4.exports=zO;function zO(e,r,t,a,n){var i=1/Math.tan(r/2),l=1/(a-n);return e[0]=i/t,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=i,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=(n+a)*l,e[11]=-1,e[12]=0,e[13]=0,e[14]=2*n*a*l,e[15]=0,e}});var o4=X((xse,l4)=>{l4.exports=NO;function NO(e,r,t,a){var n=Math.tan(r.upDegrees*Math.PI/180),i=Math.tan(r.downDegrees*Math.PI/180),l=Math.tan(r.leftDegrees*Math.PI/180),o=Math.tan(r.rightDegrees*Math.PI/180),s=2/(l+o),u=2/(n+i);return e[0]=s,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=u,e[6]=0,e[7]=0,e[8]=-((l-o)*s*.5),e[9]=(n-i)*u*.5,e[10]=a/(t-a),e[11]=-1,e[12]=0,e[13]=0,e[14]=a*t/(t-a),e[15]=0,e}});var u4=X((wse,s4)=>{s4.exports=FO;function FO(e,r,t,a,n,i,l){var o=1/(r-t),s=1/(a-n),u=1/(i-l);return e[0]=-2*o,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=-2*s,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=2*u,e[11]=0,e[12]=(r+t)*o,e[13]=(n+a)*s,e[14]=(l+i)*u,e[15]=1,e}});var c4=X((Tse,f4)=>{var IO=Up();f4.exports=HO;function HO(e,r,t,a){var n,i,l,o,s,u,f,v,h,d,m=r[0],y=r[1],g=r[2],x=a[0],T=a[1],M=a[2],b=t[0],w=t[1],S=t[2];return Math.abs(m-b)<1e-6&&Math.abs(y-w)<1e-6&&Math.abs(g-S)<1e-6?IO(e):(f=m-b,v=y-w,h=g-S,d=1/Math.sqrt(f*f+v*v+h*h),f*=d,v*=d,h*=d,n=T*h-M*v,i=M*f-x*h,l=x*v-T*f,d=Math.sqrt(n*n+i*i+l*l),d?(d=1/d,n*=d,i*=d,l*=d):(n=0,i=0,l=0),o=v*l-h*i,s=h*n-f*l,u=f*i-v*n,d=Math.sqrt(o*o+s*s+u*u),d?(d=1/d,o*=d,s*=d,u*=d):(o=0,s=0,u=0),e[0]=n,e[1]=o,e[2]=f,e[3]=0,e[4]=i,e[5]=s,e[6]=v,e[7]=0,e[8]=l,e[9]=u,e[10]=h,e[11]=0,e[12]=-(n*m+i*y+l*g),e[13]=-(o*m+s*y+u*g),e[14]=-(f*m+v*y+h*g),e[15]=1,e)}});var h4=X((Mse,v4)=>{v4.exports=OO;function OO(e){return"mat4("+e[0]+", "+e[1]+", "+e[2]+", "+e[3]+", "+e[4]+", "+e[5]+", "+e[6]+", "+e[7]+", "+e[8]+", "+e[9]+", "+e[10]+", "+e[11]+", "+e[12]+", "+e[13]+", "+e[14]+", "+e[15]+")"}});var Vp=X((Ase,d4)=>{d4.exports={create:vx(),clone:dx(),copy:mx(),identity:Up(),transpose:bx(),invert:xx(),adjoint:Tx(),determinant:Ax(),multiply:Cx(),translate:Lx(),scale:Dx(),rotate:Px(),rotateX:zx(),rotateY:Fx(),rotateZ:Hx(),fromRotation:Bx(),fromRotationTranslation:Ux(),fromScaling:Gx(),fromTranslation:Xx(),fromXRotation:Jx(),fromYRotation:$x(),fromZRotation:jx(),fromQuat:r4(),frustum:a4(),perspective:i4(),perspectiveFromFieldOfView:o4(),ortho:u4(),lookAt:c4(),str:h4()}});var rv=X(Et=>{"use strict";var BO=Vp();Et.init2dArray=function(e,r){for(var t=new Array(e),a=0;a{"use strict";var YO=Nr(),p4=_l(),UO=rv(),VO=Vp();function GO(e){var r;if(typeof e=="string"){if(r=document.getElementById(e),r===null)throw new Error("No DOM element with id '"+e+"' exists on the page.");return r}else if(e==null)throw new Error("DOM element provided is null or undefined");return e}function WO(e){var r=YO.select(e);return r.node()instanceof HTMLElement&&r.size()&&r.classed("js-plotly-plot")}function m4(e){var r=e&&e.parentNode;r&&r.removeChild(e)}function XO(e,r){y4("global",e,r)}function y4(e,r,t){var a="plotly.js-style-"+e,n=document.getElementById(a);if(!(n&&n.matches(".no-inline-styles"))){n||(n=document.createElement("style"),n.setAttribute("id",a),n.appendChild(document.createTextNode("")),document.head.appendChild(n));var i=n.sheet;i?i.insertRule?i.insertRule(r+"{"+t+"}",0):i.addRule?i.addRule(r,t,0):p4.warn("addStyleRule failed"):p4.warn("Cannot addRelatedStyleRule, probably due to strict CSP...")}}function ZO(e){var r="plotly.js-style-"+e,t=document.getElementById(r);t&&m4(t)}function JO(e,r,t,a,n,i){var l=a.split(":"),o=n.split(":"),s="data-btn-style-event-added";i||(i=document),i.querySelectorAll(e).forEach(function(u){u.getAttribute(s)||(u.addEventListener("mouseenter",function(){var f=this.querySelector(t);f&&(f.style[l[0]]=l[1])}),u.addEventListener("mouseleave",function(){var f=this.querySelector(t);f&&(r&&this.matches(r)?f.style[l[0]]=l[1]:f.style[o[0]]=o[1])}),u.setAttribute(s,!0))})}function KO(e){var r=b4(e),t=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1];return r.forEach(function(a){var n=g4(a);if(n){var i=UO.convertCssMatrix(n);t=VO.multiply(t,t,i)}}),t}function g4(e){var r=window.getComputedStyle(e,null),t=r.getPropertyValue("-webkit-transform")||r.getPropertyValue("-moz-transform")||r.getPropertyValue("-ms-transform")||r.getPropertyValue("-o-transform")||r.getPropertyValue("transform");return t==="none"?null:t.replace("matrix","").replace("3d","").slice(1,-1).split(",").map(function(a){return+a})}function b4(e){for(var r=[];$O(e);)r.push(e),e=e.parentNode,typeof ShadowRoot=="function"&&e instanceof ShadowRoot&&(e=e.host);return r}function $O(e){return e&&(e instanceof Element||e instanceof HTMLElement)}function QO(e,r){return e&&r&&e.top===r.top&&e.left===r.left&&e.right===r.right&&e.bottom===r.bottom}_4.exports={getGraphDiv:GO,isPlotDiv:WO,removeElement:m4,addStyleRule:XO,addRelatedStyleRule:y4,deleteRelatedStyleRule:ZO,setStyleOnHover:JO,getFullTransformMatrix:KO,getElementTransformMatrix:g4,getElementAndAncestors:b4,equalDomRects:QO}});var Af=X((Sse,x4)=>{"use strict";x4.exports={mode:{valType:"enumerated",dflt:"afterall",values:["immediate","next","afterall"]},direction:{valType:"enumerated",values:["forward","reverse"],dflt:"forward"},fromcurrent:{valType:"boolean",dflt:!1},frame:{duration:{valType:"number",min:0,dflt:500},redraw:{valType:"boolean",dflt:!0}},transition:{duration:{valType:"number",min:0,dflt:500,editType:"none"},easing:{valType:"enumerated",dflt:"cubic-in-out",values:["linear","quad","cubic","sin","exp","circle","elastic","back","bounce","linear-in","quad-in","cubic-in","sin-in","exp-in","circle-in","elastic-in","back-in","bounce-in","linear-out","quad-out","cubic-out","sin-out","exp-out","circle-out","elastic-out","back-out","bounce-out","linear-in-out","quad-in-out","cubic-in-out","sin-in-out","exp-in-out","circle-in-out","elastic-in-out","back-in-out","bounce-in-out"],editType:"none"},ordering:{valType:"enumerated",values:["layout first","traces first"],dflt:"layout first",editType:"none"}}}});var Ci=X((Lse,S4)=>{"use strict";var T4=Jt().extendFlat,jO=yo(),M4={valType:"flaglist",extras:["none"],flags:["calc","clearAxisTypes","plot","style","markerSize","colorbars"]},A4={valType:"flaglist",extras:["none"],flags:["calc","plot","legend","ticks","axrange","layoutstyle","modebar","camera","arraydraw","colorbars"]},eB=M4.flags.slice().concat(["fullReplot"]),rB=A4.flags.slice().concat("layoutReplot");S4.exports={traces:M4,layout:A4,traceFlags:function(){return w4(eB)},layoutFlags:function(){return w4(rB)},update:function(e,r){var t=r.editType;if(t&&t!=="none")for(var a=t.split("+"),n=0;n{"use strict";Gp.dash={valType:"string",values:["solid","dot","dash","longdash","dashdot","longdashdot"],dflt:"solid",editType:"style"};Gp.pattern={shape:{valType:"enumerated",values:["","/","\\","x","-","|","+","."],dflt:"",arrayOk:!0,editType:"style"},path:{valType:"string",arrayOk:!0,editType:"style"},fillmode:{valType:"enumerated",values:["replace","overlay"],dflt:"replace",editType:"style"},bgcolor:{valType:"color",arrayOk:!0,editType:"style"},fgcolor:{valType:"color",arrayOk:!0,editType:"style"},fgopacity:{valType:"number",editType:"style",min:0,max:1},size:{valType:"number",min:0,dflt:8,arrayOk:!0,editType:"style"},solidity:{valType:"number",min:0,max:1,dflt:.3,arrayOk:!0,editType:"style"},editType:"style"}});var Wp=X((Dse,L4)=>{"use strict";L4.exports={FORMAT_LINK:"https://github.com/d3/d3-format/tree/v1.4.5#d3-format",DATE_FORMAT_LINK:"https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format"}});var To=X(Gs=>{"use strict";var{DATE_FORMAT_LINK:tB,FORMAT_LINK:aB}=Wp(),nB=["Variables that can't be found will be replaced with the specifier.",'For example, a template of "data: %{x}, %{y}" will result in a value of "data: 1, %{y}" if x is 1 and y is missing.',"Variables with an undefined value will be replaced with the fallback value."].join(" ");function iB({supportOther:e}={}){return["Variables are inserted using %{variable},",'for example "y: %{y}"'+(e?" as well as %{xother}, {%_xother}, {%_xother_}, {%xother_}. When showing info for several points, *xother* will be added to those with different x positions from the first point. An underscore before or after *(x|y)other* will add a space on that side, only when this field is shown.":"."),`Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example "Price: %{y:$.2f}".`,aB,"for details on the formatting syntax.",`Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example "Day: %{2019-01-01|%A}".`,tB,"for details on the date formatting syntax.",nB].join(" ")}Gs.templateFormatStringDescription=iB;Gs.hovertemplateAttrs=({editType:e="none",arrayOk:r}={},t={})=>mo({valType:"string",dflt:"",editType:e},r!==!1?{arrayOk:!0}:{});Gs.texttemplateAttrs=({editType:e="calc",arrayOk:r}={},t={})=>mo({valType:"string",dflt:"",editType:e},r!==!1?{arrayOk:!0}:{});Gs.shapeTexttemplateAttrs=({editType:e="arraydraw",newshape:r}={},t={})=>({valType:"string",dflt:"",editType:e});Gs.templatefallbackAttrs=({editType:e="none"}={})=>({valType:"any",dflt:"-",editType:e})});var nv=X((Rse,z4)=>{"use strict";function Tl(e,r){return r?r.d2l(e):e}function q4(e,r){return r?r.l2d(e):e}function lB(e){return e.x0}function oB(e){return e.x1}function sB(e){return e.y0}function uB(e){return e.y1}function D4(e){return e.x0shift||0}function E4(e){return e.x1shift||0}function P4(e){return e.y0shift||0}function R4(e){return e.y1shift||0}function tv(e,r){return Tl(e.x1,r)+E4(e)-Tl(e.x0,r)-D4(e)}function av(e,r,t){return Tl(e.y1,t)+R4(e)-Tl(e.y0,t)-P4(e)}function fB(e,r){return Math.abs(tv(e,r))}function cB(e,r,t){return Math.abs(av(e,r,t))}function vB(e,r,t){return e.type!=="line"?void 0:Math.sqrt(Math.pow(tv(e,r),2)+Math.pow(av(e,r,t),2))}function hB(e,r){return q4((Tl(e.x1,r)+E4(e)+Tl(e.x0,r)+D4(e))/2,r)}function dB(e,r,t){return q4((Tl(e.y1,t)+R4(e)+Tl(e.y0,t)+P4(e))/2,t)}function pB(e,r,t){return e.type!=="line"?void 0:av(e,r,t)/tv(e,r)}var mB=["x0","x1","y0","y1","dy","height","ycenter"],yB=["x0","x1","y0","y1","dx","width","xcenter"];z4.exports={x0:lB,x1:oB,y0:sB,y1:uB,slope:pB,dx:tv,dy:av,width:fB,height:cB,length:vB,xcenter:hB,ycenter:dB,simpleXVariables:mB,simpleYVariables:yB}});var I4=X((zse,F4)=>{"use strict";var gB=Ci().overrideAll,Mo=ri(),N4=Ta(),bB=Si().dash,Ml=Jt().extendFlat,{shapeTexttemplateAttrs:_B,templatefallbackAttrs:xB}=To(),wB=nv();F4.exports=gB({newshape:{visible:Ml({},Mo.visible,{}),showlegend:{valType:"boolean",dflt:!1},legend:Ml({},Mo.legend,{}),legendgroup:Ml({},Mo.legendgroup,{}),legendgrouptitle:{text:Ml({},Mo.legendgrouptitle.text,{}),font:N4({})},legendrank:Ml({},Mo.legendrank,{}),legendwidth:Ml({},Mo.legendwidth,{}),line:{color:{valType:"color"},width:{valType:"number",min:0,dflt:4},dash:Ml({},bB,{dflt:"solid"})},fillcolor:{valType:"color",dflt:"rgba(0,0,0,0)"},fillrule:{valType:"enumerated",values:["evenodd","nonzero"],dflt:"evenodd"},opacity:{valType:"number",min:0,max:1,dflt:1},layer:{valType:"enumerated",values:["below","above","between"],dflt:"above"},drawdirection:{valType:"enumerated",values:["ortho","horizontal","vertical","diagonal"],dflt:"diagonal"},name:Ml({},Mo.name,{}),label:{text:{valType:"string",dflt:""},texttemplate:_B({newshape:!0},{keys:Object.keys(wB)}),texttemplatefallback:xB({editType:"arraydraw"}),font:N4({}),textposition:{valType:"enumerated",values:["top left","top center","top right","middle left","middle center","middle right","bottom left","bottom center","bottom right","start","middle","end"]},textangle:{valType:"angle",dflt:"auto"},xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"auto"},yanchor:{valType:"enumerated",values:["top","middle","bottom"]},padding:{valType:"number",dflt:3,min:0}}},activeshape:{fillcolor:{valType:"color",dflt:"rgb(255,0,255)",description:"Sets the color filling the active shape' interior."},opacity:{valType:"number",min:0,max:1,dflt:.5}}},"none","from-root")});var O4=X((Nse,H4)=>{"use strict";var TB=Si().dash,MB=Jt().extendFlat;H4.exports={newselection:{mode:{valType:"enumerated",values:["immediate","gradual"],dflt:"immediate",editType:"none"},line:{color:{valType:"color",editType:"none"},width:{valType:"number",min:1,dflt:1,editType:"none"},dash:MB({},TB,{dflt:"dot",editType:"none"}),editType:"none"},editType:"none"},activeselection:{fillcolor:{valType:"color",dflt:"rgba(0,0,0,0)",editType:"none"},opacity:{valType:"number",min:0,max:1,dflt:.5,editType:"none"},editType:"none"}}});var iv=X((Fse,B4)=>{"use strict";B4.exports=function(e){var r=e.editType;return{t:{valType:"number",dflt:0,editType:r},r:{valType:"number",dflt:0,editType:r},b:{valType:"number",dflt:0,editType:r},l:{valType:"number",dflt:0,editType:r},editType:r}}});var Ws=X((Ise,G4)=>{"use strict";var Xp=Ta(),AB=Af(),lv=ki(),Y4=I4(),U4=O4(),kB=iv(),V4=Jt().extendFlat,ov=Xp({editType:"calc"});ov.family.dflt='"Open Sans", verdana, arial, sans-serif';ov.size.dflt=12;ov.color.dflt=lv.defaultLine;G4.exports={font:ov,title:{text:{valType:"string",editType:"layoutstyle"},font:Xp({editType:"layoutstyle"}),subtitle:{text:{valType:"string",editType:"layoutstyle"},font:Xp({editType:"layoutstyle"}),editType:"layoutstyle"},xref:{valType:"enumerated",dflt:"container",values:["container","paper"],editType:"layoutstyle"},yref:{valType:"enumerated",dflt:"container",values:["container","paper"],editType:"layoutstyle"},x:{valType:"number",min:0,max:1,dflt:.5,editType:"layoutstyle"},y:{valType:"number",min:0,max:1,dflt:"auto",editType:"layoutstyle"},xanchor:{valType:"enumerated",dflt:"auto",values:["auto","left","center","right"],editType:"layoutstyle"},yanchor:{valType:"enumerated",dflt:"auto",values:["auto","top","middle","bottom"],editType:"layoutstyle"},pad:V4(kB({editType:"layoutstyle"}),{}),automargin:{valType:"boolean",dflt:!1,editType:"plot"},editType:"layoutstyle"},uniformtext:{mode:{valType:"enumerated",values:[!1,"hide","show"],dflt:!1,editType:"plot"},minsize:{valType:"number",min:0,dflt:0,editType:"plot"},editType:"plot"},autosize:{valType:"boolean",dflt:!1,editType:"none"},width:{valType:"number",min:10,dflt:700,editType:"plot"},height:{valType:"number",min:10,dflt:450,editType:"plot"},minreducedwidth:{valType:"number",min:2,dflt:64,editType:"plot"},minreducedheight:{valType:"number",min:2,dflt:64,editType:"plot"},margin:{l:{valType:"number",min:0,dflt:80,editType:"plot"},r:{valType:"number",min:0,dflt:80,editType:"plot"},t:{valType:"number",min:0,dflt:100,editType:"plot"},b:{valType:"number",min:0,dflt:80,editType:"plot"},pad:{valType:"number",min:0,dflt:0,editType:"plot"},autoexpand:{valType:"boolean",dflt:!0,editType:"plot"},editType:"plot"},computed:{valType:"any",editType:"none"},paper_bgcolor:{valType:"color",dflt:lv.background,editType:"plot"},plot_bgcolor:{valType:"color",dflt:lv.background,editType:"layoutstyle"},autotypenumbers:{valType:"enumerated",values:["convert types","strict"],dflt:"convert types",editType:"calc"},separators:{valType:"string",editType:"plot"},showlegend:{valType:"boolean",editType:"legend"},colorway:{valType:"colorlist",dflt:lv.defaults,editType:"calc"},datarevision:{valType:"any",editType:"calc"},uirevision:{valType:"any",editType:"none"},editrevision:{valType:"any",editType:"none"},selectionrevision:{valType:"any",editType:"none"},template:{valType:"any",editType:"calc"},newshape:Y4.newshape,activeshape:Y4.activeshape,newselection:U4.newselection,activeselection:U4.activeselection,meta:{valType:"any",arrayOk:!0,editType:"plot"},transition:V4({},AB.transition,{editType:"none"})}});var W4=X(()=>{(function(){if(!document.getElementById("ce8d464691048653ffe3a57c6c18ab566e8366f186677868cf724621a857b4fe")){var e=document.createElement("style");e.id="ce8d464691048653ffe3a57c6c18ab566e8366f186677868cf724621a857b4fe",e.textContent=`.maplibregl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0 0 0/0)}.maplibregl-canvas{left:0;position:absolute;top:0}.maplibregl-map:fullscreen{height:100%;width:100%}.maplibregl-ctrl-group button.maplibregl-ctrl-compass{touch-action:none}.maplibregl-canvas-container.maplibregl-interactive,.maplibregl-ctrl-group button.maplibregl-ctrl-compass{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-canvas-container.maplibregl-interactive.maplibregl-track-pointer{cursor:pointer}.maplibregl-canvas-container.maplibregl-interactive:active,.maplibregl-ctrl-group button.maplibregl-ctrl-compass:active{cursor:grabbing}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-canvas-container.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:pinch-zoom}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:none}.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures,.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.maplibregl-ctrl-top-left{left:0;top:0}.maplibregl-ctrl-top-right{right:0;top:0}.maplibregl-ctrl-bottom-left{bottom:0;left:0}.maplibregl-ctrl-bottom-right{bottom:0;right:0}.maplibregl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.maplibregl-ctrl-top-left .maplibregl-ctrl{float:left;margin:10px 0 0 10px}.maplibregl-ctrl-top-right .maplibregl-ctrl{float:right;margin:10px 10px 0 0}.maplibregl-ctrl-bottom-left .maplibregl-ctrl{float:left;margin:0 0 10px 10px}.maplibregl-ctrl-bottom-right .maplibregl-ctrl{float:right;margin:0 10px 10px 0}.maplibregl-ctrl-group{background:#fff;border-radius:4px}.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (forced-colors:active){.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.maplibregl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;padding:0;width:29px}.maplibregl-ctrl-group button+button{border-top:1px solid #ddd}.maplibregl-ctrl button .maplibregl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (forced-colors:active){.maplibregl-ctrl-icon{background-color:transparent}.maplibregl-ctrl-group button+button{border-top:1px solid ButtonText}}.maplibregl-ctrl button::-moz-focus-inner{border:0;padding:0}.maplibregl-ctrl-attrib-button:focus,.maplibregl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl button:disabled{cursor:not-allowed}.maplibregl-ctrl button:disabled .maplibregl-ctrl-icon{opacity:.25}.maplibregl-ctrl button:not(:disabled):hover{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.maplibregl-ctrl-group button:focus:first-child{border-radius:4px 4px 0 0}.maplibregl-ctrl-group button:focus:last-child{border-radius:0 0 4px 4px}.maplibregl-ctrl-group button:focus:only-child{border-radius:inherit}.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-fullscreen .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-shrink .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-compass .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 29 29'%3E%3Cpath d='m10.5 14 4-8 4 8z'/%3E%3Cpath fill='%23ccc' d='m10.5 16 4 8 4-8z'/%3E%3C/svg%3E")}}.maplibregl-ctrl button.maplibregl-ctrl-terrain .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%23333' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' fill='%2333b5e5' viewBox='0 0 22 22'%3E%3Cpath d='m1.754 13.406 4.453-4.851 3.09 3.09 3.281 3.277.969-.969-3.309-3.312 3.844-4.121 6.148 6.886h1.082v-.855l-7.207-8.07-4.84 5.187L6.169 6.57l-5.48 5.965v.871ZM.688 16.844h20.625v1.375H.688Zm0 0'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23aaa' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-waiting .maplibregl-ctrl-icon{animation:maplibregl-spin 2s linear infinite}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23999' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e58978' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%2333b5e5' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-background-error .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23e54e33' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-geolocate:disabled .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23666' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath fill='red' d='m14 5 1 1-9 9-1-1z'/%3E%3C/svg%3E")}}@keyframes maplibregl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.maplibregl-ctrl-logo.maplibregl-compact{width:14px}@media (forced-colors:active){a.maplibregl-ctrl-logo{background-color:transparent;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}@media (forced-colors:active) and (prefers-color-scheme:light){a.maplibregl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='23' fill='none'%3E%3Cpath fill='%23000' fill-opacity='.4' fill-rule='evenodd' d='M17.408 16.796h-1.827l2.501-12.095h.198l3.324 6.533.988 2.19.988-2.19 3.258-6.533h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.929 5.644h-.098l-2.914-5.644-.757-1.71-.345 1.71zm1.958-3.42-.726 3.663a1.255 1.255 0 0 1-1.232 1.011h-1.827a1.255 1.255 0 0 1-1.229-1.509l2.501-12.095a1.255 1.255 0 0 1 1.23-1.001h.197a1.25 1.25 0 0 1 1.12.685l3.19 6.273 3.125-6.263a1.25 1.25 0 0 1 1.123-.695h.181a1.255 1.255 0 0 1 1.227.991l1.443 6.71a5 5 0 0 1 .314-.787l.009-.016a4.6 4.6 0 0 1 1.777-1.887c.782-.46 1.668-.667 2.611-.667a4.6 4.6 0 0 1 1.7.32l.306.134c.21-.16.474-.256.759-.256h1.694a1.255 1.255 0 0 1 1.212.925 1.255 1.255 0 0 1 1.212-.925h1.711c.284 0 .545.094.755.252.613-.3 1.312-.45 2.075-.45 1.356 0 2.557.445 3.482 1.4q.47.48.763 1.064V4.701a1.255 1.255 0 0 1 1.255-1.255h1.86A1.255 1.255 0 0 1 54.44 4.7v9.194h2.217c.19 0 .37.043.532.118v-4.77c0-.356.147-.678.385-.906a2.42 2.42 0 0 1-.682-1.71c0-.665.267-1.253.735-1.7a2.45 2.45 0 0 1 1.722-.674 2.43 2.43 0 0 1 1.705.675q.318.302.504.683V4.7a1.255 1.255 0 0 1 1.255-1.255h1.744A1.255 1.255 0 0 1 65.812 4.7v3.335a4.8 4.8 0 0 1 1.526-.246c.938 0 1.817.214 2.59.69a4.47 4.47 0 0 1 1.67 1.743v-.98a1.255 1.255 0 0 1 1.256-1.256h1.777c.233 0 .451.064.639.174a3.4 3.4 0 0 1 1.567-.372c.346 0 .861.02 1.285.232a1.25 1.25 0 0 1 .689 1.004 4.7 4.7 0 0 1 .853-.588c.795-.44 1.675-.647 2.61-.647 1.385 0 2.65.39 3.525 1.396.836.938 1.168 2.173 1.168 3.528q-.001.515-.056 1.051a1.255 1.255 0 0 1-.947 1.09l.408.952a1.255 1.255 0 0 1-.477 1.552c-.418.268-.92.463-1.458.612-.613.171-1.304.244-2.049.244-1.06 0-2.043-.207-2.886-.698l-.015-.008c-.798-.48-1.419-1.135-1.818-1.963l-.004-.008a5.8 5.8 0 0 1-.548-2.512q0-.429.053-.843a1.3 1.3 0 0 1-.333-.086l-.166-.004c-.223 0-.426.062-.643.228-.03.024-.142.139-.142.59v3.883a1.255 1.255 0 0 1-1.256 1.256h-1.777a1.255 1.255 0 0 1-1.256-1.256V15.69l-.032.057a4.8 4.8 0 0 1-1.86 1.833 5.04 5.04 0 0 1-2.484.634 4.5 4.5 0 0 1-1.935-.424 1.25 1.25 0 0 1-.764.258h-1.71a1.255 1.255 0 0 1-1.256-1.255V7.687a2.4 2.4 0 0 1-.428.625c.253.23.412.561.412.93v7.553a1.255 1.255 0 0 1-1.256 1.255h-1.843a1.25 1.25 0 0 1-.894-.373c-.228.23-.544.373-.894.373H51.32a1.255 1.255 0 0 1-1.256-1.255v-1.251l-.061.117a4.7 4.7 0 0 1-1.782 1.884 4.77 4.77 0 0 1-2.485.67 5.6 5.6 0 0 1-1.485-.188l.009 2.764a1.255 1.255 0 0 1-1.255 1.259h-1.729a1.255 1.255 0 0 1-1.255-1.255v-3.537a1.255 1.255 0 0 1-1.167.793h-1.679a1.25 1.25 0 0 1-.77-.263 4.5 4.5 0 0 1-1.945.429c-.885 0-1.724-.21-2.495-.632l-.017-.01a5 5 0 0 1-1.081-.836 1.255 1.255 0 0 1-1.254 1.312h-1.81a1.255 1.255 0 0 1-1.228-.99l-.782-3.625-2.044 3.939a1.25 1.25 0 0 1-1.115.676h-.098a1.25 1.25 0 0 1-1.116-.68l-2.061-3.994zM35.92 16.63l.207-.114.223-.15q.493-.356.735-.785l.061-.118.033 1.332h1.678V9.242h-1.694l-.033 1.267q-.133-.329-.526-.658l-.032-.028a3.2 3.2 0 0 0-.668-.428l-.27-.12a3.3 3.3 0 0 0-1.235-.23q-1.136-.001-1.974.493a3.36 3.36 0 0 0-1.3 1.382q-.445.89-.444 2.074 0 1.2.51 2.107a3.8 3.8 0 0 0 1.382 1.381 3.9 3.9 0 0 0 1.893.477q.795 0 1.455-.33zm-2.789-5.38q-.576.675-.575 1.762 0 1.102.559 1.794.576.675 1.645.675a2.25 2.25 0 0 0 .934-.19 2.2 2.2 0 0 0 .468-.29l.178-.161a2.2 2.2 0 0 0 .397-.561q.244-.5.244-1.15v-.115q0-.708-.296-1.267l-.043-.077a2.2 2.2 0 0 0-.633-.709l-.13-.086-.047-.028a2.1 2.1 0 0 0-1.073-.285q-1.052 0-1.629.692zm2.316 2.706c.163-.17.28-.407.28-.83v-.114c0-.292-.06-.508-.15-.68a.96.96 0 0 0-.353-.389.85.85 0 0 0-.464-.127c-.4 0-.56.114-.664.239l-.01.012c-.148.174-.275.45-.275.945 0 .506.122.801.27.99.097.11.266.224.68.224.303 0 .504-.09.687-.269zm7.545 1.705a2.6 2.6 0 0 0 .331.423q.319.33.755.548l.173.074q.65.255 1.49.255 1.02 0 1.844-.493a3.45 3.45 0 0 0 1.316-1.4q.493-.904.493-2.089 0-1.909-.988-2.913-.988-1.02-2.584-1.02-.898 0-1.575.347a3 3 0 0 0-.415.262l-.199.166a3.4 3.4 0 0 0-.64.82V9.242h-1.712v11.553h1.729l-.017-5.134zm.53-1.138q.206.29.48.5l.155.11.053.034q.51.296 1.119.297 1.07 0 1.645-.675.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.435 0-.835.16a2 2 0 0 0-.284.136 2 2 0 0 0-.363.254 2.2 2.2 0 0 0-.46.569l-.082.162a2.6 2.6 0 0 0-.213 1.072v.115q0 .707.296 1.267l.135.211zm.964-.818a1.1 1.1 0 0 0 .367.385.94.94 0 0 0 .476.118c.423 0 .59-.117.687-.23.159-.194.28-.478.28-.95 0-.53-.133-.8-.266-.952l-.021-.025c-.078-.094-.231-.221-.68-.221a1 1 0 0 0-.503.135l-.012.007a.86.86 0 0 0-.335.343c-.073.133-.132.324-.132.614v.115a1.4 1.4 0 0 0 .14.66zm15.7-6.222q.347-.346.346-.856a1.05 1.05 0 0 0-.345-.79 1.18 1.18 0 0 0-.84-.329q-.51 0-.855.33a1.05 1.05 0 0 0-.346.79q0 .51.346.855.345.346.856.346.51 0 .839-.346zm4.337 9.314.033-1.332q.191.403.59.747l.098.081a4 4 0 0 0 .316.224l.223.122a3.2 3.2 0 0 0 1.44.322 3.8 3.8 0 0 0 1.875-.477 3.5 3.5 0 0 0 1.382-1.366q.527-.89.526-2.09 0-1.184-.444-2.073a3.24 3.24 0 0 0-1.283-1.399q-.823-.51-1.942-.51a3.5 3.5 0 0 0-1.527.344l-.086.043-.165.09a3 3 0 0 0-.33.214q-.432.315-.656.707a2 2 0 0 0-.099.198l.082-1.283V4.701h-1.744v12.095zm.473-2.509a2.5 2.5 0 0 0 .566.7q.117.098.245.18l.144.08a2.1 2.1 0 0 0 .975.232q1.07 0 1.645-.675.576-.69.576-1.778 0-1.102-.576-1.777-.56-.691-1.645-.692a2.2 2.2 0 0 0-1.015.235q-.22.113-.415.282l-.15.142a2.1 2.1 0 0 0-.42.594q-.223.479-.223 1.1v.115q0 .705.293 1.26zm2.616-.293c.157-.191.28-.479.28-.967 0-.51-.13-.79-.276-.961l-.021-.026c-.082-.1-.232-.225-.67-.225a.87.87 0 0 0-.681.279l-.012.011c-.154.155-.274.38-.274.807v.115c0 .285.057.499.144.669a1.1 1.1 0 0 0 .367.405c.137.082.28.123.455.123.423 0 .59-.118.686-.23zm8.266-3.013q.345-.13.724-.14l.069-.002q.493 0 .642.099l.247-1.794q-.196-.099-.717-.099a2.3 2.3 0 0 0-.545.063 2 2 0 0 0-.411.148 2.2 2.2 0 0 0-.4.249 2.5 2.5 0 0 0-.485.499 2.7 2.7 0 0 0-.32.581l-.05.137v-1.48h-1.778v7.553h1.777v-3.884q0-.546.159-.943a1.5 1.5 0 0 1 .466-.636 2.5 2.5 0 0 1 .399-.253 2 2 0 0 1 .224-.099zm9.784 2.656.05-.922q0-1.743-.856-2.698-.838-.97-2.584-.97-1.119-.001-2.007.493a3.46 3.46 0 0 0-1.4 1.382q-.493.906-.493 2.106 0 1.07.428 1.975.428.89 1.332 1.432.906.526 2.255.526.973 0 1.668-.185l.044-.012.135-.04q.613-.184.984-.421l-.542-1.267q-.3.162-.642.274l-.297.087q-.51.131-1.3.131-.954 0-1.497-.444a1.6 1.6 0 0 1-.192-.193q-.366-.44-.512-1.234l-.004-.021zm-5.427-1.256-.003.022h3.752v-.138q-.011-.727-.288-1.118a1 1 0 0 0-.156-.176q-.46-.428-1.316-.428-.986 0-1.494.604-.379.45-.494 1.234zm-27.053 2.77V4.7h-1.86v12.095h5.333V15.15zm7.103-5.908v7.553h-1.843V9.242h1.843z'/%3E%3Cpath fill='%23fff' d='m19.63 11.151-.757-1.71-.345 1.71-1.12 5.644h-1.827L18.083 4.7h.197l3.325 6.533.988 2.19.988-2.19L26.839 4.7h.181l2.6 12.095h-1.81l-1.218-5.644-.362-1.71-.658 1.71-2.93 5.644h-.098l-2.913-5.644zm14.836 5.81q-1.02 0-1.893-.478a3.8 3.8 0 0 1-1.381-1.382q-.51-.906-.51-2.106 0-1.185.444-2.074a3.36 3.36 0 0 1 1.3-1.382q.839-.494 1.974-.494a3.3 3.3 0 0 1 1.234.231 3.3 3.3 0 0 1 .97.575q.396.33.527.659l.033-1.267h1.694v7.553H37.18l-.033-1.332q-.279.593-1.02 1.053a3.17 3.17 0 0 1-1.662.444zm.296-1.482q.938 0 1.58-.642.642-.66.642-1.711v-.115q0-.708-.296-1.267a2.2 2.2 0 0 0-.807-.872 2.1 2.1 0 0 0-1.119-.313q-1.053 0-1.629.692-.575.675-.575 1.76 0 1.103.559 1.795.577.675 1.645.675zm6.521-6.237h1.711v1.4q.906-1.597 2.83-1.597 1.596 0 2.584 1.02.988 1.005.988 2.914 0 1.185-.493 2.09a3.46 3.46 0 0 1-1.316 1.399 3.5 3.5 0 0 1-1.844.493q-.954 0-1.662-.329a2.67 2.67 0 0 1-1.086-.97l.017 5.134h-1.728zm4.048 6.22q1.07 0 1.645-.674.577-.69.576-1.762 0-1.119-.576-1.777-.558-.675-1.645-.675-.592 0-1.12.296-.51.28-.822.823-.296.527-.296 1.234v.115q0 .708.296 1.267.313.543.823.855.51.296 1.119.297z'/%3E%3Cpath fill='%23e1e3e9' d='M51.325 4.7h1.86v10.45h3.473v1.646h-5.333zm7.12 4.542h1.843v7.553h-1.843zm.905-1.415a1.16 1.16 0 0 1-.856-.346 1.17 1.17 0 0 1-.346-.856 1.05 1.05 0 0 1 .346-.79q.346-.329.856-.329.494 0 .839.33a1.05 1.05 0 0 1 .345.79 1.16 1.16 0 0 1-.345.855q-.33.346-.84.346zm7.875 9.133a3.17 3.17 0 0 1-1.662-.444q-.723-.46-1.004-1.053l-.033 1.332h-1.71V4.701h1.743v4.657l-.082 1.283q.279-.658 1.086-1.119a3.5 3.5 0 0 1 1.778-.477q1.119 0 1.942.51a3.24 3.24 0 0 1 1.283 1.4q.445.888.444 2.072 0 1.201-.526 2.09a3.5 3.5 0 0 1-1.382 1.366 3.8 3.8 0 0 1-1.876.477zm-.296-1.481q1.069 0 1.645-.675.577-.69.577-1.778 0-1.102-.577-1.776-.56-.691-1.645-.692a2.12 2.12 0 0 0-1.58.659q-.642.641-.642 1.694v.115q0 .71.296 1.267a2.4 2.4 0 0 0 .807.872 2.1 2.1 0 0 0 1.119.313zm5.927-6.237h1.777v1.481q.263-.757.856-1.217a2.14 2.14 0 0 1 1.349-.46q.527 0 .724.098l-.247 1.794q-.149-.099-.642-.099-.774 0-1.416.494-.626.493-.626 1.58v3.883h-1.777V9.242zm9.534 7.718q-1.35 0-2.255-.526-.904-.543-1.332-1.432a4.6 4.6 0 0 1-.428-1.975q0-1.2.493-2.106a3.46 3.46 0 0 1 1.4-1.382q.889-.495 2.007-.494 1.744 0 2.584.97.855.956.856 2.7 0 .444-.05.92h-5.43q.18 1.005.708 1.45.542.443 1.497.443.79 0 1.3-.131a4 4 0 0 0 .938-.362l.542 1.267q-.411.263-1.119.46-.708.198-1.711.197zm1.596-4.558q.016-1.02-.444-1.432-.46-.428-1.316-.428-1.728 0-1.991 1.86z'/%3E%3Cpath d='M5.074 15.948a.484.657 0 0 0-.486.659v1.84a.484.657 0 0 0 .486.659h4.101a.484.657 0 0 0 .486-.659v-1.84a.484.657 0 0 0-.486-.659zm3.56 1.16H5.617v.838h3.017z' style='fill:%23fff;fill-rule:evenodd;stroke-width:1.03600001'/%3E%3Cg style='stroke-width:1.12603545'%3E%3Cpath d='M-9.408-1.416c-3.833-.025-7.056 2.912-7.08 6.615-.02 3.08 1.653 4.832 3.107 6.268.903.892 1.721 1.74 2.32 2.902l-.525-.004c-.543-.003-.992.304-1.24.639a1.87 1.87 0 0 0-.362 1.121l-.011 1.877c-.003.402.104.787.347 1.125.244.338.688.653 1.23.656l4.142.028c.542.003.99-.306 1.238-.641a1.87 1.87 0 0 0 .363-1.121l.012-1.875a1.87 1.87 0 0 0-.348-1.127c-.243-.338-.688-.653-1.23-.656l-.518-.004c.597-1.145 1.425-1.983 2.348-2.87 1.473-1.414 3.18-3.149 3.2-6.226-.016-3.59-2.923-6.684-6.993-6.707m-.006 1.1v.002c3.274.02 5.92 2.532 5.9 5.6-.017 2.706-1.39 4.026-2.863 5.44-1.034.994-2.118 2.033-2.814 3.633-.018.041-.052.055-.075.065q-.013.004-.02.01a.34.34 0 0 1-.226.084.34.34 0 0 1-.224-.086l-.092-.077c-.699-1.615-1.768-2.669-2.781-3.67-1.454-1.435-2.797-2.762-2.78-5.478.02-3.067 2.7-5.545 5.975-5.523m-.02 2.826c-1.62-.01-2.944 1.315-2.955 2.96-.01 1.646 1.295 2.988 2.916 2.999h.002c1.621.01 2.943-1.316 2.953-2.961.011-1.646-1.294-2.988-2.916-2.998m-.005 1.1c1.017.006 1.829.83 1.822 1.89s-.83 1.874-1.848 1.867c-1.018-.006-1.829-.83-1.822-1.89s.83-1.874 1.848-1.868m-2.155 11.857 4.14.025c.271.002.49.305.487.676l-.013 1.875c-.003.37-.224.67-.495.668l-4.14-.025c-.27-.002-.487-.306-.485-.676l.012-1.875c.003-.37.224-.67.494-.668' style='color:%23000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:%23000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:evenodd;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:%23000;solid-opacity:1;vector-effect:none;fill:%23000;fill-opacity:.4;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-9.415-.316C-12.69-.338-15.37 2.14-15.39 5.207c-.017 2.716 1.326 4.041 2.78 5.477 1.013 1 2.081 2.055 2.78 3.67l.092.076a.34.34 0 0 0 .225.086.34.34 0 0 0 .227-.083l.019-.01c.022-.009.057-.024.074-.064.697-1.6 1.78-2.64 2.814-3.634 1.473-1.414 2.847-2.733 2.864-5.44.02-3.067-2.627-5.58-5.901-5.601m-.057 8.784c1.621.011 2.944-1.315 2.955-2.96.01-1.646-1.295-2.988-2.916-2.999-1.622-.01-2.945 1.315-2.955 2.96s1.295 2.989 2.916 3' style='clip-rule:evenodd;fill:%23e1e3e9;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3Cpath d='M-11.594 15.465c-.27-.002-.492.297-.494.668l-.012 1.876c-.003.371.214.673.485.675l4.14.027c.271.002.492-.298.495-.668l.012-1.877c.003-.37-.215-.672-.485-.674z' style='clip-rule:evenodd;fill:%23fff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.47727823;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:.4' transform='translate(15.553 2.85)scale(.88807)'/%3E%3C/g%3E%3C/svg%3E")}}.maplibregl-ctrl.maplibregl-ctrl-attrib{background-color:hsla(0,0%,100%,.5);margin:0;padding:0 5px}@media screen{.maplibregl-ctrl-attrib.maplibregl-compact{background-color:#fff;border-radius:12px;box-sizing:content-box;color:#000;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.maplibregl-ctrl-attrib.maplibregl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact-show,.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-inner{display:none}.maplibregl-ctrl-attrib-button{background-color:hsla(0,0%,100%,.5);background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button{-webkit-appearance:none;-moz-appearance:none;appearance:none;list-style:none}.maplibregl-ctrl-attrib summary.maplibregl-ctrl-attrib-button::-webkit-details-marker{display:none}.maplibregl-ctrl-bottom-left .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-top-left .maplibregl-ctrl-attrib-button{left:0}.maplibregl-ctrl-attrib.maplibregl-compact .maplibregl-ctrl-attrib-button,.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-inner{display:block}.maplibregl-ctrl-attrib.maplibregl-compact-show .maplibregl-ctrl-attrib-button{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-bottom-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;right:0}.maplibregl-ctrl-top-right>.maplibregl-ctrl-attrib.maplibregl-compact:after{right:0;top:0}.maplibregl-ctrl-top-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{left:0;top:0}.maplibregl-ctrl-bottom-left>.maplibregl-ctrl-attrib.maplibregl-compact:after{bottom:0;left:0}}@media screen and (forced-colors:active){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='%23fff' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (forced-colors:active) and (prefers-color-scheme:light){.maplibregl-ctrl-attrib.maplibregl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill-rule='evenodd' viewBox='0 0 20 20'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.maplibregl-ctrl-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.maplibregl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.maplibregl-attrib-empty{display:none}.maplibregl-ctrl-scale{background-color:hsla(0,0%,100%,.75);border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px}.maplibregl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.maplibregl-popup-anchor-top,.maplibregl-popup-anchor-top-left,.maplibregl-popup-anchor-top-right{flex-direction:column}.maplibregl-popup-anchor-bottom,.maplibregl-popup-anchor-bottom-left,.maplibregl-popup-anchor-bottom-right{flex-direction:column-reverse}.maplibregl-popup-anchor-left{flex-direction:row}.maplibregl-popup-anchor-right{flex-direction:row-reverse}.maplibregl-popup-tip{border:10px solid transparent;height:0;width:0;z-index:1}.maplibregl-popup-anchor-top .maplibregl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.maplibregl-popup-anchor-top-left .maplibregl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.maplibregl-popup-anchor-top-right .maplibregl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.maplibregl-popup-anchor-bottom .maplibregl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.maplibregl-popup-anchor-left .maplibregl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.maplibregl-popup-anchor-right .maplibregl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.maplibregl-popup-close-button{background-color:transparent;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.maplibregl-popup-close-button:hover{background-color:rgb(0 0 0/5%)}.maplibregl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:15px 10px;pointer-events:auto;position:relative}.maplibregl-popup-anchor-top-left .maplibregl-popup-content{border-top-left-radius:0}.maplibregl-popup-anchor-top-right .maplibregl-popup-content{border-top-right-radius:0}.maplibregl-popup-anchor-bottom-left .maplibregl-popup-content{border-bottom-left-radius:0}.maplibregl-popup-anchor-bottom-right .maplibregl-popup-content{border-bottom-right-radius:0}.maplibregl-popup-track-pointer{display:none}.maplibregl-popup-track-pointer *{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-map:hover .maplibregl-popup-track-pointer{display:flex}.maplibregl-map:active .maplibregl-popup-track-pointer{display:none}.maplibregl-marker{left:0;position:absolute;top:0;transition:opacity .2s;will-change:transform}.maplibregl-user-location-dot,.maplibregl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.maplibregl-user-location-dot:before{animation:maplibregl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.maplibregl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px rgba(0,0,0,.35);box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}@keyframes maplibregl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.maplibregl-user-location-dot-stale{background-color:#aaa}.maplibregl-user-location-dot-stale:after{display:none}.maplibregl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive,.maplibregl-crosshair .maplibregl-interactive:active{cursor:crosshair}.maplibregl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}.maplibregl-cooperative-gesture-screen{align-items:center;background:rgba(0,0,0,.4);color:#fff;display:flex;font-size:1.4em;inset:0;justify-content:center;line-height:1.2;opacity:0;padding:1rem;pointer-events:none;position:absolute;transition:opacity 1s ease 1s;z-index:99999}.maplibregl-cooperative-gesture-screen.maplibregl-show{opacity:1;transition:opacity .05s}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:none}@media (hover:none),(width <= 480px){.maplibregl-cooperative-gesture-screen .maplibregl-desktop-message{display:none}.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message{display:block}}.maplibregl-pseudo-fullscreen{height:100%!important;left:0!important;position:fixed!important;top:0!important;width:100%!important;z-index:99999}`,document.head.appendChild(e)}})()});var _r=X(Fr=>{"use strict";var Zp=_l(),X4=ev(),Z4=Yp(),Bse=Mf().addStyleRule,J4=Jt(),CB=ri(),SB=Ws(),LB=J4.extendFlat,Jp=J4.extendDeepAll;Fr.modules={};Fr.allCategories={};Fr.allTypes=[];Fr.subplotsRegistry={};Fr.componentsRegistry={};Fr.layoutArrayContainers=[];Fr.layoutArrayRegexes=[];Fr.traceLayoutAttributes={};Fr.localeRegistry={};Fr.apiMethodRegistry={};Fr.collectableSubplotTypes=null;Fr.register=function(r){if(Fr.collectableSubplotTypes=null,r)r&&!Array.isArray(r)&&(r=[r]);else throw new Error("No argument passed to Plotly.register.");for(var t=0;t{"use strict";var RB=Ds().timeFormat,l6=Br(),Kp=_l(),kl=(Rs(),Ia(Ps)).mod,Js=Zt(),qn=Js.BADNUM,nn=Js.ONEDAY,kf=Js.ONEHOUR,Al=Js.ONEMIN,Zs=Js.ONESEC,Cf=Js.EPOCHJD,Qi=_r(),e6=Ds().utcFormat,zB=/^\s*(-?\d\d\d\d|\d\d)(-(\d?\d)(-(\d?\d)([ Tt]([01]?\d|2[0-3])(:([0-5]\d)(:([0-5]\d(\.\d+)?))?(Z|z|[+\-]\d\d(:?\d\d)?)?)?)?)?)?\s*$/m,NB=/^\s*(-?\d\d\d\d|\d\d)(-(\d?\di?)(-(\d?\d)([ Tt]([01]?\d|2[0-3])(:([0-5]\d)(:([0-5]\d(\.\d+)?))?(Z|z|[+\-]\d\d(:?\d\d)?)?)?)?)?)?\s*$/m,r6=new Date().getFullYear()-70;function ji(e){return e&&Qi.componentsRegistry.calendars&&typeof e=="string"&&e!=="gregorian"}Pt.dateTick0=function(e,r){var t=FB(e,!!r);if(r<2)return t;var a=Pt.dateTime2ms(t,e);return a+=nn*(r-1),Pt.ms2DateTime(a,0,e)};function FB(e,r){return ji(e)?r?Qi.getComponentMethod("calendars","CANONICAL_SUNDAY")[e]:Qi.getComponentMethod("calendars","CANONICAL_TICK")[e]:r?"2000-01-02":"2000-01-01"}Pt.dfltRange=function(e){return ji(e)?Qi.getComponentMethod("calendars","DFLTRANGE")[e]:["2000-01-01","2001-01-01"]};Pt.isJSDate=function(e){return typeof e=="object"&&e!==null&&typeof e.getTime=="function"};var uv,fv;Pt.dateTime2ms=function(e,r){if(Pt.isJSDate(e)){var t=e.getTimezoneOffset()*Al,a=(e.getUTCMinutes()-e.getMinutes())*Al+(e.getUTCSeconds()-e.getSeconds())*Zs+(e.getUTCMilliseconds()-e.getMilliseconds());if(a){var n=3*Al;t=t-n/2+kl(a-t+n/2,n)}return e=Number(e)-t,e>=uv&&e<=fv?e:qn}if(typeof e!="string"&&typeof e!="number")return qn;e=String(e);var i=ji(r),l=e.charAt(0);i&&(l==="G"||l==="g")&&(e=e.slice(1),r="");var o=i&&r.slice(0,7)==="chinese",s=e.match(o?NB:zB);if(!s)return qn;var u=s[1],f=s[3]||"1",v=Number(s[5]||1),h=Number(s[7]||0),d=Number(s[9]||0),m=Number(s[11]||0);if(i){if(u.length===2)return qn;u=Number(u);var y;try{var g=Qi.getComponentMethod("calendars","getCal")(r);if(o){var x=f.charAt(f.length-1)==="i";f=parseInt(f,10),y=g.newDate(u,g.toMonthIndex(u,f,x),v)}else y=g.newDate(u,Number(f),v)}catch(M){return qn}return y?(y.toJD()-Cf)*nn+h*kf+d*Al+m*Zs:qn}u.length===2?u=(Number(u)+2e3-r6)%100+r6:u=Number(u),f-=1;var T=new Date(Date.UTC(2e3,f,v,h,d));return T.setUTCFullYear(u),T.getUTCMonth()!==f||T.getUTCDate()!==v?qn:T.getTime()+m*Zs};uv=Pt.MIN_MS=Pt.dateTime2ms("-9999");fv=Pt.MAX_MS=Pt.dateTime2ms("9999-12-31 23:59:59.9999");Pt.isDateTime=function(e,r){return Pt.dateTime2ms(e,r)!==qn};function Xs(e,r){return String(e+Math.pow(10,r)).slice(1)}var sv=90*nn,t6=3*kf,a6=5*Al;Pt.ms2DateTime=function(e,r,t){if(typeof e!="number"||!(e>=uv&&e<=fv))return qn;r||(r=0);var a=Math.floor(kl(e+.05,1)*10),n=Math.round(e-a/10),i,l,o,s,u,f;if(ji(t)){var v=Math.floor(n/nn)+Cf,h=Math.floor(kl(e,nn));try{i=Qi.getComponentMethod("calendars","getCal")(t).fromJD(v).formatDate("yyyy-mm-dd")}catch(d){i=e6("G%Y-%m-%d")(new Date(n))}if(i.charAt(0)==="-")for(;i.length<11;)i="-0"+i.slice(1);else for(;i.length<10;)i="0"+i;l=r=uv+nn&&e<=fv-nn))return qn;var r=Math.floor(kl(e+.05,1)*10),t=new Date(Math.round(e-r/10)),a=RB("%Y-%m-%d")(t),n=t.getHours(),i=t.getMinutes(),l=t.getSeconds(),o=t.getUTCMilliseconds()*10+r;return o6(a,n,i,l,o)};function o6(e,r,t,a,n){if((r||t||a||n)&&(e+=" "+Xs(r,2)+":"+Xs(t,2),(a||n)&&(e+=":"+Xs(a,2),n))){for(var i=4;n%10===0;)i-=1,n/=10;e+="."+Xs(n,i)}return e}Pt.cleanDate=function(e,r,t){if(e===qn)return r;if(Pt.isJSDate(e)||typeof e=="number"&&isFinite(e)){if(ji(t))return Kp.error("JS Dates and milliseconds are incompatible with world calendars",e),r;if(e=Pt.ms2DateTimeLocal(+e),!e&&r!==void 0)return r}else if(!Pt.isDateTime(e,t))return Kp.error("unrecognized date",e),r;return e};var IB=/%\d?f/g,HB=/%h/g,OB={1:"1",2:"1",3:"2",4:"2"};function n6(e,r,t,a){e=e.replace(IB,function(i){var l=Math.min(+i.charAt(1)||6,6),o=(r/1e3%1+2).toFixed(l).slice(2).replace(/0+$/,"")||"0";return o});var n=new Date(Math.floor(r+.05));if(e=e.replace(HB,function(){return OB[t("%q")(n)]}),ji(a))try{e=Qi.getComponentMethod("calendars","worldCalFmt")(e,r,a)}catch(i){return"Invalid"}return t(e)(n)}var BB=[59,59.9,59.99,59.999,59.9999];function YB(e,r){var t=kl(e+.05,nn),a=Xs(Math.floor(t/kf),2)+":"+Xs(kl(Math.floor(t/Al),60),2);if(r!=="M"){l6(r)||(r=0);var n=Math.min(kl(e/Zs,60),BB[r]),i=(100+n).toFixed(r).slice(1);r>0&&(i=i.replace(/0+$/,"").replace(/[\.]$/,"")),a+=":"+i}return a}Pt.formatDate=function(e,r,t,a,n,i){if(n=ji(n)&&n,!r)if(t==="y")r=i.year;else if(t==="m")r=i.month;else if(t==="d")r=i.dayMonth+` +`+i.year;else return YB(e,t)+` +`+n6(i.dayMonthYear,e,a,n);return n6(r,e,a,n)};var i6=3*nn;Pt.incrementMonth=function(e,r,t){t=ji(t)&&t;var a=kl(e,nn);if(e=Math.round(e-a),t)try{var n=Math.round(e/nn)+Cf,i=Qi.getComponentMethod("calendars","getCal")(t),l=i.fromJD(n);return r%12?i.add(l,r,"m"):i.add(l,r/12,"y"),(l.toJD()-Cf)*nn+a}catch(s){Kp.error("invalid ms "+e+" in calendar "+t)}var o=new Date(e+i6);return o.setUTCMonth(o.getUTCMonth()+r)+a-i6};Pt.findExactDates=function(e,r){for(var t=0,a=0,n=0,i=0,l,o,s=ji(r)&&Qi.getComponentMethod("calendars","getCal")(r),u=0;u{"use strict";u6.exports=function(r){return r}});var jp=X(el=>{"use strict";var UB=Br(),VB=_l(),GB=$p(),WB=Zt().BADNUM,Qp=1e-9;el.findBin=function(e,r,t){if(UB(r.start))return t?Math.ceil((e-r.start)/r.size-Qp)-1:Math.floor((e-r.start)/r.size+Qp);var a=0,n=r.length,i=0,l=n>1?(r[n-1]-r[0])/(n-1):1,o,s;for(l>=0?s=t?XB:ZB:s=t?KB:JB,e+=l*Qp*(t?-1:1)*(l>=0?1:-1);a90&&VB.log("Long binary search..."),a-1};function XB(e,r){return er}function KB(e,r){return e>=r}el.sorterAsc=function(e,r){return e-r};el.sorterDes=function(e,r){return r-e};el.distinctVals=function(e){var r=e.slice();r.sort(el.sorterAsc);var t;for(t=r.length-1;t>-1&&r[t]===WB;t--);for(var a=r[t]-r[0]||1,n=a/(t||1)/1e4,i=[],l,o=0;o<=t;o++){var s=r[o],u=s-l;l===void 0?(i.push(s),l=s):u>n&&(a=Math.min(a,u),i.push(s),l=s)}return{vals:i,minDiff:a}};el.roundUp=function(e,r,t){for(var a=0,n=r.length-1,i,l=0,o=t?0:1,s=t?1:0,u=t?Math.ceil:Math.floor;a0&&(a=1),t&&a)return e.sort(r)}return a?e:e.reverse()};el.findIndexOfMin=function(e,r){r=r||GB;for(var t=1/0,a,n=0;nQB});function $B(e){return Object.keys(e).sort()}var QB,rm=Ln(()=>{"use strict";QB=$B});var f6=X(Rt=>{"use strict";var Sf=Br(),jB=hn().isArrayOrTypedArray;Rt.aggNums=function(e,r,t,a){var n,i;if((!a||a>t.length)&&(a=t.length),Sf(r)||(r=!1),jB(t[0])){for(i=new Array(a),n=0;ne.length-1)return e[e.length-1];var t=r%1;return t*e[Math.ceil(r)]+(1-t)*e[Math.floor(r)]}});var p6=X((Xse,d6)=>{"use strict";var c6=(Rs(),Ia(Ps)),tm=c6.mod,eY=c6.modHalf,Lf=Math.PI,Cl=2*Lf;function rY(e){return e/180*Lf}function tY(e){return e/Lf*180}function am(e){return Math.abs(e[1]-e[0])>Cl-1e-14}function v6(e,r){return eY(r-e,Cl)}function aY(e,r){return Math.abs(v6(e,r))}function h6(e,r){if(am(r))return!0;var t,a;r[0]a&&(a+=Cl);var n=tm(e,Cl),i=n+Cl;return n>=t&&n<=a||i>=t&&i<=a}function nY(e,r,t,a){if(!h6(r,a))return!1;var n,i;return t[0]=n&&e<=i}function nm(e,r,t,a,n,i,l){n=n||0,i=i||0;var o=am([t,a]),s,u,f,v,h;o?(s=0,u=Lf,f=Cl):t{"use strict";Ao.isLeftAnchor=function(r){return r.xanchor==="left"||r.xanchor==="auto"&&r.x<=1/3};Ao.isCenterAnchor=function(r){return r.xanchor==="center"||r.xanchor==="auto"&&r.x>1/3&&r.x<2/3};Ao.isRightAnchor=function(r){return r.xanchor==="right"||r.xanchor==="auto"&&r.x>=2/3};Ao.isTopAnchor=function(r){return r.yanchor==="top"||r.yanchor==="auto"&&r.y>=2/3};Ao.isMiddleAnchor=function(r){return r.yanchor==="middle"||r.yanchor==="auto"&&r.y>1/3&&r.y<2/3};Ao.isBottomAnchor=function(r){return r.yanchor==="bottom"||r.yanchor==="auto"&&r.y<=1/3}});var b6=X(ko=>{"use strict";var im=(Rs(),Ia(Ps)).mod;ko.segmentsIntersect=g6;function g6(e,r,t,a,n,i,l,o){var s=t-e,u=n-e,f=l-n,v=a-r,h=i-r,d=o-i,m=s*d-f*v;if(m===0)return null;var y=(u*d-f*h)/m,g=(u*v-s*h)/m;return g<0||g>1||y<0||y>1?null:{x:e+s*y,y:r+v*y}}ko.segmentDistance=function(r,t,a,n,i,l,o,s){if(g6(r,t,a,n,i,l,o,s))return 0;var u=a-r,f=n-t,v=o-i,h=s-l,d=u*u+f*f,m=v*v+h*h,y=Math.min(cv(u,f,d,i-r,l-t),cv(u,f,d,o-r,s-t),cv(v,h,m,r-i,t-l),cv(v,h,m,a-i,n-l));return Math.sqrt(y)};function cv(e,r,t,a,n){var i=a*e+n*r;if(i<0)return a*a+n*n;if(i>t){var l=a-e,o=n-r;return l*l+o*o}else{var s=a*r-n*e;return s*s/t}}var vv,lm,y6;ko.getTextLocation=function(r,t,a,n){if((r!==lm||n!==y6)&&(vv={},lm=r,y6=n),vv[a])return vv[a];var i=r.getPointAtLength(im(a-n/2,t)),l=r.getPointAtLength(im(a+n/2,t)),o=Math.atan((l.y-i.y)/(l.x-i.x)),s=r.getPointAtLength(im(a,t)),u=(s.x*4+i.x+l.x)/6,f=(s.y*4+i.y+l.y)/6,v={x:u,y:f,theta:o};return vv[a]=v,v};ko.clearLocationCache=function(){lm=null};ko.getVisibleSegment=function(r,t,a){var n=t.left,i=t.right,l=t.top,o=t.bottom,s=0,u=r.getTotalLength(),f=u,v,h;function d(y){var g=r.getPointAtLength(y);y===0?v=g:y===u&&(h=g);var x=g.xi?g.x-i:0,T=g.yo?g.y-o:0;return Math.sqrt(x*x+T*T)}for(var m=d(s);m;){if(s+=m+a,s>f)return;m=d(s)}for(m=d(f);m;){if(f-=m+a,s>f)return;m=d(f)}return{min:s,max:f,len:f-s,total:u,isClosed:s===0&&f===u&&Math.abs(v.x-h.x)<.1&&Math.abs(v.y-h.y)<.1}};ko.findPointOnPath=function(r,t,a,n){n=n||{};for(var i=n.pathLength||r.getTotalLength(),l=n.tolerance||.001,o=n.iterationLimit||30,s=r.getPointAtLength(0)[a]>r.getPointAtLength(i)[a]?-1:1,u=0,f=0,v=i,h,d,m;u0?v=h:f=h,u++}return d}});var hv=X(qf=>{"use strict";var rl={};qf.throttle=function(r,t,a){var n=rl[r],i=Date.now();if(!n){for(var l in rl)rl[l].tsn.ts+t){o();return}n.timer=setTimeout(function(){o(),n.timer=null},t)};qf.done=function(e){var r=rl[e];return!r||!r.timer?Promise.resolve():new Promise(function(t){var a=r.onDone;r.onDone=function(){a&&a(),t(),r.onDone=null}})};qf.clear=function(e){if(e)_6(rl[e]),delete rl[e];else for(var r in rl)qf.clear(r)};function _6(e){e&&e.timer!==null&&(clearTimeout(e.timer),e.timer=null)}});var w6=X(($se,x6)=>{"use strict";x6.exports=function(r){r._responsiveChartHandler&&(window.removeEventListener("resize",r._responsiveChartHandler),delete r._responsiveChartHandler)}});var T6=X((Qse,dv)=>{"use strict";dv.exports=om;dv.exports.isMobile=om;dv.exports.default=om;var sY=/(android|bb\d+|meego).+mobile|armv7l|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|samsungbrowser.*mobile|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i,uY=/CrOS/,fY=/android|ipad|playbook|silk/i;function om(e){e||(e={});let r=e.ua;if(!r&&typeof navigator!="undefined"&&(r=navigator.userAgent),r&&r.headers&&typeof r.headers["user-agent"]=="string"&&(r=r.headers["user-agent"]),typeof r!="string")return!1;let t=sY.test(r)&&!uY.test(r)||!!e.tablet&&fY.test(r);return!t&&e.tablet&&e.featureDetect&&navigator&&navigator.maxTouchPoints>1&&r.indexOf("Macintosh")!==-1&&r.indexOf("Safari")!==-1&&(t=!0),t}});var A6=X((jse,M6)=>{"use strict";var cY=Br(),vY=T6();M6.exports=function(r){var t;if(r&&r.hasOwnProperty("userAgent")?t=r.userAgent:t=hY(),typeof t!="string")return!0;var a=vY({ua:{headers:{"user-agent":t}},tablet:!0,featureDetect:!1});if(!a)for(var n=t.split(" "),i=1;i-1;o--){var s=n[o];if(s.slice(0,8)==="Version/"){var u=s.slice(8).split(".")[0];if(cY(u)&&(u=+u),u>=13)return!0}}}return a};function hY(){var e;return typeof navigator!="undefined"&&(e=navigator.userAgent),e&&e.headers&&typeof e.headers["user-agent"]=="string"&&(e=e.headers["user-agent"]),e}});var C6=X((eue,k6)=>{"use strict";var dY=Nr();k6.exports=function(r,t,a){var n=r.selectAll("g."+a.replace(/\s/g,".")).data(t,function(l){return l[0].trace.uid});n.exit().remove(),n.enter().append("g").attr("class",a),n.order();var i=r.classed("rangeplot")?"nodeRangePlot3":"node3";return n.each(function(l){l[0][i]=dY.select(this)}),n}});var L6=X((rue,S6)=>{"use strict";var pY=_r();S6.exports=function(r,t){for(var a=r._context.locale,n=0;n<2;n++){for(var i=r._context.locales,l=0;l<2;l++){var o=(i[a]||{}).dictionary;if(o){var s=o[t];if(s)return s}i=pY.localeRegistry}var u=a.split("-")[0];if(u===a)break;a=u}return t}});var D6=X((tue,q6)=>{"use strict";q6.exports=function(r){for(var t={},a=[],n=0,i=0;i{"use strict";E6.exports=function(r){for(var t=gY(r)?yY:mY,a=[],n=0;n{"use strict";R6.exports=function(r,t){if(!t)return r;var a=1/Math.abs(t),n=a>1?(a*r+a*t)/a:r+t,i=String(n).length;if(i>16){var l=String(t).length,o=String(r).length;if(i>=o+l){var s=parseFloat(n).toPrecision(12);s.indexOf("e+")===-1&&(n=+s)}}return n}});var I6={};qs(I6,{default:()=>xY});function _Y(e){return typeof e=="string"&&(e=e.replace(bY,"")),(0,N6.default)(e)?Number(e):F6.BADNUM}var N6,F6,bY,xY,H6=Ln(()=>{"use strict";N6=_p(Br()),F6=_p(Zt()),bY=/^['"%,$#\s']+|[, ]|['"%,$#\s']+$/g;xY=_Y});var Y6=X((iue,B6)=>{"use strict";var wY=/<[^>]*>/g,TY=/[\p{S}\p{P}]/gu,MY=/[\p{Cc}\p{Cf}\uFE00-\uFE0F\u20E3]/gu,AY=/�/g,kY=/\s+/g,pv="-",O6=pv.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),CY=new RegExp(O6+"{3,}","g"),SY=new RegExp(O6+"$","g"),LY=60;function qY(e){return typeof e.toWellFormed=="function"?e.toWellFormed():new TextDecoder().decode(new TextEncoder().encode(e))}B6.exports=function(r,t=LY){var a=qY(r!=null?r:"").replace(AY,"").replace(wY," ").replace(TY,n=>n===pv?n:"").toLowerCase().trim().replace(kY,pv).replace(MY,"").replace(CY,pv);return a.length<=t?a:Array.from(a).slice(0,t).join("").replace(SY,"")}});var Ne=X((lue,e7)=>{"use strict";var Df=Nr(),DY=Ds().utcFormat,EY=wp().format,Z6=Br(),J6=Zt(),K6=J6.FP_SAFE,PY=-K6,U6=J6.BADNUM,oe=e7.exports={};oe.adjustFormat=function(r){return!r||/^\d[.]\df/.test(r)||/[.]\d%/.test(r)?r:r==="0.f"?"~f":/^\d%/.test(r)?"~%":/^\ds/.test(r)?"~s":!/^[~,.0$]/.test(r)&&/[&fps]/.test(r)?"~"+r:r};var V6={};oe.warnBadFormat=function(e){var r=String(e);V6[r]||(V6[r]=1,oe.warn('encountered bad format: "'+r+'"'))};oe.noFormat=function(e){return String(e)};oe.numberFormat=function(e){var r;try{r=EY(oe.adjustFormat(e))}catch(t){return oe.warnBadFormat(e),oe.noFormat}return r};oe.nestedProperty=G0();oe.keyedContainer=x_();oe.relativeAttr=T_();oe.isPlainObject=yo();oe.toLogRange=X0();oe.relinkPrivateKeys=C_();var Sl=hn();oe.isArrayBuffer=Sl.isArrayBuffer;oe.isTypedArray=Sl.isTypedArray;oe.isArrayOrTypedArray=Sl.isArrayOrTypedArray;oe.isArray1D=Sl.isArray1D;oe.ensureArray=Sl.ensureArray;oe.concat=Sl.concat;oe.maxRowLength=Sl.maxRowLength;oe.minRowLength=Sl.minRowLength;var $6=(Rs(),Ia(Ps));oe.mod=$6.mod;oe.modHalf=$6.modHalf;var Ll=sx();oe.valObjectMeta=Ll.valObjectMeta;oe.coerce=Ll.coerce;oe.coerce2=Ll.coerce2;oe.coerceFont=Ll.coerceFont;oe.coercePattern=Ll.coercePattern;oe.coerceHoverinfo=Ll.coerceHoverinfo;oe.coerceSelectionMarkerOpacity=Ll.coerceSelectionMarkerOpacity;oe.validate=Ll.validate;var dn=s6();oe.dateTime2ms=dn.dateTime2ms;oe.isDateTime=dn.isDateTime;oe.ms2DateTime=dn.ms2DateTime;oe.ms2DateTimeLocal=dn.ms2DateTimeLocal;oe.cleanDate=dn.cleanDate;oe.isJSDate=dn.isJSDate;oe.formatDate=dn.formatDate;oe.incrementMonth=dn.incrementMonth;oe.dateTick0=dn.dateTick0;oe.dfltRange=dn.dfltRange;oe.findExactDates=dn.findExactDates;oe.MIN_MS=dn.MIN_MS;oe.MAX_MS=dn.MAX_MS;var Co=jp();oe.findBin=Co.findBin;oe.sorterAsc=Co.sorterAsc;oe.sorterDes=Co.sorterDes;oe.distinctVals=Co.distinctVals;oe.roundUp=Co.roundUp;oe.sort=Co.sort;oe.findIndexOfMin=Co.findIndexOfMin;oe.sortObjectKeys=(rm(),Ia(em)).default;var tl=f6();oe.aggNums=tl.aggNums;oe.len=tl.len;oe.mean=tl.mean;oe.geometricMean=tl.geometricMean;oe.median=tl.median;oe.midRange=tl.midRange;oe.variance=tl.variance;oe.stdev=tl.stdev;oe.interp=tl.interp;var ai=rv();oe.init2dArray=ai.init2dArray;oe.transposeRagged=ai.transposeRagged;oe.dot=ai.dot;oe.translationMatrix=ai.translationMatrix;oe.rotationMatrix=ai.rotationMatrix;oe.rotationXYMatrix=ai.rotationXYMatrix;oe.apply3DTransform=ai.apply3DTransform;oe.apply2DTransform=ai.apply2DTransform;oe.apply2DTransform2=ai.apply2DTransform2;oe.convertCssMatrix=ai.convertCssMatrix;oe.inverseTransformMatrix=ai.inverseTransformMatrix;var Li=p6();oe.deg2rad=Li.deg2rad;oe.rad2deg=Li.rad2deg;oe.angleDelta=Li.angleDelta;oe.angleDist=Li.angleDist;oe.isFullCircle=Li.isFullCircle;oe.isAngleInsideSector=Li.isAngleInsideSector;oe.isPtInsideSector=Li.isPtInsideSector;oe.pathArc=Li.pathArc;oe.pathSector=Li.pathSector;oe.pathAnnulus=Li.pathAnnulus;var $s=m6();oe.isLeftAnchor=$s.isLeftAnchor;oe.isCenterAnchor=$s.isCenterAnchor;oe.isRightAnchor=$s.isRightAnchor;oe.isTopAnchor=$s.isTopAnchor;oe.isMiddleAnchor=$s.isMiddleAnchor;oe.isBottomAnchor=$s.isBottomAnchor;var Qs=b6();oe.segmentsIntersect=Qs.segmentsIntersect;oe.segmentDistance=Qs.segmentDistance;oe.getTextLocation=Qs.getTextLocation;oe.clearLocationCache=Qs.clearLocationCache;oe.getVisibleSegment=Qs.getVisibleSegment;oe.findPointOnPath=Qs.findPointOnPath;var gv=Jt();oe.extendFlat=gv.extendFlat;oe.extendDeep=gv.extendDeep;oe.extendDeepAll=gv.extendDeepAll;oe.extendDeepNoArrays=gv.extendDeepNoArrays;var sm=_l();oe.log=sm.log;oe.warn=sm.warn;oe.error=sm.error;var RY=(Us(),Ia(Ys));oe.counterRegex=RY.counter;var um=hv();oe.throttle=um.throttle;oe.throttleDone=um.done;oe.clearThrottle=um.clear;var ni=Mf();oe.getGraphDiv=ni.getGraphDiv;oe.isPlotDiv=ni.isPlotDiv;oe.removeElement=ni.removeElement;oe.addStyleRule=ni.addStyleRule;oe.addRelatedStyleRule=ni.addRelatedStyleRule;oe.deleteRelatedStyleRule=ni.deleteRelatedStyleRule;oe.setStyleOnHover=ni.setStyleOnHover;oe.getFullTransformMatrix=ni.getFullTransformMatrix;oe.getElementTransformMatrix=ni.getElementTransformMatrix;oe.getElementAndAncestors=ni.getElementAndAncestors;oe.equalDomRects=ni.equalDomRects;oe.clearResponsive=w6();oe.preserveDrawingBuffer=A6();oe.makeTraceGroups=C6();oe._=L6();oe.notifier=Pp();oe.filterUnique=D6();oe.filterVisible=P6();oe.pushUnique=Yp();oe.increment=z6();oe.cleanNumber=(H6(),Ia(I6)).default;oe.slugify=Y6();oe.ensureNumber=function(r){return Z6(r)?(r=Number(r),r>K6||r=r?!1:Z6(e)&&e>=0&&e%1===0};oe.noop=ev();oe.identity=$p();oe.repeat=function(e,r){for(var t=new Array(r),a=0;at?Math.max(t,Math.min(r,e)):Math.max(r,Math.min(t,e))};oe.bBoxIntersect=function(e,r,t){return t=t||0,e.left<=r.right+t&&r.left<=e.right+t&&e.top<=r.bottom+t&&r.top<=e.bottom+t};oe.simpleMap=function(e,r,t,a,n){for(var i=e.length,l=new Array(i),o=0;o=Math.pow(2,t)?n>10?(oe.warn("randstr failed uniqueness"),l):e(r,t,a,(n||0)+1):l};oe.OptionControl=function(e,r){e||(e={}),r||(r="opt");var t={};return t.optionList=[],t._newoption=function(a){a[r]=e,t[a.name]=a,t.optionList.push(a)},t["_"+r]=e,t};oe.smooth=function(e,r){if(r=Math.round(r)||0,r<2)return e;var t=e.length,a=2*t,n=2*r-1,i=new Array(n),l=new Array(t),o,s,u,f;for(o=0;o=a&&(u-=a*Math.floor(u/a)),u<0?u=-1-u:u>=t&&(u=a-1-u),f+=e[u]*i[s];l[o]=f}return l};oe.syncOrAsync=function(e,r,t){var a,n;function i(){return oe.syncOrAsync(e,r,t)}for(;e.length;)if(n=e.splice(0,1)[0],a=n(r),a&&a.then)return a.then(i);return t&&t(r)};oe.stripTrailingSlash=function(e){return e.slice(-1)==="/"?e.slice(0,-1):e};oe.noneOrAll=function(e,r,t){if(e){var a=!1,n=!0,i,l;for(i=0;i0?n:0})};oe.fillArray=function(e,r,t,a){if(a=a||oe.identity,oe.isArrayOrTypedArray(e))for(var n=0;nFY.test(window.navigator.userAgent);var IY=/Firefox\/(\d+)\.\d+/;oe.getFirefoxVersion=function(){var e=IY.exec(window.navigator.userAgent);if(e&&e.length===2){var r=parseInt(e[1]);if(!isNaN(r))return r}return null};oe.isD3Selection=function(e){return e instanceof Df.selection};oe.ensureSingle=function(e,r,t,a){var n=e.select(r+(t?"."+t:""));if(n.size())return n;var i=e.append(r);return t&&i.classed(t,!0),a&&i.call(a),i};oe.ensureSingleById=function(e,r,t,a){var n=e.select(r+"#"+t);if(n.size())return n;var i=e.append(r).attr("id",t);return a&&i.call(a),i};oe.objectFromPath=function(e,r){for(var t=e.split("."),a,n=a={},i=0;i1?n+l[1]:"";if(i&&(l.length>1||o.length>4||t))for(;a.test(o);)o=o.replace(a,"$1"+i+"$2");return o+s};oe.TEMPLATE_STRING_REGEX=/%{([^\s%{}:]*)([:|\|][^}]*)?}/g;var j6=/^\w*$/;oe.templateString=function(e,r){var t={};return e.replace(oe.TEMPLATE_STRING_REGEX,function(a,n){var i;return j6.test(n)?i=r[n]:(t[n]=t[n]||oe.nestedProperty(r,n).get,i=t[n](!0)),i!==void 0?i:""})};var BY={max:10,count:0,name:"hovertemplate"};oe.hovertemplateString=e=>fm(F0(mo({},e),{opts:BY}));var YY={max:10,count:0,name:"texttemplate"};oe.texttemplateString=e=>fm(F0(mo({},e),{opts:YY}));var UY=/^(\S+)([\*\/])(-?\d+(\.\d+)?)$/;function VY(e){var r=e.match(UY);return r?{key:r[1],op:r[2],number:Number(r[3])}:{key:e,op:null,number:null}}var GY={max:10,count:0,name:"texttemplate",parseMultDiv:!0};oe.texttemplateStringForShapes=e=>fm(F0(mo({},e),{opts:GY}));var G6=/^[:|\|]/;function fm({data:e=[],locale:r,fallback:t,labels:a={},opts:n,template:i}){return i.replace(oe.TEMPLATE_STRING_REGEX,(l,o,s)=>{let u=["xother","yother"].includes(o),f=["_xother","_yother"].includes(o),v=["_xother_","_yother_"].includes(o),h=["xother_","yother_"].includes(o),d=u||f||h||v;(f||v)&&(o=o.substring(1)),(h||v)&&(o=o.substring(0,o.length-1));let m=null,y=null;if(n.parseMultDiv){var g=VY(o);o=g.key,m=g.op,y=g.number}let x;if(d){if(a[o]===void 0)return"";x=a[o]}else for(let w of e)if(w){if(w.hasOwnProperty(o)){x=w[o];break}if(j6.test(o)||(x=oe.nestedProperty(w,o).get(!0)),x!==void 0)break}if(x===void 0){let{count:w,max:S,name:k}=n,q=t===!1?l:t;return w=yv&&l<=W6,u=o>=yv&&o<=W6;if(s&&(a=10*a+l-yv),u&&(n=10*n+o-yv),!s||!u){if(a!==n)return a-n;if(l!==o)return l-o}}return n-a};var Ks=2e9;oe.seedPseudoRandom=function(){Ks=2e9};oe.pseudoRandom=function(){var e=Ks;return Ks=(69069*Ks+1)%4294967296,Math.abs(Ks-e)<429496729?oe.pseudoRandom():Ks/4294967296};oe.fillText=function(e,r,t){var a=Array.isArray(t)?function(l){t.push(l)}:function(l){t.text=l},n=oe.extractOption(e,r,"htx","hovertext");if(oe.isValidTextValue(n))return a(n);var i=oe.extractOption(e,r,"tx","text");if(oe.isValidTextValue(i))return a(i)};oe.isValidTextValue=function(e){return e||e===0};oe.formatPercent=function(e,r){r=r||0;for(var t=(Math.round(100*e*Math.pow(10,r))*Math.pow(.1,r)).toFixed(r)+"%",a=0;a1&&(u=1):u=0,oe.strTranslate(n-u*(t+l),i-u*(a+o))+oe.strScale(u)+(s?"rotate("+s+(r?"":" "+t+" "+a)+")":"")};oe.setTransormAndDisplay=function(e,r){e.attr("transform",oe.getTextTransform(r)),e.style("display",r.scale?null:"none")};oe.ensureUniformFontSize=function(e,r){var t=oe.extendFlat({},r);return t.size=Math.max(r.size,e._fullLayout.uniformtext.minsize||0),t};oe.join2=function(e,r,t){var a=e.length;return a>1?e.slice(0,-1).join(r)+t+e[a-1]:e.join(r)};oe.bigFont=function(e){return Math.round(1.2*e)};var X6=oe.getFirefoxVersion(),WY=X6!==null&&X6<86;oe.getPositionFromD3Event=function(){return WY?[Df.event.layerX,Df.event.layerY]:[Df.event.offsetX,Df.event.offsetY]}});var a7=X(()=>{"use strict";var XY=Ne(),r7={"X,X div":'direction:ltr;font-family:"Open Sans",verdana,arial,sans-serif;margin:0;padding:0;border:0;',"X input,X button":'font-family:"Open Sans",verdana,arial,sans-serif;',"X input:focus,X button:focus":"outline:none;","X a":"text-decoration:none;","X a:hover":"text-decoration:none;","X .crisp":"shape-rendering:crispEdges;","X .user-select-none":"-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none;","X svg a":"fill:#447adb;","X svg a:hover":"fill:#3c6dc5;","X .main-svg":"position:absolute;top:0;left:0;pointer-events:none;","X .main-svg .draglayer":"pointer-events:all;","X .cursor-default":"cursor:default;","X .cursor-pointer":"cursor:pointer;","X .cursor-crosshair":"cursor:crosshair;","X .cursor-move":"cursor:move;","X .cursor-col-resize":"cursor:col-resize;","X .cursor-row-resize":"cursor:row-resize;","X .cursor-ns-resize":"cursor:ns-resize;","X .cursor-ew-resize":"cursor:ew-resize;","X .cursor-sw-resize":"cursor:sw-resize;","X .cursor-s-resize":"cursor:s-resize;","X .cursor-se-resize":"cursor:se-resize;","X .cursor-w-resize":"cursor:w-resize;","X .cursor-e-resize":"cursor:e-resize;","X .cursor-nw-resize":"cursor:nw-resize;","X .cursor-n-resize":"cursor:n-resize;","X .cursor-ne-resize":"cursor:ne-resize;","X .cursor-grab":"cursor:-webkit-grab;cursor:grab;","X .modebar":"position:absolute;top:2px;right:2px;","X .ease-bg":"-webkit-transition:background-color .3s ease 0s;-moz-transition:background-color .3s ease 0s;-ms-transition:background-color .3s ease 0s;-o-transition:background-color .3s ease 0s;transition:background-color .3s ease 0s;","X .modebar--hover>:not(.watermark)":"opacity:0;-webkit-transition:opacity .3s ease 0s;-moz-transition:opacity .3s ease 0s;-ms-transition:opacity .3s ease 0s;-o-transition:opacity .3s ease 0s;transition:opacity .3s ease 0s;","X:hover .modebar--hover .modebar-group":"opacity:1;","X:focus-within .modebar--hover .modebar-group":"opacity:1;","X .modebar-group":"float:left;display:inline-block;box-sizing:border-box;padding-left:8px;position:relative;vertical-align:middle;white-space:nowrap;","X .modebar-group a":"display:grid;place-content:center;","X .modebar-btn":"position:relative;font-size:16px;padding:3px 4px;height:22px;cursor:pointer;line-height:normal;box-sizing:border-box;border:none;background:rgba(0,0,0,0);","X .modebar-btn svg":"position:relative;","X .modebar-btn:focus-visible":"outline:1px solid #000;outline-offset:1px;border-radius:3px;","X .modebar.vertical":"display:flex;flex-direction:column;flex-wrap:wrap;align-content:flex-end;max-height:100%;","X .modebar.vertical svg":"top:-1px;","X .modebar.vertical .modebar-group":"display:block;float:none;padding-left:0px;padding-bottom:8px;","X .modebar.vertical .modebar-group .modebar-btn":"display:block;text-align:center;","X [data-title]:before,X [data-title]:after":"position:absolute;-webkit-transform:translate3d(0, 0, 0);-moz-transform:translate3d(0, 0, 0);-ms-transform:translate3d(0, 0, 0);-o-transform:translate3d(0, 0, 0);transform:translate3d(0, 0, 0);display:none;opacity:0;z-index:1001;pointer-events:none;top:110%;right:50%;","X [data-title]:hover:before,X [data-title]:hover:after":"display:block;opacity:1;","X [data-title]:before":'content:"";position:absolute;background:rgba(0,0,0,0);border:6px solid rgba(0,0,0,0);z-index:1002;margin-top:-12px;border-bottom-color:#69738a;margin-right:-6px;',"X [data-title]:after":"content:attr(data-title);background:#69738a;color:#fff;padding:8px 10px;font-size:12px;line-height:12px;white-space:nowrap;margin-right:-18px;border-radius:2px;","X .vertical [data-title]:before,X .vertical [data-title]:after":"top:0%;right:200%;","X .vertical [data-title]:before":"border:6px solid rgba(0,0,0,0);border-left-color:#69738a;margin-top:8px;margin-right:-30px;","X .plotly-cloud-dialog":'font-family:"Open Sans",verdana,arial,sans-serif;position:absolute;top:0;left:0;width:100%;height:100%;z-index:1001;display:flex;align-items:center;justify-content:center;background-color:rgba(0,0,0,.4);',"X .plotly-cloud-dialog .plotly-cloud-dialog-box":"box-sizing:border-box;min-width:300px;max-width:420px;padding:20px 24px;background-color:#fff;border:1px solid #e0e2e5;border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,.25);font-size:13px;color:#2a3f5f;","X .plotly-cloud-dialog .plotly-cloud-dialog-title":"font-size:16px;font-weight:bold;margin-bottom:12px;","X .plotly-cloud-dialog .plotly-cloud-dialog-message":"line-height:1.5;overflow-wrap:break-word;word-wrap:break-word;","X .plotly-cloud-dialog .plotly-cloud-dialog-message--hostname":"font-weight:bold;","X .plotly-cloud-dialog .plotly-cloud-dialog-buttons":"display:flex;justify-content:flex-end;margin-top:20px;","X .plotly-cloud-dialog .plotly-cloud-dialog-btn":"font-family:inherit;font-size:13px;padding:7px 16px;margin-left:8px;border-radius:3px;border:1px solid rgba(0,0,0,0);cursor:pointer;","X .plotly-cloud-dialog .plotly-cloud-dialog-btn:focus-visible":"outline:2px solid #447adb;outline-offset:1px;","X .plotly-cloud-dialog .plotly-cloud-dialog-btn--cancel":"background-color:#f3f3f3;margin-left:auto;color:#777;","X .plotly-cloud-dialog .plotly-cloud-dialog-btn--cancel:hover":"background-color:#e0e2e5;","X .plotly-cloud-dialog .plotly-cloud-dialog-btn--confirm":"background-color:#447adb;color:#fff;","X .plotly-cloud-dialog .plotly-cloud-dialog-btn--confirm:hover":"background-color:#1d3b84;",Y:'font-family:"Open Sans",verdana,arial,sans-serif;position:fixed;top:50px;right:20px;z-index:10000;font-size:10pt;max-width:180px;',"Y p":"margin:0;","Y .notifier-note":"min-width:180px;max-width:250px;border:1px solid #fff;z-index:3000;margin:0;background-color:#8c97af;background-color:rgba(140,151,175,.9);color:#fff;padding:10px;overflow-wrap:break-word;word-wrap:break-word;-ms-hyphens:auto;-webkit-hyphens:auto;hyphens:auto;","Y .notifier-close":"color:#fff;opacity:.8;float:right;padding:0 5px;background:none;border:none;font-size:20px;font-weight:bold;line-height:20px;","Y .notifier-close:hover":"color:#444;text-decoration:none;cursor:pointer;"};for(cm in r7)t7=cm.replace(/^,/," ,").replace(/X/g,".js-plotly-plot .plotly").replace(/Y/g,".plotly-notifier"),XY.addStyleRule(t7,r7[cm]);var t7,cm});var vm=X((fue,n7)=>{n7.exports=!0});var dm=X((cue,i7)=>{"use strict";var ZY=vm(),hm;typeof window.matchMedia=="function"?hm=!window.matchMedia("(hover: none)").matches:hm=ZY;i7.exports=hm});var xv=X((vue,pm)=>{"use strict";var js=typeof Reflect=="object"?Reflect:null,l7=js&&typeof js.apply=="function"?js.apply:function(r,t,a){return Function.prototype.apply.call(r,t,a)},bv;js&&typeof js.ownKeys=="function"?bv=js.ownKeys:Object.getOwnPropertySymbols?bv=function(r){return Object.getOwnPropertyNames(r).concat(Object.getOwnPropertySymbols(r))}:bv=function(r){return Object.getOwnPropertyNames(r)};function JY(e){console&&console.warn&&console.warn(e)}var s7=Number.isNaN||function(r){return r!==r};function dt(){dt.init.call(this)}pm.exports=dt;pm.exports.once=jY;dt.EventEmitter=dt;dt.prototype._events=void 0;dt.prototype._eventsCount=0;dt.prototype._maxListeners=void 0;var o7=10;function _v(e){if(typeof e!="function")throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof e)}Object.defineProperty(dt,"defaultMaxListeners",{enumerable:!0,get:function(){return o7},set:function(e){if(typeof e!="number"||e<0||s7(e))throw new RangeError('The value of "defaultMaxListeners" is out of range. It must be a non-negative number. Received '+e+".");o7=e}});dt.init=function(){(this._events===void 0||this._events===Object.getPrototypeOf(this)._events)&&(this._events=Object.create(null),this._eventsCount=0),this._maxListeners=this._maxListeners||void 0};dt.prototype.setMaxListeners=function(r){if(typeof r!="number"||r<0||s7(r))throw new RangeError('The value of "n" is out of range. It must be a non-negative number. Received '+r+".");return this._maxListeners=r,this};function u7(e){return e._maxListeners===void 0?dt.defaultMaxListeners:e._maxListeners}dt.prototype.getMaxListeners=function(){return u7(this)};dt.prototype.emit=function(r){for(var t=[],a=1;a0&&(l=t[0]),l instanceof Error)throw l;var o=new Error("Unhandled error."+(l?" ("+l.message+")":""));throw o.context=l,o}var s=i[r];if(s===void 0)return!1;if(typeof s=="function")l7(s,this,t);else for(var u=s.length,f=d7(s,u),a=0;a0&&l.length>n&&!l.warned){l.warned=!0;var o=new Error("Possible EventEmitter memory leak detected. "+l.length+" "+String(r)+" listeners added. Use emitter.setMaxListeners() to increase limit");o.name="MaxListenersExceededWarning",o.emitter=e,o.type=r,o.count=l.length,JY(o)}return e}dt.prototype.addListener=function(r,t){return f7(this,r,t,!1)};dt.prototype.on=dt.prototype.addListener;dt.prototype.prependListener=function(r,t){return f7(this,r,t,!0)};function KY(){if(!this.fired)return this.target.removeListener(this.type,this.wrapFn),this.fired=!0,arguments.length===0?this.listener.call(this.target):this.listener.apply(this.target,arguments)}function c7(e,r,t){var a={fired:!1,wrapFn:void 0,target:e,type:r,listener:t},n=KY.bind(a);return n.listener=t,a.wrapFn=n,n}dt.prototype.once=function(r,t){return _v(t),this.on(r,c7(this,r,t)),this};dt.prototype.prependOnceListener=function(r,t){return _v(t),this.prependListener(r,c7(this,r,t)),this};dt.prototype.removeListener=function(r,t){var a,n,i,l,o;if(_v(t),n=this._events,n===void 0)return this;if(a=n[r],a===void 0)return this;if(a===t||a.listener===t)--this._eventsCount===0?this._events=Object.create(null):(delete n[r],n.removeListener&&this.emit("removeListener",r,a.listener||t));else if(typeof a!="function"){for(i=-1,l=a.length-1;l>=0;l--)if(a[l]===t||a[l].listener===t){o=a[l].listener,i=l;break}if(i<0)return this;i===0?a.shift():$Y(a,i),a.length===1&&(n[r]=a[0]),n.removeListener!==void 0&&this.emit("removeListener",r,o||t)}return this};dt.prototype.off=dt.prototype.removeListener;dt.prototype.removeAllListeners=function(r){var t,a,n;if(a=this._events,a===void 0)return this;if(a.removeListener===void 0)return arguments.length===0?(this._events=Object.create(null),this._eventsCount=0):a[r]!==void 0&&(--this._eventsCount===0?this._events=Object.create(null):delete a[r]),this;if(arguments.length===0){var i=Object.keys(a),l;for(n=0;n=0;n--)this.removeListener(r,t[n]);return this};function v7(e,r,t){var a=e._events;if(a===void 0)return[];var n=a[r];return n===void 0?[]:typeof n=="function"?t?[n.listener||n]:[n]:t?QY(n):d7(n,n.length)}dt.prototype.listeners=function(r){return v7(this,r,!0)};dt.prototype.rawListeners=function(r){return v7(this,r,!1)};dt.listenerCount=function(e,r){return typeof e.listenerCount=="function"?e.listenerCount(r):h7.call(e,r)};dt.prototype.listenerCount=h7;function h7(e){var r=this._events;if(r!==void 0){var t=r[e];if(typeof t=="function")return 1;if(t!==void 0)return t.length}return 0}dt.prototype.eventNames=function(){return this._eventsCount>0?bv(this._events):[]};function d7(e,r){for(var t=new Array(r),a=0;a{"use strict";var mm=xv().EventEmitter,rU={init:function(e){if(e._ev instanceof mm)return e;var r=new mm,t=new mm;return e._ev=r,e._internalEv=t,e.on=r.on.bind(r),e.once=r.once.bind(r),e.removeListener=r.removeListener.bind(r),e.removeAllListeners=r.removeAllListeners.bind(r),e._internalOn=t.on.bind(t),e._internalOnce=t.once.bind(t),e._removeInternalListener=t.removeListener.bind(t),e._removeAllInternalListeners=t.removeAllListeners.bind(t),e.emit=function(a,n){r.emit(a,n),t.emit(a,n)},typeof e.addEventListener=="function"&&e.addEventListener("wheel",()=>{},{passive:!0}),e},triggerHandler:function(e,r,t){var a,n=e._ev;if(!n)return;var i=n._events[r];if(!i)return;function l(s){if(s.listener){if(n.removeListener(r,s.listener),!s.fired)return s.fired=!0,s.listener.apply(n,[t])}else return s.apply(n,[t])}i=Array.isArray(i)?i:[i];var o;for(o=0;o{"use strict";var y7=Ne(),tU=_o().dfltConfig;function aU(e,r){for(var t=[],a,n=0;ntU.queueLength&&(e.undoQueue.queue.shift(),e.undoQueue.index--)};al.startSequence=function(e){e.undoQueue=e.undoQueue||{index:0,queue:[],sequence:!1},e.undoQueue.sequence=!0,e.undoQueue.beginSequence=!0};al.stopSequence=function(e){e.undoQueue=e.undoQueue||{index:0,queue:[],sequence:!1},e.undoQueue.sequence=!1,e.undoQueue.beginSequence=!1};al.undo=function(r){var t,a;if(!(r.undoQueue===void 0||isNaN(r.undoQueue.index)||r.undoQueue.index<=0)){for(r.undoQueue.index--,t=r.undoQueue.queue[r.undoQueue.index],r.undoQueue.inSequence=!0,a=0;a=r.undoQueue.queue.length)){for(t=r.undoQueue.queue[r.undoQueue.index],r.undoQueue.inSequence=!0,a=0;a{"use strict";_7.exports={_isLinkedToArray:"frames_entry",group:{valType:"string"},name:{valType:"string"},traces:{valType:"any"},baseframe:{valType:"string"},data:{valType:"any"},layout:{valType:"any"}}});var tu=X(Kt=>{"use strict";var Dn=_r(),Pf=Ne(),Tv=ri(),gm=Ws(),nU=ym(),iU=Af(),lU=_o().configAttributes,x7=Ci(),ii=Pf.extendDeepAll,eu=Pf.isPlainObject,oU=Pf.isArrayOrTypedArray,Mv=Pf.nestedProperty,sU=Pf.valObjectMeta,bm="_isSubplotObj",Av="_isLinkedToArray",uU="_arrayAttrRegexps",T7="_deprecated",_m=[bm,Av,uU,T7];Kt.IS_SUBPLOT_OBJ=bm;Kt.IS_LINKED_TO_ARRAY=Av;Kt.DEPRECATED=T7;Kt.UNDERSCORE_ATTRS=_m;Kt.get=function(){var e={};return Dn.allTypes.forEach(function(r){e[r]=cU(r)}),{defs:{valObjects:sU,metaKeys:_m.concat(["description","role","editType","impliedEdits"]),editType:{traces:x7.traces,layout:x7.layout},impliedEdits:{}},traces:e,layout:vU(),frames:hU(),animation:ru(iU),config:ru(lU)}};Kt.crawl=function(e,r,t,a){var n=t||0;a=a||"",Object.keys(e).forEach(function(i){var l=e[i];if(_m.indexOf(i)===-1){var o=(a?a+".":"")+i;r(l,i,e,n,o),!Kt.isValObject(l)&&eu(l)&&i!=="impliedEdits"&&Kt.crawl(l,r,n+1,o)}})};Kt.isValObject=function(e){return e&&e.valType!==void 0};Kt.findArrayAttributes=function(e){var r=[],t=[],a=[],n,i;function l(s,u,f,v){t=t.slice(0,v).concat([u]),a=a.slice(0,v).concat([s&&s._isLinkedToArray]);var h=s&&(s.valType==="data_array"||s.arrayOk===!0)&&!(t[v-1]==="colorbar"&&(u==="ticktext"||u==="tickvals"));h&&o(n,0,"")}function o(s,u,f){var v=s[t[u]],h=f+t[u];if(u===t.length-1)oU(v)&&r.push(i+h);else if(a[u]){if(Array.isArray(v))for(var d=0;d=i.length)return!1;if(e.dimensions===2){if(t++,r.length===t)return e;var l=r[t];if(!wv(l))return!1;e=i[n][l]}else e=i[n]}else e=i}}return e}function wv(e){return e===Math.round(e)&&e>=0}function cU(e){var r,t;r=Dn.modules[e]._module,t=r.basePlotModule;var a={};a.type=null;var n=ii({},Tv),i=ii({},r.attributes);Kt.crawl(i,function(s,u,f,v,h){Mv(n,h).set(void 0),s===void 0&&Mv(i,h).set(void 0)}),ii(a,n),Dn.traceIs(e,"noOpacity")&&delete a.opacity,Dn.traceIs(e,"showLegend")||(delete a.showlegend,delete a.legendgroup),Dn.traceIs(e,"noHover")&&(delete a.hoverinfo,delete a.hoverlabel),r.selectPoints||delete a.selectedpoints,ii(a,i),t.attributes&&ii(a,t.attributes),a.type=e;var l={meta:r.meta||{},categories:r.categories||{},animatable:!!r.animatable,type:e,attributes:ru(a)};if(r.layoutAttributes){var o={};ii(o,r.layoutAttributes),l.layoutAttributes=ru(o)}return r.animatable||Kt.crawl(l,function(s){Kt.isValObject(s)&&"anim"in s&&delete s.anim}),l}function vU(){var e={},r,t;ii(e,gm);for(r in Dn.subplotsRegistry)if(t=Dn.subplotsRegistry[r],!!t.layoutAttributes)if(Array.isArray(t.attr))for(var a=0;a{"use strict";var au=Ne(),gU=ri(),ql="templateitemname",xm={name:{valType:"string",editType:"none"}};xm[ql]={valType:"string",editType:"calc"};So.templatedArray=function(e,r){return r._isLinkedToArray=e,r.name=xm.name,r[ql]=xm[ql],r};So.traceTemplater=function(e){var r={},t,a;for(t in e)a=e[t],Array.isArray(a)&&a.length&&(r[t]=0);function n(i){t=au.coerce(i,{},gU,"type");var l={type:t,_template:null};if(t in r){a=e[t];var o=r[t]%a.length;r[t]++,l._template=a[o]}return l}return{newTrace:n}};So.newContainer=function(e,r,t){var a=e._template,n=a&&(a[r]||t&&a[t]);au.isPlainObject(n)||(n=null);var i=e[r]={_template:n};return i};So.arrayTemplater=function(e,r,t){var a=e._template,n=a&&a[k7(r)],i=a&&a[r];(!Array.isArray(i)||!i.length)&&(i=[]);var l={};function o(u){var f={name:u.name,_input:u},v=f[ql]=u[ql];if(!A7(v))return f._template=n,f;for(var h=0;h=a&&(t._input||{})._templateitemname;i&&(n=a);var l=r+"["+n+"]",o;function s(){o={},i&&(o[l]={},o[l][ql]=i)}s();function u(d,m){o[d]=m}function f(d,m){i?au.nestedProperty(o[l],d).set(m):o[l+"."+d]=m}function v(){var d=o;return s(),d}function h(d,m){d&&f(d,m);var y=v();for(var g in y)au.nestedProperty(e,g).set(y[g])}return{modifyBase:u,modifyItem:f,getUpdateObj:v,applyUpdate:h}}});var ta=X((gue,C7)=>{"use strict";var Rf=(Us(),Ia(Ys)).counter;C7.exports={idRegex:{x:Rf("x","( domain)?"),y:Rf("y","( domain)?")},attrRegex:Rf("[xy]axis"),xAxisMatch:Rf("xaxis"),yAxisMatch:Rf("yaxis"),AX_ID_PATTERN:/^[xyz][0-9]*( domain)?$/,AX_NAME_PATTERN:/^[xyz]axis[0-9]*$/,SUBPLOT_PATTERN:/^x([0-9]*)y([0-9]*)$/,HOUR_PATTERN:"hour",WEEKDAY_PATTERN:"day of week",MINDRAG:8,MINZOOM:20,DRAGGERSIZE:20,REDRAWDELAY:50,DFLTRANGEX:[-1,6],DFLTRANGEY:[-1,4],traceLayerClasses:["imagelayer","heatmaplayer","contourcarpetlayer","contourlayer","funnellayer","waterfalllayer","barlayer","carpetlayer","violinlayer","boxlayer","ohlclayer","scattercarpetlayer","scatterlayer"],clipOnAxisFalseQuery:[".scatterlayer",".barlayer",".funnellayer",".waterfalllayer"],layerValue2layerClass:{"above traces":"above","below traces":"below"},zindexSeparator:"z"}});var $t=X(ln=>{"use strict";var bU=_r(),wm=ta();ln.id2name=function(r){if(!(typeof r!="string"||!r.match(wm.AX_ID_PATTERN))){var t=r.split(" ")[0].slice(1);return t==="1"&&(t=""),r.charAt(0)+"axis"+t}};ln.name2id=function(r){if(r.match(wm.AX_NAME_PATTERN)){var t=r.slice(5);return t==="1"&&(t=""),r.charAt(0)+t}};ln.cleanId=function(r,t,a){var n=/( domain)$/.test(r);if(!(typeof r!="string"||!r.match(wm.AX_ID_PATTERN))&&!(t&&r.charAt(0)!==t)&&!(n&&!a)){var i=r.split(" ")[0].slice(1).replace(/^0+/,"");return i==="1"&&(i=""),r.charAt(0)+i+(n&&a?" domain":"")}};ln.list=function(e,r,t){var a=e._fullLayout;if(!a)return[];var n=ln.listIds(e,r),i=new Array(n.length),l;for(l=0;la?1:-1:+(e.slice(1)||1)-+(r.slice(1)||1)};ln.ref2id=function(e){return/^[xyz]/.test(e)?e.split(" ")[0]:!1};function S7(e,r){if(r&&r.length){for(var t=0;t{"use strict";function _U(e){var r=e._fullLayout._zoomlayer;r&&r.selectAll(".outline-controllers").remove()}function xU(e){var r=e._fullLayout._zoomlayer;r&&r.selectAll(".select-outline").remove(),e._fullLayout._outlining=!1}L7.exports={clearOutlineControllers:_U,clearOutline:xU}});var kv=X((xue,q7)=>{"use strict";q7.exports={scattermode:{valType:"enumerated",values:["group","overlay"],dflt:"overlay",editType:"calc"},scattergap:{valType:"number",min:0,max:1,editType:"calc"}}});var Tm=X(Sv=>{"use strict";var Cv=_r(),wue=ta().SUBPLOT_PATTERN;Sv.getSubplotCalcData=function(e,r,t){var a=Cv.subplotsRegistry[r];if(!a)return[];for(var n=a.attr,i=[],l=0;l{"use strict";var wU=_r(),nu=Ne();Lo.manageCommandObserver=function(e,r,t,a){var n={},i=!0;r&&r._commandObserver&&(n=r._commandObserver),n.cache||(n.cache={}),n.lookupTable={};var l=Lo.hasSimpleAPICommandBindings(e,t,n.lookupTable);if(r&&r._commandObserver){if(l)return n;if(r._commandObserver.remove)return r._commandObserver.remove(),r._commandObserver=null,n}if(l){D7(e,l,n.cache),n.check=function(){if(i){var f=D7(e,l,n.cache);return f.changed&&a&&n.lookupTable[f.value]!==void 0&&(n.disable(),Promise.resolve(a({value:f.value,type:l.type,prop:l.prop,traces:l.traces,index:n.lookupTable[f.value]})).then(n.enable,n.enable)),f.changed}};for(var o=["plotly_relayout","plotly_redraw","plotly_restyle","plotly_update","plotly_animatingframe","plotly_afterplot"],s=0;s0?".":"")+n;nu.isPlainObject(i)?Mm(i,r,l,a+1):r(l,n,i)}})}});var Ut=X((kue,V7)=>{"use strict";var Aue=Nr(),MU=Ds().timeFormatLocale,AU=wp().formatLocale,zf=Br(),kU=Tp(),CU=df().version,Gr=_r(),SU=tu(),LU=vt(),vr=Ne(),qU=Er(),z7=Zt().BADNUM,on=$t(),DU=Dl().clearOutline,EU=kv(),Am=Af(),PU=ym(),RU=Tm().getModuleCalcData,N7=vr.relinkPrivateKeys,qo=vr._,Qe=V7.exports={};vr.extendFlat(Qe,Gr);Qe.attributes=ri();Qe.attributes.type.values=Qe.allTypes;Qe.fontAttrs=Ta();Qe.layoutAttributes=Ws();var qv=R7();Qe.executeAPICommand=qv.executeAPICommand;Qe.computeAPICommandBindings=qv.computeAPICommandBindings;Qe.manageCommandObserver=qv.manageCommandObserver;Qe.hasSimpleAPICommandBindings=qv.hasSimpleAPICommandBindings;Qe.redrawText=function(e){return e=vr.getGraphDiv(e),new Promise(function(r){setTimeout(function(){e._fullLayout&&(Gr.getComponentMethod("annotations","draw")(e),Gr.getComponentMethod("legend","draw")(e),Gr.getComponentMethod("colorbar","draw")(e),r(Qe.previousPromises(e)))},300)})};Qe.resize=function(e){e=vr.getGraphDiv(e);var r,t=new Promise(function(a,n){(!e||vr.isHidden(e))&&n(new Error("Resize must be passed a displayed plot div element.")),e._redrawTimer&&clearTimeout(e._redrawTimer),e._resolveResize&&(r=e._resolveResize),e._resolveResize=a,e._redrawTimer=setTimeout(function(){if(!e.layout||e.layout.width&&e.layout.height||vr.isHidden(e)){a(e);return}delete e.layout.width,delete e.layout.height;var i=e.changed;e.autoplay=!0,Gr.call("relayout",e,{autosize:!0}).then(function(){e.changed=i,e._resolveResize===a&&(delete e._resolveResize,a(e))})},100)});return r&&r(t),t};Qe.previousPromises=function(e){if((e._promises||[]).length)return Promise.all(e._promises).then(function(){e._promises=[]})};Qe.sendDataToCloud=function(e,r){e.emit("plotly_beforeexport");let t=new URL(r).origin;var a=Qe.graphJson(e,!1,"object");a.version=CU;var n=new URL(r);n.searchParams.set("origin",window.location.origin);var i=window.open(n.href,"_blank");if(!i){console.error("Unable to open Plotly Cloud (the popup may have been blocked)"),e.emit("plotly_exportfail");return}var l=function(o){o.origin===t&&o.data&&o.data.type==="CHART_AUTH_SUCCESS"&&(i.postMessage({type:"chart",chart:a},t),window.removeEventListener("message",l),e.emit("plotly_afterexport"))};return window.addEventListener("message",l),!1};var zU=["days","shortDays","months","shortMonths","periods","dateTime","date","time","decimal","thousands","grouping","currency"],NU=["year","month","dayMonth","dayMonthYear"];Qe.supplyDefaults=function(e,r){var t=r&&r.skipUpdateCalc,a=e._fullLayout||{};if(a._skipDefaults){delete a._skipDefaults;return}var n=e._fullLayout={},i=e.layout||{},l=e._fullData||[],o=e._fullData=[],s=e.data||[],u=e.calcdata||[],f=e._context||{},v;e._transitionData||Qe.createTransitionData(e),n._dfltTitle={plot:qo(e,"Click to enter Plot title"),subtitle:qo(e,"Click to enter Plot subtitle"),x:qo(e,"Click to enter X axis title"),y:qo(e,"Click to enter Y axis title"),colorbar:qo(e,"Click to enter Colorscale title"),annotation:qo(e,"new text")},n._traceWord=qo(e,"trace");var h=F7(e,zU);if(a._initialAutoSizeIsDone){var d=a.width,m=a.height;Qe.supplyLayoutGlobalDefaults(i,n,h),i.width||(n.width=d),i.height||(n.height=m),Qe.sanitizeMargins(n)}else{Qe.supplyLayoutGlobalDefaults(i,n,h);var y=!i.width||!i.height,g=n.autosize,x=f.autosizable,T=y&&(g||x);T?Qe.plotAutoSize(e,i,n):y&&Qe.sanitizeMargins(n),!g&&y&&(i.width=n.width,i.height=n.height)}n._d3locale=HU(h,n.separators),n._extraFormat=F7(e,NU),n._initialAutoSizeIsDone=!0,n._dataLength=s.length,n._modules=[],n._visibleModules=[],n._basePlotModules=[];var M=n._subplots=IU(),b=n._splomAxes={x:{},y:{}},w=n._splomSubplots={};n._splomGridDflt={},n._scatterStackOpts={},n._firstScatter={},n._alignmentOpts={},n._colorAxes={},n._requestRangeslider={},n._traceUids=FU(l,s),Qe.supplyDataDefaults(s,o,i,n);var S=Object.keys(b.x),k=Object.keys(b.y);if(S.length>1&&k.length>1){for(Gr.getComponentMethod("grid","sizeDefaults")(i,n),v=0;v15&&k.length>15&&n.shapes.length===0&&n.images.length===0,Qe.linkSubplots(o,n,l,a),Qe.cleanPlot(o,n,l,a);var F=!!(a._has&&a._has("cartesian")),I=!!(n._has&&n._has("cartesian")),H=F,O=I;H&&!O?a._bgLayer.remove():O&&!H&&(n._shouldCreateBgLayer=!0),a._zoomlayer&&!e._dragging&&DU({_fullLayout:a}),OU(o,n),N7(n,a),Gr.getComponentMethod("colorscale","crossTraceDefaults")(o,n),n._preGUI||(n._preGUI={}),n._tracePreGUI||(n._tracePreGUI={});var G=n._tracePreGUI,Y={},B;for(B in G)Y[B]="old";for(v=0;v0){var f=1-2*i;l=Math.round(f*l),o=Math.round(f*o)}}var v=Qe.layoutAttributes.width.min,h=Qe.layoutAttributes.height.min;l1,m=!t.height&&Math.abs(a.height-o)>1;(m||d)&&(d&&(a.width=l),m&&(a.height=o)),r._initialAutoSize||(r._initialAutoSize={width:l,height:o}),Qe.sanitizeMargins(a)};Qe.supplyLayoutModuleDefaults=function(e,r,t,a){var n=Gr.componentsRegistry,i=r._basePlotModules,l,o,s,u=Gr.subplotsRegistry.cartesian;for(l in n)s=n[l],s.includeBasePlot&&s.includeBasePlot(e,r);i.length||i.push(u),r._has("cartesian")&&(Gr.getComponentMethod("grid","contentDefaults")(e,r),u.finalizeSubplots(e,r));for(var f in r._subplots)r._subplots[f].sort(vr.subplotSort);for(o=0;o1&&(t.l/=g,t.r/=g)}if(h){var x=(t.t+t.b)/h;x>1&&(t.t/=x,t.b/=x)}var T=t.xl!==void 0?t.xl:t.x,M=t.xr!==void 0?t.xr:t.x,b=t.yt!==void 0?t.yt:t.y,w=t.yb!==void 0?t.yb:t.y;d[r]={l:{val:T,size:t.l+y},r:{val:M,size:t.r+y},b:{val:w,size:t.b+y},t:{val:b,size:t.t+y}},m[r]=1}if(!a._replotting)return Qe.doAutoMargin(e)}};function YU(e){if("_redrawFromAutoMarginCount"in e._fullLayout)return!1;var r=on.list(e,"",!0);for(var t in r)if(r[t].autoshift||r[t].shift)return!0;return!1}Qe.doAutoMargin=function(e){var r=e._fullLayout,t=r.width,a=r.height;r._size||(r._size={}),O7(r);var n=r._size,i=r.margin,l={t:0,b:0,l:0,r:0},o=vr.extendFlat({},n),s=i.l,u=i.r,f=i.t,v=i.b,h=r._pushmargin,d=r._pushmarginIds,m=r.minreducedwidth,y=r.minreducedheight;if(i.autoexpand!==!1){for(var g in h)d[g]||delete h[g];var x=e._fullLayout._reservedMargin;for(var T in x)for(var M in x[T]){var b=x[T][M];l[M]=Math.max(l[M],b)}h.base={l:{val:0,size:s},r:{val:1,size:u},t:{val:1,size:f},b:{val:0,size:v}};for(var w in l){var S=0;for(var k in h)k!=="base"&&zf(h[k][w].size)&&(S=h[k][w].size>S?h[k][w].size:S);var q=Math.max(0,i[w]-S);l[w]=Math.max(0,l[w]-q)}for(var D in h){var P=h[D].l||{},z=h[D].b||{},F=P.val,I=P.size,H=z.val,O=z.size,G=t-l.r-l.l,Y=a-l.t-l.b;for(var B in h){if(zf(I)&&h[B].r){var V=h[B].r.val,J=h[B].r.size;if(V>F){var Z=(I*V+(J-G)*F)/(V-F),re=(J*(1-F)+(I-G)*(1-V))/(V-F);Z+re>s+u&&(s=Z,u=re)}}if(zf(O)&&h[B].t){var se=h[B].t.val,ve=h[B].t.size;if(se>H){var xe=(O*se+(ve-Y)*H)/(se-H),Q=(ve*(1-H)+(O-Y)*(1-se))/(se-H);xe+Q>v+f&&(v=xe,f=Q)}}}}}var we=vr.constrain(t-i.l-i.r,B7,m),fe=vr.constrain(a-i.t-i.b,Y7,y),ge=Math.max(0,t-we),_e=Math.max(0,a-fe);if(ge){var ce=(s+u)/ge;ce>1&&(s/=ce,u/=ce)}if(_e){var Me=(v+f)/_e;Me>1&&(v/=Me,f/=Me)}if(n.l=Math.round(s)+l.l,n.r=Math.round(u)+l.r,n.t=Math.round(f)+l.t,n.b=Math.round(v)+l.b,n.p=Math.round(i.pad),n.w=Math.round(t)-n.l-n.r,n.h=Math.round(a)-n.t-n.b,!r._replotting&&(Qe.didMarginChange(o,n)||YU(e))){"_redrawFromAutoMarginCount"in r?r._redrawFromAutoMarginCount++:r._redrawFromAutoMarginCount=1;var Re=3*(1+Object.keys(d).length);if(r._redrawFromAutoMarginCount1)return!0}return!1};Qe.graphJson=function(e,r=!1,t="json",a=!1,n=!1){(a&&r&&!e._fullData||a&&!r&&!e._fullLayout)&&Qe.supplyDefaults(e);var i=a?e._fullData:e.data,l=a?e._fullLayout:e.layout,o=(e._transitionData||{})._frames;function s(v,h){if(typeof v=="function")return h?"_function_":null;if(vr.isPlainObject(v)){var d={};return Object.keys(v).sort().forEach(function(x){if(["_","["].indexOf(x.charAt(0))===-1){if(typeof v[x]=="function"){h&&(d[x]="_function");return}d[x]=s(v[x],h)}}),d}var m=Array.isArray(v),y=vr.isTypedArray(v);if((m||y)&&v.dtype&&v.shape){var g=v.bdata;return s({dtype:v.dtype,shape:v.shape,bdata:vr.isArrayBuffer(g)?kU.encode(g):g},h)}return m?v.map(function(x){return s(x,h)}):y?vr.simpleMap(v,vr.identity):vr.isJSDate(v)?vr.ms2DateTimeLocal(+v):v}var u={data:(i||[]).map(function(v){var h=s(v);return r&&delete h.fit,h})};if(!r&&(u.layout=s(l),a)){var f=l._size;u.layout.computed={margin:{b:f.b,l:f.l,r:f.r,t:f.t}}}return o&&(u.frames=s(o)),n&&(u.config=s(e._context,!0)),t==="object"?u:JSON.stringify(u)};Qe.modifyFrames=function(e,r){var t,a,n,i=e._transitionData._frames,l=e._transitionData._frameHash;for(t=0;t0&&(e._transitioningWithDuration=!0),e._transitionData._interruptCallbacks.push(function(){a=!0}),t.redraw&&e._transitionData._interruptCallbacks.push(function(){return Gr.call("redraw",e)}),e._transitionData._interruptCallbacks.push(function(){e.emit("plotly_transitioninterrupted",[])});var h=0,d=0;function m(){return h++,function(){d++,!a&&d===h&&o(v)}}t.runFn(m),setTimeout(m())})}function o(v){if(e._transitionData)return i(e._transitionData._interruptCallbacks),Promise.resolve().then(function(){if(t.redraw)return Gr.call("redraw",e)}).then(function(){e._transitioning=!1,e._transitioningWithDuration=!1,e.emit("plotly_transitioned",[])}).then(v)}function s(){if(e._transitionData)return e._transitioning=!1,n(e._transitionData._interruptCallbacks)}var u=[Qe.previousPromises,s,t.prepareFn,Qe.rehover,Qe.reselect,l],f=vr.syncOrAsync(u,e);return(!f||!f.then)&&(f=Promise.resolve()),f.then(function(){return e})}Qe.doCalcdata=function(e,r){var t=on.list(e),a=e._fullData,n=e._fullLayout,i,l,o,s=new Array(a.length),u=(e.calcdata||[]).slice();for(e.calcdata=s,n._numBoxes=0,n._numViolins=0,n._violinScaleGroupStats={},e._hmpixcount=0,e._hmlumcount=0,n._piecolormap={},n._sunburstcolormap={},n._treemapcolormap={},n._iciclecolormap={},n._funnelareacolormap={},o=0;o{"use strict";Do.xmlns="http://www.w3.org/2000/xmlns/";Do.svg="http://www.w3.org/2000/svg";Do.xlink="http://www.w3.org/1999/xlink";Do.svgAttrs={xmlns:Do.svg,"xmlns:xlink":Do.xlink}});var La=X((Sue,G7)=>{"use strict";G7.exports={FROM_BL:{left:0,center:.5,right:1,bottom:0,middle:.5,top:1},FROM_TL:{left:0,center:.5,right:1,bottom:1,middle:.5,top:0},FROM_BR:{left:1,center:.5,right:0,bottom:0,middle:.5,top:1},LINE_SPACING:1.3,CAP_SHIFT:.7,MID_SHIFT:.35,OPPOSITE_SIDE:{left:"right",right:"left",top:"bottom",bottom:"top"}}});var da=X(pn=>{"use strict";var Qt=Nr(),li=Ne(),XU=li.strTranslate,km=Eo(),ZU=La().LINE_SPACING,JU=/([^$]*)([$]+[^$]*[$]+)([^$]*)/,J7=e=>e?e.match(JU):null;pn.matchTex=J7;pn.convertToTspans=function(e,r,t){var a=e.text(),n=!e.attr("data-notex")&&r&&r._context.typesetMath&&typeof MathJax!="undefined"&&J7(a),i=Qt.select(e.node().parentNode);if(i.empty())return;var l=e.attr("class")?e.attr("class").split(" ")[0]:"text";l+="-math",i.selectAll("svg."+l).remove(),i.selectAll("g."+l+"-group").remove(),e.style("display",null).attr({"data-unformatted":a,"data-math":"N"});function o(){i.empty()||(l=e.attr("class")+"-math",i.select("svg."+l).remove()),e.text("").style("white-space","pre");var s=sV(e.node(),a);s&&e.style("pointer-events","all"),pn.positionText(e),t&&t.call(e)}return n?(r&&r._promises||[]).push(new Promise(function(s){e.style("display","none");var u=parseInt(e.node().style.fontSize,10),f={fontSize:u},v=e.attr("text-anchor");jU(n[2],f,function(h,d,m){i.selectAll("svg."+l).remove(),i.selectAll("g."+l+"-group").remove();var y=h&&h.select("svg");if(!y||!y.node()){o(),s();return}e.style("display","none");var g=i.append("g").classed(l+"-group",!0).attr({"pointer-events":"none","data-unformatted":a,"data-math":"Y"});g.node().appendChild(y.node()),d&&d.node()&&y.node().insertBefore(d.node().cloneNode(!0),y.node().firstChild);var x=m.width,T=m.height;y.attr({class:l,height:T,preserveAspectRatio:"xMinYMin meet"}).style({overflow:"visible","pointer-events":"none","font-size":f.fontSize+"px"});var M=e.node().style.fill||"black",b=y.select("g");b.attr({fill:M,stroke:M});var w=b.node().getBoundingClientRect(),S=w.width,k=w.height;(S>x||k>T)&&(y.style("overflow","hidden"),w=y.node().getBoundingClientRect(),S=w.width,k=w.height);var q=+e.attr("x"),D=+e.attr("y"),P=u||e.node().getBoundingClientRect().height,z=-P/4;l[0]==="y"?g.attr({transform:"rotate("+[-90,q,D]+")"+XU(-S/2,z-k/2)}):l[0]==="l"?D=z-k/2:l[0]==="a"&&l.indexOf("atitle")!==0?(q=0,D=z):(q=q-S*(v==="middle"?.5:v==="end"?1:0),D=D+z-k/2),y.attr({x:q,y:D}),t&&t.call(e,g),s(g)})})):o(),e};var KU=/(<|<|<)/g,$U=/(>|>|>)/g;function QU(e){return e.replace(KU,"\\lt ").replace($U,"\\gt ")}var Cm=null;function jU(e,r,t){let a=parseInt((MathJax.version||"").split(".")[0]);if(a!==3&&a!==4){li.warn("Unsupported MathJax version:",MathJax.version);return}var n;let i=function(){if(!Cm){let u=MathJax._.output.svg_ts.SVG,f=li.extendFlat({},MathJax.config.svg,{fontCache:"local"});a===4&&(f.linebreaks=li.extendFlat({},f.linebreaks,{inline:!1})),Cm=MathJax._.mathjax.mathjax.document(document,li.extendFlat({},MathJax.config.options,{InputJax:MathJax.startup.input,OutputJax:new u(f)}))}let o="math-output-"+li.randstr({},64);n=Qt.select("body").append("div").attr({id:o}).style({visibility:"hidden",position:"absolute","font-size":r.fontSize+"px"});let s=QU(e).replace(/^\$+|\$+$/g,"");return MathJax._.mathjax.mathjax.handleRetriesFor(function(){return Cm.convert(s,{display:!1})}).then(function(u){n.node().appendChild(u)})},l=function(){let o=n.select(".MathJax"),s=!o.empty()&&n.select("svg").node();if(!s)li.log("There was an error in the tex syntax.",e),t();else{let u=s.getBoundingClientRect(),f=o.select("defs");t(o,f,u)}n.remove()};Promise.resolve().then(i).then(l).catch(o=>{li.log("MathJax typesetting failed.",e,o),n&&n.remove(),t()})}var K7={sup:"font-size:70%",sub:"font-size:70%",s:"text-decoration:line-through",u:"text-decoration:underline",b:"font-weight:bold",i:"font-style:italic",a:"cursor:pointer",span:"",em:"font-style:italic;font-weight:bold"},eV={sub:"0.3em",sup:"-0.6em"},rV={sub:"-0.21em",sup:"0.42em"},W7="\u200B",X7=["http:","https:","mailto:","",void 0,":"],$7=pn.NEWLINES=/(\r\n?|\n)/g,Lm=/(<[^<>]*>)/,qm=/<(\/?)([^ >]*)(\s+(.*))?>/i,tV=//i;pn.BR_TAG_ALL=//gi;var Q7=/(^|[\s"'])style\s*=\s*("([^"]*);?"|'([^']*);?')/i,j7=/(^|[\s"'])href\s*=\s*("([^"]*)"|'([^']*)')/i,e8=/(^|[\s"'])target\s*=\s*("([^"\s]*)"|'([^'\s]*)')/i,aV=/(^|[\s"'])popup\s*=\s*("([\w=,]*)"|'([\w=,]*)')/i;function Po(e,r){if(!e)return null;var t=e.match(r),a=t&&(t[3]||t[4]);return a&&Dv(a)}var nV=/(^|;)\s*color:/;pn.plainText=function(e,r){r=r||{};for(var t=r.len!==void 0&&r.len!==-1?r.len:1/0,a=r.allowedTags!==void 0?r.allowedTags:["br"],n="...",i=n.length,l=e.split(Lm),o=[],s="",u=0,f=0;fi?o.push(v.slice(0,Math.max(0,y-i))+n):o.push(v.slice(0,y));break}s=""}}return o.join("")};var iV={mu:"\u03BC",amp:"&",lt:"<",gt:">",nbsp:"\xA0",times:"\xD7",plusmn:"\xB1",deg:"\xB0"},lV=/&(#\d+|#x[\da-fA-F]+|[a-z]+);/g;function Dv(e){return e.replace(lV,function(r,t){var a;return t.charAt(0)==="#"?a=oV(t.charAt(1)==="x"?parseInt(t.slice(2),16):parseInt(t.slice(1),10)):a=iV[t],a||r})}pn.convertEntities=Dv;function oV(e){if(!(e>1114111)){var r=String.fromCodePoint;if(r)return r(e);var t=String.fromCharCode;return e<=65535?t(e):t((e>>10)+55232,e%1024+56320)}}function sV(e,r){r=r.replace($7," ");var t=!1,a=[],n,i=-1;function l(){i++;var S=document.createElementNS(km.svg,"tspan");Qt.select(S).attr({class:"line",dy:i*ZU+"em"}),e.appendChild(S),n=S;var k=a;if(a=[{node:S}],k.length>1)for(var q=1;q.",r);return}var k=a.pop();S!==k.type&&li.log("Start tag <"+k.type+"> doesnt match end tag <"+S+">. Pretending it did match.",r),n=a[a.length-1].node}var f=tV.test(r);f?l():(n=e,a=[{node:e}]);for(var v=r.split(Lm),h=0;h{"use strict";var uV=Nr(),Ff=Br(),Ev=Ne(),iu=Er(),fV=wo().isValid;function cV(e,r,t){var a=r?Ev.nestedProperty(e,r).get()||{}:e,n=a[t||"color"];n&&n._inputArray&&(n=n._inputArray);var i=!1;if(Ev.isArrayOrTypedArray(n)){for(var l=0;l=0;a--,n++){var i=e[a];t[n]=[1-i[0],i[1]]}return t}function o8(e,r){r=r||{};for(var t=e.domain,a=e.range,n=a.length,i=new Array(n),l=0;l{"use strict";var u8=Wp(),hV=u8.FORMAT_LINK,dV=u8.DATE_FORMAT_LINK;function pV(e,r){return{valType:"string",dflt:"",editType:"none",description:(r?Dm:f8)("hover text",e)+["By default the values are formatted using "+(r?"generic number format":"`"+e+"axis.hoverformat`")+"."].join(" ")}}function Dm(e,r){return["Sets the "+e+" formatting rule"+(r?" for `"+r+"`":""),"using d3 formatting mini-languages","which are very similar to those in Python. For numbers, see: "+hV+"."].join(" ")}function f8(e,r){return Dm(e,r)+[" And for dates see: "+dV+".","We add two items to d3's date formatter:","*%h* for half of the year as a decimal number as well as","*%{n}f* for fractional seconds","with n digits. For example, *2016-10-13 09:15:23.456* with tickformat","*%H~%M~%S.%2f* would display *09~15~23.46*"].join(" ")}c8.exports={axisHoverFormat:pV,descriptionOnlyNumbers:Dm,descriptionWithDates:f8}});var oi=X((Pue,S8)=>{"use strict";var v8=Ta(),lu=ki(),C8=Si().dash,Pm=Jt().extendFlat,h8=vt().templatedArray,Eue=To().templateFormatStringDescription,d8=Pv().descriptionWithDates,mV=Zt().ONEDAY,qi=ta(),yV=qi.HOUR_PATTERN,gV=qi.WEEKDAY_PATTERN,Em={valType:"enumerated",values:["auto","linear","array"],editType:"ticks",impliedEdits:{tick0:void 0,dtick:void 0}},bV=Pm({},Em,{values:Em.values.slice().concat(["sync"])});function p8(e){return{valType:"integer",min:0,dflt:e?5:0,editType:"ticks"}}var m8={valType:"any",editType:"ticks",impliedEdits:{tickmode:"linear"}},y8={valType:"any",editType:"ticks",impliedEdits:{tickmode:"linear"}},g8={valType:"data_array",editType:"ticks"},b8={valType:"enumerated",values:["outside","inside",""],editType:"ticks"};function _8(e){var r={valType:"number",min:0,editType:"ticks"};return e||(r.dflt=5),r}function x8(e){var r={valType:"number",min:0,editType:"ticks"};return e||(r.dflt=1),r}var w8={valType:"color",dflt:lu.defaultLine,editType:"ticks"},T8={valType:"color",dflt:lu.lightLine,editType:"ticks"};function M8(e){var r={valType:"number",min:0,editType:"ticks"};return e||(r.dflt=1),r}var A8=Pm({},C8,{editType:"ticks"}),k8={valType:"boolean",editType:"ticks"};S8.exports={visible:{valType:"boolean",editType:"plot"},color:{valType:"color",dflt:lu.defaultLine,editType:"ticks"},title:{text:{valType:"string",editType:"ticks"},font:v8({editType:"ticks",description:"Sets this axis' title font."}),standoff:{valType:"number",min:0,editType:"ticks"},editType:"ticks"},type:{valType:"enumerated",values:["-","linear","log","date","category","multicategory"],dflt:"-",editType:"calc",_noTemplating:!0},autotypenumbers:{valType:"enumerated",values:["convert types","strict"],dflt:"convert types",editType:"calc"},autorange:{valType:"enumerated",values:[!0,!1,"reversed","min reversed","max reversed","min","max"],dflt:!0,editType:"axrange",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},autorangeoptions:{minallowed:{valType:"any",editType:"plot",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},maxallowed:{valType:"any",editType:"plot",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},clipmin:{valType:"any",editType:"plot",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},clipmax:{valType:"any",editType:"plot",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},include:{valType:"any",arrayOk:!0,editType:"plot",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},editType:"plot"},rangemode:{valType:"enumerated",values:["normal","tozero","nonnegative"],dflt:"normal",editType:"plot"},range:{valType:"info_array",items:[{valType:"any",editType:"axrange",impliedEdits:{"^autorange":!1},anim:!0},{valType:"any",editType:"axrange",impliedEdits:{"^autorange":!1},anim:!0}],editType:"axrange",impliedEdits:{autorange:!1},anim:!0},minallowed:{valType:"any",editType:"plot",impliedEdits:{"^autorange":!1}},maxallowed:{valType:"any",editType:"plot",impliedEdits:{"^autorange":!1}},fixedrange:{valType:"boolean",dflt:!1,editType:"calc"},modebardisable:{valType:"flaglist",flags:["autoscale","zoominout"],extras:["none"],dflt:"none",editType:"modebar"},insiderange:{valType:"info_array",items:[{valType:"any",editType:"plot"},{valType:"any",editType:"plot"}],editType:"plot"},scaleanchor:{valType:"enumerated",values:[qi.idRegex.x.toString(),qi.idRegex.y.toString(),!1],editType:"plot"},scaleratio:{valType:"number",min:0,dflt:1,editType:"plot"},constrain:{valType:"enumerated",values:["range","domain"],editType:"plot"},constraintoward:{valType:"enumerated",values:["left","center","right","top","middle","bottom"],editType:"plot"},matches:{valType:"enumerated",values:[qi.idRegex.x.toString(),qi.idRegex.y.toString()],editType:"calc"},rangebreaks:h8("rangebreak",{enabled:{valType:"boolean",dflt:!0,editType:"calc"},bounds:{valType:"info_array",items:[{valType:"any",editType:"calc"},{valType:"any",editType:"calc"}],editType:"calc"},pattern:{valType:"enumerated",values:[gV,yV,""],editType:"calc"},values:{valType:"info_array",freeLength:!0,editType:"calc",items:{valType:"any",editType:"calc"}},dvalue:{valType:"number",editType:"calc",min:0,dflt:mV},editType:"calc"}),tickmode:bV,nticks:p8(),tick0:m8,dtick:y8,ticklabelstep:{valType:"integer",min:1,dflt:1,editType:"ticks"},tickvals:g8,ticktext:{valType:"data_array",editType:"ticks"},ticks:b8,tickson:{valType:"enumerated",values:["labels","boundaries"],dflt:"labels",editType:"ticks"},ticklabelmode:{valType:"enumerated",values:["instant","period"],dflt:"instant",editType:"ticks"},ticklabelposition:{valType:"enumerated",values:["outside","inside","outside top","inside top","outside left","inside left","outside right","inside right","outside bottom","inside bottom"],dflt:"outside",editType:"calc"},ticklabeloverflow:{valType:"enumerated",values:["allow","hide past div","hide past domain"],editType:"calc"},ticklabelshift:{valType:"integer",dflt:0,editType:"ticks"},ticklabelstandoff:{valType:"integer",dflt:0,editType:"ticks"},ticklabelindex:{valType:"integer",arrayOk:!0,editType:"calc"},mirror:{valType:"enumerated",values:[!0,"ticks",!1,"all","allticks"],dflt:!1,editType:"ticks+layoutstyle"},ticklen:_8(),tickwidth:x8(),tickcolor:w8,showticklabels:{valType:"boolean",dflt:!0,editType:"ticks"},labelalias:{valType:"any",dflt:!1,editType:"ticks"},automargin:{valType:"flaglist",flags:["height","width","left","right","top","bottom"],extras:[!0,!1],dflt:!1,editType:"ticks"},showspikes:{valType:"boolean",dflt:!1,editType:"modebar"},spikecolor:{valType:"color",dflt:null,editType:"none"},spikethickness:{valType:"number",dflt:3,editType:"none"},spikedash:Pm({},C8,{dflt:"dash",editType:"none"}),spikemode:{valType:"flaglist",flags:["toaxis","across","marker"],dflt:"toaxis",editType:"none"},spikesnap:{valType:"enumerated",values:["data","cursor","hovered data"],dflt:"hovered data",editType:"none"},tickfont:v8({editType:"ticks"}),tickangle:{valType:"angle",dflt:"auto",editType:"ticks"},autotickangles:{valType:"info_array",freeLength:!0,items:{valType:"angle"},dflt:[0,30,90],editType:"ticks"},tickprefix:{valType:"string",dflt:"",editType:"ticks"},showtickprefix:{valType:"enumerated",values:["all","first","last","none"],dflt:"all",editType:"ticks"},ticksuffix:{valType:"string",dflt:"",editType:"ticks"},showticksuffix:{valType:"enumerated",values:["all","first","last","none"],dflt:"all",editType:"ticks"},showexponent:{valType:"enumerated",values:["all","first","last","none"],dflt:"all",editType:"ticks"},exponentformat:{valType:"enumerated",values:["none","e","E","power","SI","B","SI extended"],dflt:"B",editType:"ticks"},minexponent:{valType:"number",dflt:3,min:0,editType:"ticks"},separatethousands:{valType:"boolean",dflt:!1,editType:"ticks"},tickformat:{valType:"string",dflt:"",editType:"ticks",description:d8("tick label")},tickformatstops:h8("tickformatstop",{enabled:{valType:"boolean",dflt:!0,editType:"ticks"},dtickrange:{valType:"info_array",items:[{valType:"any",editType:"ticks"},{valType:"any",editType:"ticks"}],editType:"ticks"},value:{valType:"string",dflt:"",editType:"ticks"},editType:"ticks"}),hoverformat:{valType:"string",dflt:"",editType:"none",description:d8("hover text")},unifiedhovertitle:{text:{valType:"string",dflt:"",editType:"none"},editType:"none"},showline:{valType:"boolean",dflt:!1,editType:"ticks+layoutstyle"},linecolor:{valType:"color",dflt:lu.defaultLine,editType:"layoutstyle"},linewidth:{valType:"number",min:0,dflt:1,editType:"ticks+layoutstyle"},showgrid:k8,gridcolor:T8,gridwidth:M8(),griddash:A8,zeroline:{valType:"boolean",editType:"ticks"},zerolinecolor:{valType:"color",dflt:lu.defaultLine,editType:"ticks"},zerolinelayer:{valType:"enumerated",values:["above traces","below traces"],dflt:"below traces",editType:"plot"},zerolinewidth:{valType:"number",dflt:1,editType:"ticks"},showdividers:{valType:"boolean",dflt:!0,editType:"ticks"},dividercolor:{valType:"color",dflt:lu.defaultLine,editType:"ticks"},dividerwidth:{valType:"number",dflt:1,editType:"ticks"},anchor:{valType:"enumerated",values:["free",qi.idRegex.x.toString(),qi.idRegex.y.toString()],editType:"plot"},side:{valType:"enumerated",values:["top","bottom","left","right"],editType:"plot"},overlaying:{valType:"enumerated",values:["free",qi.idRegex.x.toString(),qi.idRegex.y.toString()],editType:"plot"},minor:{tickmode:Em,nticks:p8("minor"),tick0:m8,dtick:y8,tickvals:g8,ticks:b8,ticklen:_8("minor"),tickwidth:x8("minor"),tickcolor:w8,gridcolor:T8,gridwidth:M8("minor"),griddash:A8,showgrid:k8,editType:"ticks"},minorloglabels:{valType:"enumerated",values:["small digits","complete","none"],dflt:"small digits",editType:"calc"},layer:{valType:"enumerated",values:["above traces","below traces"],dflt:"above traces",editType:"plot"},domain:{valType:"info_array",items:[{valType:"number",min:0,max:1,editType:"plot"},{valType:"number",min:0,max:1,editType:"plot"}],dflt:[0,1],editType:"plot"},position:{valType:"number",min:0,max:1,dflt:0,editType:"plot"},autoshift:{valType:"boolean",dflt:!1,editType:"plot"},shift:{valType:"number",editType:"plot"},categoryorder:{valType:"enumerated",values:["trace","category ascending","category descending","array","total ascending","total descending","min ascending","min descending","max ascending","max descending","sum ascending","sum descending","mean ascending","mean descending","geometric mean ascending","geometric mean descending","median ascending","median descending"],dflt:"trace",editType:"calc"},categoryarray:{valType:"data_array",editType:"calc"},uirevision:{valType:"any",editType:"none"},editType:"calc"}});var Rv=X((Rue,D8)=>{"use strict";var pt=oi(),L8=Ta(),q8=Jt().extendFlat,_V=Ci().overrideAll;D8.exports=_V({orientation:{valType:"enumerated",values:["h","v"],dflt:"v"},thicknessmode:{valType:"enumerated",values:["fraction","pixels"],dflt:"pixels"},thickness:{valType:"number",min:0,dflt:30},lenmode:{valType:"enumerated",values:["fraction","pixels"],dflt:"fraction"},len:{valType:"number",min:0,dflt:1},x:{valType:"number"},xref:{valType:"enumerated",dflt:"paper",values:["container","paper"],editType:"layoutstyle"},xanchor:{valType:"enumerated",values:["left","center","right"]},xpad:{valType:"number",min:0,dflt:10},y:{valType:"number"},yref:{valType:"enumerated",dflt:"paper",values:["container","paper"],editType:"layoutstyle"},yanchor:{valType:"enumerated",values:["top","middle","bottom"]},ypad:{valType:"number",min:0,dflt:10},outlinecolor:pt.linecolor,outlinewidth:pt.linewidth,bordercolor:pt.linecolor,borderwidth:{valType:"number",min:0,dflt:0},bgcolor:{valType:"color",dflt:"rgba(0,0,0,0)"},tickmode:pt.minor.tickmode,nticks:pt.nticks,tick0:pt.tick0,dtick:pt.dtick,tickvals:pt.tickvals,ticktext:pt.ticktext,ticks:q8({},pt.ticks,{dflt:""}),ticklabeloverflow:q8({},pt.ticklabeloverflow,{}),ticklabelposition:{valType:"enumerated",values:["outside","inside","outside top","inside top","outside left","inside left","outside right","inside right","outside bottom","inside bottom"],dflt:"outside"},ticklen:pt.ticklen,tickwidth:pt.tickwidth,tickcolor:pt.tickcolor,ticklabelstep:pt.ticklabelstep,showticklabels:pt.showticklabels,labelalias:pt.labelalias,tickfont:L8({description:"Sets the color bar's tick label font"}),tickangle:pt.tickangle,tickformat:pt.tickformat,tickformatstops:pt.tickformatstops,tickprefix:pt.tickprefix,showtickprefix:pt.showtickprefix,ticksuffix:pt.ticksuffix,showticksuffix:pt.showticksuffix,separatethousands:pt.separatethousands,exponentformat:pt.exponentformat,minexponent:pt.minexponent,showexponent:pt.showexponent,title:{text:{valType:"string"},font:L8({description:"Sets this color bar's title font."}),side:{valType:"enumerated",values:["right","top","bottom"]}}},"colorbars","from-root")});var If=X((Nue,P8)=>{"use strict";var xV=Rv(),wV=(Us(),Ia(Ys)).counter,TV=(rm(),Ia(em)).default,E8=wo().scales,zue=TV(E8);function zv(e){return"`"+e+"`"}P8.exports=function(r,t){r=r||"",t=t||{};var a=t.cLetter||"c",n="onlyIfNumerical"in t?t.onlyIfNumerical:!!r,i="noScale"in t?t.noScale:r==="marker.line",l="showScaleDflt"in t?t.showScaleDflt:a==="z",o=typeof t.colorscaleDflt=="string"?E8[t.colorscaleDflt]:null,s=t.editTypeOverride||"",u=r?r+".":"",f,v;"colorAttr"in t?(f=t.colorAttr,v=t.colorAttr):(f={z:"z",c:"color"}[a],v="in "+zv(u+f));var h=n?" Has an effect only if "+v+" is set to a numerical array.":"",d=a+"auto",m=a+"min",y=a+"max",g=a+"mid",x=zv(u+d),T=zv(u+m),M=zv(u+y),b=T+" and "+M,w={};w[m]=w[y]=void 0;var S={};S[d]=!1;var k={};return f==="color"&&(k.color={valType:"color",arrayOk:!0,editType:s||"style"},t.anim&&(k.color.anim=!0)),k[d]={valType:"boolean",dflt:!0,editType:"calc",impliedEdits:w},k[m]={valType:"number",dflt:null,editType:s||"plot",impliedEdits:S},k[y]={valType:"number",dflt:null,editType:s||"plot",impliedEdits:S},k[g]={valType:"number",dflt:null,editType:"calc",impliedEdits:w},k.colorscale={valType:"colorscale",editType:"calc",dflt:o,impliedEdits:{autocolorscale:!1}},k.autocolorscale={valType:"boolean",dflt:t.autoColorDflt!==!1,editType:"calc",impliedEdits:{colorscale:void 0}},k.reversescale={valType:"boolean",dflt:!1,editType:"plot"},i||(k.showscale={valType:"boolean",dflt:l,editType:"calc"},k.colorbar=xV),t.noColorAxis||(k.coloraxis={valType:"subplotid",regex:wV("coloraxis"),dflt:null,editType:"calc"}),k}});var zm=X((Fue,R8)=>{"use strict";var MV=Jt().extendFlat,AV=If(),Rm=wo().scales;R8.exports={editType:"calc",colorscale:{editType:"calc",sequential:{valType:"colorscale",dflt:Rm.Reds,editType:"calc"},sequentialminus:{valType:"colorscale",dflt:Rm.Blues,editType:"calc"},diverging:{valType:"colorscale",dflt:Rm.RdBu,editType:"calc"}},coloraxis:MV({_isSubplotObj:!0,editType:"calc"},AV("",{colorAttr:"corresponding trace color array(s)",noColorAxis:!0,showScaleDflt:!0}))}});var Nm=X((Iue,z8)=>{"use strict";var kV=Ne();z8.exports=function(r){return kV.isPlainObject(r.colorbar)}});var Hm=X(Im=>{"use strict";var Fm=Br(),N8=Ne(),F8=Zt(),CV=F8.ONEDAY,SV=F8.ONEWEEK;Im.dtick=function(e,r){var t=r==="log",a=r==="date",n=r==="category",i=a?CV:1;if(!e)return i;if(Fm(e))return e=Number(e),e<=0?i:n?Math.max(1,Math.round(e)):a?Math.max(.1,e):e;if(typeof e!="string"||!(a||t))return i;var l=e.charAt(0),o=e.slice(1);return o=Fm(o)?Number(o):0,o<=0||!(a&&l==="M"&&o===Math.round(o)||t&&l==="L"||t&&l==="D"&&(o===1||o===2))?i:e};Im.tick0=function(e,r,t,a){if(r==="date")return N8.cleanDate(e,N8.dateTick0(t,a%SV===0?1:0));if(!(a==="D1"||a==="D2"))return Fm(e)?Number(e):0}});var Om=X((Oue,H8)=>{"use strict";var I8=Hm(),LV=Ne().isArrayOrTypedArray,qV=hn().isTypedArraySpec,DV=hn().decodeTypedArraySpec;H8.exports=function(r,t,a,n,i={}){var l=i.isMinor,o=l?r.minor||{}:r,s=l?t.minor:t,u=l?"minor.":"";function f(b){var w=o[b];return qV(w)&&(w=DV(w)),w!==void 0?w:(s._template||{})[b]}var v=f("tick0"),h=f("dtick"),d=f("tickvals"),m=f("overlaying"),y=n==="category"||n==="multicategory",g;LV(d)?g="array":h?g="linear":m&&!y?g="sync":g="auto";var x=a(u+"tickmode",g);if(x==="auto"||x==="sync")a(u+"nticks");else if(x==="linear"){var T=s.dtick=I8.dtick(h,n);s.tick0=I8.tick0(v,n,t.calendar,T)}else if(n!=="multicategory"){var M=a(u+"tickvals");M===void 0?s.tickmode="auto":l||a("ticktext")}}});var Ym=X((Bue,B8)=>{"use strict";var Bm=Ne(),O8=oi();B8.exports=function(r,t,a,n){var i=n.isMinor,l=i?r.minor||{}:r,o=i?t.minor:t,s=i?O8.minor:O8,u=i?"minor.":"",f=Bm.coerce2(l,o,s,"ticklen",i?(t.ticklen||5)*.6:void 0),v=Bm.coerce2(l,o,s,"tickwidth",i?t.tickwidth||1:void 0),h=Bm.coerce2(l,o,s,"tickcolor",(i?t.tickcolor:void 0)||o.color),d=a(u+"ticks",!i&&n.outerTicks||f||v||h?"outside":"");d||(delete o.ticklen,delete o.tickwidth,delete o.tickcolor)}});var Um=X((Yue,Y8)=>{"use strict";Y8.exports=function(r){var t=["showexponent","showtickprefix","showticksuffix"],a=t.filter(function(i){return r[i]!==void 0}),n=function(i){return r[i]===r[a[0]]};if(a.every(n)||a.length===1)return r[a[0]]}});var si=X((Uue,U8)=>{"use strict";var Nv=Ne(),EV=vt();U8.exports=function(r,t,a){var n=a.name,i=a.inclusionAttr||"visible",l=t[n],o=Nv.isArrayOrTypedArray(r[n])?r[n]:[],s=t[n]=[],u=EV.arrayTemplater(t,n,i),f,v;for(f=0;f{"use strict";var Vm=Ne(),PV=Er().contrast,V8=oi(),RV=Um(),zV=si();G8.exports=function(r,t,a,n,i){i||(i={});var l=a("labelalias");Vm.isPlainObject(l)||delete t.labelalias;var o=RV(r),s=a("showticklabels");if(s){i.noTicklabelshift||a("ticklabelshift"),i.noTicklabelstandoff||a("ticklabelstandoff");var u=i.font||{},f=t.color,v=t.ticklabelposition||"",h=v.indexOf("inside")!==-1?PV(i.bgColor):f&&f!==V8.color.dflt?f:u.color;if(Vm.coerceFont(a,"tickfont",u,{overrideDflt:{color:h}}),!i.noTicklabelstep&&n!=="multicategory"&&n!=="log"&&a("ticklabelstep"),!i.noAng){var d=a("tickangle");!i.noAutotickangles&&d==="auto"&&a("autotickangles")}if(n!=="category"){var m=a("tickformat");zV(r,t,{name:"tickformatstops",inclusionAttr:"enabled",handleItemDefaults:NV}),t.tickformatstops.length||delete t.tickformatstops,!i.noExp&&!m&&n!=="date"&&(a("showexponent",o),a("exponentformat"),a("minexponent"),a("separatethousands"))}!i.noMinorloglabels&&n==="log"&&a("minorloglabels")}};function NV(e,r){function t(n,i){return Vm.coerce(e,r,V8.tickformatstops,n,i)}var a=t("enabled");a&&(t("dtickrange"),t("value"))}});var Wm=X((Gue,W8)=>{"use strict";var FV=Um();W8.exports=function(r,t,a,n,i){i||(i={});var l=i.tickSuffixDflt,o=FV(r),s=a("tickprefix");s&&a("showtickprefix",o);var u=a("ticksuffix",l);u&&a("showticksuffix",o)}});var Xm=X((Wue,X8)=>{"use strict";var El=Ne(),IV=vt(),HV=Om(),OV=Ym(),BV=Gm(),YV=Wm(),UV=Rv();X8.exports=function(r,t,a){var n=IV.newContainer(t,"colorbar"),i=r.colorbar||{};function l(z,F){return El.coerce(i,n,UV,z,F)}var o=a.margin||{t:0,b:0,l:0,r:0},s=a.width-o.l-o.r,u=a.height-o.t-o.b,f=l("orientation"),v=f==="v",h=l("thicknessmode");l("thickness",h==="fraction"?30/(v?s:u):30);var d=l("lenmode");l("len",d==="fraction"?1:v?u:s);var m=l("yref"),y=l("xref"),g=m==="paper",x=y==="paper",T,M,b,w="left";v?(b="middle",w=x?"left":"right",T=x?1.02:1,M=.5):(b=g?"bottom":"top",w="center",T=.5,M=g?1.02:1),El.coerce(i,n,{x:{valType:"number",min:x?-2:0,max:x?3:1,dflt:T}},"x"),El.coerce(i,n,{y:{valType:"number",min:g?-2:0,max:g?3:1,dflt:M}},"y"),l("xanchor",w),l("xpad"),l("yanchor",b),l("ypad"),El.noneOrAll(i,n,["x","y"]),l("outlinecolor"),l("outlinewidth"),l("bordercolor"),l("borderwidth"),l("bgcolor");var S=El.coerce(i,n,{ticklabelposition:{valType:"enumerated",dflt:"outside",values:v?["outside","inside","outside top","inside top","outside bottom","inside bottom"]:["outside","inside","outside left","inside left","outside right","inside right"]}},"ticklabelposition");l("ticklabeloverflow",S.indexOf("inside")!==-1?"hide past domain":"hide past div"),HV(i,n,l,"linear");var k=a.font,q={noAutotickangles:!0,noTicklabelshift:!0,noTicklabelstandoff:!0,outerTicks:!1,font:k};S.indexOf("inside")!==-1&&(q.bgColor="black"),YV(i,n,l,"linear",q),BV(i,n,l,"linear",q),OV(i,n,l,"linear",q),l("title.text",a._dfltTitle.colorbar);var D=n.showticklabels?n.tickfont:k,P=El.extendFlat({},k,{family:D.family,size:El.bigFont(D.size)});El.coerceFont(l,"title.font",P),l("title.side",v?"top":"right")}});var ou=X((Xue,K8)=>{"use strict";var Z8=Br(),Jm=Ne(),VV=Nm(),GV=Xm(),J8=wo().isValid,WV=_r().traceIs;function Zm(e,r){var t=r.slice(0,r.length-1);return r?Jm.nestedProperty(e,t).get()||{}:e}K8.exports=function e(r,t,a,n,i){var l=i.prefix,o=i.cLetter,s="_module"in t,u=Zm(r,l),f=Zm(t,l),v=Zm(t._template||{},l)||{},h=function(){return delete r.coloraxis,delete t.coloraxis,e(r,t,a,n,i)};if(s){var d=a._colorAxes||{},m=n(l+"coloraxis");if(m){var y=WV(t,"contour")&&Jm.nestedProperty(t,"contours.coloring").get()||"heatmap",g=d[m];g?(g[2].push(h),g[0]!==y&&(g[0]=!1,Jm.warn(["Ignoring coloraxis:",m,"setting","as it is linked to incompatible colorscales."].join(" ")))):d[m]=[y,t,[h]];return}}var x=u[o+"min"],T=u[o+"max"],M=Z8(x)&&Z8(T)&&x{"use strict";var $8=Ne(),XV=vt(),Q8=zm(),ZV=ou();j8.exports=function(r,t){function a(v,h){return $8.coerce(r,t,Q8,v,h)}a("colorscale.sequential"),a("colorscale.sequentialminus"),a("colorscale.diverging");var n=t._colorAxes,i,l;function o(v,h){return $8.coerce(i,l,Q8.coloraxis,v,h)}for(var s in n){var u=n[s];if(u[0])i=r[s]||{},l=XV.newContainer(t,s,"coloraxis"),l._name=s,ZV(i,l,t,o,{prefix:"",cLetter:"c"});else{for(var f=0;f{"use strict";var JV=Ne(),KV=En().hasColorscale,$V=En().extractOpts;r9.exports=function(r,t){function a(f,v){var h=f["_"+v];h!==void 0&&(f[v]=h)}function n(f,v){var h=v.container?JV.nestedProperty(f,v.container).get():f;if(h)if(h.coloraxis)h._colorAx=t[h.coloraxis];else{var d=$V(h),m=d.auto;(m||d.min===void 0)&&a(h,v.min),(m||d.max===void 0)&&a(h,v.max),d.autocolorscale&&a(h,"colorscale")}}for(var i=0;i{"use strict";var a9=Br(),Km=Ne(),QV=En().extractOpts;n9.exports=function(r,t,a){var n=r._fullLayout,i=a.vals,l=a.containerStr,o=l?Km.nestedProperty(t,l).get():t,s=QV(o),u=s.auto!==!1,f=s.min,v=s.max,h=s.mid,d=function(){return Km.aggNums(Math.min,null,i)},m=function(){return Km.aggNums(Math.max,null,i)};if(f===void 0?f=d():u&&(o._colorAx&&a9(f)?f=Math.min(f,d()):f=d()),v===void 0?v=m():u&&(o._colorAx&&a9(v)?v=Math.max(v,m()):v=m()),u&&h!==void 0&&(v-h>h-f?f=h-(v-h):v-h=0?y=n.colorscale.sequential:y=n.colorscale.sequentialminus,s._sync("colorscale",y)}}});var $m=X(($ue,i9)=>{"use strict";var Iv=wo(),su=En();i9.exports={moduleType:"component",name:"colorscale",attributes:If(),layoutAttributes:zm(),supplyLayoutDefaults:e9(),handleDefaults:ou(),crossTraceDefaults:t9(),calc:Fv(),scales:Iv.scales,defaultScale:Iv.defaultScale,getScale:Iv.get,isValidScale:Iv.isValid,hasColorscale:su.hasColorscale,extractOpts:su.extractOpts,extractScale:su.extractScale,flipScale:su.flipScale,makeColorScaleFunc:su.makeColorScaleFunc,makeColorScaleFuncFromTrace:su.makeColorScaleFuncFromTrace}});var mn=X((Que,o9)=>{"use strict";var l9=Ne(),jV=hn().isTypedArraySpec;o9.exports={hasLines:function(e){return e.visible&&e.mode&&e.mode.indexOf("lines")!==-1},hasMarkers:function(e){return e.visible&&(e.mode&&e.mode.indexOf("markers")!==-1||e.type==="splom")},hasText:function(e){return e.visible&&e.mode&&e.mode.indexOf("text")!==-1},isBubble:function(e){var r=e.marker;return l9.isPlainObject(r)&&(l9.isArrayOrTypedArray(r.size)||jV(r.size))}}});var u9=X((jue,s9)=>{"use strict";var eG=Br();s9.exports=function(r,t){t||(t=2);var a=r.marker,n=a.sizeref||1,i=a.sizemin||0,l=a.sizemode==="area"?function(o){return Math.sqrt(o/n)}:function(o){return o/n};return function(o){var s=l(o/t);return eG(s)&&s>0?Math.max(s,i):0}}});var Di=X(qa=>{"use strict";var Hv=Ne();qa.getSubplot=function(e){return e.subplot||e.xaxis+e.yaxis||e.geo};qa.isTraceInSubplots=function(e,r){if(e.type==="splom"){for(var t=e.xaxes||[],a=e.yaxes||[],n=0;n=0&&t.index{v9.exports=iG;var Qm={a:7,c:6,h:1,l:2,m:2,q:4,s:4,t:2,v:1,z:0},nG=/([astvzqmhlc])([^astvzqmhlc]*)/ig;function iG(e){var r=[];return e.replace(nG,function(t,a,n){var i=a.toLowerCase();for(n=oG(n),i=="m"&&n.length>2&&(r.push([a].concat(n.splice(0,2))),i="l",a=a=="m"?"l":"L");;){if(n.length==Qm[i])return n.unshift(a),r.push(n);if(n.length{"use strict";var sG=jm(),Xe=function(e,r){return r?Math.round(e*(r=Math.pow(10,r)))/r:Math.round(e)},kr="M0,0Z",h9=Math.sqrt(2),Pl=Math.sqrt(3),ey=Math.PI,ry=Math.cos,ty=Math.sin;g9.exports={circle:{n:0,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2),n="M"+a+",0A"+a+","+a+" 0 1,1 0,-"+a+"A"+a+","+a+" 0 0,1 "+a+",0Z";return t?Sr(r,t,n):n}},square:{n:1,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2);return Sr(r,t,"M"+a+","+a+"H-"+a+"V-"+a+"H"+a+"Z")}},diamond:{n:2,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.3,2);return Sr(r,t,"M"+a+",0L0,"+a+"L-"+a+",0L0,-"+a+"Z")}},cross:{n:3,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*.4,2),n=Xe(e*1.2,2);return Sr(r,t,"M"+n+","+a+"H"+a+"V"+n+"H-"+a+"V"+a+"H-"+n+"V-"+a+"H-"+a+"V-"+n+"H"+a+"V-"+a+"H"+n+"Z")}},x:{n:4,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*.8/h9,2),n="l"+a+","+a,i="l"+a+",-"+a,l="l-"+a+",-"+a,o="l-"+a+","+a;return Sr(r,t,"M0,"+a+n+i+l+i+l+o+l+o+n+o+n+"Z")}},"triangle-up":{n:5,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*2/Pl,2),n=Xe(e/2,2),i=Xe(e,2);return Sr(r,t,"M-"+a+","+n+"H"+a+"L0,-"+i+"Z")}},"triangle-down":{n:6,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*2/Pl,2),n=Xe(e/2,2),i=Xe(e,2);return Sr(r,t,"M-"+a+",-"+n+"H"+a+"L0,"+i+"Z")}},"triangle-left":{n:7,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*2/Pl,2),n=Xe(e/2,2),i=Xe(e,2);return Sr(r,t,"M"+n+",-"+a+"V"+a+"L-"+i+",0Z")}},"triangle-right":{n:8,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*2/Pl,2),n=Xe(e/2,2),i=Xe(e,2);return Sr(r,t,"M-"+n+",-"+a+"V"+a+"L"+i+",0Z")}},"triangle-ne":{n:9,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*.6,2),n=Xe(e*1.2,2);return Sr(r,t,"M-"+n+",-"+a+"H"+a+"V"+n+"Z")}},"triangle-se":{n:10,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*.6,2),n=Xe(e*1.2,2);return Sr(r,t,"M"+a+",-"+n+"V"+a+"H-"+n+"Z")}},"triangle-sw":{n:11,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*.6,2),n=Xe(e*1.2,2);return Sr(r,t,"M"+n+","+a+"H-"+a+"V-"+n+"Z")}},"triangle-nw":{n:12,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*.6,2),n=Xe(e*1.2,2);return Sr(r,t,"M-"+a+","+n+"V-"+a+"H"+n+"Z")}},pentagon:{n:13,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*.951,2),n=Xe(e*.588,2),i=Xe(-e,2),l=Xe(e*-.309,2),o=Xe(e*.809,2);return Sr(r,t,"M"+a+","+l+"L"+n+","+o+"H-"+n+"L-"+a+","+l+"L0,"+i+"Z")}},hexagon:{n:14,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2),n=Xe(e/2,2),i=Xe(e*Pl/2,2);return Sr(r,t,"M"+i+",-"+n+"V"+n+"L0,"+a+"L-"+i+","+n+"V-"+n+"L0,-"+a+"Z")}},hexagon2:{n:15,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2),n=Xe(e/2,2),i=Xe(e*Pl/2,2);return Sr(r,t,"M-"+n+","+i+"H"+n+"L"+a+",0L"+n+",-"+i+"H-"+n+"L-"+a+",0Z")}},octagon:{n:16,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*.924,2),n=Xe(e*.383,2);return Sr(r,t,"M-"+n+",-"+a+"H"+n+"L"+a+",-"+n+"V"+n+"L"+n+","+a+"H-"+n+"L-"+a+","+n+"V-"+n+"Z")}},star:{n:17,f:function(e,r,t){if(Cr(r))return kr;var a=e*1.4,n=Xe(a*.225,2),i=Xe(a*.951,2),l=Xe(a*.363,2),o=Xe(a*.588,2),s=Xe(-a,2),u=Xe(a*-.309,2),f=Xe(a*.118,2),v=Xe(a*.809,2),h=Xe(a*.382,2);return Sr(r,t,"M"+n+","+u+"H"+i+"L"+l+","+f+"L"+o+","+v+"L0,"+h+"L-"+o+","+v+"L-"+l+","+f+"L-"+i+","+u+"H-"+n+"L0,"+s+"Z")}},hexagram:{n:18,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*.66,2),n=Xe(e*.38,2),i=Xe(e*.76,2);return Sr(r,t,"M-"+i+",0l-"+n+",-"+a+"h"+i+"l"+n+",-"+a+"l"+n+","+a+"h"+i+"l-"+n+","+a+"l"+n+","+a+"h-"+i+"l-"+n+","+a+"l-"+n+",-"+a+"h-"+i+"Z")}},"star-triangle-up":{n:19,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*Pl*.8,2),n=Xe(e*.8,2),i=Xe(e*1.6,2),l=Xe(e*4,2),o="A "+l+","+l+" 0 0 1 ";return Sr(r,t,"M-"+a+","+n+o+a+","+n+o+"0,-"+i+o+"-"+a+","+n+"Z")}},"star-triangle-down":{n:20,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*Pl*.8,2),n=Xe(e*.8,2),i=Xe(e*1.6,2),l=Xe(e*4,2),o="A "+l+","+l+" 0 0 1 ";return Sr(r,t,"M"+a+",-"+n+o+"-"+a+",-"+n+o+"0,"+i+o+a+",-"+n+"Z")}},"star-square":{n:21,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.1,2),n=Xe(e*2,2),i="A "+n+","+n+" 0 0 1 ";return Sr(r,t,"M-"+a+",-"+a+i+"-"+a+","+a+i+a+","+a+i+a+",-"+a+i+"-"+a+",-"+a+"Z")}},"star-diamond":{n:22,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.4,2),n=Xe(e*1.9,2),i="A "+n+","+n+" 0 0 1 ";return Sr(r,t,"M-"+a+",0"+i+"0,"+a+i+a+",0"+i+"0,-"+a+i+"-"+a+",0Z")}},"diamond-tall":{n:23,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*.7,2),n=Xe(e*1.4,2);return Sr(r,t,"M0,"+n+"L"+a+",0L0,-"+n+"L-"+a+",0Z")}},"diamond-wide":{n:24,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.4,2),n=Xe(e*.7,2);return Sr(r,t,"M0,"+n+"L"+a+",0L0,-"+n+"L-"+a+",0Z")}},hourglass:{n:25,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2);return Sr(r,t,"M"+a+","+a+"H-"+a+"L"+a+",-"+a+"H-"+a+"Z")},noDot:!0},bowtie:{n:26,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2);return Sr(r,t,"M"+a+","+a+"V-"+a+"L-"+a+","+a+"V-"+a+"Z")},noDot:!0},"circle-cross":{n:27,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2);return Sr(r,t,"M0,"+a+"V-"+a+"M"+a+",0H-"+a+"M"+a+",0A"+a+","+a+" 0 1,1 0,-"+a+"A"+a+","+a+" 0 0,1 "+a+",0Z")},needLine:!0,noDot:!0},"circle-x":{n:28,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2),n=Xe(e/h9,2);return Sr(r,t,"M"+n+","+n+"L-"+n+",-"+n+"M"+n+",-"+n+"L-"+n+","+n+"M"+a+",0A"+a+","+a+" 0 1,1 0,-"+a+"A"+a+","+a+" 0 0,1 "+a+",0Z")},needLine:!0,noDot:!0},"square-cross":{n:29,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2);return Sr(r,t,"M0,"+a+"V-"+a+"M"+a+",0H-"+a+"M"+a+","+a+"H-"+a+"V-"+a+"H"+a+"Z")},needLine:!0,noDot:!0},"square-x":{n:30,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2);return Sr(r,t,"M"+a+","+a+"L-"+a+",-"+a+"M"+a+",-"+a+"L-"+a+","+a+"M"+a+","+a+"H-"+a+"V-"+a+"H"+a+"Z")},needLine:!0,noDot:!0},"diamond-cross":{n:31,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.3,2);return Sr(r,t,"M"+a+",0L0,"+a+"L-"+a+",0L0,-"+a+"ZM0,-"+a+"V"+a+"M-"+a+",0H"+a)},needLine:!0,noDot:!0},"diamond-x":{n:32,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.3,2),n=Xe(e*.65,2);return Sr(r,t,"M"+a+",0L0,"+a+"L-"+a+",0L0,-"+a+"ZM-"+n+",-"+n+"L"+n+","+n+"M-"+n+","+n+"L"+n+",-"+n)},needLine:!0,noDot:!0},"cross-thin":{n:33,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.4,2);return Sr(r,t,"M0,"+a+"V-"+a+"M"+a+",0H-"+a)},needLine:!0,noDot:!0,noFill:!0},"x-thin":{n:34,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2);return Sr(r,t,"M"+a+","+a+"L-"+a+",-"+a+"M"+a+",-"+a+"L-"+a+","+a)},needLine:!0,noDot:!0,noFill:!0},asterisk:{n:35,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.2,2),n=Xe(e*.85,2);return Sr(r,t,"M0,"+a+"V-"+a+"M"+a+",0H-"+a+"M"+n+","+n+"L-"+n+",-"+n+"M"+n+",-"+n+"L-"+n+","+n)},needLine:!0,noDot:!0,noFill:!0},hash:{n:36,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e/2,2),n=Xe(e,2);return Sr(r,t,"M"+a+","+n+"V-"+n+"M"+(a-n)+",-"+n+"V"+n+"M"+n+","+a+"H-"+n+"M-"+n+","+(a-n)+"H"+n)},needLine:!0,noFill:!0},"y-up":{n:37,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.2,2),n=Xe(e*1.6,2),i=Xe(e*.8,2);return Sr(r,t,"M-"+a+","+i+"L0,0M"+a+","+i+"L0,0M0,-"+n+"L0,0")},needLine:!0,noDot:!0,noFill:!0},"y-down":{n:38,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.2,2),n=Xe(e*1.6,2),i=Xe(e*.8,2);return Sr(r,t,"M-"+a+",-"+i+"L0,0M"+a+",-"+i+"L0,0M0,"+n+"L0,0")},needLine:!0,noDot:!0,noFill:!0},"y-left":{n:39,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.2,2),n=Xe(e*1.6,2),i=Xe(e*.8,2);return Sr(r,t,"M"+i+","+a+"L0,0M"+i+",-"+a+"L0,0M-"+n+",0L0,0")},needLine:!0,noDot:!0,noFill:!0},"y-right":{n:40,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.2,2),n=Xe(e*1.6,2),i=Xe(e*.8,2);return Sr(r,t,"M-"+i+","+a+"L0,0M-"+i+",-"+a+"L0,0M"+n+",0L0,0")},needLine:!0,noDot:!0,noFill:!0},"line-ew":{n:41,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.4,2);return Sr(r,t,"M"+a+",0H-"+a)},needLine:!0,noDot:!0,noFill:!0},"line-ns":{n:42,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*1.4,2);return Sr(r,t,"M0,"+a+"V-"+a)},needLine:!0,noDot:!0,noFill:!0},"line-ne":{n:43,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2);return Sr(r,t,"M"+a+",-"+a+"L-"+a+","+a)},needLine:!0,noDot:!0,noFill:!0},"line-nw":{n:44,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2);return Sr(r,t,"M"+a+","+a+"L-"+a+",-"+a)},needLine:!0,noDot:!0,noFill:!0},"arrow-up":{n:45,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2),n=Xe(e*2,2);return Sr(r,t,"M0,0L-"+a+","+n+"H"+a+"Z")},backoff:1,noDot:!0},"arrow-down":{n:46,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2),n=Xe(e*2,2);return Sr(r,t,"M0,0L-"+a+",-"+n+"H"+a+"Z")},noDot:!0},"arrow-left":{n:47,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*2,2),n=Xe(e,2);return Sr(r,t,"M0,0L"+a+",-"+n+"V"+n+"Z")},noDot:!0},"arrow-right":{n:48,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*2,2),n=Xe(e,2);return Sr(r,t,"M0,0L-"+a+",-"+n+"V"+n+"Z")},noDot:!0},"arrow-bar-up":{n:49,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2),n=Xe(e*2,2);return Sr(r,t,"M-"+a+",0H"+a+"M0,0L-"+a+","+n+"H"+a+"Z")},backoff:1,needLine:!0,noDot:!0},"arrow-bar-down":{n:50,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e,2),n=Xe(e*2,2);return Sr(r,t,"M-"+a+",0H"+a+"M0,0L-"+a+",-"+n+"H"+a+"Z")},needLine:!0,noDot:!0},"arrow-bar-left":{n:51,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*2,2),n=Xe(e,2);return Sr(r,t,"M0,-"+n+"V"+n+"M0,0L"+a+",-"+n+"V"+n+"Z")},needLine:!0,noDot:!0},"arrow-bar-right":{n:52,f:function(e,r,t){if(Cr(r))return kr;var a=Xe(e*2,2),n=Xe(e,2);return Sr(r,t,"M0,-"+n+"V"+n+"M0,0L-"+a+",-"+n+"V"+n+"Z")},needLine:!0,noDot:!0},arrow:{n:53,f:function(e,r,t){if(Cr(r))return kr;var a=ey/2.5,n=2*e*ry(a),i=2*e*ty(a);return Sr(r,t,"M0,0L"+-n+","+i+"L"+n+","+i+"Z")},backoff:.9,noDot:!0},"arrow-wide":{n:54,f:function(e,r,t){if(Cr(r))return kr;var a=ey/4,n=2*e*ry(a),i=2*e*ty(a);return Sr(r,t,"M0,0L"+-n+","+i+"A "+2*e+","+2*e+" 0 0 1 "+n+","+i+"Z")},backoff:.4,noDot:!0}};function Cr(e){return e===null}var d9,p9,m9,y9;function Sr(e,r,t){if((!e||e%360===0)&&!r)return t;if(m9===e&&y9===r&&d9===t)return p9;m9=e,y9=r,d9=t;function a(g,x){var T=ry(g),M=ty(g),b=x[0],w=x[1]+(r||0);return[b*T-w*M,b*M+w*T]}for(var n=e/180*ey,i=0,l=0,o=sG(t),s="",u=0;u{"use strict";var aa=Nr(),tt=Ne(),uG=tt.numberFormat,Rl=Br(),Bv=_r(),jt=Er(),fG=$m(),Of=tt.strTranslate,Yv=da(),cG=Eo(),vG=La(),hG=vG.LINE_SPACING,L9=Q0().DESELECTDIM,dG=mn(),pG=u9(),mG=Di().appendArrayPointValue,je=H9.exports={};je.font=function(e,r){var t=r.variant,a=r.style,n=r.weight,i=r.color,l=r.size,o=r.family,s=r.shadow,u=r.lineposition,f=r.textcase;o&&e.style("font-family",o),l+1&&e.style("font-size",l+"px"),i&&e.call(jt.fill,i),n&&e.style("font-weight",n),a&&e.style("font-style",a),t&&e.style("font-variant",t),f&&e.style("text-transform",ay(gG(f))),s&&e.style("text-shadow",s==="auto"?Yv.makeTextShadow(jt.contrast(i)):ay(s)),u&&e.style("text-decoration-line",ay(bG(u)))};function ay(e){return e==="none"?void 0:e}var yG={normal:"none",lower:"lowercase",upper:"uppercase","word caps":"capitalize"};function gG(e){return yG[e]}function bG(e){return e.replace("under","underline").replace("over","overline").replace("through","line-through").split("+").join(" ")}je.setPosition=function(e,r,t){e.attr("x",r).attr("y",t)};je.setSize=function(e,r,t){e.attr("width",r).attr("height",t)};je.setRect=function(e,r,t,a,n){e.call(je.setPosition,r,t).call(je.setSize,a,n)};je.translatePoint=function(e,r,t,a){var n=t.c2p(e.x),i=a.c2p(e.y);if(Rl(n)&&Rl(i)&&r.node())r.node().nodeName==="text"?r.attr("x",n).attr("y",i):r.attr("transform",Of(n,i));else return!1;return!0};je.translatePoints=function(e,r,t){e.each(function(a){var n=aa.select(this);je.translatePoint(a,n,r,t)})};je.hideOutsideRangePoint=function(e,r,t,a,n,i){r.attr("display",t.isPtWithinRange(e,n)&&a.isPtWithinRange(e,i)?null:"none")};je.hideOutsideRangePoints=function(e,r){if(r._hasClipOnAxisFalse){var t=r.xaxis,a=r.yaxis;e.each(function(n){var i=n[0].trace,l=i.xcalendar,o=i.ycalendar,s=Bv.traceIs(i,"bar-like")?".bartext":".point,.textpoint";e.selectAll(s).each(function(u){je.hideOutsideRangePoint(u,aa.select(this),t,a,l,o)})})}};je.crispRound=function(e,r,t){return!r||!Rl(r)?t||0:e._context.staticPlot?r:r<1?1:Math.round(r)};je.singleLineStyle=function(e,r,t,a,n){r.style("fill","none");var i=(((e||[])[0]||{}).trace||{}).line||{},l=t||i.width||0,o=n||i.dash||"";jt.stroke(r,a||i.color),je.dashLine(r,o,l)};je.lineGroupStyle=function(e,r,t,a){e.style("fill","none").each(function(n){var i=(((n||[])[0]||{}).trace||{}).line||{},l=r||i.width||0,o=a||i.dash||"";aa.select(this).call(jt.stroke,t||i.color).call(je.dashLine,o,l)})};je.dashLine=function(e,r,t){t=+t||0,r=je.dashStyle(r,t),e.style({"stroke-dasharray":r,"stroke-width":t+"px"})};je.dashStyle=function(e,r){r=+r||1;var t=Math.max(r,3);return e==="solid"?e="":e==="dot"?e=t+"px,"+t+"px":e==="dash"?e=3*t+"px,"+3*t+"px":e==="longdash"?e=5*t+"px,"+5*t+"px":e==="dashdot"?e=3*t+"px,"+t+"px,"+t+"px,"+t+"px":e==="longdashdot"&&(e=5*t+"px,"+2*t+"px,"+t+"px,"+2*t+"px"),e};function q9(e,r,t,a){var n=r.fillpattern,i=r.fillgradient,l=je.getPatternAttr,o=n&&(l(n.shape,0,"")||l(n.path,0,""));if(o){var s=l(n.bgcolor,0,null),u=l(n.fgcolor,0,null),f=n.fgopacity,v=l(n.size,0,8),h=l(n.solidity,0,.3),d=r.uid;je.pattern(e,"point",t,d,o,v,h,void 0,n.fillmode,s,u,f)}else if(i&&i.type!=="none"){var m=i.type,y="scatterfill-"+r.uid;if(a&&(y="legendfill-"+r.uid),!a&&(i.start!==void 0||i.stop!==void 0)){var g,x;m==="horizontal"?(g={x:i.start,y:0},x={x:i.stop,y:0}):m==="vertical"&&(g={x:0,y:i.start},x={x:0,y:i.stop}),g.x=r._xA.c2p(g.x===void 0?r._extremes.x.min[0].val:g.x,!0),g.y=r._yA.c2p(g.y===void 0?r._extremes.y.min[0].val:g.y,!0),x.x=r._xA.c2p(x.x===void 0?r._extremes.x.max[0].val:x.x,!0),x.y=r._yA.c2p(x.y===void 0?r._extremes.y.max[0].val:x.y,!0),e.call(P9,t,y,"linear",i.colorscale,"fill",g,x,!0,!1)}else m==="horizontal"&&(m=m+"reversed"),e.call(je.gradient,t,y,m,i.colorscale,"fill")}else r.fillcolor&&e.call(jt.fill,r.fillcolor)}je.singleFillStyle=function(e,r){var t=aa.select(e.node()),a=t.data(),n=((a[0]||[])[0]||{}).trace||{};q9(e,n,r,!1)};je.fillGroupStyle=function(e,r,t){e.style("stroke-width",0).each(function(a){var n=aa.select(this);a[0].trace&&q9(n,a[0].trace,r,t)})};var _9=b9();je.symbolNames=[];je.symbolFuncs=[];je.symbolBackOffs=[];je.symbolNeedLines={};je.symbolNoDot={};je.symbolNoFill={};je.symbolList=[];Object.keys(_9).forEach(function(e){var r=_9[e],t=r.n;je.symbolList.push(t,String(t),e,t+100,String(t+100),e+"-open"),je.symbolNames[t]=e,je.symbolFuncs[t]=r.f,je.symbolBackOffs[t]=r.backoff||0,r.needLine&&(je.symbolNeedLines[t]=!0),r.noDot?je.symbolNoDot[t]=!0:je.symbolList.push(t+200,String(t+200),e+"-dot",t+300,String(t+300),e+"-open-dot"),r.noFill&&(je.symbolNoFill[t]=!0)});var _G=je.symbolNames.length,xG="M0,0.5L0.5,0L0,-0.5L-0.5,0Z";je.symbolNumber=function(e){if(Rl(e))e=+e;else if(typeof e=="string"){var r=0;e.indexOf("-open")>0&&(r=100,e=e.replace("-open","")),e.indexOf("-dot")>0&&(r+=200,e=e.replace("-dot","")),e=je.symbolNames.indexOf(e),e>=0&&(e+=r)}return e%100>=_G||e>=400?0:Math.floor(Math.max(e,0))};function D9(e,r,t,a){var n=e%100;return je.symbolFuncs[n](r,t,a)+(e>=200?xG:"")}var x9=uG("~f"),E9={radial:{type:"radial"},radialreversed:{type:"radial",reversed:!0},horizontal:{type:"linear",start:{x:1,y:0},stop:{x:0,y:0}},horizontalreversed:{type:"linear",start:{x:1,y:0},stop:{x:0,y:0},reversed:!0},vertical:{type:"linear",start:{x:0,y:1},stop:{x:0,y:0}},verticalreversed:{type:"linear",start:{x:0,y:1},stop:{x:0,y:0},reversed:!0}};je.gradient=function(e,r,t,a,n,i){var l=E9[a];return P9(e,r,t,l.type,n,i,l.start,l.stop,!1,l.reversed)};function P9(e,r,t,a,n,i,l,o,s,u){var f=n.length,v;a==="linear"?v={node:"linearGradient",attrs:{x1:l.x,y1:l.y,x2:o.x,y2:o.y,gradientUnits:s?"userSpaceOnUse":"objectBoundingBox"},reversed:u}:a==="radial"&&(v={node:"radialGradient",reversed:u});for(var h=new Array(f),d=0;d=0&&e.i===void 0&&(e.i=i.i),r.style("opacity",a.selectedOpacityFn?a.selectedOpacityFn(e):e.mo===void 0?l.opacity:e.mo),a.ms2mrc){var s;e.ms==="various"||l.size==="various"?s=3:s=a.ms2mrc(e.ms),e.mrc=s,a.selectedSizeFn&&(s=e.mrc=a.selectedSizeFn(e));var u=je.symbolNumber(e.mx||l.symbol)||0;e.om=u%200>=100;var f=fy(e,t),v=uy(e,t);r.attr("d",D9(u,s,f,v))}var h=!1,d,m,y;if(e.so)y=o.outlierwidth,m=o.outliercolor,d=l.outliercolor;else{var g=(o||{}).width;y=(e.mlw+1||g+1||(e.trace?(e.trace.marker.line||{}).width:0)+1)-1||0,"mlc"in e?m=e.mlcc=a.lineScale(e.mlc):tt.isArrayOrTypedArray(o.color)?m=jt.defaultLine:m=o.color,tt.isArrayOrTypedArray(l.color)&&(d=jt.defaultLine,h=!0),"mc"in e?d=e.mcc=a.markerScale(e.mc):d=l.color||l.colors||"rgba(0,0,0,0)",a.selectedColorFn&&(d=a.selectedColorFn(e))}let x=e.mld||(o||{}).dash;if(x&&je.dashLine(r,x,y),e.om)r.call(jt.stroke,d).style({"stroke-width":(y||1)+"px",fill:"none"});else{r.style("stroke-width",(e.isBlank?0:y)+"px");var T=l.gradient,M=e.mgt;M?h=!0:M=T&&T.type,tt.isArrayOrTypedArray(M)&&(M=M[0],E9[M]||(M=0));var b=l.pattern,w=je.getPatternAttr,S=b&&(w(b.shape,e.i,"")||w(b.path,e.i,""));if(M&&M!=="none"){var k=e.mgc;k?h=!0:k=T.color;var q=t.uid;h&&(q+="-"+e.i),je.gradient(r,n,q,M,[[0,k],[1,d]],"fill")}else if(S){var D=!1,P=b.fgcolor;!P&&i&&i.color&&(P=i.color,D=!0);var z=w(P,e.i,i&&i.color||null),F=w(b.bgcolor,e.i,null),I=b.fgopacity,H=w(b.size,e.i,8),O=w(b.solidity,e.i,.3);D=D||e.mcc||tt.isArrayOrTypedArray(b.shape)||tt.isArrayOrTypedArray(b.path)||tt.isArrayOrTypedArray(b.bgcolor)||tt.isArrayOrTypedArray(b.fgcolor)||tt.isArrayOrTypedArray(b.size)||tt.isArrayOrTypedArray(b.solidity);var G=t.uid;D&&(G+="-"+e.i),je.pattern(r,"point",n,G,S,H,O,e.mcc,b.fillmode,F,z,I)}else tt.isArrayOrTypedArray(d)?jt.fill(r,d[e.i]):jt.fill(r,d);y&&jt.stroke(r,m)}};je.makePointStyleFns=function(e){var r={},t=e.marker;return r.markerScale=je.tryColorscale(t,""),r.lineScale=je.tryColorscale(t,"line"),Bv.traceIs(e,"symbols")&&(r.ms2mrc=dG.isBubble(e)?pG(e):function(){return(t.size||6)/2}),e.selectedpoints&&tt.extendFlat(r,je.makeSelectedPointStyleFns(e)),r};je.makeSelectedPointStyleFns=function(e){var r={},t=e.selected||{},a=e.unselected||{},n=e.marker||{},i=t.marker||{},l=a.marker||{},o=n.opacity,s=i.opacity,u=l.opacity,f=s!==void 0,v=u!==void 0;(tt.isArrayOrTypedArray(o)||f||v)&&(r.selectedOpacityFn=function(b){var w=b.mo===void 0?n.opacity:b.mo;return b.selected?f?s:w:v?u:L9*w});var h=n.color,d=i.color,m=l.color;(d||m)&&(r.selectedColorFn=function(b){var w=b.mcc||h;return b.selected?d||w:m||w});var y=n.size,g=i.size,x=l.size,T=g!==void 0,M=x!==void 0;return Bv.traceIs(e,"symbols")&&(T||M)&&(r.selectedSizeFn=function(b){var w=b.mrc||y/2;return b.selected?T?g/2:w:M?x/2:w}),r};je.makeSelectedTextStyleFns=function(e){var r={},t=e.selected||{},a=e.unselected||{},n=e.textfont||{},i=t.textfont||{},l=a.textfont||{},o=n.color,s=i.color,u=l.color;return r.selectedTextColorFn=function(f){var v=f.tc||o;return f.selected?s||v:u||(s?v:jt.addOpacity(v,L9))},r};je.selectedPointStyle=function(e,r){if(!(!e.size()||!r.selectedpoints)){var t=je.makeSelectedPointStyleFns(r),a=r.marker||{},n=[];t.selectedOpacityFn&&n.push(function(i,l){i.style("opacity",t.selectedOpacityFn(l))}),t.selectedColorFn&&n.push(function(i,l){jt.fill(i,t.selectedColorFn(l))}),t.selectedSizeFn&&n.push(function(i,l){var o=l.mx||a.symbol||0,s=t.selectedSizeFn(l);i.attr("d",D9(je.symbolNumber(o),s,fy(l,r),uy(l,r))),l.mrc2=s}),n.length&&e.each(function(i){for(var l=aa.select(this),o=0;o0?t:0}je.textPointStyle=function(e,r,t){if(e.size()){var a;if(r.selectedpoints){var n=je.makeSelectedTextStyleFns(r);a=n.selectedTextColorFn}var i=r.texttemplate,l=t._fullLayout;e.each(function(o){var s=aa.select(this),u=i?tt.extractOption(o,r,"txt","texttemplate"):tt.extractOption(o,r,"tx","text");if(!u&&u!==0){s.remove();return}if(i){var f=r._module.formatLabels,v=f?f(o,r,l):{},h={};mG(h,r,o.i),u=tt.texttemplateString({data:[h,o,r._meta],fallback:r.texttemplatefallback,labels:v,locale:l._d3locale,template:u})}var d=o.tp||r.textposition,m=z9(o,r),y=a?a(o):o.tc||r.textfont.color;s.call(je.font,{family:o.tf||r.textfont.family,weight:o.tw||r.textfont.weight,style:o.ty||r.textfont.style,variant:o.tv||r.textfont.variant,textcase:o.tC||r.textfont.textcase,lineposition:o.tE||r.textfont.lineposition,shadow:o.tS||r.textfont.shadow,size:m,color:y}).text(u).call(Yv.convertToTspans,t).call(R9,d,m,o.mrc)})}};je.selectedTextStyle=function(e,r){if(!(!e.size()||!r.selectedpoints)){var t=je.makeSelectedTextStyleFns(r);e.each(function(a){var n=aa.select(this),i=t.selectedTextColorFn(a),l=a.tp||r.textposition,o=z9(a,r);jt.fill(n,i);var s=Bv.traceIs(r,"bar-like");R9(n,l,o,a.mrc2||a.mrc,s)})}};var w9=.5;je.smoothopen=function(e,r){if(e.length<3)return"M"+e.join("L");var t="M"+e[0],a=[],n;for(n=1;n=s||b>=f&&b<=s)&&(w<=v&&w>=u||w>=v&&w<=u)&&(e=[b,w])}return e}je.applyBackoff=I9;je.makeTester=function(){var e=tt.ensureSingleById(aa.select("body"),"svg","js-plotly-tester",function(t){t.attr(cG.svgAttrs).style({position:"absolute",left:"-10000px",top:"-10000px",width:"9000px",height:"9000px","z-index":"1"})}),r=tt.ensureSingle(e,"path","js-reference-point",function(t){t.attr("d","M0,0H1V1H0Z").style({"stroke-width":0,fill:"black"})});je.tester=e,je.testref=r};je.savedBBoxes={};var iy=0,MG=1e4;je.bBox=function(e,r,t){t||(t=T9(e));var a;if(t){if(a=je.savedBBoxes[t],a)return tt.extendFlat({},a)}else if(e.childNodes.length===1){var n=e.childNodes[0];if(t=T9(n),t){var i=+n.getAttribute("x")||0,l=+n.getAttribute("y")||0,o=n.getAttribute("transform");if(!o){var s=je.bBox(n,!1,t);return i&&(s.left+=i,s.right+=i),l&&(s.top+=l,s.bottom+=l),s}if(t+="~"+i+"~"+l+"~"+o,a=je.savedBBoxes[t],a)return tt.extendFlat({},a)}}var u,f;r?u=e:(f=je.tester.node(),u=e.cloneNode(!0),f.appendChild(u)),aa.select(u).attr("transform",null).call(Yv.positionText,0,0);var v=u.getBoundingClientRect(),h=je.testref.node().getBoundingClientRect();r||f.removeChild(u);var d={height:v.height,width:v.width,left:v.left-h.left,top:v.top-h.top,right:v.right-h.left,bottom:v.bottom-h.top};return iy>=MG&&(je.savedBBoxes={},iy=0),t&&(je.savedBBoxes[t]=d),iy++,tt.extendFlat({},d)};function T9(e){var r=e.getAttribute("data-unformatted");if(r!==null)return r+e.getAttribute("data-math")+e.getAttribute("text-anchor")+e.getAttribute("style")}je.setClipUrl=function(e,r,t){e.attr("clip-path",sy(r,t))};function sy(e,r){if(!e)return null;var t=r._context,a=t._exportedPlot?"":t._baseUrl||"";return a?"url('"+a+"#"+e+"')":"url(#"+e+")"}je.getTranslate=function(e){var r=/.*\btranslate\((-?\d*\.?\d*)[^-\d]*(-?\d*\.?\d*)[^\d].*/,t=e.attr?"attr":"getAttribute",a=e[t]("transform")||"",n=a.replace(r,function(i,l,o){return[l,o].join(" ")}).split(" ");return{x:+n[0]||0,y:+n[1]||0}};je.setTranslate=function(e,r,t){var a=/(\btranslate\(.*?\);?)/,n=e.attr?"attr":"getAttribute",i=e.attr?"attr":"setAttribute",l=e[n]("transform")||"";return r=r||0,t=t||0,l=l.replace(a,"").trim(),l+=Of(r,t),l=l.trim(),e[i]("transform",l),l};je.getScale=function(e){var r=/.*\bscale\((\d*\.?\d*)[^\d]*(\d*\.?\d*)[^\d].*/,t=e.attr?"attr":"getAttribute",a=e[t]("transform")||"",n=a.replace(r,function(i,l,o){return[l,o].join(" ")}).split(" ");return{x:+n[0]||1,y:+n[1]||1}};je.setScale=function(e,r,t){var a=/(\bscale\(.*?\);?)/,n=e.attr?"attr":"getAttribute",i=e.attr?"attr":"setAttribute",l=e[n]("transform")||"";return r=r||1,t=t||1,l=l.replace(a,"").trim(),l+="scale("+r+","+t+")",l=l.trim(),e[i]("transform",l),l};var AG=/\s*sc.*/;je.setPointGroupScale=function(e,r,t){if(r=r||1,t=t||1,!!e){var a=r===1&&t===1?"":"scale("+r+","+t+")";e.each(function(){var n=(this.getAttribute("transform")||"").replace(AG,"");n+=a,n=n.trim(),this.setAttribute("transform",n)})}};var kG=/translate\([^)]*\)\s*$/;je.setTextPointsScale=function(e,r,t){e&&e.each(function(){var a,n=aa.select(this),i=n.select("text");if(i.node()){var l=parseFloat(i.attr("x")||0),o=parseFloat(i.attr("y")||0),s=(n.attr("transform")||"").match(kG);r===1&&t===1?a=[]:a=[Of(l,o),"scale("+r+","+t+")",Of(-l,-o)],s&&a.push(s),n.attr("transform",a.join(""))}})};function uy(e,r){var t;return e&&(t=e.mf),t===void 0&&(t=r.marker&&r.marker.standoff||0),!r._geo&&!r._xA?-t:t}je.getMarkerStandoff=uy;var Hf=Math.atan2,Ro=Math.cos,fu=Math.sin;function M9(e,r){var t=r[0],a=r[1];return[t*Ro(e)-a*fu(e),t*fu(e)+a*Ro(e)]}var A9,k9,C9,S9,ly,oy;function fy(e,r){var t=e.ma;t===void 0&&(t=r.marker.angle,(!Rl(t)||tt.isArrayOrTypedArray(t))&&(t=0));var a,n,i=r.marker.angleref;if(i==="previous"||i==="north"){if(r._geo){var l=r._geo.project(e.lonlat);a=l[0],n=l[1]}else{var o=r._xA,s=r._yA;if(o&&s)a=o.c2p(e.x),n=s.c2p(e.y);else return 90}if(r._geo){var u=e.lonlat[0],f=e.lonlat[1],v=r._geo.project([u,f+1e-5]),h=r._geo.project([u+1e-5,f]),d=Hf(h[1]-n,h[0]-a),m=Hf(v[1]-n,v[0]-a),y;if(i==="north")y=t/180*Math.PI;else if(i==="previous"){var g=u/180*Math.PI,x=f/180*Math.PI,T=A9/180*Math.PI,M=k9/180*Math.PI,b=T-g,w=Ro(M)*fu(b),S=fu(M)*Ro(x)-Ro(M)*fu(x)*Ro(b);y=-Hf(w,S)-Math.PI,A9=u,k9=f}var k=M9(d,[Ro(y),0]),q=M9(m,[fu(y),0]);t=Hf(k[1]+q[1],k[0]+q[0])/Math.PI*180,i==="previous"&&!(oy===r.uid&&e.i===ly+1)&&(t=null)}if(i==="previous"&&!r._geo)if(oy===r.uid&&e.i===ly+1&&Rl(a)&&Rl(n)){var D=a-C9,P=n-S9,z=r.line&&r.line.shape||"",F=z.slice(z.length-1);F==="h"&&(P=0),F==="v"&&(D=0),t+=Hf(P,D)/Math.PI*180+90}else t=null}return C9=a,S9=n,ly=e.i,oy=r.uid,t}je.getMarkerAngle=fy});var Bf=X((nfe,U9)=>{"use strict";var cu=Nr(),CG=Br(),SG=Ut(),cy=_r(),Fo=Ne(),O9=Fo.strTranslate,Uv=at(),Vv=Er(),vu=da(),B9=Q0(),LG=La().OPPOSITE_SIDE,Y9=/ [XY][0-9]* /,vy=1.6,hy=1.6;function qG(e,r,t){var a=e._fullLayout,n=t.propContainer,i=t.propName,l=t.placeholder,o=t.traceIndex,s=t.avoid||{},u=t.attributes,f=t.transform,v=t.containerGroup,h=1,d=n.title,m=(d&&d.text?d.text:"").trim(),y=!1,g=d&&d.font?d.font:{},x=g.family,T=g.size,M=g.color,b=g.weight,w=g.style,S=g.variant,k=g.textcase,q=g.lineposition,D=g.shadow,P=t.subtitlePropName,z=!!P,F=t.subtitlePlaceholder,I=(n.title||{}).subtitle||{text:"",font:{}},H=(I.text||"").trim(),O=!1,G=1,Y=I.font,B=Y.family,V=Y.size,J=Y.color,Z=Y.weight,re=Y.style,se=Y.variant,ve=Y.textcase,xe=Y.lineposition,Q=Y.shadow,we;i==="title.text"?we="titleText":i.indexOf("axis")!==-1?we="axisTitleText":i.indexOf("colorbar")!==-1&&(we="colorbarTitleText");var fe=e._context.edits[we];function ge(Ke,We){return Ke===void 0||We===void 0?!1:Ke.replace(Y9," % ")===We.replace(Y9," % ")}m===""?h=0:ge(m,l)&&(fe||(m=""),h=.2,y=!0),z&&(H===""?G=0:ge(H,F)&&(fe||(H=""),G=.2,O=!0)),t._meta?m=Fo.templateString(m,t._meta):a._meta&&(m=Fo.templateString(m,a._meta));var _e=m||H||fe,ce;v||(v=Fo.ensureSingle(a._infolayer,"g","g-"+r),ce=a._hColorbarMoveTitle);var Me=v.selectAll("text."+r).data(_e?[0]:[]);Me.enter().append("text"),Me.text(m).attr("class",r),Me.exit().remove();var Re=null,Te=r+"-subtitle",Pe=H||fe;if(z&&(Re=v.selectAll("text."+Te).data(Pe?[0]:[]),Re.enter().append("text"),Re.text(H).attr("class",Te),Re.exit().remove()),!_e)return v;function Le(Ke,We){Fo.syncOrAsync([Ce,Ie],{title:Ke,subtitle:We})}function Ce(Ke){var We=Ke.title,ke=Ke.subtitle,ze;!f&&ce&&(f={}),f?(ze="",f.rotate&&(ze+="rotate("+[f.rotate,u.x,u.y]+")"),(f.offset||ce)&&(ze+=O9(0,(f.offset||0)-(ce||0)))):ze=null,We.attr("transform",ze);function He(me){if(me){var be=cu.select(me.node().parentNode).select("."+Te);if(!be.empty()){var Fe=me.node().getBBox();if(Fe.height){var Ge=Fe.y+Fe.height+vy*V;be.attr("y",Ge)}}}}if(We.style("opacity",h*Vv.opacity(M)).call(Uv.font,{color:Vv.rgb(M),size:cu.round(T,2),family:x,weight:b,style:w,variant:S,textcase:k,shadow:D,lineposition:q}).attr(u).call(vu.convertToTspans,e,He),ke&&!ke.empty()){var te=v.select("."+r+"-math-group"),he=We.node().getBBox(),Ze=te.node()?te.node().getBBox():void 0,hr=Ze?Ze.y+Ze.height+vy*V:he.y+he.height+hy*V,De=Fo.extendFlat({},u,{y:hr});ke.attr("transform",ze),ke.style("opacity",G*Vv.opacity(J)).call(Uv.font,{color:Vv.rgb(J),size:cu.round(V,2),family:B,weight:Z,style:re,variant:se,textcase:ve,shadow:Q,lineposition:xe}).attr(De).call(vu.convertToTspans,e)}return SG.previousPromises(e)}function Ie(Ke){var We=Ke.title,ke=cu.select(We.node().parentNode);if(s&&s.selection&&s.side&&m){ke.attr("transform",null);var ze=LG[s.side],He=s.side==="left"||s.side==="top"?-1:1,te=CG(s.pad)?s.pad:2,he=Uv.bBox(ke.node()),Ze={t:0,b:0,l:0,r:0},hr=e._fullLayout._reservedMargin;for(var De in hr)for(var me in hr[De]){var be=hr[De][me];Ze[me]=Math.max(Ze[me],be)}var Fe={left:Ze.l,top:Ze.t,right:a.width-Ze.r,bottom:a.height-Ze.b},Ge=s.maxShift||He*(Fe[s.side]-he[s.side]),Ue=0;if(Ge<0)Ue=Ge;else{var Ve=s.offsetLeft||0,ir=s.offsetTop||0;he.left-=Ve,he.right-=Ve,he.top-=ir,he.bottom-=ir,s.selection.each(function(){var er=Uv.bBox(this);Fo.bBoxIntersect(he,er,te)&&(Ue=Math.max(Ue,He*(er[s.side]-he[ze])+te))}),Ue=Math.min(Ge,Ue),n._titleScoot=Math.abs(Ue)}if(Ue>0||Ge<0){var ar={left:[-Ue,0],right:[Ue,0],top:[0,-Ue],bottom:[0,Ue]}[s.side];ke.attr("transform",O9(ar[0],ar[1]))}}}Me.call(Le,Re);function Oe(Ke,We){Ke.text(We).on("mouseover.opacity",function(){cu.select(this).transition().duration(B9.SHOW_PLACEHOLDER).style("opacity",1)}).on("mouseout.opacity",function(){cu.select(this).transition().duration(B9.HIDE_PLACEHOLDER).style("opacity",0)})}if(fe&&(m?Me.on(".opacity",null):(Oe(Me,l),y=!0),Me.call(vu.makeEditable,{gd:e}).on("edit",function(Ke){o!==void 0?cy.call("_guiRestyle",e,i,Ke,o):cy.call("_guiRelayout",e,i,Ke)}).on("cancel",function(){this.text(this.attr("data-unformatted")).call(Le)}).on("input",function(Ke){this.text(Ke||" ").call(vu.positionText,u.x,u.y)}),z)){if(z&&!m){var qe=Me.node().getBBox(),Je=qe.y+qe.height+hy*V;Re.attr("y",Je)}H?Re.on(".opacity",null):(Oe(Re,F),O=!0),Re.call(vu.makeEditable,{gd:e}).on("edit",function(Ke){cy.call("_guiRelayout",e,"title.subtitle.text",Ke)}).on("cancel",function(){this.text(this.attr("data-unformatted")).call(Le)}).on("input",function(Ke){this.text(Ke||" ").call(vu.positionText,Re.attr("x"),Re.attr("y"))})}return Me.classed("js-placeholder",y),Re&&!Re.empty()&&Re.classed("js-placeholder",O),v}U9.exports={draw:qG,SUBTITLE_PADDING_EM:hy,SUBTITLE_PADDING_MATHJAX_EM:vy}});var Qv=X((ife,Z9)=>{"use strict";var DG=Nr(),EG=Ds().utcFormat,lt=Ne(),PG=lt.numberFormat,Ei=Br(),zl=lt.cleanNumber,RG=lt.ms2DateTime,V9=lt.dateTime2ms,Pi=lt.ensureNumber,G9=lt.isArrayOrTypedArray,Nl=Zt(),Gv=Nl.FP_SAFE,ui=Nl.BADNUM,zG=Nl.LOG_CLIP,NG=Nl.ONEWEEK,Wv=Nl.ONEDAY,Xv=Nl.ONEHOUR,W9=Nl.ONEMIN,X9=Nl.ONESEC,Zv=$t(),$v=ta(),Jv=$v.HOUR_PATTERN,Kv=$v.WEEKDAY_PATTERN;function Yf(e){return Math.pow(10,e)}function dy(e){return e!=null}Z9.exports=function(r,t){t=t||{};var a=r._id||"x",n=a.charAt(0);function i(b,w){if(b>0)return Math.log(b)/Math.LN10;if(b<=0&&w&&r.range&&r.range.length===2){var S=r.range[0],k=r.range[1];return .5*(S+k-2*zG*Math.abs(S-k))}else return ui}function l(b,w,S,k){if((k||{}).msUTC&&Ei(b))return+b;var q=V9(b,S||r.calendar);if(q===ui)if(Ei(b)){b=+b;var D=Math.floor(lt.mod(b+.05,1)*10),P=Math.round(b-D/10);q=V9(new Date(P))+D/10}else return ui;return q}function o(b,w,S){return RG(b,w,S||r.calendar)}function s(b){return r._categories[Math.round(b)]}function u(b){if(dy(b)){if(r._categoriesMap===void 0&&(r._categoriesMap={}),r._categoriesMap[b]!==void 0)return r._categoriesMap[b];r._categories.push(typeof b=="number"?String(b):b);var w=r._categories.length-1;return r._categoriesMap[b]=w,w}return ui}function f(b,w){for(var S=new Array(w),k=0;kr.range[1]&&(S=!S);for(var k=S?-1:1,q=k*b,D=0,P=0;PF)D=P+1;else{D=q<(z+F)/2?P:P+1;break}}var I=r._B[D]||0;return isFinite(I)?m(b,r._m2,I):0},x=function(b){var w=r._rangebreaks.length;if(!w)return y(b,r._m,r._b);for(var S=0,k=0;kr._rangebreaks[k].pmax&&(S=k+1);return y(b,r._m2,r._B[S])}}r.c2l=r.type==="log"?i:Pi,r.l2c=r.type==="log"?Yf:Pi,r.l2p=g,r.p2l=x,r.c2p=r.type==="log"?function(b,w){return g(i(b,w))}:g,r.p2c=r.type==="log"?function(b){return Yf(x(b))}:x,["linear","-"].indexOf(r.type)!==-1?(r.d2r=r.r2d=r.d2c=r.r2c=r.d2l=r.r2l=zl,r.c2d=r.c2r=r.l2d=r.l2r=Pi,r.d2p=r.r2p=function(b){return r.l2p(zl(b))},r.p2d=r.p2r=x,r.cleanPos=Pi):r.type==="log"?(r.d2r=r.d2l=function(b,w){return i(zl(b),w)},r.r2d=r.r2c=function(b){return Yf(zl(b))},r.d2c=r.r2l=zl,r.c2d=r.l2r=Pi,r.c2r=i,r.l2d=Yf,r.d2p=function(b,w){return r.l2p(r.d2r(b,w))},r.p2d=function(b){return Yf(x(b))},r.r2p=function(b){return r.l2p(zl(b))},r.p2r=x,r.cleanPos=Pi):r.type==="date"?(r.d2r=r.r2d=lt.identity,r.d2c=r.r2c=r.d2l=r.r2l=l,r.c2d=r.c2r=r.l2d=r.l2r=o,r.d2p=r.r2p=function(b,w,S){return r.l2p(l(b,0,S))},r.p2d=r.p2r=function(b,w,S){return o(x(b),w,S)},r.cleanPos=function(b){return lt.cleanDate(b,ui,r.calendar)}):r.type==="category"?(r.d2c=r.d2l=u,r.r2d=r.c2d=r.l2d=s,r.d2r=r.d2l_noadd=h,r.r2c=function(b){var w=d(b);return w!==void 0?w:r.fraction2r(.5)},r.l2r=r.c2r=Pi,r.r2l=d,r.d2p=function(b){return r.l2p(r.r2c(b))},r.p2d=function(b){return s(x(b))},r.r2p=r.d2p,r.p2r=x,r.cleanPos=function(b){return typeof b=="string"&&b!==""?b:Pi(b)}):r.type==="multicategory"&&(r.r2d=r.c2d=r.l2d=s,r.d2r=r.d2l_noadd=h,r.r2c=function(b){var w=h(b);return w!==void 0?w:r.fraction2r(.5)},r.r2c_just_indices=v,r.l2r=r.c2r=Pi,r.r2l=h,r.d2p=function(b){return r.l2p(r.r2c(b))},r.p2d=function(b){return s(x(b))},r.r2p=r.d2p,r.p2r=x,r.cleanPos=function(b){return Array.isArray(b)||typeof b=="string"&&b!==""?b:Pi(b)},r.setupMultiCategory=function(b){var w=r._traceIndices,S,k,q=r._matchGroup;if(q&&r._categories.length===0){for(var D in q)if(D!==a){var P=t[Zv.id2name(D)];w=w.concat(P._traceIndices)}}var z=[[0,{}],[0,{}]],F=[];for(S=0;SP[1]&&(k[D?0:1]=S,q[1]=P[1]),q[0]>=q[1])if(w!==void 0){var z=P[0]+1;S!==void 0&&(z=Math.min(z,P[1])),k[D?0:1]=r.l2r(z)}else S!==void 0&&(k[D?1:0]=r.l2r(P[1]-1))}},r.cleanRange=function(b,w){r._cleanRange(b,w),r.limitRange(b)},r._cleanRange=function(b,w){w||(w={}),b||(b="range");var S=lt.nestedProperty(r,b).get(),k,q;if(r.type==="date"?q=lt.dfltRange(r.calendar):n==="y"?q=$v.DFLTRANGEY:r._name==="realaxis"?q=[0,1]:q=w.dfltRange||$v.DFLTRANGEX,q=q.slice(),(r.rangemode==="tozero"||r.rangemode==="nonnegative")&&(q[0]=0),!S||S.length!==2){lt.nestedProperty(r,b).set(q);return}var D=S[0]===null,P=S[1]===null;for(r.type==="date"&&!r.autorange&&(S[0]=lt.cleanDate(S[0],ui,r.calendar),S[1]=lt.cleanDate(S[1],ui,r.calendar)),k=0;k<2;k++)if(r.type==="date"){if(!lt.isDateTime(S[k],r.calendar)){r[b]=q;break}if(r.r2l(S[0])===r.r2l(S[1])){var z=lt.constrain(r.r2l(S[0]),lt.MIN_MS+1e3,lt.MAX_MS-1e3);S[0]=r.l2r(z-1e3),S[1]=r.l2r(z+1e3);break}}else{if(!Ei(S[k]))if(!(D||P)&&Ei(S[1-k]))S[k]=S[1-k]*(k?10:.1);else{r[b]=q;break}if(S[k]<-Gv?S[k]=-Gv:S[k]>Gv&&(S[k]=Gv),S[0]===S[1]){var F=Math.max(1,Math.abs(S[0]*1e-6));S[0]-=F,S[1]+=F}}},r.setScale=function(b){var w=t._size;if(r.overlaying){var S=Zv.getFromId({_fullLayout:t},r.overlaying);r.domain=S.domain}var k=b&&r._r?"_r":"range",q=r.calendar;r.cleanRange(k);var D=r.r2l(r[k][0],q),P=r.r2l(r[k][1],q),z=n==="y";if(z?(r._offset=w.t+(1-r.domain[1])*w.h,r._length=w.h*(r.domain[1]-r.domain[0]),r._m=r._length/(D-P),r._b=-r._m*P):(r._offset=w.l+r.domain[0]*w.w,r._length=w.w*(r.domain[1]-r.domain[0]),r._m=r._length/(P-D),r._b=-r._m*D),r._rangebreaks=[],r._lBreaks=0,r._m2=0,r._B=[],r.rangebreaks){var F,I;if(r._rangebreaks=r.locateBreaks(Math.min(D,P),Math.max(D,P)),r._rangebreaks.length){for(F=0;FP&&(H=!H),H&&r._rangebreaks.reverse();var O=H?-1:1;for(r._m2=O*r._length/(Math.abs(P-D)-r._lBreaks),r._B.push(-r._m2*(z?P:D)),F=0;Fq&&(q+=7,Dq&&(q+=24,D=k&&D=k&&b=Q.min&&(reQ.max&&(Q.max=se),ve=!1)}ve&&P.push({min:re,max:se})}};for(S=0;S{"use strict";var J9=Br(),py=Ne(),FG=Zt().BADNUM,jv=py.isArrayOrTypedArray,IG=py.isDateTime,HG=py.cleanNumber,K9=Math.round;Q9.exports=function(r,t,a){var n=r,i=a.noMultiCategory;if(jv(n)&&!n.length)return"-";if(!i&&VG(n))return"multicategory";if(i&&Array.isArray(n[0])){for(var l=[],o=0;oi*2}function $9(e){return Math.max(1,(e-1)/1e3)}function UG(e,r){for(var t=e.length,a=$9(t),n=0,i=0,l={},o=0;on*2}function VG(e){return jv(e[0])&&jv(e[1])}});var Uf=X((ofe,lw)=>{"use strict";var GG=Nr(),tw=Br(),Fl=Ne(),e1=Zt().FP_SAFE,WG=_r(),XG=at(),aw=$t(),ZG=aw.getFromId,JG=aw.isLinked;lw.exports={applyAutorangeOptions:iw,getAutoRange:yy,makePadFn:gy,doAutoRange:$G,findExtremes:QG,concatExtremes:xy};function yy(e,r){var t,a,n=[],i=e._fullLayout,l=gy(i,r,0),o=gy(i,r,1),s=xy(e,r),u=s.min,f=s.max;if(u.length===0||f.length===0)return Fl.simpleMap(r.range,r.r2l);var v=u[0].val,h=f[0].val;for(t=1;t0&&(P=M-l(S)-o(k),P>b?z/P>w&&(q=S,D=k,w=z/P):z/M>w&&(q={val:S.val,nopad:1},D={val:k.val,nopad:1},w=z/M));function F(Y,B){return Math.max(Y,o(B))}if(v===h){var I=v-1,H=v+1;if(x)if(v===0)n=[0,1];else{var O=(v>0?f:u).reduce(F,0),G=v/(1-Math.min(.5,O/M));n=v>0?[0,G]:[G,0]}else T?n=[Math.max(0,I),Math.max(1,H)]:n=[I,H]}else x?(q.val>=0&&(q={val:0,nopad:1}),D.val<=0&&(D={val:0,nopad:1})):T&&(q.val-w*l(q)<0&&(q={val:0,nopad:1}),D.val<=0&&(D={val:1,nopad:1})),w=(D.val-q.val-j9(r,S.val,k.val))/(M-l(q)-o(D)),n=[q.val-w*l(q),D.val+w*o(D)];return n=iw(n,r),r.limitRange&&r.limitRange(),m&&n.reverse(),Fl.simpleMap(n,r.l2r||Number)}function j9(e,r,t){var a=0;if(e.rangebreaks)for(var n=e.locateBreaks(r,t),i=0;i0?t.ppadplus:t.ppadminus)||t.ppad||0),S=b((e._m>0?t.ppadminus:t.ppadplus)||t.ppad||0),k=b(t.vpadplus||t.vpad),q=b(t.vpadminus||t.vpad);if(!u){if(T=1/0,M=-1/0,s)for(v=0;v0&&(T=h),h>M&&h-e1&&(T=h),h>M&&h=z;v--)P(v);return{min:a,max:n,opts:t}}function by(e,r,t,a){nw(e,r,t,a,jG)}function _y(e,r,t,a){nw(e,r,t,a,eW)}function nw(e,r,t,a,n){for(var i=a.tozero,l=a.extrapad,o=!0,s=0;s=t&&(u.extrapad||!l)){o=!1;break}else n(r,u.val)&&u.pad<=t&&(l||!u.extrapad)&&(e.splice(s,1),s--)}if(o){var f=i&&r===0;e.push({val:r,pad:f?0:t,extrapad:f?!1:l})}}function rw(e){return tw(e)&&Math.abs(e)=r}function rW(e,r){var t=r.autorangeoptions;return t&&t.minallowed!==void 0&&r1(r,t.minallowed,t.maxallowed)?t.minallowed:t&&t.clipmin!==void 0&&r1(r,t.clipmin,t.clipmax)?Math.max(e,r.d2l(t.clipmin)):e}function tW(e,r){var t=r.autorangeoptions;return t&&t.maxallowed!==void 0&&r1(r,t.minallowed,t.maxallowed)?t.maxallowed:t&&t.clipmax!==void 0&&r1(r,t.clipmin,t.clipmax)?Math.min(e,r.d2l(t.clipmax)):e}function r1(e,r,t){return r!==void 0&&t!==void 0?(r=e.d2l(r),t=e.d2l(t),r=s&&(i=s,t=s),l<=s&&(l=s,a=s)}}return t=rW(t,r),a=tW(a,r),[t,a]}});var mt=X((ufe,Lw)=>{"use strict";var Pn=Nr(),zt=Br(),hu=Ut(),Gf=_r(),br=Ne(),du=br.strTranslate,Io=da(),aW=Bf(),Wf=Er(),yn=at(),nW=oi(),ow=Hm(),sfe=ta(),Aa=Zt(),iW=Aa.ONEMAXYEAR,n1=Aa.ONEAVGYEAR,i1=Aa.ONEMINYEAR,lW=Aa.ONEMAXQUARTER,Ay=Aa.ONEAVGQUARTER,l1=Aa.ONEMINQUARTER,oW=Aa.ONEMAXMONTH,pu=Aa.ONEAVGMONTH,o1=Aa.ONEMINMONTH,gn=Aa.ONEWEEK,Ha=Aa.ONEDAY,Il=Ha/2,zi=Aa.ONEHOUR,Xf=Aa.ONEMIN,s1=Aa.ONESEC,sW=Aa.ONEMILLI,uW=Aa.ONEMICROSEC,Ho=Aa.MINUS_SIGN,f1=Aa.BADNUM,ky={K:"zeroline"},Cy={K:"gridline",L:"path"},Sy={K:"minor-gridline",L:"path"},gw={K:"tick",L:"path"},sw={K:"tick",L:"text"},uw={width:["x","r","l","xl","xr"],height:["y","t","b","yt","yb"],right:["r","xr"],left:["l","xl"],top:["t","yt"],bottom:["b","yb"]},c1=La(),Vf=c1.MID_SHIFT,Oo=c1.CAP_SHIFT,Zf=c1.LINE_SPACING,fW=c1.OPPOSITE_SIDE,u1=3,Be=Lw.exports={};Be.setConvert=Qv();var cW=my(),Ni=$t(),vW=Ni.idSort,hW=Ni.isLinked;Be.id2name=Ni.id2name;Be.name2id=Ni.name2id;Be.cleanId=Ni.cleanId;Be.list=Ni.list;Be.listIds=Ni.listIds;Be.getFromId=Ni.getFromId;Be.getFromTrace=Ni.getFromTrace;var bw=Uf();Be.getAutoRange=bw.getAutoRange;Be.findExtremes=bw.findExtremes;var dW=1e-4;function Ey(e){var r=(e[1]-e[0])*dW;return[e[0]-r,e[1]+r]}function pW(e,r,t){if(![t,e,r].every(zt)||t===0)return e;let a=Math.round((e-r)/t),n=r+a*t;return e!==n&&Math.abs(e-n)h+" domain")),s=s.concat(i||[]),f.length>l?(br.warn("Array attribute "+u+" has more entries than expected, truncating to "+l),f=f.slice(0,l)):f.length2e-6||((t-e._forceTick0)/e._minDtick%1+1.000001)%1>2e-6)&&(e._minDtick=0))};Be.saveRangeInitial=function(e,r){for(var t=Be.list(e,"",!0),a=!1,n=0;nv*.3||u(a)||u(n))){var h=t.dtick/2;e+=e+hl){var o=Number(t.slice(1));i.exactYears>l&&o%12===0?e=Be.tickIncrement(e,"M6","reverse")+Ha*1.5:i.exactMonths>l?e=Be.tickIncrement(e,"M1","reverse")+Ha*15.5:e-=Il;var s=Be.tickIncrement(e,t);if(s<=a)return s}return e}Be.prepMinorTicks=function(e,r,t){var v,h;if(!((v=r.minor)!=null&&v.dtick)){delete e.dtick;var a=r.dtick&&zt(r._tmin),n;if(a){var i=Be.tickIncrement(r._tmin,r.dtick,!0);n=[r._tmin,i*.99+r._tmin*.01]}else{var l=br.simpleMap(r.range,r.r2l);n=[l[0],.8*l[0]+.2*l[1]]}if(e.range=br.simpleMap(n,r.l2r),e._isMinor=!0,Be.prepTicks(e,t),a){var o=zt(r.dtick),s=zt(e.dtick),u=o?r.dtick:+r.dtick.substring(1),f=s?e.dtick:+e.dtick.substring(1);o&&s?wy(u,f)?u===2*gn&&f===2*Ha&&(e.dtick=gn):u===2*gn&&f===3*Ha?e.dtick=gn:u===gn&&!(r._input.minor||{}).nticks?e.dtick=Ha:vw(u/f,2.5)?e.dtick=u/2:e.dtick=u:String(r.dtick).charAt(0)==="M"?s?e.dtick="M1":wy(u,f)?u>=12&&f===2&&(e.dtick="M3"):e.dtick=r.dtick:String(e.dtick).charAt(0)==="L"?String(r.dtick).charAt(0)==="L"?wy(u,f)||(e.dtick=vw(u/f,2.5)?r.dtick/2:r.dtick):e.dtick="D1":e.dtick==="D2"&&+r.dtick>1&&(e.dtick=1)}e.range=r.range}((h=r.minor)==null?void 0:h._tick0Init)===void 0&&(e.tick0=r.tick0)};function wy(e,r){return Math.abs((e/r+.5)%1-.5)<.001}function vw(e,r){return Math.abs(e/r-1)<.001}Be.prepTicks=function(e,r){var t=br.simpleMap(e.range,e.r2l,void 0,void 0,r);if(e.tickmode==="auto"||!e.dtick){var a=e.nticks,n;a||(e.type==="category"||e.type==="multicategory"?(n=e.tickfont?br.bigFont(e.tickfont.size||12):15,a=e._length/n):(n=e._id.charAt(0)==="y"?40:80,a=br.constrain(e._length/n,4,9)+1),e._name==="radialaxis"&&(a*=2)),e.minor&&e.minor.tickmode!=="array"||e.tickmode==="array"&&(a*=100),e._roughDTick=Math.abs(t[1]-t[0])/a,Be.autoTicks(e,e._roughDTick),e._minDtick>0&&e.dtick0?(i=a-1,l=a):(i=a,l=a);var o=e[i].value,s=e[l].value,u=Math.abs(s-o),f=t||u,v=0;f>=i1?u>=i1&&u<=iW?v=u:v=n1:t===Ay&&f>=l1?u>=l1&&u<=lW?v=u:v=Ay:f>=o1?u>=o1&&u<=oW?v=u:v=pu:t===gn&&f>=gn?v=gn:f>=Ha?v=Ha:t===Il&&f>=Il?v=Il:t===zi&&f>=zi&&(v=zi);var h;v>=u&&(v=u,h=!0);var d=n+v;if(r.rangebreaks&&v>0){for(var m=84,y=0,g=0;ggn&&(v=u)}(v>0||a===0)&&(e[a].periodX=n+v/2)}}Be.calcTicks=function(r,t){for(var a=r.type,n=r.calendar,i=r.ticklabelstep,l=r.ticklabelmode==="period",o=r.range[0]>r.range[1],s=!r.ticklabelindex||br.isArrayOrTypedArray(r.ticklabelindex)?r.ticklabelindex:[r.ticklabelindex],u=br.simpleMap(r.range,r.r2l,void 0,void 0,t),f=u[1]=(b?0:1);w--){var S=!w;w?(r._dtickInit=r.dtick,r._tick0Init=r.tick0):b&&(r.minor._dtickInit=r.minor.dtick,r.minor._tick0Init=r.minor.tick0);var k=w?r:br.extendFlat({},r,b?r.minor:{minor:{}});if(S?Be.prepMinorTicks(k,r,t):Be.prepTicks(k,t),k.tickmode==="array"){w?(g=[],m=hw(r,!S)):(x=[],y=hw(r,!S));continue}if(k.tickmode==="sync"){g=[],m=xW(r);continue}var q=Ey(u),D=q[0],P=q[1],z=zt(k.dtick),F=a==="log"&&!(z||k.dtick.charAt(0)==="L"),I=Be.tickFirst(k,t);if(w){if(r._tmin=I,I=P:O<=P;O=Be.tickIncrement(O,B,f,n)){if(w&&G++,k.rangebreaks&&!f){if(O=h)break}if(g.length>d||O===H)break;H=O;var J={value:pW(O,V,B)};w?(O===I&&(J.first=!0),F&&O!==(O|0)&&(J.simpleLabel=!0),i>1&&G%i&&(J.skipLabel=!0),g.push(J)):(J.minor=!0,x.push(J))}}if(!x||x.length<3)s=!1;else{var Z=(x[2].value-x[1].value)*(o?-1:1);WW(Z,r.tickformat)||(s=!1,x=x.slice(1))}if(!s)T=g;else{var re=g.concat(x);l&&g.length&&(re=re.slice(1)),re=re.sort(function(We,ke){return We.value-ke.value}).filter(function(We,ke,ze){return ke===0||We.value!==ze[ke-1].value});var se=re.map(function(We,ke){return We.minor===void 0&&!We.skipLabel?ke:null}).filter(function(We){return We!==null});se.forEach(function(We){s.map(function(ke){var ze=We+ke;ze>=0&&ze-1;Me--){if(g[Me].drop){g.splice(Me,1);continue}g[Me].value=My(g[Me].value,r);var Le=r.c2p(g[Me].value);(Re?Pe>Le-Te:Peh||Heh&&(ze.periodX=h),Hen&&mn&&gn1)r/=n1,a=n(10),e.dtick="M"+12*Ri(r,a,t1);else if(i>pu)r/=pu,e.dtick="M"+Ri(r,1,dw);else if(i>Ha){if(e.dtick=Ri(r,Ha,e._hasDayOfWeekBreaks?[1,2,7,14]:wW),!t){var l=Be.getTickFormat(e),o=e.ticklabelmode==="period";o&&(e._rawTick0=e.tick0),/%[uVW]/.test(l)?e.tick0=br.dateTick0(e.calendar,2):e.tick0=br.dateTick0(e.calendar,1),o&&(e._dowTick0=e.tick0)}}else i>zi?e.dtick=Ri(r,zi,dw):i>Xf?e.dtick=Ri(r,Xf,pw):i>s1?e.dtick=Ri(r,s1,pw):(a=n(10),e.dtick=Ri(r,a,t1))}else if(e.type==="log"){e.tick0=0;var s=br.simpleMap(e.range,e.r2l);if(e._isMinor&&(r*=1.5),r>.7)e.dtick=Math.ceil(r);else if(Math.abs(s[1]-s[0])<1){var u=1.5*Math.abs((s[1]-s[0])/r);r=Math.abs(Math.pow(10,s[1])-Math.pow(10,s[0]))/u,a=n(10),e.dtick="L"+Ri(r,a,t1)}else e.dtick=r>.3?"D2":"D1"}else e.type==="category"||e.type==="multicategory"?(e.tick0=0,e.dtick=Math.ceil(Math.max(r,1))):Ny(e)?(e.tick0=0,a=1,e.dtick=Ri(r,a,TW)):(e.tick0=0,a=n(10),e.dtick=Ri(r,a,t1));if(e.dtick===0&&(e.dtick=1),!zt(e.dtick)&&typeof e.dtick!="string"){var f=e.dtick;throw e.dtick=1,"ax.dtick error: "+String(f)}};function Tw(e){var r=e.dtick;if(e._tickexponent=0,!zt(r)&&typeof r!="string"&&(r=1),(e.type==="category"||e.type==="multicategory")&&(e._tickround=null),e.type==="date"){var t=e.r2l(e.tick0),a=e.l2r(t).replace(/(^-|i)/g,""),n=a.length;if(String(r).charAt(0)==="M")n>10||a.slice(5)!=="01-01"?e._tickround="d":e._tickround=+r.slice(1)%12===0?"y":"m";else if(r>=Ha&&n<=10||r>=Ha*15)e._tickround="d";else if(r>=Xf&&n<=16||r>=zi)e._tickround="M";else if(r>=s1&&n<=19||r>=Xf)e._tickround="S";else{var i=e.l2r(t+r).replace(/^-/,"").length;e._tickround=Math.max(n,i)-20,e._tickround<0&&(e._tickround=4)}}else if(zt(r)||r.charAt(0)==="L"){var l=e.range.map(e.r2d||Number);zt(r)||(r=Number(r.slice(1))),e._tickround=2-Math.floor(Math.log(r)/Math.LN10+.01);var o=Math.max(Math.abs(l[0]),Math.abs(l[1])),s=Math.floor(Math.log(o)/Math.LN10+.01),u=e.minexponent===void 0?3:e.minexponent;Math.abs(s)>u&&(mu(e.exponentformat)&&e.exponentformat!=="SI extended"&&!Py(s)||mu(e.exponentformat)&&e.exponentformat==="SI extended"&&!Ry(s)?e._tickexponent=3*Math.round((s-1)/3):e._tickexponent=s)}else e._tickround=null}Be.tickIncrement=function(e,r,t,a){var n=t?-1:1;if(zt(r))return br.increment(e,n*r);var i=r.charAt(0),l=n*Number(r.slice(1));if(i==="M")return br.incrementMonth(e,l,a);if(i==="L")return Math.log(Math.pow(10,e)+l)/Math.LN10;if(i==="D"){var o=r==="D2"?ww:xw,s=e+n*.01,u=br.roundUp(br.mod(s,1),o,t);return Math.floor(s)+Math.log(Pn.round(Math.pow(10,u),1))/Math.LN10}throw"unrecognized dtick "+String(r)};Be.tickFirst=function(e,r){var t=e.r2l||Number,a=br.simpleMap(e.range,t,void 0,void 0,r),n=a[1]=0&&b<=e._length?M:null};if(s&&br.isArrayOrTypedArray(e.ticktext)){var m=br.simpleMap(e.range,e.r2l),y=(Math.abs(m[1]-m[0])-(e._lBreaks||0))/1e4;for(h=0;h"+o;else{var u=Kf(e),f=e._trueSide||e.side;(!u&&f==="top"||u&&f==="bottom")&&(l+="
")}r.text=l}function AW(e,r,t,a,n){var i=e.dtick,l=r.x,o=e.tickformat,s=typeof i=="string"&&i.charAt(0);if(n==="never"&&(n=""),a&&s!=="L"&&(i="L3",s="L"),o||s==="L")r.text=Jf(Math.pow(10,l),e,n,a);else if(zt(i)||s==="D"&&(e.minorloglabels==="complete"||br.mod(l+.01,1)<.1)){var u;e.minorloglabels==="complete"&&!(br.mod(l+.01,1)<.1)&&(u=!0,r.fontSize*=.75);var f=Math.pow(10,l).toExponential(0),v=f.split("e"),h=+v[1],d=Math.abs(h),m=e.exponentformat;m==="power"||mu(m)&&m!=="SI extended"&&Py(h)||mu(m)&&m==="SI extended"&&Ry(h)?(r.text=v[0],d>0&&(r.text+="x10"),r.text==="1x10"&&(r.text="10"),h!==0&&h!==1&&(r.text+=""+(h>0?"":Ho)+d+""),r.fontSize*=1.25):(m==="e"||m==="E")&&d>2?r.text=v[0]+m+(h>0?"+":Ho)+d:(r.text=Jf(Math.pow(10,l),e,"","fakehover"),i==="D1"&&e._id.charAt(0)==="y"&&(r.dy-=r.fontSize/6))}else if(s==="D")r.text=e.minorloglabels==="none"?"":String(Math.round(Math.pow(10,br.mod(l,1)))),r.fontSize*=.75;else throw"unrecognized dtick "+String(i);if(e.dtick==="D1"){var y=String(r.text).charAt(0);(y==="0"||y==="1")&&(e._id.charAt(0)==="y"?r.dx-=r.fontSize/4:(r.dy+=r.fontSize/2,r.dx+=(e.range[1]>e.range[0]?1:-1)*r.fontSize*(l<0?.5:.25)))}}function kW(e,r){var t=e._categories[Math.round(r.x)];t===void 0&&(t=""),r.text=String(t)}function CW(e,r,t){var a=Math.round(r.x),n=e._categories[a]||[],i=n[1]===void 0?"":String(n[1]),l=n[0]===void 0?"":String(n[0]);t?r.text=l+" - "+i:(r.text=i,r.text2=l)}function SW(e,r,t,a,n){n==="never"?n="":e.showexponent==="all"&&Math.abs(r.x/e.dtick)<1e-6&&(n="hide"),r.text=Jf(r.x,e,n,a)}function LW(e,r,t,a,n){if(e.thetaunit==="radians"&&!t){var i=r.x/180;if(i===0)r.text="0";else{var l=qW(i);if(l[1]>=100)r.text=Jf(br.deg2rad(r.x),e,n,a);else{var o=r.x<0;l[1]===1?l[0]===1?r.text="\u03C0":r.text=l[0]+"\u03C0":r.text=["",l[0],"","\u2044","",l[1],"","\u03C0"].join(""),o&&(r.text=Ho+r.text)}}}else r.text=Jf(r.x,e,n,a)}function qW(e){function r(o,s){return Math.abs(o-s)<=1e-6}function t(o,s){return r(s,0)?o:t(s,o%s)}function a(o){for(var s=1;!r(Math.round(o*s)/s,o);)s*=10;return s}var n=a(e),i=e*n,l=Math.abs(t(i,n));return[Math.round(i/l),Math.round(n/l)]}var Aw=["f","p","n","\u03BC","m","","k","M","G","T"],DW=["q","r","y","z","a",...Aw,"P","E","Z","Y","R","Q"],mu=e=>["SI","SI extended","B"].includes(e);function Py(e){return e>14||e<-15}function Ry(e){return e>32||e<-30}function EW(e,r){return mu(r)?!!(r==="SI extended"&&Ry(e)||r!=="SI extended"&&Py(e)):!1}function Jf(e,r,t,a){var n=e<0,i=r._tickround,l=t||r.exponentformat||"B",o=r._tickexponent,s=Be.getTickFormat(r),u=r.separatethousands;if(a){var f={exponentformat:l,minexponent:r.minexponent,dtick:r.showexponent==="none"?r.dtick:zt(e)&&Math.abs(e)||1,range:r.showexponent==="none"?r.range.map(r.r2d):[0,e||1]};Tw(f),i=(Number(f._tickround)||0)+4,o=f._tickexponent,r.hoverformat&&(s=r.hoverformat)}if(s)return r._numFormat(s)(e).replace(/-/g,Ho);l==="none"&&(o=0),e=Math.abs(e);let v=Math.pow(10,-i)/2;if(e"+h+"":l==="B"&&o===9?e+="B":mu(l)&&(e+=l==="SI extended"?DW[o/3+10]:Aw[o/3+5])}return n?Ho+e:e}Be.getTickFormat=function(e){var r;function t(s){return typeof s!="string"?s:Number(s.replace("M",""))*pu}function a(s,u){var f=["L","D"];if(typeof s==typeof u){if(typeof s=="number")return s-u;var v=f.indexOf(s.charAt(0)),h=f.indexOf(u.charAt(0));return v===h?Number(s.replace(/(L|D)/g,""))-Number(u.replace(/(L|D)/g,"")):v-h}else return typeof s=="number"?1:-1}function n(s,u,f){var v=f||function(m){return m},h=u[0],d=u[1];return(!h&&typeof h!="number"||v(h)<=v(s))&&(!d&&typeof d!="number"||v(d)>=v(s))}function i(s,u){var f=u[0]===null,v=u[1]===null,h=a(s,u[0])>=0,d=a(s,u[1])<=0;return(f||h)&&(v||d)}var l,o;if(e.tickformatstops&&e.tickformatstops.length>0)switch(e.type){case"date":case"linear":{for(r=0;r=0&&n.unshift(n.splice(f,1).shift())}});var o={false:{left:0,right:0}};return br.syncOrAsync(n.map(function(s){return function(){if(s){var u=Be.getFromId(e,s);t||(t={}),t.axShifts=o,t.overlayingShiftedAx=l;var f=Be.drawOne(e,u,t);return u._shiftPusher&&Dy(u,u._fullDepth||0,o,!0),u._r=u.range.slice(),u._rl=br.simpleMap(u._r,u.r2l),f}}}))};Be.drawOne=function(e,r,t){t=t||{};var a=t.axShifts||{},n=t.overlayingShiftedAx||[],i,l,o;r.setScale();var s=e._fullLayout,u=r._id,f=u.charAt(0),v=Be.counterLetter(u),h=s._plots[r._mainSubplot],d=r.zerolinelayer==="above traces";if(!h)return;if(r._shiftPusher=r.autoshift||n.indexOf(r._id)!==-1||n.indexOf(r.overlaying)!==-1,r._shiftPusher&r.anchor==="free"){var m=r.linewidth/2||0;r.ticks==="inside"&&(m+=r.ticklen),Dy(r,m,a,!0),Dy(r,r.shift||0,a,!1)}(t.skipTitle!==!0||r._shift===void 0)&&(r._shift=GW(r,a));var y=h[f+"axislayer"],g=r._mainLinePosition,x=g+=r._shift,T=r._mainMirrorPosition,M=r._vals=Be.calcTicks(r),b=[r.mirror,x,T].join("_");for(i=0;i0?ke.bottom-Ke:0,We))));var he=0,Ze=0;if(r._shiftPusher&&(he=Math.max(We,ke.height>0?qe==="l"?Ke-ke.left:ke.right-Ke:0),r.title.text!==s._dfltTitle[f]&&(Ze=(r._titleStandoff||0)+(r._titleScoot||0),qe==="l"&&(Ze+=yw(r))),r._fullDepth=Math.max(he,Ze)),r.automargin){ze={x:0,y:0,r:0,l:0,t:0,b:0};var hr=[0,1],De=typeof r._shift=="number"?r._shift:0;if(f==="x"){if(qe==="b"?ze[qe]=r._depth:(ze[qe]=r._depth=Math.max(ke.width>0?Ke-ke.top:0,We),hr.reverse()),ke.width>0){var me=ke.right-(r._offset+r._length);me>0&&(ze.xr=1,ze.r=me);var be=r._offset-ke.left;be>0&&(ze.xl=0,ze.l=be)}}else if(qe==="l"?(r._depth=Math.max(ke.height>0?Ke-ke.left:0,We),ze[qe]=r._depth-De):(r._depth=Math.max(ke.height>0?ke.right-Ke:0,We),ze[qe]=r._depth+De,hr.reverse()),ke.height>0){var Fe=ke.bottom-(r._offset+r._length);Fe>0&&(ze.yb=0,ze.b=Fe);var Ge=r._offset-ke.top;Ge>0&&(ze.yt=1,ze.t=Ge)}ze[v]=r.anchor==="free"?r.position:r._anchorAxis.domain[hr[0]],r.title.text!==s._dfltTitle[f]&&(ze[qe]+=yw(r)+(r.title.standoff||0)),r.mirror&&r.anchor!=="free"&&(He={x:0,y:0,r:0,l:0,t:0,b:0},He[Je]=r.linewidth,r.mirror&&r.mirror!==!0&&(He[Je]+=We),r.mirror===!0||r.mirror==="ticks"?He[v]=r._anchorAxis.domain[hr[1]]:(r.mirror==="all"||r.mirror==="allticks")&&(He[v]=[r._counterDomainMin,r._counterDomainMax][hr[1]]))}Oe&&(te=Gf.getComponentMethod("rangeslider","autoMarginOpts")(e,r)),typeof r.automargin=="string"&&(mw(ze,r.automargin),mw(He,r.automargin)),hu.autoMargin(e,zy(r),ze),hu.autoMargin(e,Cw(r),He),hu.autoMargin(e,Sw(r),te)}),br.syncOrAsync(Ce)}};function mw(e,r){if(e){var t=Object.keys(uw).reduce(function(a,n){return r.indexOf(n)!==-1&&uw[n].forEach(function(i){a[i]=1}),a},{});Object.keys(e).forEach(function(a){t[a]||(a.length===1?e[a]=0:delete e[a])})}}function PW(e,r){var t=[],a,n=function(i,l){var o=i.xbnd[l];o!==null&&t.push(br.extendFlat({},i,{x:o}))};if(r.length){for(a=0;ae.range[1],o=e.ticklabelposition&&e.ticklabelposition.indexOf("inside")!==-1,s=!o;if(t){var u=l?-1:1;t=t*u}if(a){var f=e.side,v=o&&(f==="top"||f==="left")||s&&(f==="bottom"||f==="right")?1:-1;a=a*v}return e._id.charAt(0)==="x"?function(h){return du(n+e._offset+e.l2p(Ly(h))+t,i+a)}:function(h){return du(i+a,n+e._offset+e.l2p(Ly(h))+t)}};function Ly(e){return e.periodX!==void 0?e.periodX:e.x}function FW(e){var r=e.ticklabelposition||"",t=e.tickson||"",a=function(m){return r.indexOf(m)!==-1},n=a("top"),i=a("left"),l=a("right"),o=a("bottom"),s=a("inside"),u=t!=="boundaries"&&(o||i||n||l);if(!u&&!s)return[0,0];var f=e.side,v=u?(e.tickwidth||0)/2:0,h=u1,d=e.tickfont?e.tickfont.size:12;return(o||n)&&(v+=d*Oo,h+=(e.linewidth||0)/2),(i||l)&&(v+=(e.linewidth||0)/2,h+=u1),s&&f==="top"&&(h-=d*(1-Oo)),(i||n)&&(v=-v),(f==="bottom"||f==="right")&&(h=-h),[u?v:0,s?h:0]}Be.makeTickPath=function(e,r,t,a){a||(a={});var n=a.minor;if(n&&!e.minor)return"";var i=a.len!==void 0?a.len:n?e.minor.ticklen:e.ticklen,l=e._id.charAt(0),o=(e.linewidth||1)/2;return l==="x"?"M0,"+(r+o*t)+"v"+i*t:"M"+(r+o*t)+",0h"+i*t};Be.makeLabelFns=function(e,r,t){var a=e.ticklabelposition||"",n=e.tickson||"",i=function(I){return a.indexOf(I)!==-1},l=i("top"),o=i("left"),s=i("right"),u=i("bottom"),f=n!=="boundaries"&&(u||o||l||s),v=i("inside"),h=a==="inside"&&e.ticks==="inside"||!v&&e.ticks==="outside"&&n!=="boundaries",d=0,m=0,y=h?e.ticklen:0;if(v?y*=-1:f&&(y=0),h&&(d+=y,t)){var g=br.deg2rad(t);d=y*Math.cos(g)+1,m=y*Math.sin(g)}e.showticklabels&&(h||e.showline)&&(d+=.2*e.tickfont.size),d+=(e.linewidth||1)/2*(v?-1:1);var x={labelStandoff:d,labelShift:m},T,M,b,w,S=0,k=e.side,q=e._id.charAt(0),D=e.tickangle,P;if(q==="x")P=!v&&k==="bottom"||v&&k==="top",w=P?1:-1,v&&(w*=-1),T=m*w,M=r+d*w,b=P?1:-.2,Math.abs(D)===90&&(v?b+=Vf:D===-90&&k==="bottom"?b=Oo:D===90&&k==="top"?b=Vf:b=.5,S=Vf/2*(D/90)),x.xFn=function(I){return I.dx+T+S*I.fontSize},x.yFn=function(I){return I.dy+M+I.fontSize*b},x.anchorFn=function(I,H){if(f){if(o)return"end";if(s)return"start"}return!zt(H)||H===0||H===180?"middle":H*w<0!==v?"end":"start"},x.heightFn=function(I,H,O){return H<-60||H>60?-.5*O:e.side==="top"!==v?-O:0};else if(q==="y"){if(P=!v&&k==="left"||v&&k==="right",w=P?1:-1,v&&(w*=-1),T=d,M=m*w,b=0,!v&&Math.abs(D)===90&&(D===-90&&k==="left"||D===90&&k==="right"?b=Oo:b=.5),v){var z=zt(D)?+D:0;if(z!==0){var F=br.deg2rad(z);S=Math.abs(Math.sin(F))*Oo*w,b=0}}x.xFn=function(I){return I.dx+r-(T+I.fontSize*b)*w+S*I.fontSize},x.yFn=function(I){return I.dy+M+I.fontSize*Vf},x.anchorFn=function(I,H){return zt(H)&&Math.abs(H)===90?"middle":P?"end":"start"},x.heightFn=function(I,H,O){return e.side==="right"&&(H*=-1),H<-30?-O:H<30?-.5*O:0}}return x};function v1(e){return[e.text,e.x,e.axInfo,e.font,e.fontSize,e.fontColor].join("_")}Be.drawTicks=function(e,r,t){t=t||{};var a=r._id+"tick",n=[].concat(r.minor&&r.minor.ticks?t.vals.filter(function(l){return l.minor&&!l.noTick}):[]).concat(r.ticks?t.vals.filter(function(l){return!l.minor&&!l.noTick}):[]),i=t.layer.selectAll("path."+a).data(n,v1);i.exit().remove(),i.enter().append("path").classed(a,1).classed("ticks",1).classed("crisp",t.crisp!==!1).each(function(l){return Wf.stroke(Pn.select(this),l.minor?r.minor.tickcolor:r.tickcolor)}).style("stroke-width",function(l){return yn.crispRound(e,l.minor?r.minor.tickwidth:r.tickwidth,1)+"px"}).attr("d",t.path).style("display",null),h1(r,[gw]),i.attr("transform",t.transFn)};Be.drawGrid=function(e,r,t){if(t=t||{},r.tickmode!=="sync"){var a=r._id+"grid",n=r.minor&&r.minor.showgrid,i=n?t.vals.filter(function(x){return x.minor}):[],l=r.showgrid?t.vals.filter(function(x){return!x.minor}):[],o=t.counterAxis;if(o&&Be.shouldShowZeroLine(e,r,o))for(var s=r.tickmode==="array",u=0;u=0;m--){var y=m?h:d;if(y){var g=y.selectAll("path."+a).data(m?l:i,v1);g.exit().remove(),g.enter().append("path").classed(a,1).classed("crisp",t.crisp!==!1),g.attr("transform",t.transFn).attr("d",t.path).each(function(x){return Wf.stroke(Pn.select(this),x.minor?r.minor.gridcolor:r.gridcolor||"#ddd")}).style("stroke-dasharray",function(x){return yn.dashStyle(x.minor?r.minor.griddash:r.griddash,x.minor?r.minor.gridwidth:r.gridwidth)}).style("stroke-width",function(x){return(x.minor?v:r._gw)+"px"}).style("display",null),typeof t.path=="function"&&g.attr("d",t.path)}}h1(r,[Cy,Sy])}};Be.drawZeroLine=function(e,r,t){t=t||t;var a=r._id+"zl",n=Be.shouldShowZeroLine(e,r,t.counterAxis),i=t.layer.selectAll("path."+a).data(n?[{x:0,id:r._id}]:[]);i.exit().remove(),i.enter().append("path").classed(a,1).classed("zl",1).classed("crisp",t.crisp!==!1).each(function(){t.layer.selectAll("path").sort(function(l,o){return vW(l.id,o.id)})}),i.attr("transform",t.transFn).attr("d",t.path).call(Wf.stroke,r.zerolinecolor||Wf.defaultLine).style("stroke-width",yn.crispRound(e,r.zerolinewidth,r._gw||1)+"px").style("display",null),h1(r,[ky])};Be.drawLabels=function(e,r,t){t=t||{};var a=e._fullLayout,n=r._id,i=r.zerolinelayer==="above traces",l=t.cls||n+"tick",o=t.vals.filter(function(V){return V.text}),s=t.labelFns,u=t.secondary?0:r.tickangle,f=(r._prevTickAngles||{})[l],v=t.layer.selectAll("g."+l).data(r.showticklabels?o:[],v1),h=[];v.enter().append("g").classed(l,1).append("text").attr("text-anchor","middle").each(function(V){var J=Pn.select(this),Z=e._promises.length;J.call(Io.positionText,s.xFn(V),s.yFn(V)).call(yn.font,{family:V.font,size:V.fontSize,color:V.fontColor,weight:V.fontWeight,style:V.fontStyle,variant:V.fontVariant,textcase:V.fontTextcase,lineposition:V.fontLineposition,shadow:V.fontShadow}).text(V.text).call(Io.convertToTspans,e),e._promises[Z]?h.push(e._promises.pop().then(function(){d(J,u)})):d(J,u)}),h1(r,[sw]),v.exit().remove(),t.repositionOnUpdate&&v.each(function(V){Pn.select(this).select("text").call(Io.positionText,s.xFn(V),s.yFn(V))});function d(V,J){V.each(function(Z){var re=Pn.select(this),se=re.select(".text-math-group"),ve=s.anchorFn(Z,J),xe=t.transFn.call(re.node(),Z)+(zt(J)&&+J!=0?" rotate("+J+","+s.xFn(Z)+","+(s.yFn(Z)-Z.fontSize/2)+")":""),Q=Io.lineCount(re),we=Zf*Z.fontSize,fe=s.heightFn(Z,zt(J)?+J:0,(Q-1)*we);if(fe&&(xe+=du(0,fe)),se.empty()){var ge=re.select("text");ge.attr({transform:xe,"text-anchor":ve}),ge.style("display",null),r._adjustTickLabelsOverflow&&r._adjustTickLabelsOverflow()}else{var _e=yn.bBox(se.node()).width,ce=_e*{end:-.5,start:.5}[ve];se.attr("transform",xe+du(ce,0))}})}r._adjustTickLabelsOverflow=function(){var V=r.ticklabeloverflow;if(!(!V||V==="allow")){var J=V.indexOf("hide")!==-1,Z=r._id.charAt(0)==="x",re=0,se=Z?e._fullLayout.width:e._fullLayout.height;if(V.indexOf("domain")!==-1){var ve=br.simpleMap(r.range,r.r2l);re=r.l2p(ve[0])+r._offset,se=r.l2p(ve[1])+r._offset}var xe=Math.min(re,se),Q=Math.max(re,se),we=r.side,fe=1/0,ge=-1/0;v.each(function(Re){var Te=Pn.select(this),Pe=Te.select(".text-math-group");if(Pe.empty()){var Le=yn.bBox(Te.node()),Ce=0;Z?(Le.right>Q||Le.leftQ||Le.top+(r.tickangle?0:Re.fontSize/4)r["_visibleLabelMin_"+ve._id]?Te.style("display","none"):Q.K==="tick"&&!xe&&Te.node().style.display!=="none"&&Te.style("display",null)})})})})},d(v,f+1?f:u);function m(){return h.length&&Promise.all(h)}var y=null;function g(){if(d(v,u),o.length&&r.autotickangles&&(r.type!=="log"||String(r.dtick).charAt(0)!=="D")){y=r.autotickangles[0];var V=0,J=[],Z,re=1;v.each(function(ze){V=Math.max(V,ze.fontSize);var He=r.l2p(ze.x),te=qy(this),he=yn.bBox(te.node());re=Math.max(re,Io.lineCount(te)),J.push({top:0,bottom:10,height:10,left:He-he.width/2,right:He+he.width/2+2,width:he.width+2})});var se=(r.tickson==="boundaries"||r.showdividers)&&!t.secondary,ve=o.length,xe=Math.abs((o[ve-1].x-o[0].x)*r._m)/(ve-1),Q=se?xe/2:xe,we=se?r.ticklen:V*1.25*re,fe=Math.sqrt(Math.pow(Q,2)+Math.pow(we,2)),ge=Q/fe,_e=r.autotickangles.map(function(ze){return ze*Math.PI/180}),ce=_e.find(function(ze){return Math.abs(Math.cos(ze))<=ge});ce===void 0&&(ce=_e.reduce(function(ze,He){return Math.abs(Math.cos(ze))G*O&&(F=O,D[q]=P[q]=I[q])}var Y=Math.abs(F-z);Y-w>0?(Y-=w,w*=1+w/Y):w=0,r._id.charAt(0)!=="y"&&(w=-w),D[k]=M.p2r(M.r2p(P[k])+S*w),M.autorange==="min"||M.autorange==="max reversed"?(D[0]=null,M._rangeInitial0=void 0,M._rangeInitial1=void 0):(M.autorange==="max"||M.autorange==="min reversed")&&(D[1]=null,M._rangeInitial0=void 0,M._rangeInitial1=void 0),a._insideTickLabelsUpdaterange[M._name+".range"]=D}var B=br.syncOrAsync(x);return B&&B.then&&e._promises.push(B),B};function IW(e,r,t){var a=r._id+"divider",n=t.vals,i=t.layer.selectAll("path."+a).data(n,v1);i.exit().remove(),i.enter().insert("path",":first-child").classed(a,1).classed("crisp",1).call(Wf.stroke,r.dividercolor).style("stroke-width",yn.crispRound(e,r.dividerwidth,1)+"px"),i.attr("transform",t.transFn).attr("d",t.path)}Be.getPxPosition=function(e,r){var t=e._fullLayout._size,a=r._id.charAt(0),n=r.side,i;if(r.anchor!=="free"?i=r._anchorAxis:a==="x"?i={_offset:t.t+(1-(r.position||0))*t.h,_length:0}:a==="y"&&(i={_offset:t.l+(r.position||0)*t.w+r._shift,_length:0}),n==="top"||n==="left")return i._offset;if(n==="bottom"||n==="right")return i._offset+i._length};function yw(e){var r=e.title.font.size,t=(e.title.text.match(Io.BR_TAG_ALL)||[]).length;return e.title.hasOwnProperty("standoff")?r*(Oo+t*Zf):t?r*(t+1)*Zf:r}function HW(e,r){var t=e._fullLayout,a=r._id,n=a.charAt(0),i=r.title.font.size,l,o=(r.title.text.match(Io.BR_TAG_ALL)||[]).length;if(r.title.hasOwnProperty("standoff"))r.side==="bottom"||r.side==="right"?l=r._depth+r.title.standoff+i*Oo:(r.side==="top"||r.side==="left")&&(l=r._depth+r.title.standoff+i*(Vf+o*Zf));else{var s=Kf(r);if(r.type==="multicategory")l=r._depth;else{var u=1.5*i;s&&(u=.5*i,r.ticks==="outside"&&(u+=r.ticklen)),l=10+u+(r.linewidth?r.linewidth-1:0)}s||(n==="x"?l+=r.side==="top"?i*(r.showticklabels?1:0):i*(r.showticklabels?1.5:.5):l+=r.side==="right"?i*(r.showticklabels?1:.5):i*(r.showticklabels?.5:0))}var f=Be.getPxPosition(e,r),v,h,d;n==="x"?(h=r._offset+r._length/2,d=r.side==="top"?f-l:f+l):(d=r._offset+r._length/2,h=r.side==="right"?f+l:f-l,v={rotate:"-90",offset:0});var m;if(r.type!=="multicategory"){var y=r._selections[r._id+"tick"];if(m={selection:y,side:r.side},y&&y.node()&&y.node().parentNode){var g=yn.getTranslate(y.node().parentNode);m.offsetLeft=g.x,m.offsetTop=g.y}r.title.hasOwnProperty("standoff")&&(m.pad=0)}return r._titleStandoff=l,aW.draw(e,a+"title",{propContainer:r,propName:r._name+".title.text",placeholder:t._dfltTitle[n],avoid:m,transform:v,attributes:{x:h,y:d,"text-anchor":"middle"}})}Be.shouldShowZeroLine=function(e,r,t){var a=br.simpleMap(r.range,r.r2l);return a[0]*a[1]<=0&&r.zeroline&&(r.type==="linear"||r.type==="-")&&!(r.rangebreaks&&r.maskBreaks(0)===f1)&&(kw(r,0)||!OW(e,r,t,a)||BW(e,r))};Be.clipEnds=function(e,r){return r.filter(function(t){return kw(e,t.x)})};function kw(e,r){var t=e.l2p(r);return t>1&&t1)for(n=1;n=n.min&&e=uW:/%L/.test(r)?e>=sW:/%[SX]/.test(r)?e>=s1:/%M/.test(r)?e>=Xf:/%[HI]/.test(r)?e>=zi:/%p/.test(r)?e>=Il:/%[Aadejuwx]/.test(r)?e>=Ha:/%[UVW]/.test(r)?e>=gn:/%[Bbm]/.test(r)?e>=o1:/%[q]/.test(r)?e>=l1:/%[Yy]/.test(r)?e>=i1:!0}});var Dw=X((ffe,qw)=>{"use strict";qw.exports=function(r,t,a){var n,i;if(a){var l=t==="reversed"||t==="min reversed"||t==="max reversed";n=a[l?1:0],i=a[l?0:1]}var o=r("autorangeoptions.minallowed",i===null?n:void 0),s=r("autorangeoptions.maxallowed",n===null?i:void 0);o===void 0&&r("autorangeoptions.clipmin"),s===void 0&&r("autorangeoptions.clipmax"),r("autorangeoptions.include")}});var Fy=X((cfe,Ew)=>{"use strict";var XW=Dw();Ew.exports=function(r,t,a,n){var i=t._template||{},l=t.type||i.type||"-";a("minallowed"),a("maxallowed");var o=a("range");if(!o){var s;!n.noInsiderange&&l!=="log"&&(s=a("insiderange"),s&&(s[0]===null||s[1]===null)&&(t.insiderange=!1,s=void 0),s&&(o=a("range",s)))}var u=t.getAutorangeDflt(o,n),f=a("autorange",u),v;o&&(o[0]===null&&o[1]===null||(o[0]===null||o[1]===null)&&(f==="reversed"||f===!0)||o[0]!==null&&(f==="min"||f==="max reversed")||o[1]!==null&&(f==="max"||f==="min reversed"))&&(o=void 0,delete t.range,t.autorange=!0,v=!0),v||(u=t.getAutorangeDflt(o,n),f=a("autorange",u)),f&&(XW(a,f,o),(l==="linear"||l==="-")&&a("rangemode")),t.cleanRange()}});var Rw=X((vfe,Pw)=>{var ZW={left:0,top:0};Pw.exports=JW;function JW(e,r,t){r=r||e.currentTarget||e.srcElement,Array.isArray(t)||(t=[0,0]);var a=e.clientX||0,n=e.clientY||0,i=KW(r);return t[0]=a-i.left,t[1]=n-i.top,t}function KW(e){return e===window||e===document||e===document.body?ZW:e.getBoundingClientRect()}});var Iy=X((hfe,zw)=>{"use strict";var $W=vm();function QW(){var e=!1;try{var r=Object.defineProperty({},"passive",{get:function(){e=!0}});window.addEventListener("test",null,r),window.removeEventListener("test",null,r)}catch(t){e=!1}return e}zw.exports=$W&&QW()});var Fw=X((dfe,Nw)=>{"use strict";Nw.exports=function(r,t,a,n,i){var l=(r-a)/(n-a),o=l+t/(n-a),s=(l+o)/2;return i==="left"||i==="bottom"?l:i==="center"||i==="middle"?s:i==="right"||i==="top"?o:l<2/3-s?l:o>4/3-s?o:s}});var Ow=X((pfe,Hw)=>{"use strict";var Iw=Ne(),jW=[["sw-resize","s-resize","se-resize"],["w-resize","move","e-resize"],["nw-resize","n-resize","ne-resize"]];Hw.exports=function(r,t,a,n){return a==="left"?r=0:a==="center"?r=1:a==="right"?r=2:r=Iw.constrain(Math.floor(r*3),0,2),n==="bottom"?t=0:n==="middle"?t=1:n==="top"?t=2:t=Iw.constrain(Math.floor(t*3),0,2),jW[t][r]}});var Yw=X((mfe,Bw)=>{"use strict";var eX=Ef(),rX=hv(),tX=Mf().getGraphDiv,aX=bf(),Hy=Bw.exports={};Hy.wrapped=function(e,r,t){e=tX(e),e._fullLayout&&rX.clear(e._fullLayout._uid+aX.HOVERID),Hy.raw(e,r,t)};Hy.raw=function(r,t){var a=r._fullLayout,n=r._hoverdata;t||(t={}),!(t.target&&!r._dragged&&eX.triggerHandler(r,"plotly_beforehover",t)===!1)&&(a._hoverlayer.selectAll("g").remove(),a._hoverlayer.selectAll("line").remove(),a._hoverlayer.selectAll("circle").remove(),r._hoverdata=void 0,t.target&&n&&r.emit("plotly_unhover",{event:t,points:n}))}});var fi=X((yfe,Ww)=>{"use strict";var nX=Rw(),Oy=dm(),iX=Iy(),lX=Ne().removeElement,oX=ta(),Bo=Ww.exports={};Bo.align=Fw();Bo.getCursor=Ow();var Vw=Yw();Bo.unhover=Vw.wrapped;Bo.unhoverRaw=Vw.raw;Bo.init=function(r){var t=r.gd,a=1,n=t._context.doubleClickDelay,i=r.element,l,o,s,u,f,v,h,d;t._mouseDownTime||(t._mouseDownTime=0),i.style.pointerEvents="all",i.onmousedown=g,iX?(i._ontouchstart&&i.removeEventListener("touchstart",i._ontouchstart),i._ontouchstart=g,i.addEventListener("touchstart",g,{passive:!1})):i.ontouchstart=g;function m(M,b,w){return Math.abs(M)n&&(a=Math.max(a-1,1)),t._dragged)r.doneFn&&r.doneFn();else{var b;v.target===h?b=v:(b={target:h,srcElement:h,toElement:h},Object.keys(v).concat(Object.keys(v.__proto__)).forEach(w=>{var S=v[w];!b[w]&&typeof S!="function"&&(b[w]=S)})),r.clickFn&&r.clickFn(a,b),d||h.dispatchEvent(new MouseEvent("click",M))}t._dragging=!1,t._dragged=!1}};function Gw(){var e=document.createElement("div");e.className="dragcover";var r=e.style;return r.position="fixed",r.left=0,r.right=0,r.top=0,r.bottom=0,r.zIndex=999999999,r.background="none",document.body.appendChild(e),e}Bo.coverSlip=Gw;function Uw(e){return nX(e.changedTouches?e.changedTouches[0]:e,document.body)}});var Hl=X((gfe,Xw)=>{"use strict";Xw.exports=function(r,t){(r.attr("class")||"").split(" ").forEach(function(a){a.indexOf("cursor-")===0&&r.classed(a,!1)}),t&&r.classed("cursor-"+t,!0)}});var Kw=X((bfe,Jw)=>{"use strict";var By=Hl(),$f="data-savedcursor",Zw="!!";Jw.exports=function(r,t){var a=r.attr($f);if(t){if(!a){for(var n=(r.attr("class")||"").split(" "),i=0;i{"use strict";var Yy=Ta(),sX=ki();$w.exports={_isSubplotObj:!0,visible:{valType:"boolean",dflt:!0,editType:"legend"},bgcolor:{valType:"color",editType:"legend"},bordercolor:{valType:"color",dflt:sX.defaultLine,editType:"legend"},maxheight:{valType:"number",min:0,editType:"legend"},borderwidth:{valType:"number",min:0,dflt:0,editType:"legend"},font:Yy({editType:"legend"}),grouptitlefont:Yy({editType:"legend"}),orientation:{valType:"enumerated",values:["v","h"],dflt:"v",editType:"legend"},traceorder:{valType:"flaglist",flags:["reversed","grouped"],extras:["normal"],editType:"legend"},tracegroupgap:{valType:"number",min:0,dflt:10,editType:"legend"},entrywidth:{valType:"number",min:0,editType:"legend"},entrywidthmode:{valType:"enumerated",values:["fraction","pixels"],dflt:"pixels",editType:"legend"},indentation:{valType:"number",min:-15,dflt:0,editType:"legend"},itemsizing:{valType:"enumerated",values:["trace","constant"],dflt:"trace",editType:"legend"},itemwidth:{valType:"number",min:30,dflt:30,editType:"legend"},itemclick:{valType:"enumerated",values:["toggle","toggleothers",!1],dflt:"toggle",editType:"legend"},itemdoubleclick:{valType:"enumerated",values:["toggle","toggleothers",!1],dflt:"toggleothers",editType:"legend"},groupclick:{valType:"enumerated",values:["toggleitem","togglegroup"],dflt:"togglegroup",editType:"legend"},titleclick:{valType:"enumerated",values:["toggle","toggleothers",!1],editType:"legend"},titledoubleclick:{valType:"enumerated",values:["toggle","toggleothers",!1],editType:"legend"},x:{valType:"number",editType:"legend"},xref:{valType:"enumerated",dflt:"paper",values:["container","paper"],editType:"layoutstyle"},xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"left",editType:"legend"},y:{valType:"number",editType:"legend"},yref:{valType:"enumerated",dflt:"paper",values:["container","paper"],editType:"layoutstyle"},yanchor:{valType:"enumerated",values:["auto","top","middle","bottom"],editType:"legend"},uirevision:{valType:"any",editType:"none"},valign:{valType:"enumerated",values:["top","middle","bottom"],dflt:"middle",editType:"legend"},title:{text:{valType:"string",dflt:"",editType:"legend"},font:Yy({editType:"legend"}),side:{valType:"enumerated",values:["top","left","top left","top center","top right"],editType:"legend"},editType:"legend"},editType:"legend"}});var jf=X(Qf=>{"use strict";Qf.isGrouped=function(r){return(r.traceorder||"").indexOf("grouped")!==-1};Qf.isVertical=function(r){return r.orientation!=="h"};Qf.isReversed=function(r){return(r.traceorder||"").indexOf("reversed")!==-1};Qf.getId=function(r){return r._id||"legend"}});var Gy=X((wfe,Qw)=>{"use strict";var d1=_r(),bn=Ne(),uX=vt(),fX=ri(),cX=Uy(),vX=Ws(),Vy=jf();function hX(e,r,t,a,n){var i=r[e]||{},l=uX.newContainer(t,e);function o(V,J){return bn.coerce(i,l,cX,V,J)}var s=bn.coerceFont(o,"font",t.font);o("bgcolor",t.paper_bgcolor),o("bordercolor");var u=o("visible");if(!u)return;var f,v=function(V,J){var Z=f._input,re=f;return bn.coerce(Z,re,fX,V,J)},h=t.font||{},d=bn.coerceFont(o,"grouptitlefont",h,{overrideDflt:{size:Math.round(h.size*1.1)}}),m=0,y=!1,g="normal",x=(t.shapes||[]).filter(function(V){return V.showlegend});function T(V){return d1.traceIs(V,"pie-like")&&V._length!=null&&(Array.isArray(V.legend)||Array.isArray(V.showlegend))}a.filter(T).forEach(function(V){V.visible&&m++;for(var J=0;JV.legend.length)for(var re=V.legend.length;re(e==="legend"?1:0));if(S===!1&&(t[e]=void 0),!(S===!1&&!i.uirevision)&&(o("uirevision",t.uirevision),S!==!1)){o("borderwidth");var k=o("orientation"),q=o("yref"),D=o("xref"),P=k==="h",z=q==="paper",F=D==="paper",I,H,O,G="left";P?(I=0,d1.getComponentMethod("rangeslider","isVisible")(r.xaxis)?z?(H=1.1,O="bottom"):(H=1,O="top"):z?(H=-.1,O="top"):(H=0,O="bottom")):(H=1,O="auto",F?I=1.02:(I=1,G="right")),bn.coerce(i,l,{x:{valType:"number",editType:"legend",min:F?-2:0,max:F?3:1,dflt:I}},"x"),bn.coerce(i,l,{y:{valType:"number",editType:"legend",min:z?-2:0,max:z?3:1,dflt:H}},"y"),o("traceorder",g),Vy.isGrouped(t[e])&&o("tracegroupgap"),o("entrywidth"),o("entrywidthmode"),o("indentation"),o("itemsizing"),o("itemwidth"),o("itemclick"),o("itemdoubleclick"),o("groupclick"),o("xanchor",G),o("yanchor",O),o("maxheight"),o("valign"),bn.noneOrAll(i,l,["x","y"]);var Y=o("title.text");if(Y){o("title.side",P?"left":"top");var B=bn.extendFlat({},s,{size:bn.bigFont(s.size)});bn.coerceFont(o,"title.font",B);let V=n>1;o("titleclick",V?"toggle":!1),o("titledoubleclick",V?"toggleothers":!1)}}}Qw.exports=function(r,t,a){var n,i=a.slice(),l=t.shapes;if(l)for(n=0;n{"use strict";var Ol=_r(),Wy=Ne(),dX=Wy.pushUnique,pX=jf(),jw=!0;Xy.handleItemClick=function(r,t,a,n){var i=t._fullLayout;if(t._dragged||t._editing)return;var l=r.data()[0][0];if(l.groupTitle&&l.noClick)return;var o=a.groupclick;n==="toggle"&&a.itemdoubleclick==="toggleothers"&&jw&&t.data&&t._context.showTips&&(Wy.notifier(Wy._(t,"Double-click on legend to isolate one trace"),"long",t),jw=!1);var s=o==="togglegroup",u=i.hiddenlabels?i.hiddenlabels.slice():[],f=t._fullData;let v=(i.shapes||[]).filter(Ce=>Ce.showlegend||Ce.legendgroup);var h=f.concat(v),d=l.trace;d._isShape&&(d=i.shapes[d.index]);var m=d.legendgroup,y,g,x,T,M,b,w={},S=[],k=[],q=[];function D(Ce,Ie){var Oe=S.indexOf(Ce),qe=w.visible;return qe||(qe=w.visible=[]),S.indexOf(Ce)===-1&&(S.push(Ce),Oe=S.length-1),qe[Oe]=Ie,Oe}var P=(i.shapes||[]).map(function(Ce){return Ce._input}),z=!1;function F(Ce,Ie){P[Ce].visible=Ie,z=!0}function I(Ce,Ie){if(!(l.groupTitle&&!s)){var Oe=Ce._isShape,qe=Ce.index;qe===void 0&&(qe=Ce._index);var Je=Ce.visible===!1?!1:Ie;Oe?F(qe,Je):D(qe,Je)}}var H=d.legend;if(!d._isShape&&Ol.traceIs(d,"pie-like")){var O=l.label,G=u.indexOf(O);if(n==="toggle")G===-1?u.push(O):u.splice(G,1);else if(n==="toggleothers"){var Y=G!==-1,B=[];for(y=0;yx.showlegend||x.legendgroup),s=i.concat(o);function u(x){return(x.legend||"legend")===l}var f,v;if(a==="toggle")f=!s.some(function(T){return u(T)&&T.visible===!0}),v=!1;else{let x=s.some(function(T){return!u(T)&&T.visible===!0&&T.showlegend!==!1});f=!0,v=!x}let h={visible:[]},d=[],m=(n.shapes||[]).map(function(x){return x._input});for(var y=!1,g=0;g{"use strict";eT.exports={scrollBarWidth:6,scrollBarMinHeight:20,scrollBarColor:"#808BA4",scrollBarMargin:4,scrollBarEnterAttrs:{rx:20,ry:3,width:0,height:0},titlePad:2,itemGap:5}});var aT=X((Afe,tT)=>{"use strict";var{isArrayOrTypedArray:Ky}=hn(),rT=_r(),$y=jf();tT.exports=function(r,t,a){var n=t._inHover,i=$y.isGrouped(t),l=$y.isReversed(t),o={},s=[],u=!1,f={},v=0,h=0,d,m;function y(J,Z,re){if(t.visible!==!1&&!(a&&J!==t._id))if(Z===""||!$y.isGrouped(t)){var se="~~i"+v;s.push(se),o[se]=[re],v++}else s.indexOf(Z)===-1?(s.push(Z),u=!0,o[Z]=[re]):o[Z].push(re)}for(d=0;dI&&(z=I)}D[d][0]._groupMinRank=z,D[d][0]._preGroupSort=d}var H=function(J,Z){return J[0]._groupMinRank-Z[0]._groupMinRank||J[0]._preGroupSort-Z[0]._preGroupSort},O=function(J,Z){var re=Ky(J.trace.legendrank)?J.trace.legendrank[J.i]:J.trace.legendrank,se=Ky(Z.trace.legendrank)?Z.trace.legendrank[Z.i]:Z.trace.legendrank;return re-se||J._preSort-Z._preSort};for(D.forEach(function(J,Z){J[0]._preGroupSort=Z}),D.sort(H),d=0;d{"use strict";var p1=Ne();function nT(e){return e.indexOf("e")!==-1?e.replace(/[.]?0+e/,"e"):e.indexOf(".")!==-1?e.replace(/[.]?0+$/,""):e}Yo.formatPiePercent=function(r,t){var a=nT((r*100).toPrecision(3));return p1.numSeparate(a,t)+"%"};Yo.formatPieValue=function(r,t){var a=nT(r.toPrecision(10));return p1.numSeparate(a,t)};Yo.getFirstFilled=function(r,t){if(p1.isArrayOrTypedArray(r))for(var a=0;a{"use strict";var mX=at(),yX=Er();iT.exports=function(r,t,a,n){var i=a.marker.pattern;i&&i.shape?mX.pointStyle(r,a,n,t):yX.fill(r,t.color)}});var y1=X((Sfe,uT)=>{"use strict";var oT=Er(),sT=m1().castOption,gX=lT();uT.exports=function(r,t,a,n){var i=a.marker.line,l=sT(i.color,t.pts)||oT.defaultLine,o=sT(i.width,t.pts)||0;r.call(gX,t,a,n).style("stroke-width",o).call(oT.stroke,l)}});var r2=X((Lfe,pT)=>{"use strict";var Oa=Nr(),Qy=_r(),Da=Ne(),fT=Da.strTranslate,_n=at(),Rn=Er(),jy=En().extractOpts,g1=mn(),bX=y1(),_X=m1().castOption,xX=Jy(),cT=12,vT=5,Uo=2,wX=10,yu=5;pT.exports=function(r,t,a){var n=t._fullLayout;a||(a=n.legend);var i=a.itemsizing==="constant",l=a.itemwidth,o=(l+xX.itemGap*2)/2,s=fT(o,0),u=function(k,q,D,P){var z;if(k+1)z=k;else if(q&&q.width>0)z=q.width;else return 0;return i?P:Math.min(z,D)};r.each(function(k){var q=Oa.select(this),D=Da.ensureSingle(q,"g","layers");D.style("opacity",k[0].trace.opacity);var P=a.indentation,z=a.valign,F=k[0].lineHeight,I=k[0].height;if(z==="middle"&&P===0||!F||!I)D.attr("transform",null);else{var H={top:1,bottom:-1}[z],O=H*(.5*(F-I+3))||0,G=a.indentation;D.attr("transform",fT(G,O))}var Y=D.selectAll("g.legendfill").data([k]);Y.enter().append("g").classed("legendfill",!0);var B=D.selectAll("g.legendlines").data([k]);B.enter().append("g").classed("legendlines",!0);var V=D.selectAll("g.legendsymbols").data([k]);V.enter().append("g").classed("legendsymbols",!0),V.selectAll("g.legendpoints").data([k]).enter().append("g").classed("legendpoints",!0)}).each(S).each(h).each(m).each(d).each(g).each(b).each(M).each(f).each(v).each(x).each(T);function f(k){var q=hT(k),D=q.showFill,P=q.showLine,z=q.showGradientLine,F=q.showGradientFill,I=q.anyFill,H=q.anyLine,O=k[0],G=O.trace,Y,B,V=jy(G),J=V.colorscale,Z=V.reversescale,re=function(ge){if(ge.size())if(D)_n.fillGroupStyle(ge,t,!0);else{var _e="legendfill-"+G.uid;_n.gradient(ge,t,_e,e2(Z),J,"fill")}},se=function(ge){if(ge.size()){var _e="legendline-"+G.uid;_n.lineGroupStyle(ge),_n.gradient(ge,t,_e,e2(Z),J,"stroke")}},ve=g1.hasMarkers(G)||!I?"M5,0":H?"M5,-2":"M5,-3",xe=Oa.select(this),Q=xe.select(".legendfill").selectAll("path").data(D||F?[k]:[]);if(Q.enter().append("path").classed("js-fill",!0),Q.exit().remove(),Q.attr("d",ve+"h"+l+"v6h-"+l+"z").call(re),P||z){var we=u(void 0,G.line,wX,vT);B=Da.minExtend(G,{line:{width:we}}),Y=[Da.minExtend(O,{trace:B})]}var fe=xe.select(".legendlines").selectAll("path").data(P||z?[Y]:[]);fe.enter().append("path").classed("js-line",!0),fe.exit().remove(),fe.attr("d",ve+(z?"l"+l+",0.0001":"h"+l)).call(P?_n.lineGroupStyle:se)}function v(k){var q=hT(k),D=q.anyFill,P=q.anyLine,z=q.showLine,F=q.showMarker,I=k[0],H=I.trace,O=!F&&!P&&!D&&g1.hasText(H),G,Y;function B(we,fe,ge,_e){var ce=Da.nestedProperty(H,we).get(),Me=Da.isArrayOrTypedArray(ce)&&fe?fe(ce):ce;if(i&&Me&&_e!==void 0&&(Me=_e),ge){if(Mege[1])return ge[1]}return Me}function V(we){return I._distinct&&I.index&&we[I.index]?we[I.index]:we[0]}if(F||O||z){var J={},Z={};if(F){J.mc=B("marker.color",V);var re=H.type==="scattermap";J.mx=re?"circle":B("marker.symbol",V),J.mo=B("marker.opacity",Da.mean,[.2,1]),J.mlc=B("marker.line.color",V),J.mlw=B("marker.line.width",Da.mean,[0,5],Uo),J.mld=B("marker.line.dash",V),Z.marker={sizeref:1,sizemin:1,sizemode:"diameter"};var se=B("marker.size",Da.mean,[2,16],cT);J.ms=se,Z.marker.size=se}z&&(Z.line={width:B("line.width",V,[0,10],vT)}),O&&(J.tx="Aa",J.tp=B("textposition",V),J.ts=10,J.tc=B("textfont.color",V),J.tf=B("textfont.family",V),J.tw=B("textfont.weight",V),J.ty=B("textfont.style",V),J.tv=B("textfont.variant",V),J.tC=B("textfont.textcase",V),J.tE=B("textfont.lineposition",V),J.tS=B("textfont.shadow",V)),G=[Da.minExtend(I,J)],Y=Da.minExtend(H,Z),Y.selectedpoints=null,Y.texttemplate=null}var ve=Oa.select(this).select("g.legendpoints"),xe=ve.selectAll("path.scatterpts").data(F?G:[]);xe.enter().insert("path",":first-child").classed("scatterpts",!0).attr("transform",s),xe.exit().remove(),xe.call(_n.pointStyle,Y,t),F&&(G[0].mrc=3);var Q=ve.selectAll("g.pointtext").data(O?G:[]);Q.enter().append("g").classed("pointtext",!0).append("text").attr("transform",s),Q.exit().remove(),Q.selectAll("text").call(_n.textPointStyle,Y,t)}function h(k){var q=k[0].trace,D=q.type==="waterfall";if(k[0]._distinct&&D){var P=k[0].trace[k[0].dir].marker;return k[0].mc=P.color,k[0].mlw=P.line.width,k[0].mlc=P.line.color,y(k,this,"waterfall")}var z=[];q.visible&&D&&(z=k[0].hasTotals?[["increasing","M-6,-6V6H0Z"],["totals","M6,6H0L-6,-6H-0Z"],["decreasing","M6,6V-6H0Z"]]:[["increasing","M-6,-6V6H6Z"],["decreasing","M6,6V-6H-6Z"]]);var F=Oa.select(this).select("g.legendpoints").selectAll("path.legendwaterfall").data(z);F.enter().append("path").classed("legendwaterfall",!0).attr("transform",s).style("stroke-miterlimit",1),F.exit().remove(),F.each(function(I){var H=Oa.select(this),O=q[I[0]].marker,G=u(void 0,O.line,yu,Uo);H.attr("d",I[1]).style("stroke-width",G+"px").call(Rn.fill,O.color),G&&H.call(Rn.stroke,O.line.color)})}function d(k){y(k,this)}function m(k){y(k,this,"funnel")}function y(k,q,D){var P=k[0].trace,z=P.marker||{},F=z.line||{},I=z.cornerradius?"M6,3a3,3,0,0,1-3,3H-3a3,3,0,0,1-3-3V-3a3,3,0,0,1,3-3H3a3,3,0,0,1,3,3Z":"M6,6H-6V-6H6Z",H=D?P.visible&&P.type===D:Qy.traceIs(P,"bar"),O=Oa.select(q).select("g.legendpoints").selectAll("path.legend"+D).data(H?[k]:[]);O.enter().append("path").classed("legend"+D,!0).attr("d",I).attr("transform",s),O.exit().remove(),O.each(function(G){var Y=Oa.select(this),B=G[0],V=u(B.mlw,z.line,yu,Uo);Y.style("stroke-width",V+"px");var J=B.mcc;if(!a._inHover&&"mc"in B){var Z=jy(z),re=Z.mid;re===void 0&&(re=(Z.max+Z.min)/2),J=_n.tryColorscale(z,"")(re)}var se=J||B.mc||z.color,ve=z.pattern,xe=_n.getPatternAttr,Q=ve&&(xe(ve.shape,0,"")||xe(ve.path,0,""));if(Q){var we=xe(ve.bgcolor,0,null),fe=xe(ve.fgcolor,0,null),ge=ve.fgopacity,_e=dT(ve.size,8,10),ce=dT(ve.solidity,.5,1),Me="legend-"+P.uid;Y.call(_n.pattern,"legend",t,Me,Q,_e,ce,J,ve.fillmode,we,fe,ge)}else Y.call(Rn.fill,se);V&&Rn.stroke(Y,B.mlc||F.color)})}function g(k){var q=k[0].trace,D=Oa.select(this).select("g.legendpoints").selectAll("path.legendbox").data(q.visible&&Qy.traceIs(q,"box-violin")?[k]:[]);D.enter().append("path").classed("legendbox",!0).attr("d","M6,6H-6V-6H6Z").attr("transform",s),D.exit().remove(),D.each(function(){var P=Oa.select(this);if((q.boxpoints==="all"||q.points==="all")&&Rn.opacity(q.fillcolor)===0&&Rn.opacity((q.line||{}).color)===0){var z=Da.minExtend(q,{marker:{size:i?cT:Da.constrain(q.marker.size,2,16),sizeref:1,sizemin:1,sizemode:"diameter"}});D.call(_n.pointStyle,z,t)}else{var F=u(void 0,q.line,yu,Uo);P.style("stroke-width",F+"px").call(Rn.fill,q.fillcolor),F&&Rn.stroke(P,q.line.color)}})}function x(k){var q=k[0].trace,D=Oa.select(this).select("g.legendpoints").selectAll("path.legendcandle").data(q.visible&&q.type==="candlestick"?[k,k]:[]);D.enter().append("path").classed("legendcandle",!0).attr("d",function(P,z){return z?"M-15,0H-8M-8,6V-6H8Z":"M15,0H8M8,-6V6H-8Z"}).attr("transform",s).style("stroke-miterlimit",1),D.exit().remove(),D.each(function(P,z){var F=Oa.select(this),I=q[z?"increasing":"decreasing"],H=u(void 0,I.line,yu,Uo);F.style("stroke-width",H+"px").call(Rn.fill,I.fillcolor),H&&Rn.stroke(F,I.line.color)})}function T(k){var q=k[0].trace,D=Oa.select(this).select("g.legendpoints").selectAll("path.legendohlc").data(q.visible&&q.type==="ohlc"?[k,k]:[]);D.enter().append("path").classed("legendohlc",!0).attr("d",function(P,z){return z?"M-15,0H0M-8,-6V0":"M15,0H0M8,6V0"}).attr("transform",s).style("stroke-miterlimit",1),D.exit().remove(),D.each(function(P,z){var F=Oa.select(this),I=q[z?"increasing":"decreasing"],H=u(void 0,I.line,yu,Uo);F.style("fill","none").call(_n.dashLine,I.line.dash,H),H&&Rn.stroke(F,I.line.color)})}function M(k){w(k,this,"pie")}function b(k){w(k,this,"funnelarea")}function w(k,q,D){var P=k[0],z=P.trace,F=D?z.visible&&z.type===D:Qy.traceIs(z,D),I=Oa.select(q).select("g.legendpoints").selectAll("path.legend"+D).data(F?[k]:[]);if(I.enter().append("path").classed("legend"+D,!0).attr("d","M6,6H-6V-6H6Z").attr("transform",s),I.exit().remove(),I.size()){var H=z.marker||{},O=u(_X(H.line.width,P.pts),H.line,yu,Uo),G="pieLike",Y=Da.minExtend(z,{marker:{line:{width:O}}},G),B=Da.minExtend(P,{trace:Y},G);bX(I,B,Y,t)}}function S(k){var q=k[0].trace,D,P=[];if(q.visible)switch(q.type){case"histogram2d":case"heatmap":P=[["M-15,-2V4H15V-2Z"]],D=!0;break;case"choropleth":case"choroplethmap":P=[["M-6,-6V6H6V-6Z"]],D=!0;break;case"densitymap":P=[["M-6,0 a6,6 0 1,0 12,0 a 6,6 0 1,0 -12,0"]],D="radial";break;case"cone":P=[["M-6,2 A2,2 0 0,0 -6,6 V6L6,4Z"],["M-6,-6 A2,2 0 0,0 -6,-2 L6,-4Z"],["M-6,-2 A2,2 0 0,0 -6,2 L6,0Z"]],D=!1;break;case"streamtube":P=[["M-6,2 A2,2 0 0,0 -6,6 H6 A2,2 0 0,1 6,2 Z"],["M-6,-6 A2,2 0 0,0 -6,-2 H6 A2,2 0 0,1 6,-6 Z"],["M-6,-2 A2,2 0 0,0 -6,2 H6 A2,2 0 0,1 6,-2 Z"]],D=!1;break;case"surface":P=[["M-6,-6 A2,3 0 0,0 -6,0 H6 A2,3 0 0,1 6,-6 Z"],["M-6,1 A2,3 0 0,1 -6,6 H6 A2,3 0 0,0 6,0 Z"]],D=!0;break;case"mesh3d":P=[["M-6,6H0L-6,-6Z"],["M6,6H0L6,-6Z"],["M-6,-6H6L0,6Z"]],D=!1;break;case"volume":P=[["M-6,6H0L-6,-6Z"],["M6,6H0L6,-6Z"],["M-6,-6H6L0,6Z"]],D=!0;break;case"isosurface":P=[["M-6,6H0L-6,-6Z"],["M6,6H0L6,-6Z"],["M-6,-6 A12,24 0 0,0 6,-6 L0,6Z"]],D=!1;break}var z=Oa.select(this).select("g.legendpoints").selectAll("path.legend3dandfriends").data(P);z.enter().append("path").classed("legend3dandfriends",!0).attr("transform",s).style("stroke-miterlimit",1),z.exit().remove(),z.each(function(F,I){var H=Oa.select(this),O=jy(q),G=O.colorscale,Y=O.reversescale,B=function(re){if(re.size()){var se="legendfill-"+q.uid;_n.gradient(re,t,se,e2(Y,D==="radial"),G,"fill")}},V;if(G){if(!D){var Z=G.length;V=I===0?G[Y?Z-1:0][1]:I===1?G[Y?0:Z-1][1]:G[Math.floor((Z-1)/2)][1]}}else{var J=q.vertexcolor||q.facecolor||q.color;V=Da.isArrayOrTypedArray(J)?J[I]||J[0]:J}H.attr("d",F[0]),V?H.call(Rn.fill,V):H.call(B)})}};function e2(e,r){var t=r?"radial":"horizontal";return t+(e?"":"reversed")}function hT(e){var r=e[0].trace,t=r.contours,a=g1.hasLines(r)||r.visible&&r.type==="quiver",n=g1.hasMarkers(r),i=r.visible&&r.fill&&r.fill!=="none",l=!1,o=!1;if(t){var s=t.coloring;s==="lines"?l=!0:a=s==="none"||s==="heatmap"||t.showlines,t.type==="constraint"?i=t._operation!=="=":(s==="fill"||s==="heatmap")&&(o=!0)}return{showMarker:n,showLine:a,showFill:i,showGradientLine:l,showGradientFill:o,anyLine:a||l,anyFill:i||o}}function dT(e,r,t){return e&&Da.isArrayOrTypedArray(e)?r:e>t?t:e}});var i2=X((qfe,MT)=>{"use strict";var Ja=Nr(),Nt=Ne(),a2=Ut(),Vo=_r(),x1=Ef(),t2=fi(),Ft=at(),ec=Er(),Go=da(),mT=Zy().handleItemClick,yT=Zy().handleTitleClick,Vt=Jy(),n2=La(),wT=n2.LINE_SPACING,bu=n2.FROM_TL,gT=n2.FROM_BR,bT=aT(),TX=r2(),_u=jf(),gu=1,MX=/^legend[0-9]*$/;MT.exports=function(r,t){if(t)_T(r,t);else{var a=r._fullLayout,n=a._legends,i=a._infolayer.selectAll('[class^="legend"]');i.each(function(){var u=Ja.select(this),f=u.attr("class"),v=f.split(" ")[0];v.match(MX)&&n.indexOf(v)===-1&&u.remove()});for(var l=0;l1)}var m=a.hiddenlabels||[];if(!o&&(!a.showlegend||!s.length))return l.selectAll("."+n).remove(),a._topdefs.select("#"+i).remove(),a2.autoMargin(e,n);var y=Nt.ensureSingle(l,"g",n,function(q){o||q.attr("pointer-events","all")}),g=Nt.ensureSingleById(a._topdefs,"clipPath",i,function(q){q.append("rect")}),x=Nt.ensureSingle(y,"rect","bg",function(q){q.attr("shape-rendering","crispEdges")});x.call(ec.stroke,t.bordercolor).call(ec.fill,t.bgcolor).style("stroke-width",t.borderwidth+"px");var T=Nt.ensureSingle(y,"g","scrollbox"),M=t.title;t._titleWidth=0,t._titleHeight=0;var b;M.text?(b=Nt.ensureSingle(T,"text",n+"titletext"),b.attr("text-anchor","start").call(Ft.font,M.font).text(M.text),_1(b,T,e,t,gu),!o&&(t.titleclick||t.titledoubleclick)&&SX(T,e,t,n)):(T.selectAll("."+n+"titletext").remove(),T.selectAll("."+n+"titletoggle").remove());var w=Nt.ensureSingle(y,"rect","scrollbar",function(q){q.attr(Vt.scrollBarEnterAttrs).call(ec.fill,Vt.scrollBarColor)}),S=T.selectAll("g.groups").data(s);S.enter().append("g").attr("class","groups"),S.exit().remove();var k=S.selectAll("g.traces").data(Nt.identity);k.enter().append("g").attr("class","traces"),k.exit().remove(),k.style("opacity",function(q){let D=q[0],P=D.trace;if(D.groupTitle){let z=P.legendgroup,F=(a.shapes||[]).filter(function(H){return H.showlegend});return e._fullData.concat(F).some(function(H){return H.legendgroup===z&&(H.legend||"legend")===n&&H.visible===!0})?1:.5}return Vo.traceIs(P,"pie-like")?m.indexOf(q[0].label)!==-1?.5:1:P.visible==="legendonly"?.5:1}).each(function(){Ja.select(this).call(kX,e,t)}).call(TX,e,t).each(function(q){o||q[0].groupTitle&&t.groupclick==="toggleitem"||Ja.select(this).call(CX,e,n)}),Nt.syncOrAsync([a2.previousPromises,function(){return DX(e,S,k,t,T)},function(){var q=a._size,D=t.borderwidth,P=t.xref==="paper",z=t.yref==="paper";if(M.text){let Pe=(a.shapes||[]).filter(function(Ce){return Ce.showlegend}),Le=e._fullData.concat(Pe).some(function(Ce){let Ie=Ce.legend||"legend";var Oe=Array.isArray(Ie)?Ie.includes(n):Ie===n;return Oe&&Ce.visible===!0});b.style("opacity",Le?1:.5)}if(!o){var F,I;P?F=q.l+q.w*t.x-bu[w1(t)]*t._width:F=a.width*t.x-bu[w1(t)]*t._width,z?I=q.t+q.h*(1-t.y)-bu[T1(t)]*t._effHeight:I=a.height*(1-t.y)-bu[T1(t)]*t._effHeight;var H=EX(e,n,F,I);if(H)return;if(a.margin.autoexpand){var O=F,G=I;F=P?Nt.constrain(F,0,a.width-t._width):O,I=z?Nt.constrain(I,0,a.height-t._effHeight):G,F!==O&&Nt.log("Constrain "+n+".x to make legend fit inside graph"),I!==G&&Nt.log("Constrain "+n+".y to make legend fit inside graph")}Ft.setTranslate(y,F,I)}if(w.on(".drag",null),y.on("wheel",null),o||t._height<=t._maxHeight||e._context.staticPlot){var Y=t._effHeight;o&&(Y=t._height),x.attr({width:t._width-D,height:Y-D,x:D/2,y:D/2}),Ft.setTranslate(T,0,0),g.select("rect").attr({width:t._width-2*D,height:Y-2*D,x:D,y:D}),Ft.setClipUrl(T,i,e),Ft.setRect(w,0,0,0,0),delete t._scrollY}else{var B=Math.max(Vt.scrollBarMinHeight,t._effHeight*t._effHeight/t._height),V=t._effHeight-B-2*Vt.scrollBarMargin,J=t._height-t._effHeight,Z=V/J,re=Math.min(t._scrollY||0,J);x.attr({width:t._width-2*D+Vt.scrollBarWidth+Vt.scrollBarMargin,height:t._effHeight-D,x:D/2,y:D/2}),g.select("rect").attr({width:t._width-2*D+Vt.scrollBarWidth+Vt.scrollBarMargin,height:t._effHeight-2*D,x:D,y:D+re}),Ft.setClipUrl(T,i,e),_e(re,B,Z),y.on("wheel",function(){re=Nt.constrain(t._scrollY+Ja.event.deltaY/J*V,0,J),_e(re,B,Z),re!==0&&re!==J&&Ja.event.preventDefault()});var se,ve,xe,Q=function(Pe,Le,Ce){var Ie=(Ce-Le)/Z+Pe;return Nt.constrain(Ie,0,J)},we=function(Pe,Le,Ce){var Ie=(Le-Ce)/Z+Pe;return Nt.constrain(Ie,0,J)},fe=Ja.behavior.drag().on("dragstart",function(){var Pe=Ja.event.sourceEvent;Pe.type==="touchstart"?se=Pe.changedTouches[0].clientY:se=Pe.clientY,xe=re}).on("drag",function(){var Pe=Ja.event.sourceEvent;Pe.buttons===2||Pe.ctrlKey||(Pe.type==="touchmove"?ve=Pe.changedTouches[0].clientY:ve=Pe.clientY,re=Q(xe,se,ve),_e(re,B,Z))});w.call(fe);var ge=Ja.behavior.drag().on("dragstart",function(){var Pe=Ja.event.sourceEvent;Pe.type==="touchstart"&&(se=Pe.changedTouches[0].clientY,xe=re)}).on("drag",function(){var Pe=Ja.event.sourceEvent;Pe.type==="touchmove"&&(ve=Pe.changedTouches[0].clientY,re=we(xe,se,ve),_e(re,B,Z))});T.call(ge)}function _e(Pe,Le,Ce){t._scrollY=e._fullLayout[n]._scrollY=Pe,Ft.setTranslate(T,0,-Pe),Ft.setRect(w,t._width,Vt.scrollBarMargin+Pe*Ce,Vt.scrollBarWidth,Le),g.select("rect").attr("y",D+Pe)}if(e._context.edits.legendPosition){var ce,Me,Re,Te;y.classed("cursor-move",!0),t2.init({element:y.node(),gd:e,prepFn:function(Pe){if(Pe.target!==w.node()){var Le=Ft.getTranslate(y);Re=Le.x,Te=Le.y}},moveFn:function(Pe,Le){if(Re!==void 0&&Te!==void 0){var Ce=Re+Pe,Ie=Te+Le;Ft.setTranslate(y,Ce,Ie),ce=t2.align(Ce,t._width,q.l,q.l+q.w,t.xanchor),Me=t2.align(Ie+t._height,-t._height,q.t+q.h,q.t,t.yanchor)}},doneFn:function(){if(ce!==void 0&&Me!==void 0){var Pe={};Pe[n+".x"]=ce,Pe[n+".y"]=Me,Vo.call("_guiRelayout",e,Pe)}},clickFn:function(Pe,Le){var Ce=l.selectAll("g.traces").filter(function(){var Ie=this.getBoundingClientRect();return Le.clientX>=Ie.left&&Le.clientX<=Ie.right&&Le.clientY>=Ie.top&&Le.clientY<=Ie.bottom});Ce.size()>0&&TT(e,t,Ce,Pe,Le)}})}}],e)}}function b1(e,r,t){var a=e[0],n=a.width,i=r.entrywidthmode,l=a.trace.legendwidth||r.entrywidth;return i==="fraction"?r._maxWidth*l:t+(l||n)}function TT(e,r,t,a,n){var i=e._fullLayout,l=t.data()[0][0].trace,o=r.itemclick,s=r.itemdoubleclick,u={event:n,node:t.node(),curveNumber:l.index,expandedIndex:l.index,data:e.data,layout:e.layout,frames:e._transitionData._frames,config:e._context,fullData:e._fullData,fullLayout:i};l._group&&(u.group=l._group),Vo.traceIs(l,"pie-like")&&(u.label=t.datum()[0].label);var f=x1.triggerHandler(e,"plotly_legendclick",u);if(a===1){if(f===!1)return;r._clickTimeout=setTimeout(function(){e._fullLayout&&o&&mT(t,e,r,o)},e._context.doubleClickDelay)}else if(a===2){r._clickTimeout&&clearTimeout(r._clickTimeout),e._legendMouseDownTime=0;var v=x1.triggerHandler(e,"plotly_legenddoubleclick",u);v!==!1&&f!==!1&&s&&mT(t,e,r,s)}}function kX(e,r,t){var a=_u.getId(t),n=e.data()[0][0],i=n.trace,l=Vo.traceIs(i,"pie-like"),o=!t._inHover&&r._context.edits.legendText&&!l,s=t._maxNameLength,u,f;n.groupTitle?(u=n.groupTitle.text,f=n.groupTitle.font):(f=t.font,t.entries?u=n.text:(u=l?n.label:i.name,i._meta&&(u=Nt.templateString(u,i._meta))));var v=Nt.ensureSingle(e,"text",a+"text");v.attr("text-anchor","start").call(Ft.font,f).text(o?xT(u,s):u);var h=t.indentation+t.itemwidth+Vt.itemGap*2;Go.positionText(v,h,0),o?v.call(Go.makeEditable,{gd:r,text:u}).call(_1,e,r,t).on("edit",function(d){this.text(xT(d,s)).call(_1,e,r,t);var m={};return m.name=d,n.trace._isShape?Vo.call("_guiRelayout",r,"shapes["+i.index+"].name",m.name):Vo.call("_guiRestyle",r,m,i.index)}):_1(v,e,r,t)}function xT(e,r){var t=Math.max(4,r);if(e&&e.trim().length>=t/2)return e;e=e||"";for(var a=t-e.length;a>0;a--)e+=" ";return e}function CX(e,r,t){var a=r._context.doubleClickDelay,n,i=1,l=Nt.ensureSingle(e,"rect",t+"toggle",function(o){r._context.staticPlot||o.style("cursor","pointer").attr("pointer-events","all"),o.call(ec.fill,"rgba(0,0,0,0)")});r._context.staticPlot||(l.on("mousedown",function(){n=new Date().getTime(),n-r._legendMouseDownTimea&&(i=Math.max(i-1,1)),TT(r,o,e,i,Ja.event)}}))}function SX(e,r,t,a){if(r._fullData.some(function(u){let f=u.legend||"legend";return(Array.isArray(f)?f.includes(a):f===a)&&Vo.traceIs(u,"pie-like")}))return;let i=r._context.doubleClickDelay;var l,o=1;let s=Nt.ensureSingle(e,"rect",a+"titletoggle",function(u){r._context.staticPlot||u.style("cursor","pointer").attr("pointer-events","all"),u.call(ec.fill,"rgba(0,0,0,0)")});r._context.staticPlot||(s.on("mousedown",function(){l=new Date().getTime(),l-r._legendMouseDownTimei&&(o=Math.max(o-1,1));let u={event:Ja.event,legendId:a,data:r.data,layout:r.layout,fullData:r._fullData,fullLayout:r._fullLayout};if(o===1&&t.titleclick){if(x1.triggerHandler(r,"plotly_legendtitleclick",u)===!1)return;t._titleClickTimeout=setTimeout(function(){r._fullLayout&&yT(r,t,t.titleclick)},i)}else o===2&&(t._titleClickTimeout&&clearTimeout(t._titleClickTimeout),r._legendMouseDownTime=0,x1.triggerHandler(r,"plotly_legendtitledoubleclick",u)!==!1&&t.titledoubleclick&&yT(r,t,t.titledoubleclick))}))}function _1(e,r,t,a,n){a._inHover&&e.attr("data-notex",!0),Go.convertToTspans(e,t,function(){LX(r,t,a,n)})}function LX(e,r,t,a){var n=e.data()[0][0],i=n&&n.trace.showlegend;if(Array.isArray(i)&&(i=i[n.i]!==!1),!t._inHover&&n&&!i){e.remove();return}var l=e.select("g[class*=math-group]"),o=l.node(),s=_u.getId(t);t||(t=r._fullLayout[s]);var u=t.borderwidth,f;a===gu?f=t.title.font:n.groupTitle?f=n.groupTitle.font:f=t.font;var v=f.size*wT,h,d;if(o){var m=Ft.bBox(o);h=m.height,d=m.width,a===gu?Ft.setTranslate(l,u,u+h*.75):Ft.setTranslate(l,0,h*.25)}else{var y="."+s+(a===gu?"title":"")+"text",g=e.select(y),x=Go.lineCount(g),T=g.node();if(h=v*x,d=T?Ft.bBox(T).width:0,a===gu)t.title.side==="left"&&(d+=Vt.itemGap*2),Go.positionText(g,u+Vt.titlePad,u+v);else{var M=Vt.itemGap*2+t.indentation+t.itemwidth;n.groupTitle&&(M=Vt.itemGap,d-=t.indentation+t.itemwidth),Go.positionText(g,M,-v*((x-1)/2-.3))}}a===gu?(t._titleWidth=d,t._titleHeight=h):(n.lineHeight=v,n.height=Math.max(h,16)+3,n.width=d)}function qX(e){var r=0,t=0,a=e.title.side;return a&&(a.indexOf("left")!==-1&&(r=e._titleWidth),a.indexOf("top")!==-1&&(t=e._titleHeight)),[r,t]}function DX(e,r,t,a,n){var i=e._fullLayout,l=_u.getId(a);a||(a=i[l]);var o=i._size,s=_u.isVertical(a),u=_u.isGrouped(a),f=a.entrywidthmode==="fraction",v=a.borderwidth,h=2*v,d=Vt.itemGap,m=a.indentation+a.itemwidth+d*2,y=2*(v+d),g=T1(a),x=a.y<0||a.y===0&&g==="top",T=a.y>1||a.y===1&&g==="bottom",M=a.tracegroupgap,b={};let{orientation:w,yref:S}=a,{maxheight:k}=a,q=x||T||w!=="v"||S!=="paper";k||(k=q?.5:1);let D=q?i.height:o.h;a._maxHeight=Math.max(k>1?k:k*D,30);var P=0;a._width=0,a._height=0;var z=qX(a);if(s)t.each(function(Pe){var Le=Pe[0].height;Ft.setTranslate(this,v+z[0],v+z[1]+a._height+Le/2+d),a._height+=Le,a._width=Math.max(a._width,Pe[0].width)}),P=m+a._width,a._width+=d+m+h,a._height+=y,u&&(r.each(function(Pe,Le){Ft.setTranslate(this,0,Le*a.tracegroupgap)}),a._height+=(a._lgroupsLength-1)*a.tracegroupgap);else{var F=w1(a),I=a.x<0||a.x===0&&F==="right",H=a.x>1||a.x===1&&F==="left",O=T||x,G=i.width/2;a._maxWidth=Math.max(I?O&&F==="left"?o.l+o.w:G:H?O&&F==="right"?o.r+o.w:G:o.w,2*m);var Y=0,B=0;t.each(function(Pe){var Le=b1(Pe,a,m);Y=Math.max(Y,Le),B+=Le}),P=null;var V=0;if(u){var J=0,Z=0,re=0;r.each(function(){var Pe=0,Le=0;Ja.select(this).selectAll("g.traces").each(function(Ie){var Oe=b1(Ie,a,m),qe=Ie[0].height;Ft.setTranslate(this,z[0],z[1]+v+d+qe/2+Le),Le+=qe,Pe=Math.max(Pe,Oe),b[Ie[0].trace.legendgroup]=Pe});var Ce=Pe+d;Z>0&&Ce+v+Z>a._maxWidth?(V=Math.max(V,Z),Z=0,re+=J+M,J=Le):J=Math.max(J,Le),Ft.setTranslate(this,Z,re),Z+=Ce}),a._width=Math.max(V,Z)+v,a._height=re+J+y}else{var se=t.size(),ve=B+h+(se-1)*d=a._maxWidth&&(V=Math.max(V,fe),Q=0,we+=xe,a._height+=xe,xe=0),Ft.setTranslate(this,z[0]+v+Q,z[1]+v+we+Le/2+d),fe=Q+Ce+d,Q+=Ie,xe=Math.max(xe,Le)}),ve?(a._width=Q+h,a._height=xe+y):(a._width=Math.max(V,fe)+h,a._height+=xe+y)}}a._width=Math.ceil(Math.max(a._width+z[0],a._titleWidth+2*(v+Vt.titlePad))),a._height=Math.ceil(Math.max(a._height+z[1],a._titleHeight+2*(v+Vt.itemGap))),a._effHeight=Math.min(a._height,a._maxHeight);var ge=e._context.edits,_e=ge.legendText||ge.legendPosition;t.each(function(Pe){var Le=Ja.select(this).select("."+l+"toggle"),Ce=Pe[0].height,Ie=Pe[0].trace.legendgroup,Oe=b1(Pe,a,m);u&&Ie!==""&&(Oe=b[Ie]);var qe=_e?m:P||Oe;!s&&!f&&(qe+=d/2),Ft.setRect(Le,0,-Ce/2,qe,Ce)});var ce=n.select("."+l+"titletext");ce.node()&&AX(ce,a,v);var Me=n.select("."+l+"titletoggle");if(Me.size()&&ce.node()){var Re=ce.attr("x")||0,Te=Vt.titlePad;Ft.setRect(Me,Re-Te,v,a._titleWidth+2*Te,a._titleHeight+2*Te)}}function EX(e,r,t,a){var n=e._fullLayout,i=n[r],l=w1(i),o=T1(i),s=i.xref==="paper",u=i.yref==="paper";e._fullLayout._reservedMargin[r]={};var f=i.y<.5?"b":"t",v=i.x<.5?"l":"r",h={r:n.width-t,l:t+i._width,b:n.height-a,t:a+i._effHeight};if(s&&u)return a2.autoMargin(e,r,{x:i.x,y:i.y,l:i._width*bu[l],r:i._width*gT[l],b:i._effHeight*gT[o],t:i._effHeight*bu[o]});s?e._fullLayout._reservedMargin[r][f]=h[f]:u||i.orientation==="v"?e._fullLayout._reservedMargin[r][v]=h[v]:e._fullLayout._reservedMargin[r][f]=h[f]}function w1(e){return Nt.isRightAnchor(e)?"right":Nt.isCenterAnchor(e)?"center":"left"}function T1(e){return Nt.isBottomAnchor(e)?"bottom":Nt.isMiddleAnchor(e)?"middle":"top"}});var u2=X(s2=>{"use strict";var Wo=Nr(),nl=Br(),At=Ne(),PX=At.pushUnique,l2=At.strTranslate,RX=At.strRotate,zX=Ef(),zn=da(),NX=Kw(),Fi=at(),St=Er(),M1=fi(),Ii=mt(),FX=ta().zindexSeparator,wu=_r(),ci=Di(),Xo=bf(),IX=Gy(),HX=i2(),ET=Xo.YANGLE,o2=Math.PI*ET/180,OX=1/Math.sin(o2),BX=Math.cos(o2),YX=Math.sin(o2),yt=Xo.HOVERARROWSIZE,Or=Xo.HOVERTEXTPAD,AT={box:!0,ohlc:!0,violin:!0,candlestick:!0},UX={scatter:!0,scattergl:!0,splom:!0};function kT(e,r){return e.distance-r.distance}s2.hover=function(r,t,a,n){r=At.getGraphDiv(r);var i=t.target;At.throttle(r._fullLayout._uid+Xo.HOVERID,Xo.HOVERMINTIME,function(){VX(r,t,a,n,i)})};s2.loneHover=function(r,t){var a=!0;Array.isArray(r)||(a=!1,r=[r]);var n=t.gd,i=FT(n),l=IT(n),o=r.map(function(g){var x=g._x0||g.x0||g.x||0,T=g._x1||g.x1||g.x||0,M=g._y0||g.y0||g.y||0,b=g._y1||g.y1||g.y||0,w=g.eventData;if(w){var S=Math.min(x,T),k=Math.max(x,T),q=Math.min(M,b),D=Math.max(M,b),P=g.trace;if(wu.traceIs(P,"gl3d")){var z=n._fullLayout[P.scene]._scene.container,F=z.offsetLeft,I=z.offsetTop;S+=F,k+=F,q+=I,D+=I}w.bbox={x0:S+l,x1:k+l,y0:q+i,y1:D+i},w.xPixel=(x+T)/2,w.yPixel=(M+b)/2,t.inOut_bbox&&t.inOut_bbox.push(w.bbox)}else w=!1;return{color:g.color||St.defaultLine,x0:g.x0||g.x||0,x1:g.x1||g.x||0,y0:g.y0||g.y||0,y1:g.y1||g.y||0,xLabel:g.xLabel,yLabel:g.yLabel,zLabel:g.zLabel,text:g.text,name:g.name,idealAlign:g.idealAlign,borderColor:g.borderColor,fontFamily:g.fontFamily,fontSize:g.fontSize,fontColor:g.fontColor,fontWeight:g.fontWeight,fontStyle:g.fontStyle,fontVariant:g.fontVariant,nameLength:g.nameLength,textAlign:g.textAlign,trace:g.trace||{index:0,hoverinfo:""},xa:{_offset:0},ya:{_offset:0},index:0,hovertemplate:g.hovertemplate||!1,hovertemplateLabels:g.hovertemplateLabels||!1,eventData:w}}),s=!1,u=RT(o,{gd:n,hovermode:"closest",rotateLabels:s,bgColor:t.bgColor||St.background,container:Wo.select(t.container),outerContainer:t.outerContainer||t.container}),f=u.hoverLabels,v=5,h=0,d=0;f.sort(function(g,x){return g.y0-x.y0}).each(function(g,x){var T=g.y0-g.by/2;T-vk[0]._length||Te<0||Te>q[0]._length)return M1.unhoverRaw(e,r)}if(r.pointerX=Re+k[0]._offset,r.pointerY=Te+q[0]._offset,"xval"in r?G=ci.flat(i,r.xval):G=ci.p2c(k,Re),"yval"in r?Y=ci.flat(i,r.yval):Y=ci.p2c(q,Te),!nl(G[0])||!nl(Y[0]))return At.warn("Fx.hover failed",r,e),M1.unhoverRaw(e,r)}o.clickanywhere&&(e._hoverXVals=G,e._hoverYVals=Y,e._hoverXAxes=k,e._hoverYAxes=q);var Ce=1/0;function Ie(cr,st){for(V=0;Vfe&&(H.splice(0,fe),Ce=H[0].distance),v&&I!==0&&H.length===0){we.distance=I,we.index=!1;var nt=Z._module.hoverPoints(we,xe,Q,"closest",{hoverLayer:o._hoverlayer});if(nt&&(nt=nt.filter(function(Bt){return Bt.spikeDistance<=I})),nt&&nt.length){var Qa,Kn=nt.filter(function(Bt){return Bt.xa.showspikes&&Bt.xa.spikesnap!=="hovered data"});if(Kn.length){var ra=Kn[0];nl(ra.x0)&&nl(ra.y0)&&(Qa=qe(ra),(!ge.vLinePoint||ge.vLinePoint.spikeDistance>Qa.spikeDistance)&&(ge.vLinePoint=Qa))}var ht=nt.filter(function(Bt){return Bt.ya.showspikes&&Bt.ya.spikesnap!=="hovered data"});if(ht.length){var Ot=ht[0];nl(Ot.x0)&&nl(Ot.y0)&&(Qa=qe(Ot),(!ge.hLinePoint||ge.hLinePoint.spikeDistance>Qa.spikeDistance)&&(ge.hLinePoint=Qa))}}}}}Ie();function Oe(cr,st,Wr){for(var it=null,fa=1/0,ca,nt=0;ntcr.trace.index===be.trace.index):H=[be];var Fe=H.length,Ge=DT("x",be,o),Ue=DT("y",be,o);Ie(Ge,Ue);var Ve=[],ir={},ar=0,er=function(cr){var st=AT[cr.trace.type]?PT(cr):cr.trace.index;if(!ir[st])ar++,ir[st]=ar,Ve.push(cr);else{var Wr=ir[st]-1,it=Ve[Wr];Wr>0&&Math.abs(cr.distance)Fe-1;Ee--)er(H[Ee]);H=Ve,ke()}var ee=e._hoverdata,ae=[],Ae=FT(e),Se=IT(e);for(let cr of H){var $e=ci.makeEventData(cr,cr.trace,cr.cd);if(cr.hovertemplate!==!1){var fr=!1;cr.cd[cr.index]&&cr.cd[cr.index].ht&&(fr=cr.cd[cr.index].ht),cr.hovertemplate=fr||cr.trace.hovertemplate||!1}if(cr.xa&&cr.ya){var sr=cr.x0+cr.xa._offset,Ar=cr.x1+cr.xa._offset,Hr=cr.y0+cr.ya._offset,Zr=cr.y1+cr.ya._offset,ot=Math.min(sr,Ar),Tt=Math.max(sr,Ar),bt=Math.min(Hr,Zr),Gt=Math.max(Hr,Zr);$e.bbox={x0:ot+Se,x1:Tt+Se,y0:bt+Ae,y1:Gt+Ae},$e.xPixel=(sr+Ar)/2,$e.yPixel=(Hr+Zr)/2}cr.eventData=[$e],ae.push($e)}e._hoverdata=ae;var Ht=h==="y"&&(O.length>1||H.length>1)||h==="closest"&&_e&&H.length>1,Wa=St.combine(o.plot_bgcolor||St.background,o.paper_bgcolor),ga=RT(H,{gd:e,hovermode:h,rotateLabels:Ht,bgColor:Wa,container:o._hoverlayer,outerContainer:o._paper.node(),commonLabelOpts:o.hoverlabel,hoverdistance:o.hoverdistance}),oa=ga.hoverLabels;if(ci.isUnifiedHover(h)||(WX(oa,Ht,o,ga.commonLabelBoundingBox),NT(oa,Ht,o._invScaleX,o._invScaleY)),n&&n.tagName){var Xa=wu.getComponentMethod("annotations","hasClickToShow")(e,ae);NX(Wo.select(n),Xa?"pointer":"")}var sa=JX(e,r,ee);if(!n||a||!sa&&!o.hoveranywhere)return;ee&&sa&&e.emit("plotly_unhover",{event:r,points:ee}),ua(e._hoverdata);function ua(cr){e.emit("plotly_hover",{event:r,points:cr,xaxes:k,yaxes:q,xvals:G,yvals:Y})}}function PT(e){return[e.trace.index,e.index,e.x0,e.y0,e.name,e.attr,e.xa?e.xa._id:"",e.ya?e.ya._id:""].join(",")}var GX=/([\s\S]*)<\/extra>/;function RT(e,r){var t=r.gd,a=t._fullLayout,n=r.hovermode,i=r.rotateLabels,l=r.bgColor,o=r.container,s=r.outerContainer,u=r.commonLabelOpts||{};if(e.length===0)return[[]];var f=r.fontFamily||Xo.HOVERFONT,v=r.fontSize||Xo.HOVERFONTSIZE,h=r.fontWeight||a.font.weight,d=r.fontStyle||a.font.style,m=r.fontVariant||a.font.variant,y=r.fontTextcase||a.font.textcase,g=r.fontLineposition||a.font.lineposition,x=r.fontShadow||a.font.shadow,T=e[0],M=T.xa,b=T.ya,w=n.charAt(0),S=w+"Label",k=T[S];if(k===void 0&&M.type==="multicategory")for(var q=0;qa.width-ae&&(Ae=a.width-ae),me.attr("d","M"+(er-Ae)+",0L"+(er-Ae+yt)+","+ee+yt+"H"+ae+"v"+ee+(Or*2+ar.height)+"H"+-ae+"V"+ee+yt+"H"+(er-Ae-yt)+"Z"),er=Ae,V.minX=er-ae,V.maxX=er+ae,M.side==="top"?(V.minY=Ee-(Or*2+ar.height),V.maxY=Ee-Or):(V.minY=Ee+Or,V.maxY=Ee+(Or*2+ar.height))}else{var Se,$e,fr;b.side==="right"?(Se="start",$e=1,fr="",er=M._offset+M._length):(Se="end",$e=-1,fr="-",er=M._offset),Ee=b._offset+(T.y0+T.y1)/2,be.attr("text-anchor",Se),me.attr("d","M0,0L"+fr+yt+","+yt+"V"+(Or+ar.height/2)+"h"+fr+(Or*2+ar.width)+"V-"+(Or+ar.height/2)+"H"+fr+yt+"V-"+yt+"Z"),V.minY=Ee-(Or+ar.height/2),V.maxY=Ee+(Or+ar.height/2),b.side==="right"?(V.minX=er+yt,V.maxX=er+yt+(Or*2+ar.width)):(V.minX=er-yt-(Or*2+ar.width),V.maxX=er-yt);var sr=ar.height/2,Ar=P-ar.top-sr,Hr="clip"+a._uid+"commonlabel"+b._id,Zr;if(erme.hoverinfo!=="none");if(De.length===0)return[];var J=a.hoverlabel,Z=J.font,re=De[0],se=((n==="x unified"?re.xa:re.ya).unifiedhovertitle||{}).text,ve=se?At.hovertemplateString({data:n==="x unified"?[{xa:re.xa,x:re.xVal}]:[{ya:re.ya,y:re.yVal}],fallback:re.trace.hovertemplatefallback,locale:a._d3locale,template:se}):k,xe={showlegend:!0,legend:{title:{text:ve,font:Z},font:Z,bgcolor:J.bgcolor,bordercolor:J.bordercolor,borderwidth:1,tracegroupgap:7,traceorder:a.legend?a.legend.traceorder:void 0,orientation:"v"}},Q={font:Z};IX(xe,Q,t._fullData);var we=Q.legend;we.entries=[];for(var fe=0;fe=0?he=ke:ze+Le=0?he=ze:He+Le=0?Ze=Ke:We+Ce=0?Ze=We:te+Ce=0,(De.idealAlign==="top"||!bt)&&Gt?(fr-=Ar/2,De.anchor="end"):bt?(fr+=Ar/2,De.anchor="start"):De.anchor="middle",De.crossPos=fr;else{if(De.pos=fr,bt=$e+sr/2+Tt<=z,Gt=$e-sr/2-Tt>=0,(De.idealAlign==="left"||!bt)&&Gt)$e-=sr/2,De.anchor="end";else if(bt)$e+=sr/2,De.anchor="start";else{De.anchor="middle";var Ht=Tt/2,Wa=$e+Ht-z,ga=$e-Ht;Wa>0&&($e-=Wa),ga<0&&($e+=-ga)}De.crossPos=$e}Ee.attr("text-anchor",De.anchor),ae&&ee.attr("text-anchor",De.anchor),me.attr("transform",l2($e,fr)+(i?RX(ET):""))}),{hoverLabels:hr,commonLabelBoundingBox:V}}function CT(e,r,t,a,n,i){var v,h;var l="",o="";e.nameOverride!==void 0&&(e.name=e.nameOverride),e.name&&(e.trace._meta&&(e.name=At.templateString(e.name,e.trace._meta)),l=qT(e.name,e.nameLength));var s=t.charAt(0),u=s==="x"?"y":"x";e.zLabel!==void 0?(e.xLabel!==void 0&&(o+="x: "+e.xLabel+"
"),e.yLabel!==void 0&&(o+="y: "+e.yLabel+"
"),e.trace.type!=="choropleth"&&e.trace.type!=="choroplethmap"&&(o+=(o?"z: ":"")+e.zLabel)):r&&e[s+"Label"]===n?o=e[u+"Label"]||"":e.xLabel===void 0?e.yLabel!==void 0&&e.trace.type!=="scattercarpet"&&(o=e.yLabel):e.yLabel===void 0?o=e.xLabel:o="("+e.xLabel+", "+e.yLabel+")",(e.text||e.text===0)&&!Array.isArray(e.text)&&(o+=(o?"
":"")+e.text),e.extraText!==void 0&&(o+=(o?"
":"")+e.extraText),i&&o===""&&!e.hovertemplate&&(l===""&&i.remove(),o=l),(h=(v=e.trace)==null?void 0:v.hoverlabel)!=null&&h.split&&(e.hovertemplate="");let{hovertemplate:f=!1}=e;if(f){let d=e.hovertemplateLabels||e;e[s+"Label"]!==n&&(d[s+"other"]=d[s+"Val"],d[s+"otherLabel"]=d[s+"Label"]),o=At.hovertemplateString({data:[e.eventData[0]||{},e.trace._meta],fallback:e.trace.hovertemplatefallback,labels:d,locale:a._d3locale,template:f}),o=o.replace(GX,(m,y)=>(l=qT(y,e.nameLength),""))}return[o,l]}function WX(e,r,t,a){var n=r?"xa":"ya",i=r?"ya":"xa",l=0,o=1,s=e.size(),u=new Array(s),f=0,v=a.minX,h=a.maxX,d=a.minY,m=a.maxY,y=function(G){return G*t._invScaleX},g=function(G){return G*t._invScaleY};e.each(function(G){var Y=G[n],B=G[i],V=Y._id.charAt(0)==="x",J=Y.range;f===0&&J&&J[0]>J[1]!==V&&(o=-1);var Z=0,re=V?t.width:t.height;if(t.hovermode==="x"||t.hovermode==="y"){var se=zT(G,r),ve=G.anchor,xe=ve==="end"?-1:1,Q,we;if(ve==="middle")Q=G.crossPos+(V?g(se.y-G.by/2):y(G.bx/2+G.tx2width/2)),we=Q+(V?g(G.by):y(G.bx));else if(V)Q=G.crossPos+g(yt+se.y)-g(G.by/2-yt),we=Q+g(G.by);else{var fe=y(xe*yt+se.x),ge=fe+y(xe*G.bx);Q=G.crossPos+Math.min(fe,ge),we=G.crossPos+Math.max(fe,ge)}V?d!==void 0&&m!==void 0&&Math.min(we,m)-Math.max(Q,d)>1&&(B.side==="left"?(Z=B._mainLinePosition,re=t.width):re=B._mainLinePosition):v!==void 0&&h!==void 0&&Math.min(we,h)-Math.max(Q,v)>1&&(B.side==="top"?(Z=B._mainLinePosition,re=t.height):re=B._mainLinePosition)}u[f++]=[{datum:G,traceIndex:G.trace.index,dp:0,pos:G.pos,posref:G.posref,size:G.by*(V?OX:1)/2,pmin:Z,pmax:re}]}),u.sort(function(G,Y){return G[0].posref-Y[0].posref||o*(Y[0].traceIndex-G[0].traceIndex)});var x,T,M,b,w,S,k;function q(G){var Y=G[0],B=G[G.length-1];if(T=Y.pmin-Y.pos-Y.dp+Y.size,M=B.pos+B.dp+B.size-Y.pmax,T>.01){for(w=G.length-1;w>=0;w--)G[w].dp+=T;x=!1}if(!(M<.01)){if(T<-.01){for(w=G.length-1;w>=0;w--)G[w].dp-=M;x=!1}if(x){var V=0;for(b=0;bY.pmax&&V++;for(b=G.length-1;b>=0&&!(V<=0);b--)S=G[b],S.pos>Y.pmax-1&&(S.del=!0,V--);for(b=0;b=0;w--)G[w].dp-=M;for(b=G.length-1;b>=0&&!(V<=0);b--)S=G[b],S.pos+S.dp+S.size>Y.pmax&&(S.del=!0,V--)}}}for(;!x&&l<=s;){for(l++,x=!0,b=0;b.01){for(w=P.length-1;w>=0;w--)P[w].dp+=T;for(D.push.apply(D,P),u.splice(b+1,1),k=0,w=D.length-1;w>=0;w--)k+=D[w].dp;for(M=k/D.length,w=D.length-1;w>=0;w--)D[w].dp-=M;x=!1}else b++}u.forEach(q)}for(b=u.length-1;b>=0;b--){var I=u[b];for(w=I.length-1;w>=0;w--){var H=I[w],O=H.datum;O.offset=H.dp,O.del=H.del}}}function zT(e,r){var t=0,a=e.offset;return r&&(a*=-YX,t=e.offset*BX),{x:t,y:a}}function XX(e){var r={start:1,end:-1,middle:0}[e.anchor],t=r*(yt+Or),a=t+r*(e.txwidth+Or),n=e.anchor==="middle";return n&&(t-=e.tx2width/2,a+=e.txwidth/2+Or),{alignShift:r,textShiftX:t,text2ShiftX:a}}function NT(e,r,t,a){var n=function(l){return l*t},i=function(l){return l*a};e.each(function(l){var o=Wo.select(this);if(l.del)return o.remove();var s=o.select("text.nums"),u=l.anchor,f=u==="end"?-1:1,v=XX(l),h=zT(l,r),d=h.x,m=h.y,y=u==="middle",g="hoverlabel"in l.trace?l.trace.hoverlabel.showarrow:!0,x;y?x="M-"+n(l.bx/2+l.tx2width/2)+","+i(m-l.by/2)+"h"+n(l.bx)+"v"+i(l.by)+"h-"+n(l.bx)+"Z":g?x="M0,0L"+n(f*yt+d)+","+i(yt+m)+"v"+i(l.by/2-yt)+"h"+n(f*l.bx)+"v-"+i(l.by)+"H"+n(f*yt+d)+"V"+i(m-yt)+"Z":x="M"+n(f*yt+d)+","+i(m-l.by/2)+"h"+n(f*l.bx)+"v"+i(l.by)+"h"+n(-f*l.bx)+"Z",o.select("path").attr("d",x);var T=d+v.textShiftX,M=m+l.ty0-l.by/2+Or,b=l.textAlign||"auto";b!=="auto"&&(b==="left"&&u!=="start"?(s.attr("text-anchor","start"),T=y?-l.bx/2-l.tx2width/2+Or:-l.bx-Or):b==="right"&&u!=="end"&&(s.attr("text-anchor","end"),T=y?l.bx/2-l.tx2width/2-Or:l.bx+Or)),s.call(zn.positionText,n(T),i(M)),l.tx2width&&(o.select("text.name").call(zn.positionText,n(v.text2ShiftX+v.alignShift*Or+d),i(m+l.ty0-l.by/2+Or)),o.select("rect").call(Fi.setRect,n(v.text2ShiftX+(v.alignShift-1)*l.tx2width/2+d),i(m-l.by/2-1),n(l.tx2width),i(l.by+2)))})}function ZX(e,r){var t=e.index,a=e.trace||{},n=e.cd[0],i=e.cd[t]||{};function l(h){return h||nl(h)&&h===0}var o=Array.isArray(t)?function(h,d){var m=At.castOption(n,t,h);return l(m)?m:At.extractOption({},a,"",d)}:function(h,d){return At.extractOption(i,a,h,d)};function s(h,d,m){var y=o(d,m);l(y)&&(e[h]=y)}if(s("hoverinfo","hi","hoverinfo"),s("bgcolor","hbg","hoverlabel.bgcolor"),s("borderColor","hbc","hoverlabel.bordercolor"),s("fontFamily","htf","hoverlabel.font.family"),s("fontSize","hts","hoverlabel.font.size"),s("fontColor","htc","hoverlabel.font.color"),s("fontWeight","htw","hoverlabel.font.weight"),s("fontStyle","hty","hoverlabel.font.style"),s("fontVariant","htv","hoverlabel.font.variant"),s("nameLength","hnl","hoverlabel.namelength"),s("textAlign","hta","hoverlabel.align"),e.posref=r==="y"||r==="closest"&&a.orientation==="h"?e.xa._offset+(e.x0+e.x1)/2:e.ya._offset+(e.y0+e.y1)/2,e.x0=At.constrain(e.x0,0,e.xa._length),e.x1=At.constrain(e.x1,0,e.xa._length),e.y0=At.constrain(e.y0,0,e.ya._length),e.y1=At.constrain(e.y1,0,e.ya._length),e.xLabelVal!==void 0&&(e.xLabel="xLabel"in e?e.xLabel:Ii.hoverLabelText(e.xa,e.xLabelVal,a.xhoverformat),e.xVal=e.xa.c2d(e.xLabelVal)),e.yLabelVal!==void 0&&(e.yLabel="yLabel"in e?e.yLabel:Ii.hoverLabelText(e.ya,e.yLabelVal,a.yhoverformat),e.yVal=e.ya.c2d(e.yLabelVal)),e.zLabelVal!==void 0&&e.zLabel===void 0&&(e.zLabel=String(e.zLabelVal)),!isNaN(e.xerr)&&!(e.xa.type==="log"&&e.xerr<=0)){var u=Ii.tickText(e.xa,e.xa.c2l(e.xerr),"hover").text;e.xerrneg!==void 0?e.xLabel+=" +"+u+" / -"+Ii.tickText(e.xa,e.xa.c2l(e.xerrneg),"hover").text:e.xLabel+=" \xB1 "+u,r==="x"&&(e.distance+=1)}if(!isNaN(e.yerr)&&!(e.ya.type==="log"&&e.yerr<=0)){var f=Ii.tickText(e.ya,e.ya.c2l(e.yerr),"hover").text;e.yerrneg!==void 0?e.yLabel+=" +"+f+" / -"+Ii.tickText(e.ya,e.ya.c2l(e.yerrneg),"hover").text:e.yLabel+=" \xB1 "+f,r==="y"&&(e.distance+=1)}var v=e.hoverinfo||e.trace.hoverinfo;return v&&v!=="all"&&(v=Array.isArray(v)?v:v.split("+"),v.indexOf("x")===-1&&(e.xLabel=void 0),v.indexOf("y")===-1&&(e.yLabel=void 0),v.indexOf("z")===-1&&(e.zLabel=void 0),v.indexOf("text")===-1&&(e.text=void 0),v.indexOf("name")===-1&&(e.name=void 0)),e}function ST(e,r,t){var a=t.container,n=t.fullLayout,i=n._size,l=t.event,o=!!r.hLinePoint,s=!!r.vLinePoint,u,f;if(a.selectAll(".spikeline").remove(),!!(s||o)){var v=St.combine(n.plot_bgcolor,n.paper_bgcolor);if(o){var h=r.hLinePoint,d,m;u=h&&h.xa,f=h&&h.ya;var y=f.spikesnap;y==="cursor"?(d=l.pointerX,m=l.pointerY):(d=u._offset+h.x,m=f._offset+h.y);var g=St.color(h.color).contrast(St.color(v))<1.5?St.contrast(v):h.color,x=f.spikemode,T=f.spikethickness,M=f.spikecolor||g,b=Ii.getPxPosition(e,f),w,S;if(x.indexOf("toaxis")!==-1||x.indexOf("across")!==-1){if(x.indexOf("toaxis")!==-1&&(w=b,S=d),x.indexOf("across")!==-1){var k=f._counterDomainMin,q=f._counterDomainMax;f.anchor==="free"&&(k=Math.min(k,f.position),q=Math.max(q,f.position)),w=i.l+k*i.w,S=i.l+q*i.w}a.insert("line",":first-child").attr({x1:w,x2:S,y1:m,y2:m,"stroke-width":T,stroke:M,"stroke-dasharray":Fi.dashStyle(f.spikedash,T)}).classed("spikeline",!0).classed("crisp",!0),a.insert("line",":first-child").attr({x1:w,x2:S,y1:m,y2:m,"stroke-width":T+2,stroke:v}).classed("spikeline",!0).classed("crisp",!0)}x.indexOf("marker")!==-1&&a.insert("circle",":first-child").attr({cx:b+(f.side!=="right"?T:-T),cy:m,r:T,fill:M}).classed("spikeline",!0)}if(s){var D=r.vLinePoint,P,z;u=D&&D.xa,f=D&&D.ya;var F=u.spikesnap;F==="cursor"?(P=l.pointerX,z=l.pointerY):(P=u._offset+D.x,z=f._offset+D.y);var I=St.color(D.color).contrast(St.color(v))<1.5?St.contrast(v):D.color,H=u.spikemode,O=u.spikethickness,G=u.spikecolor||I,Y=Ii.getPxPosition(e,u),B,V;if(H.indexOf("toaxis")!==-1||H.indexOf("across")!==-1){if(H.indexOf("toaxis")!==-1&&(B=Y,V=z),H.indexOf("across")!==-1){var J=u._counterDomainMin,Z=u._counterDomainMax;u.anchor==="free"&&(J=Math.min(J,u.position),Z=Math.max(Z,u.position)),B=i.t+(1-Z)*i.h,V=i.t+(1-J)*i.h}a.insert("line",":first-child").attr({x1:P,x2:P,y1:B,y2:V,"stroke-width":O,stroke:G,"stroke-dasharray":Fi.dashStyle(u.spikedash,O)}).classed("spikeline",!0).classed("crisp",!0),a.insert("line",":first-child").attr({x1:P,x2:P,y1:B,y2:V,"stroke-width":O+2,stroke:v}).classed("spikeline",!0).classed("crisp",!0)}H.indexOf("marker")!==-1&&a.insert("circle",":first-child").attr({cx:P,cy:Y-(u.side!=="top"?O:-O),r:O,fill:G}).classed("spikeline",!0)}}}function JX(e,r,t){if(!t||t.length!==e._hoverdata.length)return!0;for(var a=t.length-1;a>=0;a--){var n=t[a],i=e._hoverdata[a];if(n.curveNumber!==i.curveNumber||String(n.pointNumber)!==String(i.pointNumber)||String(n.pointNumbers)!==String(i.pointNumbers)||n.binNumber!==i.binNumber)return!0}return!1}function LT(e,r){return!r||r.vLinePoint!==e._spikepoints.vLinePoint||r.hLinePoint!==e._spikepoints.hLinePoint}function qT(e,r){return zn.plainText(e||"",{len:r,allowedTags:["br","sub","sup","b","i","em","s","u"]})}function KX(e,r){for(var t=r.charAt(0),a=[],n=[],i=[],l=0;le.offsetTop+e.clientTop,IT=e=>e.offsetLeft+e.clientLeft;function xu(e,r){var t=e._fullLayout,a=r.getBoundingClientRect(),n=a.left,i=a.top,l=n+a.width,o=i+a.height,s=At.apply3DTransform(t._invTransform)(n,i),u=At.apply3DTransform(t._invTransform)(l,o),f=s[0],v=s[1],h=u[0],d=u[1];return{x:f,y:v,width:h-f,height:d-v,top:Math.min(v,d),left:Math.min(f,h),right:Math.max(f,h),bottom:Math.max(v,d)}}});var A1=X((Efe,HT)=>{"use strict";var $X=Ne(),QX=Er(),jX=Di().isUnifiedHover;HT.exports=function(r,t,a,n){n=n||{};var i=t.legend;function l(o){n.font[o]||(n.font[o]=i?t.legend.font[o]:t.font[o])}t&&jX(t.hovermode)&&(n.font||(n.font={}),l("size"),l("family"),l("color"),l("weight"),l("style"),l("variant"),i?(n.bgcolor||(n.bgcolor=QX.combine(t.legend.bgcolor,t.paper_bgcolor)),n.bordercolor||(n.bordercolor=t.legend.bordercolor)):n.bgcolor||(n.bgcolor=t.paper_bgcolor)),a("hoverlabel.bgcolor",n.bgcolor),a("hoverlabel.bordercolor",n.bordercolor),a("hoverlabel.namelength",n.namelength),a("hoverlabel.showarrow",n.showarrow),$X.coerceFont(a,"hoverlabel.font",n.font),a("hoverlabel.align",n.align)}});var BT=X((Pfe,OT)=>{"use strict";var eZ=Ne(),rZ=A1(),tZ=zs();OT.exports=function(r,t){function a(n,i){return eZ.coerce(r,t,tZ,n,i)}rZ(r,t,a)}});var VT=X((Rfe,UT)=>{"use strict";var YT=Ne(),aZ=Z0(),nZ=A1();UT.exports=function(r,t,a,n){function i(o,s){return YT.coerce(r,t,aZ,o,s)}var l=YT.extendFlat({},n.hoverlabel);t.hovertemplate&&(l.namelength=-1),nZ(r,t,i,l)}});var f2=X((zfe,GT)=>{"use strict";var iZ=Ne(),lZ=zs();GT.exports=function(r,t){function a(n,i){return t[n]!==void 0?t[n]:iZ.coerce(r,t,lZ,n,i)}return a("clickmode"),a("hoversubplots"),a("hoveranywhere"),a("clickanywhere"),a("hovermode")}});var ZT=X((Nfe,XT)=>{"use strict";var WT=Ne(),oZ=zs(),sZ=f2(),uZ=A1();XT.exports=function(r,t){function a(u,f){return WT.coerce(r,t,oZ,u,f)}var n=sZ(r,t);n&&(a("hoverdistance"),a("spikedistance"),n.indexOf("unified")!==-1&&a("hoversort"));var i=a("dragmode");i==="select"&&a("selectdirection");var l=t._has("map"),o=t._has("geo"),s=t._basePlotModules.length;t.dragmode==="zoom"&&((l||o)&&s===1||l&&o&&s===2)&&(t.dragmode="pan"),uZ(r,t,a),WT.coerceFont(a,"hoverlabel.grouptitlefont",t.hoverlabel.font)}});var $T=X((Ffe,KT)=>{"use strict";var c2=Ne(),JT=_r();KT.exports=function(r){var t=r.calcdata,a=r._fullLayout;function n(u){return function(f){return c2.coerceHoverinfo({hoverinfo:f},{_module:u._module},a)}}for(var i=0;i{"use strict";var cZ=_r(),vZ=u2().hover;QT.exports=function(r,t,a){var n=cZ.getComponentMethod("annotations","onClick")(r,r._hoverdata),i=r._fullLayout;a!==void 0&&vZ(r,t,a,!0);function l(){var s,u,f,v;var o={points:r._hoverdata,event:t};(s=o.xaxes)!=null||(o.xaxes=r._hoverXAxes),(u=o.yaxes)!=null||(o.yaxes=r._hoverYAxes),(f=o.xvals)!=null||(o.xvals=r._hoverXVals),(v=o.yvals)!=null||(o.yvals=r._hoverYVals),r.emit("plotly_click",o)}(r._hoverdata||i.clickanywhere)&&t&&t.target&&(r._hoverdata||(r._hoverdata=[]),n&&n.then?n.then(l):l(),t.stopImmediatePropagation&&t.stopImmediatePropagation())}});var vi=X((Hfe,tM)=>{"use strict";var hZ=Nr(),k1=Ne(),dZ=fi(),rc=Di(),eM=zs(),rM=u2();tM.exports={moduleType:"component",name:"fx",constants:bf(),schema:{layout:eM},attributes:Z0(),layoutAttributes:eM,supplyLayoutGlobalDefaults:BT(),supplyDefaults:VT(),supplyLayoutDefaults:ZT(),calc:$T(),getDistanceFunction:rc.getDistanceFunction,getClosest:rc.getClosest,inbox:rc.inbox,quadrature:rc.quadrature,appendArrayPointValue:rc.appendArrayPointValue,castHoverOption:mZ,castHoverinfo:yZ,hover:rM.hover,unhover:dZ.unhover,loneHover:rM.loneHover,loneUnhover:pZ,click:jT()};function pZ(e){var r=k1.isD3Selection(e)?e:hZ.select(e);r.selectAll("g.hovertext").remove(),r.selectAll(".spikeline").remove()}function mZ(e,r,t){return k1.castOption(e,r,"hoverlabel."+t)}function yZ(e,r,t){function a(n){return k1.coerceHoverinfo({hoverinfo:n},{_module:e._module},r)}return k1.castOption(e,t,"hoverinfo",a)}});var Tu=X(il=>{"use strict";il.selectMode=function(e){return e==="lasso"||e==="select"};il.drawMode=function(e){return e==="drawclosedpath"||e==="drawopenpath"||e==="drawline"||e==="drawrect"||e==="drawcircle"};il.openMode=function(e){return e==="drawline"||e==="drawopenpath"};il.rectMode=function(e){return e==="select"||e==="drawline"||e==="drawrect"||e==="drawcircle"};il.freeMode=function(e){return e==="lasso"||e==="drawclosedpath"||e==="drawopenpath"};il.selectingOrDrawing=function(e){return il.freeMode(e)||il.rectMode(e)}});var C1=X((Bfe,aM)=>{"use strict";aM.exports=function(r){var t=r._fullLayout;t._glcanvas&&t._glcanvas.size()&&t._glcanvas.each(function(a){a.regl&&a.regl.clear({color:!0,depth:!0})})}});var S1=X((Yfe,nM)=>{"use strict";nM.exports={undo:{width:857.1,height:1e3,path:"m857 350q0-87-34-166t-91-137-137-92-166-34q-96 0-183 41t-147 114q-4 6-4 13t5 11l76 77q6 5 14 5 9-1 13-7 41-53 100-82t126-29q58 0 110 23t92 61 61 91 22 111-22 111-61 91-92 61-110 23q-55 0-105-20t-90-57l77-77q17-16 8-38-10-23-33-23h-250q-15 0-25 11t-11 25v250q0 24 22 33 22 10 39-8l72-72q60 57 137 88t159 31q87 0 166-34t137-92 91-137 34-166z",transform:"matrix(1 0 0 -1 0 850)"},home:{width:928.6,height:1e3,path:"m786 296v-267q0-15-11-26t-25-10h-214v214h-143v-214h-214q-15 0-25 10t-11 26v267q0 1 0 2t0 2l321 264 321-264q1-1 1-4z m124 39l-34-41q-5-5-12-6h-2q-7 0-12 3l-386 322-386-322q-7-4-13-4-7 2-12 7l-35 41q-4 5-3 13t6 12l401 334q18 15 42 15t43-15l136-114v109q0 8 5 13t13 5h107q8 0 13-5t5-13v-227l122-102q5-5 6-12t-4-13z",transform:"matrix(1 0 0 -1 0 850)"},"camera-retro":{width:1e3,height:1e3,path:"m518 386q0 8-5 13t-13 5q-37 0-63-27t-26-63q0-8 5-13t13-5 12 5 5 13q0 23 16 38t38 16q8 0 13 5t5 13z m125-73q0-59-42-101t-101-42-101 42-42 101 42 101 101 42 101-42 42-101z m-572-320h858v71h-858v-71z m643 320q0 89-62 152t-152 62-151-62-63-152 63-151 151-63 152 63 62 151z m-571 358h214v72h-214v-72z m-72-107h858v143h-462l-36-71h-360v-72z m929 143v-714q0-30-21-51t-50-21h-858q-29 0-50 21t-21 51v714q0 30 21 51t50 21h858q29 0 50-21t21-51z",transform:"matrix(1 0 0 -1 0 850)"},zoombox:{width:1e3,height:1e3,path:"m1000-25l-250 251c40 63 63 138 63 218 0 224-182 406-407 406-224 0-406-182-406-406s183-406 407-406c80 0 155 22 218 62l250-250 125 125z m-812 250l0 438 437 0 0-438-437 0z m62 375l313 0 0-312-313 0 0 312z",transform:"matrix(1 0 0 -1 0 850)"},pan:{width:1e3,height:1e3,path:"m1000 350l-187 188 0-125-250 0 0 250 125 0-188 187-187-187 125 0 0-250-250 0 0 125-188-188 186-187 0 125 252 0 0-250-125 0 187-188 188 188-125 0 0 250 250 0 0-126 187 188z",transform:"matrix(1 0 0 -1 0 850)"},zoom_plus:{width:875,height:1e3,path:"m1 787l0-875 875 0 0 875-875 0z m687-500l-187 0 0-187-125 0 0 187-188 0 0 125 188 0 0 187 125 0 0-187 187 0 0-125z",transform:"matrix(1 0 0 -1 0 850)"},zoom_minus:{width:875,height:1e3,path:"m0 788l0-876 875 0 0 876-875 0z m688-500l-500 0 0 125 500 0 0-125z",transform:"matrix(1 0 0 -1 0 850)"},autoscale:{width:1e3,height:1e3,path:"m250 850l-187 0-63 0 0-62 0-188 63 0 0 188 187 0 0 62z m688 0l-188 0 0-62 188 0 0-188 62 0 0 188 0 62-62 0z m-875-938l0 188-63 0 0-188 0-62 63 0 187 0 0 62-187 0z m875 188l0-188-188 0 0-62 188 0 62 0 0 62 0 188-62 0z m-125 188l-1 0-93-94-156 156 156 156 92-93 2 0 0 250-250 0 0-2 93-92-156-156-156 156 94 92 0 2-250 0 0-250 0 0 93 93 157-156-157-156-93 94 0 0 0-250 250 0 0 0-94 93 156 157 156-157-93-93 0 0 250 0 0 250z",transform:"matrix(1 0 0 -1 0 850)"},tooltip_basic:{width:1500,height:1e3,path:"m375 725l0 0-375-375 375-374 0-1 1125 0 0 750-1125 0z",transform:"matrix(1 0 0 -1 0 850)"},tooltip_compare:{width:1125,height:1e3,path:"m187 786l0 2-187-188 188-187 0 0 937 0 0 373-938 0z m0-499l0 1-187-188 188-188 0 0 937 0 0 376-938-1z",transform:"matrix(1 0 0 -1 0 850)"},plotlylogo:{width:1542,height:1e3,path:"m0-10h182v-140h-182v140z m228 146h183v-286h-183v286z m225 714h182v-1000h-182v1000z m225-285h182v-715h-182v715z m225 142h183v-857h-183v857z m231-428h182v-429h-182v429z m225-291h183v-138h-183v138z",transform:"matrix(1 0 0 -1 0 850)"},"z-axis":{width:1e3,height:1e3,path:"m833 5l-17 108v41l-130-65 130-66c0 0 0 38 0 39 0-1 36-14 39-25 4-15-6-22-16-30-15-12-39-16-56-20-90-22-187-23-279-23-261 0-341 34-353 59 3 60 228 110 228 110-140-8-351-35-351-116 0-120 293-142 474-142 155 0 477 22 477 142 0 50-74 79-163 96z m-374 94c-58-5-99-21-99-40 0-24 65-43 144-43 79 0 143 19 143 43 0 19-42 34-98 40v216h87l-132 135-133-135h88v-216z m167 515h-136v1c16 16 31 34 46 52l84 109v54h-230v-71h124v-1c-16-17-28-32-44-51l-89-114v-51h245v72z",transform:"matrix(1 0 0 -1 0 850)"},"3d_rotate":{width:1e3,height:1e3,path:"m922 660c-5 4-9 7-14 11-359 263-580-31-580-31l-102 28 58-400c0 1 1 1 2 2 118 108 351 249 351 249s-62 27-100 42c88 83 222 183 347 122 16-8 30-17 44-27-2 1-4 2-6 4z m36-329c0 0 64 229-88 296-62 27-124 14-175-11 157-78 225-208 249-266 8-19 11-31 11-31 2 5 6 15 11 32-5-13-8-20-8-20z m-775-239c70-31 117-50 198-32-121 80-199 346-199 346l-96-15-58-12c0 0 55-226 155-287z m603 133l-317-139c0 0 4-4 19-14 7-5 24-15 24-15s-177-147-389 4c235-287 536-112 536-112l31-22 100 299-4-1z m-298-153c6-4 14-9 24-15 0 0-17 10-24 15z",transform:"matrix(1 0 0 -1 0 850)"},camera:{width:1e3,height:1e3,path:"m500 450c-83 0-150-67-150-150 0-83 67-150 150-150 83 0 150 67 150 150 0 83-67 150-150 150z m400 150h-120c-16 0-34 13-39 29l-31 93c-6 15-23 28-40 28h-340c-16 0-34-13-39-28l-31-94c-6-15-23-28-40-28h-120c-55 0-100-45-100-100v-450c0-55 45-100 100-100h800c55 0 100 45 100 100v450c0 55-45 100-100 100z m-400-550c-138 0-250 112-250 250 0 138 112 250 250 250 138 0 250-112 250-250 0-138-112-250-250-250z m365 380c-19 0-35 16-35 35 0 19 16 35 35 35 19 0 35-16 35-35 0-19-16-35-35-35z",transform:"matrix(1 0 0 -1 0 850)"},movie:{width:1e3,height:1e3,path:"m938 413l-188-125c0 37-17 71-44 94 64 38 107 107 107 187 0 121-98 219-219 219-121 0-219-98-219-219 0-61 25-117 66-156h-115c30 33 49 76 49 125 0 103-84 187-187 187s-188-84-188-187c0-57 26-107 65-141-38-22-65-62-65-109v-250c0-70 56-126 125-126h500c69 0 125 56 125 126l188-126c34 0 62 28 62 63v375c0 35-28 63-62 63z m-750 0c-69 0-125 56-125 125s56 125 125 125 125-56 125-125-56-125-125-125z m406-1c-87 0-157 70-157 157 0 86 70 156 157 156s156-70 156-156-70-157-156-157z",transform:"matrix(1 0 0 -1 0 850)"},question:{width:857.1,height:1e3,path:"m500 82v107q0 8-5 13t-13 5h-107q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h107q8 0 13 5t5 13z m143 375q0 49-31 91t-77 65-95 23q-136 0-207-119-9-14 4-24l74-55q4-4 10-4 9 0 14 7 30 38 48 51 19 14 48 14 27 0 48-15t21-33q0-21-11-34t-38-25q-35-16-65-48t-29-70v-20q0-8 5-13t13-5h107q8 0 13 5t5 13q0 10 12 27t30 28q18 10 28 16t25 19 25 27 16 34 7 45z m214-107q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z",transform:"matrix(1 0 0 -1 0 850)"},disk:{width:857.1,height:1e3,path:"m214-7h429v214h-429v-214z m500 0h72v500q0 8-6 21t-11 20l-157 156q-5 6-19 12t-22 5v-232q0-22-15-38t-38-16h-322q-22 0-37 16t-16 38v232h-72v-714h72v232q0 22 16 38t37 16h465q22 0 38-16t15-38v-232z m-214 518v178q0 8-5 13t-13 5h-107q-7 0-13-5t-5-13v-178q0-8 5-13t13-5h107q7 0 13 5t5 13z m357-18v-518q0-22-15-38t-38-16h-750q-23 0-38 16t-16 38v750q0 22 16 38t38 16h517q23 0 50-12t42-26l156-157q16-15 27-42t11-49z",transform:"matrix(1 0 0 -1 0 850)"},cloudupload:{width:640,height:640,path:"M451.5 160C434.9 160 418.8 164.5 404.7 172.7C388.9 156.7 370.5 143.3 350.2 133.2C378.4 109.2 414.3 96 451.5 96C537.9 96 608 166 608 252.5C608 294 591.5 333.8 562.2 363.1L491.1 434.2C461.8 463.5 422 480 380.5 480C294.1 480 224 410 224 323.5C224 322 224 320.5 224.1 319C224.6 301.3 239.3 287.4 257 287.9C274.7 288.4 288.6 303.1 288.1 320.8C288.1 321.7 288.1 322.6 288.1 323.4C288.1 374.5 329.5 415.9 380.6 415.9C405.1 415.9 428.6 406.2 446 388.8L517.1 317.7C534.4 300.4 544.2 276.8 544.2 252.3C544.2 201.2 502.8 159.8 451.7 159.8zM307.2 237.3C305.3 236.5 303.4 235.4 301.7 234.2C289.1 227.7 274.7 224 259.6 224C235.1 224 211.6 233.7 194.2 251.1L123.1 322.2C105.8 339.5 96 363.1 96 387.6C96 438.7 137.4 480.1 188.5 480.1C205 480.1 221.1 475.7 235.2 467.5C251 483.5 269.4 496.9 289.8 507C261.6 530.9 225.8 544.2 188.5 544.2C102.1 544.2 32 474.2 32 387.7C32 346.2 48.5 306.4 77.8 277.1L148.9 206C178.2 176.7 218 160.2 259.5 160.2C346.1 160.2 416 230.8 416 317.1C416 318.4 416 319.7 416 321C415.6 338.7 400.9 352.6 383.2 352.2C365.5 351.8 351.6 337.1 352 319.4C352 318.6 352 317.9 352 317.1C352 283.4 334 253.8 307.2 237.5z",transform:"matrix(1 0 0 1 -15 -15)"},drawopenpath:{width:70,height:70,path:"M33.21,85.65a7.31,7.31,0,0,1-2.59-.48c-8.16-3.11-9.27-19.8-9.88-41.3-.1-3.58-.19-6.68-.35-9-.15-2.1-.67-3.48-1.43-3.79-2.13-.88-7.91,2.32-12,5.86L3,32.38c1.87-1.64,11.55-9.66,18.27-6.9,2.13.87,4.75,3.14,5.17,9,.17,2.43.26,5.59.36,9.25a224.17,224.17,0,0,0,1.5,23.4c1.54,10.76,4,12.22,4.48,12.4.84.32,2.79-.46,5.76-3.59L43,80.07C41.53,81.57,37.68,85.64,33.21,85.65ZM74.81,69a11.34,11.34,0,0,0,6.09-6.72L87.26,44.5,74.72,32,56.9,38.35c-2.37.86-5.57,3.42-6.61,6L38.65,72.14l8.42,8.43ZM55,46.27a7.91,7.91,0,0,1,3.64-3.17l14.8-5.3,8,8L76.11,60.6l-.06.19a6.37,6.37,0,0,1-3,3.43L48.25,74.59,44.62,71Zm16.57,7.82A6.9,6.9,0,1,0,64.64,61,6.91,6.91,0,0,0,71.54,54.09Zm-4.05,0a2.85,2.85,0,1,1-2.85-2.85A2.86,2.86,0,0,1,67.49,54.09Zm-4.13,5.22L60.5,56.45,44.26,72.7l2.86,2.86ZM97.83,35.67,84.14,22l-8.57,8.57L89.26,44.24Zm-13.69-8,8,8-2.85,2.85-8-8Z",transform:"matrix(1 0 0 1 -15 -15)"},drawclosedpath:{width:90,height:90,path:"M88.41,21.12a26.56,26.56,0,0,0-36.18,0l-2.07,2-2.07-2a26.57,26.57,0,0,0-36.18,0,23.74,23.74,0,0,0,0,34.8L48,90.12a3.22,3.22,0,0,0,4.42,0l36-34.21a23.73,23.73,0,0,0,0-34.79ZM84,51.24,50.16,83.35,16.35,51.25a17.28,17.28,0,0,1,0-25.47,20,20,0,0,1,27.3,0l4.29,4.07a3.23,3.23,0,0,0,4.44,0l4.29-4.07a20,20,0,0,1,27.3,0,17.27,17.27,0,0,1,0,25.46ZM66.76,47.68h-33v6.91h33ZM53.35,35H46.44V68h6.91Z",transform:"matrix(1 0 0 1 -5 -5)"},lasso:{width:1031,height:1e3,path:"m1018 538c-36 207-290 336-568 286-277-48-473-256-436-463 10-57 36-108 76-151-13-66 11-137 68-183 34-28 75-41 114-42l-55-70 0 0c-2-1-3-2-4-3-10-14-8-34 5-45 14-11 34-8 45 4 1 1 2 3 2 5l0 0 113 140c16 11 31 24 45 40 4 3 6 7 8 11 48-3 100 0 151 9 278 48 473 255 436 462z m-624-379c-80 14-149 48-197 96 42 42 109 47 156 9 33-26 47-66 41-105z m-187-74c-19 16-33 37-39 60 50-32 109-55 174-68-42-25-95-24-135 8z m360 75c-34-7-69-9-102-8 8 62-16 128-68 170-73 59-175 54-244-5-9 20-16 40-20 61-28 159 121 317 333 354s407-60 434-217c28-159-121-318-333-355z",transform:"matrix(1 0 0 -1 0 850)"},selectbox:{width:1e3,height:1e3,path:"m0 850l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z m285 0l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z m-857-286l0-143 143 0 0 143-143 0z m857 0l0-143 143 0 0 143-143 0z m-857-285l0-143 143 0 0 143-143 0z m857 0l0-143 143 0 0 143-143 0z m-857-286l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z m285 0l0-143 143 0 0 143-143 0z m286 0l0-143 143 0 0 143-143 0z",transform:"matrix(1 0 0 -1 0 850)"},drawline:{width:70,height:70,path:"M60.64,62.3a11.29,11.29,0,0,0,6.09-6.72l6.35-17.72L60.54,25.31l-17.82,6.4c-2.36.86-5.57,3.41-6.6,6L24.48,65.5l8.42,8.42ZM40.79,39.63a7.89,7.89,0,0,1,3.65-3.17l14.79-5.31,8,8L61.94,54l-.06.19a6.44,6.44,0,0,1-3,3.43L34.07,68l-3.62-3.63Zm16.57,7.81a6.9,6.9,0,1,0-6.89,6.9A6.9,6.9,0,0,0,57.36,47.44Zm-4,0a2.86,2.86,0,1,1-2.85-2.85A2.86,2.86,0,0,1,53.32,47.44Zm-4.13,5.22L46.33,49.8,30.08,66.05l2.86,2.86ZM83.65,29,70,15.34,61.4,23.9,75.09,37.59ZM70,21.06l8,8-2.84,2.85-8-8ZM87,80.49H10.67V87H87Z",transform:"matrix(1 0 0 1 -15 -15)"},drawrect:{width:80,height:80,path:"M78,22V79H21V22H78m9-9H12V88H87V13ZM68,46.22H31V54H68ZM53,32H45.22V69H53Z",transform:"matrix(1 0 0 1 -10 -10)"},drawcircle:{width:80,height:80,path:"M50,84.72C26.84,84.72,8,69.28,8,50.3S26.84,15.87,50,15.87,92,31.31,92,50.3,73.16,84.72,50,84.72Zm0-60.59c-18.6,0-33.74,11.74-33.74,26.17S31.4,76.46,50,76.46,83.74,64.72,83.74,50.3,68.6,24.13,50,24.13Zm17.15,22h-34v7.11h34Zm-13.8-13H46.24v34h7.11Z",transform:"matrix(1 0 0 1 -10 -10)"},eraseshape:{width:80,height:80,path:"M82.77,78H31.85L6,49.57,31.85,21.14H82.77a8.72,8.72,0,0,1,8.65,8.77V69.24A8.72,8.72,0,0,1,82.77,78ZM35.46,69.84H82.77a.57.57,0,0,0,.49-.6V29.91a.57.57,0,0,0-.49-.61H35.46L17,49.57Zm32.68-34.7-24,24,5,5,24-24Zm-19,.53-5,5,24,24,5-5Z",transform:"matrix(1 0 0 1 -10 -10)"},spikeline:{width:1e3,height:1e3,path:"M512 409c0-57-46-104-103-104-57 0-104 47-104 104 0 57 47 103 104 103 57 0 103-46 103-103z m-327-39l92 0 0 92-92 0z m-185 0l92 0 0 92-92 0z m370-186l92 0 0 93-92 0z m0-184l92 0 0 92-92 0z",transform:"matrix(1.5 0 0 -1.5 0 850)"},pencil:{width:1792,height:1792,path:"M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z",transform:"matrix(1 0 0 1 0 1)"},newplotlylogo:{name:"newplotlylogo",svg:[""," plotly-logomark"," "," "," "," "," "," "," "," "," "," "," "," "," ",""].join("")}}});var q1=X((Ufe,iM)=>{"use strict";var L1=32;iM.exports={CIRCLE_SIDES:L1,i000:0,i090:L1/4,i180:L1/2,i270:L1/4*3,cos45:Math.cos(Math.PI/4),sin45:Math.sin(Math.PI/4),SQRT2:Math.sqrt(2)}});var D1=X((Vfe,oM)=>{"use strict";var gZ=Ne().strTranslate;function lM(e,r){switch(e.type){case"log":return e.p2d(r);case"date":return e.p2r(r,0,e.calendar);default:return e.p2r(r)}}function bZ(e,r){switch(e.type){case"log":return e.d2p(r);case"date":return e.r2p(r,0,e.calendar);default:return e.r2p(r)}}function _Z(e){var r=e._id.charAt(0)==="y"?1:0;return function(t){return lM(e,t[r])}}function xZ(e){return gZ(e.xaxis._offset,e.yaxis._offset)}oM.exports={p2r:lM,r2p:bZ,axValue:_Z,getTransform:xZ}});var Bl=X(ll=>{"use strict";var wZ=jm(),fM=q1(),Mu=fM.CIRCLE_SIDES,v2=fM.SQRT2,cM=D1(),sM=cM.p2r,uM=cM.r2p,TZ=[0,3,4,5,6,1,2],MZ=[0,3,4,1,2];ll.writePaths=function(e){var r=e.length;if(!r)return"M0,0Z";for(var t="",a=0;a0&&s{"use strict";var vM=$t(),yM=Tu(),AZ=yM.drawMode,kZ=yM.openMode,Au=q1(),hM=Au.i000,dM=Au.i090,pM=Au.i180,mM=Au.i270,CZ=Au.cos45,SZ=Au.sin45,gM=D1(),P1=gM.p2r,Yl=gM.r2p,LZ=Dl(),qZ=LZ.clearOutline,R1=Bl(),DZ=R1.readPaths,EZ=R1.writePaths,PZ=R1.ellipseOver,RZ=R1.fixDatesForPaths;function zZ(e,r){if(e.length){var t=e[0][0];if(t){var a=r.gd,n=r.isActiveShape,i=r.dragmode,l=(a.layout||{}).shapes||[];if(!AZ(i)&&n!==void 0){var o=a._fullLayout._activeShapeIndex;if(o{"use strict";var NZ=Tu(),FZ=NZ.selectMode,IZ=Dl(),HZ=IZ.clearOutline,h2=Bl(),OZ=h2.readPaths,BZ=h2.writePaths,YZ=h2.fixDatesForPaths;xM.exports=function(r,t){if(r.length){var a=r[0][0];if(a){var n=a.getAttribute("d"),i=t.gd,l=i._fullLayout.newselection,o=t.plotinfo,s=o.xaxis,u=o.yaxis,f=t.isActiveSelection,v=t.dragmode,h=(i.layout||{}).selections||[];if(!FZ(v)&&f!==void 0){var d=i._fullLayout._activeSelectionIndex;if(d{"use strict";wM.exports={segmentRE:/[MLHVQCTSZ][^MLHVQCTSZ]*/g,paramRE:/[^\s,]+/g,paramIsX:{M:{0:!0,drawn:0},L:{0:!0,drawn:0},H:{0:!0,drawn:0},V:{},Q:{0:!0,2:!0,drawn:2},C:{0:!0,2:!0,4:!0,drawn:4},T:{0:!0,drawn:0},S:{0:!0,2:!0,drawn:2},Z:{}},paramIsY:{M:{1:!0,drawn:1},L:{1:!0,drawn:1},H:{},V:{0:!0,drawn:0},Q:{1:!0,3:!0,drawn:3},C:{1:!0,3:!0,5:!0,drawn:5},T:{1:!0,drawn:1},S:{1:!0,3:!0,drawn:3},Z:{}},numParams:{M:2,L:2,H:1,V:1,Q:4,C:6,T:2,S:4,Z:0}}});var Vl=X(pa=>{"use strict";var Oi=ac(),TM=Ne(),Ul=mt();pa.rangeToShapePosition=function(e){return e.type==="log"?e.r2d:function(r){return r}};pa.shapePositionToRange=function(e){return e.type==="log"?e.d2r:function(r){return r}};pa.decodeDate=function(e){return function(r){return r.replace&&(r=r.replace("_"," ")),e(r)}};pa.encodeDate=function(e){return function(r){return e(r).replace(" ","_")}};pa.extractPathCoords=function(e,r,t){var a=[],n=e.match(Oi.segmentRE);return n.forEach(function(i){var l=r[i.charAt(0)].drawn;if(l!==void 0){var o=i.slice(1).match(Oi.paramRE);if(!(!o||o.length{let o=l.charAt(0),s=n[o].drawn!==void 0;return i+(s?1:0)},0)};pa.getDataToPixel=function(e,r,t,a,n){var i=e._fullLayout._size,l;if(r)if(n==="domain")l=function(s){return r._length*(a?1-s:s)+r._offset};else{var o=pa.shapePositionToRange(r);l=function(s){var u=Hi(r,t);return r._offset+r.r2p(o(s,!0))+u},r.type==="date"&&(l=pa.decodeDate(l))}else a?l=function(s){return i.t+i.h*(1-s)}:l=function(s){return i.l+i.w*s};return l};pa.getPixelToData=function(e,r,t,a){var n=e._fullLayout._size,i;if(r)if(a==="domain")i=function(o){var s=(o-r._offset)/r._length;return t?1-s:s};else{var l=pa.rangeToShapePosition(r);i=function(o){return l(r.p2r(o-r._offset))}}else t?i=function(o){return 1-(o-n.t)/n.h}:i=function(o){return(o-n.l)/n.w};return i};pa.roundPositionForSharpStrokeRendering=function(e,r){var t=Math.round(r%2)===1,a=Math.round(e);return t?a+.5:a};pa.makeShapesOptionsAndPlotinfo=function(e,r){var t=e._fullLayout.shapes[r]||{},a=e._fullLayout._plots[t.xref+t.yref],n=!!a;return n?a._hadPlotinfo=!0:(a={},t.xref&&t.xref!=="paper"&&(a.xaxis=e._fullLayout[t.xref+"axis"]),t.yref&&t.yref!=="paper"&&(a.yaxis=e._fullLayout[t.yref+"axis"])),a.xsizemode=t.xsizemode,a.ysizemode=t.ysizemode,a.xanchor=t.xanchor,a.yanchor=t.yanchor,{options:t,plotinfo:a}};pa.makeSelectionsOptionsAndPlotinfo=function(e,r){var t=e._fullLayout.selections[r]||{},a=e._fullLayout._plots[t.xref+t.yref],n=!!a;return n?a._hadPlotinfo=!0:(a={},t.xref&&(a.xaxis=e._fullLayout[t.xref+"axis"]),t.yref&&(a.yaxis=e._fullLayout[t.yref+"axis"])),{options:t,plotinfo:a}};pa.getPathString=function(e,r){let t=r.type,a=Ul.getRefType(r.xref),n=Ul.getRefType(r.yref),i=e._fullLayout._size;var l,o,s,u,f,v,h,d,m,y,g,x;function T(P,z,F,I){var H;if(P)if(z==="domain")I?H=function(O){return P._offset+P._length*(1-O)}:H=function(O){return P._offset+P._length*O};else{let O=pa.shapePositionToRange(P);H=function(G){return P._offset+P.r2p(O(G,!0))},F==="path"&&P.type==="date"&&(H=pa.decodeDate(H))}else I?H=function(O){return i.t+i.h*(1-O)}:H=function(O){return i.l+i.w*O};return H}if(a==="array"?(h=[],l=r.xref.map(function(P){return Ul.getFromId(e,P)}),h=r.xref.map(function(P,z){return T(l[z],Ul.getRefType(P),t,!1)})):(l=Ul.getFromId(e,r.xref),h=T(l,a,t,!1)),n==="array"?(d=[],o=r.yref.map(function(P){return Ul.getFromId(e,P)}),d=r.yref.map(function(P,z){return T(o[z],Ul.getRefType(P),t,!0)})):(o=Ul.getFromId(e,r.yref),d=T(o,n,t,!0)),t==="path")return UZ(r,h,d);if(a==="array")s=Hi(l[0],r.x0shift),u=Hi(l[1],r.x1shift),m=h[0](r.x0)+s,y=h[1](r.x1)+u;else if(s=Hi(l,r.x0shift),u=Hi(l,r.x1shift),r.xsizemode==="pixel"){let P=h(r.xanchor);m=P+r.x0+s,y=P+r.x1+u}else m=h(r.x0)+s,y=h(r.x1)+u;if(n==="array")f=Hi(o[0],r.y0shift),v=Hi(o[1],r.y1shift),g=d[0](r.y0)+f,x=d[1](r.y1)+v;else if(f=Hi(o,r.y0shift),v=Hi(o,r.y1shift),r.ysizemode==="pixel"){let P=d(r.yanchor);g=P-r.y0+f,x=P-r.y1+v}else g=d(r.y0)+f,x=d(r.y1)+v;if(t==="line")return"M"+m+","+g+"L"+y+","+x;if(t==="rect")return"M"+m+","+g+"H"+y+"V"+x+"H"+m+"Z";var M=(m+y)/2,b=(g+x)/2,w=Math.abs(M-m),S=Math.abs(b-g),k="A"+w+","+S,q=M+w+","+b,D=M+","+(b-S);return"M"+q+k+" 0 1,1 "+D+k+" 0 0,1 "+q+"Z"};function UZ(e,r,t){let a=e.path,n=e.xsizemode,i=e.ysizemode,l=e.xanchor,o=e.yanchor,s=Array.isArray(e.xref),u=Array.isArray(e.yref);var f=0,v=0;return a.replace(Oi.segmentRE,function(h){var d=0,m=h.charAt(0),y=Oi.paramIsX[m],g=Oi.paramIsY[m],x=Oi.numParams[m];let T=y.drawn!==void 0,M=g.drawn!==void 0,b=s?r[f]:r,w=u?t[v]:t;var S=h.slice(1).replace(Oi.paramRE,function(k){return y[d]?n==="pixel"?k=b(l)+Number(k):k=b(k):g[d]&&(i==="pixel"?k=w(o)-Number(k):k=w(k)),d++,d>x&&(k="X"),k});return d>x&&(S=S.replace(/[\s,]*X.*/,""),TM.log("Ignoring extra params in segment "+h)),T&&f++,M&&v++,m+S})}pa.getPixelShift=Hi;function Hi(e,r){r=r||0;var t=0;return r&&e&&(e.type==="category"||e.type==="multicategory")&&(t=(e.r2p(1)-e.r2p(0))*r),t}});var p2=X((Kfe,kM)=>{"use strict";var VZ=Ne(),Bi=mt(),MM=da(),AM=at(),GZ=Bl().readPaths,Zo=Vl(),WZ=Zo.getPathString,nc=nv(),XZ=La().FROM_TL;kM.exports=function(r,t,a,n){if(n.selectAll(".shape-label").remove(),!!(a.label.text||a.label.texttemplate)){var i;if(a.label.texttemplate){var l={};if(a.type!=="path"){var o=Bi.getFromId(r,a.xref),s=Bi.getFromId(r,a.yref);let Z=Array.isArray(a.xref),re=Array.isArray(a.yref);for(var u in nc){var f=typeof nc[u]=="function",v=!Z||nc.simpleXVariables.includes(u),h=!re||nc.simpleYVariables.includes(u);if(f&&v&&h){var d=nc[u](a,o,s);d!==void 0&&(l[u]=d)}}}i=VZ.texttemplateStringForShapes({data:[l],fallback:a.label.texttemplatefallback,locale:r._fullLayout._d3locale,template:a.label.texttemplate})}else i=a.label.text;var m={"data-index":t},y=a.label.font,g={"data-notex":1},x=n.append("g").attr(m).classed("shape-label",!0),T=x.append("text").attr(g).classed("shape-label-text",!0).text(i),M,b,w,S;if(a.path){var k=WZ(r,a),q=GZ(k,r);M=1/0,w=1/0,b=-1/0,S=-1/0;for(var D=0;DZo.getDataToPixel(r,Pe,Te,!1,Le)(Re),Me=(Re,Te,Pe,Le)=>Zo.getDataToPixel(r,Pe,Te,!0,Le)(Re);if(a.xsizemode==="pixel"){let Re=ce(a.xanchor,void 0,se,we),Te=Zo.getPixelShift(se,a.x0shift),Pe=Zo.getPixelShift(se,a.x1shift);M=Re+a.x0+Te,b=Re+a.x1+Pe}else M=ce(a.x0,a.x0shift,se,we),b=ce(a.x1,a.x1shift,ve,fe);if(a.ysizemode==="pixel"){let Re=Me(a.yanchor,void 0,xe,ge),Te=Zo.getPixelShift(xe,a.y0shift),Pe=Zo.getPixelShift(xe,a.y1shift);w=Re-a.y0+Te,S=Re-a.y1+Pe}else w=Me(a.y0,a.y0shift,xe,ge),S=Me(a.y1,a.y1shift,Q,_e)}var O=a.label.textangle;O==="auto"&&(a.type==="line"?O=ZZ(M,w,b,S):O=0),T.call(function(Z){return Z.call(AM.font,y).attr({}),MM.convertToTspans(Z,r),Z});var G=AM.bBox(T.node()),Y=JZ(M,w,b,S,a,O,G),B=Y.textx,V=Y.texty,J=Y.xanchor;T.attr({"text-anchor":{left:"start",center:"middle",right:"end"}[J],y:V,x:B,transform:"rotate("+O+","+B+","+V+")"}).call(MM.positionText,B,V)}};function ZZ(e,r,t,a){var n,i;return i=Math.abs(t-e),t>=e?n=r-a:n=a-r,-180/Math.PI*Math.atan2(n,i)}function JZ(e,r,t,a,n,i,l){var o=n.label.textposition,s=n.label.textangle,u=n.label.padding,f=n.type,v=Math.PI/180*i,h=Math.sin(v),d=Math.cos(v),m=n.label.xanchor,y=n.label.yanchor,g,x,T,M;if(f==="line"){o==="start"?(g=e,x=r):o==="end"?(g=t,x=a):(g=(e+t)/2,x=(r+a)/2),m==="auto"&&(o==="start"?s==="auto"?t>e?m="left":te?m="right":te?m="right":te?m="left":t{"use strict";var KZ=Ne(),$Z=KZ.strTranslate,CM=fi(),qM=Tu(),QZ=qM.drawMode,DM=qM.selectMode,EM=_r(),SM=Er(),F1=q1(),jZ=F1.i000,eJ=F1.i090,rJ=F1.i180,tJ=F1.i270,aJ=Dl(),PM=aJ.clearOutlineControllers,y2=Bl(),N1=y2.pointsOnRectangle,m2=y2.pointsOnEllipse,nJ=y2.writePaths,iJ=z1().newShapes,lJ=z1().createShapeObj,oJ=d2(),sJ=p2();RM.exports=function e(r,t,a,n){n||(n=0);var i=a.gd;function l(){e(r,t,a,n++),(m2(r[0])||a.hasText)&&o({redrawing:!0})}function o(Y){var B={};a.isActiveShape!==void 0&&(a.isActiveShape=!1,B=iJ(t,a)),a.isActiveSelection!==void 0&&(a.isActiveSelection=!1,B=oJ(t,a),i._fullLayout._reselect=!0),Object.keys(B).length&&EM.call((Y||{}).redrawing?"relayout":"_guiRelayout",i,B)}var s=i._fullLayout,u=s._zoomlayer,f=a.dragmode,v=QZ(f),h=DM(f);(v||h)&&(i._fullLayout._outlining=!0),PM(i),t.attr("d",nJ(r));var d,m,y,g,x;if(!n&&(a.isActiveShape||a.isActiveSelection)){x=uJ([],r);var T=u.append("g").attr("class","outline-controllers");P(T),G()}if(v&&a.hasText){var M=u.select(".label-temp"),b=lJ(t,a,a.dragmode);sJ(i,"label-temp",b,M)}function w(Y){y=+Y.srcElement.getAttribute("data-i"),g=+Y.srcElement.getAttribute("data-j"),d[y][g].moveFn=S}function S(Y,B){if(r.length){var V=x[y][g][1],J=x[y][g][2],Z=r[y],re=Z.length;if(N1(Z)){var se=Y,ve=B;if(a.isActiveSelection){var xe=LM(Z,g);xe[1]===Z[g][1]?ve=0:se=0}for(var Q=0;Q1&&!(Y.length===2&&Y[1][0]==="Z")&&(g===0&&(Y[0][0]="M"),r[y]=Y,l(),o())}}function D(Y,B){if(Y===2){y=+B.srcElement.getAttribute("data-i"),g=+B.srcElement.getAttribute("data-j");var V=r[y];!N1(V)&&!m2(V)&&q()}}function P(Y){d=[];for(var B=0;B{"use strict";var cJ=Nr(),OM=_r(),b2=Ne(),Jo=mt(),vJ=Bl().readPaths,hJ=I1(),O1=p2(),BM=Dl().clearOutlineControllers,g2=Er(),B1=at(),dJ=vt().arrayEditor,zM=fi(),NM=vi(),FM=Hl(),Ko=ac(),sn=Vl(),_2=sn.getPathString;VM.exports={draw:x2,drawOne:YM,eraseActiveShape:bJ,drawLabel:O1};function x2(e){var r=e._fullLayout;r._shapeUpperLayer.selectAll("path").remove(),r._shapeLowerLayer.selectAll("path").remove(),r._shapeUpperLayer.selectAll("text").remove(),r._shapeLowerLayer.selectAll("text").remove();for(var t in r._plots){var a=r._plots[t].shapelayer;a&&(a.selectAll("path").remove(),a.selectAll("text").remove())}for(var n=0;n{if(!e._dragging&&e._fullLayout.hoveranywhere){let l=n(i);l&&NM.hover(e,l,t.id)}}),a.addEventListener("click",i=>{if(!e._dragged&&e._fullLayout.clickanywhere){let l=n(i);l&&NM.click(e,l,t.id)}})}function UM(e,r,t){let a=t.xref,n=t.yref;if(Array.isArray(a)||Array.isArray(n)){let i="clip"+r._fullLayout._uid+"shape"+t._index,l=mJ(r,a,n);b2.ensureSingleById(r._fullLayout._clips,"clipPath",i,function(o){o.append("rect")}).select("rect").attr(l),B1.setClipUrl(e,i,r)}else{let i=(a+n).replace(/paper/g,"").replace(/[xyz][0-9]* *domain/g,"");B1.setClipUrl(e,i?"clip"+r._fullLayout._uid+i:null,r)}}function mJ(e,r,t){let a=e._fullLayout._size;function n(o,s){let u=(Array.isArray(o)?o:[o]).map(h=>Jo.getFromId(e,h)).filter(Boolean);if(!u.length)return s?[a.t,a.t+a.h]:[a.l,a.l+a.w];let f=u.map(function(h){return h._offset}),v=u.map(function(h){return h._offset+h._length});return[Math.min(...f),Math.max(...v)]}let i=n(r,!1),l=n(t,!0);return{x:i[0],y:l[0],width:i[1]-i[0],height:l[1]-l[0]}}function yJ(e,r,t,a,n,i){var l=10,o=10,s=t.xsizemode==="pixel",u=t.ysizemode==="pixel",f=t.type==="line",v=t.type==="path",h=i.modifyItem,d,m,y,g,x,T,M,b,w,S,k,q,D,P,z,F=cJ.select(r.node().parentNode),I=Jo.getFromId(e,t.xref),H=Jo.getRefType(t.xref),O=Jo.getFromId(e,t.yref),G=Jo.getRefType(t.yref),Y=t.x0shift,B=t.x1shift,V=t.y0shift,J=t.y1shift,Z=function(Oe,qe){var Je=sn.getDataToPixel(e,I,qe,!1,H);return Je(Oe)},re=function(Oe,qe){var Je=sn.getDataToPixel(e,O,qe,!0,G);return Je(Oe)},se=sn.getPixelToData(e,I,!1,H),ve=sn.getPixelToData(e,O,!0,G),xe=fe(),Q={element:xe.node(),gd:e,prepFn:ce,doneFn:Me,clickFn:Re},we;zM.init(Q),xe.node().onmousemove=_e;function fe(){return f?ge():r}function ge(){var Oe=10,qe=Math.max(t.line.width,Oe),Je=n.append("g").attr("data-index",a).attr("drag-helper",!0);Je.append("path").attr("d",r.attr("d")).style({cursor:"move","stroke-width":qe,"stroke-opacity":"0"});var Ke={"fill-opacity":"0"},We=Math.max(qe/2,Oe);return Je.append("circle").attr({"data-line-point":"start-point",cx:s?Z(t.xanchor)+t.x0:Z(t.x0,Y),cy:u?re(t.yanchor)-t.y0:re(t.y0,V),r:We}).style(Ke).classed("cursor-grab",!0),Je.append("circle").attr({"data-line-point":"end-point",cx:s?Z(t.xanchor)+t.x1:Z(t.x1,B),cy:u?re(t.yanchor)-t.y1:re(t.y1,J),r:We}).style(Ke).classed("cursor-grab",!0),Je}function _e(Oe){if(H1(e)){we=null;return}if(f)Oe.target.tagName==="path"?we="move":we=Oe.target.attributes["data-line-point"].value==="start-point"?"resize-over-start-point":"resize-over-end-point";else{var qe=Q.element.getBoundingClientRect(),Je=qe.right-qe.left,Ke=qe.bottom-qe.top,We=Oe.clientX-qe.left,ke=Oe.clientY-qe.top,ze=!v&&Je>l&&Ke>o&&!Oe.shiftKey?zM.getCursor(We/Je,1-ke/Ke):"move";FM(r,ze),we=ze.split("-")[0]}}function ce(Oe){H1(e)||(s&&(x=Z(t.xanchor)),u&&(T=re(t.yanchor)),t.type==="path"?z=t.path:(d=s?t.x0:Z(t.x0),m=u?t.y0:re(t.y0),y=s?t.x1:Z(t.x1),g=u?t.y1:re(t.y1)),dg?(M=m,k="y0",b=g,q="y1"):(M=g,k="y1",b=m,q="y0"),_e(Oe),Le(n,t),Ie(r,t,e),Q.moveFn=we==="move"?Te:Pe,Q.altKey=Oe.altKey)}function Me(){H1(e)||(FM(r),Ce(n),UM(r,e,t),OM.call("_guiRelayout",e,i.getUpdateObj()))}function Re(){H1(e)||Ce(n)}function Te(Oe,qe){if(t.type==="path"){var Je=function(ke){return ke},Ke=Je,We=Je;s?h("xanchor",t.xanchor=se(x+Oe)):(Ke=function(ze){return se(Z(ze)+Oe)},I&&I.type==="date"&&(Ke=sn.encodeDate(Ke))),u?h("yanchor",t.yanchor=ve(T+qe)):(We=function(ze){return ve(re(ze)+qe)},O&&O.type==="date"&&(We=sn.encodeDate(We))),h("path",t.path=IM(z,Ke,We))}else s?h("xanchor",t.xanchor=se(x+Oe)):(h("x0",t.x0=se(d+Oe)),h("x1",t.x1=se(y+Oe))),u?h("yanchor",t.yanchor=ve(T+qe)):(h("y0",t.y0=ve(m+qe)),h("y1",t.y1=ve(g+qe)));r.attr("d",_2(e,t)),Le(n,t),O1(e,a,t,F)}function Pe(Oe,qe){if(v){var Je=function(Ve){return Ve},Ke=Je,We=Je;s?h("xanchor",t.xanchor=se(x+Oe)):(Ke=function(ir){return se(Z(ir)+Oe)},I&&I.type==="date"&&(Ke=sn.encodeDate(Ke))),u?h("yanchor",t.yanchor=ve(T+qe)):(We=function(ir){return ve(re(ir)+qe)},O&&O.type==="date"&&(We=sn.encodeDate(We))),h("path",t.path=IM(z,Ke,We))}else if(f){if(we==="resize-over-start-point"){var ke=d+Oe,ze=u?m-qe:m+qe;h("x0",t.x0=s?ke:se(ke)),h("y0",t.y0=u?ze:ve(ze))}else if(we==="resize-over-end-point"){var He=y+Oe,te=u?g-qe:g+qe;h("x1",t.x1=s?He:se(He)),h("y1",t.y1=u?te:ve(te))}}else{var he=function(Ve){return we.indexOf(Ve)!==-1},Ze=he("n"),hr=he("s"),De=he("w"),me=he("e"),be=Ze?M+qe:M,Fe=hr?b+qe:b,Ge=De?w+Oe:w,Ue=me?S+Oe:S;u&&(Ze&&(be=M-qe),hr&&(Fe=b-qe)),(!u&&Fe-be>o||u&&be-Fe>o)&&(h(k,t[k]=u?be:ve(be)),h(q,t[q]=u?Fe:ve(Fe))),Ue-Ge>l&&(h(D,t[D]=s?Ge:se(Ge)),h(P,t[P]=s?Ue:se(Ue)))}r.attr("d",_2(e,t)),Le(n,t),O1(e,a,t,F)}function Le(Oe,qe){(s||u)&&Je();function Je(){var Ke=qe.type!=="path",We=Oe.selectAll(".visual-cue").data([0]),ke=1;We.enter().append("path").attr({fill:"#fff","fill-rule":"evenodd",stroke:"#000","stroke-width":ke}).classed("visual-cue",!0);var ze=Z(s?qe.xanchor:b2.midRange(Ke?[qe.x0,qe.x1]:sn.extractPathCoords(qe.path,Ko.paramIsX))),He=re(u?qe.yanchor:b2.midRange(Ke?[qe.y0,qe.y1]:sn.extractPathCoords(qe.path,Ko.paramIsY)));if(ze=sn.roundPositionForSharpStrokeRendering(ze,ke),He=sn.roundPositionForSharpStrokeRendering(He,ke),s&&u){var te="M"+(ze-1-ke)+","+(He-1-ke)+"h-8v2h8 v8h2v-8 h8v-2h-8 v-8h-2 Z";We.attr("d",te)}else if(s){var he="M"+(ze-1-ke)+","+(He-9-ke)+"v18 h2 v-18 Z";We.attr("d",he)}else{var Ze="M"+(ze-9-ke)+","+(He-1-ke)+"h18 v2 h-18 Z";We.attr("d",Ze)}}}function Ce(Oe){Oe.selectAll(".visual-cue").remove()}function Ie(Oe,qe,Je){var Ke=qe.xref,We=qe.yref,ke=Jo.getFromId(Je,Ke),ze=Jo.getFromId(Je,We),He="";Ke!=="paper"&&!ke.autorange&&(He+=Ke),We!=="paper"&&!ze.autorange&&(He+=We),B1.setClipUrl(Oe,He?"clip"+Je._fullLayout._uid+He:null,Je)}}function IM(e,r,t){return e.replace(Ko.segmentRE,function(a){var n=0,i=a.charAt(0),l=Ko.paramIsX[i],o=Ko.paramIsY[i],s=Ko.numParams[i],u=a.slice(1).replace(Ko.paramRE,function(f){return n>=s||(l[n]?f=r(f):o[n]&&(f=t(f)),n++),f});return i+u})}function gJ(e,r){if(Y1(e)){var t=r.node(),a=+t.getAttribute("data-index");if(a>=0){if(a===e._fullLayout._activeShapeIndex){HM(e);return}e._fullLayout._activeShapeIndex=a,e._fullLayout._deactivateShape=HM,x2(e)}}}function HM(e){if(Y1(e)){var r=e._fullLayout._activeShapeIndex;r>=0&&(BM(e),delete e._fullLayout._activeShapeIndex,x2(e))}}function bJ(e){if(Y1(e)){BM(e);var r=e._fullLayout._activeShapeIndex,t=(e.layout||{}).shapes||[];if(r{"use strict";var GM=Nr(),V1=Ne()._;WM.exports=function(r,t,a){var n=GM.select(r._fullLayout._paperdiv.node());n.selectAll(".plotly-cloud-dialog").remove();var i=n.append("div").classed("plotly-cloud-dialog",!0),l=i.append("div").classed("plotly-cloud-dialog-box",!0);l.append("div").classed("plotly-cloud-dialog-title",!0).text(V1(r,"Share with Plotly Cloud"));var o=new URL(t).hostname,s=l.append("div");s.classed("plotly-cloud-dialog-message",!0),s.append("span").text(V1(r,"This chart and its data will be sent to ")),s.append("span").text(o).classed("plotly-cloud-dialog-message--hostname",!0),s.append("span").text(". ");var u=l.append("div").classed("plotly-cloud-dialog-buttons",!0);function f(){i.remove(),document.removeEventListener("keydown",v)}function v(h){(h.key==="Escape"||h.keyCode===27)&&f()}document.addEventListener("keydown",v),i.on("click",function(){GM.event.target===i.node()&&f()}),u.append("button").classed("plotly-cloud-dialog-btn",!0).classed("plotly-cloud-dialog-btn--cancel",!0).text(V1(r,"Cancel")).on("click",f),u.append("button").classed("plotly-cloud-dialog-btn",!0).classed("plotly-cloud-dialog-btn--confirm",!0).text(V1(r,"Share")).on("click",function(){f(),a()})}});var M2=X((ece,eA)=>{"use strict";var Nn=_r(),_J=Ut(),JM=$t(),$r=S1(),xJ=U1().eraseActiveShape,wJ=XM(),G1=Ne(),Ur=G1._,Qr=eA.exports={};Qr.toImage={name:"toImage",title:function(e){var r=e._context.toImageButtonOptions||{},t=r.format||"png";return t==="png"?Ur(e,"Download plot as a PNG"):Ur(e,"Download plot")},icon:$r.camera,click:function(e){var r=e._context.toImageButtonOptions,t={format:r.format||"png"};G1.notifier(Ur(e,"Preparing image - this may take a few seconds"),"long",e),["filename","width","height","scale"].forEach(function(a){a in r&&(t[a]=r[a])}),Nn.call("downloadImage",e,t).then(function(a){G1.notifier(Ur(e,"Image download succeeded")+" - "+a,"long",e)}).catch(function(){G1.notifier(Ur(e,"Sorry, there was a problem downloading your image!"),"long",e)})}};Qr.sendChartToCloud={name:"sendChartToCloud",title:function(e){return Ur(e,"Share Chart")},icon:$r.cloudupload,click:function(e){var r=(window.PLOTLYENV||{}).BASE_URL||e._context.plotlyServerURL;if(!r){console.error("No destination URL provided (plotlyServerURL is not set)");return}try{new URL(r)}catch(t){console.error("Invalid plotlyServerURL: "+r);return}wJ(e,r,function(){_J.sendDataToCloud(e,r)})}};Qr.zoom2d={name:"zoom2d",_cat:"zoom",title:function(e){return Ur(e,"Zoom")},attr:"dragmode",val:"zoom",icon:$r.zoombox,click:Ba};Qr.pan2d={name:"pan2d",_cat:"pan",title:function(e){return Ur(e,"Pan")},attr:"dragmode",val:"pan",icon:$r.pan,click:Ba};Qr.select2d={name:"select2d",_cat:"select",title:function(e){return Ur(e,"Box Select")},attr:"dragmode",val:"select",icon:$r.selectbox,click:Ba};Qr.lasso2d={name:"lasso2d",_cat:"lasso",title:function(e){return Ur(e,"Lasso Select")},attr:"dragmode",val:"lasso",icon:$r.lasso,click:Ba};Qr.drawclosedpath={name:"drawclosedpath",title:function(e){return Ur(e,"Draw closed freeform")},attr:"dragmode",val:"drawclosedpath",icon:$r.drawclosedpath,click:Ba};Qr.drawopenpath={name:"drawopenpath",title:function(e){return Ur(e,"Draw open freeform")},attr:"dragmode",val:"drawopenpath",icon:$r.drawopenpath,click:Ba};Qr.drawline={name:"drawline",title:function(e){return Ur(e,"Draw line")},attr:"dragmode",val:"drawline",icon:$r.drawline,click:Ba};Qr.drawrect={name:"drawrect",title:function(e){return Ur(e,"Draw rectangle")},attr:"dragmode",val:"drawrect",icon:$r.drawrect,click:Ba};Qr.drawcircle={name:"drawcircle",title:function(e){return Ur(e,"Draw circle")},attr:"dragmode",val:"drawcircle",icon:$r.drawcircle,click:Ba};Qr.eraseshape={name:"eraseshape",title:function(e){return Ur(e,"Erase active shape")},icon:$r.eraseshape,click:xJ};Qr.zoomIn2d={name:"zoomIn2d",_cat:"zoomin",title:function(e){return Ur(e,"Zoom in")},attr:"zoom",val:"in",icon:$r.zoom_plus,click:Ba};Qr.zoomOut2d={name:"zoomOut2d",_cat:"zoomout",title:function(e){return Ur(e,"Zoom out")},attr:"zoom",val:"out",icon:$r.zoom_minus,click:Ba};Qr.autoScale2d={name:"autoScale2d",_cat:"autoscale",title:function(e){return Ur(e,"Autoscale")},attr:"zoom",val:"auto",icon:$r.autoscale,click:Ba};Qr.resetScale2d={name:"resetScale2d",_cat:"resetscale",title:function(e){return Ur(e,"Reset axes")},attr:"zoom",val:"reset",icon:$r.home,click:Ba};Qr.hoverClosestCartesian={name:"hoverClosestCartesian",_cat:"hoverclosest",title:function(e){return Ur(e,"Show closest data on hover")},attr:"hovermode",val:"closest",icon:$r.tooltip_basic,gravity:"ne",click:Ba};Qr.hoverCompareCartesian={name:"hoverCompareCartesian",_cat:"hoverCompare",title:function(e){return Ur(e,"Compare data on hover")},attr:"hovermode",val:function(e){return e._fullLayout._isHoriz?"y":"x"},icon:$r.tooltip_compare,gravity:"ne",click:Ba};function Ba(e,r){var t=r.currentTarget,a=t.getAttribute("data-attr"),n=t.getAttribute("data-val")||!0,i=e._fullLayout,l={},o=JM.list(e,null,!0),s=i._cartesianSpikesEnabled,u,f;if(a==="zoom"){var v=n==="in"?.5:2,h=(1+v)/2,d=(1-v)/2,m,y;for(f=0;f-S),[y,g]=v.invert(f.midPt),{minscale:x}=u.projection,T=(o=u.projection.maxscale)!=null?o:1/0,M=Math.min(x,T),b=Math.max(x,T),w=n==="in"?ZM*h:1/ZM*h;w>b?w=b:w{"use strict";var rA=M2(),kJ=Object.keys(rA),tA=["drawline","drawopenpath","drawclosedpath","drawcircle","drawrect","eraseshape"],aA=["v1hovermode","hoverclosest","hovercompare","togglehover","togglespikelines"].concat(tA),ku=[],CJ=function(e){if(aA.indexOf(e._cat||e.name)===-1){var r=e.name,t=(e._cat||e.name).toLowerCase();ku.indexOf(r)===-1&&ku.push(r),ku.indexOf(t)===-1&&ku.push(t)}};kJ.forEach(function(e){CJ(rA[e])});ku.sort();nA.exports={DRAW_MODES:tA,backButtons:aA,foreButtons:ku}});var C2={};qs(C2,{default:()=>LJ});var k2,SJ,LJ,S2=Ln(()=>{"use strict";k2=_p(A2()),SJ={editType:"modebar",orientation:{valType:"enumerated",values:["v","h"],dflt:"h",editType:"modebar",description:"Sets the orientation of the modebar."},bgcolor:{valType:"color",editType:"modebar",description:"Sets the background color of the modebar."},color:{valType:"color",editType:"modebar",description:"Sets the color of the icons in the modebar."},activecolor:{valType:"color",editType:"modebar",description:"Sets the color of the active or hovered on icons in the modebar."},uirevision:{valType:"any",editType:"none",description:["Controls persistence of user-driven changes related to the modebar,","including `hovermode`, `dragmode`, and `showspikes` at both the","root level and inside subplots. Defaults to `layout.uirevision`."].join(" ")},add:{valType:"string",arrayOk:!0,dflt:"",editType:"modebar",description:["Determines which predefined modebar buttons to add.","Please note that these buttons will only be shown if they are","compatible with all trace types used in a graph.","Similar to `config.modeBarButtonsToAdd` option.",`This may include *${k2.default.backButtons.join("*, *")}*.`].join(" ")},remove:{valType:"string",arrayOk:!0,dflt:"",editType:"modebar",description:["Determines which predefined modebar buttons to remove.","Similar to `config.modeBarButtonsToRemove` option.",`This may include *${k2.default.foreButtons.join("*, *")}*.`].join(" ")}},LJ=SJ});var lA=X((tce,iA)=>{"use strict";var qJ=Ne(),ic=Er(),DJ=vt(),EJ=(S2(),Ia(C2)).default;iA.exports=function(r,t){var a=r.modebar||{},n=DJ.newContainer(t,"modebar");function i(o,s){return qJ.coerce(a,n,EJ,o,s)}i("orientation"),i("bgcolor",ic.addOpacity(t.paper_bgcolor,.5));var l=ic.contrast(ic.rgb(t.modebar.bgcolor));i("color",ic.addOpacity(l,.3)),i("activecolor",ic.addOpacity(l,.7)),i("uirevision",t.uirevision),i("add"),i("remove")}});var fA=X((ace,uA)=>{"use strict";var L2=Nr(),PJ=Br(),Z1=Ne(),oA=S1(),RJ=df().version,zJ=new DOMParser;function sA(e){this.container=e.container,this.element=document.createElement("div"),this.update(e.graphInfo,e.buttons),this.container.appendChild(this.element)}var Yi=sA.prototype;Yi.update=function(e,r){this.graphInfo=e;var t=this.graphInfo._context,a=this.graphInfo._fullLayout,n="modebar-"+a._uid;this.element.setAttribute("id",n),this.element.setAttribute("role","toolbar"),this._uid=n,this.element.className="modebar modebar--custom",t.displayModeBar==="hover"&&(this.element.className+=" modebar--hover ease-bg"),a.modebar.orientation==="v"&&(this.element.className+=" vertical",r=r.reverse());var i=a.modebar,l="#"+n+" .modebar-group";document.querySelectorAll(l).forEach(function(v){v.style.backgroundColor=i.bgcolor});var o=!this.hasButtons(r),s=this.hasLogo!==t.displaylogo,u=this.locale!==t.locale;if(this.locale=t.locale,(o||s||u)&&(this.removeAllButtons(),this.updateButtons(r),t.watermark||t.displaylogo)){var f=this.getLogo();t.watermark&&(f.className=f.className+" watermark"),a.modebar.orientation==="v"?this.element.insertBefore(f,this.element.childNodes[0]):this.element.appendChild(f),this.hasLogo=!0}this.updateActiveButton(),Z1.setStyleOnHover("#"+n+" .modebar-btn",".active",".icon path","fill: "+i.activecolor,"fill: "+i.color,this.element)};Yi.updateButtons=function(e){var r=this;this.buttons=e,this.buttonElements=[],this.buttonsNames=[],this.buttons.forEach(function(t){var a=r.createGroup();t.forEach(function(n){var i=n.name;if(!i)throw new Error("must provide button 'name' in button config");if(r.buttonsNames.indexOf(i)!==-1)throw new Error("button name '"+i+"' is taken");r.buttonsNames.push(i);var l=r.createButton(n);r.buttonElements.push(l),a.appendChild(l)}),r.element.appendChild(a)})};Yi.createGroup=function(){var e=document.createElement("div");e.className="modebar-group";var r=this.graphInfo._fullLayout.modebar;return e.style.backgroundColor=r.bgcolor,e};Yi.createButton=function(e){var r=this,t=document.createElement("button");t.setAttribute("type","button"),t.setAttribute("rel","tooltip"),t.className="modebar-btn";var a=e.title;a===void 0?a=e.name:typeof a=="function"&&(a=a(this.graphInfo)),(a||a===0)&&(t.setAttribute("data-title",a),t.setAttribute("aria-label",a)),e.attr!==void 0&&t.setAttribute("data-attr",e.attr);var n=e.val;n!==void 0&&(typeof n=="function"&&(n=n(this.graphInfo)),t.setAttribute("data-val",n));var i=e.click;if(typeof i!="function")throw new Error("must provide button 'click' function in button config");t.addEventListener("click",function(o){e.click(r.graphInfo,o),r.updateActiveButton(o.currentTarget)}),t.setAttribute("data-toggle",e.toggle||!1),e.toggle&&L2.select(t).classed("active",!0);var l=e.icon;return typeof l=="function"?t.appendChild(l()):t.appendChild(this.createIcon(l||oA.question)),t.setAttribute("data-gravity",e.gravity||"n"),t};Yi.createIcon=function(e){var r=PJ(e.height)?Number(e.height):e.ascent-e.descent,t="http://www.w3.org/2000/svg",a;if(e.path){a=document.createElementNS(t,"svg"),a.setAttribute("viewBox",[0,0,e.width,r].join(" ")),a.setAttribute("class","icon");var n=document.createElementNS(t,"path");n.setAttribute("d",e.path),e.transform?n.setAttribute("transform",e.transform):e.ascent!==void 0&&n.setAttribute("transform","matrix(1 0 0 -1 0 "+e.ascent+")"),a.appendChild(n)}if(e.svg){var i=zJ.parseFromString(e.svg,"application/xml");a=i.childNodes[0]}return a.setAttribute("height","1em"),a.setAttribute("width","1em"),a};Yi.updateActiveButton=function(e){var r=this.graphInfo._fullLayout,t=e!==void 0?e.getAttribute("data-attr"):null;this.buttonElements.forEach(function(a){var n=a.getAttribute("data-val")||!0,i=a.getAttribute("data-attr"),l=a.getAttribute("data-toggle")==="true",o=L2.select(a),s=function(v,h){var d=r.modebar,m=v.querySelector(".icon path");m&&(h||v.matches(":hover")?m.style.fill=d.activecolor:m.style.fill=d.color)};if(l){if(i===t){var u=!o.classed("active");o.classed("active",u),s(a,u)}}else{var f=i===null?i:Z1.nestedProperty(r,i).get();o.classed("active",f===n),s(a,f===n)}})};Yi.hasButtons=function(e){var r=this.buttons;if(!r||e.length!==r.length)return!1;for(var t=0;t{"use strict";var IJ=$t(),cA=mn(),q2=_r(),HJ=Di().isUnifiedHover,OJ=fA(),J1=M2(),BJ=A2().DRAW_MODES,YJ=Ne().extendDeep;vA.exports=function(r){var t=r._fullLayout,a=r._context,n=t._modeBar;if(!a.displayModeBar&&!a.watermark){n&&(n.destroy(),delete t._modeBar);return}if(!Array.isArray(a.modeBarButtonsToRemove))throw new Error(["*modeBarButtonsToRemove* configuration options","must be an array."].join(" "));if(!Array.isArray(a.modeBarButtonsToAdd))throw new Error(["*modeBarButtonsToAdd* configuration options","must be an array."].join(" "));var i=a.modeBarButtons,l;Array.isArray(i)&&i.length?l=ZJ(i):!a.displayModeBar&&a.watermark?l=[]:l=UJ(r),n?n.update(r,l):t._modeBar=OJ(r,l)};function UJ(e){var r=e._fullLayout,t=e._fullData,a=e._context;function n(Y,B){if(typeof B=="string"){if(B.toLowerCase()===Y.toLowerCase())return!0}else{var V=B.name,J=B._cat||B.name;if(V===Y||J===Y.toLowerCase())return!0}return!1}var i=r.modebar.add;typeof i=="string"&&(i=[i]);var l=r.modebar.remove;typeof l=="string"&&(l=[l]);var o=a.modeBarButtonsToAdd.concat(i.filter(function(Y){for(var B=0;B1?(D=["toggleHover"],P=["resetViews"]):v?(q=["zoomInGeo","zoomOutGeo"],D=["hoverClosestGeo"],P=["resetGeo"]):f?(D=["hoverClosest3d"],P=["resetCameraDefault3d","resetCameraLastSave3d"]):y?(q=["zoomInMap","zoomOutMap"],D=["toggleHover"],P=["resetViewMap"]):h?D=["hoverClosestPie"]:T?(D=["hoverClosestCartesian","hoverCompareCartesian"],P=["resetViewSankey"]):D=["toggleHover"],u&&D.push("toggleSpikelines","hoverClosestCartesian","hoverCompareCartesian"),(WJ(t)||b)&&(D=[]),u&&!M&&(q=["zoomIn2d","zoomOut2d","autoScale2d"],P[0]!=="resetViews"&&(P=["resetScale2d"])),f?z=["zoom3d","pan3d","orbitRotation","tableRotation"]:u&&!M||m?z=["zoom2d","pan2d"]:y||v?z=["pan2d"]:g&&(z=["zoom2d"]),GJ(t)&&z.push("select2d","lasso2d");var F=[],I=function(Y){F.indexOf(Y)===-1&&D.indexOf(Y)!==-1&&F.push(Y)};if(Array.isArray(o)){for(var H=[],O=0;O{"use strict";dA.exports={moduleType:"component",name:"modebar",layoutAttributes:(S2(),Ia(C2)).default,supplyLayoutDefaults:lA(),manage:hA()}});var E2=X((lce,pA)=>{"use strict";var JJ=La().FROM_BL;pA.exports=function(r,t,a){a===void 0&&(a=JJ[r.constraintoward||"center"]);var n=[r.r2l(r.range[0]),r.r2l(r.range[1])],i=n[0]+(n[1]-n[0])*a;r.range=r._input.range=[r.l2r(i+(n[0]-i)*t),r.l2r(i+(n[1]-i)*t)],r.setScale()}});var oc=X(lc=>{"use strict";var $o=Ne(),P2=Uf(),hi=$t().id2name,KJ=oi(),mA=E2(),$J=Qv(),QJ=Zt().ALMOST_EQUAL,jJ=La().FROM_BL;lc.handleDefaults=function(e,r,t){var a=t.axIds,n=t.axHasImage,i=r._axisConstraintGroups=[],l=r._axisMatchGroups=[],o,s,u,f,v,h,d,m;for(o=0;oi?t.slice(i):a.slice(n))+l}function rK(e,r){for(var t=r._size,a=t.h/t.w,n={},i=Object.keys(e),l=0;lQJ*m&&!T)){for(i=0;iF&&JP&&(P=J);var re=(P-D)/(2*z);v/=re,D=s.l2r(D),P=s.l2r(P),s.range=s._input.range=S{"use strict";var $1=Nr(),Ya=_r(),xn=Ut(),Fn=Ne(),N2=da(),F2=C1(),sc=Er(),Cu=at(),_A=Bf(),AA=D2(),uc=mt(),ol=La(),kA=oc(),tK=kA.enforce,aK=kA.clean,xA=Uf().doAutoRange,CA="start",nK="middle",SA="end",iK=ta().zindexSeparator;na.layoutStyles=function(e){return Fn.syncOrAsync([xn.doAutoMargin,oK],e)};function lK(e,r,t){for(var a=0;a=e[1]||n[1]<=e[0])&&i[0]r[0])return!0}return!1}function oK(e){var r=e._fullLayout,t=r._size,a=t.p,n=uc.list(e,"",!0),i,l,o,s,u,f;if(r._paperdiv.style({width:e._context.responsive&&r.autosize&&!e._context._hasZeroWidth&&!e.layout.width?"100%":r.width+"px",height:e._context.responsive&&r.autosize&&!e._context._hasZeroHeight&&!e.layout.height?"100%":r.height+"px"}).selectAll(".main-svg").call(Cu.setSize,r.width,r.height),e._context.setBackground(e,r.paper_bgcolor),na.drawMainTitle(e),AA.manage(e),!r._has("cartesian"))return xn.previousPromises(e);function v(ce,Me,Re){var Te=ce._lw/2;if(ce._id.charAt(0)==="x"){if(Me){if(Re==="top")return Me._offset-a-Te}else return t.t+t.h*(1-(ce.position||0))+Te%1;return Me._offset+Me._length+a+Te}if(Me){if(Re==="right")return Me._offset+Me._length+a+Te}else return t.l+t.w*(ce.position||0)+Te%1;return Me._offset-a-Te}for(i=0;i0){cK(e,i,u,s),o.attr({x:l,y:i,"text-anchor":a,dy:MA(r.yanchor)}).call(N2.positionText,l,i);var f=(r.text.match(N2.BR_TAG_ALL)||[]).length;if(f){var v=ol.LINE_SPACING*f+ol.MID_SHIFT;r.y===0&&(v=-v),o.selectAll(".line").each(function(){var g=+this.getAttribute("dy").slice(0,-2)-v+"em";this.setAttribute("dy",g)})}var h=$1.select(e).selectAll(".gtitle-subtitle");if(h.node()){var d=o.node().getBBox(),m=d.y+d.height,y=m+_A.SUBTITLE_PADDING_EM*r.subtitle.font.size;h.attr({x:l,y,"text-anchor":a,dy:MA(r.yanchor)}).call(N2.positionText,l,y)}}}};function sK(e,r,t,a,n){var i=r.yref==="paper"?e._fullLayout._size.h:e._fullLayout.height,l=Fn.isTopAnchor(r)?a:a-n,o=t==="b"?i-l:l;return Fn.isTopAnchor(r)&&t==="t"||Fn.isBottomAnchor(r)&&t==="b"?!1:o.5?"t":"b",l=e._fullLayout.margin[i],o=0;return r.yref==="paper"?o=t+r.pad.t+r.pad.b:r.yref==="container"&&(o=uK(i,a,n,e._fullLayout.height,t)+r.pad.t+r.pad.b),o>l?o:0}function cK(e,r,t,a){var n="title.automargin",i=e._fullLayout.title,l=i.y>.5?"t":"b",o={x:i.x,y:i.y,t:0,b:0},s={};i.yref==="paper"&&sK(e,i,l,r,a)?o[l]=t:i.yref==="container"&&(s[l]=t,e._fullLayout._reservedMargin[n]=s),xn.allowAutoMargin(e,n),xn.autoMargin(e,n,o)}function vK(e,r){var t=e.title,a=e._size,n=0;return r===CA?n=t.pad.l:r===SA&&(n=-t.pad.r),t.xref==="paper"?a.l+a.w*t.x+n:e.width*t.x+n}function hK(e,r){var t=e.title,a=e._size,n=0;return r==="0em"||!r?n=-t.pad.b:r===ol.CAP_SHIFT+"em"&&(n=t.pad.t),t.y==="auto"?a.t/2:t.yref==="paper"?a.t+a.h-a.h*t.y+n:e.height-e.height*t.y+n}function MA(e){return e==="top"?ol.CAP_SHIFT+.3+"em":e==="bottom"?"-0.3em":ol.MID_SHIFT+"em"}function dK(e){var r=e.title,t=nK;return Fn.isRightAnchor(r)?t=SA:Fn.isLeftAnchor(r)&&(t=CA),t}function pK(e){var r=e.title,t="0em";return Fn.isTopAnchor(r)?t=ol.CAP_SHIFT+"em":Fn.isMiddleAnchor(r)&&(t=ol.MID_SHIFT+"em"),t}na.doTraceStyle=function(e){var r=e.calcdata,t=[],a;for(a=0;a{"use strict";var mK=Bl().readPaths,yK=I1(),LA=Dl().clearOutlineControllers,I2=Er(),qA=at(),gK=vt().arrayEditor,DA=Vl(),bK=DA.getPathString;PA.exports={draw:j1,drawOne:EA,activateLastSelection:wK};function j1(e){var r=e._fullLayout;LA(e),r._selectionLayer.selectAll("path").remove();for(var t in r._plots){var a=r._plots[t].selectionLayer;a&&a.selectAll("path").remove()}for(var n=0;n=0;g--){var x=l.append("path").attr(s).style("opacity",g?.1:u).call(I2.stroke,v).call(I2.fill,f).call(qA.dashLine,g?"solid":d,g?4+h:h);if(_K(x,e,a),m){var T=gK(e.layout,"selections",a);x.style({cursor:"move"});var M={element:x.node(),plotinfo:n,gd:e,editHelpers:T,isActiveSelection:!0},b=mK(o,e);yK(b,x,M)}else x.style("pointer-events",g?"all":"none");y[g]=x}var w=y[0],S=y[1];S.node().addEventListener("click",function(){return xK(e,w)})}}function _K(e,r,t){var a=t.xref+t.yref;qA.setClipUrl(e,"clip"+r._fullLayout._uid+a,r)}function xK(e,r){if(eh(e)){var t=r.node(),a=+t.getAttribute("data-index");if(a>=0){if(a===e._fullLayout._activeSelectionIndex){H2(e);return}e._fullLayout._activeSelectionIndex=a,e._fullLayout._deactivateSelection=H2,j1(e)}}}function wK(e){if(eh(e)){var r=e._fullLayout.selections.length-1;e._fullLayout._activeSelectionIndex=r,e._fullLayout._deactivateSelection=H2,j1(e)}}function H2(e){if(eh(e)){var r=e._fullLayout._activeSelectionIndex;r>=0&&(LA(e),delete e._fullLayout._activeSelectionIndex,j1(e))}}});var zA=X((fce,RA)=>{function TK(){var e,r=0,t=!1;function a(n,i){return e.list.push({type:n,data:i?JSON.parse(JSON.stringify(i)):void 0}),e}return e={list:[],segmentId:function(){return r++},checkIntersection:function(n,i){return a("check",{seg1:n,seg2:i})},segmentChop:function(n,i){return a("div_seg",{seg:n,pt:i}),a("chop",{seg:n,pt:i})},statusRemove:function(n){return a("pop_seg",{seg:n})},segmentUpdate:function(n){return a("seg_update",{seg:n})},segmentNew:function(n,i){return a("new_seg",{seg:n,primary:i})},segmentRemove:function(n){return a("rem_seg",{seg:n})},tempStatus:function(n,i,l){return a("temp_status",{seg:n,above:i,below:l})},rewind:function(n){return a("rewind",{seg:n})},status:function(n,i,l){return a("status",{seg:n,above:i,below:l})},vert:function(n){return n===t?e:(t=n,a("vert",{x:n}))},log:function(n){return typeof n!="string"&&(n=JSON.stringify(n,!1," ")),a("log",{txt:n})},reset:function(){return a("reset")},selected:function(n){return a("selected",{segs:n})},chainStart:function(n){return a("chain_start",{seg:n})},chainRemoveHead:function(n,i){return a("chain_rem_head",{index:n,pt:i})},chainRemoveTail:function(n,i){return a("chain_rem_tail",{index:n,pt:i})},chainNew:function(n,i){return a("chain_new",{pt1:n,pt2:i})},chainMatch:function(n){return a("chain_match",{index:n})},chainClose:function(n){return a("chain_close",{index:n})},chainAddHead:function(n,i){return a("chain_add_head",{index:n,pt:i})},chainAddTail:function(n,i){return a("chain_add_tail",{index:n,pt:i})},chainConnect:function(n,i){return a("chain_con",{index1:n,index2:i})},chainReverse:function(n){return a("chain_rev",{index:n})},chainJoin:function(n,i){return a("chain_join",{index1:n,index2:i})},done:function(){return a("done")}},e}RA.exports=TK});var FA=X((cce,NA)=>{function MK(e){typeof e!="number"&&(e=1e-10);var r={epsilon:function(t){return typeof t=="number"&&(e=t),e},pointAboveOrOnLine:function(t,a,n){var i=a[0],l=a[1],o=n[0],s=n[1],u=t[0],f=t[1];return(o-i)*(f-l)-(s-l)*(u-i)>=-e},pointBetween:function(t,a,n){var i=t[1]-a[1],l=n[0]-a[0],o=t[0]-a[0],s=n[1]-a[1],u=o*l+i*s;if(u-e)},pointsSameX:function(t,a){return Math.abs(t[0]-a[0])e!=o-i>e&&(l-f)*(i-v)/(o-v)+f-n>e&&(s=!s),l=f,o=v}return s}};return r}NA.exports=MK});var HA=X((vce,IA)=>{var AK={create:function(){var e={root:{root:!0,next:null},exists:function(r){return!(r===null||r===e.root)},isEmpty:function(){return e.root.next===null},getHead:function(){return e.root.next},insertBefore:function(r,t){for(var a=e.root,n=e.root.next;n!==null;){if(t(n)){r.prev=n.prev,r.next=n,n.prev.next=r,n.prev=r;return}a=n,n=n.next}a.next=r,r.prev=a,r.next=null},findTransition:function(r){for(var t=e.root,a=e.root.next;a!==null&&!r(a);)t=a,a=a.next;return{before:t===e.root?null:t,after:a,insert:function(n){return n.prev=t,n.next=a,t.next=n,a!==null&&(a.prev=n),n}}}};return e},node:function(e){return e.prev=null,e.next=null,e.remove=function(){e.prev.next=e.next,e.next&&(e.next.prev=e.prev),e.prev=null,e.next=null},e}};IA.exports=AK});var BA=X((hce,OA)=>{var fc=HA();function kK(e,r,t){function a(m,y){return{id:t?t.segmentId():-1,start:m,end:y,myFill:{above:null,below:null},otherFill:null}}function n(m,y,g){return{id:t?t.segmentId():-1,start:m,end:y,myFill:{above:g.myFill.above,below:g.myFill.below},otherFill:null}}var i=fc.create();function l(m,y,g,x,T,M){var b=r.pointsCompare(y,T);return b!==0?b:r.pointsSame(g,M)?0:m!==x?m?1:-1:r.pointAboveOrOnLine(g,x?T:M,x?M:T)?1:-1}function o(m,y){i.insertBefore(m,function(g){var x=l(m.isStart,m.pt,y,g.isStart,g.pt,g.other.pt);return x<0})}function s(m,y){var g=fc.node({isStart:!0,pt:m.start,seg:m,primary:y,other:null,status:null});return o(g,m.end),g}function u(m,y,g){var x=fc.node({isStart:!1,pt:y.end,seg:y,primary:g,other:m,status:null});m.other=x,o(x,m.pt)}function f(m,y){var g=s(m,y);return u(g,m,y),g}function v(m,y){t&&t.segmentChop(m.seg,y),m.other.remove(),m.seg.end=y,m.other.pt=y,o(m.other,m.pt)}function h(m,y){var g=n(y,m.seg.end,m.seg);return v(m,y),f(g,m.primary)}function d(m,y){var g=fc.create();function x(O,G){var Y=O.seg.start,B=O.seg.end,V=G.seg.start,J=G.seg.end;return r.pointsCollinear(Y,V,J)?r.pointsCollinear(B,V,J)||r.pointAboveOrOnLine(B,V,J)?1:-1:r.pointAboveOrOnLine(Y,V,J)?1:-1}function T(O){return g.findTransition(function(G){var Y=x(O,G.ev);return Y>0})}function M(O,G){var Y=O.seg,B=G.seg,V=Y.start,J=Y.end,Z=B.start,re=B.end;t&&t.checkIntersection(Y,B);var se=r.linesIntersect(V,J,Z,re);if(se===!1){if(!r.pointsCollinear(V,J,Z)||r.pointsSame(V,re)||r.pointsSame(J,Z))return!1;var ve=r.pointsSame(V,Z),xe=r.pointsSame(J,re);if(ve&&xe)return G;var Q=!ve&&r.pointBetween(V,Z,re),we=!xe&&r.pointBetween(J,Z,re);if(ve)return we?h(G,J):h(O,re),G;Q&&(xe||(we?h(G,J):h(O,re)),h(G,V))}else se.alongA===0&&(se.alongB===-1?h(O,Z):se.alongB===0?h(O,se.pt):se.alongB===1&&h(O,re)),se.alongB===0&&(se.alongA===-1?h(G,V):se.alongA===0?h(G,se.pt):se.alongA===1&&h(G,J));return!1}for(var b=[];!i.isEmpty();){var w=i.getHead();if(t&&t.vert(w.pt[0]),w.isStart){let O=function(){if(k){var G=M(w,k);if(G)return G}return q?M(w,q):!1};var H=O;t&&t.segmentNew(w.seg,w.primary);var S=T(w),k=S.before?S.before.ev:null,q=S.after?S.after.ev:null;t&&t.tempStatus(w.seg,k?k.seg:!1,q?q.seg:!1);var D=O();if(D){if(e){var P;w.seg.myFill.below===null?P=!0:P=w.seg.myFill.above!==w.seg.myFill.below,P&&(D.seg.myFill.above=!D.seg.myFill.above)}else D.seg.otherFill=w.seg.myFill;t&&t.segmentUpdate(D.seg),w.other.remove(),w.remove()}if(i.getHead()!==w){t&&t.rewind(w.seg);continue}if(e){var P;w.seg.myFill.below===null?P=!0:P=w.seg.myFill.above!==w.seg.myFill.below,q?w.seg.myFill.below=q.seg.myFill.above:w.seg.myFill.below=m,P?w.seg.myFill.above=!w.seg.myFill.below:w.seg.myFill.above=w.seg.myFill.below}else if(w.seg.otherFill===null){var z;q?w.primary===q.primary?z=q.seg.otherFill.above:z=q.seg.myFill.above:z=w.primary?y:m,w.seg.otherFill={above:z,below:z}}t&&t.status(w.seg,k?k.seg:!1,q?q.seg:!1),w.other.status=S.insert(fc.node({ev:w}))}else{var F=w.status;if(F===null)throw new Error("PolyBool: Zero-length segment detected; your epsilon is probably too small or too large");if(g.exists(F.prev)&&g.exists(F.next)&&M(F.prev.ev,F.next.ev),t&&t.statusRemove(F.ev.seg),F.remove(),!w.primary){var I=w.seg.myFill;w.seg.myFill=w.seg.otherFill,w.seg.otherFill=I}b.push(w.seg)}i.getHead().remove()}return t&&t.done(),b}return e?{addRegion:function(m){for(var y,g=m[m.length-1],x=0;x{function CK(e,r,t){var a=[],n=[];return e.forEach(function(i){var l=i.start,o=i.end;if(r.pointsSame(l,o)){console.warn("PolyBool: Warning: Zero-length segment detected; your epsilon is probably too small or too large");return}t&&t.chainStart(i);var s={index:0,matches_head:!1,matches_pt1:!1},u={index:0,matches_head:!1,matches_pt1:!1},f=s;function v(H,O,G){return f.index=H,f.matches_head=O,f.matches_pt1=G,f===s?(f=u,!1):(f=null,!0)}for(var h=0;h{function cc(e,r,t){var a=[];return e.forEach(function(n){var i=(n.myFill.above?8:0)+(n.myFill.below?4:0)+(n.otherFill&&n.otherFill.above?2:0)+(n.otherFill&&n.otherFill.below?1:0);r[i]!==0&&a.push({id:t?t.segmentId():-1,start:n.start,end:n.end,myFill:{above:r[i]===1,below:r[i]===2},otherFill:null})}),t&&t.selected(a),a}var SK={union:function(e,r){return cc(e,[0,2,1,0,2,2,0,0,1,0,1,0,0,0,0,0],r)},intersect:function(e,r){return cc(e,[0,0,0,0,0,2,0,2,0,0,1,1,0,2,1,0],r)},difference:function(e,r){return cc(e,[0,0,0,0,2,0,2,0,1,1,0,0,0,1,2,0],r)},differenceRev:function(e,r){return cc(e,[0,2,1,0,0,0,1,1,0,2,0,2,0,0,0,0],r)},xor:function(e,r){return cc(e,[0,2,1,0,2,0,0,1,1,0,0,2,0,1,2,0],r)}};VA.exports=SK});var XA=X((mce,WA)=>{var LK={toPolygon:function(e,r){function t(i){if(i.length<=0)return e.segments({inverted:!1,regions:[]});function l(u){var f=u.slice(0,u.length-1);return e.segments({inverted:!1,regions:[f]})}for(var o=l(i[0]),s=1;s{var qK=zA(),DK=FA(),ZA=BA(),EK=UA(),vc=GA(),JA=XA(),In=!1,hc=DK(),un;un={buildLog:function(e){return e===!0?In=qK():e===!1&&(In=!1),In===!1?!1:In.list},epsilon:function(e){return hc.epsilon(e)},segments:function(e){var r=ZA(!0,hc,In);return e.regions.forEach(r.addRegion),{segments:r.calculate(e.inverted),inverted:e.inverted}},combine:function(e,r){var t=ZA(!1,hc,In);return{combined:t.calculate(e.segments,e.inverted,r.segments,r.inverted),inverted1:e.inverted,inverted2:r.inverted}},selectUnion:function(e){return{segments:vc.union(e.combined,In),inverted:e.inverted1||e.inverted2}},selectIntersect:function(e){return{segments:vc.intersect(e.combined,In),inverted:e.inverted1&&e.inverted2}},selectDifference:function(e){return{segments:vc.difference(e.combined,In),inverted:e.inverted1&&!e.inverted2}},selectDifferenceRev:function(e){return{segments:vc.differenceRev(e.combined,In),inverted:!e.inverted1&&e.inverted2}},selectXor:function(e){return{segments:vc.xor(e.combined,In),inverted:e.inverted1!==e.inverted2}},polygon:function(e){return{regions:EK(e.segments,hc,In),inverted:e.inverted}},polygonFromGeoJSON:function(e){return JA.toPolygon(un,e)},polygonToGeoJSON:function(e){return JA.fromPolygon(un,hc,e)},union:function(e,r){return dc(e,r,un.selectUnion)},intersect:function(e,r){return dc(e,r,un.selectIntersect)},difference:function(e,r){return dc(e,r,un.selectDifference)},differenceRev:function(e,r){return dc(e,r,un.selectDifferenceRev)},xor:function(e,r){return dc(e,r,un.selectXor)}};function dc(e,r,t){var a=un.segments(e),n=un.segments(r),i=un.combine(a,n),l=t(i);return un.polygon(l)}typeof window=="object"&&(window.PolyBool=un);KA.exports=un});var jA=X((gce,QA)=>{QA.exports=function(r,t,a,n){var i=r[0],l=r[1],o=!1;a===void 0&&(a=0),n===void 0&&(n=t.length);for(var s=n-a,u=0,f=s-1;ul!=m>l&&i<(d-v)*(l-h)/(m-h)+v;y&&(o=!o)}return o}});var Y2=X((bce,ek)=>{"use strict";var B2=rv().dot,rh=Zt().BADNUM,th=ek.exports={};th.tester=function(r){var t=r.slice(),a=t[0][0],n=a,i=t[0][1],l=i,o;for((t[t.length-1][0]!==t[0][0]||t[t.length-1][1]!==t[0][1])&&t.push(t[0]),o=1;on||x===rh||xl||y&&u(m))}function v(m,y){var g=m[0],x=m[1];if(g===rh||gn||x===rh||xl)return!1;var T=t.length,M=t[0][0],b=t[0][1],w=0,S,k,q,D,P;for(S=1;SMath.max(k,M)||x>Math.max(q,b)))if(xo||Math.abs(B2(v,u))>n)return!0;return!1};th.filter=function(r,t){var a=[r[0]],n=0,i=0;function l(s){r.push(s);var u=a.length,f=n;a.splice(i+1);for(var v=f+1;v1){var o=r.pop();l(o)}return{addPt:l,raw:r,filtered:a}}});var tk=X((_ce,rk)=>{"use strict";rk.exports={BENDPX:1.5,MINSELECT:12,SELECTDELAY:100,SELECTID:"-select"}});var Mk=X((xce,Tk)=>{"use strict";var ak=$A(),PK=jA(),yc=_r(),RK=at().dashStyle,pc=Er(),zK=vi(),NK=Di().makeEventData,wc=Tu(),FK=wc.freeMode,IK=wc.rectMode,gc=wc.drawMode,W2=wc.openMode,X2=wc.selectMode,nk=Vl(),ik=ac(),fk=I1(),ck=Dl().clearOutline,vk=Bl(),U2=vk.handleEllipse,HK=vk.readPaths,OK=z1().newShapes,BK=d2(),YK=O2().activateLastSelection,nh=Ne(),UK=nh.sorterAsc,hk=Y2(),mc=hv(),Hn=$t().getFromId,VK=C1(),GK=Q1().redrawReglTraces,ih=tk(),Ui=ih.MINSELECT,WK=hk.filter,Z2=hk.tester,J2=D1(),lk=J2.p2r,XK=J2.axValue,ZK=J2.getTransform;function K2(e){return e.subplot!==void 0}function JK(e,r,t,a,n){var i=!K2(a),l=FK(n),o=IK(n),s=W2(n),u=gc(n),f=X2(n),v=n==="drawline",h=n==="drawcircle",d=v||h,m=a.gd,y=m._fullLayout,g=f&&y.newselection.mode==="immediate"&&i,x=y._zoomlayer,T=a.element.getBoundingClientRect(),M=a.plotinfo,b=ZK(M),w=r-T.left,S=t-T.top;y._calcInverseTransform(m);var k=nh.apply3DTransform(y._invTransform)(w,S);w=k[0],S=k[1];var q=y._invScaleX,D=y._invScaleY,P=w,z=S,F="M"+w+","+S,I=a.xaxes[0],H=a.yaxes[0],O=I._length,G=H._length,Y=e.altKey&&!(gc(n)&&s),B,V,J,Z,re,se,ve;pk(e,m,a),l&&(B=WK([[w,S]],ih.BENDPX));var xe=x.selectAll("path.select-outline-"+M.id).data([1]),Q=u?y.newshape:y.newselection;u&&(a.hasText=Q.label.text||Q.label.texttemplate);var we=u&&!s?Q.fillcolor:"rgba(0,0,0,0)",fe=Q.line.color||(i?pc.contrast(m._fullLayout.plot_bgcolor):"#7f7f7f");xe.enter().append("path").attr("class","select-outline select-outline-"+M.id).style({opacity:u?Q.opacity/2:1,"stroke-dasharray":RK(Q.line.dash,Q.line.width),"stroke-width":Q.line.width+"px","shape-rendering":"crispEdges"}).call(pc.stroke,fe).call(pc.fill,we).attr("fill-rule","evenodd").classed("cursor-move",!!u).attr("transform",b).attr("d",F+"Z");var ge=x.append("path").attr("class","zoombox-corners").style({fill:pc.background,stroke:pc.defaultLine,"stroke-width":1}).attr("transform",b).attr("d","M0,0Z");if(u&&a.hasText){var _e=x.select(".label-temp");_e.empty()&&(_e=x.append("g").classed("label-temp",!0).classed("select-outline",!0).style({opacity:.8}))}var ce=y._uid+ih.SELECTID,Me=[],Re=lh(m,a.xaxes,a.yaxes,a.subplot);g&&!e.shiftKey&&(a._clearSubplotSelections=function(){if(i){var Pe=I._id,Le=H._id;_k(m,Pe,Le,Re);for(var Ce=(m.layout||{}).selections||[],Ie=[],Oe=!1,qe=0;qe=0){m._fullLayout._deactivateShape(m);return}if(!u){var Ce=y.clickmode;mc.done(ce).then(function(){if(mc.clear(ce),Pe===2){for(xe.remove(),re=0;re-1&&dk(Le,m,a.xaxes,a.yaxes,a.subplot,a,xe),Ce==="event"&&xc(m,void 0);zK.click(m,Le,M.id)}).catch(nh.error)}},a.doneFn=function(){ge.remove(),mc.done(ce).then(function(){mc.clear(ce),!g&&Z&&a.selectionDefs&&(Z.subtract=Y,a.selectionDefs.push(Z),a.mergedPolygons.length=0,[].push.apply(a.mergedPolygons,J)),(g||u)&&bc(a,g),a.doneFnCompleted&&a.doneFnCompleted(Me),f&&xc(m,ve)}).catch(nh.error)}}function dk(e,r,t,a,n,i,l){var o=r._hoverdata,s=r._fullLayout,u=s.clickmode,f=u.indexOf("event")>-1,v=[],h,d,m,y,g,x,T,M,b,w;if(e$(o)){pk(e,r,i),h=lh(r,t,a,n);var S=r$(o,h),k=S.pointNumbers.length>0;if(k?t$(h,S):a$(h)&&(T=sk(S))){for(l&&l.remove(),w=0;w=0}function jK(e){return e._fullLayout._activeSelectionIndex>=0}function bc(e,r){var t=e.dragmode,a=e.plotinfo,n=e.gd;QK(n)&&n._fullLayout._deactivateShape(n),jK(n)&&n._fullLayout._deactivateSelection(n);var i=n._fullLayout,l=i._zoomlayer,o=gc(t),s=X2(t);if(o||s){var u=l.selectAll(".select-outline-"+a.id);if(u&&n._fullLayout._outlining){var f;o&&(f=OK(u,e)),f&&yc.call("_guiRelayout",n,{shapes:f});var v;s&&!K2(e)&&(v=BK(u,e)),v&&(n._fullLayout._noEmitSelectedAtStart=!0,yc.call("_guiRelayout",n,{selections:v}).then(function(){r&&YK(n)})),n._fullLayout._outlining=!1}}a.selection={},a.selection.selectionDefs=e.selectionDefs=[],a.selection.mergedPolygons=e.mergedPolygons=[]}function ok(e){return e._id}function lh(e,r,t,a){if(!e.calcdata)return[];var n=[],i=r.map(ok),l=t.map(ok),o,s,u;for(u=0;u0,i=n?a[0]:t;return r.selectedpoints?r.selectedpoints.indexOf(i)>-1:!1}function t$(e,r){var t=[],a,n,i,l;for(l=0;l0&&t.push(a);if(t.length===1&&(i=t[0]===r.searchInfo,i&&(n=r.searchInfo.cd[0].trace,n.selectedpoints.length===r.pointNumbers.length))){for(l=0;l1||(r+=a.selectedpoints.length,r>1)))return!1;return r===1}function _c(e,r,t){var a,n;for(a=0;a-1&&r;if(!l&&r){var Pe=uk(e,!0);if(Pe.length){var Le=Pe[0].xref,Ce=Pe[0].yref;if(Le&&Ce){var Ie=xk(Pe),Oe=wk([Hn(e,Le,"x"),Hn(e,Ce,"y")]);Oe(Me,Ie)}}e._fullLayout._noEmitSelectedAtStart?e._fullLayout._noEmitSelectedAtStart=!1:Te&&xc(e,Me),h._reselect=!1}if(!l&&h._deselect){var qe=h._deselect;o=qe.xref,s=qe.yref,l$(o,s,f)||_k(e,o,s,a),Te&&(Me.points.length?xc(e,Me):j2(e)),h._deselect=!1}return{eventData:Me,selectionTesters:t}}function i$(e){var r=e.calcdata;if(r)for(var t=0;t{"use strict";Ak.exports=[{path:"",backoff:0},{path:"M-2.4,-3V3L0.6,0Z",backoff:.6},{path:"M-3.7,-2.5V2.5L1.3,0Z",backoff:1.3},{path:"M-4.45,-3L-1.65,-0.2V0.2L-4.45,3L1.55,0Z",backoff:1.55},{path:"M-2.2,-2.2L-0.2,-0.2V0.2L-2.2,2.2L-1.4,3L1.6,0L-1.4,-3Z",backoff:1.6},{path:"M-4.4,-2.1L-0.6,-0.2V0.2L-4.4,2.1L-4,3L2,0L-4,-3Z",backoff:2},{path:"M2,0A2,2 0 1,1 0,-2A2,2 0 0,1 2,0Z",backoff:0,noRotate:!0},{path:"M2,2V-2H-2V2Z",backoff:0,noRotate:!0}]});var Tc=X((Tce,kk)=>{"use strict";kk.exports={axisRefDescription:function(e,r,t){return["If set to a",e,"axis id (e.g. *"+e+"* or","*"+e+"2*), the `"+e+"` position refers to a",e,"coordinate. If set to *paper*, the `"+e+"`","position refers to the distance from the",r,"of the plotting","area in normalized coordinates where *0* (*1*) corresponds to the",r,"("+t+"). If set to a",e,"axis ID followed by","*domain* (separated by a space), the position behaves like for","*paper*, but refers to the distance in fractions of the domain","length from the",r,"of the domain of that axis: e.g.,","*"+e+"2 domain* refers to the domain of the second",e,"axis and a",e,"position of 0.5 refers to the","point between the",r,"and the",t,"of the domain of the","second",e,"axis."].join(" ")}}});var Su=X((Ace,Lk)=>{"use strict";var Ck=eg(),Sk=Ta(),oh=ta(),c$=vt().templatedArray,Mce=Tc();Lk.exports=c$("annotation",{visible:{valType:"boolean",dflt:!0,editType:"calc+arraydraw"},text:{valType:"string",editType:"calc+arraydraw"},textangle:{valType:"angle",dflt:0,editType:"calc+arraydraw"},font:Sk({editType:"calc+arraydraw",colorEditType:"arraydraw"}),width:{valType:"number",min:1,dflt:null,editType:"calc+arraydraw"},height:{valType:"number",min:1,dflt:null,editType:"calc+arraydraw"},opacity:{valType:"number",min:0,max:1,dflt:1,editType:"arraydraw"},align:{valType:"enumerated",values:["left","center","right"],dflt:"center",editType:"arraydraw"},valign:{valType:"enumerated",values:["top","middle","bottom"],dflt:"middle",editType:"arraydraw"},bgcolor:{valType:"color",dflt:"rgba(0,0,0,0)",editType:"arraydraw"},bordercolor:{valType:"color",dflt:"rgba(0,0,0,0)",editType:"arraydraw"},borderpad:{valType:"number",min:0,dflt:1,editType:"calc+arraydraw"},borderwidth:{valType:"number",min:0,dflt:1,editType:"calc+arraydraw"},showarrow:{valType:"boolean",dflt:!0,editType:"calc+arraydraw"},arrowcolor:{valType:"color",editType:"arraydraw"},arrowhead:{valType:"integer",min:0,max:Ck.length,dflt:1,editType:"arraydraw"},startarrowhead:{valType:"integer",min:0,max:Ck.length,dflt:1,editType:"arraydraw"},arrowside:{valType:"flaglist",flags:["end","start"],extras:["none"],dflt:"end",editType:"arraydraw"},arrowsize:{valType:"number",min:.3,dflt:1,editType:"calc+arraydraw"},startarrowsize:{valType:"number",min:.3,dflt:1,editType:"calc+arraydraw"},arrowwidth:{valType:"number",min:.1,editType:"calc+arraydraw"},standoff:{valType:"number",min:0,dflt:0,editType:"calc+arraydraw"},startstandoff:{valType:"number",min:0,dflt:0,editType:"calc+arraydraw"},ax:{valType:"any",editType:"calc+arraydraw"},ay:{valType:"any",editType:"calc+arraydraw"},axref:{valType:"enumerated",dflt:"pixel",values:["pixel",oh.idRegex.x.toString()],editType:"calc"},ayref:{valType:"enumerated",dflt:"pixel",values:["pixel",oh.idRegex.y.toString()],editType:"calc"},xref:{valType:"enumerated",values:["paper",oh.idRegex.x.toString()],editType:"calc"},x:{valType:"any",editType:"calc+arraydraw"},xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"auto",editType:"calc+arraydraw"},xshift:{valType:"number",dflt:0,editType:"calc+arraydraw"},yref:{valType:"enumerated",values:["paper",oh.idRegex.y.toString()],editType:"calc"},y:{valType:"any",editType:"calc+arraydraw"},yanchor:{valType:"enumerated",values:["auto","top","middle","bottom"],dflt:"auto",editType:"calc+arraydraw"},yshift:{valType:"number",dflt:0,editType:"calc+arraydraw"},clicktoshow:{valType:"enumerated",values:[!1,"onoff","onout"],dflt:!1,editType:"arraydraw"},xclick:{valType:"any",editType:"arraydraw"},yclick:{valType:"any",editType:"arraydraw"},hovertext:{valType:"string",editType:"arraydraw"},hoverlabel:{bgcolor:{valType:"color",editType:"arraydraw"},bordercolor:{valType:"color",editType:"arraydraw"},font:Sk({editType:"arraydraw"}),editType:"arraydraw"},captureevents:{valType:"boolean",editType:"arraydraw"},editType:"calc"})});var sh=X((kce,qk)=>{"use strict";qk.exports={PTS_LINESONLY:20,minTolerance:.2,toleranceGrowth:10,maxScreensAway:20,eventDataKeys:[]}});var Ek=X((Cce,Dk)=>{"use strict";Dk.exports=function(r){return{valType:"color",editType:"style",anim:!0}}});var Qo=X((Sce,Ok)=>{"use strict";var Pk=Pv().axisHoverFormat,{hovertemplateAttrs:v$,texttemplateAttrs:h$,templatefallbackAttrs:Rk}=To(),zk=If(),d$=Ta(),Nk=Si().dash,p$=Si().pattern,m$=at(),y$=sh(),Mc=Jt().extendFlat,g$=Ek();function Fk(e){return{valType:"any",dflt:0,editType:"calc"}}function Ik(e){return{valType:"any",editType:"calc"}}function Hk(e){return{valType:"enumerated",values:["start","middle","end"],dflt:"middle",editType:"calc"}}Ok.exports={x:{valType:"data_array",editType:"calc+clearAxisTypes",anim:!0},x0:{valType:"any",dflt:0,editType:"calc+clearAxisTypes",anim:!0},dx:{valType:"number",dflt:1,editType:"calc",anim:!0},y:{valType:"data_array",editType:"calc+clearAxisTypes",anim:!0},y0:{valType:"any",dflt:0,editType:"calc+clearAxisTypes",anim:!0},dy:{valType:"number",dflt:1,editType:"calc",anim:!0},xperiod:Fk("x"),yperiod:Fk("y"),xperiod0:Ik("x0"),yperiod0:Ik("y0"),xperiodalignment:Hk("x"),yperiodalignment:Hk("y"),xhoverformat:Pk("x"),yhoverformat:Pk("y"),offsetgroup:{valType:"string",dflt:"",editType:"calc"},alignmentgroup:{valType:"string",dflt:"",editType:"calc"},stackgroup:{valType:"string",dflt:"",editType:"calc"},orientation:{valType:"enumerated",values:["v","h"],editType:"calc"},groupnorm:{valType:"enumerated",values:["","fraction","percent"],dflt:"",editType:"calc"},stackgaps:{valType:"enumerated",values:["infer zero","interpolate"],dflt:"infer zero",editType:"calc"},text:{valType:"string",dflt:"",arrayOk:!0,editType:"calc"},texttemplate:h$(),texttemplatefallback:Rk({editType:"calc"}),hovertext:{valType:"string",dflt:"",arrayOk:!0,editType:"style"},mode:{valType:"flaglist",flags:["lines","markers","text"],extras:["none"],editType:"calc"},hoveron:{valType:"flaglist",flags:["points","fills"],editType:"style"},hovertemplate:v$({},{keys:y$.eventDataKeys}),hovertemplatefallback:Rk(),line:{color:{valType:"color",editType:"style",anim:!0},width:{valType:"number",min:0,dflt:2,editType:"style",anim:!0},shape:{valType:"enumerated",values:["linear","spline","hv","vh","hvh","vhv"],dflt:"linear",editType:"plot"},smoothing:{valType:"number",min:0,max:1.3,dflt:1,editType:"plot"},dash:Mc({},Nk,{editType:"style"}),backoff:{valType:"number",min:0,dflt:"auto",arrayOk:!0,editType:"plot"},simplify:{valType:"boolean",dflt:!0,editType:"plot"},editType:"plot"},connectgaps:{valType:"boolean",dflt:!1,editType:"calc"},cliponaxis:{valType:"boolean",dflt:!0,editType:"plot"},fill:{valType:"enumerated",values:["none","tozeroy","tozerox","tonexty","tonextx","toself","tonext"],editType:"calc"},fillcolor:g$(!0),fillgradient:Mc({type:{valType:"enumerated",values:["radial","horizontal","vertical","none"],dflt:"none",editType:"calc"},start:{valType:"number",editType:"calc"},stop:{valType:"number",editType:"calc"},colorscale:{valType:"colorscale",editType:"style"},editType:"calc"}),fillpattern:p$,marker:Mc({symbol:{valType:"enumerated",values:m$.symbolList,dflt:"circle",arrayOk:!0,editType:"style"},opacity:{valType:"number",min:0,max:1,arrayOk:!0,editType:"style",anim:!0},angle:{valType:"angle",dflt:0,arrayOk:!0,editType:"plot",anim:!1},angleref:{valType:"enumerated",values:["previous","up"],dflt:"up",editType:"plot",anim:!1},standoff:{valType:"number",min:0,dflt:0,arrayOk:!0,editType:"plot",anim:!0},size:{valType:"number",min:0,dflt:6,arrayOk:!0,editType:"calc",anim:!0},maxdisplayed:{valType:"number",min:0,dflt:0,editType:"plot"},sizeref:{valType:"number",dflt:1,editType:"calc"},sizemin:{valType:"number",min:0,dflt:0,editType:"calc"},sizemode:{valType:"enumerated",values:["diameter","area"],dflt:"diameter",editType:"calc"},line:Mc({width:{valType:"number",min:0,arrayOk:!0,editType:"style",anim:!0},dash:Mc({},Nk,{arrayOk:!0}),editType:"calc"},zk("marker.line",{anim:!0})),gradient:{type:{valType:"enumerated",values:["radial","horizontal","vertical","none"],arrayOk:!0,dflt:"none",editType:"calc"},color:{valType:"color",arrayOk:!0,editType:"calc"},editType:"calc"},editType:"calc"},zk("marker",{anim:!0})),selected:{marker:{opacity:{valType:"number",min:0,max:1,editType:"style"},color:{valType:"color",editType:"style"},size:{valType:"number",min:0,editType:"style"},editType:"style"},textfont:{color:{valType:"color",editType:"style"},editType:"style"},editType:"style"},unselected:{marker:{opacity:{valType:"number",min:0,max:1,editType:"style"},color:{valType:"color",editType:"style"},size:{valType:"number",min:0,editType:"style"},editType:"style"},textfont:{color:{valType:"color",editType:"style"},editType:"style"},editType:"style"},textposition:{valType:"enumerated",values:["top left","top center","top right","middle left","middle center","middle right","bottom left","bottom center","bottom right"],dflt:"middle center",arrayOk:!0,editType:"calc"},textfont:d$({editType:"calc",colorEditType:"style",arrayOk:!0}),zorder:{valType:"integer",dflt:0,editType:"plot"}}});var rg=X((qce,Uk)=>{"use strict";var Bk=Su(),Yk=Qo().line,b$=Si().dash,uh=Jt().extendFlat,_$=Ci().overrideAll,x$=vt().templatedArray,Lce=Tc();Uk.exports=_$(x$("selection",{type:{valType:"enumerated",values:["rect","path"]},xref:uh({},Bk.xref,{}),yref:uh({},Bk.yref,{}),x0:{valType:"any"},x1:{valType:"any"},y0:{valType:"any"},y1:{valType:"any"},path:{valType:"string",editType:"arraydraw"},opacity:{valType:"number",min:0,max:1,dflt:.7,editType:"arraydraw"},line:{color:Yk.color,width:uh({},Yk.width,{min:1,dflt:1}),dash:uh({},b$,{dflt:"dot"})}}),"arraydraw","from-root")});var Xk=X((Dce,Wk)=>{"use strict";var Vk=Ne(),fh=mt(),w$=si(),T$=rg(),Gk=Vl();Wk.exports=function(r,t){w$(r,t,{name:"selections",handleItemDefaults:M$});for(var a=t.selections,n=0;n{"use strict";Zk.exports=function(r,t,a){a("newselection.mode");var n=a("newselection.line.width");n&&(a("newselection.line.color"),a("newselection.line.dash")),a("activeselection.fillcolor"),a("activeselection.opacity")}});var Ac=X((Pce,Qk)=>{"use strict";var A$=_r(),Kk=Ne(),$k=$t();Qk.exports=function(r){return function(a,n){var i=a[r];if(Array.isArray(i))for(var l=A$.subplotsRegistry.cartesian,o=l.idRegex,s=n._subplots,u=s.xaxis,f=s.yaxis,v=s.cartesian,h=n._has("cartesian"),d=0;d{"use strict";var jk=O2(),kc=Mk();eC.exports={moduleType:"component",name:"selections",layoutAttributes:rg(),supplyLayoutDefaults:Xk(),supplyDrawNewSelectionDefaults:Jk(),includeBasePlot:Ac()("selections"),draw:jk.draw,drawOne:jk.drawOne,reselect:kc.reselect,prepSelect:kc.prepSelect,clearOutline:kc.clearOutline,clearSelectionsCache:kc.clearSelectionsCache,selectOnClick:kc.selectOnClick}});var _C=X((zce,bC)=>{"use strict";var og=Nr(),On=Ne(),rC=On.numberFormat,k$=Iy(),ch=_r(),uC=On.strTranslate,C$=da(),ag=Er(),Wl=at(),S$=vi(),tC=mt(),L$=Hl(),q$=fi(),fC=Tu(),vh=fC.selectingOrDrawing,D$=fC.freeMode,E$=La().FROM_TL,P$=C1(),R$=Q1().redrawReglTraces,z$=Ut(),ng=$t().getFromId,N$=Lu().prepSelect,F$=Lu().clearOutline,I$=Lu().selectOnClick,tg=E2(),sg=ta(),aC=sg.MINDRAG,Ka=sg.MINZOOM,nC=!0;function H$(e,r,t,a,n,i,l,o){var s=e._fullLayout._zoomlayer,u=l+o==="nsew",f=(l+o).length===1,v,h,d,m,y,g,x,T,M,b,w,S,k,q,D,P,z,F,I,H,O,G,Y;t+=r.yaxis._shift;function B(){if(v=r.xaxis,h=r.yaxis,M=v._length,b=h._length,x=v._offset,T=h._offset,d={},d[v._id]=v,m={},m[h._id]=h,l&&o)for(var me=r.overlays,be=0;be=0){Fe._fullLayout._deactivateShape(Fe);return}var Ge=Fe._fullLayout.clickmode;if(lg(Fe),me===2&&!f&&He(),u)Ge.indexOf("select")>-1&&I$(be,Fe,y,g,r.id,Z),Ge.indexOf("event")>-1&&S$.click(Fe,be,r.id);else if(me===1&&f){var Ue=l?h:v,Ve=l==="s"||o==="w"?0:1,ir=Ue._name+".range["+Ve+"]",ar=O$(Ue,Ve),er="left",Ee="middle";if(Ue.fixedrange)return;l?(Ee=l==="n"?"top":"bottom",Ue.side==="right"&&(er="right")):o==="e"&&(er="right"),Fe._context.showAxisRangeEntryBoxes&&og.select(J).call(C$.makeEditable,{gd:Fe,immediate:!0,background:Fe._fullLayout.paper_bgcolor,text:String(ar),fill:Ue.tickfont?Ue.tickfont.color:"#444",horizontalAlign:er,verticalAlign:Ee}).on("edit",function(ee){var ae=Ue.d2r(ee);ae!==void 0&&ch.call("_guiRelayout",Fe,ir,ae)})}}q$.init(Z);var ve,xe,Q,we,fe,ge,_e,ce,Me,Re;function Te(me,be,Fe){var Ge=J.getBoundingClientRect();ve=be-Ge.left,xe=Fe-Ge.top,e._fullLayout._calcInverseTransform(e);var Ue=On.apply3DTransform(e._fullLayout._invTransform)(ve,xe);ve=Ue[0],xe=Ue[1],Q={l:ve,r:ve,w:0,t:xe,b:xe,h:0},we=e._hmpixcount?e._hmlumcount/e._hmpixcount:ag.color(e._fullLayout.plot_bgcolor).luminosity(),fe="M0,0H"+M+"V"+b+"H0V0",ge=!1,_e="xy",Re=!1,ce=hC(s,we,x,T,fe),Me=dC(s,x,T)}function Pe(me,be){if(e._transitioningWithDuration)return!1;var Fe=Math.max(0,Math.min(M,G*me+ve)),Ge=Math.max(0,Math.min(b,Y*be+xe)),Ue=Math.abs(Fe-ve),Ve=Math.abs(Ge-xe);Q.l=Math.min(ve,Fe),Q.r=Math.max(ve,Fe),Q.t=Math.min(xe,Ge),Q.b=Math.max(xe,Ge);function ir(){_e="",Q.r=Q.l,Q.t=Q.b,Me.attr("d","M0,0Z")}if(w.isSubplotConstrained)Ue>Ka||Ve>Ka?(_e="xy",Ue/M>Ve/b?(Ve=Ue*b/M,xe>Ge?Q.t=xe-Ve:Q.b=xe+Ve):(Ue=Ve*M/b,ve>Fe?Q.l=ve-Ue:Q.r=ve+Ue),Me.attr("d",hh(Q))):ir();else if(S.isSubplotConstrained)if(Ue>Ka||Ve>Ka){_e="xy";var ar=Math.min(Q.l/M,(b-Q.b)/b),er=Math.max(Q.r/M,(b-Q.t)/b);Q.l=ar*M,Q.r=er*M,Q.b=(1-ar)*b,Q.t=(1-er)*b,Me.attr("d",hh(Q))}else ir();else!q||Ve0){var ee;if(S.isSubplotConstrained||!k&&q.length===1){for(ee=0;ee1&&(ir.maxallowed!==void 0&&P===(ir.range[0]1&&(ar.maxallowed!==void 0&&z===(ar.range[0]=0?Math.min(e,.9):1/(1/Math.max(e,-.3)+3.222))}function Y$(e,r,t){return e?e==="nsew"?t?"":r==="pan"?"move":"crosshair":e.toLowerCase()+"-resize":"pointer"}function hC(e,r,t,a,n){return e.append("path").attr("class","zoombox").style({fill:r>.2?"rgba(0,0,0,0)":"rgba(255,255,255,0)","stroke-width":0}).attr("transform",uC(t,a)).attr("d",n+"Z")}function dC(e,r,t){return e.append("path").attr("class","zoombox-corners").style({fill:ag.background,stroke:ag.defaultLine,"stroke-width":1,opacity:0}).attr("transform",uC(r,t)).attr("d","M0,0Z")}function pC(e,r,t,a,n,i){e.attr("d",a+"M"+t.l+","+t.t+"v"+t.h+"h"+t.w+"v-"+t.h+"h-"+t.w+"Z"),mC(e,r,n,i)}function mC(e,r,t,a){t||(e.transition().style("fill",a>.2?"rgba(0,0,0,0.4)":"rgba(255,255,255,0.3)").duration(200),r.transition().style("opacity",1).duration(200))}function lg(e){og.select(e).selectAll(".zoombox,.js-zoombox-backdrop,.js-zoombox-menu,.zoombox-corners").remove()}function yC(e){nC&&e.data&&e._context.showTips&&(On.notifier(On._(e,"Double-click to zoom back out"),"long",e),nC=!1)}function U$(e,r){return"M"+(e.l-.5)+","+(r-Ka-.5)+"h-3v"+(2*Ka+1)+"h3ZM"+(e.r+.5)+","+(r-Ka-.5)+"h3v"+(2*Ka+1)+"h-3Z"}function V$(e,r){return"M"+(r-Ka-.5)+","+(e.t-.5)+"v-3h"+(2*Ka+1)+"v3ZM"+(r-Ka-.5)+","+(e.b+.5)+"v3h"+(2*Ka+1)+"v-3Z"}function hh(e){var r=Math.floor(Math.min(e.b-e.t,e.r-e.l,Ka)/2);return"M"+(e.l-3.5)+","+(e.t-.5+r)+"h3v"+-r+"h"+r+"v-3h-"+(r+3)+"ZM"+(e.r+3.5)+","+(e.t-.5+r)+"h-3v"+-r+"h"+-r+"v-3h"+(r+3)+"ZM"+(e.r+3.5)+","+(e.b+.5-r)+"h-3v"+r+"h"+-r+"v3h"+(r+3)+"ZM"+(e.l-3.5)+","+(e.b+.5-r)+"h3v"+r+"h"+r+"v3h-"+(r+3)+"Z"}function oC(e,r,t,a,n){for(var i=!1,l={},o={},s,u,f,v,h=(n||{}).xaHash,d=(n||{}).yaHash,m=0;m{"use strict";var G$=Nr(),dh=vi(),W$=fi(),X$=Hl(),di=_C().makeDragBox,ia=ta().DRAGGERSIZE;ph.initInteractions=function(r){var t=r._fullLayout;if(r._context.staticPlot){G$.select(r).selectAll(".drag").remove();return}if(!(!t._has("cartesian")&&!t._has("splom"))){var a=Object.keys(t._plots||{}).sort(function(i,l){if((t._plots[i].mainplot&&!0)===(t._plots[l].mainplot&&!0)){var o=i.split("y"),s=l.split("y");return o[0]===s[0]?Number(o[1]||1)-Number(s[1]||1):Number(o[0]||1)-Number(s[0]||1)}return t._plots[i].mainplot?1:-1});a.forEach(function(i){var l=t._plots[i],o=l.xaxis,s=l.yaxis;if(!l.mainplot){var u=di(r,l,o._offset,s._offset,o._length,s._length,"ns","ew");u.onmousemove=function(h){r._fullLayout._rehover=function(){r._fullLayout._hoversubplot===i&&r._fullLayout._plots[i]&&dh.hover(r,h,i)},dh.hover(r,h,i),r._fullLayout._lasthover=u,r._fullLayout._hoversubplot=i},u.onmouseout=function(h){r._dragging||(r._fullLayout._hoversubplot=null,W$.unhover(r,h))},r._context.showAxisDragHandles&&(di(r,l,o._offset-ia,s._offset-ia,ia,ia,"n","w"),di(r,l,o._offset+o._length,s._offset-ia,ia,ia,"n","e"),di(r,l,o._offset-ia,s._offset+s._length,ia,ia,"s","w"),di(r,l,o._offset+o._length,s._offset+s._length,ia,ia,"s","e"))}if(r._context.showAxisDragHandles){if(i===o._mainSubplot){var f=o._mainLinePosition;o.side==="top"&&(f-=ia),di(r,l,o._offset+o._length*.1,f,o._length*.8,ia,"","ew"),di(r,l,o._offset,f,o._length*.1,ia,"","w"),di(r,l,o._offset+o._length*.9,f,o._length*.1,ia,"","e")}if(i===s._mainSubplot){var v=s._mainLinePosition;s.side!=="right"&&(v-=ia),di(r,l,v,s._offset+s._length*.1,ia,s._length*.8,"ns",""),di(r,l,v,s._offset+s._length*.9,ia,s._length*.1,"s",""),di(r,l,v,s._offset,ia,s._length*.1,"n","")}}});var n=t._hoverlayer.node();n.onmousemove=function(i){i.target=r._fullLayout._lasthover,dh.hover(r,i,t._hoversubplot)},n.onclick=function(i){i.target=r._fullLayout._lasthover,dh.click(r,i)},n.onmousedown=function(i){r._fullLayout._lasthover.onmousedown(i)},ph.updateFx(r)}};ph.updateFx=function(e){var r=e._fullLayout,t=r.dragmode==="pan"?"move":"crosshair";X$(r._draggers,t)}});var TC=X((Fce,wC)=>{"use strict";var xC=_r();wC.exports=function(r){for(var t=xC.layoutArrayContainers,a=xC.layoutArrayRegexes,n=r.split("[")[0],i,l,o=0;o{"use strict";var Z$=yo(),fg=ev(),Cc=_l(),J$=jp().sorterAsc,cg=_r();Sc.containerArrayMatch=TC();var K$=Sc.isAddVal=function(r){return r==="add"||Z$(r)},MC=Sc.isRemoveVal=function(r){return r===null||r==="remove"};Sc.applyContainerArrayChanges=function(r,t,a,n,i){var l=t.astr,o=cg.getComponentMethod(l,"supplyLayoutDefaults"),s=cg.getComponentMethod(l,"draw"),u=cg.getComponentMethod(l,"drawOne"),f=n.replot||n.recalc||o===fg||s===fg,v=r.layout,h=r._fullLayout;if(a[""]){Object.keys(a).length>1&&Cc.warn("Full array edits are incompatible with other edits",l);var d=a[""][""];if(MC(d))t.set(null);else if(Array.isArray(d))t.set(d);else return Cc.warn("Unrecognized full array edit value",l,d),!0;return f?!1:(o(v,h),s(r),!0)}var m=Object.keys(a).map(Number).sort(J$),y=t.get(),g=y||[],x=i(h,l).get(),T=[],M=-1,b=g.length,w,S,k,q,D,P,z,F;for(w=0;wg.length-(z?0:1)){Cc.warn("index out of range",l,k);continue}if(P!==void 0)D.length>1&&Cc.warn("Insertion & removal are incompatible with edits to the same index.",l,k),MC(P)?T.push(k):z?(P==="add"&&(P={}),g.splice(k,0,P),x&&x.splice(k,0,{})):Cc.warn("Unrecognized full object edit value",l,k,P),M===-1&&(M=k);else for(S=0;S=0;w--)g.splice(T[w],1),x&&x.splice(T[w],1);if(g.length?y||t.set(g):t.set(null),f)return!1;if(o(v,h),u!==fg){var I;if(M===-1)I=m;else{for(b=Math.max(g.length,b),I=[],w=0;w=M));w++)I.push(k);for(w=M;w{"use strict";var LC=Br(),qC=_r(),Ua=Ne(),Lc=Ut(),DC=$t(),qc=DC.cleanId,$$=DC.getFromTrace,vg=qC.traceIs,Q$=["x","y","z"];Bn.clearPromiseQueue=function(e){Array.isArray(e._promises)&&e._promises.length>0&&Ua.log("Clearing previous rejected promises from queue."),e._promises=[]};Bn.cleanLayout=function(e){var r,t;e||(e={}),e.xaxis1&&(e.xaxis||(e.xaxis=e.xaxis1),delete e.xaxis1),e.yaxis1&&(e.yaxis||(e.yaxis=e.yaxis1),delete e.yaxis1),e.scene1&&(e.scene||(e.scene=e.scene1),delete e.scene1);var a=(Lc.subplotsRegistry.cartesian||{}).attrRegex,n=(Lc.subplotsRegistry.polar||{}).attrRegex,i=(Lc.subplotsRegistry.ternary||{}).attrRegex,l=(Lc.subplotsRegistry.gl3d||{}).attrRegex,o=Object.keys(e);for(r=0;r3?(g.x=1.02,g.xanchor="left"):g.x<-2&&(g.x=-.02,g.xanchor="right"),g.y>3?(g.y=1.02,g.yanchor="bottom"):g.y<-2&&(g.y=-.02,g.yanchor="top")),e.dragmode==="rotate"&&(e.dragmode="orbit"),e.template&&e.template.layout&&Bn.cleanLayout(e.template.layout),e};function qu(e,r,t=!1){var a=e[r],n=r.charAt(0);t&&Array.isArray(a)||a&&a!=="paper"&&(e[r]=qc(a,n,!0))}Bn.cleanData=function(e){for(var r=0;r0)return e.slice(0,r)}Bn.hasParent=function(e,r){for(var t=SC(r);t;){if(t in e)return!0;t=SC(t)}return!1};Bn.clearAxisTypes=function(e,r,t){for(var a=0;a{let t=(...a)=>a.every(n=>Ua.isPlainObject(n))||a.every(n=>Array.isArray(n));if([e,r].every(a=>Array.isArray(a))){if(e.length!==r.length)return!1;for(let a=0;aUa.isPlainObject(a))){if(Object.keys(e).length!==Object.keys(r).length)return!1;for(let a in e){if(a.startsWith("_"))continue;let n=e[a],i=r[a];if(n!==i&&!(t(n,i)?hg(n,i):!1))return!1}return!0}return!1};Bn.collectionsAreEqual=hg});var bg=X(Yr=>{"use strict";var bh=Nr(),rQ=Br(),tQ=dm(),tr=Ne(),ft=tr.nestedProperty,mg=Ef(),$a=b7(),Yn=_r(),kh=tu(),wr=Ut(),Va=mt(),aQ=Fy(),nQ=oi(),dg=at(),iQ=Er(),lQ=ug().initInteractions,oQ=Eo(),sQ=Lu().clearOutline,FC=_o().dfltConfig,yh=AC(),It=EC(),et=Q1(),Xl=Ci(),uQ=ta().AX_NAME_PATTERN,pg=0,PC=5;function fQ(e,r,t,a){var n;if(e=tr.getGraphDiv(e),mg.init(e),tr.isPlainObject(r)){var i=r;r=i.data,t=i.layout,a=i.config,n=i.frames}var l=mg.triggerHandler(e,"plotly_beforeplot",[r,t,a]);if(l===!1)return Promise.reject();!r&&!t&&!tr.isPlotDiv(e)&&tr.warn("Calling _doPlot as if redrawing but this container doesn't yet have a plot.",e);function o(){if(n)return Yr.addFrames(e,n)}HC(e,a),t||(t={}),bh.select(e).classed("js-plotly-plot",!0),dg.makeTester(),Array.isArray(e._promises)||(e._promises=[]);var s=(e.data||[]).length===0&&Array.isArray(r);Array.isArray(r)&&(It.cleanData(r),s?e.data=r:e.data.push.apply(e.data,r),e.empty=!1),(!e.layout||s)&&(e.layout=It.cleanLayout(t)),wr.supplyDefaults(e);var u=e._fullLayout,f=u._has("cartesian");u._replotting=!0,(s||u._shouldCreateBgLayer)&&(PQ(e),u._shouldCreateBgLayer&&delete u._shouldCreateBgLayer),dg.initGradients(e),dg.initPatterns(e),s&&Va.saveShowSpikeInitial(e);var v=!e.calcdata||e.calcdata.length!==(e._fullData||[]).length;v&&wr.doCalcdata(e);for(var h=0;h=e.data.length||n<-e.data.length)throw new Error(t+" must be valid indices for gd.data.");if(r.indexOf(n,a+1)>-1||n>=0&&r.indexOf(-e.data.length+n)>-1||n<0&&r.indexOf(e.data.length+n)>-1)throw new Error("each index in "+t+" must be unique.")}}function OC(e,r,t){if(!Array.isArray(e.data))throw new Error("gd.data must be an array.");if(typeof r=="undefined")throw new Error("currentIndices is a required argument.");if(Array.isArray(r)||(r=[r]),xh(e,r,"currentIndices"),typeof t!="undefined"&&!Array.isArray(t)&&(t=[t]),typeof t!="undefined"&&xh(e,t,"newIndices"),typeof t!="undefined"&&r.length!==t.length)throw new Error("current and new indices must be of equal length.")}function pQ(e,r,t){var a,n;if(!Array.isArray(e.data))throw new Error("gd.data must be an array.");if(typeof r=="undefined")throw new Error("traces must be defined.");for(Array.isArray(r)||(r=[r]),a=0;a=0&&f=0&&f0&&typeof q.parts[z]!="string";)z--;var F=q.parts[z],I=q.parts[z-1]+"."+F,H=q.parts.slice(0,z).join("."),O=ft(e.layout,H).get(),G=ft(a,H).get(),Y=q.get();if(D!==void 0){x[k]=D,T[k]=F==="reverse"?D:sl(Y);var B=kh.getLayoutValObject(a,q.parts);if(B&&B.impliedEdits&&D!==null)for(var V in B.impliedEdits)M(tr.relativeAttr(k,V),B.impliedEdits[V]);if(["width","height"].indexOf(k)!==-1)if(D){M("autosize",null);var J=k==="height"?"width":"height";M(J,a[J])}else a[k]=e._initialAutoSize[k];else if(k==="autosize")M("width",D?null:a.width),M("height",D?null:a.height);else if(I.match(JC))S(I),ft(a,H+"._inputRange").set(null);else if(I.match(KC)){S(I),ft(a,H+"._inputRange").set(null);var Z=ft(a,H).get();Z._inputDomain&&(Z._input.domain=Z._inputDomain.slice())}else I.match(bQ)&&ft(a,H+"._inputDomain").set(null);if(F==="type"){w=O;var re=G.type==="linear"&&D==="log",se=G.type==="log"&&D==="linear";if(re||se){if(!w||!w.range)M(H+".autorange",!0);else if(G.autorange)re&&(w.range=w.range[1]>w.range[0]?[1,2]:[2,1]);else{var ve=w.range[0],xe=w.range[1];re?(ve<=0&&xe<=0&&M(H+".autorange",!0),ve<=0?ve=xe/1e6:xe<=0&&(xe=ve/1e6),M(H+".range[0]",Math.log(ve)/Math.LN10),M(H+".range[1]",Math.log(xe)/Math.LN10)):(M(H+".range[0]",Math.pow(10,ve)),M(H+".range[1]",Math.pow(10,xe)))}Array.isArray(a._subplots.polar)&&a._subplots.polar.length&&a[q.parts[0]]&&q.parts[1]==="radialaxis"&&delete a[q.parts[0]]._subplot.viewInitial["radialaxis.range"],Yn.getComponentMethod("annotations","convertCoords")(e,G,D,M),Yn.getComponentMethod("images","convertCoords")(e,G,D,M)}else M(H+".autorange",!0),M(H+".range",null);ft(a,H+"._inputRange").set(null)}else if(F.match(uQ)){var Q=ft(a,k).get(),we=(D||{}).type;(!we||we==="-")&&(we="linear"),Yn.getComponentMethod("annotations","convertCoords")(e,Q,we,M),Yn.getComponentMethod("images","convertCoords")(e,Q,we,M)}var fe=yh.containerArrayMatch(k);if(fe){f=fe.array,v=fe.index;var ge=fe.property,_e=B||{editType:"calc"};v!==""&&ge===""&&(yh.isAddVal(D)?T[k]=null:yh.isRemoveVal(D)?T[k]=(ft(t,f).get()||[])[v]:tr.warn("unrecognized full object value",r)),Xl.update(g,_e),u[f]||(u[f]={});var ce=u[f][v];ce||(ce=u[f][v]={}),ce[ge]=D,delete r[k]}else F==="reverse"?(O.range?O.range.reverse():(M(H+".autorange",!0),O.range=[1,0]),G.autorange?g.calc=!0:g.plot=!0):(k==="dragmode"&&(D===!1&&Y!==!1||D!==!1&&Y===!1)||a._has("scatter-like")&&a._has("regl")&&k==="dragmode"&&(D==="lasso"||D==="select")&&!(Y==="lasso"||Y==="select")?g.plot=!0:B?Xl.update(g,B):g.calc=!0,q.set(D))}}for(f in u){var Me=yh.applyContainerArrayChanges(e,i(t,f),u[f],g,i);Me||(g.plot=!0)}for(var Re in b){w=Va.getFromId(e,Re);var Te=w&&w._constraintGroup;if(Te){g.calc=!0;for(var Pe in Te)b[Pe]||(Va.getFromId(e,Pe)._constraintShrinkable=!0)}}(QC(e)||r.height||r.width)&&(g.plot=!0);var Le=a.shapes;for(v=0;v1;)if(a.pop(),t=ft(r,a.join(".")+".uirevision").get(),t!==void 0)return t;return r.uirevision}function wQ(e,r){for(var t=0;t[H,e._ev.listeners(H)]);i=Yr.newPlot(e,r,t,a).then(()=>{for(let[H,O]of I)O.forEach(G=>e.on(H,G));return Yr.react(e,r,t,a)})}else{e.data=r||[],It.cleanData(e.data),e.layout=t||{},It.cleanLayout(e.layout),MQ(e.data,e.layout,o,s),wr.supplyDefaults(e,{skipUpdateCalc:!0});var v=e._fullData,h=e._fullLayout,d=h.datarevision===void 0,m=h.transition,y=CQ(e,s,h,d,m),g=y.newDataRevision,x=kQ(e,o,v,d,m,g);if(QC(e)&&(y.layoutReplot=!0),x.calc||y.calc){e.calcdata=void 0;for(var T=Object.getOwnPropertyNames(h),M=0;M(f||e.emit("plotly_react",{config:a,data:r,layout:t}),e))}function kQ(e,r,t,a,n,i){var l=r.length===t.length;if(!n&&!l)return{fullReplot:!0,calc:!0};var o=Xl.traceFlags();o.arrays={},o.nChanges=0,o.nChangesAnim=0;var s,u;function f(d){var m=kh.getTraceValObject(u,d);return!u._module.animatable&&m.anim&&(m.anim=!1),m}var v={getValObject:f,flags:o,immutable:a,transition:n,newDataRevision:i,gd:e},h={};for(s=0;s=n.length?n[0]:n[u]:n}function o(u){return Array.isArray(i)?u>=i.length?i[0]:i[u]:i}function s(u,f){var v=0;return function(){if(u&&++v===f)return u()}}return new Promise(function(u,f){function v(){if(a._frameQueue.length!==0){for(;a._frameQueue.length;){var F=a._frameQueue.pop();F.onInterrupt&&F.onInterrupt()}e.emit("plotly_animationinterrupted",[])}}function h(F){if(F.length!==0){for(var I=0;Ia._timeToNext&&m()};F()}var g=0;function x(F){return Array.isArray(n)?g>=n.length?F.transitionOpts=n[g]:F.transitionOpts=n[0]:F.transitionOpts=n,g++,F}var T,M,b=[],w=r==null,S=Array.isArray(r),k=!w&&!S&&tr.isPlainObject(r);if(k)b.push({type:"object",data:x(tr.extendFlat({},r))});else if(w||["string","number"].indexOf(typeof r)!==-1)for(T=0;T0&&PP)&&z.push(M);b=z}}b.length>0?h(b):(e.emit("plotly_animated"),u())})}function LQ(e,r,t){if(e=tr.getGraphDiv(e),r==null)return Promise.resolve();if(!tr.isPlotDiv(e))throw new Error("This element is not a Plotly plot: "+e+". It's likely that you've failed to create a plot before adding frames. For more details, see https://plotly.com/javascript/animations/");var a,n,i,l,o=e._transitionData._frames,s=e._transitionData._frameHash;if(!Array.isArray(r))throw new Error("addFrames failure: frameList must be an Array of frame definitions"+r);var u=o.length+r.length*2,f=[],v={};for(a=r.length-1;a>=0;a--)if(tr.isPlainObject(r[a])){var h=r[a].name,d=(s[h]||v[h]||{}).name,m=r[a].name,y=s[d]||v[d];d&&m&&typeof m=="number"&&y&&pgq.index?-1:k.index=0;a--){if(n=f[a].frame,typeof n.name=="number"&&tr.warn("Warning: addFrames accepts frames with numeric names, but the numbers areimplicitly cast to strings"),!n.name)for(;s[n.name="frame "+e._transitionData._counter++];);if(s[n.name]){for(i=0;i=0;t--)a=r[t],i.push({type:"delete",index:a}),l.unshift({type:"insert",index:a,value:n[a]});var o=wr.modifyFrames,s=wr.modifyFrames,u=[e,l],f=[e,i];return $a&&$a.add(e,o,u,s,f),wr.modifyFrames(e,i)}function DQ(e){e=tr.getGraphDiv(e);var r=e._fullLayout||{},t=e._fullData||[];return wr.cleanPlot([],{},t,r),wr.purge(e),mg.purge(e),r._container&&r._container.remove(),delete e._context,e}function EQ(e){var r=e._fullLayout,t=e.getBoundingClientRect();if(!tr.equalDomRects(t,r._lastBBox)){var a=r._invTransform=tr.inverseTransformMatrix(tr.getFullTransformMatrix(e));r._invScaleX=Math.sqrt(a[0][0]*a[0][0]+a[0][1]*a[0][1]+a[0][2]*a[0][2]),r._invScaleY=Math.sqrt(a[1][0]*a[1][0]+a[1][1]*a[1][1]+a[1][2]*a[1][2]),r._lastBBox=t}}function PQ(e){var r=bh.select(e),t=e._fullLayout;if(t._calcInverseTransform=EQ,t._calcInverseTransform(e),t._container=r.selectAll(".plot-container").data([0]),t._container.enter().insert("div",":first-child").classed("plot-container",!0).classed("plotly",!0).style({width:"100%",height:"100%"}),t._paperdiv=t._container.selectAll(".svg-container").data([0]),t._paperdiv.enter().append("div").classed("user-select-none",!0).classed("svg-container",!0).style("position","relative"),t._glcontainer=t._paperdiv.selectAll(".gl-container").data([{}]),t._glcontainer.enter().append("div").classed("gl-container",!0),t._paperdiv.selectAll(".main-svg").remove(),t._paperdiv.select(".modebar-container").remove(),t._paper=t._paperdiv.insert("svg",":first-child").classed("main-svg",!0),t._toppaper=t._paperdiv.append("svg").classed("main-svg",!0),t._modebardiv=t._paperdiv.append("div"),delete t._modeBar,t._hoverpaper=t._paperdiv.append("svg").classed("main-svg",!0),!t._uid){var a={};bh.selectAll("defs").each(function(){this.id&&(a[this.id.split("-")[1]]=1)}),t._uid=tr.randstr(a)}t._paperdiv.selectAll(".main-svg").attr(oQ.svgAttrs),t._defs=t._paper.append("defs").attr("id","defs-"+t._uid),t._clips=t._defs.append("g").classed("clips",!0),t._topdefs=t._toppaper.append("defs").attr("id","topdefs-"+t._uid),t._topclips=t._topdefs.append("g").classed("clips",!0),t._bgLayer=t._paper.append("g").classed("bglayer",!0),t._draggers=t._paper.append("g").classed("draglayer",!0);var n=t._paper.append("g").classed("layer-below",!0);t._imageLowerLayer=n.append("g").classed("imagelayer",!0),t._shapeLowerLayer=n.append("g").classed("shapelayer",!0),t._cartesianlayer=t._paper.append("g").classed("cartesianlayer",!0),t._polarlayer=t._paper.append("g").classed("polarlayer",!0),t._smithlayer=t._paper.append("g").classed("smithlayer",!0),t._ternarylayer=t._paper.append("g").classed("ternarylayer",!0),t._geolayer=t._paper.append("g").classed("geolayer",!0),t._funnelarealayer=t._paper.append("g").classed("funnelarealayer",!0),t._pielayer=t._paper.append("g").classed("pielayer",!0),t._iciclelayer=t._paper.append("g").classed("iciclelayer",!0),t._treemaplayer=t._paper.append("g").classed("treemaplayer",!0),t._sunburstlayer=t._paper.append("g").classed("sunburstlayer",!0),t._indicatorlayer=t._toppaper.append("g").classed("indicatorlayer",!0),t._glimages=t._paper.append("g").classed("glimages",!0);var i=t._toppaper.append("g").classed("layer-above",!0);t._imageUpperLayer=i.append("g").classed("imagelayer",!0),t._shapeUpperLayer=i.append("g").classed("shapelayer",!0),t._selectionLayer=t._toppaper.append("g").classed("selectionlayer",!0),t._infolayer=t._toppaper.append("g").classed("infolayer",!0),t._menulayer=t._toppaper.append("g").classed("menulayer",!0),t._zoomlayer=t._toppaper.append("g").classed("zoomlayer",!0),t._hoverlayer=t._hoverpaper.append("g").classed("hoverlayer",!0),t._modebardiv.classed("modebar-container",!0).style("position","absolute").style("top","0px").style("right","0px"),e.emit("plotly_framework")}Yr.animate=SQ;Yr.addFrames=LQ;Yr.deleteFrames=qQ;Yr.addTraces=GC;Yr.deleteTraces=WC;Yr.extendTraces=UC;Yr.moveTraces=yg;Yr.prependTraces=VC;Yr.newPlot=dQ;Yr._doPlot=fQ;Yr.purge=DQ;Yr.react=AQ;Yr.redraw=hQ;Yr.relayout=Dc;Yr.restyle=wh;Yr.setPlotConfig=cQ;Yr.update=Mh;Yr._guiRelayout=gg(Dc);Yr._guiRestyle=gg(wh);Yr._guiUpdate=gg(Mh);Yr._storeDirectGUIEdit=gQ});var jo=X(Un=>{"use strict";var RQ=_r();Un.getDelay=function(e){return e._has&&(e._has("gl3d")||e._has("map"))?500:0};Un.getRedrawFunc=function(e){return function(){RQ.getComponentMethod("colorbar","draw")(e)}};Un.encodeSVG=function(e){return"data:image/svg+xml,"+encodeURIComponent(e)};Un.encodeJSON=function(e){return"data:application/json,"+encodeURIComponent(e)};var jC=window.URL||window.webkitURL;Un.createObjectURL=function(e){return jC.createObjectURL(e)};Un.revokeObjectURL=function(e){return jC.revokeObjectURL(e)};Un.createBlob=function(e,r){if(r==="svg")return new window.Blob([e],{type:"image/svg+xml;charset=utf-8"});if(r==="full-json")return new window.Blob([e],{type:"application/json;charset=utf-8"});var t=zQ(window.atob(e));return new window.Blob([t],{type:"image/"+r})};Un.octetStream=function(e){document.location.href="data:application/octet-stream"+e};Un.getPlotTitle=function(e){var a,n,i,l;var r=e._fullLayout;if(r){var t=(a=r.title)==null?void 0:a.text;return t===((n=r._dfltTitle)==null?void 0:n.plot)?void 0:t}return(l=(i=e.layout)==null?void 0:i.title)==null?void 0:l.text};Un.getPlotSubtitle=function(e){var a,n,i,l,o,s;var r=e._fullLayout;if(r){var t=(n=(a=r.title)==null?void 0:a.subtitle)==null?void 0:n.text;return t===((i=r._dfltTitle)==null?void 0:i.subtitle)?void 0:t}return(s=(o=(l=e.layout)==null?void 0:l.title)==null?void 0:o.subtitle)==null?void 0:s.text};function zQ(e){for(var r=e.length,t=new ArrayBuffer(r),a=new Uint8Array(t),n=0;n{"use strict";var xg=Nr(),Yce=Ne(),NQ=at(),FQ=Er(),Uce=Eo(),_g=/"/g,es="TOBESTRIPPED",IQ=new RegExp(`("${es})|(${es}")|("${es})|(${es}")`,"g"),HQ=["&","&","&",""",""",""","'","'","'"],OQ=["<","<","<"],BQ=[">",">",">"];function YQ(e){let r=xg.select("body").append("div").style({display:"none"}).html(""),t=e.replace(/(&[^;]*;)/gi,a=>{let n=a.toLowerCase();return HQ.includes(n)?a:OQ.includes(n)?"<":BQ.includes(n)?">":a.includes("<")||a.includes(">")?"":r.html(a).text()});return r.remove(),t}function UQ(e){return e.replace(/&(?!\w+;|\#[0-9]+;| \#x[0-9A-F]+;)/g,"&")}eS.exports=function(r,t,a){var n=r._fullLayout,i=n._paper,l=n._toppaper,o=n.width,s=n.height,u;i.insert("rect",":first-child").call(NQ.setRect,0,0,o,s).call(FQ.fill,n.paper_bgcolor);var f=n._basePlotModules||[];for(u=0;u{"use strict";var VQ=Ne(),GQ=xv().EventEmitter,Pc=jo();function WQ(e){var r=e.emitter||new GQ,t=new Promise(function(a,n){var i=window.Image,l=e.svg,o=e.format||"png",s=e.canvas,u=e.scale||1,f=e.width||300,v=e.height||150,h=u*f,d=u*v,m=s.getContext("2d",{willReadFrequently:!0}),y=new i,g,x;o==="svg"||VQ.isSafari()?x=Pc.encodeSVG(l):(g=Pc.createBlob(l,"svg"),x=Pc.createObjectURL(g)),s.width=h,s.height=d,y.onload=function(){var T;switch(g=null,Pc.revokeObjectURL(x),o!=="svg"&&m.drawImage(y,0,0,h,d),o){case"jpeg":T=s.toDataURL("image/jpeg");break;case"png":T=s.toDataURL("image/png");break;case"webp":T=s.toDataURL("image/webp");break;case"svg":T=x;break;default:var M="Image format is not jpeg, png, svg or webp.";if(n(new Error(M)),!e.promise)return r.emit("error",M)}a(T),e.promise||r.emit("success",T)},y.onerror=function(T){if(g=null,Pc.revokeObjectURL(x),n(T),!e.promise)return r.emit("error",T)},y.src=x});return e.promise?t:r}rS.exports=WQ});var Tg=X((Wce,nS)=>{"use strict";var tS=Br(),aS=bg(),XQ=Ut(),Vi=Ne(),Rc=jo(),ZQ=Sh(),JQ=Lh(),KQ=df().version,wg={format:{valType:"enumerated",values:["png","jpeg","webp","svg","full-json"],dflt:"png"},width:{valType:"number",min:1},height:{valType:"number",min:1},scale:{valType:"number",min:0,dflt:1},setBackground:{valType:"any",dflt:!1},imageDataOnly:{valType:"boolean",dflt:!1}};function $Q(e,r){r=r||{};var t,a,n,i;Vi.isPlainObject(e)?(t=e.data||[],a=e.layout||{},n=e.config||{},i={}):(e=Vi.getGraphDiv(e),t=Vi.extendDeep([],e.data),a=Vi.extendDeep({},e.layout),n=e._context,i=e._fullLayout||{});function l(S){return!(S in r)||Vi.validate(r[S],wg[S])}if(!l("width")&&r.width!==null||!l("height")&&r.height!==null)throw new Error("Height and width should be pixel values.");if(!l("format"))throw new Error("Export format is not "+Vi.join2(wg.format.values,", "," or ")+".");var o={};function s(S,k){return Vi.coerce(r,o,wg,S,k)}var u=s("format"),f=s("width"),v=s("height"),h=s("scale"),d=s("setBackground"),m=s("imageDataOnly"),y=document.createElement("div");y.style.position="absolute",y.style.left="-5000px",document.body.appendChild(y);var g=Vi.extendFlat({},a);f?g.width=f:r.width===null&&tS(i.width)&&(g.width=i.width),v?g.height=v:r.height===null&&tS(i.height)&&(g.height=i.height);var x=Vi.extendFlat({},n,{_exportedPlot:!0,staticPlot:!0,setBackground:d}),T=Rc.getRedrawFunc(y);function M(){return new Promise(function(S){setTimeout(S,Rc.getDelay(y._fullLayout))})}function b(){return new Promise(function(S,k){var q=ZQ(y,u,h),D=y._fullLayout.width,P=y._fullLayout.height;function z(){aS.purge(y),document.body.removeChild(y)}if(u==="full-json"){var F=XQ.graphJson(y,!1,"object",!0,!0);return F.version=KQ,F=JSON.stringify(F),z(),S(m?F:Rc.encodeJSON(F))}if(z(),u==="svg")return S(m?q:Rc.encodeSVG(q));var I=document.createElement("canvas");I.id=Vi.randstr(),JQ({format:u,width:D,height:P,scale:h,canvas:I,svg:q,promise:!0}).then(S).catch(k)})}function w(S){return m?S.replace(Rc.IMAGE_URL_PREFIX,""):S}return new Promise(function(S,k){aS.newPlot(y,t,g,x).then(T).then(M).then(b).then(function(q){S(w(q))}).catch(function(q){k(q)})})}nS.exports=$Q});var oS=X((Xce,lS)=>{"use strict";var Vn=Ne(),QQ=Ut(),jQ=tu(),ej=_o().dfltConfig,pi=Vn.isPlainObject,ts=Array.isArray,Mg=Vn.isArrayOrTypedArray;lS.exports=function(r,t){r===void 0&&(r=[]),t===void 0&&(t={});var a=jQ.get(),n=[],i={_context:Vn.extendFlat({},ej)},l,o;ts(r)?(i.data=Vn.extendDeep([],r),l=r):(i.data=[],l=[],n.push(la("array","data"))),pi(t)?(i.layout=Vn.extendDeep({},t),o=t):(i.layout={},o={},arguments.length>1&&n.push(la("object","layout"))),QQ.supplyDefaults(i);for(var s=i._fullData,u=l.length,f=0;fv.length&&a.push(la("unused",n,u.concat(v.length)));var x=v.length,T=Array.isArray(g);T&&(x=Math.min(x,g.length));var M,b,w,S,k;if(h.dimensions===2)for(b=0;bv[b].length&&a.push(la("unused",n,u.concat(b,v[b].length)));var q=v[b].length;for(M=0;M<(T?Math.min(q,g[b].length):q);M++)w=T?g[b][M]:g,S=f[b][M],k=v[b][M],Vn.validate(S,w)?k!==S&&k!==+S&&a.push(la("dynamic",n,u.concat(b,M),S,k)):a.push(la("value",n,u.concat(b,M),S))}else a.push(la("array",n,u.concat(b),f[b]));else for(b=0;b{"use strict";var oj=Ne(),Dh=jo();function sj(e,r,t){var a=document.createElement("a"),n="download"in a,i=new Promise(function(l,o){var s,u;if(n)return s=Dh.createBlob(e,t),u=Dh.createObjectURL(s),a.href=u,a.download=r,document.body.appendChild(a),a.click(),document.body.removeChild(a),Dh.revokeObjectURL(u),s=null,l(r);if(oj.isSafari()){var f=t==="svg"?",":";base64,";return Dh.octetStream(f+encodeURIComponent(e)),l(r)}o(new Error("download error"))});return i}sS.exports=sj});var Ag=X((Jce,hS)=>{"use strict";var Eh=Ne(),fS=da(),uj=Tg(),fj=uS(),cS=jo(),vS=40;function cj(e,r){var t;return Eh.isPlainObject(e)||(t=Eh.getGraphDiv(e)),r=r||{},r.format=r.format||"png",r.width=r.width||null,r.height=r.height||null,r.imageDataOnly=!0,new Promise(function(a,n){t&&t._snapshotInProgress&&n(new Error("Image capture already in progress.")),t&&(t._snapshotInProgress=!0);var i=uj(e,r),l=r.filename||e.fn;if(!l){let s=cS.getPlotTitle(e);if(s&&!fS.matchTex(s))l=Eh.slugify(s,vS);else{let u=cS.getPlotSubtitle(e);u&&!fS.matchTex(u)&&(l=Eh.slugify(u,vS))}}var o=l||"plot-image";o+="."+r.format.replace("-","."),i.then(function(s){return t&&(t._snapshotInProgress=!1),fj(s,o,r.format)}).then(function(s){a(s)}).catch(function(s){t&&(t._snapshotInProgress=!1),n(s)})})}hS.exports=cj});var gS=X(kg=>{"use strict";var fn=Ne(),cn=fn.isPlainObject,dS=tu(),pS=Ut(),vj=ri(),mS=vt(),yS=_o().dfltConfig;kg.makeTemplate=function(e){e=fn.isPlainObject(e)?e:fn.getGraphDiv(e),e=fn.extendDeep({_context:yS},{data:e.data,layout:e.layout}),pS.supplyDefaults(e);var r=e.data||[],t=e.layout||{};t._basePlotModules=e._fullLayout._basePlotModules,t._modules=e._fullLayout._modules;var a={data:{},layout:{}};r.forEach(function(d){var m={};zc(d,m,dj.bind(null,d));var y=fn.coerce(d,{},vj,"type"),g=a.data[y];g||(g=a.data[y]=[]),g.push(m)}),zc(t,a.layout,hj.bind(null,t)),delete a.layout.template;var n=t.template;if(cn(n)){var i=n.layout,l,o,s,u,f,v;cn(i)&&Ph(i,a.layout);var h=n.data;if(cn(h)){for(o in a.data)if(s=h[o],Array.isArray(s)){for(f=a.data[o],v=f.length,u=s.length,l=0;lx?l.push({code:"unused",traceType:d,templateCount:g,dataCount:x}):x>g&&l.push({code:"reused",traceType:d,templateCount:g,dataCount:x})}}function T(M,b){for(var w in M)if(w.charAt(0)!=="_"){var S=M[w],k=Gn(M,w,b);cn(S)?(Array.isArray(M)&&S._template===!1&&S.templateitemname&&l.push({code:"missing",path:k,templateitemname:S.templateitemname}),T(S,k)):Array.isArray(S)&&pj(S)&&T(S,k)}}if(T({data:s,layout:o},""),l.length)return l.map(mj)};function pj(e){for(var r=0;r{"use strict";var ea=bg();gt._doPlot=ea._doPlot;gt.newPlot=ea.newPlot;gt.restyle=ea.restyle;gt.relayout=ea.relayout;gt.redraw=ea.redraw;gt.update=ea.update;gt._guiRestyle=ea._guiRestyle;gt._guiRelayout=ea._guiRelayout;gt._guiUpdate=ea._guiUpdate;gt._storeDirectGUIEdit=ea._storeDirectGUIEdit;gt.react=ea.react;gt.extendTraces=ea.extendTraces;gt.prependTraces=ea.prependTraces;gt.addTraces=ea.addTraces;gt.deleteTraces=ea.deleteTraces;gt.moveTraces=ea.moveTraces;gt.purge=ea.purge;gt.addFrames=ea.addFrames;gt.deleteFrames=ea.deleteFrames;gt.animate=ea.animate;gt.setPlotConfig=ea.setPlotConfig;var yj=Mf().getGraphDiv,gj=U1().eraseActiveShape;gt.deleteActiveShape=function(e){return gj(yj(e))};gt.toImage=Tg();gt.validate=oS();gt.downloadImage=Ag();var bS=gS();gt.makeTemplate=bS.makeTemplate;gt.validateTemplate=bS.validateTemplate});var Sg=X((Qce,xS)=>{"use strict";var Cg=Ne(),bj=_r();xS.exports=function(r,t,a,n){var i=n("x"),l=n("y"),o,s=bj.getComponentMethod("calendars","handleTraceDefaults");if(s(r,t,["x","y"],a),i){var u=Cg.minRowLength(i);l?o=Math.min(u,Cg.minRowLength(l)):(o=u,n("y0"),n("dy"))}else{if(!l)return 0;o=Cg.minRowLength(l),n("x0"),n("dx")}return t._length=o,o}});var Lg=X((jce,MS)=>{"use strict";var wS=Ne().dateTick0,_j=Zt(),xj=_j.ONEWEEK;function TS(e,r){return e%xj===0?wS(r,1):wS(r,0)}MS.exports=function(r,t,a,n,i){if(i||(i={x:!0,y:!0}),i.x){var l=n("xperiod");l&&(n("xperiod0",TS(l,t.xcalendar)),n("xperiodalignment"))}if(i.y){var o=n("yperiod");o&&(n("yperiod0",TS(o,t.ycalendar)),n("yperiodalignment"))}}});var CS=X((e0e,kS)=>{"use strict";var AS=["orientation","groupnorm","stackgaps"];kS.exports=function(r,t,a,n){var i=a._scatterStackOpts,l=n("stackgroup");if(l){var o=t.xaxis+t.yaxis,s=i[o];s||(s=i[o]={});var u=s[l],f=!1;u?u.traces.push(t):(u=s[l]={traceIndices:[],traces:[t]},f=!0);for(var v={orientation:t.x&&!t.y?"h":"v"},h=0;h{"use strict";var SS=Er(),LS=En().hasColorscale,qS=ou(),wj=mn();DS.exports=function(r,t,a,n,i,l={}){var o=wj.isBubble(r),s=(r.line||{}).color,u;if(s&&(a=s),i("marker.symbol"),i("marker.opacity",o?.7:1),i("marker.size"),l.noAngle||(i("marker.angle"),l.noAngleRef||i("marker.angleref"),l.noStandOff||i("marker.standoff")),i("marker.color",a),LS(r,"marker")&&qS(r,t,n,i,{prefix:"marker.",cLetter:"c"}),l.noSelect||(i("selected.marker.color"),i("unselected.marker.color"),i("selected.marker.size"),i("unselected.marker.size")),l.noLine||(s&&!Array.isArray(s)&&t.marker.color!==s?u=s:o?u=SS.background:u=SS.defaultLine,i("marker.line.color",u),LS(r,"marker.line")&&qS(r,t,n,i,{prefix:"marker.line.",cLetter:"c"}),i("marker.line.width",o?1:0),l.noLineDash||i("marker.line.dash")),o&&(i("marker.sizeref"),i("marker.sizemin"),i("marker.sizemode")),l.gradient){var f=i("marker.gradient.type");f!=="none"&&i("marker.gradient.color")}}});var RS=X((t0e,PS)=>{"use strict";var Tj=Ne().isArrayOrTypedArray,Mj=En().hasColorscale,Aj=ou();PS.exports=function(r,t,a,n,i,l){l||(l={});var o=(r.marker||{}).color;if(o&&o._inputArray&&(o=o._inputArray),i("line.color",a),Mj(r,"line"))Aj(r,t,n,i,{prefix:"line.",cLetter:"c"});else{var s=(Tj(o)?!1:o)||a;i("line.color",s)}i("line.width"),l.noDash||i("line.dash"),l.backoff&&i("line.backoff")}});var NS=X((a0e,zS)=>{"use strict";zS.exports=function(r,t,a){var n=a("line.shape");n==="spline"&&a("line.smoothing")}});var IS=X((n0e,FS)=>{"use strict";var kj=Ne();FS.exports=function(e,r,t,a,n){n=n||{},a("textposition"),kj.coerceFont(a,"textfont",n.font||t.font,n),n.noSelect||(a("selected.textfont.color"),a("unselected.textfont.color"))}});var BS=X((i0e,OS)=>{"use strict";var zh=Er(),HS=Ne().isArrayOrTypedArray;function Cj(e){for(var r=zh.interpolate(e[0][1],e[1][1],.5),t=2;t{"use strict";var YS=Ne(),Sj=_r(),Lj=Qo(),qj=sh(),Du=mn(),Dj=Sg(),Ej=Lg(),Pj=CS(),Rj=ES(),zj=RS(),US=NS(),Nj=IS(),Fj=BS(),Ij=Ne().coercePattern;VS.exports=function(r,t,a,n){function i(d,m){return YS.coerce(r,t,Lj,d,m)}var l=Dj(r,t,n,i);if(l||(t.visible=!1),!!t.visible){Ej(r,t,n,i),i("xhoverformat"),i("yhoverformat"),i("zorder");var o=Pj(r,t,n,i);n.scattermode==="group"&&t.orientation===void 0&&i("orientation","v");var s=!o&&l{"use strict";var Hj=oc().getAxisGroup;WS.exports=function(r,t,a,n,i){var l=t.orientation,o=t[{v:"x",h:"y"}[l]+"axis"],s=Hj(a,o)+l,u=a._alignmentOpts||{},f=n("alignmentgroup"),v=u[s];v||(v=u[s]={});var h=v[f];h?h.traces.push(t):h=v[f]={traces:[t],alignmentIndex:Object.keys(v).length,offsetGroups:{}};var d=n("offsetgroup")||"",m=h.offsetGroups,y=m[d];t._offsetIndex=0,(i!=="group"||d)&&(y||(y=m[d]={offsetIndex:Object.keys(m).length}),t._offsetIndex=y.offsetIndex)}});var ZS=X((s0e,XS)=>{"use strict";var Oj=Ne(),Bj=qg(),Yj=Qo();XS.exports=function(r,t){var a,n,i,l=t.scattermode;function o(h){return Oj.coerce(n._input,n,Yj,h)}if(t.scattermode==="group")for(i=0;i=0;f--){var v=r[f];if(v.type==="scatter"&&v.xaxis===s.xaxis&&v.yaxis===s.yaxis){v.opacity=void 0;break}}}}}});var KS=X((u0e,JS)=>{"use strict";var Uj=Ne(),Vj=kv();JS.exports=function(e,r){function t(n,i){return Uj.coerce(e,r,Vj,n,i)}var a=r.barmode==="group";r.scattermode==="group"&&t("scattergap",a?r.bargap:.2)}});var Dg=X((f0e,QS)=>{"use strict";var Gj=Br(),$S=Ne(),Wj=$S.dateTime2ms,Nh=$S.incrementMonth,Xj=Zt(),Zj=Xj.ONEAVGMONTH;QS.exports=function(r,t,a,n){if(t.type!=="date")return{vals:n};var i=r[a+"periodalignment"];if(!i)return{vals:n};var l=r[a+"period"],o;if(Gj(l)){if(l=+l,l<=0)return{vals:n}}else if(typeof l=="string"&&l.charAt(0)==="M"){var s=+l.substring(1);if(s>0&&Math.round(s)===s)o=s;else return{vals:n}}for(var u=t.calendar,f=i==="start",v=i==="end",h=r[a+"period0"],d=Wj(h,u)||0,m=[],y=[],g=[],x=n.length,T=0;TM;)S=Nh(S,-o,u);for(;S<=M;)S=Nh(S,o,u);w=Nh(S,-o,u)}else{for(b=Math.round((M-d)/l),S=d+b*l;S>M;)S-=l;for(;S<=M;)S+=l;w=S-l}m[T]=f?w:v?S:(w+S)/2,y[T]=w,g[T]=S}return{vals:m,starts:y,ends:g}}});var rL=X((c0e,eL)=>{"use strict";var Eg=En().hasColorscale,Pg=Fv(),jS=mn();eL.exports=function(r,t){jS.hasLines(t)&&Eg(t,"line")&&Pg(r,t,{vals:t.line.color,containerStr:"line",cLetter:"c"}),jS.hasMarkers(t)&&(Eg(t,"marker")&&Pg(r,t,{vals:t.marker.color,containerStr:"marker",cLetter:"c"}),Eg(t,"marker.line")&&Pg(r,t,{vals:t.marker.line.color,containerStr:"marker.line",cLetter:"c"}))}});var Rg=X((v0e,tL)=>{"use strict";var kt=Ne();tL.exports=function(r,t){for(var a=0;a{"use strict";var aL=Ne();nL.exports=function(r,t){aL.isArrayOrTypedArray(t.selectedpoints)&&aL.tagSelected(r,t)}});var Og=X((d0e,cL)=>{"use strict";var iL=Br(),Fg=Ne(),Nc=mt(),lL=Dg(),Ng=Zt().BADNUM,Ig=mn(),Jj=rL(),Kj=Rg(),$j=zg();function Qj(e,r){var t=e._fullLayout,a=r._xA=Nc.getFromId(e,r.xaxis||"x","x"),n=r._yA=Nc.getFromId(e,r.yaxis||"y","y"),i=a.makeCalcdata(r,"x"),l=n.makeCalcdata(r,"y"),o=lL(r,a,"x",i),s=lL(r,n,"y",l),u=o.vals,f=s.vals,v=r._length,h=new Array(v),d=r.ids,m=Hg(r,t,a,n),y=!1,g,x,T,M,b,w;uL(t,r);var S="x",k="y",q;if(m)Fg.pushUnique(m.traceIndices,r.index),g=m.orientation==="v",g?(k="s",q="x"):(S="s",q="y"),b=m.stackgaps==="interpolate";else{var D=sL(r,v);oL(e,r,a,n,u,f,D)}var P=!!r.xperiodalignment,z=!!r.yperiodalignment;for(x=0;xx&&h[M].gap;)M--;for(w=h[M].s,T=h.length-1;T>M;T--)h[T].s=w;for(;x{"use strict";vL.exports=Fh;var jj=Ne().distinctVals;function Fh(e,r){this.traces=e,this.sepNegVal=r.sepNegVal,this.overlapNoMerge=r.overlapNoMerge;for(var t=1/0,a=r.posAxis._id.charAt(0),n=[],i=0;i{"use strict";dL.exports={TEXTPAD:3,eventDataKeys:["value","label"]}});var Wg=X((y0e,gL)=>{"use strict";var Wn=Br(),{isArrayOrTypedArray:ul}=Ne(),{BADNUM:Eu}=Zt(),eee=_r(),Ic=mt(),{getAxisGroup:ree}=oc(),Ih=hL(),{TEXTPAD:tee}=Fc(),{LINE_SPACING:aee}=La(),{BR_TAG_ALL:nee}=da();function iee(e,r){for(var t=r.xaxis,a=r.yaxis,n=e._fullLayout,i=e._fullData,l=e.calcdata,o=[],s=[],u=0;us+l||!Wn(o))}for(var f=0;fa(i))):a(e.text);let n=e.outsidetextfont.size*aee*t+tee;return{ppadplus:r.some(i=>i.s<0)?n:0,ppadminus:r.some(i=>i.s>=0)?n:0}}return{ppadplus:void 0,ppadminus:void 0}}function pee(e,r,t,a){for(var n=Pu(a),i=0;i{"use strict";var bL=Og(),_L=Wg().setGroupPositions;function yee(e,r){for(var t=r.xaxis,a=r.yaxis,n=e._fullLayout,i=e._fullData,l=e.calcdata,o=[],s=[],u=0;uD[f]&&f{"use strict";var bee=at(),kL=Zt(),Hc=kL.BADNUM,CL=kL.LOG_CLIP,TL=CL+.5,ML=CL-.5,Hh=Ne(),_ee=Hh.segmentsIntersect,AL=Hh.constrain,Zg=sh();SL.exports=function(r,t){var a=t.trace||{},n=t.xaxis,i=t.yaxis,l=n.type==="log",o=i.type==="log",s=n._length,u=i._length,f=t.backoff,v=a.marker,h=t.connectGaps,d=t.baseTolerance,m=t.shape,y=m==="linear",g=a.fill&&a.fill!=="none",x=[],T=Zg.minTolerance,M=r.length,b=new Array(M),w=0,S,k,q,D,P,z,F,I,H,O,G,Y,B,V,J,Z;function re(Ee){var ee=r[Ee];if(!ee)return!1;var ae=t.linearized?n.l2p(ee.x):n.c2p(ee.x),Ae=t.linearized?i.l2p(ee.y):i.c2p(ee.y);if(ae===Hc){if(l&&(ae=n.c2p(ee.x,!0)),ae===Hc)return!1;o&&Ae===Hc&&(ae*=Math.abs(n._m*u*(n._m>0?TL:ML)/(i._m*s*(i._m>0?TL:ML)))),ae*=1e3}if(Ae===Hc){if(o&&(Ae=i.c2p(ee.y,!0)),Ae===Hc)return!1;Ae*=1e3}return[ae,Ae]}function se(Ee,ee,ae,Ae){var Se=ae-Ee,$e=Ae-ee,fr=.5-Ee,sr=.5-ee,Ar=Se*Se+$e*$e,Hr=Se*fr+$e*sr;if(Hr>0&&Hr1||Math.abs(fr.y-ae[0][1])>1)&&(fr=[fr.x,fr.y],Ae&&we(fr,Ee)_e||Ee[1]Me)return[AL(Ee[0],ge,_e),AL(Ee[1],ce,Me)]}function Ke(Ee,ee){if(Ee[0]===ee[0]&&(Ee[0]===ge||Ee[0]===_e)||Ee[1]===ee[1]&&(Ee[1]===ce||Ee[1]===Me))return!0}function We(Ee,ee){var ae=[],Ae=Je(Ee),Se=Je(ee);return Ae&&Se&&Ke(Ae,Se)||(Ae&&ae.push(Ae),Se&&ae.push(Se)),ae}function ke(Ee,ee,ae){return function(Ae,Se){var $e=Je(Ae),fr=Je(Se),sr=[];if($e&&fr&&Ke($e,fr))return sr;$e&&sr.push($e),fr&&sr.push(fr);var Ar=2*Hh.constrain((Ae[Ee]+Se[Ee])/2,ee,ae)-(($e||Ae)[Ee]+(fr||Se)[Ee]);if(Ar){var Hr;$e&&fr?Hr=Ar>0==$e[Ee]>fr[Ee]?$e:fr:Hr=$e||fr,Hr[Ee]+=Ar}return sr}}var ze;m==="linear"||m==="spline"?ze=qe:m==="hv"||m==="vh"?ze=We:m==="hvh"?ze=ke(0,ge,_e):m==="vhv"&&(ze=ke(1,ce,Me));function He(Ee,ee){var ae=ee[0]-Ee[0],Ae=(ee[1]-Ee[1])/ae,Se=(Ee[1]*ee[0]-ee[1]*Ee[0])/ae;return Se>0?[Ae>0?ge:_e,Me]:[Ae>0?_e:ge,ce]}function te(Ee){var ee=Ee[0],ae=Ee[1],Ae=ee===b[w-1][0],Se=ae===b[w-1][1];if(!(Ae&&Se))if(w>1){var $e=ee===b[w-2][0],fr=ae===b[w-2][1];Ae&&(ee===ge||ee===_e)&&$e?fr?w--:b[w-1]=Ee:Se&&(ae===ce||ae===Me)&&fr?$e?w--:b[w-1]=Ee:b[w++]=Ee}else b[w++]=Ee}function he(Ee){b[w-1][0]!==Ee[0]&&b[w-1][1]!==Ee[1]&&te([Le,Ce]),te(Ee),Ie=null,Le=Ce=0}var Ze=Hh.isArrayOrTypedArray(v);function hr(Ee){if(Ee&&f&&(Ee.i=S,Ee.d=r,Ee.trace=a,Ee.marker=Ze?v[Ee.i]:v,Ee.backoff=f),ve=Ee[0]/s,xe=Ee[1]/u,Te=Ee[0]_e?_e:0,Pe=Ee[1]Me?Me:0,Te||Pe){if(!w)b[w++]=[Te||Ee[0],Pe||Ee[1]];else if(Ie){var ee=ze(Ie,Ee);ee.length>1&&(he(ee[0]),b[w++]=ee[1])}else Oe=ze(b[w-1],Ee)[0],b[w++]=Oe;var ae=b[w-1];Te&&Pe&&(ae[0]!==Te||ae[1]!==Pe)?(Ie&&(Le!==Te&&Ce!==Pe?te(Le&&Ce?He(Ie,Ee):[Le||Te,Ce||Pe]):Le&&Ce&&te([Le,Ce])),te([Te,Pe])):Le-Te&&Ce-Pe&&te([Te||Le,Pe||Ce]),Ie=Ee,Le=Te,Ce=Pe}else Ie&&he(ze(Ie,Ee)[0]),b[w++]=Ee}for(S=0;SQ(z,De))break;q=z,B=H[0]*I[0]+H[1]*I[1],B>G?(G=B,D=z,F=!1):B=r.length||!z)break;hr(z),k=z}}Ie&&te([Le||Ie[0],Ce||Ie[1]]),x.push(b.slice(0,w))}var me=m.slice(m.length-1);if(f&&me!=="h"&&me!=="v"){for(var be=!1,Fe=-1,Ge=[],Ue=0;Ue{"use strict";var qL={tonextx:1,tonexty:1,tonext:1};DL.exports=function(r,t,a){var n,i,l,o,s,u={},f=!1,v=-1,h=0,d=-1;for(i=0;i=0?s=d:(s=d=h,h++),s{"use strict";var mi=Nr(),xee=_r(),Oc=Ne(),Ru=Oc.ensureSingle,RL=Oc.identity,Lt=at(),zu=mn(),wee=LL(),Tee=EL(),Oh=Y2().tester;zL.exports=function(r,t,a,n,i,l){var o,s,u=!i,f=!!i&&i.duration>0,v=Tee(r,t,a);if(o=n.selectAll("g.trace").data(v,function(d){return d[0].trace.uid}),o.enter().append("g").attr("class",function(d){return"trace scatter trace"+d[0].trace.uid}).style("stroke-miterlimit",2),o.order(),Mee(r,o,t),f){l&&(s=l());var h=mi.transition().duration(i.duration).ease(i.easing).each("end",function(){s&&s()}).each("interrupt",function(){s&&s()});h.each(function(){n.selectAll("g.trace").each(function(d,m){PL(r,m,t,d,v,this,i)})})}else o.each(function(d,m){PL(r,m,t,d,v,this,i)});u&&o.exit().remove(),n.selectAll("path:not([d])").remove()};function Mee(e,r,t){r.each(function(a){var n=Ru(mi.select(this),"g","fills");Lt.setClipUrl(n,t.layerClipId,e);var i=a[0].trace;i._ownFill=null,i._nextFill=null;var l=[];i._ownfill&&l.push("_ownFill"),i._nexttrace&&l.push("_nextFill");var o=n.selectAll("g").data(l,RL);o.enter().append("g"),o.exit().remove(),o.order().each(function(s){i[s]=Ru(mi.select(this),"path","js-fill")})})}function PL(e,r,t,a,n,i,l){var o=e._context.staticPlot,s;Aee(e,r,t,a,n);var u=!!l&&l.duration>0;function f(ke){return u?ke.transition():ke}var v=t.xaxis,h=t.yaxis,d=a[0].trace,m=d.line,y=mi.select(i),g=Ru(y,"g","errorbars"),x=Ru(y,"g","lines"),T=Ru(y,"g","points"),M=Ru(y,"g","text");if(xee.getComponentMethod("errorbars","plot")(e,g,t,l),d.visible!==!0)return;f(y).style("opacity",d.opacity);var b,w,S=d.fill.charAt(d.fill.length-1);S!=="x"&&S!=="y"&&(S="");var k,q;S==="y"?(k=1,q=h.c2p(0,!0)):S==="x"&&(k=0,q=v.c2p(0,!0)),a[0][t.isRangePlot?"nodeRangePlot3":"node3"]=y;var D="",P=[],z=d._prevtrace,F=null,I=null;z&&(D=z._prevRevpath||"",w=z._nextFill,P=z._ownPolygons,F=z._fillsegments,I=z._fillElement);var H,O,G="",Y="",B,V,J,Z,re,se,ve=[];d._polygons=[];var xe=[],Q=[],we=Oc.noop;if(b=d._ownFill,zu.hasLines(d)||d.fill!=="none"){w&&w.datum(a),["hv","vh","hvh","vhv"].indexOf(m.shape)!==-1?(B=Lt.steps(m.shape),V=Lt.steps(m.shape.split("").reverse().join(""))):m.shape==="spline"?B=V=function(ke){var ze=ke[ke.length-1];return ke.length>1&&ke[0][0]===ze[0]&&ke[0][1]===ze[1]?Lt.smoothclosed(ke.slice(1),m.smoothing):Lt.smoothopen(ke,m.smoothing)}:B=V=function(ke){return"M"+ke.join("L")},J=function(ke){return V(ke.reverse())},Q=wee(a,{xaxis:v,yaxis:h,trace:d,connectGaps:d.connectgaps,baseTolerance:Math.max(m.width||1,3)/4,shape:m.shape,backoff:m.backoff,simplify:m.simplify,fill:d.fill}),xe=new Array(Q.length);var fe=0;for(s=0;s=o[0]&&y.x<=o[1]&&y.y>=s[0]&&y.y<=s[1]}),h=Math.ceil(v.length/f),d=0;n.forEach(function(y,g){var x=y[0].trace;zu.hasMarkers(x)&&x.marker.maxdisplayed>0&&g{"use strict";FL.exports={container:"marker",min:"cmin",max:"cmax"}});var HL=X((T0e,IL)=>{"use strict";var Bh=mt();IL.exports=function(r,t,a){var n={},i={_fullLayout:a},l=Bh.getFromTrace(i,t,"x"),o=Bh.getFromTrace(i,t,"y"),s=r.orig_x;s===void 0&&(s=r.x);var u=r.orig_y;return u===void 0&&(u=r.y),n.xLabel=Bh.tickText(l,l.c2l(s),!0).text,n.yLabel=Bh.tickText(o,o.c2l(u),!0).text,n}});var jg=X((M0e,OL)=>{"use strict";var Kg=Nr(),Nu=at(),kee=_r();function Cee(e){var r=Kg.select(e).selectAll("g.trace.scatter");r.style("opacity",function(t){return t[0].trace.opacity}),r.selectAll("g.points").each(function(t){var a=Kg.select(this),n=t.trace||t[0].trace;$g(a,n,e)}),r.selectAll("g.text").each(function(t){var a=Kg.select(this),n=t.trace||t[0].trace;Qg(a,n,e)}),r.selectAll("g.trace path.js-line").call(Nu.lineGroupStyle),r.selectAll("g.trace path.js-fill").call(Nu.fillGroupStyle,e,!1),kee.getComponentMethod("errorbars","style")(r)}function $g(e,r,t){Nu.pointStyle(e.selectAll("path.point"),r,t)}function Qg(e,r,t){Nu.textPointStyle(e.selectAll("text"),r,t)}function See(e,r,t){var a=r[0].trace;a.selectedpoints?(Nu.selectedPointStyle(t.selectAll("path.point"),a),Nu.selectedTextStyle(t.selectAll("text"),a)):($g(t,a,e),Qg(t,a,e))}OL.exports={style:Cee,stylePoints:$g,styleText:Qg,styleOnSelect:See}});var YL=X((A0e,BL)=>{"use strict";var Fu=Er(),Lee=mn();BL.exports=function(r,t){var a,n;if(r.mode==="lines")return a=r.line.color,a&&Fu.opacity(a)?a:r.fillcolor;if(r.mode==="none")return r.fill?r.fillcolor:"";var i=t.mcc||(r.marker||{}).color,l=t.mlcc||((r.marker||{}).line||{}).color;return n=i&&Fu.opacity(i)?i:l&&Fu.opacity(l)&&(t.mlw||((r.marker||{}).line||{}).width)?l:"",n?Fu.opacity(n)<.3?Fu.addOpacity(n,.3):n:(a=(r.line||{}).color,a&&Fu.opacity(a)&&Lee.hasLines(r)&&r.line.width?a:r.fillcolor)}});var GL=X((k0e,VL)=>{"use strict";var Yh=Ne(),UL=vi(),qee=_r(),Dee=YL(),e3=Er(),Eee=Yh.fillText;VL.exports=function(r,t,a,n){var i=r.cd,l=i[0].trace,o=r.xa,s=r.ya,u=o.c2p(t),f=s.c2p(a),v=[u,f],h=l.hoveron||"",d=l.mode.indexOf("markers")!==-1?3:.5,m=!!l.xperiodalignment,y=!!l.yperiodalignment;if(h.indexOf("points")!==-1){var g=function(Y){if(m){var B=o.c2p(Y.xStart),V=o.c2p(Y.xEnd);return u>=Math.min(B,V)&&u<=Math.max(B,V)?0:1/0}var J=Math.max(3,Y.mrc||0),Z=1-1/J,re=Math.abs(o.c2p(Y.x)-u);return re=Math.min(B,V)&&f<=Math.max(B,V)?0:1/0}var J=Math.max(3,Y.mrc||0),Z=1-1/J,re=Math.abs(s.c2p(Y.y)-f);return reve!=Me>=ve&&(ge=we[Q-1][0],_e=we[Q][0],Me-ce&&(fe=ge+(_e-ge)*(ve-ce)/(Me-ce),J=Math.min(J,fe),Z=Math.max(Z,fe)));return J=Math.max(J,0),Z=Math.min(Z,o._length),{x0:J,x1:Z,y0:ve,y1:ve}}if(h.indexOf("fills")!==-1&&l._fillElement){var H=F(l._fillElement)&&!F(l._fillExclusionElement);if(H){var O=I(l._polygons);O===null&&(O={x0:v[0],x1:v[0],y0:v[1],y1:v[1]});var G=e3.defaultLine;return e3.opacity(l.fillcolor)?G=l.fillcolor:e3.opacity((l.line||{}).color)&&(G=l.line.color),Yh.extendFlat(r,{distance:r.maxHoverDistance,x0:O.x0,x1:O.x1,y0:O.y0,y1:O.y1,color:G,hovertemplate:!1}),delete r.index,l.text&&!Yh.isArrayOrTypedArray(l.text)?r.text=String(l.text):r.text=l.name,[r]}}}});var ZL=X((C0e,XL)=>{"use strict";var WL=mn();XL.exports=function(r,t){var a=r.cd,n=r.xaxis,i=r.yaxis,l=[],o=a[0].trace,s,u,f,v,h=o.mode&&!WL.hasMarkers(o)&&!WL.hasText(o);if(h)return[];if(t===!1)for(s=0;s{"use strict";JL.exports={xaxis:{valType:"subplotid",dflt:"x",editType:"calc+clearAxisTypes"},yaxis:{valType:"subplotid",dflt:"y",editType:"calc+clearAxisTypes"}}});var eq=X((L0e,jL)=>{"use strict";var Uh=_r().traceIs,r3=my();jL.exports=function(r,t,a,n){a("autotypenumbers",n.autotypenumbersDflt);var i=a("type",(n.splomStash||{}).type);i==="-"&&(Pee(t,n.data),t.type==="-"?t.type="linear":r.type=t.type)};function Pee(e,r){if(e.type==="-"){var t=e._id,a=t.charAt(0),n;t.indexOf("scene")!==-1&&(t=a);var i=Ree(r,t,a);if(i){if(i.type==="histogram"&&a==={v:"y",h:"x"}[i.orientation||"v"]){e.type="linear";return}var l=a+"calendar",o=i[l],s={noMultiCategory:!Uh(i,"cartesian")||Uh(i,"noMultiCategory")};if(i.type==="box"&&i._hasPreCompStats&&a==={h:"x",v:"y"}[i.orientation||"v"]&&(s.noMultiCategory=!0),s.autotypenumbers=e.autotypenumbers,QL(i,a)){var u=$L(i),f=[];for(n=0;n0&&(n["_"+t+"axes"]||{})[r])return n;if((n[t+"axis"]||t)===r){if(QL(n,t))return n;if((n[t]||[]).length||n[t+"0"])return n}}}function $L(e){return{v:"x",h:"y"}[e.orientation||"v"]}function QL(e,r){var t=$L(e),a=Uh(e,"box-violin");return a&&r===t&&e[t]===void 0&&e[t+"0"]===void 0}});var tq=X((q0e,rq)=>{"use strict";var zee=hn().isTypedArraySpec;function Nee(e,r){var t=r.dataAttr||e._id.charAt(0),a={},n,i,l;if(r.axData)n=r.axData;else for(n=[],i=0;i0||zee(i),o;l&&(o="array");var s=a("categoryorder",o),u;s==="array"&&(u=a("categoryarray")),!l&&s==="array"&&(s=t.categoryorder="trace"),s==="trace"?t._initialCategories=[]:s==="array"?t._initialCategories=u.slice():(u=Nee(t,n).sort(),s==="category ascending"?t._initialCategories=u:s==="category descending"&&(t._initialCategories=u.reverse()))}}});var iq=X((D0e,nq)=>{"use strict";var aq=Er(),Fee=ki(),Iee=Ne();nq.exports=function(r,t,a,n){n=n||{};var i=n.dfltColor;function l(q,D){return Iee.coerce2(r,t,n.attributes,q,D)}var o=l("linecolor",i),s=l("linewidth"),u=a("showline",n.showLine||!!o||!!s);u||(delete t.linecolor,delete t.linewidth);var f=aq.mix(i,n.bgColor,n.blend||Fee.lightFraction),v=l("gridcolor",f),h=l("gridwidth"),d=l("griddash"),m=a("showgrid",n.showGrid||!!v||!!h||!!d);if(m||(delete t.gridcolor,delete t.gridwidth,delete t.griddash),n.hasMinor){var y=aq.mix(t.gridcolor,n.bgColor,67),g=l("minor.gridcolor",y),x=l("minor.gridwidth",t.gridwidth||1),T=l("minor.griddash",t.griddash||"solid"),M=a("minor.showgrid",!!g||!!x||!!T);M||(delete t.minor.gridcolor,delete t.minor.gridwidth,delete t.minor.griddash)}if(!n.noZeroLine){var b=l("zerolinelayer"),w=l("zerolinecolor",i),S=l("zerolinewidth"),k=a("zeroline",n.showGrid||!!w||!!S);k||(delete t.zerolinelayer,delete t.zerolinecolor,delete t.zerolinewidth)}}});var a3=X((E0e,cq)=>{"use strict";var lq=Br(),Hee=_r(),Bc=Ne(),Oee=vt(),Bee=si(),t3=oi(),oq=Om(),sq=Ym(),Yee=Gm(),Uee=Wm(),Vee=tq(),Gee=iq(),Wee=Fy(),uq=Qv(),Vh=ta().WEEKDAY_PATTERN,Xee=ta().HOUR_PATTERN;cq.exports=function(r,t,a,n,i){var l=n.letter,o=n.font||{},s=n.splomStash||{},u=a("visible",!n.visibleDflt),f=t._template||{},v=t.type||f.type||"-",h;if(v==="date"){var d=Hee.getComponentMethod("calendars","handleDefaults");d(r,t,"calendar",n.calendar),n.noTicklabelmode||(h=a("ticklabelmode"))}!n.noTicklabelindex&&(v==="date"||v==="linear")&&a("ticklabelindex");var m="";(!n.noTicklabelposition||v==="multicategory")&&(m=Bc.coerce(r,t,{ticklabelposition:{valType:"enumerated",dflt:"outside",values:h==="period"?["outside","inside"]:l==="x"?["outside","inside","outside left","inside left","outside right","inside right"]:["outside","inside","outside top","inside top","outside bottom","inside bottom"]}},"ticklabelposition")),n.noTicklabeloverflow||a("ticklabeloverflow",m.indexOf("inside")!==-1?"hide past domain":v==="category"||v==="multicategory"?"allow":"hide past div"),uq(t,i),Wee(r,t,a,n),Vee(r,t,a,n),n.noHover||(v!=="category"&&a("hoverformat"),n.noUnifiedhovertitle||a("unifiedhovertitle.text"));var y=a("color"),g=y!==t3.color.dflt?y:o.color,x=s.label||i._dfltTitle[l];if(Uee(r,t,a,v,n),!u)return t;a("title.text",x),Bc.coerceFont(a,"title.font",o,{overrideDflt:{size:Bc.bigFont(o.size),color:g}}),oq(r,t,a,v);var T=n.hasMinor;if(T&&(Oee.newContainer(t,"minor"),oq(r,t,a,v,{isMinor:!0})),Yee(r,t,a,v,n),sq(r,t,a,n),T){var M=n.isMinor;n.isMinor=!0,sq(r,t,a,n),n.isMinor=M}Gee(r,t,a,{dfltColor:y,bgColor:n.bgColor,showGrid:n.showGrid,hasMinor:T,attributes:t3}),T&&t.ticklabelindex==null&&!t.minor.ticks&&!t.minor.showgrid&&delete t.minor,(t.showline||t.ticks)&&a("mirror");var b=v==="multicategory";if(!n.noTickson&&(v==="category"||b)&&(t.ticks||t.showgrid)&&(b?(a("tickson","boundaries"),delete t.ticklabelposition):a("tickson")),b){var w=a("showdividers");w&&(a("dividercolor"),a("dividerwidth"))}if(v==="date")if(Bee(r,t,{name:"rangebreaks",inclusionAttr:"enabled",handleItemDefaults:Zee}),!t.rangebreaks.length)delete t.rangebreaks;else{for(var S=0;S=2){var l="",o,s;if(i.length===2){for(o=0;o<2;o++)if(s=fq(i[o]),s){l=Vh;break}}var u=a("pattern",l);if(u===Vh)for(o=0;o<2;o++)s=fq(i[o]),s&&(r.bounds[o]=i[o]=s-1);if(u)for(o=0;o<2;o++)switch(s=i[o],u){case Vh:if(!lq(s)){r.enabled=!1;return}if(s=+s,s!==Math.floor(s)||s<0||s>=7){r.enabled=!1;return}r.bounds[o]=i[o]=s;break;case Xee:if(!lq(s)){r.enabled=!1;return}if(s=+s,s<0||s>24){r.enabled=!1;return}r.bounds[o]=i[o]=s;break}if(t.autorange===!1){var f=t.range;if(f[0]f[1]){r.enabled=!1;return}}else if(i[0]>f[0]&&i[1]{"use strict";var Kee=Br(),Gh=Ne();vq.exports=function(r,t,a,n){var i=n.counterAxes||[],l=n.overlayableAxes||[],o=n.letter,s=n.grid,u=n.overlayingDomain,f,v,h,d,m,y;s&&(v=s._domains[o][s._axisMap[t._id]],f=s._anchors[t._id],v&&(h=s[o+"side"].split(" ")[0],d=s.domain[o][h==="right"||h==="top"?1:0])),v=v||[0,1],f=f||(Kee(r.position)?"free":i[0]||"free"),h=h||(o==="x"?"bottom":"left"),d=d||0,m=0,y=!1;var g=Gh.coerce(r,t,{anchor:{valType:"enumerated",values:["free"].concat(i),dflt:f}},"anchor"),x=Gh.coerce(r,t,{side:{valType:"enumerated",values:o==="x"?["bottom","top"]:["left","right"],dflt:h}},"side");if(g==="free"){if(o==="y"){var T=a("autoshift");T&&(d=x==="left"?u[0]:u[1],y=t.automargin?t.automargin:!0,m=x==="left"?-3:3),a("shift",m)}a("position",d)}a("automargin",y);var M=!1;if(l.length&&(M=Gh.coerce(r,t,{overlaying:{valType:"enumerated",values:[!1].concat(l),dflt:!1}},"overlaying")),!M){var b=a("domain",v);b[0]>b[1]-1/4096&&(t.domain=v),Gh.noneOrAll(r.domain,t.domain,v),t.tickmode==="sync"&&(t.tickmode="auto")}return a("layer"),t}});var xq=X((R0e,_q)=>{"use strict";var as=Ne(),hq=Er(),$ee=Di().isUnifiedHover,Qee=f2(),dq=vt(),jee=Ws(),pq=oi(),ere=eq(),mq=a3(),rre=oc(),yq=n3(),l3=$t(),Gi=l3.id2name,gq=l3.name2id,tre=ta().AX_ID_PATTERN,bq=_r(),Wh=bq.traceIs,i3=bq.getComponentMethod;function Xh(e,r,t){Array.isArray(e[r])?e[r].push(t):e[r]=[t]}_q.exports=function(r,t,a){var n=t.autotypenumbers,i={},l={},o={},s={},u={},f={},v={},h={},d={},m={},y,g;for(y=0;y{"use strict";var are=Nr(),wq=_r(),Zh=Ne(),wn=at(),Jh=mt();Tq.exports=function(r,t,a,n){var i=r._fullLayout;if(t.length===0){Jh.redrawComponents(r);return}function l(g){var x=g.xaxis,T=g.yaxis;i._defs.select("#"+g.clipId+"> rect").call(wn.setTranslate,0,0).call(wn.setScale,1,1),g.plot.call(wn.setTranslate,x._offset,T._offset).call(wn.setScale,1,1);var M=g.plot.selectAll(".scatterlayer .trace");M.selectAll(".point").call(wn.setPointGroupScale,1,1),M.selectAll(".textpoint").call(wn.setTextPointsScale,1,1),M.call(wn.hideOutsideRangePoints,g)}function o(g,x){var T=g.plotinfo,M=T.xaxis,b=T.yaxis,w=M._length,S=b._length,k=!!g.xr1,q=!!g.yr1,D=[];if(k){var P=Zh.simpleMap(g.xr0,M.r2l),z=Zh.simpleMap(g.xr1,M.r2l),F=P[1]-P[0],I=z[1]-z[0];D[0]=(P[0]*(1-x)+x*z[0]-P[0])/(P[1]-P[0])*w,D[2]=w*(1-x+x*I/F),M.range[0]=M.l2r(P[0]*(1-x)+x*z[0]),M.range[1]=M.l2r(P[1]*(1-x)+x*z[1])}else D[0]=0,D[2]=w;if(q){var H=Zh.simpleMap(g.yr0,b.r2l),O=Zh.simpleMap(g.yr1,b.r2l),G=H[1]-H[0],Y=O[1]-O[0];D[1]=(H[1]*(1-x)+x*O[1]-H[1])/(H[0]-H[1])*S,D[3]=S*(1-x+x*Y/G),b.range[0]=M.l2r(H[0]*(1-x)+x*O[0]),b.range[1]=b.l2r(H[1]*(1-x)+x*O[1])}else D[1]=0,D[3]=S;Jh.drawOne(r,M,{skipTitle:!0}),Jh.drawOne(r,b,{skipTitle:!0}),Jh.redrawComponents(r,[M._id,b._id]);var B=k?w/D[2]:1,V=q?S/D[3]:1,J=k?D[0]:0,Z=q?D[1]:0,re=k?D[0]/D[2]*w:0,se=q?D[1]/D[3]*S:0,ve=M._offset-re,xe=b._offset-se;T.clipRect.call(wn.setTranslate,J,Z).call(wn.setScale,1/B,1/V),T.plot.call(wn.setTranslate,ve,xe).call(wn.setScale,B,V),wn.setPointGroupScale(T.zoomScalePts,1/B,1/V),wn.setTextPointsScale(T.zoomScaleTxt,1/B,1/V)}var s;n&&(s=n());function u(){for(var g={},x=0;xa.duration?(u(),d=window.cancelAnimationFrame(y)):d=window.requestAnimationFrame(y)}return v=Date.now(),d=window.requestAnimationFrame(y),Promise.resolve()}});var Qh=X(Ea=>{"use strict";var $h=Nr(),Aq=_r(),ns=Ne(),nre=Ut(),ire=at(),kq=Tm().getModuleCalcData,Zl=$t(),yi=ta(),lre=Eo(),Xr=ns.ensureSingle;function Kh(e,r,t){return ns.ensureSingle(e,r,t,function(a){a.datum(t)})}var is=yi.zindexSeparator;Ea.name="cartesian";Ea.attr=["xaxis","yaxis"];Ea.idRoot=["x","y"];Ea.idRegex=yi.idRegex;Ea.attrRegex=yi.attrRegex;Ea.attributes=KL();Ea.layoutAttributes=oi();Ea.supplyLayoutDefaults=xq();Ea.transitionAxes=Mq();Ea.finalizeSubplots=function(e,r){var t=r._subplots,a=t.xaxis,n=t.yaxis,i=t.cartesian,l=i,o={},s={},u,f,v;for(u=0;u0){var d=h.id;if(d.indexOf(is)!==-1)continue;d+=is+(u+1),h=ns.extendFlat({},h,{id:d,plot:n._cartesianlayer.selectAll(".subplot").select("."+d)})}for(var m=[],y,g=0;g1&&(w+=is+b),M.push(o+w),l=0;l1,v=r.mainplotinfo;if(!r.mainplot||f)if(u)r.xlines=Xr(a,"path","xlines-above"),r.ylines=Xr(a,"path","ylines-above"),r.xaxislayer=Xr(a,"g","xaxislayer-above"),r.yaxislayer=Xr(a,"g","yaxislayer-above");else{if(!l){var h=Xr(a,"g","layer-subplot");r.shapelayer=Xr(h,"g","shapelayer"),r.imagelayer=Xr(h,"g","imagelayer"),v&&f?(r.minorGridlayer=v.minorGridlayer,r.gridlayer=v.gridlayer,r.zerolinelayer=v.zerolinelayer):(r.minorGridlayer=Xr(a,"g","minor-gridlayer"),r.gridlayer=Xr(a,"g","gridlayer"),r.zerolinelayer=Xr(a,"g","zerolinelayer"));var d=Xr(a,"g","layer-between");r.shapelayerBetween=Xr(d,"g","shapelayer"),r.imagelayerBetween=Xr(d,"g","imagelayer"),Xr(a,"path","xlines-below"),Xr(a,"path","ylines-below"),r.overlinesBelow=Xr(a,"g","overlines-below"),Xr(a,"g","xaxislayer-below"),Xr(a,"g","yaxislayer-below"),r.overaxesBelow=Xr(a,"g","overaxes-below")}r.overplot=Xr(a,"g","overplot"),r.plot=Xr(r.overplot,"g",n),v&&f?r.zerolinelayerAbove=v.zerolinelayerAbove:r.zerolinelayerAbove=Xr(a,"g","zerolinelayer-above"),l||(r.xlines=Xr(a,"path","xlines-above"),r.ylines=Xr(a,"path","ylines-above"),r.overlinesAbove=Xr(a,"g","overlines-above"),Xr(a,"g","xaxislayer-above"),Xr(a,"g","yaxislayer-above"),r.overaxesAbove=Xr(a,"g","overaxes-above"),r.xlines=a.select(".xlines-"+o),r.ylines=a.select(".ylines-"+s),r.xaxislayer=a.select(".xaxislayer-"+o),r.yaxislayer=a.select(".yaxislayer-"+s))}else{var m=v.plotgroup,y=n+"-x",g=n+"-y";r.minorGridlayer=v.minorGridlayer,r.gridlayer=v.gridlayer,r.zerolinelayer=v.zerolinelayer,r.zerolinelayerAbove=v.zerolinelayerAbove,Xr(v.overlinesBelow,"path",y),Xr(v.overlinesBelow,"path",g),Xr(v.overaxesBelow,"g",y),Xr(v.overaxesBelow,"g",g),r.plot=Xr(v.overplot,"g",n),Xr(v.overlinesAbove,"path",y),Xr(v.overlinesAbove,"path",g),Xr(v.overaxesAbove,"g",y),Xr(v.overaxesAbove,"g",g),r.xlines=m.select(".overlines-"+o).select("."+y),r.ylines=m.select(".overlines-"+s).select("."+g),r.xaxislayer=m.select(".overaxes-"+o).select("."+y),r.yaxislayer=m.select(".overaxes-"+s).select("."+g)}l||(u||(Kh(r.minorGridlayer,"g",r.xaxis._id),Kh(r.minorGridlayer,"g",r.yaxis._id),r.minorGridlayer.selectAll("g").map(function(x){return x[0]}).sort(Zl.idSort),Kh(r.gridlayer,"g",r.xaxis._id),Kh(r.gridlayer,"g",r.yaxis._id),r.gridlayer.selectAll("g").map(function(x){return x[0]}).sort(Zl.idSort)),r.xlines.style("fill","none").classed("crisp",!0),r.ylines.style("fill","none").classed("crisp",!0))}function Lq(e,r){if(e){var t={};e.each(function(s){var u=s[0],f=$h.select(this);f.remove(),qq(u,r),t[u]=!0});for(var a in r._plots)for(var n=r._plots[a],i=n.overlays||[],l=0;l{"use strict";var jh=mn();Dq.exports={hasLines:jh.hasLines,hasMarkers:jh.hasMarkers,hasText:jh.hasText,isBubble:jh.isBubble,attributes:Qo(),layoutAttributes:kv(),supplyDefaults:GS(),crossTraceDefaults:ZS(),supplyLayoutDefaults:KS(),calc:Og().calc,crossTraceCalc:wL(),arraysToCalcdata:Rg(),plot:NL(),colorbar:Jg(),formatLabels:HL(),style:jg().style,styleOnSelect:jg().styleOnSelect,hoverPoints:GL(),selectPoints:ZL(),animatable:!0,moduleType:"trace",name:"scatter",basePlotModule:Qh(),categories:["cartesian","svg","symbols","errorBarsOK","showLegend","scatter-like","zoomScale"],meta:{}}});var zq=X((I0e,Rq)=>{"use strict";var sre=Nr(),ure=Er(),Pq=eg(),o3=Ne(),fre=o3.strScale,cre=o3.strRotate,vre=o3.strTranslate;Rq.exports=function(r,t,a){var n=r.node(),i=Pq[a.arrowhead||0],l=Pq[a.startarrowhead||0],o=(a.arrowwidth||1)*(a.arrowsize||1),s=(a.arrowwidth||1)*(a.startarrowsize||1),u=t.indexOf("start")>=0,f=t.indexOf("end")>=0,v=i.backoff*o+a.standoff,h=l.backoff*s+a.startstandoff,d,m,y,g;if(n.nodeName==="line"){d={x:+r.attr("x1"),y:+r.attr("y1")},m={x:+r.attr("x2"),y:+r.attr("y2")};var x=d.x-m.x,T=d.y-m.y;if(y=Math.atan2(T,x),g=y+Math.PI,v&&h&&v+h>Math.sqrt(x*x+T*T)){H();return}if(v){if(v*v>x*x+T*T){H();return}var M=v*Math.cos(y),b=v*Math.sin(y);m.x+=M,m.y+=b,r.attr({x2:m.x,y2:m.y})}if(h){if(h*h>x*x+T*T){H();return}var w=h*Math.cos(y),S=h*Math.sin(y);d.x-=w,d.y-=S,r.attr({x1:d.x,y1:d.y})}}else if(n.nodeName==="path"){var k=n.getTotalLength(),q="";if(k{"use strict";var Nq=Nr(),s3=_r(),hre=Ut(),Kl=Ne(),u3=Kl.strTranslate,Uc=mt(),ls=Er(),fl=at(),Fq=vi(),f3=da(),c3=Hl(),Yc=fi(),dre=vt().arrayEditor,pre=zq();Oq.exports={draw:mre,drawOne:Iq,drawRaw:Hq};function mre(e){var r=e._fullLayout;r._infolayer.selectAll(".annotation").remove();for(var t=0;t2/3?Se="right":Se="center"),{center:0,middle:0,left:.5,bottom:-.5,right:-.5,top:.5}[Se]}for(var ce=!1,Me=["x","y"],Re=0;Re1)&&(Le===Pe?(De=Ce.r2fraction(r["a"+Te]),(De<0||De>1)&&(ce=!0)):ce=!0),ze=Ce._offset+Ce.r2p(r[Te]),he=.5}else{var me=hr==="domain";Te==="x"?(te=r[Te],ze=me?Ce._offset+Ce._length*te:ze=o.l+o.w*te):(te=1-r[Te],ze=me?Ce._offset+Ce._length*te:ze=o.t+o.h*te),he=r.showarrow?.5:te}if(r.showarrow){ke.head=ze;var be=r["a"+Te];if(Ze=Oe*_e(.5,r.xanchor)-qe*_e(.5,r.yanchor),Le===Pe){var Fe=Uc.getRefType(Le);Fe==="domain"?(Te==="y"&&(be=1-be),ke.tail=Ce._offset+Ce._length*be):Fe==="paper"?Te==="y"?(be=1-be,ke.tail=o.t+o.h*be):ke.tail=o.l+o.w*be:ke.tail=Ce._offset+Ce.r2p(be),He=Ze}else ke.tail=ze+be,He=Ze+be;ke.text=ke.tail+Ze;var Ge=l[Te==="x"?"width":"height"];if(Pe==="paper"&&(ke.head=Kl.constrain(ke.head,1,Ge-1)),Le==="pixel"){var Ue=-Math.max(ke.tail-3,ke.text),Ve=Math.min(ke.tail+3,ke.text)-Ge;Ue>0?(ke.tail+=Ue,ke.text+=Ue):Ve>0&&(ke.tail-=Ve,ke.text-=Ve)}ke.tail+=We,ke.head+=We}else Ze=Je*_e(he,Ke),He=Ze,ke.text=ze+Ze;ke.text+=We,Ze+=We,He+=We,r["_"+Te+"padplus"]=Je/2+He,r["_"+Te+"padminus"]=Je/2-He,r["_"+Te+"size"]=Je,r["_"+Te+"shift"]=Ze}if(ce){k.remove();return}var ir=0,ar=0;if(r.align!=="left"&&(ir=(Q-ve)*(r.align==="center"?.5:1)),r.valign!=="top"&&(ar=(we-xe)*(r.valign==="middle"?.5:1)),re)Z.select("svg").attr({x:P+ir-1,y:P+ar}).call(fl.setClipUrl,F?y:null,e);else{var er=P+ar-se.top,Ee=P+ir-se.left;G.call(f3.positionText,Ee,er).call(fl.setClipUrl,F?y:null,e)}I.select("rect").call(fl.setRect,P,P,Q,we),z.call(fl.setRect,q/2,q/2,fe-q,ge-q),k.call(fl.setTranslate,Math.round(g.x.text-fe/2),Math.round(g.y.text-ge/2)),M.attr({transform:"rotate("+x+","+g.x.text+","+g.y.text+")"});var ee=function(Ae,Se){T.selectAll(".annotation-arrow-g").remove();var $e=g.x.head,fr=g.y.head,sr=g.x.tail+Ae,Ar=g.y.tail+Se,Hr=g.x.text+Ae,Zr=g.y.text+Se,ot=Kl.rotationXYMatrix(x,Hr,Zr),Tt=Kl.apply2DTransform(ot),bt=Kl.apply2DTransform2(ot),Gt=+z.attr("width"),Ht=+z.attr("height"),Wa=Hr-.5*Gt,ga=Wa+Gt,oa=Zr-.5*Ht,Xa=oa+Ht,sa=[[Wa,oa,Wa,Xa],[Wa,Xa,ga,Xa],[ga,Xa,ga,oa],[ga,oa,Wa,oa]].map(bt);if(!sa.reduce(function(ht,Ot){return ht^!!Kl.segmentsIntersect($e,fr,$e+1e6,fr+1e6,Ot[0],Ot[1],Ot[2],Ot[3])},!1)){sa.forEach(function(ht){var Ot=Kl.segmentsIntersect(sr,Ar,$e,fr,ht[0],ht[1],ht[2],ht[3]);Ot&&(sr=Ot.x,Ar=Ot.y)});var ua=r.arrowwidth,cr=r.arrowcolor,st=r.arrowside,Wr=T.append("g").style({opacity:ls.opacity(cr)}).classed("annotation-arrow-g",!0),it=Wr.append("path").attr("d","M"+sr+","+Ar+"L"+$e+","+fr).style("stroke-width",ua+"px").call(ls.stroke,ls.rgb(cr));if(pre(it,st,r),s.annotationPosition&&it.node().parentNode&&!a){var fa=$e,ca=fr;if(r.standoff){var nt=Math.sqrt(Math.pow($e-sr,2)+Math.pow(fr-Ar,2));fa+=r.standoff*(sr-$e)/nt,ca+=r.standoff*(Ar-fr)/nt}var Qa=Wr.append("path").classed("annotation-arrow",!0).classed("anndrag",!0).classed("cursor-move",!0).attr({d:"M3,3H-3V-3H3ZM0,0L"+(sr-fa)+","+(Ar-ca),transform:u3(fa,ca)}).style("stroke-width",ua+6+"px").call(ls.stroke,"rgba(0,0,0,0)").call(ls.fill,"rgba(0,0,0,0)"),Kn,ra;Yc.init({element:Qa.node(),gd:e,prepFn:function(){var ht=fl.getTranslate(k);Kn=ht.x,ra=ht.y,n&&n.autorange&&h(n._name+".autorange",!0),i&&i.autorange&&h(i._name+".autorange",!0)},moveFn:function(ht,Ot){var Bt=Tt(Kn,ra),so=Bt[0]+ht,ml=Bt[1]+Ot;k.call(fl.setTranslate,so,ml),d("x",Jl(n,ht,"x",o,r)),d("y",Jl(i,Ot,"y",o,r)),r.axref===r.xref&&d("ax",Jl(n,ht,"ax",o,r)),r.ayref===r.yref&&d("ay",Jl(i,Ot,"ay",o,r)),Wr.attr("transform",u3(ht,Ot)),M.attr({transform:"rotate("+x+","+so+","+ml+")"})},doneFn:function(){s3.call("_guiRelayout",e,m());var ht=document.querySelector(".js-notes-box-panel");ht&&ht.redraw(ht.selectedObj)}})}}};if(r.showarrow&&ee(0,0),b){var ae;Yc.init({element:k.node(),gd:e,prepFn:function(){ae=M.attr("transform")},moveFn:function(Ae,Se){var $e="pointer";if(r.showarrow)r.axref===r.xref?d("ax",Jl(n,Ae,"ax",o,r)):d("ax",r.ax+Ae),r.ayref===r.yref?d("ay",Jl(i,Se,"ay",o.w,r)):d("ay",r.ay+Se),ee(Ae,Se);else{if(a)return;var fr,sr;if(n)fr=Jl(n,Ae,"x",o,r);else{var Ar=r._xsize/o.w,Hr=r.x+(r._xshift-r.xshift)/o.w-Ar/2;fr=Yc.align(Hr+Ae/o.w,Ar,0,1,r.xanchor)}if(i)sr=Jl(i,Se,"y",o,r);else{var Zr=r._ysize/o.h,ot=r.y-(r._yshift+r.yshift)/o.h-Zr/2;sr=Yc.align(ot-Se/o.h,Zr,0,1,r.yanchor)}d("x",fr),d("y",sr),(!n||!i)&&($e=Yc.getCursor(n?.5:fr,i?.5:sr,r.xanchor,r.yanchor))}M.attr({transform:u3(Ae,Se)+ae}),c3(k,$e)},clickFn:function(Ae,Se){r.captureevents&&e.emit("plotly_clickannotation",S(Se))},doneFn:function(){c3(k),s3.call("_guiRelayout",e,m());var Ae=document.querySelector(".js-notes-box-panel");Ae&&Ae.redraw(Ae.selectedObj)}})}}s.annotationText?G.call(f3.makeEditable,{delegate:k,gd:e}).call(Y).on("edit",function(V){r.text=V,this.call(Y),d("text",V),n&&n.autorange&&h(n._name+".autorange",!0),i&&i.autorange&&h(i._name+".autorange",!0),s3.call("_guiRelayout",e,m())}):G.call(Y)}});var Wq=X((O0e,Gq)=>{"use strict";var Bq=Ne(),yre=_r(),Yq=vt().arrayEditor;Gq.exports={hasClickToShow:gre,onClick:bre};function gre(e,r){var t=Vq(e,r);return t.on.length>0||t.explicitOff.length>0}function bre(e,r){var t=Vq(e,r),a=t.on,n=t.off.concat(t.explicitOff),i={},l=e._fullLayout.annotations,o,s;if(a.length||n.length){for(o=0;o{"use strict";var v3=Ne(),Iu=Er();Xq.exports=function(r,t,a,n){n("opacity");var i=n("bgcolor"),l=n("bordercolor"),o=Iu.opacity(l);n("borderpad");var s=n("borderwidth"),u=n("showarrow");n("text",u?" ":a._dfltTitle.annotation),n("textangle"),v3.coerceFont(n,"font",a.font),n("width"),n("align");var f=n("height");if(f&&n("valign"),u){var v=n("arrowside"),h,d;v.indexOf("end")!==-1&&(h=n("arrowhead"),d=n("arrowsize")),v.indexOf("start")!==-1&&(n("startarrowhead",h),n("startarrowsize",d)),n("arrowcolor",o?t.bordercolor:Iu.defaultLine),n("arrowwidth",(o&&s||1)*2),n("standoff"),n("startstandoff")}var m=n("hovertext"),y=a.hoverlabel||{};if(m){var g=n("hoverlabel.bgcolor",y.bgcolor||(Iu.opacity(i)?Iu.rgb(i):Iu.defaultLine)),x=n("hoverlabel.bordercolor",y.bordercolor||Iu.contrast(g)),T=v3.extendFlat({},y.font);T.color||(T.color=x),v3.coerceFont(n,"hoverlabel.font",T)}n("captureevents",!!m)}});var Jq=X((Y0e,Zq)=>{"use strict";var d3=Ne(),os=mt(),_re=si(),xre=h3(),wre=Su();Zq.exports=function(r,t){_re(r,t,{name:"annotations",handleItemDefaults:Tre})};function Tre(e,r,t){function a(M,b){return d3.coerce(e,r,wre,M,b)}var n=a("visible"),i=a("clicktoshow");if(n||i){xre(e,r,t,a);for(var l=r.showarrow,o=["x","y"],s=[-10,-30],u={_fullLayout:t},f=0;f<2;f++){var v=o[f],h=os.coerceRef(e,r,u,v,"","paper");if(h!=="paper"){var d=os.getFromId(u,h);d._annIndices.push(r._index)}if(os.coercePosition(r,u,a,h,v,.5),l){var m="a"+v,y=os.coerceRef(e,r,u,m,"pixel",["pixel","paper"]);y!=="pixel"&&y!==h&&(y=r[m]="pixel");var g=y==="pixel"?s[f]:.4;os.coercePosition(r,u,a,y,m,g)}a(v+"anchor"),a(v+"shift")}if(d3.noneOrAll(e,r,["x","y"]),l&&d3.noneOrAll(e,r,["ax","ay"]),i){var x=a("xclick"),T=a("yclick");r._xclick=x===void 0?r.x:os.cleanPosition(x,u,r.xref),r._yclick=T===void 0?r.y:os.cleanPosition(T,u,r.yref)}}}});var Qq=X((U0e,$q)=>{"use strict";var p3=Ne(),ss=mt(),Mre=ed().draw;$q.exports=function(r){var t=r._fullLayout,a=p3.filterVisible(t.annotations);if(a.length&&r._fullData.length)return p3.syncOrAsync([Mre,Are],r)};function Are(e){var r=e._fullLayout;p3.filterVisible(r.annotations).forEach(function(t){var a=ss.getFromId(e,t.xref),n=ss.getFromId(e,t.yref),i=ss.getRefType(t.xref),l=ss.getRefType(t.yref);t._extremes={},i==="range"&&Kq(t,a),l==="range"&&Kq(t,n)})}function Kq(e,r){var t=r._id,a=t.charAt(0),n=e[a],i=e["a"+a],l=e[a+"ref"],o=e["a"+a+"ref"],s=e["_"+a+"padplus"],u=e["_"+a+"padminus"],f={x:1,y:-1}[a]*e[a+"shift"],v=3*e.arrowsize*e.arrowwidth||0,h=v+f,d=v-f,m=3*e.startarrowsize*e.arrowwidth||0,y=m+f,g=m-f,x;if(o===l){var T=ss.findExtremes(r,[r.r2c(n)],{ppadplus:h,ppadminus:d}),M=ss.findExtremes(r,[r.r2c(i)],{ppadplus:Math.max(s,y),ppadminus:Math.max(u,g)});x={min:[T.min[0],M.min[0]],max:[T.max[0],M.max[0]]}}else y=i?y+i:y,g=i?g-i:g,x=ss.findExtremes(r,[r.r2c(n)],{ppadplus:Math.max(s,h,y),ppadminus:Math.max(u,d,g)});e._extremes[t]=x}});var eD=X((V0e,jq)=>{"use strict";var kre=Br(),Cre=X0();jq.exports=function(r,t,a,n){t=t||{};var i=a==="log"&&t.type==="linear",l=a==="linear"&&t.type==="log";if(!(i||l))return;var o=r._fullLayout.annotations,s=t._id.charAt(0),u,f;function v(d){var m=u[d],y=null;i?y=Cre(m,t.range):y=Math.pow(10,m),kre(y)||(y=null),n(f+d,y)}for(var h=0;h{"use strict";var m3=ed(),rD=Wq();tD.exports={moduleType:"component",name:"annotations",layoutAttributes:Su(),supplyLayoutDefaults:Jq(),includeBasePlot:Ac()("annotations"),calcAutorange:Qq(),draw:m3.draw,drawOne:m3.drawOne,drawRaw:m3.drawRaw,hasClickToShow:rD.hasClickToShow,onClick:rD.onClick,convertCoords:eD()}});var rd=X((W0e,nD)=>{"use strict";var ct=Su(),Sre=Ci().overrideAll,Lre=vt().templatedArray;nD.exports=Sre(Lre("annotation",{visible:ct.visible,x:{valType:"any"},y:{valType:"any"},z:{valType:"any"},ax:{valType:"number"},ay:{valType:"number"},xanchor:ct.xanchor,xshift:ct.xshift,yanchor:ct.yanchor,yshift:ct.yshift,text:ct.text,textangle:ct.textangle,font:ct.font,width:ct.width,height:ct.height,opacity:ct.opacity,align:ct.align,valign:ct.valign,bgcolor:ct.bgcolor,bordercolor:ct.bordercolor,borderpad:ct.borderpad,borderwidth:ct.borderwidth,showarrow:ct.showarrow,arrowcolor:ct.arrowcolor,arrowhead:ct.arrowhead,startarrowhead:ct.startarrowhead,arrowside:ct.arrowside,arrowsize:ct.arrowsize,startarrowsize:ct.startarrowsize,arrowwidth:ct.arrowwidth,standoff:ct.standoff,startstandoff:ct.startstandoff,hovertext:ct.hovertext,hoverlabel:ct.hoverlabel,captureevents:ct.captureevents}),"calc","from-root")});var lD=X((X0e,iD)=>{"use strict";var y3=Ne(),qre=mt(),Dre=si(),Ere=h3(),Pre=rd();iD.exports=function(r,t,a){Dre(r,t,{name:"annotations",handleItemDefaults:Rre,fullLayout:a.fullLayout})};function Rre(e,r,t,a){function n(o,s){return y3.coerce(e,r,Pre,o,s)}function i(o){var s=o+"axis",u={_fullLayout:{}};return u._fullLayout[s]=t[s],qre.coercePosition(r,u,n,o,o,.5)}var l=n("visible");l&&(Ere(e,r,a.fullLayout,n),i("x"),i("y"),i("z"),y3.noneOrAll(e,r,["x","y","z"]),r.xref="x",r.yref="y",r.zref="z",n("xanchor"),n("yanchor"),n("xshift"),n("yshift"),r.showarrow&&(r.axref="pixel",r.ayref="pixel",n("ax",-10),n("ay",-30),y3.noneOrAll(e,r,["ax","ay"])))}});var fD=X((Z0e,uD)=>{"use strict";var oD=Ne(),sD=mt();uD.exports=function(r){for(var t=r.fullSceneLayout,a=t.annotations,n=0;n{"use strict";function g3(e,r){var t=[0,0,0,0],a,n;for(a=0;a<4;++a)for(n=0;n<4;++n)t[n]+=e[4*a+n]*r[a];return t}function Nre(e,r){var t=g3(e.projection,g3(e.view,g3(e.model,[r[0],r[1],r[2],1])));return t}cD.exports=Nre});var dD=X((K0e,hD)=>{"use strict";var Fre=ed().drawRaw,Ire=vD(),Hre=["x","y","z"];hD.exports=function(r){for(var t=r.fullSceneLayout,a=r.dataScale,n=t.annotations,i=0;i1){o=!0;break}}o?r.fullLayout._infolayer.select(".annotation-"+r.id+'[data-index="'+i+'"]').remove():(l._pdata=Ire(r.glplot.cameraParams,[t.xaxis.r2l(l.x)*a[0],t.yaxis.r2l(l.y)*a[1],t.zaxis.r2l(l.z)*a[2]]),Fre(r.graphDiv,l,i,r.id,l._xa,l._ya))}}});var yD=X(($0e,mD)=>{"use strict";var Ore=_r(),pD=Ne();mD.exports={moduleType:"component",name:"annotations3d",schema:{subplots:{scene:{annotations:rd()}}},layoutAttributes:rd(),handleDefaults:lD(),includeBasePlot:Bre,convert:fD(),draw:dD()};function Bre(e,r){var t=Ore.subplotsRegistry.gl3d;if(t)for(var a=t.attrRegex,n=Object.keys(e),i=0;i{"use strict";var Q0e=ta(),gD=Ta(),bD=Qo().line,Yre=Si().dash,gi=Jt().extendFlat,Ure=vt().templatedArray,j0e=Tc(),Hu=ri(),_D=Su(),{shapeTexttemplateAttrs:Vre,templatefallbackAttrs:Gre}=To(),Wre=nv();xD.exports=Ure("shape",{visible:gi({},Hu.visible,{editType:"calc+arraydraw"}),showlegend:{valType:"boolean",dflt:!1,editType:"calc+arraydraw"},legend:gi({},Hu.legend,{editType:"calc+arraydraw"}),legendgroup:gi({},Hu.legendgroup,{editType:"calc+arraydraw"}),legendgrouptitle:{text:gi({},Hu.legendgrouptitle.text,{editType:"calc+arraydraw"}),font:gD({editType:"calc+arraydraw"}),editType:"calc+arraydraw"},legendrank:gi({},Hu.legendrank,{editType:"calc+arraydraw"}),legendwidth:gi({},Hu.legendwidth,{editType:"calc+arraydraw"}),type:{valType:"enumerated",values:["circle","rect","path","line"],editType:"calc+arraydraw"},layer:{valType:"enumerated",values:["below","above","between"],dflt:"above",editType:"arraydraw"},xref:gi({},_D.xref,{arrayOk:!0}),xsizemode:{valType:"enumerated",values:["scaled","pixel"],dflt:"scaled",editType:"calc+arraydraw"},xanchor:{valType:"any",editType:"calc+arraydraw"},x0:{valType:"any",editType:"calc+arraydraw"},x1:{valType:"any",editType:"calc+arraydraw"},x0shift:{valType:"number",dflt:0,min:-1,max:1,editType:"calc"},x1shift:{valType:"number",dflt:0,min:-1,max:1,editType:"calc"},yref:gi({},_D.yref,{arrayOk:!0}),ysizemode:{valType:"enumerated",values:["scaled","pixel"],dflt:"scaled",editType:"calc+arraydraw"},yanchor:{valType:"any",editType:"calc+arraydraw"},y0:{valType:"any",editType:"calc+arraydraw"},y1:{valType:"any",editType:"calc+arraydraw"},y0shift:{valType:"number",dflt:0,min:-1,max:1,editType:"calc"},y1shift:{valType:"number",dflt:0,min:-1,max:1,editType:"calc"},path:{valType:"string",editType:"calc+arraydraw"},opacity:{valType:"number",min:0,max:1,dflt:1,editType:"arraydraw"},line:{color:gi({},bD.color,{editType:"arraydraw"}),width:gi({},bD.width,{editType:"calc+arraydraw"}),dash:gi({},Yre,{editType:"arraydraw"}),editType:"calc+arraydraw"},fillcolor:{valType:"color",dflt:"rgba(0,0,0,0)",editType:"arraydraw"},fillrule:{valType:"enumerated",values:["evenodd","nonzero"],dflt:"evenodd",editType:"arraydraw"},editable:{valType:"boolean",dflt:!1,editType:"calc+arraydraw"},label:{text:{valType:"string",dflt:"",editType:"arraydraw"},texttemplate:Vre({},{keys:Object.keys(Wre)}),texttemplatefallback:Gre({editType:"arraydraw"}),font:gD({editType:"calc+arraydraw",colorEditType:"arraydraw"}),textposition:{valType:"enumerated",values:["top left","top center","top right","middle left","middle center","middle right","bottom left","bottom center","bottom right","start","middle","end"],editType:"arraydraw"},textangle:{valType:"angle",dflt:"auto",editType:"calc+arraydraw"},xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"auto",editType:"calc+arraydraw"},yanchor:{valType:"enumerated",values:["top","middle","bottom"],editType:"calc+arraydraw"},padding:{valType:"number",dflt:3,min:0,editType:"arraydraw"},editType:"arraydraw"},editType:"arraydraw"})});var TD=X((rve,wD)=>{"use strict";var Ou=Ne(),Tn=mt(),Xre=si(),Zre=b3(),Vc=Vl();wD.exports=function(r,t){Xre(r,t,{name:"shapes",handleItemDefaults:Kre})};function Jre(e,r){return e?"bottom":r.indexOf("top")!==-1?"top":r.indexOf("bottom")!==-1?"bottom":"middle"}function Kre(e,r,t){function a(M,b){return Ou.coerce(e,r,Zre,M,b)}r._isShape=!0;var n=a("visible");if(!n)return;var i=a("showlegend");a("legend"),a("legendgroup"),i&&(a("legendwidth"),a("legendgrouptitle.text"),Ou.coerceFont(a,"legendgrouptitle.font"),a("legendrank"));var l=a("path"),o=l?"path":"rect",s=a("type",o),u=s!=="path";u&&delete r.path,a("editable"),a("layer"),a("opacity"),a("fillcolor"),a("fillrule");var f=a("line.width");f&&(a("line.color"),a("line.dash"));var v=a("xsizemode"),h=a("ysizemode");let d=[.25,.75],m=[0,10];["x","y"].forEach(M=>{var b=M+"anchor",w=M==="x"?v:h,S={_fullLayout:t},k,q,D,P;let z=M+"ref",F=e[z];if(Array.isArray(F)&&F.length>0){let I=Vc.countDefiningCoords(s,l,M);P=Tn.coerceRefArray(e,r,S,M,void 0,"paper",I),r["_"+M+"refArray"]=!0}else P=Tn.coerceRef(e,r,S,M,void 0,"paper");if(Array.isArray(P))P.forEach(function(I){Tn.getRefType(I)==="range"&&(k=Tn.getFromId(S,I),k&&k._shapeIndices.indexOf(r._index)===-1&&k._shapeIndices.push(r._index))}),u&&[0,1].forEach(function(I){let H=P[I];Tn.getRefType(H)==="range"?(k=Tn.getFromId(S,H),q=Vc.shapePositionToRange(k),D=Vc.rangeToShapePosition(k),(k.type==="category"||k.type==="multicategory")&&a(M+I+"shift")):q=D=Ou.identity;let G=M+I,Y=e[G];if(e[G]=q(e[G],!0),w==="pixel"?a(G,m[I]):Tn.coercePosition(r,S,a,H,G,d[I]),r[G]=D(r[G]),e[G]=Y,I===0&&w==="pixel"){let B=e[b];e[b]=q(e[b],!0),Tn.coercePosition(r,S,a,H,b,.25),r[b]=D(r[b]),e[b]=B}});else{if(Tn.getRefType(P)==="range"?(k=Tn.getFromId(S,P),k._shapeIndices.push(r._index),D=Vc.rangeToShapePosition(k),q=Vc.shapePositionToRange(k),u&&(k.type==="category"||k.type==="multicategory")&&(a(M+"0shift"),a(M+"1shift"))):q=D=Ou.identity,u){let H=M+"0",O=M+"1",G=e[H],Y=e[O];e[H]=q(e[H],!0),e[O]=q(e[O],!0),w==="pixel"?(a(H,m[0]),a(O,m[1])):(Tn.coercePosition(r,S,a,P,H,d[0]),Tn.coercePosition(r,S,a,P,O,d[1])),r[H]=D(r[H]),r[O]=D(r[O]),e[H]=G,e[O]=Y}if(w==="pixel"){let H=e[b];e[b]=q(e[b],!0),Tn.coercePosition(r,S,a,P,b,.25),r[b]=D(r[b]),e[b]=H}}}),u&&Ou.noneOrAll(e,r,["x0","x1","y0","y1"]);var y=s==="line",g,x;if(u&&(g=a("label.texttemplate"),a("label.texttemplatefallback")),g||(x=a("label.text")),x||g){a("label.textangle");var T=a("label.textposition",y?"middle":"middle center");a("label.xanchor"),a("label.yanchor",Jre(y,T)),a("label.padding"),Ou.coerceFont(a,"label.font",t.font)}}});var kD=X((tve,AD)=>{"use strict";var $re=Er(),MD=Ne();function Qre(e,r){return e?"bottom":r.indexOf("top")!==-1?"top":r.indexOf("bottom")!==-1?"bottom":"middle"}AD.exports=function(r,t,a){a("newshape.visible"),a("newshape.name"),a("newshape.showlegend"),a("newshape.legend"),a("newshape.legendwidth"),a("newshape.legendgroup"),a("newshape.legendgrouptitle.text"),MD.coerceFont(a,"newshape.legendgrouptitle.font"),a("newshape.legendrank"),a("newshape.drawdirection"),a("newshape.layer"),a("newshape.fillcolor"),a("newshape.fillrule"),a("newshape.opacity");var n=a("newshape.line.width");if(n){var i=(r||{}).plot_bgcolor||"#FFF";a("newshape.line.color",$re.contrast(i)),a("newshape.line.dash")}var l=r.dragmode==="drawline",o=a("newshape.label.text"),s=a("newshape.label.texttemplate");if(a("newshape.label.texttemplatefallback"),o||s){a("newshape.label.textangle");var u=a("newshape.label.textposition",l?"middle":"middle center");a("newshape.label.xanchor"),a("newshape.label.yanchor",Qre(l,u)),a("newshape.label.padding"),MD.coerceFont(a,"newshape.label.font",t.font)}a("activeshape.fillcolor"),a("activeshape.opacity")}});var PD=X((ave,ED)=>{"use strict";var _3=Ne(),Xn=mt(),Wi=ac(),x3=Vl();ED.exports=function(r){var t=r._fullLayout,a=_3.filterVisible(t.shapes);if(!(!a.length||!r._fullData.length))for(var n=0;n{l=Xn.getFromId(r,v),i._extremes[l._id]=Xn.findExtremes(l,h,SD(i))})}else i.xref!=="paper"&&s!=="domain"&&(l=Xn.getFromId(r,i.xref),o=qD(l,i,Wi.paramIsX),o&&(i._extremes[l._id]=Xn.findExtremes(l,o,SD(i))));if(u==="array"){let f=CD(r,i,"y");Object.entries(f).forEach(([v,h])=>{l=Xn.getFromId(r,v),i._extremes[l._id]=Xn.findExtremes(l,h,LD(i))})}else i.yref!=="paper"&&u!=="domain"&&(l=Xn.getFromId(r,i.yref),o=qD(l,i,Wi.paramIsY),o&&(i._extremes[l._id]=Xn.findExtremes(l,o,LD(i))))}};function CD(e,r,t){let a=r[t+"ref"],n=t==="x"?Wi.paramIsX:Wi.paramIsY;function i(v,h){v==="paper"||Xn.getRefType(v)==="domain"||(l[v]||(l[v]=[]),l[v].push(h))}let l={};if(r.type==="path"&&r.path){let v=r.path.match(Wi.segmentRE)||[];for(var o=0,s=0;sm&&(i(a[o],y[m]),o++)}}else i(a[0],r[t+"0"]),i(a[1],r[t+"1"]);let u={};for(let v in l){let h=Xn.getFromId(e,v);if(h){var f=h.type==="category"||h.type==="multicategory"?h.r2c:h.d2c;h.type==="date"&&(f=x3.decodeDate(f)),u[h._id]=l[v].map(f)}}return u}function SD(e){return DD(e.line.width,e.xsizemode,e.x0,e.x1,e.path,!1)}function LD(e){return DD(e.line.width,e.ysizemode,e.y0,e.y1,e.path,!0)}function DD(e,r,t,a,n,i){var l=e/2,o=i;if(r==="pixel"){var s=n?x3.extractPathCoords(n,i?Wi.paramIsY:Wi.paramIsX):[t,a],u=_3.aggNums(Math.max,null,s),f=_3.aggNums(Math.min,null,s),v=f<0?Math.abs(f)+l:l,h=u>0?u+l:l;return{ppad:l,ppadplus:o?v:h,ppadminus:o?h:v}}else return{ppad:l}}function qD(e,r,t){var a=e._id.charAt(0)==="x"?"x":"y",n=e.type==="category"||e.type==="multicategory",i,l,o=0,s=0,u=n?e.r2c:e.d2c,f=r[a+"sizemode"]==="scaled";if(f?(i=r[a+"0"],l=r[a+"1"],n&&(o=r[a+"0shift"],s=r[a+"1shift"])):(i=r[a+"anchor"],l=r[a+"anchor"]),i!==void 0)return[u(i)+o,u(l)+s];if(r.path){var v=1/0,h=-1/0,d=r.path.match(Wi.segmentRE),m,y,g,x,T;for(e.type==="date"&&(u=x3.decodeDate(u)),m=0;mh&&(h=T)));if(h>=v)return[v,h]}}});var ND=X((nve,zD)=>{"use strict";var RD=U1();zD.exports={moduleType:"component",name:"shapes",layoutAttributes:b3(),supplyLayoutDefaults:TD(),supplyDrawNewShapeDefaults:kD(),includeBasePlot:Ac()("shapes"),calcAutorange:PD(),draw:RD.draw,drawOne:RD.drawOne}});var w3=X((lve,ID)=>{"use strict";var FD=ta(),jre=vt().templatedArray,ive=Tc();ID.exports=jre("image",{visible:{valType:"boolean",dflt:!0,editType:"arraydraw"},source:{valType:"string",editType:"arraydraw"},layer:{valType:"enumerated",values:["below","above"],dflt:"above",editType:"arraydraw"},sizex:{valType:"number",dflt:0,editType:"arraydraw"},sizey:{valType:"number",dflt:0,editType:"arraydraw"},sizing:{valType:"enumerated",values:["fill","contain","stretch"],dflt:"contain",editType:"arraydraw"},opacity:{valType:"number",min:0,max:1,dflt:1,editType:"arraydraw"},x:{valType:"any",dflt:0,editType:"arraydraw"},y:{valType:"any",dflt:0,editType:"arraydraw"},xanchor:{valType:"enumerated",values:["left","center","right"],dflt:"left",editType:"arraydraw"},yanchor:{valType:"enumerated",values:["top","middle","bottom"],dflt:"top",editType:"arraydraw"},xref:{valType:"enumerated",values:["paper",FD.idRegex.x.toString()],dflt:"paper",editType:"arraydraw"},yref:{valType:"enumerated",values:["paper",FD.idRegex.y.toString()],dflt:"paper",editType:"arraydraw"},editType:"arraydraw"})});var OD=X((ove,HD)=>{"use strict";var ete=Ne(),T3=mt(),rte=si(),tte=w3(),ate="images";HD.exports=function(r,t){var a={name:ate,handleItemDefaults:nte};rte(r,t,a)};function nte(e,r,t){function a(h,d){return ete.coerce(e,r,tte,h,d)}var n=a("source"),i=a("visible",!!n);if(!i)return r;a("layer"),a("xanchor"),a("yanchor"),a("sizex"),a("sizey"),a("sizing"),a("opacity");for(var l={_fullLayout:t},o=["x","y"],s=0;s<2;s++){var u=o[s],f=T3.coerceRef(e,r,l,u,"paper",void 0);if(f!=="paper"){var v=T3.getFromId(l,f);v._imgIndices.push(r._index)}T3.coercePosition(r,l,a,f,u,0)}return r}});var VD=X((sve,UD)=>{"use strict";var BD=Nr(),ite=at(),Bu=mt(),YD=$t(),lte=Eo();UD.exports=function(r){var t=r._fullLayout,a=[],n={},i=[],l,o;for(o=0;o{"use strict";var GD=Br(),ote=X0();WD.exports=function(r,t,a,n){t=t||{};var i=a==="log"&&t.type==="linear",l=a==="linear"&&t.type==="log";if(i||l){for(var o=r._fullLayout.images,s=t._id.charAt(0),u,f,v=0;v{"use strict";ZD.exports={moduleType:"component",name:"images",layoutAttributes:w3(),supplyLayoutDefaults:OD(),includeBasePlot:Ac()("images"),draw:VD(),convertCoords:XD()}});var td=X((cve,KD)=>{"use strict";KD.exports={name:"updatemenus",containerClassName:"updatemenu-container",headerGroupClassName:"updatemenu-header-group",headerClassName:"updatemenu-header",headerArrowClassName:"updatemenu-header-arrow",dropdownButtonGroupClassName:"updatemenu-dropdown-button-group",dropdownButtonClassName:"updatemenu-dropdown-button",buttonClassName:"updatemenu-button",itemRectClassName:"updatemenu-item-rect",itemTextClassName:"updatemenu-item-text",menuIndexAttrName:"updatemenu-active-index",autoMarginIdRoot:"updatemenu-",blankHeaderOpts:{label:" "},minWidth:30,minHeight:30,textPadX:24,arrowPadX:16,rx:2,ry:2,textOffsetX:12,textOffsetY:3,arrowOffsetX:4,gapButtonHeader:5,gapButton:2,activeColor:"#F4FAFF",hoverColor:"#F4FAFF",arrowSymbol:{left:"\u25C4",right:"\u25BA",up:"\u25B2",down:"\u25BC"}}});var M3=X((vve,QD)=>{"use strict";var ste=Ta(),ute=ki(),fte=Jt().extendFlat,cte=Ci().overrideAll,vte=iv(),$D=vt().templatedArray,hte=$D("button",{visible:{valType:"boolean"},method:{valType:"enumerated",values:["restyle","relayout","animate","update","skip"],dflt:"restyle"},args:{valType:"info_array",freeLength:!0,items:[{valType:"any"},{valType:"any"},{valType:"any"}]},args2:{valType:"info_array",freeLength:!0,items:[{valType:"any"},{valType:"any"},{valType:"any"}]},label:{valType:"string",dflt:""},execute:{valType:"boolean",dflt:!0}});QD.exports=cte($D("updatemenu",{_arrayAttrRegexps:[/^updatemenus\[(0|[1-9][0-9]+)\]\.buttons/],visible:{valType:"boolean"},type:{valType:"enumerated",values:["dropdown","buttons"],dflt:"dropdown"},direction:{valType:"enumerated",values:["left","right","up","down"],dflt:"down"},active:{valType:"integer",min:-1,dflt:0},showactive:{valType:"boolean",dflt:!0},buttons:hte,x:{valType:"number",min:-2,max:3,dflt:-.05},xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"right"},y:{valType:"number",min:-2,max:3,dflt:1},yanchor:{valType:"enumerated",values:["auto","top","middle","bottom"],dflt:"top"},pad:fte(vte({editType:"arraydraw"}),{}),font:ste({}),bgcolor:{valType:"color"},bordercolor:{valType:"color",dflt:ute.borderLine},borderwidth:{valType:"number",min:0,dflt:1,editType:"arraydraw"}}),"arraydraw","from-root")});var tE=X((hve,rE)=>{"use strict";var ad=Ne(),jD=si(),eE=M3(),dte=td(),pte=dte.name,mte=eE.buttons;rE.exports=function(r,t){var a={name:pte,handleItemDefaults:yte};jD(r,t,a)};function yte(e,r,t){function a(l,o){return ad.coerce(e,r,eE,l,o)}var n=jD(e,r,{name:"buttons",handleItemDefaults:gte}),i=a("visible",n.length>0);i&&(a("active"),a("direction"),a("type"),a("showactive"),a("x"),a("y"),ad.noneOrAll(e,r,["x","y"]),a("xanchor"),a("yanchor"),a("pad.t"),a("pad.r"),a("pad.b"),a("pad.l"),ad.coerceFont(a,"font",t.font),a("bgcolor",t.paper_bgcolor),a("bordercolor"),a("borderwidth"))}function gte(e,r){function t(n,i){return ad.coerce(e,r,mte,n,i)}var a=t("visible",e.method==="skip"||Array.isArray(e.args));a&&(t("method"),t("args"),t("args2"),t("label"),t("execute"))}});var iE=X((dve,nE)=>{"use strict";nE.exports=wt;var bi=Nr(),aE=Er(),Yu=at(),nd=Ne();function wt(e,r,t){this.gd=e,this.container=r,this.id=t,this.position=null,this.translateX=null,this.translateY=null,this.hbar=null,this.vbar=null,this.bg=this.container.selectAll("rect.scrollbox-bg").data([0]),this.bg.exit().on(".drag",null).on("wheel",null).remove(),this.bg.enter().append("rect").classed("scrollbox-bg",!0).style("pointer-events","all").attr({opacity:0,x:0,y:0,width:0,height:0})}wt.barWidth=2;wt.barLength=20;wt.barRadius=2;wt.barPad=1;wt.barColor="#808BA4";wt.prototype.enable=function(r,t,a){var n=this.gd._fullLayout,i=n.width,l=n.height;this.position=r;var o=this.position.l,s=this.position.w,u=this.position.t,f=this.position.h,v=this.position.direction,h=v==="down",d=v==="left",m=v==="right",y=v==="up",g=s,x=f,T,M,b,w;!h&&!d&&!m&&!y&&(this.position.direction="down",h=!0);var S=h||y;S?(T=o,M=T+g,h?(b=u,w=Math.min(b+x,l),x=w-b):(w=u+x,b=Math.max(w-x,0),x=w-b)):(b=u,w=b+x,d?(M=o+g,T=Math.max(M-g,0),g=M-T):(T=o,M=Math.min(T+g,i),g=M-T)),this._box={l:T,t:b,w:g,h:x};var k=s>g,q=wt.barLength+2*wt.barPad,D=wt.barWidth+2*wt.barPad,P=o,z=u+f;z+D>l&&(z=l-D);var F=this.container.selectAll("rect.scrollbar-horizontal").data(k?[0]:[]);F.exit().on(".drag",null).remove(),F.enter().append("rect").classed("scrollbar-horizontal",!0).call(aE.fill,wt.barColor),k?(this.hbar=F.attr({rx:wt.barRadius,ry:wt.barRadius,x:P,y:z,width:q,height:D}),this._hbarXMin=P+q/2,this._hbarTranslateMax=g-q):(delete this.hbar,delete this._hbarXMin,delete this._hbarTranslateMax);var I=f>x,H=wt.barWidth+2*wt.barPad,O=wt.barLength+2*wt.barPad,G=o+s,Y=u;G+H>i&&(G=i-H);var B=this.container.selectAll("rect.scrollbar-vertical").data(I?[0]:[]);B.exit().on(".drag",null).remove(),B.enter().append("rect").classed("scrollbar-vertical",!0).call(aE.fill,wt.barColor),I?(this.vbar=B.attr({rx:wt.barRadius,ry:wt.barRadius,x:G,y:Y,width:H,height:O}),this._vbarYMin=Y+O/2,this._vbarTranslateMax=x-O):(delete this.vbar,delete this._vbarYMin,delete this._vbarTranslateMax);var V=this.id,J=T-.5,Z=I?M+H+.5:M+.5,re=b-.5,se=k?w+D+.5:w+.5,ve=n._topdefs.selectAll("#"+V).data(k||I?[0]:[]);if(ve.exit().remove(),ve.enter().append("clipPath").attr("id",V).append("rect"),k||I?(this._clipRect=ve.select("rect").attr({x:Math.floor(J),y:Math.floor(re),width:Math.ceil(Z)-Math.floor(J),height:Math.ceil(se)-Math.floor(re)}),this.container.call(Yu.setClipUrl,V,this.gd),this.bg.attr({x:o,y:u,width:s,height:f})):(this.bg.attr({width:0,height:0}),this.container.on("wheel",null).on(".drag",null).call(Yu.setClipUrl,null),delete this._clipRect),k||I){var xe=bi.behavior.drag().on("dragstart",function(){bi.event.sourceEvent.preventDefault()}).on("drag",this._onBoxDrag.bind(this));this.container.on("wheel",null).on("wheel",this._onBoxWheel.bind(this)).on(".drag",null).call(xe);var Q=bi.behavior.drag().on("dragstart",function(){bi.event.sourceEvent.preventDefault(),bi.event.sourceEvent.stopPropagation()}).on("drag",this._onBarDrag.bind(this));k&&this.hbar.on(".drag",null).call(Q),I&&this.vbar.on(".drag",null).call(Q)}this.setTranslate(t,a)};wt.prototype.disable=function(){(this.hbar||this.vbar)&&(this.bg.attr({width:0,height:0}),this.container.on("wheel",null).on(".drag",null).call(Yu.setClipUrl,null),delete this._clipRect),this.hbar&&(this.hbar.on(".drag",null),this.hbar.remove(),delete this.hbar,delete this._hbarXMin,delete this._hbarTranslateMax),this.vbar&&(this.vbar.on(".drag",null),this.vbar.remove(),delete this.vbar,delete this._vbarYMin,delete this._vbarTranslateMax)};wt.prototype._onBoxDrag=function(){var r=this.translateX,t=this.translateY;this.hbar&&(r-=bi.event.dx),this.vbar&&(t-=bi.event.dy),this.setTranslate(r,t)};wt.prototype._onBoxWheel=function(){var r=this.translateX,t=this.translateY;this.hbar&&(r+=bi.event.deltaY),this.vbar&&(t+=bi.event.deltaY),this.setTranslate(r,t)};wt.prototype._onBarDrag=function(){var r=this.translateX,t=this.translateY;if(this.hbar){var a=r+this._hbarXMin,n=a+this._hbarTranslateMax,i=nd.constrain(bi.event.x,a,n),l=(i-a)/(n-a),o=this.position.w-this._box.w;r=l*o}if(this.vbar){var s=t+this._vbarYMin,u=s+this._vbarTranslateMax,f=nd.constrain(bi.event.y,s,u),v=(f-s)/(u-s),h=this.position.h-this._box.h;t=v*h}this.setTranslate(r,t)};wt.prototype.setTranslate=function(r,t){var a=this.position.w-this._box.w,n=this.position.h-this._box.h;if(r=nd.constrain(r||0,0,a),t=nd.constrain(t||0,0,n),this.translateX=r,this.translateY=t,this.container.call(Yu.setTranslate,this._box.l-this.position.l-r,this._box.t-this.position.t-t),this._clipRect&&this._clipRect.attr({x:Math.floor(this.position.l+r-.5),y:Math.floor(this.position.t+t-.5)}),this.hbar){var i=r/a;this.hbar.call(Yu.setTranslate,r+i*this._hbarTranslateMax,t)}if(this.vbar){var l=t/n;this.vbar.call(Yu.setTranslate,r,t+l*this._vbarTranslateMax)}}});var pE=X((pve,dE)=>{"use strict";var Uu=Nr(),Gc=Ut(),Wc=Er(),Vu=at(),Mn=Ne(),id=da(),bte=vt().arrayEditor,oE=La().LINE_SPACING,xr=td(),_te=iE();dE.exports=function(r){var t=r._fullLayout,a=Mn.filterVisible(t[xr.name]);function n(h){Gc.autoMargin(r,vE(h))}var i=t._menulayer.selectAll("g."+xr.containerClassName).data(a.length>0?[0]:[]);if(i.enter().append("g").classed(xr.containerClassName,!0).style("cursor","pointer"),i.exit().each(function(){Uu.select(this).selectAll("g."+xr.headerGroupClassName).each(n)}).remove(),a.length!==0){var l=i.selectAll("g."+xr.headerGroupClassName).data(a,xte);l.enter().append("g").classed(xr.headerGroupClassName,!0);for(var o=Mn.ensureSingle(i,"g",xr.dropdownButtonGroupClassName,function(h){h.style("pointer-events","all")}),s=0;s{"use strict";var Ste=td();mE.exports={moduleType:"component",name:Ste.name,layoutAttributes:M3(),supplyLayoutDefaults:tE(),draw:pE()}});var Zc=X((yve,gE)=>{"use strict";gE.exports={name:"sliders",containerClassName:"slider-container",groupClassName:"slider-group",inputAreaClass:"slider-input-area",railRectClass:"slider-rail-rect",railTouchRectClass:"slider-rail-touch-rect",gripRectClass:"slider-grip-rect",tickRectClass:"slider-tick-rect",inputProxyClass:"slider-input-proxy",labelsClass:"slider-labels",labelGroupClass:"slider-label-group",labelClass:"slider-label",currentValueClass:"slider-current-value",railHeight:5,menuIndexAttrName:"slider-active-index",autoMarginIdRoot:"slider-",minWidth:30,minHeight:30,textPadX:40,arrowOffsetX:4,railRadius:2,railWidth:5,railBorder:4,railBorderWidth:1,railBorderColor:"#bec8d9",railBgColor:"#f8fafc",railInset:8,stepInset:10,gripRadius:10,gripWidth:20,gripHeight:20,gripBorder:20,gripBorderWidth:1,gripBorderColor:"#bec8d9",gripBgColor:"#f6f8fa",gripBgActiveColor:"#dbdde0",labelPadding:8,labelOffset:0,tickWidth:1,tickColor:"#333",tickOffset:25,tickLength:7,minorTickOffset:25,minorTickColor:"#333",minorTickLength:4,currentValuePadding:8,currentValueInset:0}});var S3=X((gve,xE)=>{"use strict";var bE=Ta(),Lte=iv(),qte=Jt().extendDeepAll,Dte=Ci().overrideAll,Ete=Af(),_E=vt().templatedArray,us=Zc(),Pte=_E("step",{visible:{valType:"boolean",dflt:!0},method:{valType:"enumerated",values:["restyle","relayout","animate","update","skip"],dflt:"restyle"},args:{valType:"info_array",freeLength:!0,items:[{valType:"any"},{valType:"any"},{valType:"any"}]},label:{valType:"string"},value:{valType:"string"},execute:{valType:"boolean",dflt:!0}});xE.exports=Dte(_E("slider",{visible:{valType:"boolean",dflt:!0},active:{valType:"number",min:0,dflt:0},steps:Pte,lenmode:{valType:"enumerated",values:["fraction","pixels"],dflt:"fraction"},len:{valType:"number",min:0,dflt:1},x:{valType:"number",min:-2,max:3,dflt:0},pad:qte(Lte({editType:"arraydraw"}),{},{t:{dflt:20}}),xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"left"},y:{valType:"number",min:-2,max:3,dflt:0},yanchor:{valType:"enumerated",values:["auto","top","middle","bottom"],dflt:"top"},transition:{duration:{valType:"number",min:0,dflt:150},easing:{valType:"enumerated",values:Ete.transition.easing.values,dflt:"cubic-in-out"}},currentvalue:{visible:{valType:"boolean",dflt:!0},xanchor:{valType:"enumerated",values:["left","center","right"],dflt:"left"},offset:{valType:"number",dflt:10},prefix:{valType:"string"},suffix:{valType:"string"},font:bE({})},font:bE({}),activebgcolor:{valType:"color",dflt:us.gripBgActiveColor},bgcolor:{valType:"color",dflt:us.railBgColor},bordercolor:{valType:"color",dflt:us.railBorderColor},borderwidth:{valType:"number",min:0,dflt:us.railBorderWidth},ticklen:{valType:"number",min:0,dflt:us.tickLength},tickcolor:{valType:"color",dflt:us.tickColor},tickwidth:{valType:"number",min:0,dflt:1},minorticklen:{valType:"number",min:0,dflt:us.minorTickLength}}),"arraydraw","from-root")});var AE=X((bve,ME)=>{"use strict";var Gu=Ne(),wE=si(),TE=S3(),Rte=Zc(),zte=Rte.name,Nte=TE.steps;ME.exports=function(r,t){wE(r,t,{name:zte,handleItemDefaults:Fte})};function Fte(e,r,t){function a(v,h){return Gu.coerce(e,r,TE,v,h)}for(var n=wE(e,r,{name:"steps",handleItemDefaults:Ite}),i=0,l=0;l{"use strict";var _i=Nr(),ld=Ut(),$l=Er(),xi=at(),An=Ne(),Hte=An.strTranslate,Jc=da(),Ote=vt().arrayEditor,Pr=Zc(),D3=La(),SE=D3.LINE_SPACING,L3=D3.FROM_TL,q3=D3.FROM_BR;RE.exports=function(r){var t=r._context.staticPlot,a=r._fullLayout,n=Bte(a,r),i=a._infolayer.selectAll("g."+Pr.containerClassName).data(n.length>0?[0]:[]);i.enter().append("g").classed(Pr.containerClassName,!0).style("cursor",t?null:"ew-resize");function l(f){f._commandObserver&&(f._commandObserver.remove(),delete f._commandObserver),ld.autoMargin(r,LE(f))}if(i.exit().each(function(){_i.select(this).selectAll("g."+Pr.groupClassName).each(l)}).remove(),n.length!==0){var o=i.selectAll("g."+Pr.groupClassName).data(n,Yte);o.enter().append("g").classed(Pr.groupClassName,!0),o.exit().each(l).remove();for(var s=0;s0&&(o=o.transition().duration(r.transition.duration).ease(r.transition.easing)),o.attr("transform",Hte(l-Pr.gripWidth*.5,r._dims.currentValueTotalHeight))}}function E3(e,r){var t=e._dims;return t.inputAreaStart+Pr.stepInset+(t.inputAreaLength-2*Pr.stepInset)*Math.min(1,Math.max(0,r))}function CE(e,r){var t=e._dims;return Math.min(1,Math.max(0,(r-Pr.stepInset-t.inputAreaStart)/(t.inputAreaLength-2*Pr.stepInset-2*t.inputAreaStart)))}function Jte(e,r,t){var a=t._dims,n=An.ensureSingle(e,"rect",Pr.railTouchRectClass,function(i){i.call(EE,r,e,t).style("pointer-events","all")});n.attr({width:a.inputAreaLength,height:Math.max(a.inputAreaWidth,Pr.tickOffset+t.ticklen+a.labelHeight)}).call($l.fill,t.bgcolor).attr("opacity",0),xi.setTranslate(n,0,a.currentValueTotalHeight)}function Kte(e,r){var t=r._dims,a=t.inputAreaLength-Pr.railInset*2,n=An.ensureSingle(e,"rect",Pr.railRectClass);n.attr({width:a,height:Pr.railWidth,rx:Pr.railRadius,ry:Pr.railRadius,"shape-rendering":"crispEdges"}).call($l.stroke,r.bordercolor).call($l.fill,r.bgcolor).style("stroke-width",r.borderwidth+"px"),xi.setTranslate(n,Pr.railInset,(t.inputAreaWidth-Pr.railWidth)*.5+t.currentValueTotalHeight)}});var FE=X((xve,NE)=>{"use strict";var $te=Zc();NE.exports={moduleType:"component",name:$te.name,layoutAttributes:S3(),supplyLayoutDefaults:AE(),draw:zE()}});var sd=X((wve,HE)=>{"use strict";var IE=ki();HE.exports={bgcolor:{valType:"color",dflt:IE.background,editType:"plot"},bordercolor:{valType:"color",dflt:IE.defaultLine,editType:"plot"},borderwidth:{valType:"integer",dflt:0,min:0,editType:"plot"},autorange:{valType:"boolean",dflt:!0,editType:"calc",impliedEdits:{"range[0]":void 0,"range[1]":void 0}},range:{valType:"info_array",items:[{valType:"any",editType:"calc",impliedEdits:{"^autorange":!1}},{valType:"any",editType:"calc",impliedEdits:{"^autorange":!1}}],editType:"calc",impliedEdits:{autorange:!1}},thickness:{valType:"number",dflt:.15,min:0,max:1,editType:"plot"},visible:{valType:"boolean",dflt:!0,editType:"calc"},editType:"calc"}});var P3=X((Tve,OE)=>{"use strict";OE.exports={_isSubplotObj:!0,rangemode:{valType:"enumerated",values:["auto","fixed","match"],dflt:"match",editType:"calc"},range:{valType:"info_array",items:[{valType:"any",editType:"plot"},{valType:"any",editType:"plot"}],editType:"plot"},editType:"calc"}});var ud=X((Mve,BE)=>{"use strict";BE.exports={name:"rangeslider",containerClassName:"rangeslider-container",bgClassName:"rangeslider-bg",rangePlotClassName:"rangeslider-rangeplot",maskMinClassName:"rangeslider-mask-min",maskMaxClassName:"rangeslider-mask-max",slideBoxClassName:"rangeslider-slidebox",grabberMinClassName:"rangeslider-grabber-min",grabAreaMinClassName:"rangeslider-grabarea-min",handleMinClassName:"rangeslider-handle-min",grabberMaxClassName:"rangeslider-grabber-max",grabAreaMaxClassName:"rangeslider-grabarea-max",handleMaxClassName:"rangeslider-handle-max",maskMinOppAxisClassName:"rangeslider-mask-min-opp-axis",maskMaxOppAxisClassName:"rangeslider-mask-max-opp-axis",maskColor:"rgba(0,0,0,0.4)",maskOppAxisColor:"rgba(0,0,0,0.2)",slideBoxFill:"transparent",slideBoxCursor:"ew-resize",grabAreaFill:"transparent",grabAreaCursor:"col-resize",grabAreaWidth:10,handleWidth:4,handleRadius:1,handleStrokeWidth:1,extraPad:15}});var VE=X(cd=>{"use strict";var Qte=$t(),jte=da(),YE=ud(),eae=La().LINE_SPACING,fd=YE.name;function UE(e){var r=e&&e[fd];return r&&r.visible}cd.isVisible=UE;cd.makeData=function(e){for(var r=Qte.list({_fullLayout:e},"x",!0),t=e.margin,a=[],n=0;n{"use strict";var vd=Ne(),GE=vt(),WE=$t(),rae=sd(),tae=P3();XE.exports=function(r,t,a){var n=r[a],i=t[a];if(!(n.rangeslider||t._requestRangeslider[i._id]))return;vd.isPlainObject(n.rangeslider)||(n.rangeslider={});var l=n.rangeslider,o=GE.newContainer(i,"rangeslider");function s(w,S){return vd.coerce(l,o,rae,w,S)}var u,f;function v(w,S){return vd.coerce(u,f,tae,w,S)}var h=s("visible");if(h){s("bgcolor",t.plot_bgcolor),s("bordercolor"),s("borderwidth"),s("thickness"),s("autorange",!i.isValidRange(l.range)),s("range");var d=t._subplots;if(d)for(var m=d.cartesian.filter(function(w){return w.slice(0,Math.max(0,w.indexOf("y")))===WE.name2id(a)}).map(function(w){return w.slice(w.indexOf("y"),w.length)}),y=vd.simpleMap(m,WE.id2name),g=0;g{"use strict";var aae=$t().list,nae=Uf().getAutoRange,iae=ud();JE.exports=function(r){for(var t=aae(r,"x",!0),a=0;a{"use strict";var hd=Nr(),lae=_r(),oae=Ut(),qt=Ne(),dd=qt.strTranslate,QE=at(),Ql=Er(),sae=Bf(),uae=Qh(),R3=$t(),fae=fi(),cae=Hl(),Ir=ud();jE.exports=function(e){for(var r=e._fullLayout,t=r._rangeSliderData,a=0;a=B.max)G=z[Y+1];else if(O=B.pmax)G=z[Y+1];else if(O0?e.touches[0].clientX:0}function vae(e,r,t,a){if(r._context.staticPlot)return;var n=e.select("rect."+Ir.slideBoxClassName).node(),i=e.select("rect."+Ir.grabAreaMinClassName).node(),l=e.select("rect."+Ir.grabAreaMaxClassName).node();function o(){var s=hd.event,u=s.target,f=$E(s),v=f-e.node().getBoundingClientRect().left,h=a.d2p(t._rl[0]),d=a.d2p(t._rl[1]),m=fae.coverSlip();this.addEventListener("touchmove",y),this.addEventListener("touchend",g),m.addEventListener("mousemove",y),m.addEventListener("mouseup",g);function y(x){var T=$E(x),M=+T-f,b,w,S;switch(u){case n:if(S="ew-resize",h+M>t._length||d+M<0)return;b=h+M,w=d+M;break;case i:if(S="col-resize",h+M>t._length)return;b=h+M,w=d;break;case l:if(S="col-resize",d+M<0)return;b=h,w=d+M;break;default:S="ew-resize",b=v,w=v+M;break}if(w{"use strict";var wae=Ne(),Tae=sd(),Mae=P3(),z3=VE();rP.exports={moduleType:"component",name:"rangeslider",schema:{subplots:{xaxis:{rangeslider:wae.extendFlat({},Tae,{yaxis:Mae})}}},layoutAttributes:sd(),handleDefaults:ZE(),calcAutorange:KE(),draw:eP(),isVisible:z3.isVisible,makeData:z3.makeData,autoMarginOpts:z3.autoMarginOpts}});var pd=X((qve,nP)=>{"use strict";var Aae=Ta(),aP=ki(),kae=vt().templatedArray,Cae=kae("button",{visible:{valType:"boolean",dflt:!0,editType:"plot"},step:{valType:"enumerated",values:["month","year","day","hour","minute","second","all"],dflt:"month",editType:"plot"},stepmode:{valType:"enumerated",values:["backward","todate"],dflt:"backward",editType:"plot"},count:{valType:"number",min:0,dflt:1,editType:"plot"},label:{valType:"string",editType:"plot"},editType:"plot"});nP.exports={visible:{valType:"boolean",editType:"plot"},buttons:Cae,x:{valType:"number",min:-2,max:3,editType:"plot"},xanchor:{valType:"enumerated",values:["auto","left","center","right"],dflt:"left",editType:"plot"},y:{valType:"number",min:-2,max:3,editType:"plot"},yanchor:{valType:"enumerated",values:["auto","top","middle","bottom"],dflt:"bottom",editType:"plot"},font:Aae({editType:"plot"}),bgcolor:{valType:"color",dflt:aP.lightLine,editType:"plot"},activecolor:{valType:"color",editType:"plot"},bordercolor:{valType:"color",dflt:aP.defaultLine,editType:"plot"},borderwidth:{valType:"number",min:0,dflt:0,editType:"plot"},editType:"plot"}});var N3=X((Dve,iP)=>{"use strict";iP.exports={yPad:.02,minButtonWidth:30,rx:3,ry:3,lightAmount:25,darkAmount:10}});var sP=X((Eve,oP)=>{"use strict";var md=Ne(),Sae=Er(),Lae=vt(),qae=si(),lP=pd(),F3=N3();oP.exports=function(r,t,a,n,i){var l=r.rangeselector||{},o=Lae.newContainer(t,"rangeselector");function s(d,m){return md.coerce(l,o,lP,d,m)}var u=qae(l,o,{name:"buttons",handleItemDefaults:Dae,calendar:i}),f=s("visible",u.length>0);if(f){var v=Eae(t,a,n);s("x",v[0]),s("y",v[1]),md.noneOrAll(r,t,["x","y"]),s("xanchor"),s("yanchor"),md.coerceFont(s,"font",a.font);var h=s("bgcolor");s("activecolor",Sae.contrast(h,F3.lightAmount,F3.darkAmount)),s("bordercolor"),s("borderwidth")}};function Dae(e,r,t,a){var n=a.calendar;function i(s,u){return md.coerce(e,r,lP.buttons,s,u)}var l=i("visible");if(l){var o=i("step");o!=="all"&&(n&&n!=="gregorian"&&(o==="month"||o==="year")?r.stepmode="backward":i("stepmode"),i("count")),i("label")}}function Eae(e,r,t){for(var a=t.filter(function(o){return r[o].anchor===e._id}),n=0,i=0;i{"use strict";var Pae=xp(),Rae=Ne().titleCase;uP.exports=function(r,t){var a=r._name,n={};if(t.step==="all")n[a+".autorange"]=!0;else{var i=zae(r,t);n[a+".range[0]"]=i[0],n[a+".range[1]"]=i[1]}return n};function zae(e,r){var t=e.range,a=new Date(e.r2l(t[1])),n=r.step,i=Pae["utc"+Rae(n)],l=r.count,o;switch(r.stepmode){case"backward":o=e.l2r(+i.offset(a,-l));break;case"todate":var s=i.offset(a,-l);o=e.l2r(+i.ceil(s));break}var u=t[1];return[o,u]}});var gP=X((Rve,yP)=>{"use strict";var gd=Nr(),Nae=_r(),Fae=Ut(),cP=Er(),mP=at(),cl=Ne(),vP=cl.strTranslate,yd=da(),Iae=$t(),O3=La(),hP=O3.LINE_SPACING,dP=O3.FROM_TL,pP=O3.FROM_BR,H3=N3(),Hae=fP();yP.exports=function(r){var t=r._fullLayout,a=t._infolayer.selectAll(".rangeselector").data(Oae(r),Bae);a.enter().append("g").classed("rangeselector",!0),a.exit().remove(),a.style({cursor:"pointer","pointer-events":"all"}),a.each(function(n){var i=gd.select(this),l=n,o=l.rangeselector,s=i.selectAll("g.button").data(cl.filterVisible(o.buttons));s.enter().append("g").classed("button",!0),s.exit().remove(),s.each(function(u){var f=gd.select(this),v=Hae(l,u);u._isActive=Yae(l,u,v),f.call(I3,o,u),f.call(Vae,o,u,r),f.on("click",function(){r._dragged||Nae.call("_guiRelayout",r,v)}),f.on("mouseover",function(){u._isHovered=!0,f.call(I3,o,u)}),f.on("mouseout",function(){u._isHovered=!1,f.call(I3,o,u)})}),Wae(r,s,o,l._name,i)})};function Oae(e){for(var r=Iae.list(e,"x",!0),t=[],a=0;a{"use strict";bP.exports={moduleType:"component",name:"rangeselector",schema:{subplots:{xaxis:{rangeselector:pd()}}},layoutAttributes:pd(),handleDefaults:sP(),draw:gP()}});var bd=X(B3=>{"use strict";var xP=Jt().extendFlat;B3.attributes=function(e,r){e=e||{},r=r||{};var t={valType:"info_array",editType:e.editType,items:[{valType:"number",min:0,max:1,editType:e.editType},{valType:"number",min:0,max:1,editType:e.editType}],dflt:[0,1]},a=e.name?e.name+" ":"",n=e.trace?"trace":"subplot",i=r.description?" "+r.description:"",l={x:xP({},t,{}),y:xP({},t,{}),editType:e.editType};return e.noGridCell||(l.row={valType:"integer",min:0,dflt:0,editType:e.editType},l.column={valType:"integer",min:0,dflt:0,editType:e.editType}),l};B3.defaults=function(e,r,t,a){var n=a&&a.x||[0,1],i=a&&a.y||[0,1],l=r.grid;if(l){var o=t("domain.column");o!==void 0&&(o{"use strict";var Xae=Ne(),Zae=(Us(),Ia(Ys)).counter,Jae=bd().attributes,wP=ta().idRegex,Kae=vt(),Y3={rows:{valType:"integer",min:1,editType:"plot"},roworder:{valType:"enumerated",values:["top to bottom","bottom to top"],dflt:"top to bottom",editType:"plot"},columns:{valType:"integer",min:1,editType:"plot"},subplots:{valType:"info_array",freeLength:!0,dimensions:2,items:{valType:"enumerated",values:[Zae("xy").toString(),""],editType:"plot"},editType:"plot"},xaxes:{valType:"info_array",freeLength:!0,items:{valType:"enumerated",values:[wP.x.toString(),""],editType:"plot"},editType:"plot"},yaxes:{valType:"info_array",freeLength:!0,items:{valType:"enumerated",values:[wP.y.toString(),""],editType:"plot"},editType:"plot"},pattern:{valType:"enumerated",values:["independent","coupled"],dflt:"coupled",editType:"plot"},xgap:{valType:"number",min:0,max:1,editType:"plot"},ygap:{valType:"number",min:0,max:1,editType:"plot"},domain:Jae({name:"grid",editType:"plot",noGridCell:!0},{}),xside:{valType:"enumerated",values:["bottom","bottom plot","top plot","top"],dflt:"bottom plot",editType:"plot"},yside:{valType:"enumerated",values:["left","left plot","right plot","right"],dflt:"left plot",editType:"plot"},editType:"plot"};function _d(e,r,t){var a=r[t+"axes"],n=Object.keys((e._splomAxes||{})[t]||{});if(Array.isArray(a))return a;if(n.length)return n}function $ae(e,r){var t=e.grid||{},a=_d(r,t,"x"),n=_d(r,t,"y");if(!e.grid&&!a&&!n)return;var i=Array.isArray(t.subplots)&&Array.isArray(t.subplots[0]),l=Array.isArray(a),o=Array.isArray(n),s=l&&a!==t.xaxes&&o&&n!==t.yaxes,u,f;i?(u=t.subplots.length,f=t.subplots[0].length):(o&&(u=n.length),l&&(f=a.length));var v=Kae.newContainer(r,"grid");function h(S,k){return Xae.coerce(t,v,Y3,S,k)}var d=h("rows",u),m=h("columns",f);if(!(d*m>1)){delete r.grid;return}if(!i&&!l&&!o){var y=h("pattern")==="independent";y&&(i=!0)}v._hasSubplotGrid=i;var g=h("roworder"),x=g==="top to bottom",T=i?.2:.1,M=i?.3:.1,b,w;s&&r._splomGridDflt&&(b=r._splomGridDflt.xside,w=r._splomGridDflt.yside),v._domains={x:TP("x",h,T,b,m),y:TP("y",h,M,w,d,x)}}function TP(e,r,t,a,n,i){var l=r(e+"gap",t),o=r("domain."+e);r(e+"side",a);for(var s=new Array(n),u=o[0],f=(o[1]-u)/(n-l),v=f*(1-l),h=0;h{"use strict";CP.exports={visible:{valType:"boolean",editType:"calc"},type:{valType:"enumerated",values:["percent","constant","sqrt","data"],editType:"calc"},symmetric:{valType:"boolean",editType:"calc"},array:{valType:"data_array",editType:"calc"},arrayminus:{valType:"data_array",editType:"calc"},value:{valType:"number",min:0,dflt:10,editType:"calc"},valueminus:{valType:"number",min:0,dflt:10,editType:"calc"},traceref:{valType:"integer",min:0,dflt:0,editType:"style"},tracerefminus:{valType:"integer",min:0,dflt:0,editType:"style"},copy_ystyle:{valType:"boolean",editType:"plot"},copy_zstyle:{valType:"boolean",editType:"style"},color:{valType:"color",editType:"style"},thickness:{valType:"number",min:0,dflt:2,editType:"style"},width:{valType:"number",min:0,editType:"plot"},editType:"calc"}});var qP=X((Hve,LP)=>{"use strict";var SP=Br(),jae=_r(),ene=Ne(),rne=vt(),tne=U3();LP.exports=function(e,r,t,a){var n="error_"+a.axis,i=rne.newContainer(r,n),l=e[n]||{};function o(m,y){return ene.coerce(l,i,tne,m,y)}var s=l.array!==void 0||l.value!==void 0||l.type==="sqrt",u=o("visible",s);if(u!==!1){var f=o("type","array"in l?"data":"percent"),v=!0;f!=="sqrt"&&(v=o("symmetric",!((f==="data"?"arrayminus":"valueminus")in l))),f==="data"?(o("array"),o("traceref"),v||(o("arrayminus"),o("tracerefminus"))):(f==="percent"||f==="constant")&&(o("value"),v||o("valueminus"));var h="copy_"+a.inherit+"style";if(a.inherit){var d=r["error_"+a.inherit];(d||{}).visible&&o(h,!(l.color||SP(l.thickness)||SP(l.width)))}(!a.inherit||!i[h])&&(o("color",t),o("thickness"),o("width",jae.traceIs(r,"gl3d")?0:4))}}});var V3=X((Ove,EP)=>{"use strict";EP.exports=function(r){var t=r.type,a=r.symmetric;if(t==="data"){var n=r.array||[];if(a)return function(u,f){var v=+n[f];return[v,v]};var i=r.arrayminus||[];return function(u,f){var v=+n[f],h=+i[f];return!isNaN(v)||!isNaN(h)?[h||0,v||0]:[NaN,NaN]}}else{var l=DP(t,r.value),o=DP(t,r.valueminus);return a||r.valueminus===void 0?function(u){var f=l(u);return[f,f]}:function(u){return[o(u),l(u)]}}};function DP(e,r){if(e==="percent")return function(t){return Math.abs(t*r/100)};if(e==="constant")return function(){return Math.abs(r)};if(e==="sqrt")return function(t){return Math.sqrt(Math.abs(t))}}});var zP=X((Bve,RP)=>{"use strict";var G3=Br(),ane=_r(),W3=mt(),nne=Ne(),ine=V3();RP.exports=function(r){for(var t=r.calcdata,a=0;a{"use strict";var NP=Nr(),jl=Br(),lne=at(),one=mn();FP.exports=function(r,t,a,n){var i,l=a.xaxis,o=a.yaxis,s=n&&n.duration>0,u=r._context.staticPlot;t.each(function(f){var v=f[0].trace,h=v.error_x||{},d=v.error_y||{},m;v.ids&&(m=function(T){return T.id});var y=one.hasMarkers(v)&&v.marker.maxdisplayed>0;!d.visible&&!h.visible&&(f=[]);var g=NP.select(this).selectAll("g.errorbar").data(f,m);if(g.exit().remove(),!!f.length){h.visible||g.selectAll("path.xerror").remove(),d.visible||g.selectAll("path.yerror").remove(),g.style("opacity",1);var x=g.enter().append("g").classed("errorbar",!0);s&&x.style("opacity",0).transition().duration(n.duration).style("opacity",1),lne.setClipUrl(g,a.layerClipId,r),g.each(function(T){var M=NP.select(this),b=sne(T,l,o);if(!(y&&!T.vis)){var w,S=M.select("path.yerror");if(d.visible&&jl(b.x)&&jl(b.yh)&&jl(b.ys)){var k=d.width;w="M"+(b.x-k)+","+b.yh+"h"+2*k+"m-"+k+",0V"+b.ys,b.noYS||(w+="m-"+k+",0h"+2*k),i=!S.size(),i?S=M.append("path").style("vector-effect",u?"none":"non-scaling-stroke").classed("yerror",!0):s&&(S=S.transition().duration(n.duration).ease(n.easing)),S.attr("d",w)}else S.remove();var q=M.select("path.xerror");if(h.visible&&jl(b.y)&&jl(b.xh)&&jl(b.xs)){var D=(h.copy_ystyle?d:h).width;w="M"+b.xh+","+(b.y-D)+"v"+2*D+"m0,-"+D+"H"+b.xs,b.noXS||(w+="m0,-"+D+"v"+2*D),i=!q.size(),i?q=M.append("path").style("vector-effect",u?"none":"non-scaling-stroke").classed("xerror",!0):s&&(q=q.transition().duration(n.duration).ease(n.easing)),q.attr("d",w)}else q.remove()}})}})};function sne(e,r,t){var a={x:r.c2p(e.x),y:t.c2p(e.y)};return e.yh!==void 0&&(a.yh=t.c2p(e.yh),a.ys=t.c2p(e.ys),jl(a.ys)||(a.noYS=!0,a.ys=t.c2p(e.ys,!0))),e.xh!==void 0&&(a.xh=r.c2p(e.xh),a.xs=r.c2p(e.xs),jl(a.xs)||(a.noXS=!0,a.xs=r.c2p(e.xs,!0))),a}});var BP=X((Uve,OP)=>{"use strict";var une=Nr(),HP=Er();OP.exports=function(r){r.each(function(t){var a=t[0].trace,n=a.error_y||{},i=a.error_x||{},l=une.select(this);l.selectAll("path.yerror").style("stroke-width",n.thickness+"px").call(HP.stroke,n.color),i.copy_ystyle&&(i=n),l.selectAll("path.xerror").style("stroke-width",i.thickness+"px").call(HP.stroke,i.color)})}});var VP=X((Vve,UP)=>{"use strict";var Kc=Ne(),YP=Ci().overrideAll,$c=U3(),fs={error_x:Kc.extendFlat({},$c),error_y:Kc.extendFlat({},$c)};delete fs.error_x.copy_zstyle;delete fs.error_y.copy_zstyle;delete fs.error_y.copy_ystyle;var Qc={error_x:Kc.extendFlat({},$c),error_y:Kc.extendFlat({},$c),error_z:Kc.extendFlat({},$c)};delete Qc.error_x.copy_ystyle;delete Qc.error_y.copy_ystyle;delete Qc.error_z.copy_ystyle;delete Qc.error_z.copy_zstyle;UP.exports={moduleType:"component",name:"errorbars",schema:{traces:{scatter:fs,bar:fs,histogram:fs,scatter3d:YP(Qc,"calc","nested"),scattergl:YP(fs,"calc","nested")}},supplyDefaults:qP(),calc:zP(),makeComputeError:V3(),plot:IP(),style:BP(),hoverInfo:fne};function fne(e,r,t){(r.error_y||{}).visible&&(t.yerr=e.yh-e.y,r.error_y.symmetric||(t.yerrneg=e.y-e.ys)),(r.error_x||{}).visible&&(t.xerr=e.xh-e.x,r.error_x.symmetric||(t.xerrneg=e.x-e.xs))}});var WP=X((Gve,GP)=>{"use strict";GP.exports={cn:{colorbar:"colorbar",cbbg:"cbbg",cbfill:"cbfill",cbfills:"cbfills",cbline:"cbline",cblines:"cblines",cbaxis:"cbaxis",cbtitleunshift:"cbtitleunshift",cbtitle:"cbtitle",cboutline:"cboutline",crisp:"crisp",jsPlaceholder:"js-placeholder"}}});var jP=X((Wve,QP)=>{"use strict";var eo=Nr(),wd=Ut(),XP=_r(),vl=mt(),xd=fi(),Zn=Ne(),Ti=Zn.strTranslate,$P=Jt().extendFlat,X3=Hl(),wi=at(),Wu=Er(),cne=Bf(),vne=da(),hne=En().flipScale,dne=a3(),pne=n3(),mne=oi(),Z3=La(),ZP=Z3.LINE_SPACING,JP=Z3.FROM_TL,KP=Z3.FROM_BR,xt=WP().cn;function yne(e){var r=e._fullLayout,t=r._infolayer.selectAll("g."+xt.colorbar).data(gne(e),function(a){return a._id});t.enter().append("g").attr("class",function(a){return a._id}).classed(xt.colorbar,!0),t.each(function(a){var n=eo.select(this);Zn.ensureSingle(n,"rect",xt.cbbg),Zn.ensureSingle(n,"g",xt.cbfills),Zn.ensureSingle(n,"g",xt.cblines),Zn.ensureSingle(n,"g",xt.cbaxis,function(l){l.classed(xt.crisp,!0)}),Zn.ensureSingle(n,"g",xt.cbtitleunshift,function(l){l.append("g").classed(xt.cbtitle,!0)}),Zn.ensureSingle(n,"rect",xt.cboutline);var i=bne(n,a,e);i&&i.then&&(e._promises||[]).push(i),e._context.edits.colorbarPosition&&_ne(n,a,e)}),t.exit().each(function(a){wd.autoMargin(e,a._id)}).remove(),t.order()}function gne(e){var r=e._fullLayout,t=e.calcdata,a=[],n,i,l,o;function s(M){return $P(M,{_fillcolor:null,_line:{color:null,width:null,dash:null},_levels:{start:null,end:null,size:null},_filllevels:null,_fillgradient:null,_zrange:null})}function u(){typeof o.calc=="function"?o.calc(e,l,n):(n._fillgradient=i.reversescale?hne(i.colorscale):i.colorscale,n._zrange=[i[o.min],i[o.max]])}for(var f=0;f1){var Re=Math.pow(10,Math.floor(Math.log(Me)/Math.LN10));_e*=Re*Zn.roundUp(Me/Re,[2,5,10]),(Math.abs(F.start)/F.size+1e-6)%1<2e-6&&(fe.tick0=0)}fe.dtick=_e}fe.domain=a?[Q+m/b.h,Q+V-m/b.h]:[Q+d/b.w,Q+V-d/b.w],fe.setScale(),e.attr("transform",Ti(Math.round(b.l),Math.round(b.t)));var Te=e.select("."+xt.cbtitleunshift).attr("transform",Ti(-Math.round(b.l),-Math.round(b.t))),Pe=fe.ticklabelposition,Le=fe.title.font.size,Ce=e.select("."+xt.cbaxis),Ie,Oe=0,qe=0;function Je(He,te){var he={propContainer:fe,propName:r._propPrefix+"title.text",traceIndex:r._traceIndex,_meta:r._meta,placeholder:M._dfltTitle.colorbar,containerGroup:e.select("."+xt.cbtitle)},Ze=He.charAt(0)==="h"?He.slice(1):"h"+He;e.selectAll("."+Ze+",."+Ze+"-math-group").remove(),cne.draw(t,He,$P(he,te||{}))}function Ke(){if(a&&ge||!a&&!ge){var He,te;q==="top"&&(He=d+b.l+J*y,te=m+b.t+Z*(1-Q-V)+3+Le*.75),q==="bottom"&&(He=d+b.l+J*y,te=m+b.t+Z*(1-Q)-3-Le*.25),q==="right"&&(te=m+b.t+Z*g+3+Le*.75,He=d+b.l+J*Q),Je(fe._id+"title",{attributes:{x:He,y:te,"text-anchor":a?"start":"middle"}})}}function We(){if(a&&!ge||!a&&ge){var He=fe.position||0,te=fe._offset+fe._length/2,he,Ze;if(q==="right")Ze=te,he=b.l+J*He+10+Le*(fe.showticklabels?1:.5);else if(he=te,q==="bottom"&&(Ze=b.t+Z*He+10+(Pe.indexOf("inside")===-1?fe.tickfont.size:0)+(fe.ticks!=="inside"&&r.ticklen||0)),q==="top"){var hr=k.text.split("
").length;Ze=b.t+Z*He+10-G-ZP*Le*hr}Je((a?"h":"v")+fe._id+"title",{avoid:{selection:eo.select(t).selectAll("g."+fe._id+"tick"),side:q,offsetTop:a?0:b.t,offsetLeft:a?b.l:0,maxShift:a?M.width:M.height},attributes:{x:he,y:Ze,"text-anchor":"middle"},transform:{rotate:a?-90:0,offset:0}})}}function ke(){if(!a&&!ge||a&&ge){var He=e.select("."+xt.cbtitle),te=He.select("text"),he=[-s/2,s/2],Ze=He.select(".h"+fe._id+"title-math-group").node(),hr=15.6;te.node()&&(hr=parseInt(te.node().style.fontSize,10)*ZP);var De;if(Ze?(De=wi.bBox(Ze),qe=De.width,Oe=De.height,Oe>hr&&(he[1]-=(Oe-hr)/2)):te.node()&&!te.classed(xt.jsPlaceholder)&&(De=wi.bBox(te.node()),qe=De.width,Oe=De.height),a){if(Oe){if(Oe+=5,q==="top")fe.domain[1]-=Oe/b.h,he[1]*=-1;else{fe.domain[0]+=Oe/b.h;var me=vne.lineCount(te);he[1]+=(1-me)*hr}He.attr("transform",Ti(he[0],he[1])),fe.setScale()}}else qe&&(q==="right"&&(fe.domain[0]+=(qe+Le/2)/b.w),He.attr("transform",Ti(he[0],he[1])),fe.setScale())}e.selectAll("."+xt.cbfills+",."+xt.cblines).attr("transform",a?Ti(0,Math.round(b.h*(1-fe.domain[1]))):Ti(Math.round(b.w*fe.domain[0]),0)),Ce.attr("transform",a?Ti(0,Math.round(-b.t)):Ti(Math.round(-b.l),0));var be=e.select("."+xt.cbfills).selectAll("rect."+xt.cbfill).attr("style","").data(H);be.enter().append("rect").classed(xt.cbfill,!0).attr("style",""),be.exit().remove();var Fe=D.map(fe.c2p).map(Math.round).sort(function(ar,er){return ar-er});be.each(function(ar,er){var Ee=[er===0?D[0]:(H[er]+H[er-1])/2,er===H.length-1?D[1]:(H[er]+H[er+1])/2].map(fe.c2p).map(Math.round);a&&(Ee[1]=Zn.constrain(Ee[1]+(Ee[1]>Ee[0])?1:-1,Fe[0],Fe[1]));var ee=eo.select(this).attr(a?"x":"y",re).attr(a?"y":"x",eo.min(Ee)).attr(a?"width":"height",Math.max(G,2)).attr(a?"height":"width",Math.max(eo.max(Ee)-eo.min(Ee),2));if(r._fillgradient)wi.gradient(ee,t,r._id,a?"vertical":"horizontalreversed",r._fillgradient,"fill");else{var ae=z(ar).replace("e-","");ee.attr("fill",Wu.color(ae).hex())}});var Ge=e.select("."+xt.cblines).selectAll("path."+xt.cbline).data(S.color&&S.width?O:[]);Ge.enter().append("path").classed(xt.cbline,!0),Ge.exit().remove(),Ge.each(function(ar){var er=re,Ee=Math.round(fe.c2p(ar))+S.width/2%1;eo.select(this).attr("d","M"+(a?er+","+Ee:Ee+","+er)+(a?"h":"v")+G).call(wi.lineGroupStyle,S.width,P(ar),S.dash)}),Ce.selectAll("g."+fe._id+"tick,path").remove();var Ue=re+G+(s||0)/2-(r.ticks==="outside"?1:0),Ve=vl.calcTicks(fe),ir=vl.getTickSigns(fe)[2];return vl.drawTicks(t,fe,{vals:fe.ticks==="inside"?vl.clipEnds(fe,Ve):Ve,layer:Ce,path:vl.makeTickPath(fe,Ue,ir),transFn:vl.makeTransTickFn(fe)}),vl.drawLabels(t,fe,{vals:Ve,layer:Ce,transFn:vl.makeTransTickLabelFn(fe),labelFns:vl.makeLabelFns(fe,Ue)})}function ze(){var He,te=G+s/2;Pe.indexOf("inside")===-1&&(He=wi.bBox(Ce.node()),te+=a?He.width:He.height),Ie=Te.select("text");var he=0,Ze=a&&q==="top",hr=!a&&q==="right",De=0;if(Ie.node()&&!Ie.classed(xt.jsPlaceholder)){var me,be=Te.select(".h"+fe._id+"title-math-group").node();be&&(a&&ge||!a&&!ge)?(He=wi.bBox(be),he=He.width,me=He.height):(He=wi.bBox(Te.node()),he=He.right-b.l-(a?re:we),me=He.bottom-b.t-(a?we:re),!a&&q==="top"&&(te+=He.height,De=He.height)),hr&&(Ie.attr("transform",Ti(he/2+Le/2,0)),he*=2),te=Math.max(te,a?he:me)}var Fe=(a?d:m)*2+te+u+s/2,Ge=0;!a&&k.text&&h==="bottom"&&g<=0&&(Ge=Fe/2,Fe+=Ge,De+=Ge),M._hColorbarMoveTitle=Ge,M._hColorbarMoveCBTitle=De;var Ue=u+s,Ve=(a?re:we)-Ue/2-(a?d:0),ir=(a?we:re)-(a?B:m+De-Ge);e.select("."+xt.cbbg).attr("x",Ve).attr("y",ir).attr(a?"width":"height",Math.max(Fe-Ge,2)).attr(a?"height":"width",Math.max(B+Ue,2)).call(Wu.fill,f).call(Wu.stroke,r.bordercolor).style("stroke-width",u);var ar=hr?Math.max(he-10,0):0;e.selectAll("."+xt.cboutline).attr("x",(a?re:we+d)+ar).attr("y",(a?we+m-B:re)+(Ze?Oe:0)).attr(a?"width":"height",Math.max(G,2)).attr(a?"height":"width",Math.max(B-(a?2*m+Oe:2*d+ar),2)).call(Wu.stroke,r.outlinecolor).style({fill:"none","stroke-width":s});var er=a?se*Fe:0,Ee=a?0:(1-ve)*Fe-De;if(er=T?b.l-er:-er,Ee=x?b.t-Ee:-Ee,e.attr("transform",Ti(er,Ee)),!a&&(u||Wu.opacity(f)&&!Wu.equals(M.paper_bgcolor,f))){var ee=Ce.selectAll("text"),ae=ee[0].length,Ae=e.select("."+xt.cbbg).node(),Se=wi.bBox(Ae),$e=wi.getTranslate(e),fr=2;ee.each(function(Wa,ga){var oa=0,Xa=ae-1;if(ga===oa||ga===Xa){var sa=wi.bBox(this),ua=wi.getTranslate(this),cr;if(ga===Xa){var st=sa.right+ua.x,Wr=Se.right+$e.x+we-u-fr+y;cr=Wr-st,cr>0&&(cr=0)}else if(ga===oa){var it=sa.left+ua.x,fa=Se.left+$e.x+we+u+fr;cr=fa-it,cr<0&&(cr=0)}cr&&(ae<3?this.setAttribute("transform","translate("+cr+",0) "+this.getAttribute("transform")):this.setAttribute("visibility","hidden"))}})}var sr={},Ar=JP[v],Hr=KP[v],Zr=JP[h],ot=KP[h],Tt=Fe-G;a?(i==="pixels"?(sr.y=g,sr.t=B*Zr,sr.b=B*ot):(sr.t=sr.b=0,sr.yt=g+n*Zr,sr.yb=g-n*ot),o==="pixels"?(sr.x=y,sr.l=Fe*Ar,sr.r=Fe*Hr):(sr.l=Tt*Ar,sr.r=Tt*Hr,sr.xl=y-l*Ar,sr.xr=y+l*Hr)):(i==="pixels"?(sr.x=y,sr.l=B*Ar,sr.r=B*Hr):(sr.l=sr.r=0,sr.xl=y+n*Ar,sr.xr=y-n*Hr),o==="pixels"?(sr.y=1-g,sr.t=Fe*Zr,sr.b=Fe*ot):(sr.t=Tt*Zr,sr.b=Tt*ot,sr.yt=g-l*Zr,sr.yb=g+l*ot));var bt=r.y<.5?"b":"t",Gt=r.x<.5?"l":"r";t._fullLayout._reservedMargin[r._id]={};var Ht={r:M.width-Ve-er,l:Ve+sr.r,b:M.height-ir-Ee,t:ir+sr.b};T&&x?wd.autoMargin(t,r._id,sr):T?t._fullLayout._reservedMargin[r._id][bt]=Ht[bt]:x||a?t._fullLayout._reservedMargin[r._id][Gt]=Ht[Gt]:t._fullLayout._reservedMargin[r._id][bt]=Ht[bt]}return Zn.syncOrAsync([wd.previousPromises,Ke,ke,We,wd.previousPromises,ze],t)}function _ne(e,r,t){var a=r.orientation==="v",n=t._fullLayout,i=n._size,l,o,s;xd.init({element:e.node(),gd:t,prepFn:function(){l=e.attr("transform"),X3(e)},moveFn:function(u,f){e.attr("transform",l+Ti(u,f)),o=xd.align((a?r._uFrac:r._vFrac)+u/i.w,a?r._thickFrac:r._lenFrac,0,1,r.xanchor),s=xd.align((a?r._vFrac:1-r._uFrac)-f/i.h,a?r._lenFrac:r._thickFrac,0,1,r.yanchor);var v=xd.getCursor(o,s,r.xanchor,r.yanchor);X3(e,v)},doneFn:function(){if(X3(e),o!==void 0&&s!==void 0){var u={};u[r._propPrefix+"x"]=o,u[r._propPrefix+"y"]=s,r._traceIndex!==void 0?XP.call("_guiRestyle",t,u,r._traceIndex):XP.call("_guiRelayout",t,u)}}})}function xne(e,r,t){var a=r._levels,n=[],i=[],l,o,s=a.end+a.size/100,u=a.size,f=1.001*t[0]-.001*t[1],v=1.001*t[1]-.001*t[0];for(o=0;o<1e5&&(l=a.start+o*u,!(u>0?l>=s:l<=s));o++)l>f&&l0?l>=s:l<=s));o++)l>t[0]&&l{"use strict";eR.exports={moduleType:"component",name:"colorbar",attributes:Rv(),supplyDefaults:Xm(),draw:jP().draw,hasColorbar:Nm()}});var aR=X((Zve,tR)=>{"use strict";tR.exports={moduleType:"component",name:"legend",layoutAttributes:Uy(),supplyLayoutDefaults:Gy(),draw:i2(),style:r2()}});var iR=X((Jve,nR)=>{"use strict";nR.exports={moduleType:"locale",name:"en",dictionary:{"Click to enter Colorscale title":"Click to enter Colourscale title"},format:{days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],periods:["AM","PM"],dateTime:"%a %b %e %X %Y",date:"%d/%m/%Y",time:"%H:%M:%S",decimal:".",thousands:",",grouping:[3],currency:["$",""],year:"%Y",month:"%b %Y",dayMonth:"%b %-d",dayMonthYear:"%b %-d, %Y"}}});var oR=X((Kve,lR)=>{"use strict";lR.exports={moduleType:"locale",name:"en-US",dictionary:{"Click to enter Colorscale title":"Click to enter Colorscale title"},format:{date:"%m/%d/%Y"}}});var K3=X(($ve,cR)=>{"use strict";var Tne=_r(),fR=Ne(),J3=fR.extendFlat,sR=fR.extendDeep;function uR(e){var r;switch(e){case"themes__thumb":r={autosize:!0,width:150,height:150,title:{text:""},showlegend:!1,margin:{l:5,r:5,t:5,b:5,pad:0},annotations:[]};break;case"thumbnail":r={title:{text:""},showlegend:!1,borderwidth:0,bordercolor:"",margin:{l:1,r:1,t:1,b:1,pad:0},annotations:[]};break;default:r={}}return r}function Mne(e){var r=["xaxis","yaxis","zaxis"];return r.indexOf(e.slice(0,5))>-1}cR.exports=function(r,t){var a,n=r.data,i=r.layout,l=sR([],n),o=sR({},i,uR(t.tileClass)),s=r._context||{};if(t.width&&(o.width=t.width),t.height&&(o.height=t.height),t.tileClass==="thumbnail"||t.tileClass==="themes__thumb"){o.annotations=[];var u=Object.keys(o);for(a=0;a{"use strict";var Ane=xv().EventEmitter,kne=_r(),Cne=Ne(),vR=jo(),Sne=K3(),Lne=Sh(),qne=Lh();function Dne(e,r){var t=new Ane,a=Sne(e,{format:"png"}),n=a.gd;n.style.position="absolute",n.style.left="-5000px",document.body.appendChild(n);function i(){var o=vR.getDelay(n._fullLayout);setTimeout(function(){var s=Lne(n),u=document.createElement("canvas");u.id=Cne.randstr(),t=qne({format:r.format,width:n._fullLayout.width,height:n._fullLayout.height,canvas:u,emitter:t,svg:s}),t.clean=function(){n&&document.body.removeChild(n)}},o)}var l=vR.getRedrawFunc(n);return kne.call("_doPlot",n,a.data,a.layout,a.config).then(l).then(i).catch(function(o){t.emit("error",o)}),t}hR.exports=Dne});var yR=X((jve,mR)=>{"use strict";var pR=jo(),Ene={getDelay:pR.getDelay,getRedrawFunc:pR.getRedrawFunc,clone:K3(),toSVG:Sh(),svgToImg:Lh(),toImage:dR(),downloadImage:Ag()};mR.exports=Ene});var bR=X(hl=>{"use strict";hl.version=df().version;r_();a7();var Pne=_r(),jc=hl.register=Pne.register,Q3=_S(),gR=Object.keys(Q3);for(Td=0;Td{"use strict";_R.exports=bR()});var e0=X((t1e,kR)=>{"use strict";var Ct=Qo(),wR=Pv().axisHoverFormat,{hovertemplateAttrs:Rne,texttemplateAttrs:zne,templatefallbackAttrs:TR}=To(),AR=If(),Nne=Ta(),MR=Fc(),Fne=Si().pattern,cs=Jt().extendFlat,j3=Nne({editType:"calc",arrayOk:!0,colorEditType:"style"}),Ine=Ct.marker,Hne=Ine.line,One=cs({},Hne.width,{dflt:0}),Bne=cs({width:One,editType:"calc"},AR("marker.line")),Yne=cs({line:Bne,editType:"calc"},AR("marker"),{opacity:{valType:"number",arrayOk:!0,dflt:1,min:0,max:1,editType:"style"},pattern:Fne,cornerradius:{valType:"any",editType:"calc"}});kR.exports={x:Ct.x,x0:Ct.x0,dx:Ct.dx,y:Ct.y,y0:Ct.y0,dy:Ct.dy,xperiod:Ct.xperiod,yperiod:Ct.yperiod,xperiod0:Ct.xperiod0,yperiod0:Ct.yperiod0,xperiodalignment:Ct.xperiodalignment,yperiodalignment:Ct.yperiodalignment,xhoverformat:wR("x"),yhoverformat:wR("y"),text:Ct.text,texttemplate:zne({editType:"plot"},{keys:MR.eventDataKeys}),texttemplatefallback:TR({editType:"plot"}),hovertext:Ct.hovertext,hovertemplate:Rne({},{keys:MR.eventDataKeys}),hovertemplatefallback:TR(),textposition:{valType:"enumerated",values:["inside","outside","auto","none"],dflt:"auto",arrayOk:!0,editType:"calc"},insidetextanchor:{valType:"enumerated",values:["end","middle","start"],dflt:"end",editType:"plot"},textangle:{valType:"angle",dflt:"auto",editType:"plot"},textfont:cs({},j3,{}),insidetextfont:cs({},j3,{}),outsidetextfont:cs({},j3,{}),constraintext:{valType:"enumerated",values:["inside","outside","both","none"],dflt:"both",editType:"calc"},cliponaxis:cs({},Ct.cliponaxis,{}),orientation:{valType:"enumerated",values:["v","h"],editType:"calc+clearAxisTypes"},base:{valType:"any",dflt:null,arrayOk:!0,editType:"calc"},offset:{valType:"number",dflt:null,arrayOk:!0,editType:"calc"},width:{valType:"number",dflt:null,min:0,arrayOk:!0,editType:"calc"},marker:Yne,offsetgroup:Ct.offsetgroup,alignmentgroup:Ct.alignmentgroup,selected:{marker:{opacity:Ct.selected.marker.opacity,color:Ct.selected.marker.color,editType:"style"},textfont:Ct.selected.textfont,editType:"style"},unselected:{marker:{opacity:Ct.unselected.marker.opacity,color:Ct.unselected.marker.color,editType:"style"},textfont:Ct.unselected.textfont,editType:"style"},zorder:Ct.zorder}});var e5=X((a1e,CR)=>{"use strict";CR.exports={barmode:{valType:"enumerated",values:["stack","group","overlay","relative"],dflt:"group",editType:"calc"},barnorm:{valType:"enumerated",values:["","fraction","percent"],dflt:"",editType:"calc"},bargap:{valType:"number",min:0,max:1,editType:"calc"},bargroupgap:{valType:"number",min:0,max:1,dflt:0,editType:"calc"},barcornerradius:{valType:"any",editType:"calc"}}});var DR=X((n1e,qR)=>{"use strict";var Une=Er(),SR=En().hasColorscale,LR=ou(),Vne=Ne().coercePattern;qR.exports=function(r,t,a,n,i){var l=a("marker.color",n),o=SR(r,"marker");o&&LR(r,t,i,a,{prefix:"marker.",cLetter:"c"}),a("marker.line.color",Une.defaultLine),SR(r,"marker.line")&&LR(r,t,i,a,{prefix:"marker.line.",cLetter:"c"}),a("marker.line.width"),a("marker.opacity"),Vne(a,"marker.pattern",l,o),a("selected.marker.color"),a("unselected.marker.color")}});var r0=X((i1e,FR)=>{"use strict";var ER=Br(),Zu=Ne(),PR=Er(),Gne=_r(),Wne=Sg(),Xne=Lg(),Zne=DR(),Jne=qg(),RR=e0(),Ad=Zu.coerceFont;function Kne(e,r,t,a){function n(u,f){return Zu.coerce(e,r,RR,u,f)}var i=Wne(e,r,a,n);if(!i){r.visible=!1;return}Xne(e,r,a,n),n("xhoverformat"),n("yhoverformat"),n("zorder"),n("orientation",r.x&&!r.y?"h":"v"),n("base"),n("offset"),n("width"),n("text"),n("hovertext"),n("hovertemplate"),n("hovertemplatefallback");var l=n("textposition");NR(e,r,a,n,l,{moduleHasSelected:!0,moduleHasUnselected:!0,moduleHasConstrain:!0,moduleHasCliponaxis:!0,moduleHasTextangle:!0,moduleHasInsideanchor:!0}),Zne(e,r,n,t,a);var o=(r.marker.line||{}).color,s=Gne.getComponentMethod("errorbars","supplyDefaults");s(e,r,o||PR.defaultLine,{axis:"y"}),s(e,r,o||PR.defaultLine,{axis:"x",inherit:"y"}),Zu.coerceSelectionMarkerOpacity(r,n)}function $ne(e,r){var t,a;function n(o,s){return Zu.coerce(a._input,a,RR,o,s)}for(var i=0;i=0)return e}else if(typeof e=="string"&&(e=e.trim(),e.slice(-1)==="%"&&ER(e.slice(0,-1))&&(e=+e.slice(0,-1),e>=0)))return e+"%"}function NR(e,r,t,a,n,i){i=i||{};var l=i.moduleHasSelected!==!1,o=i.moduleHasUnselected!==!1,s=i.moduleHasConstrain!==!1,u=i.moduleHasCliponaxis!==!1,f=i.moduleHasTextangle!==!1,v=i.moduleHasInsideanchor!==!1,h=!!i.hasPathbar,d=Array.isArray(n)||n==="auto",m=d||n==="inside",y=d||n==="outside";if(m||y){var g=Ad(a,"textfont",t.font),x=Zu.extendFlat({},g),T=e.textfont&&e.textfont.color,M=!T;if(M&&delete x.color,Ad(a,"insidetextfont",x),h){var b=Zu.extendFlat({},g);M&&delete b.color,Ad(a,"pathbar.textfont",b)}y&&Ad(a,"outsidetextfont",g),l&&a("selected.textfont.color"),o&&a("unselected.textfont.color"),s&&a("constraintext"),u&&a("cliponaxis"),f&&a("textangle"),a("texttemplate"),a("texttemplatefallback")}m&&v&&a("insidetextanchor")}FR.exports={supplyDefaults:Kne,crossTraceDefaults:$ne,handleText:NR,validateCornerradius:zR}});var HR=X((l1e,IR)=>{"use strict";var Qne=_r(),jne=mt(),eie=Ne(),rie=e5(),tie=r0().validateCornerradius;IR.exports=function(e,r,t){function a(y,g){return eie.coerce(e,r,rie,y,g)}for(var n=!1,i=!1,l=!1,o={},s=a("barmode"),u=s==="group",f=0;f0&&!o[h]&&(l=!0),o[h]=!0),v.visible&&v.type==="histogram"){var d=jne.getFromId({_fullLayout:r},v[v.orientation==="v"?"xaxis":"yaxis"]);d.type!=="category"&&(i=!0)}}if(!n){delete r.barmode;return}s!=="overlay"&&a("barnorm"),a("bargap",i&&!l?0:.2),a("bargroupgap");var m=a("barcornerradius");r.barcornerradius=tie(m)}});var r5=X((o1e,OR)=>{"use strict";var Ju=Ne();OR.exports=function(r,t){for(var a=0;a{"use strict";var BR=mt(),YR=Dg(),UR=En().hasColorscale,VR=Fv(),aie=r5(),nie=zg();GR.exports=function(r,t){var a=BR.getFromId(r,t.xaxis||"x"),n=BR.getFromId(r,t.yaxis||"y"),i,l,o,s,u,f,v={msUTC:!!(t.base||t.base===0)};t.orientation==="h"?(i=a.makeCalcdata(t,"x",v),o=n.makeCalcdata(t,"y"),s=YR(t,n,"y",o),u=!!t.yperiodalignment,f="y"):(i=n.makeCalcdata(t,"y",v),o=a.makeCalcdata(t,"x"),s=YR(t,a,"x",o),u=!!t.xperiodalignment,f="x"),l=s.vals;for(var h=Math.min(l.length,i.length),d=new Array(h),m=0;m{"use strict";var iie=Nr(),lie=Ne();function oie(e,r,t){var a=e._fullLayout,n=a["_"+t+"Text_minsize"];if(n){var i=a.uniformtext.mode==="hide",l;switch(t){case"funnelarea":case"pie":case"sunburst":l="g.slice";break;case"treemap":case"icicle":l="g.slice, g.pathbar";break;default:l="g.points > g.point"}r.selectAll(l).each(function(o){var s=o.transform;if(s){s.scale=i&&s.hide?0:n/s.fontSize;var u=iie.select(this).select("text");lie.setTransormAndDisplay(u,s)}})}}function sie(e,r,t){if(t.uniformtext.mode){var a=XR(e),n=t.uniformtext.minsize,i=r.scale*r.fontSize;r.hide=i{"use strict";var fie=Er(),cie=Br(),JR=Ne().isArrayOrTypedArray;vs.coerceString=function(e,r,t){if(typeof r=="string"){if(r||!e.noBlank)return r}else if((typeof r=="number"||r===!0)&&!e.strict)return String(r);return t!==void 0?t:e.dflt};vs.coerceNumber=function(e,r,t){if(cie(r)){r=+r;var a=e.min,n=e.max,i=a!==void 0&&rn;if(!i)return r}return t!==void 0?t:e.dflt};vs.coerceColor=function(e,r,t){return fie.isValid(r)?r:t!==void 0?t:e.dflt};vs.coerceEnumerated=function(e,r,t){return e.coerceNumber&&(r=+r),e.values.indexOf(r)!==-1?r:t!==void 0?t:e.dflt};vs.getValue=function(e,r){var t;return JR(e)?r{"use strict";var a0=Nr(),vie=Er(),n0=at(),KR=Ne(),$R=_r(),QR=t0().resizeText,t5=e0(),hie=t5.textfont,die=t5.insidetextfont,pie=t5.outsidetextfont,ka=kd();function mie(e){var r=a0.select(e).selectAll('g[class^="barlayer"]').selectAll("g.trace");QR(e,r,"bar");var t=r.size(),a=e._fullLayout;r.style("opacity",function(n){return n[0].trace.opacity}).each(function(n){(a.barmode==="stack"&&t>1||a.bargap===0&&a.bargroupgap===0&&!n[0].trace.marker.line.width)&&a0.select(this).attr("shape-rendering","crispEdges")}),r.selectAll("g.points").each(function(n){var i=a0.select(this),l=n[0].trace;jR(i,l,e)}),$R.getComponentMethod("errorbars","style")(r)}function jR(e,r,t){n0.pointStyle(e.selectAll("path"),r,t),ez(e,r,t)}function ez(e,r,t){e.selectAll("text").each(function(a){var n=a0.select(this),i=KR.ensureUniformFontSize(t,rz(n,a,r,t));n0.font(n,i)})}function yie(e,r,t){var a=r[0].trace;a.selectedpoints?gie(t,a,e):(jR(t,a,e),$R.getComponentMethod("errorbars","style")(t))}function gie(e,r,t){n0.selectedPointStyle(e.selectAll("path"),r),bie(e.selectAll("text"),r,t)}function bie(e,r,t){e.each(function(a){var n=a0.select(this),i;if(a.selected){i=KR.ensureUniformFontSize(t,rz(n,a,r,t));var l=r.selected.textfont&&r.selected.textfont.color;l&&(i.color=l),n0.font(n,i)}else n0.selectedTextStyle(n,r)})}function rz(e,r,t,a){var n=a._fullLayout.font,i=t.textfont;if(e.classed("bartext-inside")){var l=iz(r,t);i=az(t,r.i,n,l)}else e.classed("bartext-outside")&&(i=nz(t,r.i,n));return i}function tz(e,r,t){return a5(hie,e.textfont,r,t)}function az(e,r,t,a){var n=tz(e,r,t),i=e._input.textfont===void 0||e._input.textfont.color===void 0||Array.isArray(e.textfont.color)&&e.textfont.color[r]===void 0;return i&&(n={color:vie.contrast(a),family:n.family,size:n.size,weight:n.weight,style:n.style,variant:n.variant,textcase:n.textcase,lineposition:n.lineposition,shadow:n.shadow}),a5(die,e.insidetextfont,r,n)}function nz(e,r,t){var a=tz(e,r,t);return a5(pie,e.outsidetextfont,r,a)}function a5(e,r,t,a){r=r||{};var n=ka.getValue(r.family,t),i=ka.getValue(r.size,t),l=ka.getValue(r.color,t),o=ka.getValue(r.weight,t),s=ka.getValue(r.style,t),u=ka.getValue(r.variant,t),f=ka.getValue(r.textcase,t),v=ka.getValue(r.lineposition,t),h=ka.getValue(r.shadow,t);return{family:ka.coerceString(e.family,n,a.family),size:ka.coerceNumber(e.size,i,a.size),color:ka.coerceColor(e.color,l,a.color),weight:ka.coerceString(e.weight,o,a.weight),style:ka.coerceString(e.style,s,a.style),variant:ka.coerceString(e.variant,u,a.variant),textcase:ka.coerceString(e.variant,f,a.textcase),lineposition:ka.coerceString(e.variant,v,a.lineposition),shadow:ka.coerceString(e.variant,h,a.shadow)}}function iz(e,r){return r.type==="waterfall"?r[e.dir].marker.color:e.mcc||e.mc||r.marker.color}lz.exports={style:mie,styleTextPoints:ez,styleOnSelect:yie,getInsideTextFont:az,getOutsideTextFont:nz,getBarColor:iz,resizeText:QR}});var pz=X((v1e,dz)=>{"use strict";var Sd=Nr(),Ld=Br(),ma=Ne(),_ie=da(),xie=Er(),ro=at(),wie=_r(),qd=mt().tickText,oz=t0(),Tie=oz.recordMinTextSize,Mie=oz.clearMinTextSize,n5=Cd(),Ku=kd(),Aie=Fc(),sz=e0(),kie=sz.text,Cie=sz.textposition,Sie=Di().appendArrayPointValue,Ga=Aie.TEXTPAD;function Lie(e){return e.id}function qie(e){if(e.ids)return Lie}function i5(e){return(e>0)-(e<0)}function Xi(e,r){return e0}function Eie(e,r,t,a,n,i){var l=r.xaxis,o=r.yaxis,s=e._fullLayout,u=e._context.staticPlot;n||(n={mode:s.barmode,norm:s.barmode,gap:s.bargap,groupgap:s.bargroupgap},Mie("bar",s));var f=ma.makeTraceGroups(a,t,"trace bars").each(function(v){var h=Sd.select(this),d=v[0].trace,m=v[0].t,y=d.type==="waterfall",g=d.type==="funnel",x=d.type==="histogram",T=d.type==="bar",M=T||g,b=0;y&&d.connector.visible&&d.connector.mode==="between"&&(b=d.connector.line.width/2);var w=d.orientation==="h",S=fz(n),k=ma.ensureSingle(h,"g","points"),q=qie(d),D=k.selectAll("g.point").data(ma.identity,q);D.enter().append("g").classed("point",!0),D.exit().remove(),D.each(function(z,F){var I=Sd.select(this),H=Die(z,l,o,w),O=H[0][0],G=H[0][1],Y=H[1][0],B=H[1][1],V=(w?G-O:B-Y)===0;V&&M&&Ku.getLineWidth(d,z)&&(V=!1),V||(V=!Ld(O)||!Ld(G)||!Ld(Y)||!Ld(B)),z.isBlank=V,V&&(w?G=O:B=Y),b&&!V&&(w?(O-=Xi(O,G)*b,G+=Xi(O,G)*b):(Y-=Xi(Y,B)*b,B+=Xi(Y,B)*b));var J,Z;if(d.type==="waterfall"){if(!V){var re=d[z.dir].marker;J=re.line.width,Z=re.color}}else J=Ku.getLineWidth(d,z),Z=z.mc||d.marker.color;function se(te){var he=Sd.round(J/2%1,2);return n.gap===0&&n.groupgap===0?Sd.round(Math.round(te)-he,2):te}function ve(te,he,Ze){return Ze&&te===he?te:Math.abs(te-he)>=2?se(te):te>he?Math.ceil(te):Math.floor(te)}var xe=xie.opacity(Z),Q=xe<1||J>.01?se:ve;e._context.staticPlot||(O=Q(O,G,w),G=Q(G,O,w),Y=Q(Y,B,!w),B=Q(B,Y,!w));var we=w?l.c2p:o.c2p,fe;z.s0>0?fe=z._sMax:z.s0<0?fe=z._sMin:fe=z.s1>0?z._sMax:z._sMin;function ge(te,he){if(!te)return 0;var Ze=Math.abs(w?B-Y:G-O),hr=Math.abs(w?G-O:B-Y),De=Q(Math.abs(we(fe,!0)-we(0,!0))),me=z.hasB?Math.min(Ze/2,hr/2):Math.min(Ze/2,De),be;if(he==="%"){var Fe=Math.min(50,te);be=Ze*(Fe/100)}else be=te;return Q(Math.max(Math.min(be,me),0))}var _e=T||x?ge(m.cornerradiusvalue,m.cornerradiusform):0,ce,Me,Re="M"+O+","+Y+"V"+B+"H"+G+"V"+Y+"Z",Te=0;if(_e&&z.s){var Pe=i5(z.s0)===0||i5(z.s)===i5(z.s0)?z.s1:z.s0;if(Te=Q(z.hasB?0:Math.abs(we(fe,!0)-we(Pe,!0))),Te<_e){var Le=Xi(O,G),Ce=Xi(Y,B),Ie=Le===-Ce?1:0;if(w)if(z.hasB)ce="M"+(O+_e*Le)+","+Y+"A "+_e+","+_e+" 0 0 "+Ie+" "+O+","+(Y+_e*Ce)+"V"+(B-_e*Ce)+"A "+_e+","+_e+" 0 0 "+Ie+" "+(O+_e*Le)+","+B+"H"+(G-_e*Le)+"A "+_e+","+_e+" 0 0 "+Ie+" "+G+","+(B-_e*Ce)+"V"+(Y+_e*Ce)+"A "+_e+","+_e+" 0 0 "+Ie+" "+(G-_e*Le)+","+Y+"Z";else{Me=Math.abs(G-O)+Te;var Oe=Me<_e?_e-Math.sqrt(Me*(2*_e-Me)):0,qe=Te>0?Math.sqrt(Te*(2*_e-Te)):0,Je=Le>0?Math.max:Math.min;ce="M"+O+","+Y+"V"+(B-Oe*Ce)+"H"+Je(G-(_e-Te)*Le,O)+"A "+_e+","+_e+" 0 0 "+Ie+" "+G+","+(B-_e*Ce-qe)+"V"+(Y+_e*Ce+qe)+"A "+_e+","+_e+" 0 0 "+Ie+" "+Je(G-(_e-Te)*Le,O)+","+(Y+Oe*Ce)+"Z"}else if(z.hasB)ce="M"+(O+_e*Le)+","+Y+"A "+_e+","+_e+" 0 0 "+Ie+" "+O+","+(Y+_e*Ce)+"V"+(B-_e*Ce)+"A "+_e+","+_e+" 0 0 "+Ie+" "+(O+_e*Le)+","+B+"H"+(G-_e*Le)+"A "+_e+","+_e+" 0 0 "+Ie+" "+G+","+(B-_e*Ce)+"V"+(Y+_e*Ce)+"A "+_e+","+_e+" 0 0 "+Ie+" "+(G-_e*Le)+","+Y+"Z";else{Me=Math.abs(B-Y)+Te;var Ke=Me<_e?_e-Math.sqrt(Me*(2*_e-Me)):0,We=Te>0?Math.sqrt(Te*(2*_e-Te)):0,ke=Ce>0?Math.max:Math.min;ce="M"+(O+Ke*Le)+","+Y+"V"+ke(B-(_e-Te)*Ce,Y)+"A "+_e+","+_e+" 0 0 "+Ie+" "+(O+_e*Le-We)+","+B+"H"+(G-_e*Le+We)+"A "+_e+","+_e+" 0 0 "+Ie+" "+(G-Ke*Le)+","+ke(B-(_e-Te)*Ce,Y)+"V"+Y+"Z"}}else ce=Re}else ce=Re;var ze=uz(ma.ensureSingle(I,"path"),s,n,i);if(ze.style("vector-effect",u?"none":"non-scaling-stroke").attr("d",isNaN((G-O)*(B-Y))||V&&e._context.staticPlot?"M0,0Z":ce).call(ro.setClipUrl,r.layerClipId,e),!s.uniformtext.mode&&S){var He=ro.makePointStyleFns(d);ro.singlePointStyle(z,ze,d,He,e)}Pie(e,r,I,v,F,O,G,Y,B,_e,Te,n,i),r.layerClipId&&ro.hideOutsideRangePoint(z,I.select("text"),l,o,d.xcalendar,d.ycalendar)});var P=d.cliponaxis===!1;ro.setClipUrl(h,P?null:r.layerClipId,e)});wie.getComponentMethod("errorbars","plot")(e,f,r,n)}function Pie(e,r,t,a,n,i,l,o,s,u,f,v,h){var d=r.xaxis,m=r.yaxis,y=e._fullLayout,g;function x(Me,Re,Te){var Pe=ma.ensureSingle(Me,"text").text(Re).attr({class:"bartext bartext-"+g,"text-anchor":"middle","data-notex":1}).call(ro.font,Te).call(_ie.convertToTspans,e);return Pe}var T=a[0].trace,M=T.orientation==="h",b=Nie(y,a,n,d,m);g=Fie(T,n);var w=v.mode==="stack"||v.mode==="relative",S=a[n],k=!w||S._outmost,q=S.hasB,D=u&&u-f>Ga;if(!b||g==="none"||(S.isBlank||i===l||o===s)&&(g==="auto"||g==="inside")){t.select("text").remove();return}var P=y.font,z=n5.getBarColor(a[n],T),F=n5.getInsideTextFont(T,n,P,z),I=n5.getOutsideTextFont(T,n,P),H=T.insidetextanchor||"end",O=t.datum();M?d.type==="log"&&O.s0<=0&&(d.range[0]0&&se>0,Q;D?q?Q=hs(B-2*u,V,re,se,M)||hs(B,V-2*u,re,se,M):M?Q=hs(B-(u-f),V,re,se,M)||hs(B,V-2*(u-f),re,se,M):Q=hs(B,V-(u-f),re,se,M)||hs(B-2*(u-f),V,re,se,M):Q=hs(B,V,re,se,M),xe&&Q?g="inside":(g="outside",J.remove(),J=null)}else g="inside";if(!J){ve=ma.ensureUniformFontSize(e,g==="outside"?I:F),J=x(t,b,ve);var we=J.attr("transform");if(J.attr("transform",""),Z=ro.bBox(J.node()),re=Z.width,se=Z.height,J.attr("transform",we),re<=0||se<=0){J.remove();return}}var fe=T.textangle,ge,_e;g==="outside"?(_e=T.constraintext==="both"||T.constraintext==="outside",ge=zie(i,l,o,s,Z,{isHorizontal:M,constrained:_e,angle:fe})):(_e=T.constraintext==="both"||T.constraintext==="inside",ge=hz(i,l,o,s,Z,{isHorizontal:M,constrained:_e,angle:fe,anchor:H,hasB:q,r:u,overhead:f})),ge.fontSize=ve.size,Tie(T.type==="histogram"?"bar":T.type,ge,y),S.transform=ge;var ce=uz(J,y,v,h);ma.setTransormAndDisplay(ce,ge)}function hs(e,r,t,a,n){if(e<0||r<0)return!1;var i=t<=e&&a<=r,l=t<=r&&a<=e,o=n?e>=t*(r/a):r>=a*(e/t);return i||l||o}function cz(e){return e==="auto"?0:e}function vz(e,r){var t=Math.PI/180*r,a=Math.abs(Math.sin(t)),n=Math.abs(Math.cos(t));return{x:e.width*n+e.height*a,y:e.width*a+e.height*n}}function hz(e,r,t,a,n,i){var l=!!i.isHorizontal,o=!!i.constrained,s=i.angle||0,u=i.anchor,f=u==="end",v=u==="start",h=i.leftToRight||0,d=(h+1)/2,m=1-d,y=i.hasB,g=i.r,x=i.overhead,T=n.width,M=n.height,b=Math.abs(r-e),w=Math.abs(a-t),S=b>2*Ga&&w>2*Ga?Ga:0;b-=2*S,w-=2*S;var k=cz(s);s==="auto"&&!(T<=b&&M<=w)&&(T>b||M>w)&&(!(T>w||M>b)||TGa){var z=Rie(e,r,t,a,q,g,x,l,y);D=z.scale,P=z.pad}else D=1,o&&(D=Math.min(1,b/q.x,w/q.y)),P=0;var F=n.left*m+n.right*d,I=(n.top+n.bottom)/2,H=(e+Ga)*m+(r-Ga)*d,O=(t+a)/2,G=0,Y=0;if(v||f){var B=(l?q.x:q.y)/2;g&&(f||y)&&(S+=P);var V=l?Xi(e,r):Xi(t,a);l?v?(H=e+V*S,G=-V*B):(H=r-V*S,G=V*B):v?(O=t+V*S,Y=-V*B):(O=a-V*S,Y=V*B)}return{textX:F,textY:I,targetX:H,targetY:O,anchorX:G,anchorY:Y,scale:D,rotate:k}}function Rie(e,r,t,a,n,i,l,o,s){var u=Math.max(0,Math.abs(r-e)-2*Ga),f=Math.max(0,Math.abs(a-t)-2*Ga),v=i-Ga,h=l?v-Math.sqrt(v*v-(v-l)*(v-l)):v,d=s?v*2:o?v-l:2*h,m=s?v*2:o?2*h:v-l,y,g,x,T,M;return n.y/n.x>=f/(u-d)?T=f/n.y:n.y/n.x<=(f-m)/u?T=u/n.x:!s&&o?(y=n.x*n.x+n.y*n.y/4,g=-2*n.x*(u-v)-n.y*(f/2-v),x=(u-v)*(u-v)+(f/2-v)*(f/2-v)-v*v,T=(-g+Math.sqrt(g*g-4*y*x))/(2*y)):s?(y=(n.x*n.x+n.y*n.y)/4,g=-n.x*(u/2-v)-n.y*(f/2-v),x=(u/2-v)*(u/2-v)+(f/2-v)*(f/2-v)-v*v,T=(-g+Math.sqrt(g*g-4*y*x))/(2*y)):(y=n.x*n.x/4+n.y*n.y,g=-n.x*(u/2-v)-2*n.y*(f-v),x=(u/2-v)*(u/2-v)+(f-v)*(f-v)-v*v,T=(-g+Math.sqrt(g*g-4*y*x))/(2*y)),T=Math.min(1,T),o?M=Math.max(0,v-Math.sqrt(Math.max(0,v*v-(v-(f-n.y*T)/2)*(v-(f-n.y*T)/2)))-l):M=Math.max(0,v-Math.sqrt(Math.max(0,v*v-(v-(u-n.x*T)/2)*(v-(u-n.x*T)/2)))-l),{scale:T,pad:M}}function zie(e,r,t,a,n,i){var l=!!i.isHorizontal,o=!!i.constrained,s=i.angle||0,u=n.width,f=n.height,v=Math.abs(r-e),h=Math.abs(a-t),d;l?d=h>2*Ga?Ga:0:d=v>2*Ga?Ga:0;var m=1;o&&(m=l?Math.min(1,h/f):Math.min(1,v/u));var y=cz(s),g=vz(n,y),x=(l?g.x:g.y)/2,T=(n.left+n.right)/2,M=(n.top+n.bottom)/2,b=(e+r)/2,w=(t+a)/2,S=0,k=0,q=l?Xi(r,e):Xi(t,a);return l?(b=r-q*d,S=q*x):(w=a+q*d,k=-q*x),{textX:T,textY:M,targetX:b,targetY:w,anchorX:S,anchorY:k,scale:m,rotate:y}}function Nie(e,r,t,a,n){var i=r[0].trace,l=i.texttemplate,o;return l?o=Iie(e,r,t,a,n):i.textinfo?o=Hie(r,t,a,n):o=Ku.getValue(i.text,t),Ku.coerceString(kie,o)}function Fie(e,r){var t=Ku.getValue(e.textposition,r);return Ku.coerceEnumerated(Cie,t)}function Iie(e,r,t,a,n){var i=r[0].trace,l=ma.castOption(i,t,"texttemplate");if(!l)return"";var o=i.type==="histogram",s=i.type==="waterfall",u=i.type==="funnel",f=i.orientation==="h",v,h,d,m;f?(v="y",h=n,d="x",m=a):(v="x",h=a,d="y",m=n);function y(S){return qd(h,h.c2l(S),!0).text}function g(S){return qd(m,m.c2l(S),!0).text}var x=r[t],T={};T.label=x.p,T.labelLabel=T[v+"Label"]=y(x.p);var M=ma.castOption(i,x.i,"text");(M===0||M)&&(T.text=M),T.value=x.s,T.valueLabel=T[d+"Label"]=g(x.s);var b={};Sie(b,i,x.i),(o||b.x===void 0)&&(b.x=f?T.value:T.label),(o||b.y===void 0)&&(b.y=f?T.label:T.value),(o||b.xLabel===void 0)&&(b.xLabel=f?T.valueLabel:T.labelLabel),(o||b.yLabel===void 0)&&(b.yLabel=f?T.labelLabel:T.valueLabel),s&&(T.delta=+x.rawS||x.s,T.deltaLabel=g(T.delta),T.final=x.v,T.finalLabel=g(T.final),T.initial=T.final-T.delta,T.initialLabel=g(T.initial)),u&&(T.value=x.s,T.valueLabel=g(T.value),T.percentInitial=x.begR,T.percentInitialLabel=ma.formatPercent(x.begR),T.percentPrevious=x.difR,T.percentPreviousLabel=ma.formatPercent(x.difR),T.percentTotal=x.sumR,T.percenTotalLabel=ma.formatPercent(x.sumR));var w=ma.castOption(i,x.i,"customdata");return w&&(T.customdata=w),ma.texttemplateString({data:[b,T,i._meta],fallback:i.texttemplatefallback,labels:T,locale:e._d3locale,template:l})}function Hie(e,r,t,a){var n=e[0].trace,i=n.orientation==="h",l=n.type==="waterfall",o=n.type==="funnel";function s(w){var S=i?a:t;return qd(S,w,!0).text}function u(w){var S=i?t:a;return qd(S,+w,!0).text}var f=n.textinfo,v=e[r],h=f.split("+"),d=[],m,y=function(w){return h.indexOf(w)!==-1};if(y("label")&&d.push(s(e[r].p)),y("text")&&(m=ma.castOption(n,v.i,"text"),(m===0||m)&&d.push(m)),l){var g=+v.rawS||v.s,x=v.v,T=x-g;y("initial")&&d.push(u(T)),y("delta")&&d.push(u(g)),y("final")&&d.push(u(x))}if(o){y("value")&&d.push(u(v.s));var M=0;y("percent initial")&&M++,y("percent previous")&&M++,y("percent total")&&M++;var b=M>1;y("percent initial")&&(m=ma.formatPercent(v.begR),b&&(m+=" of initial"),d.push(m)),y("percent previous")&&(m=ma.formatPercent(v.difR),b&&(m+=" of previous"),d.push(m)),y("percent total")&&(m=ma.formatPercent(v.sumR),b&&(m+=" of total"),d.push(m))}return d.join("
")}dz.exports={plot:Eie,toMoveInsideBar:hz}});var _z=X((h1e,bz)=>{"use strict";var i0=vi(),Oie=_r(),mz=Er(),Bie=Ne().fillText,Yie=kd().getLineWidth,l5=mt().hoverLabelText,Uie=Zt().BADNUM;function Vie(e,r,t,a,n){var i=yz(e,r,t,a,n);if(i){var l=i.cd,o=l[0].trace,s=l[i.index];return i.color=gz(o,s),Oie.getComponentMethod("errorbars","hoverInfo")(s,o,i),[i]}}function yz(e,r,t,a,n){var i=e.cd,l=i[0].trace,o=i[0].t,s=a==="closest",u=l.type==="waterfall",f=e.maxHoverDistance,v=e.maxSpikeDistance,h,d,m,y,g,x,T;l.orientation==="h"?(h=t,d=r,m="y",y="x",g=O,x=F):(h=r,d=t,m="x",y="y",x=O,g=F);var M=l[m+"period"],b=s||M;function w(Q){return k(Q,-1)}function S(Q){return k(Q,1)}function k(Q,we){var fe=Q.w;return Q[m]+we*fe/2}function q(Q){return Q[m+"End"]-Q[m+"Start"]}var D=s?w:M?function(Q){return Q.p-q(Q)/2}:function(Q){return Math.min(w(Q),Q.p-o.bardelta/2)},P=s?S:M?function(Q){return Q.p+q(Q)/2}:function(Q){return Math.max(S(Q),Q.p+o.bardelta/2)};function z(Q,we,fe){return n.finiteRange&&(fe=0),i0.inbox(Q-h,we-h,fe+Math.min(1,Math.abs(we-Q)/T)-1)}function F(Q){return z(D(Q),P(Q),f)}function I(Q){return z(w(Q),S(Q),v)}function H(Q){var we=Q[y];if(u){var fe=Math.abs(Q.rawS)||0;d>0?we+=fe:d<0&&(we-=fe)}return we}function O(Q){var we=d,fe=Q.b,ge=H(Q);return i0.inbox(fe-we,ge-we,f+(ge-we)/(ge-fe)-1)}function G(Q){var we=d,fe=Q.b,ge=H(Q);return i0.inbox(fe-we,ge-we,v+(ge-we)/(ge-fe)-1)}var Y=e[m+"a"],B=e[y+"a"];T=Math.abs(Y.r2c(Y.range[1])-Y.r2c(Y.range[0]));function V(Q){return(g(Q)+x(Q))/2}var J=i0.getDistanceFunction(a,g,x,V);if(i0.getClosest(i,J,e),e.index!==!1&&i[e.index].p!==Uie){b||(D=function(Q){return Math.min(w(Q),Q.p-o.bargroupwidth/2)},P=function(Q){return Math.max(S(Q),Q.p+o.bargroupwidth/2)});var Z=e.index,re=i[Z],se=l.base?re.b+re.s:re.s;e[y+"0"]=e[y+"1"]=B.c2p(re[y],!0),e[y+"LabelVal"]=se;var ve=o.extents[o.extents.round(re.p)];e[m+"0"]=Y.c2p(s?D(re):ve[0],!0),e[m+"1"]=Y.c2p(s?P(re):ve[1],!0);var xe=re.orig_p!==void 0;return e[m+"LabelVal"]=xe?re.orig_p:re.p,e.labelLabel=l5(Y,e[m+"LabelVal"],l[m+"hoverformat"]),e.valueLabel=l5(B,e[y+"LabelVal"],l[y+"hoverformat"]),e.baseLabel=l5(B,re.b,l[y+"hoverformat"]),e.spikeDistance=(G(re)+I(re))/2,e[m+"Spike"]=Y.c2p(re.p,!0),Bie(re,l,e),e.hovertemplate=l.hovertemplate,e}}function gz(e,r){var t=r.mcc||e.marker.color,a=r.mlcc||e.marker.line.color,n=Yie(e,r);if(mz.opacity(t))return t;if(mz.opacity(a)&&n)return a}bz.exports={hoverPoints:Vie,hoverOnBars:yz,getTraceColor:gz}});var wz=X((d1e,xz)=>{"use strict";xz.exports=function(r,t,a){return r.x="xVal"in t?t.xVal:t.x,r.y="yVal"in t?t.yVal:t.y,t.xa&&(r.xaxis=t.xa),t.ya&&(r.yaxis=t.ya),a.orientation==="h"?(r.label=r.y,r.value=r.x):(r.label=r.x,r.value=r.y),r}});var Mz=X((p1e,Tz)=>{"use strict";Tz.exports=function(r,t){var a=r.cd,n=r.xaxis,i=r.yaxis,l=a[0].trace,o=l.type==="funnel",s=l.orientation==="h",u=[],f;if(t===!1)for(f=0;f{"use strict";Az.exports={attributes:e0(),layoutAttributes:e5(),supplyDefaults:r0().supplyDefaults,crossTraceDefaults:r0().crossTraceDefaults,supplyLayoutDefaults:HR(),calc:WR(),crossTraceCalc:Wg().crossTraceCalc,colorbar:Jg(),arraysToCalcdata:r5(),plot:pz().plot,style:Cd().style,styleOnSelect:Cd().styleOnSelect,hoverPoints:_z().hoverPoints,eventData:wz(),selectPoints:Mz(),moduleType:"trace",name:"bar",basePlotModule:Qh(),categories:["bar-like","cartesian","svg","bar","oriented","errorBarsOK","showLegend","zoomScale"],animatable:!0,meta:{}}});var Sz=X((y1e,Cz)=>{"use strict";Cz.exports=kz()});var o5=X((g1e,qz)=>{"use strict";var Dd=ri(),Wie=bd().attributes,Xie=Ta(),Zie=ki(),{hovertemplateAttrs:Jie,texttemplateAttrs:Kie,templatefallbackAttrs:Lz}=To(),to=Jt().extendFlat,$ie=Si().pattern,Ed=Xie({editType:"plot",arrayOk:!0,colorEditType:"plot"});qz.exports={labels:{valType:"data_array",editType:"calc"},label0:{valType:"number",dflt:0,editType:"calc"},dlabel:{valType:"number",dflt:1,editType:"calc"},values:{valType:"data_array",editType:"calc"},marker:{colors:{valType:"data_array",editType:"calc"},line:{color:{valType:"color",dflt:Zie.defaultLine,arrayOk:!0,editType:"style"},width:{valType:"number",min:0,dflt:0,arrayOk:!0,editType:"style"},editType:"calc"},pattern:$ie,editType:"calc"},text:{valType:"data_array",editType:"plot"},hovertext:{valType:"string",dflt:"",arrayOk:!0,editType:"style"},scalegroup:{valType:"string",dflt:"",editType:"calc"},textinfo:{valType:"flaglist",flags:["label","text","value","percent"],extras:["none"],editType:"calc"},hoverinfo:to({},Dd.hoverinfo,{flags:["label","text","value","percent","name"]}),hovertemplate:Jie({},{keys:["label","color","value","percent","text"]}),hovertemplatefallback:Lz(),texttemplate:Kie({editType:"plot"},{keys:["label","color","value","percent","text"]}),texttemplatefallback:Lz({editType:"plot"}),textposition:{valType:"enumerated",values:["inside","outside","auto","none"],dflt:"auto",arrayOk:!0,editType:"plot"},textfont:to({},Ed,{}),insidetextorientation:{valType:"enumerated",values:["horizontal","radial","tangential","auto"],dflt:"auto",editType:"plot"},insidetextfont:to({},Ed,{}),outsidetextfont:to({},Ed,{}),automargin:{valType:"boolean",dflt:!1,editType:"plot"},showlegend:to({},Dd.showlegend,{arrayOk:!0}),legend:to({},Dd.legend,{arrayOk:!0}),legendrank:to({},Dd.legendrank,{arrayOk:!0}),title:{text:{valType:"string",dflt:"",editType:"plot"},font:to({},Ed,{}),position:{valType:"enumerated",values:["top left","top center","top right","middle center","bottom left","bottom center","bottom right"],editType:"plot"},editType:"plot"},domain:Wie({name:"pie",trace:!0,editType:"calc"}),hole:{valType:"number",min:0,max:1,dflt:0,editType:"calc"},sort:{valType:"boolean",dflt:!0,editType:"calc"},direction:{valType:"enumerated",values:["clockwise","counterclockwise"],dflt:"counterclockwise",editType:"calc"},rotation:{valType:"angle",dflt:0,editType:"calc"},pull:{valType:"number",min:0,max:1,dflt:0,arrayOk:!0,editType:"calc"}}});var Rz=X((b1e,Pz)=>{"use strict";var Qie=Br(),l0=Ne(),jie=o5(),ele=bd().defaults,rle=r0().handleText,tle=Ne().coercePattern;function Dz(e,r){var t=l0.isArrayOrTypedArray(e),a=l0.isArrayOrTypedArray(r),n=Math.min(t?e.length:1/0,a?r.length:1/0);if(isFinite(n)||(n=0),n&&a){for(var i,l=0;l0){i=!0;break}}i||(n=0)}return{hasLabels:t,hasValues:a,len:n}}function Ez(e,r,t,a,n){var i=a("marker.line.width");i&&a("marker.line.color",n?void 0:t.paper_bgcolor);var l=a("marker.colors");tle(a,"marker.pattern",l),e.marker&&!r.marker.pattern.fgcolor&&(r.marker.pattern.fgcolor=e.marker.colors),r.marker.pattern.bgcolor||(r.marker.pattern.bgcolor=t.paper_bgcolor)}function ale(e,r,t,a){function n(T,M){return l0.coerce(e,r,jie,T,M)}var i=n("labels"),l=n("values"),o=Dz(i,l),s=o.len;if(r._hasLabels=o.hasLabels,r._hasValues=o.hasValues,!r._hasLabels&&r._hasValues&&(n("label0"),n("dlabel")),!s){r.visible=!1;return}r._length=s,Ez(e,r,a,n,!0),n("scalegroup");var u=n("text"),f=n("texttemplate");n("texttemplatefallback");var v;if(f||(v=n("textinfo",l0.isArrayOrTypedArray(u)?"text+percent":"percent")),n("hovertext"),n("hovertemplate"),n("hovertemplatefallback"),f||v&&v!=="none"){var h=n("textposition");rle(e,r,a,n,h,{moduleHasSelected:!1,moduleHasUnselected:!1,moduleHasConstrain:!1,moduleHasCliponaxis:!1,moduleHasTextangle:!1,moduleHasInsideanchor:!1});var d=Array.isArray(h)||h==="auto",m=d||h==="outside";m&&n("automargin"),(h==="inside"||h==="auto"||Array.isArray(h))&&n("insidetextorientation")}else v==="none"&&n("textposition","none");ele(r,a,n);var y=n("hole"),g=n("title.text");if(g){var x=n("title.position",y?"middle center":"top center");!y&&x==="middle center"&&(r.title.position="top center"),l0.coerceFont(n,"title.font",a.font)}n("sort"),n("direction"),n("rotation"),n("pull")}Pz.exports={handleLabelsAndValues:Dz,handleMarkerDefaults:Ez,supplyDefaults:ale}});var s5=X((_1e,zz)=>{"use strict";zz.exports={hiddenlabels:{valType:"data_array",editType:"calc"},piecolorway:{valType:"colorlist",editType:"calc"},extendpiecolors:{valType:"boolean",dflt:!0,editType:"calc"}}});var Fz=X((x1e,Nz)=>{"use strict";var nle=Ne(),ile=s5();Nz.exports=function(r,t){function a(n,i){return nle.coerce(r,t,ile,n,i)}a("hiddenlabels"),a("piecolorway",t.colorway),a("extendpiecolors")}});var u5=X((w1e,Oz)=>{"use strict";var lle=Br(),Pd=Er(),ole={};function sle(e,r){var t=[],a=e._fullLayout,n=a.hiddenlabels||[],i=r.labels,l=r.marker.colors||[],o=r.values,s=r._length,u=r._hasValues&&s,f,v;if(r.dlabel)for(i=new Array(s),f=0;f=0});var b=r.type==="funnelarea"?y:r.sort;return b&&t.sort(function(w,S){return S.v-w.v}),t[0]&&(t[0].vTotal=m),t}function Iz(e){return function(t,a){if(!t||!Pd.isValid(t))return!1;let n=Pd.color(t).rgb().string();return e[a]||(e[a]=n),n}}function ule(e,r){var t=(r||{}).type;t||(t="pie");var a=e._fullLayout,n=e.calcdata,i=a[t+"colorway"],l=a["_"+t+"colormap"];a["extend"+t+"colors"]&&(i=Hz(i,ole));for(var o=0,s=0;s{"use strict";var fle=Di().appendArrayMultiPointValues;Bz.exports=function(r,t){var a={curveNumber:t.index,pointNumbers:r.pts,data:t._input,fullData:t,label:r.label,color:r.color,value:r.v,percent:r.percent,text:r.text,bbox:r.bbox,v:r.v};return r.pts.length===1&&(a.pointNumber=a.i=r.pts[0]),fle(a,t,r.pts),t.type==="funnelarea"&&(delete a.v,delete a.i),a}});var fN=X((M1e,uN)=>{"use strict";var vn=Nr(),cle=Ut(),Rd=vi(),Zz=Er(),dl=at(),Ca=Ne(),vle=Ca.strScale,Uz=Ca.strTranslate,f5=da(),Jz=t0(),hle=Jz.recordMinTextSize,dle=Jz.clearMinTextSize,Kz=Fc().TEXTPAD,Tr=m1(),zd=Yz(),Vz=Ne().isValidTextValue;function ple(e,r){var t=e._context.staticPlot,a=e._fullLayout,n=a._size;dle("pie",a),jz(r,e),lN(r,n);var i=Ca.makeTraceGroups(a._pielayer,r,"trace").each(function(l){var o=vn.select(this),s=l[0],u=s.trace;Mle(l),o.attr("stroke-linejoin","round"),o.each(function(){var f=vn.select(this).selectAll("g.slice").data(l);f.enter().append("g").classed("slice",!0),f.exit().remove();var v=[[[],[]],[[],[]]],h=!1;f.each(function(b,w){if(b.hidden){vn.select(this).selectAll("path,g").remove();return}b.pointNumber=b.i,b.curveNumber=u.index,v[b.pxmid[1]<0?0:1][b.pxmid[0]<0?0:1].push(b);var S=s.cx,k=s.cy,q=vn.select(this),D=q.selectAll("path.surface").data([b]);if(D.enter().append("path").classed("surface",!0).style({"pointer-events":t?"none":"all"}),q.call($z,e,l),u.pull){var P=+Tr.castOption(u.pull,b.pts)||0;P>0&&(S+=P*b.pxmid[0],k+=P*b.pxmid[1])}b.cxFinal=S,b.cyFinal=k;function z(B,V,J,Z){var re=Z*(V[0]-B[0]),se=Z*(V[1]-B[1]);return"a"+Z*s.r+","+Z*s.r+" 0 "+b.largeArc+(J?" 1 ":" 0 ")+re+","+se}var F=u.hole;if(b.v===s.vTotal){var I="M"+(S+b.px0[0])+","+(k+b.px0[1])+z(b.px0,b.pxmid,!0,1)+z(b.pxmid,b.px0,!0,1)+"Z";F?D.attr("d","M"+(S+F*b.px0[0])+","+(k+F*b.px0[1])+z(b.px0,b.pxmid,!1,F)+z(b.pxmid,b.px0,!1,F)+"Z"+I):D.attr("d",I)}else{var H=z(b.px0,b.px1,!0,1);if(F){var O=1-F;D.attr("d","M"+(S+F*b.px1[0])+","+(k+F*b.px1[1])+z(b.px1,b.px0,!1,F)+"l"+O*b.px0[0]+","+O*b.px0[1]+H+"Z")}else D.attr("d","M"+S+","+k+"l"+b.px0[0]+","+b.px0[1]+H+"Z")}oN(e,b,s);var G=Tr.castOption(u.textposition,b.pts),Y=q.selectAll("g.slicetext").data(b.text&&G!=="none"?[0]:[]);Y.enter().append("g").classed("slicetext",!0),Y.exit().remove(),Y.each(function(){var B=Ca.ensureSingle(vn.select(this),"text","",function(Q){Q.attr("data-notex",1)}),V=Ca.ensureUniformFontSize(e,G==="outside"?yle(u,b,a.font):Qz(u,b,a.font));B.text(b.text).attr({class:"slicetext",transform:"","text-anchor":"middle"}).call(dl.font,V).call(f5.convertToTspans,e);var J=dl.bBox(B.node()),Z;if(G==="outside")Z=Xz(J,b);else if(Z=eN(J,b,s),G==="auto"&&Z.scale<1){var re=Ca.ensureUniformFontSize(e,u.outsidetextfont);B.call(dl.font,re),J=dl.bBox(B.node()),Z=Xz(J,b)}var se=Z.textPosAngle,ve=se===void 0?b.pxmid:Nd(s.r,se);if(Z.targetX=S+ve[0]*Z.rCenter+(Z.x||0),Z.targetY=k+ve[1]*Z.rCenter+(Z.y||0),sN(Z,J),Z.outside){var xe=Z.targetY;b.yLabelMin=xe-J.height/2,b.yLabelMid=xe,b.yLabelMax=xe+J.height/2,b.labelExtraX=0,b.labelExtraY=0,h=!0}Z.fontSize=V.size,hle(u.type,Z,a),l[w].transform=Z,Ca.setTransormAndDisplay(B,Z)})});var d=vn.select(this).selectAll("g.titletext").data(u.title.text?[0]:[]);if(d.enter().append("g").classed("titletext",!0),d.exit().remove(),d.each(function(){var b=Ca.ensureSingle(vn.select(this),"text","",function(k){k.attr("data-notex",1)}),w=u.title.text;u._meta&&(w=Ca.templateString(w,u._meta)),b.text(w).attr({class:"titletext",transform:"","text-anchor":"middle"}).call(dl.font,u.title.font).call(f5.convertToTspans,e);var S;u.title.position==="middle center"?S=_le(s):S=nN(s,n),b.attr("transform",Uz(S.x,S.y)+vle(Math.min(1,S.scale))+Uz(S.tx,S.ty))}),h&&wle(v,u),mle(f,u),h&&u.automargin){var m=dl.bBox(o.node()),y=u.domain,g=n.w*(y.x[1]-y.x[0]),x=n.h*(y.y[1]-y.y[0]),T=(.5*g-s.r)/n.w,M=(.5*x-s.r)/n.h;cle.autoMargin(e,"pie."+u.uid+".automargin",{xl:y.x[0]-T,xr:y.x[1]+T,yb:y.y[0]-M,yt:y.y[1]+M,l:Math.max(s.cx-s.r-m.left,0),r:Math.max(m.right-(s.cx+s.r),0),b:Math.max(m.bottom-(s.cy+s.r),0),t:Math.max(s.cy-s.r-m.top,0),pad:5})}})});setTimeout(function(){i.selectAll("tspan").each(function(){var l=vn.select(this);l.attr("dy")&&l.attr("dy",l.attr("dy"))})},0)}function mle(e,r){e.each(function(t){var a=vn.select(this);if(!t.labelExtraX&&!t.labelExtraY){a.select("path.textline").remove();return}var n=a.select("g.slicetext text");t.transform.targetX+=t.labelExtraX,t.transform.targetY+=t.labelExtraY,Ca.setTransormAndDisplay(n,t.transform);var i=t.cxFinal+t.pxmid[0],l=t.cyFinal+t.pxmid[1],o="M"+i+","+l,s=(t.yLabelMax-t.yLabelMin)*(t.pxmid[0]<0?-1:1)/4;if(t.labelExtraX){var u=t.labelExtraX*t.pxmid[1]/t.pxmid[0],f=t.yLabelMid+t.labelExtraY-(t.cyFinal+t.pxmid[1]);Math.abs(u)>Math.abs(f)?o+="l"+f*t.pxmid[0]/t.pxmid[1]+","+f+"H"+(i+t.labelExtraX+s):o+="l"+t.labelExtraX+","+u+"v"+(f-u)+"h"+s}else o+="V"+(t.yLabelMid+t.labelExtraY)+"h"+s;Ca.ensureSingle(a,"path","textline").call(Zz.stroke,r.outsidetextfont.color).attr({"stroke-width":Math.min(2,r.outsidetextfont.size/8),d:o,fill:"none"})})}function $z(e,r,t){var a=t[0],n=a.cx,i=a.cy,l=a.trace,o=l.type==="funnelarea";"_hasHoverLabel"in l||(l._hasHoverLabel=!1),"_hasHoverEvent"in l||(l._hasHoverEvent=!1),e.on("mouseover",function(s){var u=r._fullLayout,f=r._fullData[l.index];if(!(r._dragging||u.hovermode===!1)){var v=f.hoverinfo;if(Array.isArray(v)&&(v=Rd.castHoverinfo({hoverinfo:[Tr.castOption(v,s.pts)],_module:l._module},u,0)),v==="all"&&(v="label+text+value+percent+name"),f.hovertemplate||v!=="none"&&v!=="skip"&&v){var h=s.rInscribed||0,d=n+s.pxmid[0]*(1-h),m=i+s.pxmid[1]*(1-h),y=u.separators,g=[];if(v&&v.indexOf("label")!==-1&&g.push(s.label),s.text=Tr.castOption(f.hovertext||f.text,s.pts),v&&v.indexOf("text")!==-1){var x=s.text;Ca.isValidTextValue(x)&&g.push(x)}s.value=s.v,s.valueLabel=Tr.formatPieValue(s.v,y),v&&v.indexOf("value")!==-1&&g.push(s.valueLabel),s.percent=s.v/a.vTotal,s.percentLabel=Tr.formatPiePercent(s.percent,y),v&&v.indexOf("percent")!==-1&&g.push(s.percentLabel);var T=f.hoverlabel,M=T.font,b=[];Rd.loneHover({trace:l,x0:d-h*a.r,x1:d+h*a.r,y:m,_x0:o?n+s.TL[0]:d-h*a.r,_x1:o?n+s.TR[0]:d+h*a.r,_y0:o?i+s.TL[1]:m-h*a.r,_y1:o?i+s.BL[1]:m+h*a.r,text:g.join("
"),name:f.hovertemplate||v.indexOf("name")!==-1?f.name:void 0,idealAlign:s.pxmid[0]<0?"left":"right",color:Tr.castOption(T.bgcolor,s.pts)||s.color,borderColor:Tr.castOption(T.bordercolor,s.pts),fontFamily:Tr.castOption(M.family,s.pts),fontSize:Tr.castOption(M.size,s.pts),fontColor:Tr.castOption(M.color,s.pts),nameLength:Tr.castOption(T.namelength,s.pts),textAlign:Tr.castOption(T.align,s.pts),hovertemplate:Tr.castOption(f.hovertemplate,s.pts),hovertemplateLabels:s,eventData:[zd(s,f)]},{container:u._hoverlayer.node(),outerContainer:u._paper.node(),gd:r,inOut_bbox:b}),s.bbox=b[0],l._hasHoverLabel=!0}l._hasHoverEvent=!0,r.emit("plotly_hover",{points:[zd(s,f)],event:vn.event})}}),e.on("mouseout",function(s){var u=r._fullLayout,f=r._fullData[l.index],v=vn.select(this).datum();l._hasHoverEvent&&(s.originalEvent=vn.event,r.emit("plotly_unhover",{points:[zd(v,f)],event:vn.event}),l._hasHoverEvent=!1),l._hasHoverLabel&&(Rd.loneUnhover(u._hoverlayer.node()),l._hasHoverLabel=!1)}),e.on("click",function(s){var u=r._fullLayout,f=r._fullData[l.index];r._dragging||u.hovermode===!1||(r._hoverdata=[zd(s,f)],Rd.click(r,vn.event))})}function yle(e,r,t){var a=Tr.castOption(e.outsidetextfont.color,r.pts)||Tr.castOption(e.textfont.color,r.pts)||t.color,n=Tr.castOption(e.outsidetextfont.family,r.pts)||Tr.castOption(e.textfont.family,r.pts)||t.family,i=Tr.castOption(e.outsidetextfont.size,r.pts)||Tr.castOption(e.textfont.size,r.pts)||t.size,l=Tr.castOption(e.outsidetextfont.weight,r.pts)||Tr.castOption(e.textfont.weight,r.pts)||t.weight,o=Tr.castOption(e.outsidetextfont.style,r.pts)||Tr.castOption(e.textfont.style,r.pts)||t.style,s=Tr.castOption(e.outsidetextfont.variant,r.pts)||Tr.castOption(e.textfont.variant,r.pts)||t.variant,u=Tr.castOption(e.outsidetextfont.textcase,r.pts)||Tr.castOption(e.textfont.textcase,r.pts)||t.textcase,f=Tr.castOption(e.outsidetextfont.lineposition,r.pts)||Tr.castOption(e.textfont.lineposition,r.pts)||t.lineposition,v=Tr.castOption(e.outsidetextfont.shadow,r.pts)||Tr.castOption(e.textfont.shadow,r.pts)||t.shadow;return{color:a,family:n,size:i,weight:l,style:o,variant:s,textcase:u,lineposition:f,shadow:v}}function Qz(e,r,t){var a=Tr.castOption(e.insidetextfont.color,r.pts);!a&&e._input.textfont&&(a=Tr.castOption(e._input.textfont.color,r.pts));var n=Tr.castOption(e.insidetextfont.family,r.pts)||Tr.castOption(e.textfont.family,r.pts)||t.family,i=Tr.castOption(e.insidetextfont.size,r.pts)||Tr.castOption(e.textfont.size,r.pts)||t.size,l=Tr.castOption(e.insidetextfont.weight,r.pts)||Tr.castOption(e.textfont.weight,r.pts)||t.weight,o=Tr.castOption(e.insidetextfont.style,r.pts)||Tr.castOption(e.textfont.style,r.pts)||t.style,s=Tr.castOption(e.insidetextfont.variant,r.pts)||Tr.castOption(e.textfont.variant,r.pts)||t.variant,u=Tr.castOption(e.insidetextfont.textcase,r.pts)||Tr.castOption(e.textfont.textcase,r.pts)||t.textcase,f=Tr.castOption(e.insidetextfont.lineposition,r.pts)||Tr.castOption(e.textfont.lineposition,r.pts)||t.lineposition,v=Tr.castOption(e.insidetextfont.shadow,r.pts)||Tr.castOption(e.textfont.shadow,r.pts)||t.shadow;return{color:a||Zz.contrast(r.color),family:n,size:i,weight:l,style:o,variant:s,textcase:u,lineposition:f,shadow:v}}function jz(e,r){for(var t,a,n=0;n=-4;T-=2)x(Math.PI*T,"tan");for(T=4;T>=-4;T-=2)x(Math.PI*(T+1),"tan")}if(v||d){for(T=4;T>=-4;T-=2)x(Math.PI*(T+1.5),"rad");for(T=4;T>=-4;T-=2)x(Math.PI*(T+.5),"rad")}}if(o||m||v){var M=Math.sqrt(e.width*e.width+e.height*e.height);if(g={scale:n*a*2/M,rCenter:1-n,rotate:0},g.textPosAngle=(r.startangle+r.stopangle)/2,g.scale>=1)return g;y.push(g)}(m||d)&&(g=Gz(e,a,l,s,u),g.textPosAngle=(r.startangle+r.stopangle)/2,y.push(g)),(m||h)&&(g=Wz(e,a,l,s,u),g.textPosAngle=(r.startangle+r.stopangle)/2,y.push(g));for(var b=0,w=0,S=0;S=1)break}return y[b]}function gle(e,r){var t=e.startangle,a=e.stopangle;return t>r&&r>a||t0?1:-1)/2,y:i/(1+t*t/(a*a)),outside:!0}}function _le(e){var r=Math.sqrt(e.titleBox.width*e.titleBox.width+e.titleBox.height*e.titleBox.height);return{x:e.cx,y:e.cy,scale:e.trace.hole*e.r*2/r,tx:0,ty:-e.titleBox.height/2+e.trace.title.font.size}}function nN(e,r){var t=1,a=1,n,i=e.trace,l={x:e.cx,y:e.cy},o={tx:0,ty:0};o.ty+=i.title.font.size,n=iN(i),i.title.position.indexOf("top")!==-1?(l.y-=(1+n)*e.r,o.ty-=e.titleBox.height):i.title.position.indexOf("bottom")!==-1&&(l.y+=(1+n)*e.r);var s=xle(e.r,e.trace.aspectratio),u=r.w*(i.domain.x[1]-i.domain.x[0])/2;return i.title.position.indexOf("left")!==-1?(u=u+s,l.x-=(1+n)*s,o.tx+=e.titleBox.width/2):i.title.position.indexOf("center")!==-1?u*=2:i.title.position.indexOf("right")!==-1&&(u=u+s,l.x+=(1+n)*s,o.tx-=e.titleBox.width/2),t=u/e.titleBox.width,a=c5(e,r)/e.titleBox.height,{x:l.x,y:l.y,scale:Math.min(t,a),tx:o.tx,ty:o.ty}}function xle(e,r){return e/(r===void 0?1:r)}function c5(e,r){var t=e.trace,a=r.h*(t.domain.y[1]-t.domain.y[0]);return Math.min(e.titleBox.height,a/2)}function iN(e){var r=e.pull;if(!r)return 0;var t;if(Ca.isArrayOrTypedArray(r))for(r=0,t=0;tr&&(r=e.pull[t]);return r}function wle(e,r){var t,a,n,i,l,o,s,u,f,v,h,d,m;function y(M,b){return M.pxmid[1]-b.pxmid[1]}function g(M,b){return b.pxmid[1]-M.pxmid[1]}function x(M,b){b||(b={});var w=b.labelExtraY+(a?b.yLabelMax:b.yLabelMin),S=a?M.yLabelMin:M.yLabelMax,k=a?M.yLabelMax:M.yLabelMin,q=M.cyFinal+l(M.px0[1],M.px1[1]),D=w-S,P,z,F,I,H,O;if(D*s>0&&(M.labelExtraY=D),!!Ca.isArrayOrTypedArray(r.pull))for(z=0;z=(Tr.castOption(r.pull,F.pts)||0))&&((M.pxmid[1]-F.pxmid[1])*s>0?(I=F.cyFinal+l(F.px0[1],F.px1[1]),D=I-S-M.labelExtraY,D*s>0&&(M.labelExtraY+=D)):(k+M.labelExtraY-q)*s>0&&(P=3*o*Math.abs(z-v.indexOf(M)),H=F.cxFinal+i(F.px0[0],F.px1[0]),O=H+P-(M.cxFinal+M.pxmid[0])-M.labelExtraX,O*o>0&&(M.labelExtraX+=O)))}for(a=0;a<2;a++)for(n=a?y:g,l=a?Math.max:Math.min,s=a?1:-1,t=0;t<2;t++){for(i=t?Math.max:Math.min,o=t?1:-1,u=e[a][t],u.sort(n),f=e[1-a][t],v=f.concat(u),d=[],h=0;h1?(u=t.r,f=u/n.aspectratio):(f=t.r,u=f*n.aspectratio),u*=(1+n.baseratio)/2,s=u*f}l=Math.min(l,s/t.vTotal)}for(a=0;ar.vTotal/2?1:0,u.halfangle=Math.PI*Math.min(u.v/r.vTotal,.5),u.ring=1-a.hole,u.rInscribed=ble(u,r))}function Nd(e,r){return[e*Math.sin(r),-e*Math.cos(r)]}function oN(e,r,t){var a=e._fullLayout,n=t.trace,i=n.texttemplate,l=n.textinfo;if(!i&&l&&l!=="none"){var o=l.split("+"),s=function(b){return o.indexOf(b)!==-1},u=s("label"),f=s("text"),v=s("value"),h=s("percent"),d=a.separators,m;if(m=u?[r.label]:[],f){var y=Tr.getFirstFilled(n.text,r.pts);Vz(y)&&m.push(y)}v&&m.push(Tr.formatPieValue(r.v,d)),h&&m.push(Tr.formatPiePercent(r.v/t.vTotal,d)),r.text=m.join("
")}function g(b){return{label:b.label,value:b.v,valueLabel:Tr.formatPieValue(b.v,a.separators),percent:b.v/t.vTotal,percentLabel:Tr.formatPiePercent(b.v/t.vTotal,a.separators),color:b.color,text:b.text,customdata:Ca.castOption(n,b.i,"customdata")}}if(i){var x=Ca.castOption(n,r.i,"texttemplate");if(!x)r.text="";else{var T=g(r),M=Tr.getFirstFilled(n.text,r.pts);(Vz(M)||M==="")&&(T.text=M),r.text=Ca.texttemplateString({data:[T,n._meta],fallback:n.texttemplatefallback,labels:T,locale:e._fullLayout._d3locale,template:x})}}}function sN(e,r){var t=e.rotate*Math.PI/180,a=Math.cos(t),n=Math.sin(t),i=(r.left+r.right)/2,l=(r.top+r.bottom)/2;e.textX=i*a-l*n,e.textY=i*n+l*a,e.noCenter=!0}uN.exports={plot:ple,formatSliceLabel:oN,transformInsideText:eN,determineInsideTextFont:Qz,positionTitleOutside:nN,prerenderTitles:jz,layoutAreas:lN,attachFxHandlers:$z,computeTransform:sN}});var hN=X((A1e,vN)=>{"use strict";var cN=Nr(),Ale=y1(),kle=t0().resizeText;vN.exports=function(r){var t=r._fullLayout._pielayer.selectAll(".trace");kle(r,t,"pie"),t.each(function(a){var n=a[0],i=n.trace,l=cN.select(this);l.style({opacity:i.opacity}),l.selectAll("path.surface").each(function(o){cN.select(this).call(Ale,o,i,r)})})}});var pN=X($u=>{"use strict";var dN=Ut();$u.name="pie";$u.plot=function(e,r,t,a){dN.plotBasePlot($u.name,e,r,t,a)};$u.clean=function(e,r,t,a){dN.cleanBasePlot($u.name,e,r,t,a)}});var yN=X((C1e,mN)=>{"use strict";mN.exports={attributes:o5(),supplyDefaults:Rz().supplyDefaults,supplyLayoutDefaults:Fz(),layoutAttributes:s5(),calc:u5().calc,crossTraceCalc:u5().crossTraceCalc,plot:fN().plot,style:hN(),styleOne:y1(),moduleType:"trace",name:"pie",basePlotModule:pN(),categories:["pie-like","pie","showLegend"],meta:{}}});var bN=X((S1e,gN)=>{"use strict";gN.exports=yN()});var Pa=X((L1e,xN)=>{"use strict";var _N=Object.getOwnPropertySymbols,Cle=Object.prototype.hasOwnProperty,Sle=Object.prototype.propertyIsEnumerable;function Lle(e){if(e==null)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}function qle(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de",Object.getOwnPropertyNames(e)[0]==="5")return!1;for(var r={},t=0;t<10;t++)r["_"+String.fromCharCode(t)]=t;var a=Object.getOwnPropertyNames(r).map(function(i){return r[i]});if(a.join("")!=="0123456789")return!1;var n={};return"abcdefghijklmnopqrst".split("").forEach(function(i){n[i]=i}),Object.keys(Object.assign({},n)).join("")==="abcdefghijklmnopqrst"}catch(i){return!1}}xN.exports=qle()?Object.assign:function(e,r){for(var t,a=Lle(e),n,i=1;i{var Id=Pa();function wN(){this.regionalOptions=[],this.regionalOptions[""]={invalidCalendar:"Calendar {0} not found",invalidDate:"Invalid {0} date",invalidMonth:"Invalid {0} month",invalidYear:"Invalid {0} year",differentCalendars:"Cannot mix {0} and {1} dates"},this.local=this.regionalOptions[""],this.calendars={},this._localCals={}}Id(wN.prototype,{instance:function(e,r){e=(e||"gregorian").toLowerCase(),r=r||"";var t=this._localCals[e+"-"+r];if(!t&&this.calendars[e]&&(t=new this.calendars[e](r),this._localCals[e+"-"+r]=t),!t)throw(this.local.invalidCalendar||this.regionalOptions[""].invalidCalendar).replace(/\{0\}/,e);return t},newDate:function(e,r,t,a,n){return a=(e!=null&&e.year?e.calendar():typeof a=="string"?this.instance(a,n):a)||this.instance(),a.newDate(e,r,t)},substituteDigits:function(e){return function(r){return(r+"").replace(/[0-9]/g,function(t){return e[t]})}},substituteChineseDigits:function(e,r){return function(t){for(var a="",n=0;t>0;){var i=t%10;a=(i===0?"":e[i]+r[n])+a,n++,t=Math.floor(t/10)}return a.indexOf(e[1]+r[1])===0&&(a=a.substr(1)),a||e[0]}}});function v5(e,r,t,a){if(this._calendar=e,this._year=r,this._month=t,this._day=a,this._calendar._validateLevel===0&&!this._calendar.isValid(this._year,this._month,this._day))throw(Rr.local.invalidDate||Rr.regionalOptions[""].invalidDate).replace(/\{0\}/,this._calendar.local.name)}function Fd(e,r){return e=""+e,"000000".substring(0,r-e.length)+e}Id(v5.prototype,{newDate:function(e,r,t){return this._calendar.newDate(e==null?this:e,r,t)},year:function(e){return arguments.length===0?this._year:this.set(e,"y")},month:function(e){return arguments.length===0?this._month:this.set(e,"m")},day:function(e){return arguments.length===0?this._day:this.set(e,"d")},date:function(e,r,t){if(!this._calendar.isValid(e,r,t))throw(Rr.local.invalidDate||Rr.regionalOptions[""].invalidDate).replace(/\{0\}/,this._calendar.local.name);return this._year=e,this._month=r,this._day=t,this},leapYear:function(){return this._calendar.leapYear(this)},epoch:function(){return this._calendar.epoch(this)},formatYear:function(){return this._calendar.formatYear(this)},monthOfYear:function(){return this._calendar.monthOfYear(this)},weekOfYear:function(){return this._calendar.weekOfYear(this)},daysInYear:function(){return this._calendar.daysInYear(this)},dayOfYear:function(){return this._calendar.dayOfYear(this)},daysInMonth:function(){return this._calendar.daysInMonth(this)},dayOfWeek:function(){return this._calendar.dayOfWeek(this)},weekDay:function(){return this._calendar.weekDay(this)},extraInfo:function(){return this._calendar.extraInfo(this)},add:function(e,r){return this._calendar.add(this,e,r)},set:function(e,r){return this._calendar.set(this,e,r)},compareTo:function(e){if(this._calendar.name!==e._calendar.name)throw(Rr.local.differentCalendars||Rr.regionalOptions[""].differentCalendars).replace(/\{0\}/,this._calendar.local.name).replace(/\{1\}/,e._calendar.local.name);var r=this._year!==e._year?this._year-e._year:this._month!==e._month?this.monthOfYear()-e.monthOfYear():this._day-e._day;return r===0?0:r<0?-1:1},calendar:function(){return this._calendar},toJD:function(){return this._calendar.toJD(this)},fromJD:function(e){return this._calendar.fromJD(e)},toJSDate:function(){return this._calendar.toJSDate(this)},fromJSDate:function(e){return this._calendar.fromJSDate(e)},toString:function(){return(this.year()<0?"-":"")+Fd(Math.abs(this.year()),4)+"-"+Fd(this.month(),2)+"-"+Fd(this.day(),2)}});function h5(){this.shortYearCutoff="+10"}Id(h5.prototype,{_validateLevel:0,newDate:function(e,r,t){return e==null?this.today():(e.year&&(this._validate(e,r,t,Rr.local.invalidDate||Rr.regionalOptions[""].invalidDate),t=e.day(),r=e.month(),e=e.year()),new v5(this,e,r,t))},today:function(){return this.fromJSDate(new Date)},epoch:function(e){var r=this._validate(e,this.minMonth,this.minDay,Rr.local.invalidYear||Rr.regionalOptions[""].invalidYear);return r.year()<0?this.local.epochs[0]:this.local.epochs[1]},formatYear:function(e){var r=this._validate(e,this.minMonth,this.minDay,Rr.local.invalidYear||Rr.regionalOptions[""].invalidYear);return(r.year()<0?"-":"")+Fd(Math.abs(r.year()),4)},monthsInYear:function(e){return this._validate(e,this.minMonth,this.minDay,Rr.local.invalidYear||Rr.regionalOptions[""].invalidYear),12},monthOfYear:function(e,r){var t=this._validate(e,r,this.minDay,Rr.local.invalidMonth||Rr.regionalOptions[""].invalidMonth);return(t.month()+this.monthsInYear(t)-this.firstMonth)%this.monthsInYear(t)+this.minMonth},fromMonthOfYear:function(e,r){var t=(r+this.firstMonth-2*this.minMonth)%this.monthsInYear(e)+this.minMonth;return this._validate(e,t,this.minDay,Rr.local.invalidMonth||Rr.regionalOptions[""].invalidMonth),t},daysInYear:function(e){var r=this._validate(e,this.minMonth,this.minDay,Rr.local.invalidYear||Rr.regionalOptions[""].invalidYear);return this.leapYear(r)?366:365},dayOfYear:function(e,r,t){var a=this._validate(e,r,t,Rr.local.invalidDate||Rr.regionalOptions[""].invalidDate);return a.toJD()-this.newDate(a.year(),this.fromMonthOfYear(a.year(),this.minMonth),this.minDay).toJD()+1},daysInWeek:function(){return 7},dayOfWeek:function(e,r,t){var a=this._validate(e,r,t,Rr.local.invalidDate||Rr.regionalOptions[""].invalidDate);return(Math.floor(this.toJD(a))+2)%this.daysInWeek()},extraInfo:function(e,r,t){return this._validate(e,r,t,Rr.local.invalidDate||Rr.regionalOptions[""].invalidDate),{}},add:function(e,r,t){return this._validate(e,this.minMonth,this.minDay,Rr.local.invalidDate||Rr.regionalOptions[""].invalidDate),this._correctAdd(e,this._add(e,r,t),r,t)},_add:function(e,r,t){if(this._validateLevel++,t==="d"||t==="w"){var a=e.toJD()+r*(t==="w"?this.daysInWeek():1),n=e.calendar().fromJD(a);return this._validateLevel--,[n.year(),n.month(),n.day()]}try{var i=e.year()+(t==="y"?r:0),l=e.monthOfYear()+(t==="m"?r:0),n=e.day(),o=function(f){for(;lv-1+f.minMonth;)i++,l-=v,v=f.monthsInYear(i)};t==="y"?(e.month()!==this.fromMonthOfYear(i,l)&&(l=this.newDate(i,e.month(),this.minDay).monthOfYear()),l=Math.min(l,this.monthsInYear(i)),n=Math.min(n,this.daysInMonth(i,this.fromMonthOfYear(i,l)))):t==="m"&&(o(this),n=Math.min(n,this.daysInMonth(i,this.fromMonthOfYear(i,l))));var s=[i,this.fromMonthOfYear(i,l),n];return this._validateLevel--,s}catch(u){throw this._validateLevel--,u}},_correctAdd:function(e,r,t,a){if(!this.hasYearZero&&(a==="y"||a==="m")&&(r[0]===0||e.year()>0!=r[0]>0)){var n={y:[1,1,"y"],m:[1,this.monthsInYear(-1),"m"],w:[this.daysInWeek(),this.daysInYear(-1),"d"],d:[1,this.daysInYear(-1),"d"]}[a],i=t<0?-1:1;r=this._add(e,t*n[0]+i*n[1],n[2])}return e.date(r[0],r[1],r[2])},set:function(e,r,t){this._validate(e,this.minMonth,this.minDay,Rr.local.invalidDate||Rr.regionalOptions[""].invalidDate);var a=t==="y"?r:e.year(),n=t==="m"?r:e.month(),i=t==="d"?r:e.day();return(t==="y"||t==="m")&&(i=Math.min(i,this.daysInMonth(a,n))),e.date(a,n,i)},isValid:function(e,r,t){this._validateLevel++;var a=this.hasYearZero||e!==0;if(a){var n=this.newDate(e,r,this.minDay);a=r>=this.minMonth&&r-this.minMonth=this.minDay&&t-this.minDay13.5?13:1),u=n-(s>2.5?4716:4715);return u<=0&&u--,this.newDate(u,s,o)},toJSDate:function(e,r,t){var a=this._validate(e,r,t,Rr.local.invalidDate||Rr.regionalOptions[""].invalidDate),n=new Date(a.year(),a.month()-1,a.day());return n.setHours(0),n.setMinutes(0),n.setSeconds(0),n.setMilliseconds(0),n.setHours(n.getHours()>12?n.getHours()+2:0),n},fromJSDate:function(e){return this.newDate(e.getFullYear(),e.getMonth()+1,e.getDate())}});var Rr=TN.exports=new wN;Rr.cdate=v5;Rr.baseCalendar=h5;Rr.calendars.gregorian=d5});var MN=X(()=>{var p5=Pa(),ya=Ra();p5(ya.regionalOptions[""],{invalidArguments:"Invalid arguments",invalidFormat:"Cannot format a date from another calendar",missingNumberAt:"Missing number at position {0}",unknownNameAt:"Unknown name at position {0}",unexpectedLiteralAt:"Unexpected literal at position {0}",unexpectedText:"Additional text found at end"});ya.local=ya.regionalOptions[""];p5(ya.cdate.prototype,{formatDate:function(e,r){return typeof e!="string"&&(r=e,e=""),this._calendar.formatDate(e||"",this,r)}});p5(ya.baseCalendar.prototype,{UNIX_EPOCH:ya.instance().newDate(1970,1,1).toJD(),SECS_PER_DAY:1440*60,TICKS_EPOCH:ya.instance().jdEpoch,TICKS_PER_DAY:1440*60*1e7,ATOM:"yyyy-mm-dd",COOKIE:"D, dd M yyyy",FULL:"DD, MM d, yyyy",ISO_8601:"yyyy-mm-dd",JULIAN:"J",RFC_822:"D, d M yy",RFC_850:"DD, dd-M-yy",RFC_1036:"D, d M yy",RFC_1123:"D, d M yyyy",RFC_2822:"D, d M yyyy",RSS:"D, d M yy",TICKS:"!",TIMESTAMP:"@",W3C:"yyyy-mm-dd",formatDate:function(e,r,t){if(typeof e!="string"&&(t=r,r=e,e=""),!r)return"";if(r.calendar()!==this)throw ya.local.invalidFormat||ya.regionalOptions[""].invalidFormat;e=e||this.local.dateFormat,t=t||{};for(var a=t.dayNamesShort||this.local.dayNamesShort,n=t.dayNames||this.local.dayNames,i=t.monthNumbers||this.local.monthNumbers,l=t.monthNamesShort||this.local.monthNamesShort,o=t.monthNames||this.local.monthNames,s=t.calculateWeek||this.local.calculateWeek,u=function(b,w){for(var S=1;M+S1},f=function(b,w,S,k){var q=""+w;if(u(b,k))for(;q.length1},T=function(F,I){var H=x(F,I),O=[2,3,H?4:2,H?4:2,10,11,20]["oyYJ@!".indexOf(F)+1],G=new RegExp("^-?\\d{1,"+O+"}"),Y=r.substring(q).match(G);if(!Y)throw(ya.local.missingNumberAt||ya.regionalOptions[""].missingNumberAt).replace(/\{0\}/,q);return q+=Y[0].length,parseInt(Y[0],10)},M=this,b=function(){if(typeof o=="function"){x("m");var F=o.call(M,r.substring(q));return q+=F.length,F}return T("m")},w=function(F,I,H,O){for(var G=x(F,O)?H:I,Y=0;Y-1){h=1,d=m;for(var z=this.daysInMonth(v,h);d>z;z=this.daysInMonth(v,h))h++,d-=z}return f>-1?this.fromJD(f):this.newDate(v,h,d)},determineDate:function(e,r,t,a,n){t&&typeof t!="object"&&(n=a,a=t,t=null),typeof a!="string"&&(n=a,a="");var i=this,l=function(o){try{return i.parseDate(a,o,n)}catch(v){}o=o.toLowerCase();for(var s=(o.match(/^c/)&&t?t.newDate():null)||i.today(),u=/([+-]?[0-9]+)\s*(d|w|m|y)?/g,f=u.exec(o);f;)s.add(parseInt(f[1],10),f[2]||"d"),f=u.exec(o);return s};return r=r?r.newDate():null,e=e==null?r:typeof e=="string"?l(e):typeof e=="number"?isNaN(e)||e===1/0||e===-1/0?r:i.today().add(e,"d"):i.newDate(e),e}})});var AN=X(()=>{var ao=Ra(),Dle=Pa(),m5=ao.instance();function Hd(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}Hd.prototype=new ao.baseCalendar;Dle(Hd.prototype,{name:"Chinese",jdEpoch:17214255e-1,hasYearZero:!1,minMonth:0,firstMonth:0,minDay:1,regionalOptions:{"":{name:"Chinese",epochs:["BEC","EC"],monthNumbers:function(e,r){if(typeof e=="string"){var t=e.match(Ple);return t?t[0]:""}var a=this._validateYear(e),n=e.month(),i=""+this.toChineseMonth(a,n);return r&&i.length<2&&(i="0"+i),this.isIntercalaryMonth(a,n)&&(i+="i"),i},monthNames:function(e){if(typeof e=="string"){var r=e.match(Rle);return r?r[0]:""}var t=this._validateYear(e),a=e.month(),n=this.toChineseMonth(t,a),i=["\u4E00\u6708","\u4E8C\u6708","\u4E09\u6708","\u56DB\u6708","\u4E94\u6708","\u516D\u6708","\u4E03\u6708","\u516B\u6708","\u4E5D\u6708","\u5341\u6708","\u5341\u4E00\u6708","\u5341\u4E8C\u6708"][n-1];return this.isIntercalaryMonth(t,a)&&(i="\u95F0"+i),i},monthNamesShort:function(e){if(typeof e=="string"){var r=e.match(zle);return r?r[0]:""}var t=this._validateYear(e),a=e.month(),n=this.toChineseMonth(t,a),i=["\u4E00","\u4E8C","\u4E09","\u56DB","\u4E94","\u516D","\u4E03","\u516B","\u4E5D","\u5341","\u5341\u4E00","\u5341\u4E8C"][n-1];return this.isIntercalaryMonth(t,a)&&(i="\u95F0"+i),i},parseMonth:function(e,r){e=this._validateYear(e);var t=parseInt(r),a;if(isNaN(t))r[0]==="\u95F0"&&(a=!0,r=r.substring(1)),r[r.length-1]==="\u6708"&&(r=r.substring(0,r.length-1)),t=1+["\u4E00","\u4E8C","\u4E09","\u56DB","\u4E94","\u516D","\u4E03","\u516B","\u4E5D","\u5341","\u5341\u4E00","\u5341\u4E8C"].indexOf(r);else{var n=r[r.length-1];a=n==="i"||n==="I"}var i=this.toMonthIndex(e,t,a);return i},dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],digits:null,dateFormat:"yyyy/mm/dd",firstDay:1,isRTL:!1}},_validateYear:function(e,r){if(e.year&&(e=e.year()),typeof e!="number"||e<1888||e>2111)throw r.replace(/\{0\}/,this.local.name);return e},toMonthIndex:function(e,r,t){var a=this.intercalaryMonth(e),n=t&&r!==a;if(n||r<1||r>12)throw ao.local.invalidMonth.replace(/\{0\}/,this.local.name);var i;return a?!t&&r<=a?i=r-1:i=r:i=r-1,i},toChineseMonth:function(e,r){e.year&&(e=e.year(),r=e.month());var t=this.intercalaryMonth(e),a=t?12:11;if(r<0||r>a)throw ao.local.invalidMonth.replace(/\{0\}/,this.local.name);var n;return t?r>13;return t},isIntercalaryMonth:function(e,r){e.year&&(e=e.year(),r=e.month());var t=this.intercalaryMonth(e);return!!t&&t===r},leapYear:function(e){return this.intercalaryMonth(e)!==0},weekOfYear:function(e,r,t){var a=this._validateYear(e,ao.local.invalidyear),n=io[a-io[0]],i=n>>9&4095,l=n>>5&15,o=n&31,s;s=m5.newDate(i,l,o),s.add(4-(s.dayOfWeek()||7),"d");var u=this.toJD(e,r,t)-s.toJD();return 1+Math.floor(u/7)},monthsInYear:function(e){return this.leapYear(e)?13:12},daysInMonth:function(e,r){e.year&&(r=e.month(),e=e.year()),e=this._validateYear(e);var t=no[e-no[0]],a=t>>13,n=a?12:11;if(r>n)throw ao.local.invalidMonth.replace(/\{0\}/,this.local.name);var i=t&1<<12-r?30:29;return i},weekDay:function(e,r,t){return(this.dayOfWeek(e,r,t)||7)<6},toJD:function(e,r,t){var a=this._validate(e,i,t,ao.local.invalidDate);e=this._validateYear(a.year()),r=a.month(),t=a.day();var n=this.isIntercalaryMonth(e,r),i=this.toChineseMonth(e,r),l=Fle(e,i,t,n);return m5.toJD(l.year,l.month,l.day)},fromJD:function(e){var r=m5.fromJD(e),t=Nle(r.year(),r.month(),r.day()),a=this.toMonthIndex(t.year,t.month,t.isIntercalary);return this.newDate(t.year,a,t.day)},fromString:function(e){var r=e.match(Ele),t=this._validateYear(+r[1]),a=+r[2],n=!!r[3],i=this.toMonthIndex(t,a,n),l=+r[4];return this.newDate(t,i,l)},add:function(e,r,t){var a=e.year(),n=e.month(),i=this.isIntercalaryMonth(a,n),l=this.toChineseMonth(a,n),o=Object.getPrototypeOf(Hd.prototype).add.call(this,e,r,t);if(t==="y"){var s=o.year(),u=o.month(),f=this.isIntercalaryMonth(s,l),v=i&&f?this.toMonthIndex(s,l,!0):this.toMonthIndex(s,l,!1);v!==u&&o.month(v)}return o}});var Ele=/^\s*(-?\d\d\d\d|\d\d)[-/](\d?\d)([iI]?)[-/](\d?\d)/m,Ple=/^\d?\d[iI]?/m,Rle=/^闰?十?[一二三四五六七八九]?月/m,zle=/^闰?十?[一二三四五六七八九]?/m;ao.calendars.chinese=Hd;var no=[1887,5780,5802,19157,2742,50359,1198,2646,46378,7466,3412,30122,5482,67949,2396,5294,43597,6732,6954,36181,2772,4954,18781,2396,54427,5274,6730,47781,5800,6868,21210,4790,59703,2350,5270,46667,3402,3496,38325,1388,4782,18735,2350,52374,6804,7498,44457,2906,1388,29294,4700,63789,6442,6804,56138,5802,2772,38235,1210,4698,22827,5418,63125,3476,5802,43701,2484,5302,27223,2646,70954,7466,3412,54698,5482,2412,38062,5294,2636,32038,6954,60245,2772,4826,43357,2394,5274,39501,6730,72357,5800,5844,53978,4790,2358,38039,5270,87627,3402,3496,54708,5484,4782,43311,2350,3222,27978,7498,68965,2904,5484,45677,4700,6444,39573,6804,6986,19285,2772,62811,1210,4698,47403,5418,5780,38570,5546,76469,2420,5302,51799,2646,5414,36501,3412,5546,18869,2412,54446,5276,6732,48422,6822,2900,28010,4826,92509,2394,5274,55883,6730,6820,47956,5812,2778,18779,2358,62615,5270,5450,46757,3492,5556,27318,4718,67887,2350,3222,52554,7498,3428,38252,5468,4700,31022,6444,64149,6804,6986,43861,2772,5338,35421,2650,70955,5418,5780,54954,5546,2740,38074,5302,2646,29991,3366,61011,3412,5546,43445,2412,5294,35406,6732,72998,6820,6996,52586,2778,2396,38045,5274,6698,23333,6820,64338,5812,2746,43355,2358,5270,39499,5450,79525,3492,5548],io=[1887,966732,967231,967733,968265,968766,969297,969798,970298,970829,971330,971830,972362,972863,973395,973896,974397,974928,975428,975929,976461,976962,977462,977994,978494,979026,979526,980026,980558,981059,981559,982091,982593,983124,983624,984124,984656,985157,985656,986189,986690,987191,987722,988222,988753,989254,989754,990286,990788,991288,991819,992319,992851,993352,993851,994383,994885,995385,995917,996418,996918,997450,997949,998481,998982,999483,1000014,1000515,1001016,1001548,1002047,1002578,1003080,1003580,1004111,1004613,1005113,1005645,1006146,1006645,1007177,1007678,1008209,1008710,1009211,1009743,1010243,1010743,1011275,1011775,1012306,1012807,1013308,1013840,1014341,1014841,1015373,1015874,1016404,1016905,1017405,1017937,1018438,1018939,1019471,1019972,1020471,1021002,1021503,1022035,1022535,1023036,1023568,1024069,1024568,1025100,1025601,1026102,1026633,1027133,1027666,1028167,1028666,1029198,1029699,1030199,1030730,1031231,1031763,1032264,1032764,1033296,1033797,1034297,1034828,1035329,1035830,1036362,1036861,1037393,1037894,1038394,1038925,1039427,1039927,1040459,1040959,1041491,1041992,1042492,1043023,1043524,1044024,1044556,1045057,1045558,1046090,1046590,1047121,1047622,1048122,1048654,1049154,1049655,1050187,1050689,1051219,1051720,1052220,1052751,1053252,1053752,1054284,1054786,1055285,1055817,1056317,1056849,1057349,1057850,1058382,1058883,1059383,1059915,1060415,1060947,1061447,1061947,1062479,1062981,1063480,1064012,1064514,1065014,1065545,1066045,1066577,1067078,1067578,1068110,1068611,1069112,1069642,1070142,1070674,1071175,1071675,1072207,1072709,1073209,1073740,1074241,1074741,1075273,1075773,1076305,1076807,1077308,1077839,1078340,1078840,1079372,1079871,1080403,1080904];function Nle(e,r,t,a){var n,i;if(typeof e=="object")n=e,i=r||{};else{var l=typeof e=="number"&&e>=1888&&e<=2111;if(!l)throw new Error("Solar year outside range 1888-2111");var o=typeof r=="number"&&r>=1&&r<=12;if(!o)throw new Error("Solar month outside range 1 - 12");var s=typeof t=="number"&&t>=1&&t<=31;if(!s)throw new Error("Solar day outside range 1 - 31");n={year:e,month:r,day:t},i=a||{}}var u=io[n.year-io[0]],f=n.year<<9|n.month<<5|n.day;i.year=f>=u?n.year:n.year-1,u=io[i.year-io[0]];var v=u>>9&4095,h=u>>5&15,d=u&31,m,y=new Date(v,h-1,d),g=new Date(n.year,n.month-1,n.day);m=Math.round((g-y)/(24*3600*1e3));var x=no[i.year-no[0]],T;for(T=0;T<13;T++){var M=x&1<<12-T?30:29;if(m>13;return!b||T=1888&&e<=2111;if(!o)throw new Error("Lunar year outside range 1888-2111");var s=typeof r=="number"&&r>=1&&r<=12;if(!s)throw new Error("Lunar month outside range 1 - 12");var u=typeof t=="number"&&t>=1&&t<=30;if(!u)throw new Error("Lunar day outside range 1 - 30");var f;typeof a=="object"?(f=!1,i=a):(f=!!a,i=n||{}),l={year:e,month:r,day:t,isIntercalary:f}}var v;v=l.day-1;var h=no[l.year-no[0]],d=h>>13,m;d&&(l.month>d||l.isIntercalary)?m=l.month:m=l.month-1;for(var y=0;y>9&4095,M=x>>5&15,b=x&31,w=new Date(T,M-1,b+v);return i.year=w.getFullYear(),i.month=1+w.getMonth(),i.day=w.getDate(),i}});var kN=X(()=>{var ds=Ra(),Ile=Pa();function y5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}y5.prototype=new ds.baseCalendar;Ile(y5.prototype,{name:"Coptic",jdEpoch:18250295e-1,daysPerMonth:[30,30,30,30,30,30,30,30,30,30,30,30,5],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Coptic",epochs:["BAM","AM"],monthNames:["Thout","Paopi","Hathor","Koiak","Tobi","Meshir","Paremhat","Paremoude","Pashons","Paoni","Epip","Mesori","Pi Kogi Enavot"],monthNamesShort:["Tho","Pao","Hath","Koi","Tob","Mesh","Pat","Pad","Pash","Pao","Epi","Meso","PiK"],dayNames:["Tkyriaka","Pesnau","Pshoment","Peftoou","Ptiou","Psoou","Psabbaton"],dayNamesShort:["Tky","Pes","Psh","Pef","Pti","Pso","Psa"],dayNamesMin:["Tk","Pes","Psh","Pef","Pt","Pso","Psa"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:0,isRTL:!1}},leapYear:function(t){var r=this._validate(t,this.minMonth,this.minDay,ds.local.invalidYear),t=r.year()+(r.year()<0?1:0);return t%4===3||t%4===-1},monthsInYear:function(e){return this._validate(e,this.minMonth,this.minDay,ds.local.invalidYear||ds.regionalOptions[""].invalidYear),13},weekOfYear:function(e,r,t){var a=this.newDate(e,r,t);return a.add(-a.dayOfWeek(),"d"),Math.floor((a.dayOfYear()-1)/7)+1},daysInMonth:function(e,r){var t=this._validate(e,r,this.minDay,ds.local.invalidMonth);return this.daysPerMonth[t.month()-1]+(t.month()===13&&this.leapYear(t.year())?1:0)},weekDay:function(e,r,t){return(this.dayOfWeek(e,r,t)||7)<6},toJD:function(e,r,t){var a=this._validate(e,r,t,ds.local.invalidDate);return e=a.year(),e<0&&e++,a.day()+(a.month()-1)*30+(e-1)*365+Math.floor(e/4)+this.jdEpoch-1},fromJD:function(e){var r=Math.floor(e)+.5-this.jdEpoch,t=Math.floor((r-Math.floor((r+366)/1461))/365)+1;t<=0&&t--,r=Math.floor(e)+.5-this.newDate(t,1,1).toJD();var a=Math.floor(r/30)+1,n=r-(a-1)*30+1;return this.newDate(t,a,n)}});ds.calendars.coptic=y5});var CN=X(()=>{var pl=Ra(),Hle=Pa();function g5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}g5.prototype=new pl.baseCalendar;Hle(g5.prototype,{name:"Discworld",jdEpoch:17214255e-1,daysPerMonth:[16,32,32,32,32,32,32,32,32,32,32,32,32],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Discworld",epochs:["BUC","UC"],monthNames:["Ick","Offle","February","March","April","May","June","Grune","August","Spune","Sektober","Ember","December"],monthNamesShort:["Ick","Off","Feb","Mar","Apr","May","Jun","Gru","Aug","Spu","Sek","Emb","Dec"],dayNames:["Sunday","Octeday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Oct","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Oc","Mo","Tu","We","Th","Fr","Sa"],digits:null,dateFormat:"yyyy/mm/dd",firstDay:2,isRTL:!1}},leapYear:function(e){return this._validate(e,this.minMonth,this.minDay,pl.local.invalidYear),!1},monthsInYear:function(e){return this._validate(e,this.minMonth,this.minDay,pl.local.invalidYear),13},daysInYear:function(e){return this._validate(e,this.minMonth,this.minDay,pl.local.invalidYear),400},weekOfYear:function(e,r,t){var a=this.newDate(e,r,t);return a.add(-a.dayOfWeek(),"d"),Math.floor((a.dayOfYear()-1)/8)+1},daysInMonth:function(e,r){var t=this._validate(e,r,this.minDay,pl.local.invalidMonth);return this.daysPerMonth[t.month()-1]},daysInWeek:function(){return 8},dayOfWeek:function(e,r,t){var a=this._validate(e,r,t,pl.local.invalidDate);return(a.day()+1)%8},weekDay:function(e,r,t){var a=this.dayOfWeek(e,r,t);return a>=2&&a<=6},extraInfo:function(e,r,t){var a=this._validate(e,r,t,pl.local.invalidDate);return{century:Ole[Math.floor((a.year()-1)/100)+1]||""}},toJD:function(e,r,t){var a=this._validate(e,r,t,pl.local.invalidDate);return e=a.year()+(a.year()<0?1:0),r=a.month(),t=a.day(),t+(r>1?16:0)+(r>2?(r-2)*32:0)+(e-1)*400+this.jdEpoch-1},fromJD:function(e){e=Math.floor(e+.5)-Math.floor(this.jdEpoch)-1;var r=Math.floor(e/400)+1;e-=(r-1)*400,e+=e>15?16:0;var t=Math.floor(e/32)+1,a=e-(t-1)*32+1;return this.newDate(r<=0?r-1:r,t,a)}});var Ole={20:"Fruitbat",21:"Anchovy"};pl.calendars.discworld=g5});var SN=X(()=>{var ps=Ra(),Ble=Pa();function b5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}b5.prototype=new ps.baseCalendar;Ble(b5.prototype,{name:"Ethiopian",jdEpoch:17242205e-1,daysPerMonth:[30,30,30,30,30,30,30,30,30,30,30,30,5],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Ethiopian",epochs:["BEE","EE"],monthNames:["Meskerem","Tikemet","Hidar","Tahesas","Tir","Yekatit","Megabit","Miazia","Genbot","Sene","Hamle","Nehase","Pagume"],monthNamesShort:["Mes","Tik","Hid","Tah","Tir","Yek","Meg","Mia","Gen","Sen","Ham","Neh","Pag"],dayNames:["Ehud","Segno","Maksegno","Irob","Hamus","Arb","Kidame"],dayNamesShort:["Ehu","Seg","Mak","Iro","Ham","Arb","Kid"],dayNamesMin:["Eh","Se","Ma","Ir","Ha","Ar","Ki"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:0,isRTL:!1}},leapYear:function(t){var r=this._validate(t,this.minMonth,this.minDay,ps.local.invalidYear),t=r.year()+(r.year()<0?1:0);return t%4===3||t%4===-1},monthsInYear:function(e){return this._validate(e,this.minMonth,this.minDay,ps.local.invalidYear||ps.regionalOptions[""].invalidYear),13},weekOfYear:function(e,r,t){var a=this.newDate(e,r,t);return a.add(-a.dayOfWeek(),"d"),Math.floor((a.dayOfYear()-1)/7)+1},daysInMonth:function(e,r){var t=this._validate(e,r,this.minDay,ps.local.invalidMonth);return this.daysPerMonth[t.month()-1]+(t.month()===13&&this.leapYear(t.year())?1:0)},weekDay:function(e,r,t){return(this.dayOfWeek(e,r,t)||7)<6},toJD:function(e,r,t){var a=this._validate(e,r,t,ps.local.invalidDate);return e=a.year(),e<0&&e++,a.day()+(a.month()-1)*30+(e-1)*365+Math.floor(e/4)+this.jdEpoch-1},fromJD:function(e){var r=Math.floor(e)+.5-this.jdEpoch,t=Math.floor((r-Math.floor((r+366)/1461))/365)+1;t<=0&&t--,r=Math.floor(e)+.5-this.newDate(t,1,1).toJD();var a=Math.floor(r/30)+1,n=r-(a-1)*30+1;return this.newDate(t,a,n)}});ps.calendars.ethiopian=b5});var LN=X(()=>{var lo=Ra(),Yle=Pa();function _5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}_5.prototype=new lo.baseCalendar;Yle(_5.prototype,{name:"Hebrew",jdEpoch:347995.5,daysPerMonth:[30,29,30,29,30,29,30,29,30,29,30,29,29],hasYearZero:!1,minMonth:1,firstMonth:7,minDay:1,regionalOptions:{"":{name:"Hebrew",epochs:["BAM","AM"],monthNames:["Nisan","Iyar","Sivan","Tammuz","Av","Elul","Tishrei","Cheshvan","Kislev","Tevet","Shevat","Adar","Adar II"],monthNamesShort:["Nis","Iya","Siv","Tam","Av","Elu","Tis","Che","Kis","Tev","She","Ada","Ad2"],dayNames:["Yom Rishon","Yom Sheni","Yom Shlishi","Yom Revi'i","Yom Chamishi","Yom Shishi","Yom Shabbat"],dayNamesShort:["Ris","She","Shl","Rev","Cha","Shi","Sha"],dayNamesMin:["Ri","She","Shl","Re","Ch","Shi","Sha"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:0,isRTL:!1}},leapYear:function(e){var r=this._validate(e,this.minMonth,this.minDay,lo.local.invalidYear);return this._leapYear(r.year())},_leapYear:function(e){return e=e<0?e+1:e,Od(e*7+1,19)<7},monthsInYear:function(e){return this._validate(e,this.minMonth,this.minDay,lo.local.invalidYear),this._leapYear(e.year?e.year():e)?13:12},weekOfYear:function(e,r,t){var a=this.newDate(e,r,t);return a.add(-a.dayOfWeek(),"d"),Math.floor((a.dayOfYear()-1)/7)+1},daysInYear:function(e){var r=this._validate(e,this.minMonth,this.minDay,lo.local.invalidYear);return e=r.year(),this.toJD(e===-1?1:e+1,7,1)-this.toJD(e,7,1)},daysInMonth:function(e,r){return e.year&&(r=e.month(),e=e.year()),this._validate(e,r,this.minDay,lo.local.invalidMonth),r===12&&this.leapYear(e)||r===8&&Od(this.daysInYear(e),10)===5?30:r===9&&Od(this.daysInYear(e),10)===3?29:this.daysPerMonth[r-1]},weekDay:function(e,r,t){return this.dayOfWeek(e,r,t)!==6},extraInfo:function(e,r,t){var a=this._validate(e,r,t,lo.local.invalidDate);return{yearType:(this.leapYear(a)?"embolismic":"common")+" "+["deficient","regular","complete"][this.daysInYear(a)%10-3]}},toJD:function(e,r,t){var a=this._validate(e,r,t,lo.local.invalidDate);e=a.year(),r=a.month(),t=a.day();var n=e<=0?e+1:e,i=this.jdEpoch+this._delay1(n)+this._delay2(n)+t+1;if(r<7){for(var l=7;l<=this.monthsInYear(e);l++)i+=this.daysInMonth(e,l);for(var l=1;l=this.toJD(r===-1?1:r+1,7,1);)r++;for(var t=ethis.toJD(r,t,this.daysInMonth(r,t));)t++;var a=e-this.toJD(r,t,1)+1;return this.newDate(r,t,a)}});function Od(e,r){return e-r*Math.floor(e/r)}lo.calendars.hebrew=_5});var qN=X(()=>{var o0=Ra(),Ule=Pa();function x5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}x5.prototype=new o0.baseCalendar;Ule(x5.prototype,{name:"Islamic",jdEpoch:19484395e-1,daysPerMonth:[30,29,30,29,30,29,30,29,30,29,30,29],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Islamic",epochs:["BH","AH"],monthNames:["Muharram","Safar","Rabi' al-awwal","Rabi' al-thani","Jumada al-awwal","Jumada al-thani","Rajab","Sha'aban","Ramadan","Shawwal","Dhu al-Qi'dah","Dhu al-Hijjah"],monthNamesShort:["Muh","Saf","Rab1","Rab2","Jum1","Jum2","Raj","Sha'","Ram","Shaw","DhuQ","DhuH"],dayNames:["Yawm al-ahad","Yawm al-ithnayn","Yawm ath-thulaathaa'","Yawm al-arbi'aa'","Yawm al-kham\u012Bs","Yawm al-jum'a","Yawm as-sabt"],dayNamesShort:["Aha","Ith","Thu","Arb","Kha","Jum","Sab"],dayNamesMin:["Ah","It","Th","Ar","Kh","Ju","Sa"],digits:null,dateFormat:"yyyy/mm/dd",firstDay:6,isRTL:!1}},leapYear:function(e){var r=this._validate(e,this.minMonth,this.minDay,o0.local.invalidYear);return(r.year()*11+14)%30<11},weekOfYear:function(e,r,t){var a=this.newDate(e,r,t);return a.add(-a.dayOfWeek(),"d"),Math.floor((a.dayOfYear()-1)/7)+1},daysInYear:function(e){return this.leapYear(e)?355:354},daysInMonth:function(e,r){var t=this._validate(e,r,this.minDay,o0.local.invalidMonth);return this.daysPerMonth[t.month()-1]+(t.month()===12&&this.leapYear(t.year())?1:0)},weekDay:function(e,r,t){return this.dayOfWeek(e,r,t)!==5},toJD:function(e,r,t){var a=this._validate(e,r,t,o0.local.invalidDate);return e=a.year(),r=a.month(),t=a.day(),e=e<=0?e+1:e,t+Math.ceil(29.5*(r-1))+(e-1)*354+Math.floor((3+11*e)/30)+this.jdEpoch-1},fromJD:function(e){e=Math.floor(e)+.5;var r=Math.floor((30*(e-this.jdEpoch)+10646)/10631);r=r<=0?r-1:r;var t=Math.min(12,Math.ceil((e-29-this.toJD(r,1,1))/29.5)+1),a=e-this.toJD(r,t,1)+1;return this.newDate(r,t,a)}});o0.calendars.islamic=x5});var DN=X(()=>{var s0=Ra(),Vle=Pa();function w5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}w5.prototype=new s0.baseCalendar;Vle(w5.prototype,{name:"Julian",jdEpoch:17214235e-1,daysPerMonth:[31,28,31,30,31,30,31,31,30,31,30,31],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Julian",epochs:["BC","AD"],monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],digits:null,dateFormat:"mm/dd/yyyy",firstDay:0,isRTL:!1}},leapYear:function(t){var r=this._validate(t,this.minMonth,this.minDay,s0.local.invalidYear),t=r.year()<0?r.year()+1:r.year();return t%4===0},weekOfYear:function(e,r,t){var a=this.newDate(e,r,t);return a.add(4-(a.dayOfWeek()||7),"d"),Math.floor((a.dayOfYear()-1)/7)+1},daysInMonth:function(e,r){var t=this._validate(e,r,this.minDay,s0.local.invalidMonth);return this.daysPerMonth[t.month()-1]+(t.month()===2&&this.leapYear(t.year())?1:0)},weekDay:function(e,r,t){return(this.dayOfWeek(e,r,t)||7)<6},toJD:function(e,r,t){var a=this._validate(e,r,t,s0.local.invalidDate);return e=a.year(),r=a.month(),t=a.day(),e<0&&e++,r<=2&&(e--,r+=12),Math.floor(365.25*(e+4716))+Math.floor(30.6001*(r+1))+t-1524.5},fromJD:function(e){var r=Math.floor(e+.5),t=r+1524,a=Math.floor((t-122.1)/365.25),n=Math.floor(365.25*a),i=Math.floor((t-n)/30.6001),l=i-Math.floor(i<14?1:13),o=a-Math.floor(l>2?4716:4715),s=t-n-Math.floor(30.6001*i);return o<=0&&o--,this.newDate(o,l,s)}});s0.calendars.julian=w5});var PN=X(()=>{var Jn=Ra(),Gle=Pa();function M5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}M5.prototype=new Jn.baseCalendar;Gle(M5.prototype,{name:"Mayan",jdEpoch:584282.5,hasYearZero:!0,minMonth:0,firstMonth:0,minDay:0,regionalOptions:{"":{name:"Mayan",epochs:["",""],monthNames:["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17"],monthNamesShort:["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17"],dayNames:["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19"],dayNamesShort:["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19"],dayNamesMin:["0","1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19"],digits:null,dateFormat:"YYYY.m.d",firstDay:0,isRTL:!1,haabMonths:["Pop","Uo","Zip","Zotz","Tzec","Xul","Yaxkin","Mol","Chen","Yax","Zac","Ceh","Mac","Kankin","Muan","Pax","Kayab","Cumku","Uayeb"],tzolkinMonths:["Imix","Ik","Akbal","Kan","Chicchan","Cimi","Manik","Lamat","Muluc","Oc","Chuen","Eb","Ben","Ix","Men","Cib","Caban","Etznab","Cauac","Ahau"]}},leapYear:function(e){return this._validate(e,this.minMonth,this.minDay,Jn.local.invalidYear),!1},formatYear:function(e){var r=this._validate(e,this.minMonth,this.minDay,Jn.local.invalidYear);e=r.year();var t=Math.floor(e/400);e=e%400,e+=e<0?400:0;var a=Math.floor(e/20);return t+"."+a+"."+e%20},forYear:function(e){if(e=e.split("."),e.length<3)throw"Invalid Mayan year";for(var r=0,t=0;t19||t>0&&a<0)throw"Invalid Mayan year";r=r*20+a}return r},monthsInYear:function(e){return this._validate(e,this.minMonth,this.minDay,Jn.local.invalidYear),18},weekOfYear:function(e,r,t){return this._validate(e,r,t,Jn.local.invalidDate),0},daysInYear:function(e){return this._validate(e,this.minMonth,this.minDay,Jn.local.invalidYear),360},daysInMonth:function(e,r){return this._validate(e,r,this.minDay,Jn.local.invalidMonth),20},daysInWeek:function(){return 5},dayOfWeek:function(e,r,t){var a=this._validate(e,r,t,Jn.local.invalidDate);return a.day()},weekDay:function(e,r,t){return this._validate(e,r,t,Jn.local.invalidDate),!0},extraInfo:function(e,r,t){var a=this._validate(e,r,t,Jn.local.invalidDate),n=a.toJD(),i=this._toHaab(n),l=this._toTzolkin(n);return{haabMonthName:this.local.haabMonths[i[0]-1],haabMonth:i[0],haabDay:i[1],tzolkinDayName:this.local.tzolkinMonths[l[0]-1],tzolkinDay:l[0],tzolkinTrecena:l[1]}},_toHaab:function(e){e-=this.jdEpoch;var r=T5(e+8+340,365);return[Math.floor(r/20)+1,T5(r,20)]},_toTzolkin:function(e){return e-=this.jdEpoch,[EN(e+20,20),EN(e+4,13)]},toJD:function(e,r,t){var a=this._validate(e,r,t,Jn.local.invalidDate);return a.day()+a.month()*20+a.year()*360+this.jdEpoch},fromJD:function(e){e=Math.floor(e)+.5-this.jdEpoch;var r=Math.floor(e/360);e=e%360,e+=e<0?360:0;var t=Math.floor(e/20),a=e%20;return this.newDate(r,t,a)}});function T5(e,r){return e-r*Math.floor(e/r)}function EN(e,r){return T5(e-1,r)+1}Jn.calendars.mayan=M5});var zN=X(()=>{var ms=Ra(),Wle=Pa();function A5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}A5.prototype=new ms.baseCalendar;var RN=ms.instance("gregorian");Wle(A5.prototype,{name:"Nanakshahi",jdEpoch:22576735e-1,daysPerMonth:[31,31,31,31,31,30,30,30,30,30,30,30],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Nanakshahi",epochs:["BN","AN"],monthNames:["Chet","Vaisakh","Jeth","Harh","Sawan","Bhadon","Assu","Katak","Maghar","Poh","Magh","Phagun"],monthNamesShort:["Che","Vai","Jet","Har","Saw","Bha","Ass","Kat","Mgr","Poh","Mgh","Pha"],dayNames:["Somvaar","Mangalvar","Budhvaar","Veervaar","Shukarvaar","Sanicharvaar","Etvaar"],dayNamesShort:["Som","Mangal","Budh","Veer","Shukar","Sanichar","Et"],dayNamesMin:["So","Ma","Bu","Ve","Sh","Sa","Et"],digits:null,dateFormat:"dd-mm-yyyy",firstDay:0,isRTL:!1}},leapYear:function(e){var r=this._validate(e,this.minMonth,this.minDay,ms.local.invalidYear||ms.regionalOptions[""].invalidYear);return RN.leapYear(r.year()+(r.year()<1?1:0)+1469)},weekOfYear:function(e,r,t){var a=this.newDate(e,r,t);return a.add(1-(a.dayOfWeek()||7),"d"),Math.floor((a.dayOfYear()-1)/7)+1},daysInMonth:function(e,r){var t=this._validate(e,r,this.minDay,ms.local.invalidMonth);return this.daysPerMonth[t.month()-1]+(t.month()===12&&this.leapYear(t.year())?1:0)},weekDay:function(e,r,t){return(this.dayOfWeek(e,r,t)||7)<6},toJD:function(n,r,t){var a=this._validate(n,r,t,ms.local.invalidMonth),n=a.year();n<0&&n++;for(var i=a.day(),l=1;l=this.toJD(r+1,1,1);)r++;for(var t=e-Math.floor(this.toJD(r,1,1)+.5)+1,a=1;t>this.daysInMonth(r,a);)t-=this.daysInMonth(r,a),a++;return this.newDate(r,a,t)}});ms.calendars.nanakshahi=A5});var NN=X(()=>{var ys=Ra(),Xle=Pa();function k5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}k5.prototype=new ys.baseCalendar;Xle(k5.prototype,{name:"Nepali",jdEpoch:17007095e-1,daysPerMonth:[31,31,32,32,31,30,30,29,30,29,30,30],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,daysPerYear:365,regionalOptions:{"":{name:"Nepali",epochs:["BBS","ABS"],monthNames:["Baisakh","Jestha","Ashadh","Shrawan","Bhadra","Ashwin","Kartik","Mangsir","Paush","Mangh","Falgun","Chaitra"],monthNamesShort:["Bai","Je","As","Shra","Bha","Ash","Kar","Mang","Pau","Ma","Fal","Chai"],dayNames:["Aaitabaar","Sombaar","Manglbaar","Budhabaar","Bihibaar","Shukrabaar","Shanibaar"],dayNamesShort:["Aaita","Som","Mangl","Budha","Bihi","Shukra","Shani"],dayNamesMin:["Aai","So","Man","Bu","Bi","Shu","Sha"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:1,isRTL:!1}},leapYear:function(e){return this.daysInYear(e)!==this.daysPerYear},weekOfYear:function(e,r,t){var a=this.newDate(e,r,t);return a.add(-a.dayOfWeek(),"d"),Math.floor((a.dayOfYear()-1)/7)+1},daysInYear:function(e){var r=this._validate(e,this.minMonth,this.minDay,ys.local.invalidYear);if(e=r.year(),typeof this.NEPALI_CALENDAR_DATA[e]=="undefined")return this.daysPerYear;for(var t=0,a=this.minMonth;a<=12;a++)t+=this.NEPALI_CALENDAR_DATA[e][a];return t},daysInMonth:function(e,r){return e.year&&(r=e.month(),e=e.year()),this._validate(e,r,this.minDay,ys.local.invalidMonth),typeof this.NEPALI_CALENDAR_DATA[e]=="undefined"?this.daysPerMonth[r-1]:this.NEPALI_CALENDAR_DATA[e][r]},weekDay:function(e,r,t){return this.dayOfWeek(e,r,t)!==6},toJD:function(e,r,t){var a=this._validate(e,r,t,ys.local.invalidDate);e=a.year(),r=a.month(),t=a.day();var n=ys.instance(),i=0,l=r,o=e;this._createMissingCalendarData(e);var s=e-(l>9||l===9&&t>=this.NEPALI_CALENDAR_DATA[o][0]?56:57);for(r!==9&&(i=t,l--);l!==9;)l<=0&&(l=12,o--),i+=this.NEPALI_CALENDAR_DATA[o][l],l--;return r===9?(i+=t-this.NEPALI_CALENDAR_DATA[o][0],i<0&&(i+=n.daysInYear(s))):i+=this.NEPALI_CALENDAR_DATA[o][9]-this.NEPALI_CALENDAR_DATA[o][0],n.newDate(s,1,1).add(i,"d").toJD()},fromJD:function(e){var r=ys.instance(),t=r.fromJD(e),a=t.year(),n=t.dayOfYear(),i=a+56;this._createMissingCalendarData(i);for(var l=9,o=this.NEPALI_CALENDAR_DATA[i][0],s=this.NEPALI_CALENDAR_DATA[i][l]-o+1;n>s;)l++,l>12&&(l=1,i++),s+=this.NEPALI_CALENDAR_DATA[i][l];var u=this.NEPALI_CALENDAR_DATA[i][l]-(s-n);return this.newDate(i,l,u)},_createMissingCalendarData:function(e){var r=this.daysPerMonth.slice(0);r.unshift(17);for(var t=e-1;t{var Qu=Ra(),Zle=Pa();function Yd(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}function Bd(e){var r=e-475;e<0&&r++;var t=.242197,a=t*r,n=t*(r+1),i=a-Math.floor(a),l=n-Math.floor(n);return i>l}Yd.prototype=new Qu.baseCalendar;Zle(Yd.prototype,{name:"Persian",jdEpoch:19483205e-1,daysPerMonth:[31,31,31,31,31,31,30,30,30,30,30,29],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Persian",epochs:["BP","AP"],monthNames:["Farvardin","Ordibehesht","Khordad","Tir","Mordad","Shahrivar","Mehr","Aban","Azar","Dey","Bahman","Esfand"],monthNamesShort:["Far","Ord","Kho","Tir","Mor","Sha","Meh","Aba","Aza","Dey","Bah","Esf"],dayNames:["Yekshanbeh","Doshanbeh","Seshanbeh","Chah\u0101rshanbeh","Panjshanbeh","Jom'eh","Shanbeh"],dayNamesShort:["Yek","Do","Se","Cha","Panj","Jom","Sha"],dayNamesMin:["Ye","Do","Se","Ch","Pa","Jo","Sh"],digits:null,dateFormat:"yyyy/mm/dd",firstDay:6,isRTL:!1}},leapYear:function(e){var r=this._validate(e,this.minMonth,this.minDay,Qu.local.invalidYear);return Bd(r.year())},weekOfYear:function(e,r,t){var a=this.newDate(e,r,t);return a.add(-((a.dayOfWeek()+1)%7),"d"),Math.floor((a.dayOfYear()-1)/7)+1},daysInMonth:function(e,r){var t=this._validate(e,r,this.minDay,Qu.local.invalidMonth);return this.daysPerMonth[t.month()-1]+(t.month()===12&&this.leapYear(t.year())?1:0)},weekDay:function(e,r,t){return this.dayOfWeek(e,r,t)!==5},toJD:function(e,r,t){var a=this._validate(e,r,t,Qu.local.invalidDate);e=a.year(),r=a.month(),t=a.day();var n=0;if(e>0)for(var i=1;i0?e-1:e)*365+n+this.jdEpoch-1},fromJD:function(e){e=Math.floor(e)+.5;var r=475+(e-this.toJD(475,1,1))/365.242197,t=Math.floor(r);t<=0&&t--,e>this.toJD(t,12,Bd(t)?30:29)&&(t++,t===0&&t++);var a=e-this.toJD(t,1,1)+1,n=a<=186?Math.ceil(a/31):Math.ceil((a-6)/30),i=e-this.toJD(t,n,1)+1;return this.newDate(t,n,i)}});Qu.calendars.persian=Yd;Qu.calendars.jalali=Yd});var IN=X(()=>{var gs=Ra(),Jle=Pa(),Ud=gs.instance();function C5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}C5.prototype=new gs.baseCalendar;Jle(C5.prototype,{name:"Taiwan",jdEpoch:24194025e-1,yearsOffset:1911,daysPerMonth:[31,28,31,30,31,30,31,31,30,31,30,31],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Taiwan",epochs:["BROC","ROC"],monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],digits:null,dateFormat:"yyyy/mm/dd",firstDay:1,isRTL:!1}},leapYear:function(t){var r=this._validate(t,this.minMonth,this.minDay,gs.local.invalidYear),t=this._t2gYear(r.year());return Ud.leapYear(t)},weekOfYear:function(n,r,t){var a=this._validate(n,this.minMonth,this.minDay,gs.local.invalidYear),n=this._t2gYear(a.year());return Ud.weekOfYear(n,a.month(),a.day())},daysInMonth:function(e,r){var t=this._validate(e,r,this.minDay,gs.local.invalidMonth);return this.daysPerMonth[t.month()-1]+(t.month()===2&&this.leapYear(t.year())?1:0)},weekDay:function(e,r,t){return(this.dayOfWeek(e,r,t)||7)<6},toJD:function(n,r,t){var a=this._validate(n,r,t,gs.local.invalidDate),n=this._t2gYear(a.year());return Ud.toJD(n,a.month(),a.day())},fromJD:function(e){var r=Ud.fromJD(e),t=this._g2tYear(r.year());return this.newDate(t,r.month(),r.day())},_t2gYear:function(e){return e+this.yearsOffset+(e>=-this.yearsOffset&&e<=-1?1:0)},_g2tYear:function(e){return e-this.yearsOffset-(e>=1&&e<=this.yearsOffset?1:0)}});gs.calendars.taiwan=C5});var HN=X(()=>{var bs=Ra(),Kle=Pa(),Vd=bs.instance();function S5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}S5.prototype=new bs.baseCalendar;Kle(S5.prototype,{name:"Thai",jdEpoch:15230985e-1,yearsOffset:543,daysPerMonth:[31,28,31,30,31,30,31,31,30,31,30,31],hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Thai",epochs:["BBE","BE"],monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"],digits:null,dateFormat:"dd/mm/yyyy",firstDay:0,isRTL:!1}},leapYear:function(t){var r=this._validate(t,this.minMonth,this.minDay,bs.local.invalidYear),t=this._t2gYear(r.year());return Vd.leapYear(t)},weekOfYear:function(n,r,t){var a=this._validate(n,this.minMonth,this.minDay,bs.local.invalidYear),n=this._t2gYear(a.year());return Vd.weekOfYear(n,a.month(),a.day())},daysInMonth:function(e,r){var t=this._validate(e,r,this.minDay,bs.local.invalidMonth);return this.daysPerMonth[t.month()-1]+(t.month()===2&&this.leapYear(t.year())?1:0)},weekDay:function(e,r,t){return(this.dayOfWeek(e,r,t)||7)<6},toJD:function(n,r,t){var a=this._validate(n,r,t,bs.local.invalidDate),n=this._t2gYear(a.year());return Vd.toJD(n,a.month(),a.day())},fromJD:function(e){var r=Vd.fromJD(e),t=this._g2tYear(r.year());return this.newDate(t,r.month(),r.day())},_t2gYear:function(e){return e-this.yearsOffset-(e>=1&&e<=this.yearsOffset?1:0)},_g2tYear:function(e){return e+this.yearsOffset+(e>=-this.yearsOffset&&e<=-1?1:0)}});bs.calendars.thai=S5});var ON=X(()=>{var _s=Ra(),$le=Pa();function L5(e){this.local=this.regionalOptions[e||""]||this.regionalOptions[""]}L5.prototype=new _s.baseCalendar;$le(L5.prototype,{name:"UmmAlQura",hasYearZero:!1,minMonth:1,firstMonth:1,minDay:1,regionalOptions:{"":{name:"Umm al-Qura",epochs:["BH","AH"],monthNames:["Al-Muharram","Safar","Rabi' al-awwal","Rabi' Al-Thani","Jumada Al-Awwal","Jumada Al-Thani","Rajab","Sha'aban","Ramadan","Shawwal","Dhu al-Qi'dah","Dhu al-Hijjah"],monthNamesShort:["Muh","Saf","Rab1","Rab2","Jum1","Jum2","Raj","Sha'","Ram","Shaw","DhuQ","DhuH"],dayNames:["Yawm al-Ahad","Yawm al-Ithnain","Yawm al-Thal\u0101th\u0101\u2019","Yawm al-Arba\u2018\u0101\u2019","Yawm al-Kham\u012Bs","Yawm al-Jum\u2018a","Yawm al-Sabt"],dayNamesMin:["Ah","Ith","Th","Ar","Kh","Ju","Sa"],digits:null,dateFormat:"yyyy/mm/dd",firstDay:6,isRTL:!0}},leapYear:function(e){var r=this._validate(e,this.minMonth,this.minDay,_s.local.invalidYear);return this.daysInYear(r.year())===355},weekOfYear:function(e,r,t){var a=this.newDate(e,r,t);return a.add(-a.dayOfWeek(),"d"),Math.floor((a.dayOfYear()-1)/7)+1},daysInYear:function(e){for(var r=0,t=1;t<=12;t++)r+=this.daysInMonth(e,t);return r},daysInMonth:function(e,r){for(var t=this._validate(e,r,this.minDay,_s.local.invalidMonth),a=t.toJD()-24e5+.5,n=0,i=0;ia)return oo[n]-oo[n-1];n++}return 30},weekDay:function(e,r,t){return this.dayOfWeek(e,r,t)!==5},toJD:function(e,r,t){var a=this._validate(e,r,t,_s.local.invalidDate),n=12*(a.year()-1)+a.month()-15292,i=a.day()+oo[n-1]-1;return i+24e5-.5},fromJD:function(e){for(var r=e-24e5+.5,t=0,a=0;ar);a++)t++;var n=t+15292,i=Math.floor((n-1)/12),l=i+1,o=n-12*i,s=r-oo[t-1]+1;return this.newDate(l,o,s)},isValid:function(e,r,t){var a=_s.baseCalendar.prototype.isValid.apply(this,arguments);return a&&(e=e.year!=null?e.year:e,a=e>=1276&&e<=1500),a},_validate:function(e,r,t,a){var n=_s.baseCalendar.prototype._validate.apply(this,arguments);if(n.year<1276||n.year>1500)throw a.replace(/\{0\}/,this.local.name);return n}});_s.calendars.ummalqura=L5;var oo=[20,50,79,109,138,168,197,227,256,286,315,345,374,404,433,463,492,522,551,581,611,641,670,700,729,759,788,818,847,877,906,936,965,995,1024,1054,1083,1113,1142,1172,1201,1231,1260,1290,1320,1350,1379,1409,1438,1468,1497,1527,1556,1586,1615,1645,1674,1704,1733,1763,1792,1822,1851,1881,1910,1940,1969,1999,2028,2058,2087,2117,2146,2176,2205,2235,2264,2294,2323,2353,2383,2413,2442,2472,2501,2531,2560,2590,2619,2649,2678,2708,2737,2767,2796,2826,2855,2885,2914,2944,2973,3003,3032,3062,3091,3121,3150,3180,3209,3239,3268,3298,3327,3357,3386,3416,3446,3476,3505,3535,3564,3594,3623,3653,3682,3712,3741,3771,3800,3830,3859,3889,3918,3948,3977,4007,4036,4066,4095,4125,4155,4185,4214,4244,4273,4303,4332,4362,4391,4421,4450,4480,4509,4539,4568,4598,4627,4657,4686,4716,4745,4775,4804,4834,4863,4893,4922,4952,4981,5011,5040,5070,5099,5129,5158,5188,5218,5248,5277,5307,5336,5366,5395,5425,5454,5484,5513,5543,5572,5602,5631,5661,5690,5720,5749,5779,5808,5838,5867,5897,5926,5956,5985,6015,6044,6074,6103,6133,6162,6192,6221,6251,6281,6311,6340,6370,6399,6429,6458,6488,6517,6547,6576,6606,6635,6665,6694,6724,6753,6783,6812,6842,6871,6901,6930,6960,6989,7019,7048,7078,7107,7137,7166,7196,7225,7255,7284,7314,7344,7374,7403,7433,7462,7492,7521,7551,7580,7610,7639,7669,7698,7728,7757,7787,7816,7846,7875,7905,7934,7964,7993,8023,8053,8083,8112,8142,8171,8201,8230,8260,8289,8319,8348,8378,8407,8437,8466,8496,8525,8555,8584,8614,8643,8673,8702,8732,8761,8791,8821,8850,8880,8909,8938,8968,8997,9027,9056,9086,9115,9145,9175,9205,9234,9264,9293,9322,9352,9381,9410,9440,9470,9499,9529,9559,9589,9618,9648,9677,9706,9736,9765,9794,9824,9853,9883,9913,9943,9972,10002,10032,10061,10090,10120,10149,10178,10208,10237,10267,10297,10326,10356,10386,10415,10445,10474,10504,10533,10562,10592,10621,10651,10680,10710,10740,10770,10799,10829,10858,10888,10917,10947,10976,11005,11035,11064,11094,11124,11153,11183,11213,11242,11272,11301,11331,11360,11389,11419,11448,11478,11507,11537,11567,11596,11626,11655,11685,11715,11744,11774,11803,11832,11862,11891,11921,11950,11980,12010,12039,12069,12099,12128,12158,12187,12216,12246,12275,12304,12334,12364,12393,12423,12453,12483,12512,12542,12571,12600,12630,12659,12688,12718,12747,12777,12807,12837,12866,12896,12926,12955,12984,13014,13043,13072,13102,13131,13161,13191,13220,13250,13280,13310,13339,13368,13398,13427,13456,13486,13515,13545,13574,13604,13634,13664,13693,13723,13752,13782,13811,13840,13870,13899,13929,13958,13988,14018,14047,14077,14107,14136,14166,14195,14224,14254,14283,14313,14342,14372,14401,14431,14461,14490,14520,14550,14579,14609,14638,14667,14697,14726,14756,14785,14815,14844,14874,14904,14933,14963,14993,15021,15051,15081,15110,15140,15169,15199,15228,15258,15287,15317,15347,15377,15406,15436,15465,15494,15524,15553,15582,15612,15641,15671,15701,15731,15760,15790,15820,15849,15878,15908,15937,15966,15996,16025,16055,16085,16114,16144,16174,16204,16233,16262,16292,16321,16350,16380,16409,16439,16468,16498,16528,16558,16587,16617,16646,16676,16705,16734,16764,16793,16823,16852,16882,16912,16941,16971,17001,17030,17060,17089,17118,17148,17177,17207,17236,17266,17295,17325,17355,17384,17414,17444,17473,17502,17532,17561,17591,17620,17650,17679,17709,17738,17768,17798,17827,17857,17886,17916,17945,17975,18004,18034,18063,18093,18122,18152,18181,18211,18241,18270,18300,18330,18359,18388,18418,18447,18476,18506,18535,18565,18595,18625,18654,18684,18714,18743,18772,18802,18831,18860,18890,18919,18949,18979,19008,19038,19068,19098,19127,19156,19186,19215,19244,19274,19303,19333,19362,19392,19422,19452,19481,19511,19540,19570,19599,19628,19658,19687,19717,19746,19776,19806,19836,19865,19895,19924,19954,19983,20012,20042,20071,20101,20130,20160,20190,20219,20249,20279,20308,20338,20367,20396,20426,20455,20485,20514,20544,20573,20603,20633,20662,20692,20721,20751,20780,20810,20839,20869,20898,20928,20957,20987,21016,21046,21076,21105,21135,21164,21194,21223,21253,21282,21312,21341,21371,21400,21430,21459,21489,21519,21548,21578,21607,21637,21666,21696,21725,21754,21784,21813,21843,21873,21902,21932,21962,21991,22021,22050,22080,22109,22138,22168,22197,22227,22256,22286,22316,22346,22375,22405,22434,22464,22493,22522,22552,22581,22611,22640,22670,22700,22730,22759,22789,22818,22848,22877,22906,22936,22965,22994,23024,23054,23083,23113,23143,23173,23202,23232,23261,23290,23320,23349,23379,23408,23438,23467,23497,23527,23556,23586,23616,23645,23674,23704,23733,23763,23792,23822,23851,23881,23910,23940,23970,23999,24029,24058,24088,24117,24147,24176,24206,24235,24265,24294,24324,24353,24383,24413,24442,24472,24501,24531,24560,24590,24619,24648,24678,24707,24737,24767,24796,24826,24856,24885,24915,24944,24974,25003,25032,25062,25091,25121,25150,25180,25210,25240,25269,25299,25328,25358,25387,25416,25446,25475,25505,25534,25564,25594,25624,25653,25683,25712,25742,25771,25800,25830,25859,25888,25918,25948,25977,26007,26037,26067,26096,26126,26155,26184,26214,26243,26272,26302,26332,26361,26391,26421,26451,26480,26510,26539,26568,26598,26627,26656,26686,26715,26745,26775,26805,26834,26864,26893,26923,26952,26982,27011,27041,27070,27099,27129,27159,27188,27218,27248,27277,27307,27336,27366,27395,27425,27454,27484,27513,27542,27572,27602,27631,27661,27691,27720,27750,27779,27809,27838,27868,27897,27926,27956,27985,28015,28045,28074,28104,28134,28163,28193,28222,28252,28281,28310,28340,28369,28399,28428,28458,28488,28517,28547,28577,28607,28636,28665,28695,28724,28754,28783,28813,28843,28872,28901,28931,28960,28990,29019,29049,29078,29108,29137,29167,29196,29226,29255,29285,29315,29345,29375,29404,29434,29463,29492,29522,29551,29580,29610,29640,29669,29699,29729,29759,29788,29818,29847,29876,29906,29935,29964,29994,30023,30053,30082,30112,30141,30171,30200,30230,30259,30289,30318,30348,30378,30408,30437,30467,30496,30526,30555,30585,30614,30644,30673,30703,30732,30762,30791,30821,30850,30880,30909,30939,30968,30998,31027,31057,31086,31116,31145,31175,31204,31234,31263,31293,31322,31352,31381,31411,31441,31471,31500,31530,31559,31589,31618,31648,31676,31706,31736,31766,31795,31825,31854,31884,31913,31943,31972,32002,32031,32061,32090,32120,32150,32180,32209,32239,32268,32298,32327,32357,32386,32416,32445,32475,32504,32534,32563,32593,32622,32652,32681,32711,32740,32770,32799,32829,32858,32888,32917,32947,32976,33006,33035,33065,33094,33124,33153,33183,33213,33243,33272,33302,33331,33361,33390,33420,33450,33479,33509,33539,33568,33598,33627,33657,33686,33716,33745,33775,33804,33834,33863,33893,33922,33952,33981,34011,34040,34069,34099,34128,34158,34187,34217,34247,34277,34306,34336,34365,34395,34424,34454,34483,34512,34542,34571,34601,34631,34660,34690,34719,34749,34778,34808,34837,34867,34896,34926,34955,34985,35015,35044,35074,35103,35133,35162,35192,35222,35251,35280,35310,35340,35370,35399,35429,35458,35488,35517,35547,35576,35605,35635,35665,35694,35723,35753,35782,35811,35841,35871,35901,35930,35960,35989,36019,36048,36078,36107,36136,36166,36195,36225,36254,36284,36314,36343,36373,36403,36433,36462,36492,36521,36551,36580,36610,36639,36669,36698,36728,36757,36786,36816,36845,36875,36904,36934,36963,36993,37022,37052,37081,37111,37141,37170,37200,37229,37259,37288,37318,37347,37377,37406,37436,37465,37495,37524,37554,37584,37613,37643,37672,37701,37731,37760,37790,37819,37849,37878,37908,37938,37967,37997,38027,38056,38085,38115,38144,38174,38203,38233,38262,38292,38322,38351,38381,38410,38440,38469,38499,38528,38558,38587,38617,38646,38676,38705,38735,38764,38794,38823,38853,38882,38912,38941,38971,39001,39030,39059,39089,39118,39148,39178,39208,39237,39267,39297,39326,39355,39385,39414,39444,39473,39503,39532,39562,39592,39621,39650,39680,39709,39739,39768,39798,39827,39857,39886,39916,39946,39975,40005,40035,40064,40094,40123,40153,40182,40212,40241,40271,40300,40330,40359,40389,40418,40448,40477,40507,40536,40566,40595,40625,40655,40685,40714,40744,40773,40803,40832,40862,40892,40921,40951,40980,41009,41039,41068,41098,41127,41157,41186,41216,41245,41275,41304,41334,41364,41393,41422,41452,41481,41511,41540,41570,41599,41629,41658,41688,41718,41748,41777,41807,41836,41865,41894,41924,41953,41983,42012,42042,42072,42102,42131,42161,42190,42220,42249,42279,42308,42337,42367,42397,42426,42456,42485,42515,42545,42574,42604,42633,42662,42692,42721,42751,42780,42810,42839,42869,42899,42929,42958,42988,43017,43046,43076,43105,43135,43164,43194,43223,43253,43283,43312,43342,43371,43401,43430,43460,43489,43519,43548,43578,43607,43637,43666,43696,43726,43755,43785,43814,43844,43873,43903,43932,43962,43991,44021,44050,44080,44109,44139,44169,44198,44228,44258,44287,44317,44346,44375,44405,44434,44464,44493,44523,44553,44582,44612,44641,44671,44700,44730,44759,44788,44818,44847,44877,44906,44936,44966,44996,45025,45055,45084,45114,45143,45172,45202,45231,45261,45290,45320,45350,45380,45409,45439,45468,45498,45527,45556,45586,45615,45644,45674,45704,45733,45763,45793,45823,45852,45882,45911,45940,45970,45999,46028,46058,46088,46117,46147,46177,46206,46236,46265,46295,46324,46354,46383,46413,46442,46472,46501,46531,46560,46590,46620,46649,46679,46708,46738,46767,46797,46826,46856,46885,46915,46944,46974,47003,47033,47063,47092,47122,47151,47181,47210,47240,47269,47298,47328,47357,47387,47417,47446,47476,47506,47535,47565,47594,47624,47653,47682,47712,47741,47771,47800,47830,47860,47890,47919,47949,47978,48008,48037,48066,48096,48125,48155,48184,48214,48244,48273,48303,48333,48362,48392,48421,48450,48480,48509,48538,48568,48598,48627,48657,48687,48717,48746,48776,48805,48834,48864,48893,48922,48952,48982,49011,49041,49071,49100,49130,49160,49189,49218,49248,49277,49306,49336,49365,49395,49425,49455,49484,49514,49543,49573,49602,49632,49661,49690,49720,49749,49779,49809,49838,49868,49898,49927,49957,49986,50016,50045,50075,50104,50133,50163,50192,50222,50252,50281,50311,50340,50370,50400,50429,50459,50488,50518,50547,50576,50606,50635,50665,50694,50724,50754,50784,50813,50843,50872,50902,50931,50960,50990,51019,51049,51078,51108,51138,51167,51197,51227,51256,51286,51315,51345,51374,51403,51433,51462,51492,51522,51552,51582,51611,51641,51670,51699,51729,51758,51787,51816,51846,51876,51906,51936,51965,51995,52025,52054,52083,52113,52142,52171,52200,52230,52260,52290,52319,52349,52379,52408,52438,52467,52497,52526,52555,52585,52614,52644,52673,52703,52733,52762,52792,52822,52851,52881,52910,52939,52969,52998,53028,53057,53087,53116,53146,53176,53205,53235,53264,53294,53324,53353,53383,53412,53441,53471,53500,53530,53559,53589,53619,53648,53678,53708,53737,53767,53796,53825,53855,53884,53913,53943,53973,54003,54032,54062,54092,54121,54151,54180,54209,54239,54268,54297,54327,54357,54387,54416,54446,54476,54505,54535,54564,54593,54623,54652,54681,54711,54741,54770,54800,54830,54859,54889,54919,54948,54977,55007,55036,55066,55095,55125,55154,55184,55213,55243,55273,55302,55332,55361,55391,55420,55450,55479,55508,55538,55567,55597,55627,55657,55686,55716,55745,55775,55804,55834,55863,55892,55922,55951,55981,56011,56040,56070,56100,56129,56159,56188,56218,56247,56276,56306,56335,56365,56394,56424,56454,56483,56513,56543,56572,56601,56631,56660,56690,56719,56749,56778,56808,56837,56867,56897,56926,56956,56985,57015,57044,57074,57103,57133,57162,57192,57221,57251,57280,57310,57340,57369,57399,57429,57458,57487,57517,57546,57576,57605,57634,57664,57694,57723,57753,57783,57813,57842,57871,57901,57930,57959,57989,58018,58048,58077,58107,58137,58167,58196,58226,58255,58285,58314,58343,58373,58402,58432,58461,58491,58521,58551,58580,58610,58639,58669,58698,58727,58757,58786,58816,58845,58875,58905,58934,58964,58994,59023,59053,59082,59111,59141,59170,59200,59229,59259,59288,59318,59348,59377,59407,59436,59466,59495,59525,59554,59584,59613,59643,59672,59702,59731,59761,59791,59820,59850,59879,59909,59939,59968,59997,60027,60056,60086,60115,60145,60174,60204,60234,60264,60293,60323,60352,60381,60411,60440,60469,60499,60528,60558,60588,60618,60648,60677,60707,60736,60765,60795,60824,60853,60883,60912,60942,60972,61002,61031,61061,61090,61120,61149,61179,61208,61237,61267,61296,61326,61356,61385,61415,61445,61474,61504,61533,61563,61592,61621,61651,61680,61710,61739,61769,61799,61828,61858,61888,61917,61947,61976,62006,62035,62064,62094,62123,62153,62182,62212,62242,62271,62301,62331,62360,62390,62419,62448,62478,62507,62537,62566,62596,62625,62655,62685,62715,62744,62774,62803,62832,62862,62891,62921,62950,62980,63009,63039,63069,63099,63128,63157,63187,63216,63246,63275,63305,63334,63363,63393,63423,63453,63482,63512,63541,63571,63600,63630,63659,63689,63718,63747,63777,63807,63836,63866,63895,63925,63955,63984,64014,64043,64073,64102,64131,64161,64190,64220,64249,64279,64309,64339,64368,64398,64427,64457,64486,64515,64545,64574,64603,64633,64663,64692,64722,64752,64782,64811,64841,64870,64899,64929,64958,64987,65017,65047,65076,65106,65136,65166,65195,65225,65254,65283,65313,65342,65371,65401,65431,65460,65490,65520,65549,65579,65608,65638,65667,65697,65726,65755,65785,65815,65844,65874,65903,65933,65963,65992,66022,66051,66081,66110,66140,66169,66199,66228,66258,66287,66317,66346,66376,66405,66435,66465,66494,66524,66553,66583,66612,66641,66671,66700,66730,66760,66789,66819,66849,66878,66908,66937,66967,66996,67025,67055,67084,67114,67143,67173,67203,67233,67262,67292,67321,67351,67380,67409,67439,67468,67497,67527,67557,67587,67617,67646,67676,67705,67735,67764,67793,67823,67852,67882,67911,67941,67971,68e3,68030,68060,68089,68119,68148,68177,68207,68236,68266,68295,68325,68354,68384,68414,68443,68473,68502,68532,68561,68591,68620,68650,68679,68708,68738,68768,68797,68827,68857,68886,68916,68946,68975,69004,69034,69063,69092,69122,69152,69181,69211,69240,69270,69300,69330,69359,69388,69418,69447,69476,69506,69535,69565,69595,69624,69654,69684,69713,69743,69772,69802,69831,69861,69890,69919,69949,69978,70008,70038,70067,70097,70126,70156,70186,70215,70245,70274,70303,70333,70362,70392,70421,70451,70481,70510,70540,70570,70599,70629,70658,70687,70717,70746,70776,70805,70835,70864,70894,70924,70954,70983,71013,71042,71071,71101,71130,71159,71189,71218,71248,71278,71308,71337,71367,71397,71426,71455,71485,71514,71543,71573,71602,71632,71662,71691,71721,71751,71781,71810,71839,71869,71898,71927,71957,71986,72016,72046,72075,72105,72135,72164,72194,72223,72253,72282,72311,72341,72370,72400,72429,72459,72489,72518,72548,72577,72607,72637,72666,72695,72725,72754,72784,72813,72843,72872,72902,72931,72961,72991,73020,73050,73080,73109,73139,73168,73197,73227,73256,73286,73315,73345,73375,73404,73434,73464,73493,73523,73552,73581,73611,73640,73669,73699,73729,73758,73788,73818,73848,73877,73907,73936,73965,73995,74024,74053,74083,74113,74142,74172,74202,74231,74261,74291,74320,74349,74379,74408,74437,74467,74497,74526,74556,74586,74615,74645,74675,74704,74733,74763,74792,74822,74851,74881,74910,74940,74969,74999,75029,75058,75088,75117,75147,75176,75206,75235,75264,75294,75323,75353,75383,75412,75442,75472,75501,75531,75560,75590,75619,75648,75678,75707,75737,75766,75796,75826,75856,75885,75915,75944,75974,76003,76032,76062,76091,76121,76150,76180,76210,76239,76269,76299,76328,76358,76387,76416,76446,76475,76505,76534,76564,76593,76623,76653,76682,76712,76741,76771,76801,76830,76859,76889,76918,76948,76977,77007,77036,77066,77096,77125,77155,77185,77214,77243,77273,77302,77332,77361,77390,77420,77450,77479,77509,77539,77569,77598,77627,77657,77686,77715,77745,77774,77804,77833,77863,77893,77923,77952,77982,78011,78041,78070,78099,78129,78158,78188,78217,78247,78277,78307,78336,78366,78395,78425,78454,78483,78513,78542,78572,78601,78631,78661,78690,78720,78750,78779,78808,78838,78867,78897,78926,78956,78985,79015,79044,79074,79104,79133,79163,79192,79222,79251,79281,79310,79340,79369,79399,79428,79458,79487,79517,79546,79576,79606,79635,79665,79695,79724,79753,79783,79812,79841,79871,79900,79930,79960,79990]});var YN=X((ohe,BN)=>{"use strict";BN.exports=Ra();MN();AN();kN();CN();SN();LN();qN();DN();PN();zN();NN();FN();IN();HN();ON()});var JN=X((she,ZN)=>{"use strict";var VN=YN(),u0=Ne(),GN=Zt(),Qle=GN.EPOCHJD,jle=GN.ONEDAY,E5={valType:"enumerated",values:u0.sortObjectKeys(VN.calendars),editType:"calc",dflt:"gregorian"},WN=function(e,r,t,a){var n={};return n[t]=E5,u0.coerce(e,r,n,t,a)},eoe=function(e,r,t,a){for(var n=0;n{"use strict";KN.exports=JN()});var loe=X((fhe,jN)=>{var QN=xR();QN.register([Sz(),bN(),$N()]);jN.exports=QN});return loe();})(); +/*! Bundled license information: + +native-promise-only/lib/npo.src.js: + (*! Native Promise Only + v0.8.1 (c) Kyle Simpson + MIT License: http://getify.mit-license.org + *) + +polybooljs/index.js: + (* + * @copyright 2016 Sean Connelly (@voidqk), http://syntheti.cc + * @license MIT + * @preserve Project Home: https://github.com/voidqk/polybooljs + *) + +object-assign/index.js: + (* + object-assign + (c) Sindre Sorhus + @license MIT + *) +*/ + +window.Plotly = Plotly; +return Plotly; +})); \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index 75cca8f5782..ecc37299f33 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -212,6 +212,17 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) continue; } + // This step is reachable from two pipeline executions: it is RequiredBy "before-start" + // (so it runs during AppHost startup) and it is also part of the publish/deploy DAG. + // Adding a second DeploymentTargetAnnotation on the second pass makes + // ResourceExtensions.GetDeploymentTargetAnnotation throw on the ambiguity, so the step has + // to be idempotent. Skipping early also avoids building the container app twice, which + // would append duplicate environment variables to the resource. + if (r.Annotations.OfType().Any(a => a.ComputeEnvironment == this)) + { + continue; + } + var containerApp = await containerAppEnvironmentContext.CreateContainerAppAsync(r, options.Value, cancellationToken).ConfigureAwait(false); // Capture information about the container registry used by the diff --git a/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs index 9ca635af0df..4a3fb2b2dab 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.Java/AtsJavaCodeGenerator.cs @@ -85,6 +85,33 @@ private static void AddSplitJavaSourceFiles(Dictionary files, st } } + /// + /// Maps a generated type to its output path, placing it in the directory its package declares. + /// + /// + /// The generated SDK declares package aspire;, so the sources have to live in an + /// aspire/ directory rather than flat in .aspire/modules. javac itself does not care + /// when every file is named explicitly, which is how the CLI compiles, but anything that resolves + /// types through a source path does: IDEs build from the project model rather than the CLI's + /// argument file, so a flat layout makes the Java language server report + /// "package aspire does not exist" against a working AppHost. Emitting the package directory is + /// what lets an editor add .aspire/modules as a source root and resolve the SDK. + /// + private static string GetGeneratedFilePath(string packageLine, string fileName) + { + // "package aspire;" or "package a.b.c;" -> "aspire" / "a/b/c". Always forward slashes: the + // value is a relative path key that also gets written into the javac argument file, and javac + // accepts forward slashes on every platform. + var packageName = packageLine + .Replace("package ", string.Empty, StringComparison.Ordinal) + .Replace(";", string.Empty, StringComparison.Ordinal) + .Trim(); + + return string.IsNullOrEmpty(packageName) + ? fileName + : $"{packageName.Replace('.', '/')}/{fileName}"; + } + private static Dictionary SplitJavaSourceFiles(string source) { var packageLine = string.Empty; @@ -174,7 +201,7 @@ private static Dictionary SplitJavaSourceFiles(string source) if (braceDepth == 0) { declarations.Add( - $"{currentTypeName}.java", + GetGeneratedFilePath(packageLine, $"{currentTypeName}.java"), CreateJavaSourceFile($"{currentTypeName}.java", packageLine, importLines, currentDeclaration)); currentDeclaration = null; @@ -370,6 +397,129 @@ private static int CountBraceDelta(string line, ref bool inBlockComment) return delta; } + /// + /// Finds the line the top-level type is declared on, skipping the javadoc and comments that + /// precede it. + /// + /// + /// Java has no file-level warning suppression, so @SuppressWarnings has to go on the type + /// itself, and it has to go below any javadoc: an annotation between a doc comment and + /// the type it documents is legal, but a doc comment that is not immediately followed by the + /// declaration stops being attached to it. + /// + private static int FindDeclarationLineIndex(List declarationLines) + { + var inBlockComment = false; + + for (var i = 0; i < declarationLines.Count; i++) + { + var trimmed = declarationLines[i].Trim(); + + if (inBlockComment) + { + if (trimmed.EndsWith("*/", StringComparison.Ordinal)) + { + inBlockComment = false; + } + + continue; + } + + if (trimmed.Length == 0 || trimmed.StartsWith("//", StringComparison.Ordinal)) + { + continue; + } + + if (trimmed.StartsWith("/*", StringComparison.Ordinal)) + { + // A one-line "/** Foo DTO. */" opens and closes on the same line. + if (!trimmed.EndsWith("*/", StringComparison.Ordinal)) + { + inBlockComment = true; + } + + continue; + } + + return i; + } + + return declarationLines.Count; + } + + /// + /// Returns the subset of that + /// actually references. + /// + /// + /// Every declaration is split out of one generated compilation unit, so without filtering it + /// inherits that unit's entire import block. Copying all of it into all 226 files is what made + /// the Java language server report an unused import on nearly every file of a project-style + /// AppHost. Each import the generator emits names a single type, so "is it used" reduces to a + /// whole-word search of the declaration. + /// + private static List FilterImports(List importLines, List declarationLines) + { + var body = string.Join('\n', declarationLines); + var used = new List(); + + foreach (var importLine in importLines) + { + // "import java.util.Map;" -> "java.util.Map", "import static a.B.c;" -> "static a.B.c". + var imported = importLine["import ".Length..].TrimEnd(';').Trim(); + + // A wildcard cannot be matched against a simple name without knowing every type the + // package exports, so it is kept rather than risk dropping one the declaration needs. + if (imported.EndsWith('*')) + { + used.Add(importLine); + continue; + } + + var simpleName = imported[(imported.LastIndexOf('.') + 1)..]; + + if (ContainsWord(body, simpleName)) + { + used.Add(importLine); + } + } + + return used; + } + + /// + /// Whether appears in delimited by characters + /// that cannot be part of a Java identifier. + /// + /// + /// Substring matching would keep java.util.List alive for a file that only mentions + /// AspireList, and dropping a needed import breaks the build, so the boundary check errs + /// toward keeping imports: $ and _ count as identifier characters because Java + /// allows them in names. + /// + private static bool ContainsWord(string text, string word) + { + var index = text.IndexOf(word, StringComparison.Ordinal); + + while (index >= 0) + { + var beforeIsBoundary = index == 0 || !IsJavaIdentifierChar(text[index - 1]); + var afterIndex = index + word.Length; + var afterIsBoundary = afterIndex == text.Length || !IsJavaIdentifierChar(text[afterIndex]); + + if (beforeIsBoundary && afterIsBoundary) + { + return true; + } + + index = text.IndexOf(word, index + 1, StringComparison.Ordinal); + } + + return false; + + static bool IsJavaIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '$'; + } + private static string CreateJavaSourceFile(string fileName, string packageLine, List importLines, List declarationLines) { var builder = new StringBuilder(); @@ -380,19 +530,48 @@ private static string CreateJavaSourceFile(string fileName, string packageLine, builder.AppendLine(packageLine); builder.AppendLine(); - foreach (var importLine in importLines) + var usedImports = FilterImports(importLines, declarationLines); + + foreach (var importLine in usedImports) { builder.AppendLine(importLine); } - if (importLines.Count > 0) + if (usedImports.Count > 0) { builder.AppendLine(); } - foreach (var line in declarationLines) + var declarationIndex = FindDeclarationLineIndex(declarationLines); + + for (var i = 0; i < declarationLines.Count; i++) { - builder.AppendLine(line); + if (i == declarationIndex) + { + // This file is regenerated on every run and is not the user's to edit, so a warning + // reported against it is noise they cannot act on. Emitting clean code is not enough + // on its own: editors compile with ECJ, whose warning set is far broader than javac's + // -Xlint (neither "Unnecessary @SuppressWarnings" nor "The import X is never used" + // exists in javac), and the alternative of marking the source folder generated lives + // in the user's own pom.xml/build.gradle rather than anywhere Aspire controls. + // + // All three tokens are required, because the two compilers disagree on what "all" + // means. ECJ honours "all" as a blanket, and it is the one token ECJ never reports + // back as unnecessary. javac does not apply "all" to its -Xlint categories at all, so + // it needs each category named: "unchecked" for the Map casts in the + // generated callback adapters, and "serial" for CapabilityError, which extends + // RuntimeException without a serialVersionUID. Naming them also silences the + // "Note: Some input files use unchecked or unsafe operations" that javac otherwise + // prints on every `gradle build` and `mvn compile`. The redundancy is safe in the + // other direction: ECJ does not flag the extra tokens, because "all" suppresses its + // unnecessary-suppression diagnostic too. + // + // An annotation on the top-level type also covers the compilation unit's imports. + // protobuf-java, Dagger, and MapStruct all annotate their output the same way. + builder.AppendLine("@SuppressWarnings({\"all\", \"unchecked\", \"serial\"})"); + } + + builder.AppendLine(declarationLines[i]); } return builder.ToString(); @@ -448,8 +627,20 @@ private void WriteHeader() WriteLine(); WriteLine("package aspire;"); WriteLine(); - WriteLine("import java.util.*;"); - WriteLine("import java.util.function.*;"); + // Explicit rather than wildcard imports: CreateJavaSourceFile keeps an import only when the + // declaration it lands on references the simple name, and that decision is only exact when the + // import names one type. A wildcard would have to be matched against every name the package + // exports, so it gets copied into files that never use it. + WriteLine("import java.util.ArrayList;"); + WriteLine("import java.util.HashMap;"); + WriteLine("import java.util.LinkedHashMap;"); + WriteLine("import java.util.List;"); + WriteLine("import java.util.Map;"); + WriteLine("import java.util.Set;"); + WriteLine("import java.util.UUID;"); + WriteLine("import java.util.function.BiFunction;"); + WriteLine("import java.util.function.Consumer;"); + WriteLine("import java.util.function.Function;"); WriteLine(); } @@ -545,9 +736,8 @@ private void GenerateDtoTypes(IReadOnlyList dtoTypes) foreach (var property in dto.Properties) { var fieldName = ToCamelCase(property.Name); - var methodName = ToPascalCase(property.Name); var fieldType = MapDtoFieldTypeToJava(property); - + var methodName = DtoAccessorSuffix(property.Name); WriteLine($" public {fieldType} get{methodName}() {{ return {fieldName}; }}"); WriteLine($" public void set{methodName}({fieldType} value) {{ this.{fieldName} = value; }}"); } @@ -566,7 +756,7 @@ private void GenerateDtoTypes(IReadOnlyList dtoTypes) continue; } var fieldName = ToCamelCase(property.Name); - var methodName = ToPascalCase(property.Name); + var methodName = ToPascalCase(fieldName); var transportValueName = $"{fieldName}Value"; WriteLine($" var {transportValueName} = map.get(\"{property.Name}\");"); WriteLine($" value.set{methodName}({RenderJavaDtoPropertyTransportValueConversion(property.Type, transportValueName, property.IsOptional)});"); @@ -702,7 +892,7 @@ private string RenderJavaDtoValue( } sb.Append("set"); - sb.Append(ToPascalCase(property.Name)); + sb.Append(DtoAccessorSuffix(property.Name)); sb.Append('('); sb.Append(RenderJavaExportedValue(propertyValue, property.Type, dtoTypesById)); sb.Append("); "); @@ -955,7 +1145,12 @@ private void GenerateResourceBuilderOverloads( string returnType, string methodName, IReadOnlyList parameters, - bool hasReturn) + bool hasReturn, + // Signatures already emitted for this method. A caller that invokes this more than once for the + // same Java method - as the union expansion does, once per union member and once per arity - has + // to share one set, because two union members can both map to the same bridge parameter type and + // emitting that bridge twice is a duplicate-method compile error. + HashSet? seenSignatures = null) { if (parameters.Count == 0) { @@ -972,7 +1167,7 @@ private void GenerateResourceBuilderOverloads( return; } - var seenSignatures = new HashSet(StringComparer.Ordinal); + seenSignatures ??= new HashSet(StringComparer.Ordinal); var combinationCount = 1 << convertibleParameters.Count; for (var mask = 1; mask < combinationCount; mask++) @@ -1206,6 +1401,8 @@ private void GenerateSingleUnionOverloads(JavaCapabilityReturnInfo returnInfo, s } var unionParamName = ToCamelCase(unionParameter.Name); + // Shared for the same reason as the with-options path: two union members can map to one bridge. + var bridgeSignatures = new HashSet(StringComparer.Ordinal); foreach (var unionType in unionTypes .Select(type => new { Type = type, JavaType = MapInputTypeToJava(type, unionParameter.IsOptional || unionParameter.IsNullable) }) @@ -1246,7 +1443,8 @@ private void GenerateSingleUnionOverloads(JavaCapabilityReturnInfo returnInfo, s returnInfo.ReturnType, methodName, CreateUnionMethodParameters(parameters, unionParameter, unionType), - returnInfo.HasReturn); + returnInfo.HasReturn, + bridgeSignatures); } } @@ -1392,6 +1590,9 @@ private void GenerateUnionOverloadsWithOptions( } var unionParamName = ToCamelCase(unionParameter.Name); + // One set for the whole method: the loop below emits a bridge overload per union member and + // per arity, and two members can map to the same bridge signature. + var bridgeSignatures = new HashSet(StringComparer.Ordinal); foreach (var unionType in unionTypes .Select(type => new { Type = type, JavaType = MapInputTypeToJava(type, unionParameter.IsOptional || unionParameter.IsNullable) }) @@ -1440,7 +1641,8 @@ private void GenerateUnionOverloadsWithOptions( returnInfo.ReturnType, methodName, bridgeParameters, - returnInfo.HasReturn); + returnInfo.HasReturn, + bridgeSignatures); WriteLine($" public {returnInfo.ReturnType} {methodName}({string.Join(", ", requiredParameters.Select(parameter => ReferenceEquals(parameter, unionParameter) ? $"{MapInputTypeToJava(unionType, unionParameter.IsOptional || unionParameter.IsNullable)} {ToCamelCase(parameter.Name)}" : $"{MapParameterToJava(parameter)} {ToCamelCase(parameter.Name)}"))}) {{"); if (returnInfo.HasReturn) @@ -1453,6 +1655,17 @@ private void GenerateUnionOverloadsWithOptions( } WriteLine(" }"); WriteLine(); + + // The options arity above gets a bridge that accepts a generated resource class, so this + // arity needs one too. Without it `frontend.withReference(app)` does not compile - the only + // single-argument overloads would be the handle wrapper, the endpoint reference, the string + // and the union, and a generated resource such as NodeAppResource is none of those. + GenerateResourceBuilderOverloads( + returnInfo.ReturnType, + methodName, + CreateUnionMethodParameters(requiredParameters, unionParameter, unionType), + returnInfo.HasReturn, + bridgeSignatures); } } @@ -1828,17 +2041,46 @@ private string RenderJavaTransportValueConversion(AtsTypeRef? typeRef, string va return converted; } - private string RenderJavaDtoPropertyTransportValueConversion(AtsTypeRef? typeRef, string valueExpression, bool isOptional) + private string RenderJavaDtoPropertyTransportValueConversion(AtsTypeRef? typeRef, string valueExpression, bool isOptional, int depth = 0) { - if (typeRef?.Category != AtsTypeCategory.Dict) + // A DTO field is typed by MapDtoPropertyTypeToJava, which renders every dictionary as Map and + // every list as List. MapTypeRefToJava instead renders a *mutable* dictionary or list as + // AspireDict/AspireList, which extend HandleWrapperBase and implement neither interface. So the + // cast has to use the DTO flavour for collections at any depth, not just at the top level: + // otherwise a property as ordinary as Dictionary[] emits + // `(AspireDict[]) value` against a `Map[]` field and javac + // rejects the file. The CLI compiles the whole generated SDK in one javac invocation, so that + // fails `aspire run` entirely, in code the user is told not to edit. + if (typeRef?.Category is not (AtsTypeCategory.Dict or AtsTypeCategory.Array or AtsTypeCategory.List)) { - return RenderJavaTransportValueConversion(typeRef, valueExpression, isOptional); + return RenderJavaTransportValueConversion(typeRef, valueExpression, isOptional, depth); } var allowNull = isOptional || typeRef.IsNullable == true; + + if (typeRef.Category == AtsTypeCategory.List) + { + // Rebuilt element by element rather than cast, because the transport hands back a + // List whose elements still need converting. `item{depth}` keeps nested lambdas + // from shadowing one another. + var itemName = $"item{depth}"; + var convertedItem = RenderJavaDtoPropertyTransportValueConversion( + typeRef.ElementType, + itemName, + typeRef.ElementType?.IsNullable == true, + depth + 1); + var projected = $"((List) {valueExpression}).stream().map({itemName} -> {convertedItem}).toList()"; + + return allowNull ? $"{valueExpression} == null ? null : {projected}" : projected; + } + var converted = $"({MapDtoPropertyTypeToJava(typeRef, allowNull, useBoxedTypes: true)}) {valueExpression}"; - return allowNull ? $"{valueExpression} == null ? null : {converted}" : converted; + // An array needs no null guard: casting null to an array type is legal Java and yields null, + // so guarding would only add noise the previous shape did not have. + return allowNull && typeRef.Category == AtsTypeCategory.Dict + ? $"{valueExpression} == null ? null : {converted}" + : converted; } private static string RenderJavaPrimitiveTransportValueConversion(string typeId, string valueExpression, bool allowNull) @@ -1934,21 +2176,21 @@ private void GenerateListOrDictProperty(AtsCapabilityInfo capability, string met var wrapperType = isDict ? "AspireDict" : "AspireList"; // Determine type arguments + // Boxing is requested from the mapper rather than applied afterwards to a primitive name, so + // these type arguments match the ones the List/Dict cases of MapTypeRefToJava produce. Boxing + // "double" to "Double" here instead would leave the collection accessor returning + // AspireDict while its own setter took AspireDict, and generic + // invariance would then reject ctx.setCounts(ctx.counts()). string typeArgs; if (isDict) { - var keyType = MapTypeRefToJava(returnType.KeyType, false); - var valueType = MapTypeRefToJava(returnType.ValueType, false); - // Use boxed types for generics - keyType = BoxPrimitiveType(keyType); - valueType = BoxPrimitiveType(valueType); + var keyType = MapTypeRefToJava(returnType.KeyType, false, useBoxedTypes: true); + var valueType = MapTypeRefToJava(returnType.ValueType, false, useBoxedTypes: true); typeArgs = $"<{keyType}, {valueType}>"; } else { - var elementType = MapTypeRefToJava(returnType.ElementType, false); - // Use boxed types for generics - elementType = BoxPrimitiveType(elementType); + var elementType = MapTypeRefToJava(returnType.ElementType, false, useBoxedTypes: true); typeArgs = $"<{elementType}>"; } @@ -1972,22 +2214,6 @@ private void GenerateListOrDictProperty(AtsCapabilityInfo capability, string met WriteLine(); } - private static string BoxPrimitiveType(string type) - { - return type switch - { - "int" => "Integer", - "long" => "Long", - "double" => "Double", - "float" => "Float", - "boolean" => "Boolean", - "char" => "Character", - "byte" => "Byte", - "short" => "Short", - _ => type - }; - } - private void GenerateHandleWrapperRegistrations( IReadOnlyList handleTypes, Dictionary collectionTypes) @@ -2008,7 +2234,13 @@ private void GenerateHandleWrapperRegistrations( foreach (var (typeId, isDict) in collectionTypes) { var wrapperType = isDict ? "AspireDict" : "AspireList"; - WriteLine($" AspireClient.registerHandleWrapper(\"{typeId}\", (h, c) -> new {wrapperType}(h, c));"); + // The diamond is required rather than cosmetic: the factory is a + // BiFunction, so a raw AspireList/AspireDict here would + // raise a rawtypes warning in every consumer's IDE. Element types are not known at + // registration time, so inference against the Object target yields the erased-equivalent + // AspireList/AspireDict, which is exactly what callers re-cast + // through the typed accessors generated by GenerateListOrDictProperty. + WriteLine($" AspireClient.registerHandleWrapper(\"{typeId}\", (h, c) -> new {wrapperType}<>(h, c));"); } WriteLine(" }"); @@ -2059,8 +2291,18 @@ private void GenerateConnectionHelpers() WriteLine(" resolvedOptions.putAll(options.toMap());"); WriteLine(" }"); WriteLine(" if (resolvedOptions.get(\"Args\") == null) {"); - WriteLine(" // Note: Java doesn't have easy access to command line args from here"); - WriteLine(" resolvedOptions.put(\"Args\", new String[0]);"); + // Python, TypeScript and Rust AppHosts read the process arguments themselves + // (sys.argv[1:], process.argv.slice(2), std::env::args()), so a builder created without + // arguments still observes "--operation publish". A JVM cannot do the same: + // main(String[]) is the only place those arguments exist, and + // ProcessHandle.current().info().arguments() reports the JVM's own arguments (options and + // main class) rather than the application's. The CLI therefore forwards them in + // ASPIRE_APPHOST_ARGS, newline separated, so CreateBuilder() behaves like its + // counterparts instead of silently running the app when the user asked to publish. + WriteLine(" String forwardedArgs = System.getenv(\"ASPIRE_APPHOST_ARGS\");"); + WriteLine(" resolvedOptions.put(\"Args\", forwardedArgs == null || forwardedArgs.isEmpty()"); + WriteLine(" ? new String[0]"); + WriteLine(" : forwardedArgs.split(\"\\n\", -1));"); WriteLine(" }"); // ASPIRE_PROJECT_DIRECTORY is set by the CLI so the host reports the correct project // directory (not the JVM's user.dir) when matching --apphost requests. @@ -2331,13 +2573,21 @@ private string MapEnumType(string typeId) => private static string MapPrimitiveType(string typeId, bool useBoxedTypes) => typeId switch { AtsConstants.String or AtsConstants.Char => "String", - AtsConstants.Number => useBoxedTypes ? "Double" : "double", + // java.lang.Number rather than Double. ATS collapses every numeric to one Number type, so a C# + // int parameter such as a port or an exit code arrives here as a floating-point type, and Java + // will not convert an int literal to a Double: widening followed by boxing is not one of the + // conversions the language performs, so `targetPort(8080)` and `waitForCompletion(job, 0)` fail + // to compile with "int cannot be converted to Double". Declaring the supertype accepts int, long + // and double literals, boxed values and null alike, and the value is serialized through + // AspireClient.serializeValue(Object), which never needed a Double. + // https://docs.oracle.com/javase/specs/jls/se21/html/jls-5.html#jls-5.3 + AtsConstants.Number => useBoxedTypes ? "Number" : "double", AtsConstants.Boolean => useBoxedTypes ? "Boolean" : "boolean", AtsConstants.Void => "void", AtsConstants.Any => "Object", AtsConstants.DateTime or AtsConstants.DateTimeOffset or AtsConstants.DateOnly or AtsConstants.TimeOnly => "String", - AtsConstants.TimeSpan => useBoxedTypes ? "Double" : "double", + AtsConstants.TimeSpan => useBoxedTypes ? "Number" : "double", AtsConstants.Guid or AtsConstants.Uri => "String", AtsConstants.CancellationToken => "CancellationToken", _ => "Object" @@ -2449,6 +2699,21 @@ private static string SanitizeIdentifier(string name) return s_javaKeywords.Contains(sanitized) ? sanitized + "_" : sanitized; } + /// + /// Builds the accessor suffix for a DTO property, so Default yields Default_ to match + /// the default_ field. + /// + /// + /// Derived from the keyword-escaped field rather than the raw property name so the accessors and the + /// field stay in step, and so a property named Class becomes getClass_() instead of + /// colliding with the final java.lang.Object.getClass(), which cannot be overridden. Both the + /// accessor declarations and the exported-value initializers that call the setters must go through + /// here: when the initializer derived its call from the raw name instead, it emitted + /// setDefault(...) against a setDefault_ declaration, and javac rejected the whole + /// generated SDK with cannot find symbol. + /// + private static string DtoAccessorSuffix(string propertyName) => ToPascalCase(ToCamelCase(propertyName)); + /// /// Converts a name to PascalCase for Java class/method names. /// @@ -2466,19 +2731,29 @@ private static string ToPascalCase(string name) } /// - /// Converts a name to camelCase for Java field/variable names. + /// Converts a name to camelCase for Java field, parameter and local variable names, escaping any + /// result that collides with a Java keyword. /// + /// + /// Only type names used to route through , so a capability parameter + /// or DTO property named Default, Package or Class emitted default, + /// package or class as a bare identifier and javac rejected the generated SDK outright. + /// The Rust generator escapes every identifier it emits (r# prefix); this keeps Java at parity. + /// The trailing underscore is the convention the JLS leaves available, since no keyword ends in one. + /// + /// This only changes Java source. Transport keys are emitted from the original property name, so the + /// wire payload exchanged with the .NET host is unaffected. + /// + /// private static string ToCamelCase(string name) { if (string.IsNullOrEmpty(name)) { return name; } - if (char.IsLower(name[0])) - { - return name; - } - return char.ToLowerInvariant(name[0]) + name[1..]; + + var camelCase = char.IsLower(name[0]) ? name : char.ToLowerInvariant(name[0]) + name[1..]; + return s_javaKeywords.Contains(camelCase) ? camelCase + "_" : camelCase; } /// diff --git a/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs b/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs index 5bbe75aba49..d352dce88e6 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs +++ b/src/Aspire.Hosting.CodeGeneration.Java/JavaLanguageSupport.cs @@ -22,7 +22,20 @@ internal sealed class JavaLanguageSupport : ILanguageSupport private const string CodeGenTarget = "Java"; private const string LanguageDisplayName = "Java"; - private static readonly string[] s_detectionPatterns = ["AppHost.java"]; + + /// + /// AppHost locations, in priority order: the flat single-file layout, and the standard Maven and + /// Gradle source root that a build-tool project uses. + /// + /// + /// The flat layout is listed first and remains the default so an AppHost that predates build-tool + /// support keeps working unchanged, and so the common case needs nothing but a JDK. + /// + private static readonly string[] s_detectionPatterns = + [ + "AppHost.java", + "src/main/java/AppHost.java" + ]; /// public string Language => LanguageId; @@ -78,19 +91,137 @@ void main(String[] args) throws Exception { } """; + // Without a pom.xml or build.gradle the Java language server treats the folder as an + // "invisible project" and only puts the workspace root on the source path, so every + // reference to the generated SDK under .aspire/modules resolves to "cannot be resolved to a + // type": no completion, no navigation, and no breakpoint binding in the AppHost. Declaring + // both source roots is what makes a build-tool-free AppHost a real editing experience. + // The setting is ignored once a build file exists, because the build tool then owns the + // project model, so this stays correct if the user later adopts Maven or Gradle. + // https://github.com/redhat-developer/vscode-java/wiki/Java-Project-Settings + files[".vscode/settings.json"] = """ + { + "java.project.sourcePaths": [ + ".", + ".aspire/modules" + ], + "java.compile.nullAnalysis.mode": "disabled" + } + """; + return files; } /// public DetectionResult Detect(string directoryPath) { - var appHostPath = Path.Combine(directoryPath, "AppHost.java"); - if (!File.Exists(appHostPath)) + foreach (var pattern in s_detectionPatterns) { - return DetectionResult.NotFound; + // The patterns are written with forward slashes because they are also a wire contract, + // so they have to be translated before touching the file system on Windows. + var relativePath = pattern.Replace('/', Path.DirectorySeparatorChar); + + if (File.Exists(Path.Combine(directoryPath, relativePath))) + { + return DetectionResult.Found(LanguageId, relativePath); + } } - return DetectionResult.Found(LanguageId, "AppHost.java"); + return DetectionResult.NotFound; + } + + /// + /// Directory that the generated SDK sources and the AppHost are compiled into. + /// + private const string BuildOutputDirectory = ".java-build"; + + /// + /// Compiler options used to build the AppHost. + /// + /// + /// + /// The scaffolded AppHost is a compact source file with an instance main method, which + /// requires Java 25. That feature was previewed in Java 21 through 24 (JEP 445, 463, 477, and + /// 495) and finalized in Java 25 by JEP 512, so + /// --enable-preview is deliberately absent: passing it here compiles no preview feature + /// and only risks stamping the class files with the preview minor version (65535), which binds + /// them to one exact JDK release and forces the flag at run time too. + /// + /// + /// --release is used rather than --source because only --release also + /// constrains the visible API surface. With --source alone a newer JDK still compiles + /// against its own class library, so an AppHost can bind to APIs that do not exist in Java 25 + /// and then fail at run time on a conforming Java 25 runtime. + /// + /// + private static readonly string[] s_javacOptions = ["--release", "25"]; + + /// + /// Argument file listing the generated SDK sources, produced by the code generator. + /// + /// + /// Passed to javac as an @ argument file. javac expands these itself, so this + /// works without a shell and stays well under the command-line length limit even though the + /// generated SDK is hundreds of files. + /// + private const string GeneratedSourcesListPath = $"{GeneratedSourcesDirectory}/sources.txt"; + + /// + /// Directory the generated SDK sources are written to. + /// + private const string GeneratedSourcesDirectory = ".aspire/modules"; + + /// + /// Class that the scaffolded AppHost compiles to. + /// + /// + /// The AppHost is declared in the default package, so this is also its fully qualified name. + /// + private const string AppHostClassName = "AppHost"; + + /// + /// Name of the file written after a successful compile, used to skip the next one. + /// + internal const string CompileStampFileName = ".aspire-compile-stamp"; + + /// + /// Builds the up-to-date check that lets an unchanged AppHost skip javac entirely. + /// + /// + /// + /// javac given an explicit list of sources recompiles every one of them, so without this the + /// generated SDK — several hundred files — is recompiled on every launch even when nothing + /// changed. Every other supported language either has an incremental compiler of its own or no + /// compile step at all, so this is what brings Java to the same startup cost. + /// + /// + /// The inputs are exactly javac's source roots: the AppHost file, the sources beside it, the + /// generated SDK, and src/main/java for the layout where the AppHost sits at the project + /// root. The AppHost directory is recursive because javac is given no -sourcepath, so its + /// source path defaults to the user class path and therefore to the AppHost directory: a helper + /// class in a package beside the AppHost is compiled implicitly, and rewriting it in place moves + /// no ancestor's timestamp. The scan prunes the trees that cannot hold a package - dot + /// directories, node_modules, and javac's own output - so the sibling trees in a typical + /// solution do not give back the time this saves. + /// + /// + /// Directory javac writes classes to, which is where the stamp lives. + internal static CommandUpToDateCheck CreateCompileUpToDateCheck(string classOutputDirectory) + { + return new CommandUpToDateCheck + { + Inputs = + [ + "{appHostFile}", + "./**", + $"{GeneratedSourcesDirectory}/**", + "src/main/java/**" + ], + // Only sources are inputs. Without this the .class files javac writes beside the sources in + // the flat layout would invalidate the very check they were produced under. + FileExtensions = [".java"], + StampFile = Path.Combine(classOutputDirectory, CompileStampFileName) + }; } /// @@ -102,16 +233,35 @@ public RuntimeSpec GetRuntimeSpec() DisplayName = LanguageDisplayName, CodeGenLanguage = CodeGenTarget, DetectionPatterns = s_detectionPatterns, - // No separate install step - compilation happens in Execute + // Compilation is a pre-execute step rather than part of Execute so that Execute is a plain + // JVM launch. That is what lets the AppHost be debugged (the IDE starts the JVM itself and + // would otherwise start a shell), and it lets --no-build skip the compile. + // A Maven or Gradle AppHost replaces both commands via JavaAppHostToolchainResolver. InstallDependencies = null, + PreExecute = + [ + new CommandSpec + { + // No shell. javac creates the destination directory itself, so there is nothing + // left that needed one, and running without a shell means arguments are not + // re-split: a project under a path such as "C:\My Projects" works unchanged, on + // Windows and Unix alike, from a single spec. + Command = "javac", + Args = [.. s_javacOptions, "-d", BuildOutputDirectory, $"@{GeneratedSourcesListPath}", "{appHostFile}"], + UpToDateCheck = CreateCompileUpToDateCheck(BuildOutputDirectory) + } + ], + // Debugging the AppHost itself goes through the same Java debug adapter the resources use. + // The CLI only takes this path when the extension reports the capability, so a CLI-only + // run is unaffected. + ExtensionLaunchCapability = LanguageId, Execute = new CommandSpec { - // Use a shell to compile and run in sequence - // On Windows, use cmd /c; on Unix, use sh -c - Command = OperatingSystem.IsWindows() ? "cmd" : "sh", - Args = OperatingSystem.IsWindows() - ? ["/c", "if not exist .java-build mkdir .java-build && javac --enable-preview --source 25 -d .java-build @.aspire\\modules\\sources.txt AppHost.java && java --enable-preview -cp .java-build AppHost {args}"] - : ["-c", "mkdir -p .java-build && javac --enable-preview --source 25 -d .java-build @.aspire/modules/sources.txt AppHost.java && java --enable-preview -cp .java-build AppHost {args}"] + Command = "java", + // {args} is deliberately absent. When no argument contains that placeholder the CLI + // appends its arguments as separate argv entries, whereas substituting the placeholder + // joins them into a single space-separated string the AppHost would have to re-split. + Args = ["-cp", BuildOutputDirectory, AppHostClassName] } }; } diff --git a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java index 64ca716ef5c..783c4140238 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java +++ b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Base.java @@ -1,9 +1,17 @@ -// Base.java - Base types and utilities for Aspire Java SDK -// GENERATED CODE - DO NOT EDIT +// Base.java - Base types and utilities for the Aspire Java SDK. +// +// This is a hand-maintained template, not generated output: it is embedded into +// Aspire.Hosting.CodeGeneration.Java and split into one file per type alongside the types the +// generator emits. Everything above the package declaration is dropped during that split, and each +// emitted file gets its own "GENERATED CODE - DO NOT EDIT" banner from CreateJavaSourceFile, so +// edit this file to change what users receive. package aspire; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * HandleWrapperBase is the base class for all handle wrappers. @@ -291,6 +299,75 @@ private Handle ensureHandle() { } return resolvedHandle; } + + /** + * Gets the number of elements in the list. + * + * @return the element count + */ + public int size() { + Object result = getClient().invokeCapability("Aspire.Hosting/List.length", Map.of("list", ensureHandle().toJson())); + return ((Number) result).intValue(); + } + + /** + * Gets the element at the specified index. + * + * @param index the zero-based index + * @return the element at {@code index} + */ + @SuppressWarnings("unchecked") + public T get(int index) { + Map args = new HashMap<>(); + args.put("list", ensureHandle().toJson()); + args.put("index", index); + return (T) getClient().invokeCapability("Aspire.Hosting/List.get", args); + } + + /** + * Appends an element to the end of the list. + * + * @param item the element to append + */ + public void add(T item) { + Map args = new HashMap<>(); + args.put("list", ensureHandle().toJson()); + args.put("item", AspireClient.serializeValue(item)); + getClient().invokeCapability("Aspire.Hosting/List.add", args); + } + + /** + * Removes the element at the specified index. + * + * @param index the zero-based index + * @return {@code true} if an element was removed + */ + public boolean remove(int index) { + Map args = new HashMap<>(); + args.put("list", ensureHandle().toJson()); + args.put("index", index); + Object result = getClient().invokeCapability("Aspire.Hosting/List.removeAt", args); + return Boolean.TRUE.equals(result); + } + + /** + * Removes all elements from the list. + */ + public void clear() { + getClient().invokeCapability("Aspire.Hosting/List.clear", Map.of("list", ensureHandle().toJson())); + } + + /** + * Copies the list into a plain {@link List}. Later mutations of the underlying + * app model are not reflected in the returned copy. + * + * @return a snapshot of the list contents + */ + @SuppressWarnings("unchecked") + public List toList() { + Object result = getClient().invokeCapability("Aspire.Hosting/List.toArray", Map.of("list", ensureHandle().toJson())); + return (List) result; + } } /** @@ -367,11 +444,46 @@ public boolean containsKey(K key) { return Boolean.TRUE.equals(result); } + /** + * Gets the keys in the dictionary. + * + * @return a snapshot of the keys + */ @SuppressWarnings("unchecked") public List keys() { Object result = getClient().invokeCapability("Aspire.Hosting/Dict.keys", Map.of("dict", ensureHandle().toJson())); return (List) result; } + + /** + * Gets the values in the dictionary. + * + * @return a snapshot of the values + */ + @SuppressWarnings("unchecked") + public List values() { + Object result = getClient().invokeCapability("Aspire.Hosting/Dict.values", Map.of("dict", ensureHandle().toJson())); + return (List) result; + } + + /** + * Removes all entries from the dictionary. + */ + public void clear() { + getClient().invokeCapability("Aspire.Hosting/Dict.clear", Map.of("dict", ensureHandle().toJson())); + } + + /** + * Copies the dictionary into a plain {@link Map}. Later mutations of the + * underlying app model are not reflected in the returned copy. + * + * @return a snapshot of the dictionary contents + */ + @SuppressWarnings("unchecked") + public Map toMap() { + Object result = getClient().invokeCapability("Aspire.Hosting/Dict.toObject", Map.of("dict", ensureHandle().toJson())); + return (Map) result; + } } /** diff --git a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Transport.java b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Transport.java index acc3bf3f084..a40d16d3d08 100644 --- a/src/Aspire.Hosting.CodeGeneration.Java/Resources/Transport.java +++ b/src/Aspire.Hosting.CodeGeneration.Java/Resources/Transport.java @@ -1,15 +1,34 @@ -// Transport.java - JSON-RPC transport layer for Aspire Java SDK -// GENERATED CODE - DO NOT EDIT +// Transport.java - JSON-RPC transport layer for the Aspire Java SDK. +// +// This is a hand-maintained template, not generated output: it is embedded into +// Aspire.Hosting.CodeGeneration.Java and split into one file per type alongside the types the +// generator emits. Everything above the package declaration is dropped during that split, and each +// emitted file gets its own "GENERATED CODE - DO NOT EDIT" banner from CreateJavaSourceFile, so +// edit this file to change what users receive. package aspire; -import java.io.*; -import java.net.*; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; /** * Handle represents a remote object reference. @@ -124,11 +143,15 @@ class AspireClient { private final String socketPath; private OutputStream outputStream; private InputStream inputStream; + // Windows only. inputStream/outputStream are built from this file's descriptor, so the + // RandomAccessFile has to outlive connect() -- closing it would close the descriptor they share + // and break the connection. Holding it in a field also makes that lifetime explicit to a + // compiler's resource-leak analysis, which cannot see the escape through FileDescriptor. + private RandomAccessFile namedPipe; private final AtomicInteger requestId = new AtomicInteger(0); private final Map> callbacks = new ConcurrentHashMap<>(); private final Map> cancellations = new ConcurrentHashMap<>(); private Runnable disconnectHandler; - private volatile boolean connected = false; // Handle wrapper factory registry private static final Map> handleWrappers = new ConcurrentHashMap<>(); @@ -150,7 +173,6 @@ public void connect() throws IOException { connectUnixSocket(); } - connected = true; debug("Connected successfully"); } @@ -165,10 +187,10 @@ private void connectWindowsNamedPipe() throws IOException { debug("Opening Windows named pipe: " + pipePath); // Use RandomAccessFile to open the named pipe - RandomAccessFile pipe = new RandomAccessFile(pipePath, "rw"); - + namedPipe = new RandomAccessFile(pipePath, "rw"); + // Create streams from the RandomAccessFile - FileDescriptor fd = pipe.getFD(); + FileDescriptor fd = namedPipe.getFD(); inputStream = new FileInputStream(fd); outputStream = new FileOutputStream(fd); @@ -330,7 +352,6 @@ private Object readResponse(int expectedId) throws IOException { @SuppressWarnings("unchecked") private Map readMessage() throws IOException { // Read headers - StringBuilder headerBuilder = new StringBuilder(); int contentLength = -1; while (true) { @@ -560,7 +581,6 @@ private Object unwrapResult(Object value) { } private void handleDisconnect() { - connected = false; if (disconnectHandler != null) { disconnectHandler.run(); } diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index b54f2754bd9..9396cce43ec 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -306,7 +306,7 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) var dockerComposeEnvironmentContext = new DockerComposeEnvironmentContext(this, logger); - if (DashboardEnabled && Dashboard?.Resource is DockerComposeAspireDashboardResource dashboard) + if (DashboardEnabled && Dashboard?.Resource is DockerComposeAspireDashboardResource dashboard && !IsAlreadyTargeted(dashboard)) { // Ensure the dashboard resource is created (even though it's not part of the main application model) var dashboardService = await dockerComposeEnvironmentContext.CreateDockerComposeServiceResourceAsync(dashboard, executionContext, cancellationToken).ConfigureAwait(false); @@ -327,6 +327,17 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) continue; } + // This step is reachable from two pipeline executions: it is RequiredBy + // "before-start" (so it runs during AppHost startup) and it is also part of the + // publish/deploy DAG. Adding a second DeploymentTargetAnnotation on the second + // pass makes ResourceExtensions.GetDeploymentTargetAnnotation throw, so the step + // has to be idempotent. Skipping early also avoids re-running ConfigureOtlp, + // which would append duplicate environment variables. + if (IsAlreadyTargeted(r)) + { + continue; + } + // Configure OTLP for resources if dashboard is enabled (before creating the service resource) if (DashboardEnabled && Dashboard?.Resource.OtlpGrpcEndpoint is EndpointReference otlpGrpcEndpoint) { @@ -345,6 +356,13 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) } } + /// + /// Returns when the resource already carries a + /// produced by this environment. + /// + private bool IsAlreadyTargeted(IResource resource) => + resource.Annotations.OfType().Any(a => a.ComputeEnvironment == this); + private static IContainerRegistry GetContainerRegistry(DockerComposeEnvironmentResource environment, DistributedApplicationModel appModel) { // Check for explicit container registry reference annotation on the environment diff --git a/src/Aspire.Hosting.Java/Aspire.Hosting.Java.csproj b/src/Aspire.Hosting.Java/Aspire.Hosting.Java.csproj new file mode 100644 index 00000000000..62b5be172ba --- /dev/null +++ b/src/Aspire.Hosting.Java/Aspire.Hosting.Java.csproj @@ -0,0 +1,38 @@ + + + + $(DefaultTargetFramework) + true + aspire integration hosting java jvm framework runtime + Java support for Aspire. + true + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Aspire.Hosting.Java/IJavaAppResource.cs b/src/Aspire.Hosting.Java/IJavaAppResource.cs new file mode 100644 index 00000000000..e9129b97cdb --- /dev/null +++ b/src/Aspire.Hosting.Java/IJavaAppResource.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Java; + +/// +/// Marks a resource that runs a Java application, whether as a local process or as a container. +/// +/// +/// Implemented by and so that +/// configuration which is meaningful for any JVM, such as +/// , applies to both +/// without also appearing on unrelated executables and containers. +/// +public interface IJavaAppResource : IResourceWithEnvironment; diff --git a/src/Aspire.Hosting.Java/JavaAppResource.cs b/src/Aspire.Hosting.Java/JavaAppResource.cs new file mode 100644 index 00000000000..e7e089d98de --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaAppResource.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Java; + +/// +/// Represents a Java application resource in the distributed application model. +/// +/// The name of the resource in the application model. +/// The working directory for the Java application. +/// +/// The command is always java. When the application is launched through a Maven goal or a Gradle +/// task, the wrapper script replaces that command; see WithMavenGoal and WithGradleTask. +/// +[AspireExport(ExposeProperties = true)] +public class JavaAppResource(string name, string workingDirectory) + : ExecutableResource(name, "java", workingDirectory), IJavaAppResource, IResourceWithServiceDiscovery, IContainerFilesDestinationResource; diff --git a/src/Aspire.Hosting.Java/JavaBuildResource.cs b/src/Aspire.Hosting.Java/JavaBuildResource.cs new file mode 100644 index 00000000000..e51654cf25a --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaBuildResource.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Java; + +/// +/// A resource that represents a build step that runs before its parent Java application starts. +/// +/// The name of the resource. +/// The full path to the build tool's wrapper script. +/// The working directory to use for the command. +/// The build tool the wrapper invokes. +/// +/// The build tool is carried as data rather than expressed as a type per tool. Maven and Gradle build +/// steps differ only in the wrapper they exec and the arguments they are given, both of which are +/// already resolved from the parent resource's annotations, so a type apiece would describe nothing a +/// caller could not read from . +/// +internal sealed class JavaBuildResource(string name, string wrapperPath, string workingDirectory, JavaBuildTool tool) + : ExecutableResource(name, wrapperPath, workingDirectory) +{ + /// + /// Gets the build tool this step runs. + /// + public JavaBuildTool Tool { get; } = tool; +} diff --git a/src/Aspire.Hosting.Java/JavaBuildToolAnnotation.cs b/src/Aspire.Hosting.Java/JavaBuildToolAnnotation.cs new file mode 100644 index 00000000000..7cd9f852ad6 --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaBuildToolAnnotation.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Java; + +/// +/// Identifies the build tool that launches a . +/// +internal enum JavaBuildTool +{ + /// Apache Maven, invoked through the mvnw wrapper. + Maven, + + /// Gradle, invoked through the gradlew wrapper. + Gradle +} + +/// +/// Records that a Java application is launched through a build tool wrapper rather than through java -jar. +/// +/// The build tool that launches the application. +/// The arguments to pass to the build tool (the goal or task name, plus any extra arguments). +internal sealed class JavaBuildToolAnnotation(JavaBuildTool tool, string[] args) : IResourceAnnotation +{ + /// + /// The build tool that launches the application. + /// + public JavaBuildTool Tool { get; } = tool; + + /// + /// The arguments to pass to the build tool. + /// + public string[] Args { get; } = args; +} + +/// +/// Records how a is built before it runs. +/// +/// +/// Recorded in every execution context, not only where the build actually runs. In run mode the +/// lets WithWrapperPath re-point a build step that was created before +/// the override was supplied, which is what makes the builder calls order-independent. In publish mode +/// there is no build-step resource, but the tool and arguments still describe how to produce a deployable +/// JAR and are what the generated Dockerfile runs. +/// +/// The name of the build-step resource, or outside run mode. +/// The build tool that produces the artifact. +/// The arguments passed to the build tool. +internal sealed record JavaBuildStepAnnotation(string? ResourceName, JavaBuildTool Tool, string[] Args) : IResourceAnnotation; + +/// +/// Defers choosing between Maven and Gradle until the application is about to start. +/// +/// +/// Spring Boot and Quarkus helpers support both tools. Keeping both configurations here lets those helpers +/// build the model without touching the application directory, while run and publish still resolve the +/// same project markers through . +/// +/// Arguments that package the application with Maven. +/// Arguments that launch the application with Maven. +/// Arguments that package the application with Gradle. +/// Arguments that launch the application with Gradle. +internal sealed record JavaDetectedBuildToolAnnotation( + string[] MavenBuildArgs, + string[] MavenLaunchArgs, + string[] GradleBuildArgs, + string[] GradleLaunchArgs) : IResourceAnnotation +{ + /// + /// Returns the build and launch arguments for . + /// + internal (string[] BuildArgs, string[] LaunchArgs) GetConfiguration(JavaBuildTool tool) => tool switch + { + JavaBuildTool.Maven => (MavenBuildArgs, MavenLaunchArgs), + JavaBuildTool.Gradle => (GradleBuildArgs, GradleLaunchArgs), + _ => throw new ArgumentOutOfRangeException(nameof(tool), tool, null) + }; +} + +/// +/// Records the OpenTelemetry Java agent configured by WithOtelAgent. +/// +/// +/// The environment variable alone is not enough to reproduce the agent in a container. A relative agent +/// path names a file produced by the build, which only exists in the Dockerfile's build stage, so the +/// runtime stage has to copy it forward and the environment variable has to point at where it landed. +/// Without this the published container starts a JVM pointing at an agent JAR that is not in the image +/// and dies during VM initialization. +/// +/// +/// The agent path exactly as authored, before any resolution, or when the caller +/// asked for the location the build tool writes the agent to. That location is resolved on demand rather +/// than when the annotation is added, so WithOtelAgent() and WithMavenBuild() can be called +/// in either order. +/// +internal sealed record JavaOtelAgentAnnotation(string? AgentPath) : IResourceAnnotation; + +/// +/// Records that publishing found a Dockerfile the developer wrote and left it alone. +/// +/// +/// The rest of the integration assumes it produced the image, and so knows where the build put things. +/// An authored Dockerfile is the developer's contract instead, so anything that would otherwise point +/// at a generated layout has to notice and say so rather than guess. +/// +internal sealed record JavaAuthoredDockerfileAnnotation : IResourceAnnotation; + +/// +/// Records that a Java application is a Quarkus application, which packages differently from every other +/// build the integration supports. +/// +/// +/// Quarkus's default packaging is the "fast JAR": the runnable artifact is quarkus-app/quarkus-run.jar +/// and it is useless on its own, because its manifest Class-Path points at sibling lib, +/// app, and quarkus directories. Publishing has to carry that whole directory rather than a +/// single file, and the plain JAR the build leaves in the output directory alongside it has no +/// Main-Class at all. Neither is discoverable from the build file at publish time, when nothing has +/// been built yet, so it is recorded when the resource is added. +/// See https://quarkus.io/guides/maven-tooling#fast-jar. +/// +internal sealed class JavaQuarkusAnnotation : IResourceAnnotation; diff --git a/src/Aspire.Hosting.Java/JavaBuildToolResolver.cs b/src/Aspire.Hosting.Java/JavaBuildToolResolver.cs new file mode 100644 index 00000000000..17242b74ba9 --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaBuildToolResolver.cs @@ -0,0 +1,200 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Java; + +/// +/// Resolves Java build tools from project files. +/// +internal static class JavaBuildToolResolver +{ + private static readonly string[] s_gradleBuildFileNames = + [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ]; + + /// + /// Returns the build tool declared by files in , or + /// when none is declared. + /// + internal static JavaBuildTool? Detect( + string appDirectory, + string resourceName, + Func createAmbiguityException) + { + var hasMaven = File.Exists(Path.Combine(appDirectory, "pom.xml")); + var hasGradle = s_gradleBuildFileNames.Any(fileName => File.Exists(Path.Combine(appDirectory, fileName))); + + // Ambiguous projects are rejected rather than guessed. Maven-first detection made publish produce + // a different artifact than run mode for the same directory, while an explicit build or launch API + // records the author's choice for both paths. + if (hasMaven && hasGradle) + { + throw createAmbiguityException( + $"Directory '{appDirectory}' contains both Maven and Gradle build files, so the build tool for resource '{resourceName}' is ambiguous. " + + "Use AddJavaApp and call WithMavenBuild, WithGradleBuild, WithMavenGoal, or WithGradleTask to choose one explicitly."); + } + + return (hasMaven, hasGradle) switch + { + (true, false) => JavaBuildTool.Maven, + (false, true) => JavaBuildTool.Gradle, + _ => null + }; + } + + /// + /// Resolves the wrapper selected for a resource on the requested execution platform. + /// + /// + /// The application's own directory wins, then the search walks up to the build root. A Gradle + /// multi-project build has exactly one gradlew, next to the settings.gradle that + /// declares the subprojects, and Maven multi-module repositories keep mvnw next to the + /// aggregator POM — so a resource pointed at a module would otherwise never find a wrapper. + /// + /// An ancestor only qualifies when it also holds that tool's build-root marker, which keeps an + /// unrelated wrapper somewhere higher up the filesystem from being picked. The walk stops after + /// the directory holding .git so a submodule or nested checkout uses its own wrapper + /// rather than the outer repository's. + /// + /// + internal static string ResolveWrapperPath(JavaAppResource resource, JavaBuildTool tool, bool isWindows) + { + if (resource.TryGetLastAnnotation(out var wrapper)) + { + return wrapper.WrapperPath; + } + + var wrapperName = GetDefaultWrapperName(tool, isWindows); + var appDirectory = resource.WorkingDirectory; + + return PathNormalizer.NormalizePathForCurrentPlatform( + FindWrapperInBuildRoot(appDirectory, wrapperName, tool) ?? Path.Combine(appDirectory, wrapperName)); + } + + /// + private static string? FindWrapperInBuildRoot(string appDirectory, string wrapperName, JavaBuildTool tool) + { + for (var directory = SafeDirectoryInfo(appDirectory); directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, wrapperName); + var isApplicationDirectory = directory.FullName == appDirectory; + + if (File.Exists(candidate) + && (isApplicationDirectory + || (IsBuildRoot(directory.FullName, tool) + && !IsWorldWritable(directory) + && !IsWorldWritable(new FileInfo(candidate))))) + { + return candidate; + } + + // A worktree or submodule records .git as a file rather than a directory, so both count. + var gitPath = Path.Combine(directory.FullName, ".git"); + if (Directory.Exists(gitPath) || File.Exists(gitPath)) + { + return null; + } + } + + return null; + } + + /// + /// Returns whether a directory is the root of a build for . + /// + private static bool IsBuildRoot(string directory, JavaBuildTool tool) => tool switch + { + // Gradle requires a settings file at the root of a multi-project build; that is the directory + // the wrapper is generated into. https://docs.gradle.org/current/userguide/multi_project_builds.html + JavaBuildTool.Gradle => File.Exists(Path.Combine(directory, "settings.gradle")) + || File.Exists(Path.Combine(directory, "settings.gradle.kts")), + // A Maven aggregator is itself a project, so its POM is the marker. + // https://maven.apache.org/guides/introduction/introduction-to-the-pom.html + JavaBuildTool.Maven => File.Exists(Path.Combine(directory, "pom.xml")), + _ => false + }; + + /// + /// Returns whether any user on the machine can write to . + /// + /// + /// The application directory is named in the AppHost, so running the wrapper beside it is the + /// developer's own instruction. Ancestors are inferred instead, and the walk continues past the + /// application when no .git marks a checkout boundary - so on a shared machine an + /// application under a world-writable directory such as /tmp could otherwise pick up a + /// mvnw another user planted alongside a pom.xml, and execute it with the + /// developer's privileges before anything is built. + /// + /// Applied to the wrapper file as well as its directory, because rewriting a file in place needs + /// write permission on the file rather than on the directory holding it. + /// + /// + /// Group-writable is deliberately not rejected: distributions that enable user private groups + /// pair a umask of 002 with a group per user, so an ordinary checkout is mode 775 and rejecting + /// it would break wrapper resolution for a large share of Linux users. Telling a private group + /// from a shared one needs the owner and group membership, which .NET does not expose portably. + /// + /// + /// Only applied to inferred ancestors, and only where the mode is meaningful: Windows uses ACLs + /// that does not describe, and .NET reports + /// there. + /// + /// + private static bool IsWorldWritable(FileSystemInfo entry) + { + if (OperatingSystem.IsWindows()) + { + return false; + } + + try + { + return entry.UnixFileMode.HasFlag(UnixFileMode.OtherWrite); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The mode could not be read, so it cannot be shown to be safe. Treating it as + // world-writable falls back to the wrapper beside the application, which is the same + // outcome as finding no ancestor wrapper at all. + return true; + } + } + + /// + /// Returns the directory, or when the path cannot be interpreted. + /// + /// + /// Wrapper resolution runs while the AppHost is still being authored, so the directory may not + /// exist yet and may be a value the developer has not finished typing. + /// + private static DirectoryInfo? SafeDirectoryInfo(string path) + { + try + { + return new DirectoryInfo(path); + } + catch (Exception ex) when (ex is ArgumentException or PathTooLongException or NotSupportedException) + { + return null; + } + } + + /// + /// Returns the conventional wrapper name for a build tool on the requested execution platform. + /// + internal static string GetDefaultWrapperName(JavaBuildTool tool, bool isWindows) => (tool, isWindows) switch + { + (JavaBuildTool.Maven, true) => "mvnw.cmd", + (JavaBuildTool.Maven, false) => "mvnw", + (JavaBuildTool.Gradle, true) => "gradlew.bat", + (JavaBuildTool.Gradle, false) => "gradlew", + _ => throw new ArgumentOutOfRangeException(nameof(tool), tool, null) + }; +} diff --git a/src/Aspire.Hosting.Java/JavaContainerResource.cs b/src/Aspire.Hosting.Java/JavaContainerResource.cs new file mode 100644 index 00000000000..8f605631e8c --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaContainerResource.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Java; + +/// +/// Represents a Java application that runs from a prebuilt container image. +/// +/// The name of the resource in the application model. +/// An optional entrypoint that replaces the image's own. +[AspireExport(ExposeProperties = true)] +public class JavaContainerResource(string name, string? entrypoint = null) + : ContainerResource(name, entrypoint), IJavaAppResource, IResourceWithServiceDiscovery; diff --git a/src/Aspire.Hosting.Java/JavaDockerfileGenerator.cs b/src/Aspire.Hosting.Java/JavaDockerfileGenerator.cs new file mode 100644 index 00000000000..6b3481dceb7 --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaDockerfileGenerator.cs @@ -0,0 +1,1317 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREDOCKERFILEBUILDER001 + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.RegularExpressions; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.ApplicationModel.Docker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Java; + +/// +/// Generates the multi-stage Dockerfile that publishes a . +/// +/// +/// The container build is the only build: nothing here compiles the project on the host. The build stage +/// runs the project's own Maven or Gradle wrapper so the image is produced by exactly the tool version the +/// repository pins, and the runtime stage carries only a JRE and the resulting JAR. +/// +internal static partial class JavaDockerfileGenerator +{ + // Kept outside /app so a build that writes into its own working directory cannot move the JAR + // somewhere COPY --from does not look. + private const string ContainerArtifactPath = "/build/app.jar"; + + // Quarkus's fast JAR is a directory of interdependent parts rather than a single file, so it is staged + // as a directory and copied into /app whole. + private const string ContainerArtifactDirectory = "/build/app"; + + /// + /// Where a build-produced OpenTelemetry agent lands in the runtime image. Fixed rather than mirroring + /// the source layout so the entrypoint environment does not depend on the build tool's output paths. + /// + internal const string ContainerAgentPath = "/app/agent.jar"; + + /// + /// Build-script fragments that mean the produced artifact depends on the architecture of the machine + /// that built it. + /// + /// + /// os-maven-plugin and Gradle's osdetector both publish the detected host as + /// ${os.detected.classifier}, which is how netty-tcnative, protobuf and gRPC pick their native + /// artifact; ${os.arch} is the same idea spelled with a JVM system property; and GraalVM's + /// native-image plugins compile to a host-architecture executable. + /// See https://github.com/trustin/os-maven-plugin and https://github.com/google/osdetector-gradle-plugin. + /// + private static readonly string[] s_hostArchitectureMarkers = + [ + "os-maven-plugin", + "os.detected.", + "osdetector", + "os.arch", + "org.graalvm.buildtools", + "native-maven-plugin", + ]; + + // Without this, target/ and build/ — routinely hundreds of megabytes after a local build — are + // uploaded to the daemon and copied into the image by `COPY . .`. Multi-module projects put one next + // to every module, hence the recursive patterns. + // See https://docs.docker.com/build/concepts/context/#filename-and-location. + private const string DefaultJavaBuildContextIgnoreContent = """ + # Generated by Aspire. Author /.dockerignore to take over these rules. + target + **/target + build + **/build + .gradle + **/.gradle + .git + .gitignore + .DS_Store + .env + .env.* + .aspire + aspire-output + Dockerfile + Dockerfile.* + *.Dockerfile + *.Dockerfile.dockerignore + .dockerignore + + """; + + /// + /// Determines which build tool the container image uses to produce the deployable JAR, and with which + /// arguments. + /// + /// + /// Exposed separately from so the resolution rules — including the failure when no + /// build tool can be found — can be exercised without running a publish pipeline. + /// + /// No build tool is configured and none can be found on disk. + internal static (JavaBuildTool Tool, string[] Args) ResolveBuildTool(JavaAppResource resource, string appDirectory) + => JavaContainerBuild.ResolveToolAndArgs(resource, appDirectory); + + /// + /// Resolves the full container build, including wrapper selection, without running a publish pipeline. + /// + /// + /// The publish pipeline reports a failure by throwing while reading a Dockerfile that was never + /// written, which hides the message that explains what to fix, so the rejections are asserted here. + /// + internal static void ResolveContainerBuildForTesting(JavaAppResource resource, string appDirectory) + => JavaContainerBuild.Resolve(resource, appDirectory); + + public static void Write(JavaAppResource resource, string appDirectory, DockerfileBuilderCallbackContext context) + { + var logger = context.Services.GetService>(); + + // An application added with a prebuilt JAR and no build configuration has nothing to build: the + // artifact already exists in the context, so the image just carries it. Requiring a build tool here + // would make a runnable application unpublishable. + var prebuiltJar = TryGetPrebuiltJarPath(resource, appDirectory, out var jarPath) ? jarPath : null; + + // A .dockerignore replaces the context root's .dockerignore rather than merging with + // it, so an authored one wins outright. + if (context.Resource.TryGetLastAnnotation(out var dockerfileBuildAnnotation) + && !File.Exists(Path.Combine(appDirectory, ".dockerignore"))) + { + dockerfileBuildAnnotation.BuildContextIgnoreContent ??= BuildContextIgnoreContent(resource, prebuiltJar); + } + + var build = prebuiltJar is null ? JavaContainerBuild.Resolve(resource, appDirectory) : null; + var javaVersion = JavaVersionDetector.Detect(appDirectory, build?.Tool); + + // ctx.Resource is the ContainerResource PublishAsDockerFile substitutes in, but it shares the + // original JavaAppResource's annotation collection, which is why WithDockerfileBaseImage authored + // on the Java resource is visible from here. + context.Resource.TryGetLastAnnotation(out var baseImageAnnotation); + // A plain JDK image is always enough because a wrapper is required: the wrapper downloads the exact + // tool version the project pins, so nothing has to come from the image. That also keeps the build + // stage off the maven/gradle images, whose tags only exist for a subset of JDK releases and which + // would otherwise pin a second, unrelated tool version. + var buildImage = baseImageAnnotation?.BuildImage ?? $"docker.io/library/eclipse-temurin:{BuildJdkVersion(javaVersion, build)}-jdk"; + var runtimeImage = baseImageAnnotation?.RuntimeImage ?? $"docker.io/library/eclipse-temurin:{javaVersion}-jre"; + + if (build is not null) + { + WriteBuildStage(context, build, buildImage); + } + + // Add intermediate FROM stages for any container files sources (e.g. FROM frontend AS frontend_stage). + context.Builder.AddContainerFilesStages(context.Resource, logger); + + var runtimeStage = context.Builder.From(runtimeImage); + + runtimeStage + .WorkDir("/app") + // Add COPY --from= instructions for each container files source. + .AddContainerFiles(context.Resource, "/app", logger); + + // Quarkus's fast JAR is a directory whose parts reference each other by relative path, so the whole + // staged directory is copied into /app and the entry point names the runnable JAR inside it. + var applicationJarPath = build?.ArtifactIsDirectory == true + ? $"/app/{JavaHostingExtensions.QuarkusRunJarName}" + : "/app/app.jar"; + + // Everything under /app has to be readable by the unprivileged runtime user. That is not automatic: + // Quarkus's fast JAR stages its dependencies by copying them out of the Maven/Gradle cache, and the + // cache is a BuildKit cache mount whose files are mode 600 and owned by root. Copying those through + // unchanged produces an image that cannot start, because the JVM cannot read lib/boot: + // + // Error: Could not find or load main class io.quarkus.bootstrap.runner.QuarkusEntryPoint + // + // COPY --chown assigns ownership as the layer is written, so the 600 modes still grant the app user + // access, and it costs no extra layer. + // + // A numeric UID/GID is used rather than a named account created with groupadd/useradd, because the + // runtime image is overridable. Distros disagree on the tools (busybox adduser on Alpine takes + // different switches from shadow's useradd on Debian) and distroless images have neither, so any + // RUN that creates a user only works for the images it was written against. USER accepts a numeric + // id whether or not /etc/passwd names it, which works on every image including distroless. + const string RuntimeUser = "999:999"; + + if (prebuiltJar is null) + { + runtimeStage.CopyFrom( + "build", + build?.ArtifactIsDirectory == true ? ContainerArtifactDirectory : ContainerArtifactPath, + build?.ArtifactIsDirectory == true ? "/app" : "/app/app.jar", + RuntimeUser); + } + else + { + runtimeStage.Copy(prebuiltJar, "/app/app.jar", RuntimeUser); + } + + // A relative agent path names a file the build produced, so it only exists in the build stage. + // Carry it into the runtime image; the matching JAVA_TOOL_OPTIONS value is written by + // WithOtelAgent, which points at ContainerAgentPath in publish mode. + if (TryGetBuildProducedAgentPath(resource, out var agentPath)) + { + if (prebuiltJar is null) + { + runtimeStage.CopyFrom("build", $"/app/{agentPath}", ContainerAgentPath, RuntimeUser); + } + else + { + // No build stage exists, so the agent has to already be in the context alongside the JAR. + runtimeStage.Copy(agentPath, ContainerAgentPath, RuntimeUser); + } + } + + runtimeStage + .User(RuntimeUser) + // No shell form: with an ENTRYPOINT array the JVM is PID 1 and receives SIGTERM directly, so + // Spring's shutdown hooks run instead of the container being killed after the stop timeout. + .Entrypoint(["java", "-jar", applicationJarPath]); + } + + private static void WriteBuildStage(DockerfileBuilderCallbackContext context, JavaContainerBuild build, string buildImage) + { + // A JAR is normally architecture-neutral, so the build runs natively even when the image targets + // another architecture. There is nothing to gain from emulating a portable build, and a great deal + // to lose: cross-building an amd64 image on an arm64 machine runs Maven under QEMU, whose syscall + // translation is incomplete enough that the Maven wrapper cannot even unpack itself. + // + // tar: apache-maven-3.9.9/lib/maven-artifact-3.9.9.jar: Cannot open: Function not implemented + // + // Only the runtime stage inherits the requested platform, which is where it normally matters. + // A project that selects dependencies by the build machine's architecture is the exception: its + // JAR is not portable, so it has to be built on the platform it will run on even though that is + // slower and, when emulated, may not work at all. A broken build is easier to diagnose than an + // image that starts and then fails on the first call into a native library. + // https://docs.docker.com/build/building/multi-platform/#cross-compilation + var fromArguments = build.BuildOnTargetPlatform ? buildImage : $"--platform=$BUILDPLATFORM {buildImage}"; + + var buildStage = context.Builder + .From(fromArguments, "build") + .WorkDir("/app"); + + if (build.CacheHomeVariable is { } cacheHomeVariable) + { + // Pinned rather than inherited: the cache mount targets a fixed path, and the official tool + // images point their cache elsewhere (the gradle image defaults GRADLE_USER_HOME to + // /home/gradle/.gradle), which would leave the mount unused and re-download on every build. + buildStage.Env(cacheHomeVariable, build.ToolHome); + } + + // Copied ahead of the sources and unpacked in a layer of its own. The build tool is a fixed input + // that only changes when the wrapper does, so this layer survives every source change, while the + // build layer below is invalidated by any file in the context. + buildStage.Copy(build.WrapperPath, $"./{build.WrapperPath}"); + buildStage.Copy(build.WrapperSupportPath, $"./{build.WrapperSupportPath}"); + + if (build.RequiresUnzip) + { + // Both package managers are attempted because the base image is replaceable, and this runs + // before the wrapper so the failure it prevents cannot happen first. + buildStage.Run( + "if ! command -v unzip >/dev/null 2>&1; then " + + "(apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/*) " + + "|| apk add --no-cache unzip; fi"); + } + + buildStage.Run(build.WarmToolCommand); + + buildStage.Copy(".", "."); + + buildStage.RunWithMounts( + // The artifact is staged outside /app so a build that writes into its own working directory + // cannot move it somewhere COPY --from does not look. + $"mkdir -p /build && {build.BuildCommand} && {build.SelectArtifactCommand}", + // Both tools resolve dependencies from the network on a cold cache. A BuildKit cache mount + // keeps the local repository across builds without baking it into a layer. Only the + // dependency directory is mounted, so the wrapper's copy of the build tool stays on the + // container filesystem where a damaged cache cannot corrupt it. + // Locked because concurrent builds of sibling modules share one repository directory and + // Maven's local repository is not safe for concurrent writers. + // See https://maven.apache.org/guides/mini/guide-multiple-repositories.html + $"type=cache,id={build.CacheId},target={build.CacheTarget},sharing=locked"); + } + + /// + /// Gets the JAR to publish directly, for an application that runs a prebuilt JAR and configures no build. + /// + /// + /// A JAR path alone does not mean the artifact is prebuilt: it is also how a Maven or Gradle application + /// names the JAR its own build produces, and those still have to be built in the image. This is therefore + /// limited to the case where nothing at all describes a build — no build step, no launch tool, and no + /// build file in the directory. + /// + /// The JAR is outside the build context. + internal static bool TryGetPrebuiltJarPath(JavaAppResource resource, string appDirectory, [NotNullWhen(true)] out string? jarPath) + { + jarPath = null; + + if (!resource.TryGetLastAnnotation(out var annotation) + || resource.HasAnnotationOfType() + || resource.HasAnnotationOfType() + || resource.HasAnnotationOfType() + || DetectBuildToolForPublish(resource, appDirectory) is not null) + { + return false; + } + + // Container paths are POSIX even when the AppHost authored a Windows-style relative path. + // Only a single leading "./" is stripped; trimming every leading '.' and '/' would turn + // "../outside.jar" into "outside.jar", erasing the traversal before it could be detected and + // silently publishing a COPY of the wrong file. + jarPath = NormalizeContextRelativePath(annotation.JarPath, resource.Name, appDirectory, "its JAR"); + + return jarPath.Length > 0; + } + + /// + /// The JAR path the AppHost named, when it can be used to select the artifact the build produced. + /// + /// + /// answers a different question: whether the JAR exists before the + /// image is built. This one is about a JAR the image's own build produces, where the path is still the + /// author's explicit statement of which artifact matters. Without it a project whose build emits more + /// than one JAR — a shade plugin leaves original-*.jar beside the shaded one — fails the glob's + /// "expected exactly one" check even though the AppHost already named the file. + /// + /// A path that reaches outside the build directory is rejected rather than ignored. Falling back to the + /// glob would publish whichever JAR the build happened to emit, which is not the one the AppHost named, + /// and the divergence from run mode would be silent — the image would start and serve the wrong code. + /// + /// + /// Whitespace is allowed, unlike . That method feeds a + /// Dockerfile COPY, which splits its arguments on whitespace and has no quoting form. This one + /// feeds a RUN shell command, where SelectNamedJarCommand quotes the path. + /// + /// + internal static bool TryGetBuildOutputJarPath(JavaAppResource resource, [NotNullWhen(true)] out string? jarPath) + { + jarPath = null; + + if (!resource.TryGetLastAnnotation(out var annotation)) + { + return false; + } + + // Container paths are POSIX even when the AppHost authored a Windows-style relative path. + var normalized = annotation.JarPath.Replace('\\', '/'); + + if (normalized.StartsWith("./", StringComparison.Ordinal)) + { + normalized = normalized[2..]; + } + + if (normalized.Length == 0) + { + return false; + } + + if (IsPathRootedOnAnyPlatform(annotation.JarPath) || normalized.Split('/').Contains("..")) + { + throw new DistributedApplicationException( + $"Java application '{resource.Name}' cannot be published because its jarPath " + + $"'{annotation.JarPath}' is outside the directory the build runs in. The path is resolved " + + "against the application directory inside the container, so it has to name a file the " + + "build produces there. Pass a jarPath relative to the application directory, or use " + + "WithJarArtifact to name the published artifact separately from the one run locally."); + } + + jarPath = normalized; + + return true; + } + + /// + /// Normalizes an authored path for use inside the container build, rejecting anything that would + /// reach outside the build context. + /// + /// + /// The build context is the application directory, so only files under it are uploaded to the daemon. + /// A rooted path or one containing a .. segment names something that is not in the image, and + /// emitting it anyway fails the build with a path the author never wrote, or silently selects a + /// different in-context file. + /// + /// Only a single leading ./ is stripped. Trimming . and / as a character set would + /// turn ../outside.jar into outside.jar, erasing the traversal before it could be + /// detected. + /// + /// + /// Whitespace is rejected for the same reason a wrapper path is: a Dockerfile COPY separates its arguments on whitespace and the builder emits the + /// shell form, so target/my app.jar becomes three arguments and copies two paths that do not + /// exist. Naming the problem here beats failing inside the container build on a path nobody wrote. + /// + /// + /// The path reaches outside the build context or contains whitespace. + /// + /// Whether the authored path is absolute under either platform's rules. + /// + /// + /// applies only the rules of the host it runs on, so an AppHost + /// authored on Windows with C:\artifacts\app.jar looks relative when that same AppHost is + /// published from Linux CI. Publishing then rewrites the backslashes for the container and accepts the + /// result as if it named a file the build produced, so the image is built against a path that cannot + /// exist instead of the author being told the path is outside the build context. Publishing has to + /// reach the same verdict wherever it runs, so both forms are rejected on both platforms. + /// + /// A leading backslash covers Windows root-relative and UNC paths, and a drive qualifier is matched + /// with or without a following separator because C:app.jar is drive-relative rather than + /// context-relative. The drive test would also match a Unix directory named with a single letter and a + /// colon, which is not a name any build tool produces. + /// + /// + internal static bool IsPathRootedOnAnyPlatform(string path) + => Path.IsPathRooted(path) + || path.StartsWith('/') + || path.StartsWith('\\') + || IsWindowsRooted(path); + + /// + /// Detected without so the answer is the same on every + /// platform: a Windows AppHost publishing to a Linux image has to fail the same way a Linux one + /// does, rather than only when the build happens to run on Windows. + /// + private static bool IsWindowsRooted(string path) + => path.StartsWith('\\') + || (path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':'); + + private static string NormalizeContextRelativePath(string authored, string resourceName, string appDirectory, string description) + { + // Container paths are POSIX even when the AppHost authored a Windows-style relative path. + var normalized = authored.Replace('\\', '/'); + + if (normalized.StartsWith("./", StringComparison.Ordinal)) + { + normalized = normalized[2..]; + } + + if (IsPathRootedOnAnyPlatform(authored) || normalized.Split('/').Contains("..")) + { + throw new DistributedApplicationException( + $"Java application '{resourceName}' cannot be published because {description} '{authored}' " + + $"is outside the build context '{appDirectory}'. Only files under the application " + + "directory are uploaded to the container build."); + } + + if (normalized.Any(char.IsWhiteSpace)) + { + throw new DistributedApplicationException( + $"Java application '{resourceName}' cannot be published because {description} '{authored}' " + + "contains whitespace, which a Dockerfile COPY instruction cannot express. Move it to a " + + "path without spaces."); + } + + return normalized; + } + + private static JavaBuildTool? DetectBuildToolForPublish(JavaAppResource resource, string appDirectory) + => JavaBuildToolResolver.Detect( + appDirectory, + resource.Name, + static message => new DistributedApplicationException(message)); + + /// + /// The JDK the build stage runs on, which is not necessarily the JDK the application targets. + /// + /// + /// The build tool itself needs a JDK new enough to run it, independently of the bytecode the project + /// produces: Gradle 9 and Maven 4 refuse to start on anything below Java 17. A project targeting Java 8 + /// or 11 would otherwise get an eclipse-temurin:8-jdk build stage where the wrapper dies with + /// "Unsupported class file major version" before compiling anything. + /// + /// The requirement is read from the version the wrapper pins rather than applied to every build, + /// because it also runs the other way: Gradle releases before 7.3 cannot run on Java 17, and + /// those are exactly the wrappers an old Java 8 project tends to carry. A build whose tool version + /// cannot be determined keeps the targeted version. + /// + /// + /// Compiling for the older target still works, because that is what --release and + /// maven.compiler.release are for, and JDK 17's javac still supports targets back to 7. Only the + /// build stage is raised; the runtime stage stays on the targeted version so the image is no larger and + /// no newer than the application actually needs. + /// + /// + /// The ceiling is applied the same way, in the other direction. Capping at the newest JDK the tool + /// can run on is never worse than ignoring it: a project whose build actually resolves the target + /// another way — a Gradle toolchain the build downloads or already has — now builds where it + /// previously died on startup with "Unsupported class file major version", and one that genuinely + /// needs the newer javac fails with "release version N not supported", which names the real problem. + /// + /// + private static string BuildJdkVersion(string targetVersion, JavaContainerBuild? build) + { + if (build is null || !int.TryParse(targetVersion, CultureInfo.InvariantCulture, out var target)) + { + return targetVersion; + } + + if (target < build.MinimumBuildJdk) + { + return build.MinimumBuildJdk.ToString(CultureInfo.InvariantCulture); + } + + return build.MaximumBuildJdk > 0 && target > build.MaximumBuildJdk + ? build.MaximumBuildJdk.ToString(CultureInfo.InvariantCulture) + : targetVersion; + } + + /// + /// The default .dockerignore content, with exceptions for files the image needs from the context. + /// + /// + /// The defaults exclude target and build because they are routinely hundreds of megabytes + /// of build output. A prebuilt JAR normally sits in exactly those directories, so publishing it needs an + /// exception; without one the COPY fails with "file not found in build context" even though the file is + /// plainly there. Exceptions have to follow the exclusion they re-include. + /// See https://docs.docker.com/build/concepts/context/#dockerignore-files. + /// + private static string BuildContextIgnoreContent(JavaAppResource resource, string? prebuiltJarPath) + { + if (prebuiltJarPath is null) + { + return DefaultJavaBuildContextIgnoreContent; + } + + var exceptions = new List { prebuiltJarPath }; + + if (TryGetBuildProducedAgentPath(resource, out var agentPath)) + { + exceptions.Add(agentPath); + } + + // Each parent directory has to be re-included too: Docker does not descend into a directory it has + // already excluded, so "!target/app.jar" alone never matches when "target" itself is excluded. + var reincluded = exceptions + .SelectMany(ParentPathsAndSelf) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .Select(path => $"!{path}"); + + return DefaultJavaBuildContextIgnoreContent + string.Join('\n', reincluded) + "\n"; + } + + private static IEnumerable ParentPathsAndSelf(string path) + { + var segments = path.Split('/'); + + for (var i = 1; i <= segments.Length; i++) + { + yield return string.Join('/', segments.Take(i)); + } + } + + /// + /// Gets the OpenTelemetry agent path when it names a file the build produces inside the context. + /// + /// + /// An absolute path is left alone. It cannot have come out of the build context, so it has to be + /// supplied by the base image or a mount, and rewriting it would break that arrangement. + /// + internal static bool TryGetBuildProducedAgentPath(JavaAppResource resource, [NotNullWhen(true)] out string? agentPath) + { + agentPath = null; + + if (!resource.TryGetLastAnnotation(out var annotation)) + { + return false; + } + + var authored = JavaHostingExtensions.ResolveOtelAgentPath(resource, annotation); + + if (IsPathRootedOnAnyPlatform(authored)) + { + // A POSIX absolute path is a legitimate arrangement: the base image or a mount provides the + // agent, and rewriting it would break that. A Windows-rooted path cannot be, because the + // image the AppHost publishes to is Linux. Leaving it alone puts "-javaagent:C:\..." into + // JAVA_TOOL_OPTIONS, and the JVM then dies during VM initialization with an error that + // names the agent but not the reason. The jar artifact and the wrapper already reject + // Windows-rooted paths on every platform; this keeps the agent consistent with them. + if (IsWindowsRooted(authored)) + { + throw new DistributedApplicationException( + $"Java application '{resource.Name}' cannot be published because the OpenTelemetry agent " + + $"path '{authored}' is a Windows path, which cannot resolve inside the Linux image the " + + "application is published to. Use a path inside the application directory so it is copied " + + "into the image, or an absolute path the base image or a mount provides at runtime."); + } + + return false; + } + + // Container paths are POSIX even when the AppHost authored a Windows-style relative path. + var normalized = authored.Replace('\\', '/'); + + // Strip a single leading "./" only. Trimming the '.' and '/' characters as a set would turn + // "../agents/otel.jar" into "agents/otel.jar" and emit a COPY for a path that was never in the + // build context, failing the container build with a path the author never wrote. + if (normalized.StartsWith("./", StringComparison.Ordinal)) + { + normalized = normalized[2..]; + } + + // The Docker build context is the application directory, so a path that walks above it cannot be + // copied forward no matter how it is spelled. Say so instead of silently rewriting it. + if (normalized.Split('/').Any(segment => segment == "..")) + { + throw new DistributedApplicationException( + $"The OpenTelemetry agent path '{authored}' configured on resource '{resource.Name}' " + + $"points outside the application directory, which is the Docker build context, so it cannot be " + + $"published. Use a path inside '{resource.WorkingDirectory}', or an absolute path that the base " + + $"image or a mount provides at runtime."); + } + + // The Dockerfile builder emits the shell form of COPY, whose arguments are separated by + // whitespace with no quoted form, so "target/otel agents/javaagent.jar" would copy two paths that + // do not exist. Naming the problem beats failing later inside the container build. + if (normalized.Any(char.IsWhiteSpace)) + { + throw new DistributedApplicationException( + $"The OpenTelemetry agent path '{authored}' configured on resource '{resource.Name}' " + + $"contains whitespace, which a Dockerfile COPY instruction cannot express, so it cannot be " + + $"published. Move the agent to a path without spaces."); + } + + agentPath = normalized; + + return agentPath.Length > 0; + } + + /// + /// The build-tool-specific pieces of the container build. + /// + /// The build tool that produces the JAR. + /// The shell command that runs the build. + /// The shell command that copies the produced JAR to a fixed path. + /// The container path the build tool treats as its home directory. + /// The container path holding the tool's dependency cache, below . + /// The environment variable that pins , if the tool has one. + /// The BuildKit cache identity, scoped per tool and resource. + /// The wrapper script, relative to the build context. + /// The wrapper's support directory, relative to the build context. + /// The shell command that makes the wrapper download and unpack the build tool. + /// Whether the build image needs unzip installed before the wrapper runs. + /// Whether the staged artifact is a directory rather than a single JAR. + /// The JDK release the pinned build tool needs to start, or 0 when unknown. + /// The newest JDK release the pinned build tool can run on, or 0 when unknown. + /// Whether the build produces an architecture-specific artifact and so cannot run on the build machine's platform. + /// + /// Internal rather than private so tests can exercise the whole resolution directly. Failures raised + /// during publishing surface as a missing Dockerfile once the pipeline has swallowed them, which hides + /// the message being asserted. + /// + internal sealed record JavaContainerBuild( + JavaBuildTool Tool, + string BuildCommand, + string SelectArtifactCommand, + string ToolHome, + string CacheTarget, + string? CacheHomeVariable, + string CacheId, + string WrapperPath, + string WrapperSupportPath, + string WarmToolCommand, + bool RequiresUnzip, + bool ArtifactIsDirectory, + int MinimumBuildJdk, + int MaximumBuildJdk, + bool BuildOnTargetPlatform) + { + + public static JavaContainerBuild Resolve(JavaAppResource resource, string appDirectory) + { + var (tool, buildArgs) = ResolveToolAndArgs(resource, appDirectory); + var wrapper = ResolveWrapperForContext(resource, appDirectory, tool); + + // A wrapper checked out from a Windows clone can arrive without the executable bit, and Git + // does not record one on Windows at all. Invoking the interpreter directly sidesteps that + // rather than failing with "permission denied" deep inside the container build. + // + // Every argument is quoted because these values reach a container build as a shell command. + // A version pinned with -Dspring.profiles.active='a b' or any value containing $ or ; would + // otherwise be re-split or expanded by the shell, so the image would build differently from + // the identical arguments used on the host, where they are passed as separate argv entries. + // The wrapper path is quoted for the same reason: WithWrapperPath accepts any path, and an + // unquoted one containing a shell metacharacter would invoke something other than the wrapper. + var quotedWrapper = ShellQuoteIfNeeded($"./{wrapper}"); + + // A Windows checkout without a .gitattributes rule for mvnw/gradlew leaves CRLF line endings in + // them. Both are POSIX scripts built around `case` statements, and `sh` rejects those with + // "Syntax error: word unexpected (expecting \"in\")" - a message that says nothing about line + // endings and appears halfway through a container build. Stripping the carriage returns in the + // image is idempotent, costs nothing when they are already absent, and leaves the developer's + // working tree untouched. + var invocation = $"sed -i 's/\\r$//' {quotedWrapper} && sh {quotedWrapper}"; + + var buildCommand = $"{invocation} {string.Join(' ', buildArgs.Select(ShellQuoteIfNeeded))}"; + + var (outputGlob, toolHome, cacheSubdirectory, cacheHomeVariable, supportDirectoryName, warmArgs) = tool switch + { + // Maven resolves its local repository from the home directory and offers no variable that + // relocates it, so the mount targets root's default and the build stage runs as root. + JavaBuildTool.Maven => ("target/*.jar", "/root/.m2", "repository", (string?)null, ".mvn", "-B -ntp --version"), + JavaBuildTool.Gradle => ("build/libs/*.jar", "/root/.gradle", "caches", "GRADLE_USER_HOME", "gradle", "--no-daemon --version"), + _ => throw new UnreachableException() + }; + + var wrapperSupportPath = ResolveWrapperSupportPath(resource, appDirectory, tool, wrapper, supportDirectoryName); + + var isQuarkus = resource.HasAnnotationOfType(); + + var outputDirectory = tool is JavaBuildTool.Gradle ? "build" : "target"; + + var requiresUnzip = tool is JavaBuildTool.Maven + && MavenWrapperPinsADistributionChecksum(appDirectory, wrapperSupportPath); + + var selectArtifact = resource.TryGetLastAnnotation(out var artifact) + ? $"cp {ShellQuote(NormalizeContextRelativePath(artifact.RelativePath, resource.Name, appDirectory, "its JAR artifact"))} {ContainerArtifactPath}" + : isQuarkus + // Quarkus is decided before the JAR path because its fast-jar layout needs the whole + // target/quarkus-app directory; copying only the runner it names produces an image + // that starts and immediately dies on a missing lib directory. + ? SelectQuarkusArtifactCommand(outputDirectory, outputGlob, resource.Name) + : TryGetBuildOutputJarPath(resource, out var namedJar) + ? SelectNamedJarCommand(namedJar, ContainerArtifactPath, resource.Name) + : SelectSingleJarCommand(outputGlob, ContainerArtifactPath, resource.Name); + + // An explicit WithJarArtifact names a single file, so it stages as one even for Quarkus - which + // is how an application packaged as an uber JAR names its runner. + var artifactIsDirectory = isQuarkus && !resource.HasAnnotationOfType(); + + var bounds = ResolveBuildJdkBounds(appDirectory, wrapperSupportPath, tool); + + return new JavaContainerBuild( + tool, + buildCommand, + selectArtifact, + toolHome, + // Only the dependency cache is mounted, never the whole tool home. The wrapper downloads + // and extracts the build tool itself into /wrapper/dists, and a distribution + // left half-extracted there by an interrupted build would be reused by every later build, + // which fails while untarring over it and cannot be recovered without knowing to run + // `docker builder prune --filter type=exec.cachemount`. Keeping the distribution on the + // container filesystem means a damaged cache can only cost a re-download of dependencies. + CacheTarget: $"{toolHome}/{cacheSubdirectory}", + cacheHomeVariable, + // Scoped per resource so two Java applications built concurrently do not contend on one + // locked mount, and per tool because the two caches have different layouts. + CacheId: $"aspire-java-{tool.ToString().ToLowerInvariant()}-{resource.Name.ToLowerInvariant()}", + WrapperPath: wrapper, + WrapperSupportPath: wrapperSupportPath, + WarmToolCommand: $"{invocation} {warmArgs}", + RequiresUnzip: requiresUnzip, + ArtifactIsDirectory: artifactIsDirectory, + MinimumBuildJdk: bounds.Minimum, + MaximumBuildJdk: bounds.Maximum, + BuildOnTargetPlatform: SelectsDependenciesByBuildArchitecture(appDirectory, tool)); + } + + /// + /// Whether the project picks dependencies or produces artifacts based on the architecture of the + /// machine running the build, which makes its output architecture-specific. + /// + /// + /// The usual JAR is bytecode and runs anywhere, but a project can package a native library chosen + /// from the build machine: os-maven-plugin and Gradle's osdetector expose the host + /// as ${os.detected.classifier}, which projects using netty-tcnative, protobuf or gRPC pass + /// as a dependency classifier, and GraalVM's native-image plugins emit a host-architecture + /// executable outright. Building such a project on the build machine and shipping the result in an + /// image for another architecture produces an image that starts and then fails on the first call + /// into the native code, so those builds run on the platform they target instead. + /// + /// This reads the build script as text rather than as XML or Groovy/Kotlin, because a match + /// anywhere - including in a comment or a profile that is not active - only costs a slower build, + /// while a miss costs a broken image. A build file that cannot be read is treated as portable, + /// which is the behaviour for every project that does none of this. + /// + /// + private static bool SelectsDependenciesByBuildArchitecture(string appDirectory, JavaBuildTool tool) + { + string[] fileNames = tool is JavaBuildTool.Gradle + ? ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"] + : ["pom.xml"]; + + foreach (var fileName in fileNames) + { + string content; + try + { + var path = Path.Combine(appDirectory, fileName); + if (!File.Exists(path)) + { + continue; + } + + content = File.ReadAllText(path); + } + catch (IOException) + { + continue; + } + + foreach (var marker in s_hostArchitectureMarkers) + { + if (content.Contains(marker, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } + + /// + /// Reads the range of JDK releases the pinned build tool can run on. + /// + /// + /// The build tool's own JVM requirement is independent of the bytecode the project produces, and it + /// bounds the build stage from both directions. + /// + /// From below: Gradle 9 and Maven 4 refuse to start on anything under Java 17, so a project + /// targeting Java 8 cannot build on an eclipse-temurin:8-jdk stage. The requirement is read + /// from the pinned version rather than applied to every build, because Gradle 6 refuses to run + /// on Java 17 and those are exactly the wrappers an old Java 8 project tends to carry. + /// + /// + /// From above: each Gradle release only runs on the JDKs that existed when it shipped. Gradle 8.4 + /// can target Java 21 through a toolchain but cannot itself run on Java 21 — that starts at 8.5 — + /// so a Java 21 project with an 8.4 wrapper would get a Java 21 build stage where Gradle dies on + /// startup. The bound follows the "Support for running Gradle" column of Gradle's compatibility + /// matrix. Maven has no equivalent ceiling, so none is modelled for it. + /// + /// + /// The version comes from the distribution the wrapper pins, for example: + /// + /// distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip + /// distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip + /// + /// Note the escaped \: that the Gradle wrapper writes, and that the Maven URL carries the + /// version twice. Gradle's ceiling moves on minor releases, so major and minor are both read. + /// + /// A URL that cannot be parsed contributes no bound at all, which leaves the target version in + /// charge — the behaviour before any of this existed. + /// See https://docs.gradle.org/current/userguide/compatibility.html and + /// https://maven.apache.org/docs/history.html. + /// + private static (int Minimum, int Maximum) ResolveBuildJdkBounds(string appDirectory, string wrapperSupportPath, JavaBuildTool tool) + { + var propertiesPath = Path.Combine( + appDirectory, + wrapperSupportPath.Replace('/', Path.DirectorySeparatorChar), + "wrapper", + $"{tool.ToString().ToLowerInvariant()}-wrapper.properties"); + + string? distributionUrl = null; + try + { + foreach (var line in File.ReadLines(propertiesPath)) + { + var trimmed = line.AsSpan().TrimStart(); + if (trimmed.StartsWith("distributionUrl", StringComparison.Ordinal)) + { + var separator = trimmed.IndexOf('='); + if (separator >= 0) + { + distributionUrl = trimmed[(separator + 1)..].Trim().ToString(); + } + + break; + } + } + } + catch (IOException) + { + return (0, 0); + } + + if (distributionUrl is null) + { + return (0, 0); + } + + var match = (tool is JavaBuildTool.Gradle ? GradleDistributionRegex() : MavenDistributionRegex()).Match(distributionUrl); + if (!match.Success || !int.TryParse(match.Groups["major"].ValueSpan, CultureInfo.InvariantCulture, out var major)) + { + return (0, 0); + } + + if (tool is JavaBuildTool.Maven) + { + return (major >= 4 ? 17 : 0, 0); + } + + // A missing minor reads as 0, which lands on the first ceiling of that major - the conservative + // direction, because a wrapper is never pinned to a bare major version in practice. + _ = int.TryParse(match.Groups["minor"].ValueSpan, CultureInfo.InvariantCulture, out var minor); + + return (major >= 9 ? 17 : 0, MaximumGradleRuntimeJdk(major, minor)); + } + + /// + /// The newest JDK release a given Gradle version can run on. + /// + /// + /// Inverted from the "Support for running Gradle" column of Gradle's compatibility matrix + /// (https://docs.gradle.org/current/userguide/compatibility.html): + /// Java 20 needs 8.3+, 21 needs 8.5+, 22 needs 8.8+, 23 needs 8.10+, 24 needs 8.14+, 25 needs + /// 9.1+, 26 needs 9.4+, 27 needs 9.8+. + /// + /// Versions newer than the last row are given that row's ceiling rather than "unbounded", so a + /// Gradle release this table has not caught up with never blocks a publish: the check that + /// consumes this only fires when the target exceeds the ceiling, and an unknown-but-newer Gradle + /// always supports at least what the last known release did. Anything older than 7.3 is left + /// unbounded because those releases predate the JDKs this can select. + /// + /// + private static int MaximumGradleRuntimeJdk(int major, int minor) => (major, minor) switch + { + (>= 10, _) => 27, + (9, >= 8) => 27, + (9, >= 4) => 26, + (9, >= 1) => 25, + (9, _) => 24, + (8, >= 14) => 24, + (8, >= 10) => 23, + (8, >= 8) => 22, + (8, >= 5) => 21, + (8, >= 3) => 20, + (8, _) => 19, + (7, >= 6) => 19, + (7, >= 5) => 18, + (7, >= 3) => 17, + _ => 0 + }; + + /// + /// Determines whether the Maven wrapper pins a checksum for the distribution it downloads. + /// + /// + /// distributionSha256Sum is the checksum of the -bin.zip named by + /// distributionUrl, but mvnw silently switches to the -bin.tar.gz of the same + /// release when unzip is not on the path, then compares that archive against the ZIP's + /// checksum and stops the build: + /// + /// Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. + /// + /// The Quarkus project generator pins this checksum by default, and the Temurin images have no + /// unzip, so without this the build fails for every Quarkus application. + /// + private static bool MavenWrapperPinsADistributionChecksum(string appDirectory, string wrapperSupportPath) + { + var propertiesPath = Path.Combine( + appDirectory, + wrapperSupportPath.Replace('/', Path.DirectorySeparatorChar), + "wrapper", + "maven-wrapper.properties"); + + // The file's presence is already validated by ResolveWrapperSupportPath, so an unreadable file + // here can only be a race with an editor. Treating that as "no checksum" keeps generation + // working and, at worst, produces the same Dockerfile as before this check existed. + try + { + foreach (var line in File.ReadLines(propertiesPath)) + { + if (line.AsSpan().TrimStart().StartsWith("distributionSha256Sum", StringComparison.Ordinal)) + { + return true; + } + } + } + catch (IOException) + { + return false; + } + + return false; + } + + /// + /// Resolves the wrapper's support directory, which holds the properties file naming the build tool + /// distribution to download. + /// + /// + /// This directory is copied into the image ahead of the application sources so the wrapper can unpack + /// the build tool in a layer of its own. Without that, the tool would be downloaded again on every + /// source change, because the single build layer is invalidated by any file in the context. + /// + /// The properties file is missing. + private static string ResolveWrapperSupportPath( + JavaAppResource resource, + string appDirectory, + JavaBuildTool tool, + string wrapper, + string supportDirectoryName) + { + // The support directory sits next to the wrapper script, so a wrapper in a subdirectory of the + // context keeps its own .mvn/gradle directory there rather than at the context root. + var wrapperDirectory = Path.GetDirectoryName(wrapper.AsSpan()); + var supportPath = wrapperDirectory.IsEmpty + ? supportDirectoryName + : $"{wrapperDirectory}/{supportDirectoryName}"; + + // Both wrappers store the distribution URL in /wrapper/-wrapper.properties. + // https://maven.apache.org/wrapper/ and https://docs.gradle.org/current/userguide/gradle_wrapper.html + var propertiesName = $"{tool.ToString().ToLowerInvariant()}-wrapper.properties"; + var propertiesPath = Path.Combine( + appDirectory, + supportPath.Replace('/', Path.DirectorySeparatorChar), + "wrapper", + propertiesName); + + if (!File.Exists(propertiesPath)) + { + throw new DistributedApplicationException( + $"Java application '{resource.Name}' cannot be published because its {wrapper} has no " + + $"'{supportPath}/wrapper/{propertiesName}'. That file pins the build tool version the " + + $"image is built with. Regenerate the wrapper with " + + $"{JavaHostingExtensions.GenerateWrapperCommand(tool)} and commit the whole " + + $"'{supportPath}' directory."); + } + + return supportPath; + } + + /// + /// Resolves the wrapper script as a path relative to the build context. + /// + /// + /// A wrapper is required rather than falling back to a mvn/gradle installed in the + /// build image: the wrapper pins the tool version in the repository, so the container image is + /// produced by the same version that built the project locally and in CI. + /// + /// The wrapper also has to sit inside the build context, because only files under the context are + /// uploaded to the daemon and reachable by COPY . .. A wrapper outside it exists on the host + /// and not in the image, so the build would fail partway through with an opaque "not found". + /// + /// + /// No wrapper is present, or the configured wrapper is outside the build context. + private static string ResolveWrapperForContext(JavaAppResource resource, string appDirectory, JavaBuildTool tool) + { + // Container builds execute on Linux even when publish runs on Windows. Passing that platform + // to the shared resolver keeps the naming rule identical to run mode without selecting a batch + // script that the build stage cannot execute. + var resolvedWrapperPath = JavaBuildToolResolver.ResolveWrapperPath(resource, tool, isWindows: false); + var isConfigured = resource.HasAnnotationOfType(); + var relative = Path.GetRelativePath(appDirectory, resolvedWrapperPath).Replace('\\', '/'); + + if (relative.StartsWith("../", StringComparison.Ordinal) || IsPathRootedOnAnyPlatform(relative)) + { + throw new DistributedApplicationException( + $"Java application '{resource.Name}' cannot be published because its wrapper " + + $"'{resolvedWrapperPath}' is outside the build context '{appDirectory}'. " + + "Move the wrapper into the application directory, or set the build context to a " + + "directory that contains both."); + } + + if (!File.Exists(resolvedWrapperPath)) + { + if (isConfigured) + { + throw new DistributedApplicationException( + $"Java application '{resource.Name}' cannot be published because the wrapper " + + $"configured with WithWrapperPath was not found at '{resolvedWrapperPath}'."); + } + + var defaultWrapperName = JavaBuildToolResolver.GetDefaultWrapperName(tool, isWindows: false); + throw new DistributedApplicationException( + $"Java application '{resource.Name}' cannot be published because there is no " + + $"{defaultWrapperName} in '{appDirectory}'. Aspire builds the image with the project's " + + $"own wrapper so the container uses the tool version the repository pins. Generate one " + + $"with {JavaHostingExtensions.GenerateWrapperCommand(tool)}, or point at an existing " + + "wrapper with WithWrapperPath."); + } + + // The build stage is Linux, so a Windows batch wrapper cannot run there even though it is + // the right choice on the developer's machine. Maven and Gradle ship the POSIX script + // alongside the batch one under the same base name, so prefer that sibling and only fail + // when it is genuinely absent. + // https://maven.apache.org/wrapper/ and https://docs.gradle.org/current/userguide/gradle_wrapper.html + if (Path.GetExtension(relative) is ".cmd" or ".bat") + { + var posixSibling = relative[..^Path.GetExtension(relative).Length]; + + if (!File.Exists(Path.Combine(appDirectory, posixSibling.Replace('/', Path.DirectorySeparatorChar)))) + { + throw new DistributedApplicationException( + $"Java application '{resource.Name}' cannot be published because its wrapper " + + $"'{relative}' is a Windows batch script and the container build stage is Linux. " + + $"No '{posixSibling}' was found next to it. Generate the wrapper with " + + $"{JavaHostingExtensions.GenerateWrapperCommand(tool)} so both scripts are present."); + } + + relative = posixSibling; + } + + // A Dockerfile COPY takes its arguments separated by whitespace and has no quoted form here, + // so a wrapper path containing whitespace would copy two nonexistent paths instead of one real + // one. Rejecting it names the problem, rather than failing later inside the build with "no such + // file or directory" for a path the author never wrote. + if (relative.Any(char.IsWhiteSpace)) + { + throw new DistributedApplicationException( + $"Java application '{resource.Name}' cannot be published because its wrapper path " + + $"'{relative}' contains whitespace, which a Dockerfile COPY instruction cannot " + + "express. Move the wrapper to a path without spaces."); + } + + return relative; + } + + internal static (JavaBuildTool Tool, string[] Args) ResolveToolAndArgs(JavaAppResource resource, string appDirectory) + { + var (tool, args) = ResolveConfiguredToolAndArgs(resource, appDirectory); + + return (tool, WithoutGradleDaemon(tool, args)); + } + + /// + /// Adds --no-daemon to a Gradle invocation that does not already carry it. + /// + /// + /// The Gradle daemon outlives the RUN instruction's shell and is then killed with the + /// layer, so inside a container build it only adds startup cost and holds memory the build could + /// use. Run mode is deliberately left alone: there the daemon survives between builds and is what + /// makes an incremental rebuild fast. + /// + /// Applied here rather than in each argument list so it also covers arguments the author supplied + /// through WithGradleBuild and the ones the Spring Boot and Quarkus defaults contribute. + /// + /// + private static string[] WithoutGradleDaemon(JavaBuildTool tool, string[] args) + { + if (tool is not JavaBuildTool.Gradle + || args.Contains("--no-daemon", StringComparer.Ordinal) + // An author who asked for the daemon is not overridden; Gradle takes the last flag to win, + // so appending --no-daemon would silently reverse an explicit choice. + || args.Contains("--daemon", StringComparer.Ordinal)) + { + return args; + } + + return ["--no-daemon", .. args]; + } + + private static (JavaBuildTool Tool, string[] Args) ResolveConfiguredToolAndArgs(JavaAppResource resource, string appDirectory) + { + // A build step configured with WithMavenBuild/WithGradleBuild states both the tool and the + // arguments that produce a deployable artifact, so it is the most precise source. + if (resource.TryGetLastAnnotation(out var buildStep)) + { + return (buildStep.Tool, buildStep.Args); + } + + if (resource.TryGetLastAnnotation(out var detected)) + { + var tool = resource.TryGetLastAnnotation(out var launch) + ? launch.Tool + : DetectBuildToolForPublish(resource, appDirectory) + ?? throw new DistributedApplicationException( + $"The Java application '{resource.Name}' cannot be published because no build tool was found. " + + $"Add a pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts to '{appDirectory}'."); + + return (tool, detected.GetConfiguration(tool).BuildArgs); + } + + // A launch goal such as spring-boot:run or bootRun identifies the tool but never packages, so + // only the tool is taken from it and the packaging arguments are defaulted. + if (resource.TryGetLastAnnotation(out var buildTool)) + { + return (buildTool.Tool, DefaultPackageArgs(buildTool.Tool)); + } + + // Left for an application added with a prebuilt JAR path and no build configuration: the + // container still has to produce that JAR, so the tool comes from what is on disk. This uses + // the same detector as run mode so publish cannot silently choose Maven for an ambiguous + // directory that run mode rejects. + if (DetectBuildToolForPublish(resource, appDirectory) is { } detectedTool) + { + return (detectedTool, DefaultPackageArgs(detectedTool)); + } + + throw new DistributedApplicationException( + $"The Java application '{resource.Name}' cannot be published because no build tool was found. " + + $"Add a pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts to '{appDirectory}', " + + "or call WithMavenBuild or WithGradleBuild " + + "to state how the deployable JAR is produced."); + } + + private static string[] DefaultPackageArgs(JavaBuildTool tool) => tool switch + { + // -B disables the ANSI progress output that renders as noise in a build log, and -ntp drops + // the per-artifact download lines. Tests are skipped because the container build produces a + // deployable artifact; running the test suite belongs to CI, not to `aspire publish`. + JavaBuildTool.Maven => ["-B", "-ntp", "-DskipTests", "package"], + JavaBuildTool.Gradle => ["-x", "test", "build"], + _ => throw new UnreachableException() + }; + + /// + /// Emits a shell command that resolves to exactly one JAR and copies + /// it to a fixed path. + /// + /// + /// The artifact name is only known after the build, and both tools emit more than one JAR in the + /// common case: the Spring Boot plugin writes the executable app-0.0.1-SNAPSHOT.jar next to + /// the base plugin's app-0.0.1-SNAPSHOT-plain.jar, and -sources/-javadoc + /// artifacts appear as soon as those plugins are enabled. Those suffixes are filtered out, and + /// anything still ambiguous fails the build with an actionable message rather than picking one + /// arbitrarily and producing an image that starts and immediately exits with "no main manifest + /// attribute". + /// + /// + /// Emits a shell command that stages a Quarkus build's output as a directory containing + /// quarkus-run.jar, whichever packaging type the application uses. + /// + /// + /// Quarkus's default "fast JAR" packaging writes quarkus-app/, whose quarkus-run.jar is + /// unusable without the lib, app, and quarkus directories beside it — its manifest + /// Class-Path names them relatively. legacy-jar writes a *-runner.jar at the top of + /// the output directory whose Class-Path names a sibling lib/, and uber-jar writes a + /// single self-contained *-runner.jar with no dependency directory at all. The packaging type is + /// chosen in application configuration, which is not something the AppHost can read, so the choice is + /// made in the build stage where the output already exists and all three are normalised to the same + /// shape: a directory holding quarkus-run.jar and whatever it needs beside it. + /// + /// Which file to run is read from quarkus-artifact.properties, which every packaging type writes + /// next to its output and which names the runnable artifact relative to the output directory + /// (path=quarkus-app/quarkus-run.jar or path=app-runner.jar). Globbing cannot substitute + /// for it: legacy-jar leaves the base plugin's thin JAR beside the runner, so two files match and + /// neither carries a suffix that distinguishes them. The glob fallback is kept only for a Quarkus old + /// enough not to write the file. + /// + /// See https://quarkus.io/guides/maven-tooling#quarkus-package-jar_quarkus.package.jar.type. + /// + private static string SelectQuarkusArtifactCommand(string outputDirectory, string outputGlob, string resourceName) + { + var fastJarDirectory = $"{outputDirectory}/{JavaHostingExtensions.QuarkusFastJarDirectory}"; + var uberJarFallback = SelectSingleJarCommand( + outputGlob, + $"{ContainerArtifactDirectory}/{JavaHostingExtensions.QuarkusRunJarName}", + resourceName); + + // "cp -r /." rather than "cp -r " so the contents land directly in the destination + // whether or not it already exists, which "cp -r" alone does not guarantee. + var withoutMetadata = $"if [ -d {fastJarDirectory} ]; then cp -r {fastJarDirectory}/. {ContainerArtifactDirectory}/; " + + $"else {uberJarFallback}; fi"; + + // A path with a directory component is a layout whose runner needs everything beside it, so the + // whole directory is staged. A bare file name is a runner at the top of the output directory, + // which is self-contained under uber-jar and needs the sibling lib/ under legacy-jar. + return string.Join(" && ", + $"mkdir -p {ContainerArtifactDirectory}", + $"quarkus_artifact=$(sed -n 's/^path=//p' {outputDirectory}/quarkus-artifact.properties 2>/dev/null | head -1)", + "case \"$quarkus_artifact\" in " + + $"'') {withoutMetadata} ;; " + + $"*/*) cp -r \"{outputDirectory}/${{quarkus_artifact%/*}}/.\" {ContainerArtifactDirectory}/ ;; " + + $"*) cp \"{outputDirectory}/$quarkus_artifact\" {ContainerArtifactDirectory}/{JavaHostingExtensions.QuarkusRunJarName} && " + + $"if [ -d {outputDirectory}/lib ]; then cp -r {outputDirectory}/lib {ContainerArtifactDirectory}/lib; fi ;; " + + "esac"); + } + + /// + /// Copies the JAR the AppHost named, failing with a message that names it when the build did not + /// produce it. + /// + /// + /// A bare cp would fail with the shell's own "No such file or directory", which does not say + /// which resource or which of the two plausible causes applies. + /// + private static string SelectNamedJarCommand(string jarPath, string destination, string resourceName) + { + var quoted = ShellQuote(jarPath); + + return string.Join(" && ", + // The path is emitted as its own single-quoted shell word rather than interpolated into the + // double-quoted message, because a double-quoted $(...) would run during the image build. + $"if [ ! -f {quoted} ]; then echo \"Aspire: the build of '{resourceName}' did not produce\" {quoted} >&2; echo \"Check the jarPath passed to AddJavaApp, or use WithJarArtifact to name the published artifact separately.\" >&2; exit 1; fi", + $"cp {quoted} {destination}"); + } + + private static string SelectSingleJarCommand(string outputGlob, string destination, string resourceName) + { + // Written as a single line because each Dockerfile RUN is one shell invocation. + // + // $jars is quoted at the copy because the artifact name is not guaranteed to be whitespace-free: + // Gradle exposes archiveFileName and archivesName, and Maven exposes finalName, so a build can + // legitimately produce "reports service.jar". Quoting is safe precisely because the copy is only + // reached once the count check has established there is exactly one line. + return string.Join(" && ", + $"jars=$(ls {outputGlob} 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\\.jar$' || true)", + "count=$(printf '%s\\n' \"$jars\" | grep -c . || true)", + $"if [ \"$count\" != \"1\" ]; then echo \"Aspire: expected exactly one application JAR from the build of '{resourceName}' matching {outputGlob}, found $count:\" >&2; echo \"$jars\" >&2; echo \"Use WithJarArtifact(\\\"\\\") to select one.\" >&2; exit 1; fi", + $"cp \"$jars\" {destination}"); + } + + private static string ShellQuote(string value) => $"'{value.Replace("'", "'\\''")}'"; + /// + /// Quotes a build argument only when the shell would otherwise change its meaning. + /// + /// + /// These values reach the container build as a shell command, so a value containing whitespace, + /// quotes, $, or ; would be re-split or expanded and the image would build differently + /// from the identical arguments used on the host, where they are passed as separate argv entries. + /// Ordinary arguments such as -DskipTests are left bare so the generated Dockerfile stays + /// readable. + /// + private static string ShellQuoteIfNeeded(string value) + => value.Length > 0 && value.All(static c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '/' or ':' or '=' or '+' or '@' or '-' or ',') + ? value + : ShellQuote(value); + } + + // Matches the version in a pinned distribution archive name, for example + // .../gradle-9.0.0-bin.zip -> major 9, minor 0 + // .../gradle-8.4-bin.zip -> major 8, minor 4 + // .../gradle-8.14-rc-1-all.zip -> major 8, minor 14 + // The minor group is optional so a hypothetical "gradle-9-bin.zip" still yields a major. + [GeneratedRegex(@"gradle-(?\d+)(?:\.(?\d+))?(?:[.\-]|-bin|-all)")] + private static partial Regex GradleDistributionRegex(); + + [GeneratedRegex(@"apache-maven-(?\d+)\.")] + private static partial Regex MavenDistributionRegex(); +} + +#pragma warning restore ASPIREDOCKERFILEBUILDER001 diff --git a/src/Aspire.Hosting.Java/JavaHostingExtensions.cs b/src/Aspire.Hosting.Java/JavaHostingExtensions.cs new file mode 100644 index 00000000000..8e24038f3bc --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaHostingExtensions.cs @@ -0,0 +1,2212 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREDOCKERFILEBUILDER001 + +#pragma warning disable ASPIRECERTIFICATES001 +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIREEXTENSION001 // WithDebugSupport and WithLaunchToolArgs are experimental but used internally for debug support. + +using System.Diagnostics.CodeAnalysis; +using System.Formats.Asn1; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.Pkcs; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Java; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Java applications to an . +/// +public static partial class JavaHostingExtensions +{ + private const string JavaToolOptions = "JAVA_TOOL_OPTIONS"; + + // The icon Java resources and their build steps show in the dashboard, matching + // CommunityToolkit.Aspire.Hosting.Java so migrating users see the same thing. + private const string JavaIconName = "DrinkCoffee"; + + internal static readonly string s_defaultMavenWrapper = + JavaBuildToolResolver.GetDefaultWrapperName(JavaBuildTool.Maven, RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + internal static readonly string s_defaultGradleWrapper = + JavaBuildToolResolver.GetDefaultWrapperName(JavaBuildTool.Gradle, RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + + /// The directory Quarkus's default packaging writes under the build tool's output directory. + internal const string QuarkusFastJarDirectory = "quarkus-app"; + + /// The runnable artifact inside . + internal const string QuarkusRunJarName = "quarkus-run.jar"; + + /// + /// Adds a Java application to the application model, launched with java. + /// + /// The to add the resource to. + /// The name of the resource. + /// The application directory. Relative paths are resolved against the AppHost directory. + /// A reference to the . + /// is . + /// or is , empty, or whitespace. + /// No launch mode was configured. Raised when the resource starts, not when this is called. + /// + /// Combine with or + /// to run the application through a build tool, + /// or use the overload that accepts a jarPath to run a prebuilt JAR with java -jar. + /// Exactly one of those three launch modes must be configured. + /// + /// + /// Run a Spring Boot application through the Maven wrapper: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddJavaApp("catalog", "../catalog") + /// .WithMavenGoal("spring-boot:run") + /// .WithHttpEndpoint(env: "SERVER_PORT") + /// .WithHttpHealthCheck("/actuator/health"); + /// + /// builder.Build().Run(); + /// + /// + [AspireExport] + public static IResourceBuilder AddJavaApp( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + string appDirectory) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(appDirectory); + + // Accept both slash styles so an AppHost authored on Windows resolves the same paths on Linux. + appDirectory = PathNormalizer.NormalizePathForCurrentPlatform( + Path.Combine(builder.AppHostDirectory, appDirectory)); + + var resource = new JavaAppResource(name, appDirectory); + + var resourceBuilder = builder.AddResource(resource) + .WithIconName(JavaIconName) + .WithRequiredCommand("java", "https://adoptium.net/") + // Declared as launch tool arguments rather than through WithArgs for two reasons: they are + // pinned ahead of any caller-supplied WithArgs no matter the order the builder methods are + // called in, and they are omitted when an IDE launches the resource through a "java" launch + // configuration, which starts the JVM directly instead of going through the build tool. + .WithLaunchToolArgs(ctx => AddLaunchArgs(resource, ctx), ownedByLaunchConfigurationType: "java") + .WithOtlpExporter() + // Requested explicitly because the JVM's trust store setting replaces the default certificate + // authorities instead of adding to them, so the bundle has to carry the system roots too. + .WithCertificateTrustScope(CertificateTrustScope.System) + .WithCertificateTrustConfiguration(JavaCertificateTrustCallback) + .WithVSCodeDebugging() + .PublishAsDockerFile(containerBuilder => + { + // An authored Dockerfile in the application directory is the author's deployment contract. + // Generating over it would silently discard base image pins, extra runtime packages, and + // anything else the project depends on. + if (File.Exists(Path.Combine(appDirectory, "Dockerfile"))) + { + resource.Annotations.Add(new JavaAuthoredDockerfileAnnotation()); + return; + } + + containerBuilder.WithDockerfileBuilder( + appDirectory, + ctx => + { + // The build context was fixed when PublishAsDockerFile ran, which is during + // AddJavaApp, and DockerfileBuildAnnotation.ContextPath cannot be changed + // afterwards. A later WithWorkingDirectory therefore moves where the application + // runs without moving what is uploaded to the daemon, and the image would be built + // from the original directory. Saying so is far better than producing an image + // whose sources come from somewhere the author no longer points at. + if (!ArePathsEquivalent(resource.WorkingDirectory, appDirectory)) + { + throw new DistributedApplicationException( + $"Java application '{resource.Name}' cannot be published because its working " + + $"directory was changed to '{resource.WorkingDirectory}' after the container " + + $"build context was set to '{appDirectory}'. Pass the directory to " + + "AddJavaApp instead of calling WithWorkingDirectory afterwards."); + } + + JavaDockerfileGenerator.Write(resource, appDirectory, ctx); + }); + }); + + // The generated image copies files out of each container files source, so those sources have to be + // built first. PublishAsDockerFile removes the Java resource from the model, but the container it + // substitutes shares this annotation collection, so the callback still runs; the step lookup matches + // on resource name and therefore finds the substituted container's build steps. + resourceBuilder.WithPipelineConfiguration(context => + { + if (resource.TryGetAnnotationsOfType(out var containerFilesAnnotations)) + { + var buildSteps = context.GetSteps(resource, WellKnownPipelineTags.BuildCompute); + foreach (var containerFile in containerFilesAnnotations) + { + buildSteps.DependsOn(context.GetSteps(containerFile.Source, WellKnownPipelineTags.BuildCompute)); + } + } + }); + + return resourceBuilder; + } + + /// + /// Adds a Java application that runs a prebuilt JAR with java -jar. + /// + /// The to add the resource to. + /// The name of the resource. + /// The application directory. Relative paths are resolved against the AppHost directory. + /// The path to the JAR file to execute. Relative paths are resolved against . + /// Arguments passed to the Java application after the JAR path. + /// A reference to the . + /// or is . + /// , , or is , empty, or whitespace. + /// + /// Build the JAR with Maven, then run it: + /// + /// builder.AddJavaApp("worker", "../worker", "target/worker.jar") + /// .WithMavenBuild(); + /// + /// + [AspireExport("addJavaAppWithJar")] + public static IResourceBuilder AddJavaApp( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + string appDirectory, + string jarPath, + params string[] args) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(appDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(jarPath); + ArgumentNullException.ThrowIfNull(args); + + var rb = builder.AddJavaApp(name, appDirectory); + + rb.WithAnnotation( + // Keep the authored text in the model so diagnostics can quote the path the user recognizes. + // Run and publish normalize only when crossing into their execution environment instead of + // baking the AppHost's path semantics into a value that also targets a Linux container. + new JavaJarPathAnnotation(jarPath), + ResourceAnnotationMutationBehavior.Replace); + + if (args.Length > 0) + { + rb.WithArgs(args); + } + + return rb; + } + + /// + /// Adds a Java application that runs from an existing container image. + /// + /// The to add the resource to. + /// The name of the resource. + /// The container image that runs the application, for example mycompany/catalog. + /// The image tag. Defaults to the image's latest tag. + /// A reference to the . + /// is . + /// or is , empty, or whitespace. + /// + /// Use this when the image is built elsewhere — by a separate CI pipeline, or by a team that ships the + /// application as a container. Aspire runs the image as-is and never rebuilds it, so the JAR, the JDK, + /// and any OpenTelemetry agent all come from the image. Use + /// instead when Aspire should + /// build and run the application from source. + /// + /// No endpoint is declared, because the port the image listens on is a property of the image. Add one + /// with WithHttpEndpoint(targetPort: 8080), using whichever port the application binds — 8080 + /// for a default Spring Boot or Quarkus image. + /// + /// + /// + /// Run a published Spring Boot image and give it a database: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var db = builder.AddPostgres("pg").AddDatabase("catalogdb"); + /// + /// builder.AddJavaContainer("catalog", "mycompany/catalog", "1.4.0") + /// .WithHttpEndpoint(targetPort: 8080) + /// .WithReference(db) + /// .WithJvmArgs("-Xmx512m"); + /// + /// builder.Build().Run(); + /// + /// + [AspireExport] + public static IResourceBuilder AddJavaContainer( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + string image, + string? tag = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(image); + + var resource = new JavaContainerResource(name); + + return builder.AddResource(resource) + .WithImage(image, tag) + .WithIconName(JavaIconName) + .WithOtlpExporter() + // Requested explicitly because the JVM's trust store setting replaces the default certificate + // authorities instead of adding to them, so the bundle has to carry the system roots too. + .WithCertificateTrustScope(CertificateTrustScope.System) + .WithCertificateTrustConfiguration(JavaCertificateTrustCallback); + } + + /// + /// Adds a Spring Boot application to the application model, built and launched with its own Maven or Gradle wrapper. + /// + /// The to add the resource to. + /// The name of the resource. + /// The application directory, containing pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts. Relative paths are resolved against the AppHost directory. + /// A reference to the . + /// is . + /// or is , empty, or whitespace. + /// The directory contains neither a Maven nor a Gradle build file, or contains both. + /// + /// This is with the common + /// Spring Boot configuration already applied: the build tool is detected when the resource starts, the + /// application is launched through that tool's Spring Boot plugin (spring-boot:run or bootRun), + /// and an HTTP endpoint is declared through SERVER_PORT, which is the environment + /// variable Spring Boot reads for its listening port. Everything else is the same, so any With… method + /// that works on works here too. + /// + /// The launch goal compiles the application itself, so Aspire does not add a separate build resource before + /// it. Publishing still packages the application with tests skipped (-DskipTests for Maven, + /// -x test for Gradle). Call or + /// afterwards to customize those package arguments. + /// The one thing that does add a build resource is + /// with a build-produced agent, which cannot be + /// loaded until a build has written it. + /// + /// + /// No health check is added. /actuator/health only exists when the application depends on + /// spring-boot-starter-actuator, and adding it unconditionally would leave applications without that + /// dependency permanently unhealthy and silently stall every WaitFor on them. Add + /// WithHttpHealthCheck("/actuator/health") when the actuator is present. + /// + /// + /// + /// Two Spring Boot services and a database: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var db = builder.AddPostgres("pg").AddDatabase("catalogdb"); + /// + /// var catalog = builder.AddSpringBootApp("catalog", "../catalog") + /// .WithReference(db); + /// + /// builder.AddSpringBootApp("orders", "../orders") + /// .WithReference(catalog) + /// .WaitFor(catalog); + /// + /// builder.Build().Run(); + /// + /// + [AspireExport] + public static IResourceBuilder AddSpringBootApp( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + string appDirectory) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(appDirectory); + + var resourceBuilder = builder.AddJavaApp(name, appDirectory) + .WithDetectedBuildTool( + mavenBuildArgs: ["-B", "-ntp", "-DskipTests", "package"], + mavenLaunchArgs: ["spring-boot:run"], + gradleBuildArgs: ["build", "-x", "test"], + gradleLaunchArgs: ["bootRun"]); + + // Spring Boot reads SERVER_PORT for its listening port, so the port Aspire allocates reaches the + // application without any code in the application. No targetPort is pinned: these are host + // processes rather than containers, so a fixed target port is a real port on the machine and two + // Spring Boot services both asking for 8080 would collide. + return resourceBuilder.WithHttpEndpoint(env: "SERVER_PORT"); + } + + /// + /// Adds a Quarkus application to the application model, built and launched with its own Maven or Gradle wrapper. + /// + /// The to add the resource to. + /// The name of the resource. + /// The application directory, containing pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts. Relative paths are resolved against the AppHost directory. + /// A reference to the . + /// is . + /// or is , empty, or whitespace. + /// The directory contains neither a Maven nor a Gradle build file, or contains both. + /// + /// The build tool is detected when the resource starts, the application runs in Quarkus dev mode + /// (quarkus:dev or quarkusDev) so live coding works, and an HTTP endpoint is declared through + /// QUARKUS_HTTP_PORT, the environment variable Quarkus reads for its listening port. Everything else + /// behaves like . + /// The dev-mode goal compiles the application itself, so Aspire does not add a separate build resource before it. + /// There are two exceptions. with a build-produced agent + /// cannot be loaded until a build has written it, and a debug session launches the packaged fast JAR rather + /// than the dev-mode wrapper, which no build has written on a clean checkout. + /// + /// Quarkus Dev Services are left enabled but do not activate for anything Aspire supplies: Dev Services only + /// start a container when the corresponding configuration is missing, and a WithReference to a database + /// or broker provides it. That means Aspire's resources are used rather than a second set started underneath. + /// + /// + /// QUARKUS_PROFILE is set to dev in run mode. Dev mode already selects that profile; setting it + /// explicitly means a debugger, which launches the packaged application rather than the dev-mode wrapper, + /// resolves the same %dev. configuration the application would see when run normally. + /// + /// + /// In run mode the application is bound to all interfaces. Quarkus enables Host header validation + /// whenever it binds a localhost name, and that filter rejects the hostname Aspire publishes, which + /// makes the endpoint link in the dashboard return 400. Published output is left alone, where + /// the application already binds all interfaces. + /// + /// + /// No health check is added. /q/health only exists when the application depends on + /// quarkus-smallrye-health, and adding it unconditionally would leave applications without that + /// extension permanently unhealthy and silently stall every WaitFor on them. Add + /// WithHttpHealthCheck("/q/health") when the extension is present. + /// + /// + /// + /// A Quarkus service backed by a database Aspire provides: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var db = builder.AddPostgres("pg").AddDatabase("inventorydb"); + /// + /// builder.AddQuarkusApp("inventory", "../inventory") + /// .WithReference(db) + /// .WaitFor(db); + /// + /// builder.Build().Run(); + /// + /// + [AspireExport] + public static IResourceBuilder AddQuarkusApp( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + string appDirectory) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(appDirectory); + + var resourceBuilder = builder.AddJavaApp(name, appDirectory) + .WithAnnotation(new JavaQuarkusAnnotation(), ResourceAnnotationMutationBehavior.Replace) + .WithDetectedBuildTool( + mavenBuildArgs: ["-B", "-ntp", "-DskipTests", "package"], + mavenLaunchArgs: ["quarkus:dev"], + gradleBuildArgs: ["build", "-x", "test"], + gradleLaunchArgs: ["quarkusDev"]); + + // Called after the Quarkus and build-tool annotations are in place, because both decide whether a + // build has to run before the IDE launches the fast JAR it will attach to. + EnsureBuildRunsBeforeLaunch(resourceBuilder); + + // Declared before the run-mode block so the Host validation configuration below can name this + // endpoint's host. QUARKUS_HTTP_PORT is the variable Quarkus reads for its listening port. + resourceBuilder = resourceBuilder.WithHttpEndpoint(env: "QUARKUS_HTTP_PORT"); + + if (builder.ExecutionContext.IsRunMode) + { + resourceBuilder.WithEnvironment("QUARKUS_PROFILE", "dev"); + + // Quarkus turns on its Host header validation filter whenever quarkus.http.host holds a + // localhost name, which is the dev-mode default. The filter compares only the host portion of + // the request authority, lowercased, against an exact set, so it rejects the + // ".dev.localhost" hostname Aspire publishes with a bare 400 and no body: the + // endpoint link shown in the dashboard fails while the same request to 127.0.0.1 succeeds. + // See io.quarkus.vertx.http.runtime.HostValidationFilter. + // + // Binding all interfaces is what suppresses the filter, because it only auto-enables for a + // localhost bind. The targeted alternative, naming the hostname in + // quarkus.http.host-validation.allowed-hosts, cannot be delivered: the Quarkus Gradle plugin + // re-exports QUARKUS_* environment variables to the dev JVM as system properties by replacing + // every underscore with a dot, so a property whose name contains a dash arrives as + // "quarkus.http.host.validation.allowed.hosts" and is silently ignored. Aspire also has no + // access to the published hostname here - the endpoint's Host resolves to the bind address. + // + // Only run mode is configured, and only ever on a developer machine. A published container + // already binds all interfaces by default, so nothing needs to be said about it there. + resourceBuilder.WithEnvironment("QUARKUS_HTTP_HOST", "0.0.0.0"); + + // Quarkus dev mode starts an "observability" Dev Service when an application depends on + // quarkus-opentelemetry and no exporter endpoint is configured in the application itself. That + // Dev Service pulls grafana/otel-lgtm (roughly 600 MB), starts it through Testcontainers, and + // then *overrides* the exporter configuration to point at the container it just started: + // + // Dev Service Lgtm started, config: {quarkus.otel.exporter.otlp.endpoint=http://localhost:51845, ...} + // + // Aspire is already the observability stack here, so that override sends every span and metric + // somewhere the Aspire dashboard cannot see, leaves an orphaned container behind, and costs a + // large image pull on first run. Turning the Dev Service off lets the exporter configuration + // below win, which is what an Aspire user expects. + // See https://quarkus.io/guides/observability-devservices-lgtm. + resourceBuilder.WithEnvironment("QUARKUS_OBSERVABILITY_ENABLED", "false"); + } + + // The quarkus-opentelemetry extension does not read the standard OTEL_* environment variables that + // Aspire sets. It reads its own quarkus.otel.* configuration, so an application with the extension + // compiled in silently keeps its default endpoint and fails every export: + // + // WARNING [io.quarkus.opentelemetry.runtime.exporter.otlp.sender.VertxGrpcSender] + // Failed to export . The request could not be executed. + // Full error message: Connection refused: localhost/127.0.0.1:4317 + // + // SmallRye Config maps QUARKUS_OTEL_EXPORTER_OTLP_ENDPOINT onto quarkus.otel.exporter.otlp.endpoint, + // so mirroring the values Aspire already resolved is enough to point the extension at the dashboard. + // See https://quarkus.io/guides/opentelemetry-tracing#create-the-configuration. + // + // The callback runs after the one WithOtlpExporter installed in AddJavaApp, so the OTEL_* entries are + // already present and carry the resolved endpoint reference rather than a literal. An application that + // does not use the extension ignores these, at the cost of a "unrecognized configuration key" warning. + // + // This covers `aspire run`. It cannot cover a published image, because there the compute environment + // supplies OTEL_* from a callback it appends while preparing the deployment target — after every + // callback the AppHost registered — so there is nothing here to copy. A SmallRye config expression + // would sidestep the ordering, but it cannot be passed as an environment variable: Docker Compose + // interpolates '${...}' in its own file and rejects SmallRye's '${VAR:default}' form outright + // ("invalid interpolation format"). A deployed application therefore maps the value in its own + // application.properties, which the README documents and both playgrounds do. + resourceBuilder.WithEnvironment(context => + { + MirrorOtelVariable(context, KnownOtelConfigNames.ExporterOtlpEndpoint, "QUARKUS_OTEL_EXPORTER_OTLP_ENDPOINT"); + MirrorOtelVariable(context, KnownOtelConfigNames.ExporterOtlpProtocol, "QUARKUS_OTEL_EXPORTER_OTLP_PROTOCOL"); + MirrorOtelVariable(context, KnownOtelConfigNames.ExporterOtlpHeaders, "QUARKUS_OTEL_EXPORTER_OTLP_HEADERS"); + MirrorOtelVariable(context, KnownOtelConfigNames.ResourceAttributes, "QUARKUS_OTEL_RESOURCE_ATTRIBUTES"); + MirrorOtelVariable(context, KnownOtelConfigNames.ServiceName, "QUARKUS_OTEL_SERVICE_NAME"); + MirrorOtelVariable(context, KnownOtelConfigNames.BspScheduleDelay, "QUARKUS_OTEL_BSP_SCHEDULE_DELAY"); + MirrorOtelVariable(context, KnownOtelConfigNames.BlrpScheduleDelay, "QUARKUS_OTEL_BLRP_SCHEDULE_DELAY"); + MirrorOtelVariable(context, KnownOtelConfigNames.MetricExportInterval, "QUARKUS_OTEL_METRIC_EXPORT_INTERVAL"); + MirrorOtelVariable(context, KnownOtelConfigNames.TracesSampler, "QUARKUS_OTEL_TRACES_SAMPLER"); + }); + + return resourceBuilder; + } + + /// + /// Copies an OpenTelemetry environment variable Aspire already resolved to the name Quarkus reads it under. + /// + /// + /// The value is copied by reference rather than converted to a string: several of these are endpoint + /// references or DCP templates that only resolve once the resource starts. + /// + private static void MirrorOtelVariable(EnvironmentCallbackContext context, string standardName, string quarkusName) + { + if (context.EnvironmentVariables.TryGetValue(standardName, out var value)) + { + context.EnvironmentVariables[quarkusName] = value; + } + } + + /// + /// Requires an application directory to declare a Maven or Gradle project. + /// + /// + /// Detection is shared with publishing so the same project files cannot select different tools in each path. + /// + /// The directory has no build file, or has both Maven and Gradle files. + private static JavaBuildTool RequireBuildTool(string workingDirectory, string resourceName) + { + if (JavaBuildToolResolver.Detect( + workingDirectory, + resourceName, + static message => new InvalidOperationException(message)) is not { } tool) + { + throw new InvalidOperationException( + $"Directory '{workingDirectory}' contains no pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts, " + + $"so the build tool for resource '{resourceName}' cannot be detected. " + + $"Check the path, or use AddJavaApp for an application laid out differently."); + } + + return tool; + } + + private static IResourceBuilder WithDetectedBuildTool( + this IResourceBuilder builder, + string[] mavenBuildArgs, + string[] mavenLaunchArgs, + string[] gradleBuildArgs, + string[] gradleLaunchArgs) + where T : JavaAppResource + { + builder.WithAnnotation( + new JavaDetectedBuildToolAnnotation( + mavenBuildArgs, + mavenLaunchArgs, + gradleBuildArgs, + gradleLaunchArgs), + ResourceAnnotationMutationBehavior.Replace); + + // Maven and Gradle both launch through the platform's command interpreter. The wrapper and its + // arguments are resolved later, after the complete AppHost has had a chance to supply an override. + builder.WithCommand(WrapperCommand()); + + return builder.OnBeforeResourceStarted((resource, _, _) => + { + var (tool, configuration) = ResolveDetectedBuildTool(resource); + + // Explicit WithMaven*/WithGradle* calls identify the tool without disk detection and must + // keep their authored arguments. The deferred defaults only fill the missing half. + if (!resource.HasAnnotationOfType()) + { + builder.WithAnnotation( + new JavaBuildToolAnnotation(tool, configuration.LaunchArgs), + ResourceAnnotationMutationBehavior.Replace); + } + + if (!resource.HasAnnotationOfType()) + { + builder.WithAnnotation( + new JavaBuildStepAnnotation(ResourceName: null, tool, configuration.BuildArgs), + ResourceAnnotationMutationBehavior.Replace); + } + + ValidateWrapperExists(resource, tool); + return Task.CompletedTask; + }); + } + + private static (JavaBuildTool Tool, (string[] BuildArgs, string[] LaunchArgs) Configuration) ResolveDetectedBuildTool( + JavaAppResource resource) + { + var annotation = resource.Annotations.OfType().Single(); + var tool = TryResolveConfiguredBuildTool(resource, out var configuredTool) + ? configuredTool + : RequireBuildTool(resource.WorkingDirectory, resource.Name); + + return (tool, annotation.GetConfiguration(tool)); + } + + private static bool TryResolveConfiguredBuildTool(JavaAppResource resource, out JavaBuildTool tool) + { + if (resource.TryGetLastAnnotation(out var buildStep)) + { + tool = buildStep.Tool; + return true; + } + + if (resource.TryGetLastAnnotation(out var launch)) + { + tool = launch.Tool; + return true; + } + + if (resource.HasAnnotationOfType()) + { + tool = RequireBuildTool(resource.WorkingDirectory, resource.Name); + return true; + } + + tool = default; + return false; + } + + /// + /// Launches the Java application through a Maven goal instead of java, for example spring-boot:run. + /// + /// The Java application resource type. + /// The resource builder for the Java application. + /// The Maven goal to execute. + /// Additional arguments passed to the Maven wrapper after the goal. + /// A reference to the . + /// or is . + /// is , empty, or whitespace. + /// The application is already configured to run a prebuilt JAR or a Gradle task. + /// + /// The wrapper defaults to mvnw (mvnw.cmd on Windows) in the resource's working directory + /// and can be overridden with . + /// + [AspireExport] + public static IResourceBuilder WithMavenGoal( + this IResourceBuilder builder, + string goal, + params string[] args) where T : JavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(goal); + ArgumentNullException.ThrowIfNull(args); + + return builder.WithBuildToolLaunch(JavaBuildTool.Maven, goal, args, nameof(WithMavenGoal)); + } + + /// + /// Launches the Java application through a Gradle task instead of java, for example bootRun. + /// + /// The Java application resource type. + /// The resource builder for the Java application. + /// The Gradle task to execute. + /// Additional arguments passed to the Gradle wrapper after the task. + /// A reference to the . + /// or is . + /// is , empty, or whitespace. + /// The application is already configured to run a prebuilt JAR or a Maven goal. + /// + /// The wrapper defaults to gradlew (gradlew.bat on Windows) in the resource's working + /// directory and can be overridden with . + /// + [AspireExport] + public static IResourceBuilder WithGradleTask( + this IResourceBuilder builder, + string task, + params string[] args) where T : JavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(task); + ArgumentNullException.ThrowIfNull(args); + + return builder.WithBuildToolLaunch(JavaBuildTool.Gradle, task, args, nameof(WithGradleTask)); + } + + private static IResourceBuilder WithBuildToolLaunch( + this IResourceBuilder builder, + JavaBuildTool tool, + string goalOrTask, + string[] args, + string methodName) where T : JavaAppResource + { + // A prebuilt JAR and a build-tool launch are mutually exclusive: the build tool decides what to + // run, so -jar would be ignored. CommunityToolkit rejects both combinations; this port only + // rejected the Gradle half, letting a Maven+JAR application silently drop its JAR. + if (builder.Resource.HasAnnotationOfType()) + { + throw new InvalidOperationException( + $"{methodName} cannot be used when a JAR path has been specified. Use either the " + + $"{nameof(AddJavaApp)} overload that takes a jarPath, or {methodName}, not both."); + } + + if (builder.Resource.TryGetLastAnnotation(out var existing) && existing.Tool != tool) + { + throw new InvalidOperationException( + $"{methodName} cannot be used when the application is already configured to launch with " + + $"{existing.Tool}. A Java application is launched by a single build tool."); + } + + if (builder.Resource.TryGetLastAnnotation(out var buildStep) && buildStep.Tool != tool) + { + throw new InvalidOperationException( + $"{methodName} cannot be used when the application is already configured to build with " + + $"{buildStep.Tool}. A Java application is built and launched by a single build tool."); + } + + builder.WithAnnotation( + new JavaBuildToolAnnotation(tool, args.Length > 0 ? [goalOrTask, .. args] : [goalOrTask]), + ResourceAnnotationMutationBehavior.Replace); + + // The launch goal now compiles the application, so the build resource is redundant — unless + // something outside the launch goal needs the build's output before the application starts. + if (buildStep is not null + && builder.ApplicationBuilder.ExecutionContext.IsRunMode + && !RequiresBuildBeforeLaunch(builder)) + { + RemoveRunBuildResource(builder, buildStep); + } + + // Set the command in every execution context. Setting it only in run mode left publish emitting + // "java" as the command while the goal was still contributed as an argument, producing the + // uninvokable command line "java spring-boot:run". + return builder + .WithCommand(ResolveWrapperInvocation(builder.Resource, tool).Command) + .WithDeferredWrapperValidation(tool); + } + + /// + /// Runs a Maven build before the Java application starts. + /// + /// The Java application resource type. + /// The resource builder for the Java application. + /// Arguments passed to the Maven wrapper. Defaults to clean package. + /// A reference to the . + /// or is . + /// The application is already configured to build with Gradle. + /// + /// When the application launches with , + /// that goal performs the local compilation, so these arguments normally configure publishing without adding a + /// second run-mode build. Otherwise, the build step is a child resource that the application waits for. + /// No child is created when publishing because the generated container image performs the build. + /// + /// This runs a build before the application starts. To launch the application through Maven, + /// use instead. + /// + /// + [AspireExport] + public static IResourceBuilder WithMavenBuild( + this IResourceBuilder builder, + params string[] args) where T : JavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(args); + + return builder.WithJavaBuildStep( + JavaBuildTool.Maven, + buildResourceName: $"{builder.Resource.Name}-maven-build", + buildArgs: args.Length > 0 ? args : ["clean", "package"]); + } + + /// + /// Runs a Gradle build before the Java application starts. + /// + /// The Java application resource type. + /// The resource builder for the Java application. + /// Arguments passed to the Gradle wrapper. Defaults to clean build. + /// A reference to the . + /// or is . + /// The application is already configured to build with Maven. + /// + /// When the application launches with , + /// that task performs the local compilation, so these arguments normally configure publishing without adding a + /// second run-mode build. Otherwise, the build step is a child resource that the application waits for. + /// No child is created when publishing because the generated container image performs the build. + /// + /// This runs a build before the application starts. To launch the application through Gradle, + /// use instead. + /// + /// + [AspireExport] + public static IResourceBuilder WithGradleBuild( + this IResourceBuilder builder, + params string[] args) where T : JavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(args); + + return builder.WithJavaBuildStep( + JavaBuildTool.Gradle, + buildResourceName: $"{builder.Resource.Name}-gradle-build", + buildArgs: args.Length > 0 ? args : ["clean", "build"]); + } + + private static IResourceBuilder WithJavaBuildStep( + this IResourceBuilder builder, + JavaBuildTool tool, + string buildResourceName, + string[] buildArgs) + where T : JavaAppResource + { + // Building with both tools would produce two artifacts and leave the container build with no way + // to choose between them, so it is rejected the same way conflicting launch modes are. + if (builder.Resource.TryGetLastAnnotation(out var existing) && existing.Tool != tool) + { + throw new InvalidOperationException( + $"Resource '{builder.Resource.Name}' is already configured to build with {existing.Tool}. " + + $"Call either WithMavenBuild or WithGradleBuild, not both."); + } + + if (builder.Resource.TryGetLastAnnotation(out var launchTool) && launchTool.Tool != tool) + { + throw new InvalidOperationException( + $"Resource '{builder.Resource.Name}' is already configured to launch with {launchTool.Tool}. " + + "A Java application is built and launched by a single build tool."); + } + + // A launch goal such as spring-boot:run or bootRun compiles the application on its way to running + // it, so a build resource in front of it would only repeat work. That holds only while the launch + // goal is what actually starts the application and nothing else needs the build's output first. + var createRunResource = builder.ApplicationBuilder.ExecutionContext.IsRunMode + && (RequiresBuildBeforeLaunch(builder) + || launchTool is null && !builder.Resource.HasAnnotationOfType()); + + // Recorded in every execution context: in publish mode there is no build-step resource, but the + // generated Dockerfile still runs this tool and these arguments to produce the deployable JAR. + builder.WithAnnotation( + new JavaBuildStepAnnotation( + createRunResource ? buildResourceName : null, + tool, + buildArgs), + ResourceAnnotationMutationBehavior.Replace); + + if (!createRunResource) + { + return builder; + } + // Calling the same method twice must not add a second resource under the same name. The + // annotation was just replaced, and the arguments are read from it on every run, so the existing + // resource already reflects the new arguments. + // + // The check is on ResourceName rather than on the annotation, because an earlier call can record + // a build step without creating a resource: createRunResource is false when the launch goal + // already compiles the application. If something later makes the build mandatory - a relative + // WithOtelAgent path, for instance, whose agent JAR the build has to produce before launch - + // this call is the one that has to create it. Returning early on the annotation alone left the + // new annotation naming a resource that was never added, and the application then failed at + // startup loading an agent that nothing had built. + if (existing?.ResourceName is not null) + { + return builder; + } + + var resource = builder.Resource; + var wrapperInvocation = ResolveWrapperInvocation(resource, tool); + var buildResource = new JavaBuildResource(buildResourceName, wrapperInvocation.Command, resource.WorkingDirectory, tool); + + var buildBuilder = builder.ApplicationBuilder.AddResource(buildResource) + .WithArgs(ctx => + { + // Resolved on every evaluation rather than captured, because WithWrapperPath can replace + // the wrapper after this resource exists and the leading argument has to follow it. + foreach (var leadingArg in ResolveWrapperInvocation(resource, tool).LeadingArgs) + { + ctx.Args.Add(leadingArg); + } + + if (resource.TryGetLastAnnotation(out var buildStep)) + { + foreach (var arg in buildStep.Args) + { + ctx.Args.Add(arg); + } + } + }) + .WithIconName(JavaIconName) + .WithParentRelationship(resource) + .ExcludeFromManifest() + // The build step runs before the application, so without this a missing wrapper would first + // surface as this resource failing to exec, rather than as the actionable message. + .OnBeforeResourceStarted((_, _, _) => + { + ValidateWrapperExists(resource, tool); + return Task.CompletedTask; + }); + + return builder.WaitForCompletion(buildBuilder); + } + + private static void RemoveRunBuildResource( + IResourceBuilder builder, + JavaBuildStepAnnotation buildStep) + where T : JavaAppResource + { + if (buildStep.ResourceName is not { } buildResourceName) + { + return; + } + + var buildResource = builder.ApplicationBuilder.Resources + .OfType() + .FirstOrDefault(resource => string.Equals(resource.Name, buildResourceName, StringComparisons.ResourceName)); + + if (buildResource is null) + { + return; + } + + // The child and both dependency annotations were added as one unit by WithJavaBuildStep. Removing + // all three prevents a launch goal configured later from leaving a dangling wait on a resource + // that no longer runs. + builder.ApplicationBuilder.Resources.Remove(buildResource); + + foreach (var annotation in builder.Resource.Annotations + .Where(annotation => + annotation is WaitAnnotation wait && ReferenceEquals(wait.Resource, buildResource) + || annotation is ResourceRelationshipAnnotation relationship + && ReferenceEquals(relationship.Resource, buildResource)) + .ToArray()) + { + builder.Resource.Annotations.Remove(annotation); + } + + builder.WithAnnotation( + buildStep with { ResourceName = null }, + ResourceAnnotationMutationBehavior.Replace); + } + + /// + /// Overrides the build tool wrapper script path, for repositories whose wrapper is not in the + /// default location or does not use the default name. + /// + /// The Java application resource type. + /// The to configure. + /// The path to the wrapper script, absolute or relative to the resource's working directory. + /// A reference to the . + /// is . + /// is , empty, or whitespace. + /// + /// May be called before or after the build tool is configured. A later call re-points anything that + /// already resolved the default wrapper, so the result does not depend on the order of builder calls. + /// + [AspireExport] + public static IResourceBuilder WithWrapperPath( + this IResourceBuilder builder, + string wrapperPath) where T : JavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(wrapperPath); + + var resolvedWrapperPath = PathNormalizer.NormalizePathForCurrentPlatform( + Path.Combine(builder.Resource.WorkingDirectory, wrapperPath)); + + builder.WithAnnotation(new WrapperAnnotation(resolvedWrapperPath), ResourceAnnotationMutationBehavior.Replace); + + // Re-point anything that already captured the default wrapper. Without this, calling + // WithWrapperPath after WithMavenGoal was silently ignored. + if (builder.Resource.TryGetLastAnnotation(out var buildTool)) + { + builder.WithCommand(ResolveWrapperInvocation(builder.Resource, buildTool.Tool).Command); + } + + foreach (var buildStep in builder.Resource.Annotations.OfType()) + { + // Null outside run mode, where no build-step resource is created. + if (buildStep.ResourceName is not { } buildStepName) + { + continue; + } + + var buildResource = builder.ApplicationBuilder.Resources + .OfType() + .FirstOrDefault(r => string.Equals(r.Name, buildStepName, StringComparisons.ResourceName)); + + if (buildResource is not null) + { + builder.ApplicationBuilder.CreateResourceBuilder(buildResource) + .WithCommand(ResolveWrapperInvocation(builder.Resource, buildStep.Tool).Command); + } + } + + return builder; + } + + /// + /// Sets the main class an IDE launches when running or debugging this application. Has no effect on + /// how Aspire starts the process, which is decided by the JAR path, Maven goal, or Gradle task. + /// + /// The Java application resource type. + /// The resource builder for the Java application. + /// The fully qualified name of the class declaring main, for example com.example.Application. + /// A reference to the . + /// is . + /// is , empty, or whitespace. + /// + /// Only affects IDE execution. When omitted, the IDE resolves the main class from the project's build + /// files; set it explicitly when a project declares more than one class with a main method. + /// + [AspireExport] + public static IResourceBuilder WithMainClass( + this IResourceBuilder builder, + string mainClass) where T : JavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(mainClass); + + return builder.WithAnnotation(new JavaMainClassAnnotation(mainClass), ResourceAnnotationMutationBehavior.Replace); + } + + /// + /// Selects the JAR the generated container image runs, for projects whose build produces more than one. + /// + /// The Java application resource type. + /// The resource builder for the Java application. + /// The path to the JAR produced by the build, relative to the application directory, for example target/app.jar. + /// A reference to the . + /// is . + /// is , empty, or whitespace. + /// + /// Only affects publishing, and only when the application is built in the image. Without it the + /// container build selects the single JAR that is not a -plain, -sources, or + /// -javadoc artifact, and fails the build if that is ambiguous. + /// + /// This takes precedence over the JAR named by the jarPath overload of + /// , so a + /// resource can run one JAR locally and publish another. It has no effect on an application published + /// from a prebuilt JAR, because nothing is built in the image for it to select from. + /// + /// + [AspireExport] + public static IResourceBuilder WithJarArtifact( + this IResourceBuilder builder, + string jarPath) where T : JavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(jarPath); + + return builder.WithAnnotation(new JavaJarArtifactAnnotation(jarPath), ResourceAnnotationMutationBehavior.Replace); + } + + /// + /// Adds arguments to the Java Virtual Machine that runs the application. + /// + /// The Java application resource type. + /// The resource builder. + /// The JVM arguments, for example -Xmx512m. + /// A reference to the . + /// or is . + /// + /// Arguments are passed through the JAVA_TOOL_OPTIONS environment variable, which the JVM reads + /// however it was started — java -jar, a Maven goal, a Gradle task, or a container image's own + /// entrypoint, including the JVM those build tools fork. Values containing spaces are quoted, because + /// the JVM splits this variable on whitespace. + /// + /// This is also how a container image that already carries the OpenTelemetry Java agent turns it on, + /// since copies an agent from the build + /// context and so applies only to applications Aspire itself launches or builds: + /// WithJvmArgs("-javaagent:/app/opentelemetry-javaagent.jar"). + /// + /// + [AspireExport] + public static IResourceBuilder WithJvmArgs( + this IResourceBuilder builder, + params string[] args) where T : IJavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(args); + + if (args.Length == 0) + { + return builder; + } + + return builder.WithEnvironment(context => + { + // Keep JAVA_TOOL_OPTIONS as the single JVM-argument source even when an IDE owns the launch. + // DCP passes the resource environment to that JVM, so also emitting vmArgs would apply + // single-instance options such as -javaagent twice and can double-instrument the application. + AppendJavaToolOptions(context.EnvironmentVariables, args); + }); + } + + /// + /// Runs the application with the OpenTelemetry Java agent from the location the build tool writes it to. + /// + /// The Java application resource type. + /// The resource builder. + /// A reference to the . + /// is . + /// The resource has no Maven or Gradle build configured, so the agent location cannot be inferred. Raised when the application model is built, not when this is called. + /// + /// The agent is expected at target/agent/opentelemetry-javaagent.jar for Maven and + /// build/agent/opentelemetry-javaagent.jar for Gradle — the conventional output directory of each tool + /// with an agent subdirectory. The build has to put it there; nothing is downloaded. With Maven, copy it + /// with maven-dependency-plugin's copy goal bound to process-resources; with Gradle, + /// declare the agent in its own configuration and add a Copy task that compileJava depends on. + /// + /// May be called before or after the build tool is configured: which directory the agent is read from is + /// decided when the application model is built, so the result does not depend on the order of builder calls. + /// + /// + /// Because the build writes the agent, Aspire runs that build as its own resource in run mode and holds + /// the application until it finishes — including for Spring Boot and Quarkus, whose launch goals would + /// otherwise be the only build. This is required rather than an optimization: JAVA_TOOL_OPTIONS is + /// read by every JVM started beneath the resource, and the first of those is the wrapper's own, so an + /// agent the build has not written yet kills that JVM during VM initialization with "Error opening zip + /// file or JAR manifest missing" before the launch goal runs. + /// + /// + /// Use when the agent lives anywhere else, including + /// when it is committed to the repository or supplied by the container base image. + /// + /// + [AspireExport("withOtelAgentDefaultPath")] + public static IResourceBuilder WithOtelAgent( + this IResourceBuilder builder) where T : JavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + + // Deliberately not resolved here. WithMavenBuild may not have been called yet, and an eager read + // would make .WithOtelAgent().WithMavenBuild() throw while .WithMavenBuild().WithOtelAgent() + // worked - exactly the order dependence WithWrapperPath goes out of its way to avoid. + return builder.WithOtelAgentCore(agentPath: null); + } + + /// + /// The agent path to use, resolving the build tool's conventional location when none was authored. + /// + /// + /// The launch tool is not consulted, only the build: a resource can be launched from a prebuilt JAR and + /// still be built by Maven, and it is the build that decides whether the agent lands in target or + /// build. + /// + internal static string ResolveOtelAgentPath(IResource resource, JavaOtelAgentAnnotation annotation) + { + if (annotation.AgentPath is { } authored) + { + return authored; + } + + JavaBuildTool tool; + + if (resource.TryGetLastAnnotation(out var buildStep)) + { + tool = buildStep.Tool; + } + else if (resource is JavaAppResource app && app.HasAnnotationOfType()) + { + tool = ResolveDetectedBuildTool(app).Tool; + } + else + { + throw new InvalidOperationException( + $"Resource '{resource.Name}' has no Maven or Gradle build configured, so the OpenTelemetry agent location cannot be inferred. " + + $"Call WithMavenBuild or WithGradleBuild, or pass the agent path to WithOtelAgent."); + } + + var outputDirectory = tool is JavaBuildTool.Gradle ? "build" : "target"; + + return Path.Combine(outputDirectory, "agent", "opentelemetry-javaagent.jar"); + } + + /// + /// Runs the application with the OpenTelemetry Java agent so it exports traces, metrics, and logs to Aspire. + /// + /// The Java application resource type. + /// The resource builder. + /// The path to the opentelemetry-javaagent.jar file. + /// A reference to the . + /// is . + /// is , empty, or whitespace. + /// + /// The agent is not downloaded. Obtain it as a build dependency, or from + /// https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases, and point this at the + /// resulting file. The OTLP exporter is configured by AddJavaApp regardless of whether an agent + /// is used, so call this only when you want the agent's automatic instrumentation. + /// + /// A relative is resolved against the application directory and made absolute + /// when running locally. This is required, not cosmetic: JAVA_TOOL_OPTIONS is inherited by every JVM + /// started beneath the resource, and build tools start JVMs whose working directory is not the application + /// directory. The Gradle daemon, for example, starts from its own distribution directory, so a relative + /// -javaagent: path fails to resolve and the daemon dies during VM initialization with + /// "Error opening zip file or JAR manifest missing". + /// + /// + /// A relative path names a file the build produces, so Aspire runs that build as its own resource in run + /// mode and holds the application until it finishes. An absolute path names a file that exists + /// independently of the build, so no build resource is added. + /// + /// + /// In publish mode a relative path is rewritten to the location the generated Dockerfile copies the + /// agent to, because the path has to be interpreted inside the container rather than on the build + /// machine. An absolute path is emitted unchanged, since it cannot have come from the build context + /// and must be supplied by the base image or a mount. + /// + /// + [AspireExport] + public static IResourceBuilder WithOtelAgent( + this IResourceBuilder builder, + string agentPath) where T : JavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(agentPath); + + return builder.WithOtelAgentCore(agentPath); + } + + private static IResourceBuilder WithOtelAgentCore( + this IResourceBuilder builder, + string? agentPath) where T : JavaAppResource + { + // Recorded so the container build can copy the agent forward. The environment variable alone + // would leave a published image pointing at a JAR that is not in it. + var isFirstCall = !builder.Resource.HasAnnotationOfType(); + builder.WithAnnotation(new JavaOtelAgentAnnotation(agentPath), ResourceAnnotationMutationBehavior.Replace); + + EnsureBuildRunsBeforeLaunch(builder); + + // Callbacks accumulate even though the annotation replaces, so registering one per call would + // put a -javaagent: entry per call into JAVA_TOOL_OPTIONS and start the JVM with several agents. + // Only the first call registers, and it reads the replaced annotation so it sees the last path. + if (!isFirstCall) + { + return builder; + } + + return builder.WithEnvironment(context => + { + if (!builder.Resource.TryGetLastAnnotation(out var agent)) + { + return; + } + + var authored = ResolveOtelAgentPath(builder.Resource, agent); + + string resolved; + + if (context.ExecutionContext.IsRunMode) + { + resolved = Path.GetFullPath(Path.Combine(builder.Resource.WorkingDirectory, authored)); + } + else if (JavaDockerfileGenerator.TryGetBuildProducedAgentPath(builder.Resource, out _)) + { + // /app/agent.jar is where the generated Dockerfile copies the agent to. When the + // developer wrote the Dockerfile, nothing put it there, and a JVM told to load an agent + // that is not in the image dies during VM initialization with "Error opening zip file or + // JAR manifest missing" — which says nothing about the cause. + if (builder.Resource.HasAnnotationOfType()) + { + throw new DistributedApplicationException( + $"Java application '{builder.Resource.Name}' cannot be published because it uses " + + $"the Dockerfile in '{builder.Resource.WorkingDirectory}' and its OpenTelemetry " + + $"agent path '{authored}' is relative to the build output. Aspire copies a " + + $"build-produced agent into the image only in the Dockerfile it generates. Copy " + + $"the agent in your Dockerfile and pass its path inside the image to " + + $"{nameof(WithOtelAgent)}, for example WithOtelAgent(\"/opt/otel/javaagent.jar\")."); + } + + resolved = JavaDockerfileGenerator.ContainerAgentPath; + } + else + { + resolved = authored; + } + + AppendJavaToolOptions(context.EnvironmentVariables, [$"-javaagent:{resolved}"]); + }); + } + + /// + /// Adds the build whose output the application needs before it starts, when there is one. + /// + /// + /// + /// Two things need a build in front of a launch goal that would otherwise compile as it runs. + /// + /// + /// A build-produced OpenTelemetry agent is the first. JAVA_TOOL_OPTIONS is read by every JVM + /// started beneath the resource, and for a Maven or Gradle launch the first of those is the wrapper's + /// own. A -javaagent: naming a file the build has not written yet therefore kills that JVM + /// during VM initialization with "Error opening zip file or JAR manifest missing", before the launch + /// goal that would have produced the agent ever runs. The build has to be a resource of its own so it + /// runs in a JVM that is not carrying the agent. + /// + /// + /// A Quarkus resource handed to an IDE is the second: the IDE starts it from the fast JAR the build + /// packages rather than from the dev-mode goal. See . + /// + /// + private static void EnsureBuildRunsBeforeLaunch(IResourceBuilder builder) + where T : JavaAppResource + { + // Publish resolves the agent to the path the generated Dockerfile copies it to, the image build + // runs the packaging command itself, and no IDE launches anything, so there is no resource to add. + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode + || !RequiresBuildBeforeLaunch(builder)) + { + return; + } + + // Naming the resource and resolving its wrapper both need the tool, which is unknown when the + // agent is configured before the build tool is. That ordering stays supported: the later + // WithMavenBuild, WithGradleBuild, WithMavenGoal, or WithGradleTask call reaches + // WithJavaBuildStep, which adds the resource once RequiresBuildBeforeLaunch is true. + if (!TryResolveConfiguredBuildTool(builder.Resource, out var tool)) + { + return; + } + + var buildArgs = builder.Resource.TryGetLastAnnotation(out var buildStep) + ? buildStep.Args + : builder.Resource.HasAnnotationOfType() + ? ResolveDetectedBuildTool(builder.Resource).Configuration.BuildArgs + : DefaultBuildArgs(tool); + + builder.WithJavaBuildStep( + tool, + buildResourceName: $"{builder.Resource.Name}-{(tool is JavaBuildTool.Gradle ? "gradle" : "maven")}-build", + buildArgs: buildArgs); + } + + /// + /// The packaging arguments and + /// default to. + /// + private static string[] DefaultBuildArgs(JavaBuildTool tool) => + tool is JavaBuildTool.Gradle ? ["clean", "build"] : ["clean", "package"]; + + /// + /// Whether something other than the launch goal needs the build's output before the application + /// starts, which makes a build resource mandatory even for a goal that compiles as it runs. + /// + private static bool RequiresBuildBeforeLaunch(IResourceBuilder builder) + where T : JavaAppResource + => HasBuildProducedOtelAgent(builder.Resource) + || IdeLaunchesAPackagedArtifact(builder); + + /// + /// Whether this run hands the resource to an IDE that starts it from an artifact the build produces + /// rather than from the launch goal. + /// + /// + /// Only Quarkus is in this position. Its entry point lives in the fast JAR's boot classpath rather + /// than in the project, so has to hand the debug adapter + /// quarkus-app/quarkus-run.jar — and on a clean checkout no build has written it. The adapter's + /// response to being given no entry point is to ask which of the workspace's main classes to start, + /// a prompt nobody who has not read the AppHost can answer, so the build has to run first. + /// + /// Spring Boot needs nothing here: the adapter starts it from the classpath the Java language server + /// already compiled, so a build would only delay the session. + /// + /// + private static bool IdeLaunchesAPackagedArtifact(IResourceBuilder builder) + where T : JavaAppResource + => builder.Resource.HasAnnotationOfType() + && builder.Resource.SupportsDebugging(builder.ApplicationBuilder.Configuration, out _); + + /// + /// Whether the resource's OpenTelemetry agent is one its own build writes, rather than one the machine + /// or the base image already provides. + /// + private static bool HasBuildProducedOtelAgent(JavaAppResource resource) + { + if (!resource.TryGetLastAnnotation(out var agent)) + { + return false; + } + + // The default location is under the build tool's output directory, so it is always build-produced. + // An authored absolute path names a file that exists independently of the build; a relative one is + // resolved against the application directory, which is where the build writes. + return agent.AgentPath is not { } authored || !JavaDockerfileGenerator.IsPathRootedOnAnyPlatform(authored); + } + + /// + /// Contributes the arguments that turn the resource's command into a complete invocation: + /// -jar <path> for a prebuilt JAR, or the goal/task for a build tool launch. + /// + private static void AddLaunchArgs(JavaAppResource resource, CommandLineArgsCallbackContext ctx) + { + JavaBuildTool tool; + string[] args; + + if (resource.TryGetLastAnnotation(out var buildTool)) + { + tool = buildTool.Tool; + args = buildTool.Args; + } + else if (resource.HasAnnotationOfType()) + { + var detected = ResolveDetectedBuildTool(resource); + tool = detected.Tool; + args = detected.Configuration.LaunchArgs; + } + else + { + tool = default; + args = []; + } + + if (args.Length > 0) + { + foreach (var leadingArg in ResolveWrapperInvocation(resource, tool).LeadingArgs) + { + ctx.Args.Add(leadingArg); + } + + foreach (var arg in args) + { + ctx.Args.Add(arg); + } + + return; + } + + if (resource.TryGetLastAnnotation(out var jar)) + { + ctx.Args.Add("-jar"); + ctx.Args.Add(NormalizeJarPathForJava(jar.JarPath)); + + return; + } + + // Reached when AddJavaApp was called without a jar path and without a Maven goal or Gradle task. + // The resource would otherwise start as a bare "java" with no arguments, which prints the JVM + // usage text and exits. + throw new InvalidOperationException( + $"Java application '{resource.Name}' has no launch mode configured. Call {nameof(WithMavenGoal)} " + + $"or {nameof(WithGradleTask)} to run it through a build tool, or use the {nameof(AddJavaApp)} " + + "overload that takes a jarPath to run a prebuilt JAR."); + } + + private static string NormalizeJarPathForJava(string path) + { + // Java accepts '/' on Windows, so one target-neutral form preserves authored forward slashes + // while also making a Windows-authored relative path usable on Linux and macOS. + return path.Replace('\\', '/'); + } + + /// + /// Resolves the wrapper script for , honouring an override set by + /// . + /// + /// + /// + /// A wrapper is required; a globally installed mvn or gradle is deliberately not used as + /// a fallback. The wrapper pins the exact tool version in the repository, so the AppHost, CI, and the + /// published container image all build with the same one. Falling back to whatever happens to be on + /// PATH would make the build depend on each developer's machine and silently change behaviour + /// when that version differs, which is precisely what the wrapper exists to prevent. + /// + /// + /// Existence is deliberately not checked here. This runs while the AppHost is still being authored, + /// and is documented as usable after the + /// build tool is configured — so a project whose only wrapper is a custom one would otherwise fail + /// inside WithMavenGoal/WithGradleTask, before the override could be applied. + /// performs the check once the configuration is final. + /// + /// + /// + /// The command that launches a build tool wrapper, together with any arguments that must precede + /// the wrapper's own. + /// + /// + /// On Unix the wrapper is invoked through sh instead of being executed directly. Git does not + /// record an executable bit on Windows, so a repository committed from there checks out mvnw + /// and gradlew as mode 644 and executing them fails with "permission denied". Both are POSIX + /// shell scripts, so sh runs them either way. The container build already does this for the + /// same reason (see ), and run mode has to match or an identical + /// checkout fails on Linux and macOS while succeeding inside the image. + /// + /// On Windows the wrappers are the mvnw.cmd and gradlew.bat batch files, which sh + /// cannot run. They are launched through the command interpreter rather than directly, because a + /// batch file started with redirected stdout can silently produce no output — the same constraint + /// NpmRunner hits with npm.cmd, and the same one + /// JavaAppHostToolchainResolver.GetToolInvocation handles for Java AppHosts. + /// + /// + private static (string Command, string[] LeadingArgs) ResolveWrapperInvocation(JavaAppResource resource, JavaBuildTool tool) + => WrapperInvocationFor( + JavaBuildToolResolver.ResolveWrapperPath(resource, tool, OperatingSystem.IsWindows()), + resource.WorkingDirectory, + OperatingSystem.IsWindows()); + + /// + internal static (string Command, string[] LeadingArgs) WrapperInvocationFor(string wrapperPath, string workingDirectory, bool isWindows) + { + if (!isWindows) + { + return ("sh", [wrapperPath]); + } + + // Passing the wrapper as a path relative to the resource's working directory keeps it short and + // usually free of spaces, which matters because cmd.exe strips quotes in a way that does not + // match how arguments are escaped for it: when the *first* token on the line is quoted, cmd + // removes that quote and the last one on the line, mangling everything in between. + var relativeWrapperPath = Path.GetRelativePath(workingDirectory, wrapperPath); + + // "call" makes that unreachable rather than merely unlikely. A wrapper reached through a + // directory with a space in its name — WithWrapperPath("../build tools/mvnw.cmd") — is quoted + // when the command line is built, and quoting the first token is exactly what triggers the + // stripping. With "call" ahead of it the first character is never a quote, so the rule cannot + // apply, and "call" is how a batch file is meant to be invoked from another anyway: it returns + // control and propagates the wrapper's exit code. + // See the quote-processing rules printed by `cmd /?`. + return (Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", ["/c", "call", relativeWrapperPath]); + } + + private static string WrapperCommand() + => OperatingSystem.IsWindows() + ? Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe" + : "sh"; + + /// + /// Throws when the wrapper the resource will launch is not on disk. + /// + /// + /// Deferred to resource start so that the whole AppHost has been authored first: only then is it + /// known whether a override supplied + /// the wrapper that the default location lacks. + /// + private static void ValidateWrapperExists(JavaAppResource resource, JavaBuildTool tool) + { + var wrapperPath = JavaBuildToolResolver.ResolveWrapperPath(resource, tool, OperatingSystem.IsWindows()); + + if (File.Exists(wrapperPath)) + { + return; + } + + if (resource.HasAnnotationOfType()) + { + throw new DistributedApplicationException( + $"Java application '{resource.Name}' has no wrapper at '{wrapperPath}'. That path came " + + $"from {nameof(WithWrapperPath)} and is resolved relative to the application's working " + + $"directory '{resource.WorkingDirectory}'."); + } + + var wrapperName = JavaBuildToolResolver.GetDefaultWrapperName(tool, OperatingSystem.IsWindows()); + + throw new DistributedApplicationException( + $"Java application '{resource.Name}' has no {wrapperName} in '{resource.WorkingDirectory}' " + + $"or in the build root above it. Aspire runs Java applications through the project's own " + + $"wrapper so that every build uses the tool version the repository pins. Generate one with " + + $"{GenerateWrapperCommand(tool)}, or point at an existing wrapper with {nameof(WithWrapperPath)}."); + } + + /// + /// Arranges for the resource's wrapper to be validated once its configuration is final. + /// + private static IResourceBuilder WithDeferredWrapperValidation( + this IResourceBuilder builder, + JavaBuildTool tool) where T : JavaAppResource + { + // WithMavenGoal and WithMavenBuild both want this, and either may be called more than once, so + // the subscription is registered at most once per tool. + if (builder.Resource.Annotations.OfType().Any(a => a.Tool == tool)) + { + return builder; + } + + builder.WithAnnotation(new JavaWrapperValidationAnnotation(tool)); + + return builder.OnBeforeResourceStarted((resource, _, _) => + { + ValidateWrapperExists(resource, tool); + return Task.CompletedTask; + }); + } + + /// + /// The command that adds a wrapper to an existing project, named in the error raised when one is missing. + /// + internal static string GenerateWrapperCommand(JavaBuildTool tool) => tool switch + { + // -N keeps the goal from recursing into the modules of a multi-module build, which would litter + // every module with a wrapper that only the root needs. + JavaBuildTool.Maven => "'mvn -N wrapper:wrapper'", + JavaBuildTool.Gradle => "'gradle wrapper'", + _ => throw new ArgumentOutOfRangeException(nameof(tool), tool, null) + }; + + /// + /// Appends to the JAVA_TOOL_OPTIONS environment variable, preserving + /// whatever is already there. + /// + /// + /// The existing value may be any expression Aspire supports — a plain string, a + /// , a parameter, or an endpoint reference — so a non-string value is + /// folded into a new rather than being read as a string. An earlier + /// string-only implementation silently discarded non-string values. + /// + private static void AppendJavaToolOptions(Dictionary environmentVariables, string[] values) + { + var appended = string.Join(' ', values.Select(QuoteIfNeeded)); + + if (!environmentVariables.TryGetValue(JavaToolOptions, out var existing) || existing is null) + { + environmentVariables[JavaToolOptions] = appended; + return; + } + + environmentVariables[JavaToolOptions] = existing switch + { + string s when string.IsNullOrEmpty(s) => appended, + string s => $"{s} {appended}", + ReferenceExpression re => ReferenceExpression.Create($"{re} {appended}"), + IValueProvider valueProvider when existing is IManifestExpressionProvider manifestProvider + => ReferenceExpression.Create($"{new ComposableValue(valueProvider, manifestProvider)} {appended}"), + // Anything else is a plain value (a number, a bool, a string-convertible object) that the + // environment layer would format the same way. + _ => $"{existing} {appended}" + }; + } + + /// + /// Pairs the two facets Aspire needs to compose a value into a . + /// + /// + /// is + /// constrained to a single type implementing both interfaces, which a value typed as object + /// cannot satisfy without this adapter. + /// + private sealed class ComposableValue(IValueProvider valueProvider, IManifestExpressionProvider manifestExpressionProvider) + : IValueProvider, IManifestExpressionProvider + { + public string ValueExpression => manifestExpressionProvider.ValueExpression; + + public ValueTask GetValueAsync(CancellationToken cancellationToken) => valueProvider.GetValueAsync(cancellationToken); + } + + /// + /// Quotes a JVM option whose value contains whitespace. + /// + /// + /// The JVM tokenizes JAVA_TOOL_OPTIONS on whitespace, so an unquoted + /// -Djavax.net.ssl.trustStore=C:\Users\First Last\AppData\...\bundle.p12 arrives as two + /// unrelated options and TLS fails with no useful diagnostic. Only the value after the first + /// = is quoted, because the JVM does not accept a quoted -Dkey=value as a whole. + /// + /// An option that already contains a quote is passed through untouched. Quoting it again would turn + /// an author's own -Dmsg="hi there" into -Dmsg=""hi there"", and deciding whether the + /// existing quotes already cover the whitespace needs a real shell-style parser. Handing the option + /// back unchanged leaves quoting to the author who introduced it, which is the only reading that + /// cannot corrupt an already-correct value. + /// + /// + private static string QuoteIfNeeded(string option) + { + if (!option.Any(char.IsWhiteSpace) || option.Contains('"')) + { + return option; + } + + var separatorIndex = option.IndexOf('='); + + return separatorIndex < 0 + ? $"\"{option}\"" + : $"{option[..(separatorIndex + 1)]}\"{option[(separatorIndex + 1)..]}\""; + } + + /// + /// Builds a PKCS#12 trust store containing Aspire's development certificate and any configured + /// certificate authorities, and points the JVM at it through JAVA_TOOL_OPTIONS. + /// + /// + /// The JVM ignores the SSL_CERT_DIR and SSL_CERT_FILE variables Aspire sets for other + /// languages, so without this a Java application fails to export telemetry over HTTPS with + /// PKIX path building failed. See https://github.com/CommunityToolkit/Aspire/issues/1517. + /// + /// javax.net.ssl.trustStore replaces the JVM's trust anchors rather than adding to + /// them: once it is set, cacerts is not consulted at all. Aspire therefore has to request + /// so the generated bundle carries the system roots + /// alongside the development certificate. If the scope is still + /// , the bundle would contain only Aspire's own + /// certificates, and pointing the JVM at it would strip every public CA — breaking outbound HTTPS + /// from the application and, because JAVA_TOOL_OPTIONS is inherited by the build tool's JVM, + /// breaking Maven Central and Gradle distribution downloads too. In that case the override is + /// skipped instead. + /// + /// + private static async Task JavaCertificateTrustCallback(CertificateTrustConfigurationCallbackAnnotationContext ctx) + { + if (ctx.Scope == CertificateTrustScope.Append) + { + var resourceLoggerService = ctx.ExecutionContext.Services.GetRequiredService(); + resourceLoggerService.GetLogger(ctx.Resource).LogInformation( + "Certificate trust scope is set to 'Append', but the JVM's trust store setting replaces the default " + + "certificate authorities rather than adding to them. Skipping the trust store override so the JVM " + + "keeps trusting its built-in certificate authorities."); + return; + } + + var bundlePath = ctx.CreateCustomBundle((certificates, ct) => + { + var pkcs12Builder = new Pkcs12Builder(); + var safeContents = new Pkcs12SafeContents(); + + // Oracle/OpenJDK trusted cert bag attribute OID. Without it the JDK reads the entries as + // key entry candidates rather than trustedCertEntry, and the store trusts nothing. + // See sun.security.pkcs12.PKCS12KeyStore in the OpenJDK sources. + var trustAnchorOid = new Oid("2.16.840.1.113894.746875.1.1"); + var asnWriter = new AsnWriter(AsnEncodingRules.DER); + asnWriter.WriteObjectIdentifier("2.5.29.37.0"); // anyExtendedKeyUsage + var trustAnchorValue = asnWriter.Encode(); + + for (var i = 0; i < certificates.Count; i++) + { + // Re-import the public part only so no private key can reach the trust store. + using var publicCert = X509CertificateLoader.LoadCertificate(certificates[i].Export(X509ContentType.Cert)); + var certBag = safeContents.AddCertificate(publicCert); + certBag.Attributes.Add( + new CryptographicAttributeObject( + trustAnchorOid, + new AsnEncodedDataCollection(new AsnEncodedData(trustAnchorOid, trustAnchorValue)))); + } + + pkcs12Builder.AddSafeContentsUnencrypted(safeContents); + + // Sealed with an empty password on purpose. The MAC still protects integrity, and a trust + // store holds only public certificates, so a password would protect nothing while appearing + // in JAVA_TOOL_OPTIONS — which the dashboard shows unmasked, which every process the build + // tool forks inherits, and which the JVM itself echoes to stderr as + // "Picked up JAVA_TOOL_OPTIONS: ...". + pkcs12Builder.SealWithMac(string.Empty, HashAlgorithmName.SHA256, iterationCount: 2048); + + return Task.FromResult(pkcs12Builder.Encode()); + }); + + var bundlePathValue = await bundlePath.GetValueAsync(ctx.CancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(bundlePathValue)) + { + return; + } + + AppendJavaToolOptions( + ctx.EnvironmentVariables, + [ + $"-Djavax.net.ssl.trustStore={bundlePathValue}", + "-Djavax.net.ssl.trustStoreType=PKCS12" + ]); + } + + [Experimental("ASPIREEXTENSION001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + internal static IResourceBuilder WithVSCodeDebugging(this IResourceBuilder builder) + where T : JavaAppResource + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithDebugSupport( + mode => + { + var (mainClass, classPaths) = ResolveEntryPointForIde(builder.Resource); + + return new JavaLaunchConfiguration + { + Mode = mode, + WorkingDirectory = builder.Resource.WorkingDirectory, + MainClass = mainClass, + ClassPaths = classPaths, + // projectName scopes the adapter's entry point resolution to this resource's own + // project. It is sent alongside mainClass rather than only as a fallback: given + // mainClass alone the adapter searches every project in the workspace, and a class + // that turns up in more than one fails the launch outright with + // "Main class ... isn't unique in the workspace". That happens whenever a directory + // is covered both by its own build file and by another project's source root, which + // is easy to arrange by accident and impossible to diagnose from the error. + // + // The name is read out of pom.xml or settings.gradle rather than derived from the + // resource name, so it is the name m2e and Buildship import the project under. + // + // It is omitted when explicit class paths are supplied, because such a resource runs + // from a prebuilt archive rather than from a project the language server compiled, + // so there may be no imported project to scope to. + ProjectName = classPaths is { Length: > 0 } + ? null + : TryResolveIdeProjectName(builder.Resource), + BuildTool = TryResolveConfiguredBuildTool(builder.Resource, out var buildTool) + ? buildTool.ToString().ToLowerInvariant() + : null + }; + }, + "java"); + } + + /// + /// Determines what the IDE should launch: the entry point class and, when the resource runs a + /// prebuilt JAR, the classpath that contains it. + /// + /// + /// A JAR is never sent as the main class. The Java debug adapter documents that attribute as a + /// fully qualified class name or a .java source path, so it does not open archives; passing + /// a JAR path made the adapter fail to resolve an entry point. Instead the archive goes on the + /// classpath and its manifest's Main-Class becomes the main class, which is exactly what + /// java -jar does and works for Spring Boot fat JARs too (their manifest names + /// JarLauncher, the same class java -jar would run). + /// + private static (string? MainClass, string[]? ClassPaths) ResolveEntryPointForIde(JavaAppResource resource) + { + // An explicit WithMainClass always wins, including over a JAR's manifest, so a resource that + // ships a launcher manifest can still be debugged at its real entry point. + var explicitMainClass = resource.TryGetLastAnnotation(out var mainClass) + ? mainClass.MainClass + : null; + + if (!resource.TryGetLastAnnotation(out var jar)) + { + if (explicitMainClass is not null) + { + return (explicitMainClass, null); + } + + // Quarkus is the exception to the rule below. Its entry point lives in the fast JAR's boot + // classpath rather than in the project, so the language server's classpath cannot start it and + // the archive has to be supplied. Breakpoints still bind, because the debugger maps loaded + // classes back to project sources by name. + if (TryGetQuarkusRunJar(resource) is { } quarkusRunJar) + { + return (TryReadJarManifestMainClass(quarkusRunJar), [quarkusRunJar]); + } + + // A Maven or Gradle resource deliberately sends no classpath: the language server already + // knows the project's, and supplying one built from the JAR would bind breakpoints to + // compiled classes instead of the source the user is editing. + return (TryDiscoverProjectMainClass(resource), null); + } + + // Resolved to an absolute path because the adapter reads the archive before the debuggee's + // working directory exists. Normalize first because a Windows-authored relative path otherwise + // names a literal backslash-containing file when the AppHost runs on Linux or macOS. + var jarPath = Path.GetFullPath(Path.Combine(resource.WorkingDirectory, NormalizeJarPathForJava(jar.JarPath))); + + return (explicitMainClass ?? TryReadJarManifestMainClass(jarPath), [jarPath]); + } + + /// + /// Recovers the entry point of a Maven or Gradle resource from the JAR its build produced, or + /// returns when no single application JAR is there to read. + /// + /// + /// + /// Without this, a Spring Boot resource launched through spring-boot:run or bootRun + /// sends no main class, and vscjava.vscode-java-debug responds by asking the user to pick one + /// from every main class in the workspace. That prompt appears on each launch, offers classes + /// belonging to other resources, and cannot be answered correctly by anyone who has not read the + /// AppHost — so the entry point is resolved here instead. + /// + /// + /// The build output is the right place to look because it is the only artifact that names the entry + /// point without parsing a build file: the Spring Boot plugins record it while repackaging, and a + /// plain JAR carries it as Main-Class. + /// + /// + private static string? TryDiscoverProjectMainClass(JavaAppResource resource) + { + if (!TryResolveConfiguredBuildTool(resource, out var buildTool)) + { + return null; + } + + // Both tools have a single conventional output directory, and neither lets the AppHost know + // about a redirected one without parsing the build file. + var outputDirectory = buildTool switch + { + JavaBuildTool.Maven => Path.Combine(resource.WorkingDirectory, "target"), + JavaBuildTool.Gradle => Path.Combine(resource.WorkingDirectory, "build", "libs"), + _ => null + }; + + if (outputDirectory is null || !Directory.Exists(outputDirectory)) + { + return null; + } + + string[] jars; + + try + { + jars = Directory.GetFiles(outputDirectory, "*.jar", SearchOption.TopDirectoryOnly); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + + // A build can leave several archives behind: Maven publishes -sources and -javadoc alongside the + // application, and the Gradle Spring Boot plugin writes the unrepackaged classes to -plain.jar. + // Only one of them is the application, and guessing between two candidates would be worse than + // letting the adapter resolve the class itself. + var applicationJars = jars + .Where(jarPath => !IsAuxiliaryJar(jarPath)) + .ToArray(); + + if (applicationJars.Length != 1) + { + return null; + } + + if (TryReadJarManifest(applicationJars[0]) is not { } manifest) + { + return null; + } + + // Spring Boot repackaging points Main-Class at its own launcher and moves the application's + // entry point to Start-Class, so Start-Class is what a debugger should start. + // https://docs.spring.io/spring-boot/specification/executable-jar/launching.html + if (manifest.TryGetValue("Start-Class", out var startClass) && !string.IsNullOrWhiteSpace(startClass)) + { + return startClass; + } + + if (!manifest.TryGetValue("Main-Class", out var entryPoint) || string.IsNullOrWhiteSpace(entryPoint)) + { + return null; + } + + // A launcher is only startable with the fat JAR on the classpath, which is exactly what this + // path does not send. Reporting it would launch a JVM that fails with ClassNotFoundException. + return entryPoint.StartsWith("org.springframework.boot.loader.", StringComparison.Ordinal) + ? null + : entryPoint; + } + + /// + /// Gets the Quarkus fast JAR the build produced, or when the resource is not a + /// Quarkus application or has not been built yet. + /// + /// + /// Quarkus's default packaging leaves the runnable artifact at quarkus-app/quarkus-run.jar under the + /// build tool's output directory. The JAR that sits directly in that output directory is the plain, + /// unrunnable one — it has no Main-Class — so the ordinary discovery path finds nothing to report and + /// the debugger would fall back to asking the user which application to start. + /// See https://quarkus.io/guides/maven-tooling#fast-jar. + /// + private static string? TryGetQuarkusRunJar(JavaAppResource resource) + { + if (!resource.HasAnnotationOfType() + || !TryResolveConfiguredBuildTool(resource, out var buildTool)) + { + return null; + } + + var runJar = Path.Combine( + resource.WorkingDirectory, + buildTool is JavaBuildTool.Gradle ? "build" : "target", + QuarkusFastJarDirectory, + QuarkusRunJarName); + + return File.Exists(runJar) ? Path.GetFullPath(runJar) : null; + } + + /// + /// Compares two directory paths for equivalence, tolerating a trailing separator and, on Windows and + /// macOS, differences in case that the file system itself ignores. + /// + private static bool ArePathsEquivalent(string left, string right) + => string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase); + + private static bool IsAuxiliaryJar(string jarPath) + { + var fileName = Path.GetFileName(jarPath); + + return fileName.EndsWith("-sources.jar", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith("-javadoc.jar", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith("-plain.jar", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Resolves the name the Java language server imported this resource's project under, so the debug + /// adapter can scope entry point resolution to it instead of searching the whole workspace. + /// Returns when the imported name cannot be predicted with confidence. + /// + private static string? TryResolveIdeProjectName(JavaAppResource resource) + { + if (!TryResolveConfiguredBuildTool(resource, out var buildTool)) + { + return null; + } + + var declaredName = buildTool switch + { + JavaBuildTool.Maven => TryReadMavenArtifactId(Path.Combine(resource.WorkingDirectory, "pom.xml")), + JavaBuildTool.Gradle => TryReadGradleProjectName(resource.WorkingDirectory), + _ => null + }; + + if (declaredName is null) + { + return null; + } + + // The declared name is only used when the project directory is named the same, because that is + // the case where the language server is known to import the project under it. The two are not + // always the same: a Gradle build declaring `rootProject.name = 'javaspringboot-apphost'` inside + // a folder named JavaSpringBoot.AppHost.Java is imported as + // "javaspringboot-apphost-JavaSpringBoot.AppHost.Java", appending the directory to keep the name + // unambiguous. + // + // Guessing wrong is worse than not guessing. Without a project name the adapter resolves the + // entry point across the whole workspace, which succeeds whenever that entry point is unique; + // with a name no project answers to, every launch fails. + var directoryName = new DirectoryInfo(resource.WorkingDirectory).Name; + + return string.Equals(declaredName, directoryName, StringComparison.Ordinal) ? declaredName : null; + } + + /// + /// Reads a POM's own artifactId, which is the name the language server imports a Maven + /// project under. + /// + private static string? TryReadMavenArtifactId(string pomPath) + { + if (!File.Exists(pomPath)) + { + return null; + } + + try + { + var document = XDocument.Load(pomPath); + + // Only a direct child of identifies this module. Nearly every Spring Boot POM also + // declares spring-boot-starter-parent, and a + // descendant search would find that one first and name the wrong project. + // + // Matched on LocalName because a POM may or may not declare the Maven namespace: + // ... catalog + var artifactId = document.Root? + .Elements() + .FirstOrDefault(element => element.Name.LocalName == "artifactId")? + .Value; + + return string.IsNullOrWhiteSpace(artifactId) ? null : artifactId.Trim(); + } + catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Reads rootProject.name from a Gradle settings file, falling back to the directory name + /// that Gradle itself defaults a project's name to. + /// + private static string? TryReadGradleProjectName(string workingDirectory) + { + foreach (var fileName in (ReadOnlySpan)["settings.gradle", "settings.gradle.kts"]) + { + var settingsPath = Path.Combine(workingDirectory, fileName); + + if (!File.Exists(settingsPath)) + { + continue; + } + + try + { + // Groovy and the Kotlin DSL differ only in quoting: + // rootProject.name = 'orders' + // rootProject.name = "orders" + if (GradleRootProjectNameRegex().Match(File.ReadAllText(settingsPath)) is { Success: true } match) + { + return match.Groups["name"].Value; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Fall through to the directory name. + } + } + + // https://docs.gradle.org/current/userguide/multi_project_builds.html — a build that does not + // name itself takes the name of the directory containing it. + var directoryName = Path.GetFileName(workingDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + + return string.IsNullOrEmpty(directoryName) ? null : directoryName; + } + + [GeneratedRegex(@"rootProject\.name\s*=\s*['""](?[^'""]+)['""]")] + private static partial Regex GradleRootProjectNameRegex(); + + /// + /// Reads Main-Class from a JAR's manifest, or returns when the archive + /// is missing, unreadable, or declares no entry point. + /// + /// + /// + /// Failures are non-fatal on purpose. The JAR is usually produced by a build step that runs before + /// launch, but a user can start a debug session against a stale or half-written archive. Returning + /// null lets the IDE resolve the entry point from the project instead of failing the whole run. + /// + /// + /// META-INF/MANIFEST.MF is a line-oriented Name: value format where each line is + /// limited to 72 bytes and longer values continue on the next line with a single leading space, + /// which the space must be stripped from. Names are case-insensitive. For example: + /// + /// + /// Manifest-Version: 1.0 + /// Main-Class: com.example.catalog.averylongpackagename.that.wraps.Catalo + /// gApplication + /// + /// + /// See https://docs.oracle.com/en/java/javase/25/docs/specs/jar/jar.html#jar-manifest. + /// + /// + private static string? TryReadJarManifestMainClass(string jarPath) + { + if (TryReadJarManifest(jarPath) is not { } manifest) + { + return null; + } + + return manifest.TryGetValue("Main-Class", out var mainClass) && !string.IsNullOrWhiteSpace(mainClass) + ? mainClass + : null; + } + + /// + /// Reads the main section of a JAR's manifest, or returns when the archive is + /// missing, unreadable, or carries no manifest. + /// + private static Dictionary? TryReadJarManifest(string jarPath) + { + try + { + using var archive = ZipFile.OpenRead(jarPath); + + // The JAR specification requires this exact name, but archive entry lookup is + // case-sensitive while some tools write the directory in a different case. + var manifestEntry = archive.Entries.FirstOrDefault( + entry => string.Equals(entry.FullName, "META-INF/MANIFEST.MF", StringComparison.OrdinalIgnoreCase)); + + if (manifestEntry is null) + { + return null; + } + + using var reader = new StreamReader(manifestEntry.Open()); + + // Attribute names are case-insensitive per the specification. + var attributes = new Dictionary(StringComparer.OrdinalIgnoreCase); + string? name = null; + var value = new StringBuilder(); + + while (reader.ReadLine() is { } line) + { + // A blank line ends the main section. Everything after it describes individual archive + // entries and would overwrite the main attributes with per-entry ones of the same name. + if (line.Length == 0) + { + break; + } + + // Values longer than 72 bytes continue on the following line behind a single space, + // which belongs to the encoding rather than the value. + if (line[0] == ' ') + { + if (name is not null) + { + value.Append(line, 1, line.Length - 1); + } + + continue; + } + + Commit(); + + var separator = line.IndexOf(':'); + + if (separator < 0) + { + name = null; + continue; + } + + name = line[..separator]; + value.Append(line[(separator + 1)..].TrimStart()); + } + + Commit(); + + return attributes; + + void Commit() + { + if (name is not null && value.Length > 0) + { + attributes[name] = value.ToString(); + } + + name = null; + value.Clear(); + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) + { + return null; + } + } +} + +#pragma warning restore ASPIREPIPELINES003 +#pragma warning restore ASPIREPIPELINES001 +#pragma warning restore ASPIREDOCKERFILEBUILDER001 diff --git a/src/Aspire.Hosting.Java/JavaJarPathAnnotation.cs b/src/Aspire.Hosting.Java/JavaJarPathAnnotation.cs new file mode 100644 index 00000000000..90499adb27e --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaJarPathAnnotation.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Java; + +/// +/// Records that a Java application runs a prebuilt JAR through java -jar. +/// +/// +/// Held as an annotation rather than a property on so run, publish, and +/// debug all read one source of truth, and so the launch mode cannot be changed after the resource has +/// been configured. +/// +/// The authored path to the JAR file to execute, either absolute or relative to the application's working directory. +internal sealed record JavaJarPathAnnotation(string JarPath) : IResourceAnnotation; + +/// +/// Records an explicitly configured main class, used when an IDE launches the application itself. +/// +/// The fully qualified name of the class declaring main. +internal sealed record JavaMainClassAnnotation(string MainClass) : IResourceAnnotation; + +/// +/// Records the JAR produced by a container build, relative to the build stage's working directory. +/// +/// The path to the JAR, relative to the application directory. +internal sealed record JavaJarArtifactAnnotation(string RelativePath) : IResourceAnnotation; diff --git a/src/Aspire.Hosting.Java/JavaLaunchConfiguration.cs b/src/Aspire.Hosting.Java/JavaLaunchConfiguration.cs new file mode 100644 index 00000000000..0d24c636189 --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaLaunchConfiguration.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; +using Aspire.Hosting.ApplicationModel; + +#pragma warning disable ASPIREEXTENSION001 // Launch configuration types are experimental. + +namespace Aspire.Hosting.Java; + +/// +/// The launch configuration handed to an IDE so it can run or debug a Java application itself, +/// rather than Aspire starting the process. +/// +/// +/// +/// The IDE always starts a JVM directly, even when the resource would otherwise be launched through +/// mvnw spring-boot:run or gradlew bootRun. Those wrappers fork a second JVM, so a debugger +/// attached to the wrapper process would never see application code. The build-tool invocation is declared +/// with WithLaunchToolArgs(..., ownedByLaunchConfigurationType: "java"), which drops those arguments +/// for exactly this launch configuration type while keeping them for normal process execution. +/// +/// +/// The property names match the vscjava.vscode-java-debug launch schema after the extension +/// translates them, and mirror the keys consumed by extension/src/debugger/languages/java.ts. +/// See https://github.com/microsoft/vscode-java-debug/blob/main/Configuration.md. +/// +/// +internal sealed class JavaLaunchConfiguration() : ExecutableLaunchConfiguration("java") +{ + /// + /// The debug request type. Java applications are always launched by the IDE, never attached to. + /// + [JsonPropertyName("request")] + public string Request { get; set; } = "launch"; + + /// + /// The working directory of the Java project. The IDE uses it as the debug session's working + /// directory and to scope main class resolution to this resource's project. + /// + [JsonPropertyName("working_directory")] + public string WorkingDirectory { get; set; } = string.Empty; + + /// + /// The fully qualified main class, optionally prefixed with a Java module name + /// ([module/]com.example.App), or the path of the .java source file declaring + /// main. When omitted the IDE resolves the main class itself, scoped to + /// when one was supplied. + /// + /// + /// A JAR path is deliberately not accepted here. The debug adapter documents this attribute as + /// "the fully qualified class name (e.g. [java module name/]com.xyz.MainApp) or the java file path + /// of the program entry", so it never opens an archive to read Main-Class. An executable JAR + /// belongs in with its manifest's Main-Class sent here. + /// + [JsonPropertyName("main_class")] + public string? MainClass { get; set; } + + /// + /// The name the IDE's Java tooling imported this resource's project under, used to scope main class + /// resolution. + /// + /// + /// Sent whether or not is known, because the adapter needs the scope in + /// both cases. Without a main class it searches every project in the workspace, finds one in each + /// Java resource, and prompts the user to choose on every launch. With a main class it still + /// searches the whole workspace, and fails the launch with "Main class ... isn't unique in the + /// workspace" if the class is visible through more than one project. Scoping the search to a single + /// project resolves both. + /// + [JsonPropertyName("project_name")] + public string? ProjectName { get; set; } + + /// + /// Classpath entries the IDE should launch the JVM with. Empty when the IDE should resolve the + /// classpath from the project itself, which is the normal case for a Maven or Gradle project. + /// + /// + /// This is how a prebuilt JAR is debugged: the archive goes on the classpath and its manifest's + /// Main-Class becomes . Without it the adapter would fall back to + /// resolving the class from a language-server project that does not contain the JAR's classes. + /// + [JsonPropertyName("class_paths")] + public string[]? ClassPaths { get; set; } + + /// + /// The build tool that owns the project, used by the IDE to refresh the project's classpath + /// before starting a session. One of maven, gradle, or when the + /// application runs from a prebuilt JAR. + /// + [JsonPropertyName("build_tool")] + public string? BuildTool { get; set; } +} diff --git a/src/Aspire.Hosting.Java/JavaVersionDetector.cs b/src/Aspire.Hosting.Java/JavaVersionDetector.cs new file mode 100644 index 00000000000..e5982f42f1c --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaVersionDetector.cs @@ -0,0 +1,447 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace Aspire.Hosting.Java; + +/// +/// Determines which Java release a project targets, so the generated container image builds and runs it +/// on a matching JDK and JRE. +/// +/// +/// Detection is best effort and never fails: an unreadable or unrecognised build file falls back to +/// . Callers can always override the images with +/// WithDockerfileBaseImage. +/// +internal static partial class JavaVersionDetector +{ + /// + /// The Java release used when a project's build files declare none. Chosen as a broadly supported + /// long-term support release, which is what current Spring Boot and Quarkus releases target by + /// default, rather than the newest one. + /// + internal const string DefaultJavaVersion = "21"; + + /// The application directory to read build files from. + /// + /// The build tool that actually builds the application, when it is known. A directory can hold both a + /// POM and a Gradle script — a repository part-way through a migration, for example — and the two can + /// name different releases. Reading the tool that does not run would tag the image for a release the + /// application was never compiled for, which surfaces at runtime as + /// UnsupportedClassVersionError rather than as a build failure. This is an ordering rather than + /// a filter: Gradle projects often leave the release to a toolchain block that is not read here, and a + /// sibling POM is still better evidence than the default. + /// + public static string Detect(string appDirectory, JavaBuildTool? tool = null) + { + string?[] candidates = tool is JavaBuildTool.Gradle + ? + [ + DetectFromGradle(Path.Combine(appDirectory, "build.gradle")), + DetectFromGradle(Path.Combine(appDirectory, "build.gradle.kts")), + DetectFromPom(Path.Combine(appDirectory, "pom.xml")), + ] + : + [ + DetectFromPom(Path.Combine(appDirectory, "pom.xml")), + DetectFromGradle(Path.Combine(appDirectory, "build.gradle")), + DetectFromGradle(Path.Combine(appDirectory, "build.gradle.kts")), + ]; + + return Array.Find(candidates, candidate => candidate is not null) ?? DefaultJavaVersion; + } + + /// + /// Reads the target release from a Maven POM. + /// + /// + /// Three spellings are common and all appear in Spring Initializr output: + /// + /// <properties> + /// <java.version>21</java.version> + /// <maven.compiler.release>21</maven.compiler.release> + /// </properties> + /// <!-- or, on the compiler plugin itself --> + /// <configuration><release>21</release></configuration> + /// + /// Legacy POMs write 1.8 rather than 8; both map to the 8 image tag. + /// Property references such as <release>${java.version}</release> are not expanded — + /// the literal properties are checked first, so the reference is only reached when nothing else matched. + /// + private static string? DetectFromPom(string pomPath) + { + if (!File.Exists(pomPath)) + { + return null; + } + + XDocument document; + try + { + document = XDocument.Load(pomPath); + } + catch (Exception ex) when (ex is System.Xml.XmlException or IOException or UnauthorizedAccessException) + { + // A malformed or unreadable POM is the build tool's problem to report, not a reason to fail + // publishing before the container build has even started. + return null; + } + + // Element names are matched without their namespace so both the Maven 4 POM namespace and the + // long-standing http://maven.apache.org/POM/4.0.0 namespace are handled. + // + // Every element with a matching name is considered, not just the first: a POM often declares + // ${java.version} on the compiler plugin and a literal elsewhere, and stopping + // at the unresolvable property reference would fall back to the default version instead. + // Ordered by how directly each one decides the bytecode version, most direct first, because the + // runtime image has to be at least what the compiler actually emitted. + // + // java.version is last despite being the most recognisable. It is not a Maven property at all: + // it works only because spring-boot-starter-parent maps it onto the real one with + // ${java.version}. A POM that sets both + // java.version and maven.compiler.release overrides that mapping, so Maven compiles to the + // latter and reading java.version would pick a runtime too old to load the classes. + // https://docs.spring.io/spring-boot/maven-plugin/using.html + foreach (var (name, mustBePluginConfiguration) in ((string, bool)[]) + [ + // Explicit plugin configuration beats the property that merely supplies the parameter's + // default, and release beats target within the plugin. + // https://maven.apache.org/plugins/maven-compiler-plugin/compile-mojo.html + // + // and are only meaningful inside the compiler plugin's . + // Matched merely by having a parent they would also pick up unrelated + // plugins: maven-antrun-plugin's canonical configuration is literally + // ..., holding Ant XML rather than a Java + // release, and any plugin is free to name a of its own. + ("release", true), + ("maven.compiler.release", false), + ("target", true), + ("maven.compiler.target", false), + ("java.version", false), + ]) + { + foreach (var element in document.Descendants().Where(e => string.Equals(e.Name.LocalName, name, StringComparison.Ordinal))) + { + if (mustBePluginConfiguration && !IsCompilerPluginConfiguration(element.Parent)) + { + continue; + } + + if (Normalize(element.Value) is { } version) + { + return version; + } + } + } + + return null; + } + + /// + /// Determines whether an element is a <configuration> belonging to + /// maven-compiler-plugin. + /// + /// + /// Both places a compiler configuration can appear are accepted, because both really set the release: + /// + /// <plugin> + /// <artifactId>maven-compiler-plugin</artifactId> + /// <configuration><release>21</release></configuration> <!-- plugin level --> + /// <executions><execution> + /// <configuration><release>21</release></configuration> <!-- execution level --> + /// </execution></executions> + /// </plugin> + /// + /// The plugin is identified by artifactId alone: groupId defaults to + /// org.apache.maven.plugins and is routinely omitted for the core plugins. + /// See https://maven.apache.org/plugins/maven-compiler-plugin/compile-mojo.html. + /// + private static bool IsCompilerPluginConfiguration(XElement? configuration) + { + if (!string.Equals(configuration?.Name.LocalName, "configuration", StringComparison.Ordinal)) + { + return false; + } + + for (var ancestor = configuration!.Parent; ancestor is not null; ancestor = ancestor.Parent) + { + if (!string.Equals(ancestor.Name.LocalName, "plugin", StringComparison.Ordinal)) + { + continue; + } + + return ancestor.Elements().Any(e => + string.Equals(e.Name.LocalName, "artifactId", StringComparison.Ordinal) + && string.Equals(e.Value.Trim(), "maven-compiler-plugin", StringComparison.Ordinal)); + } + + return false; + } + + /// + /// Reads the target release from a Gradle build script. + /// + /// + /// Groovy and Kotlin DSL spellings that appear in Spring Initializr and Gradle's own documentation: + /// + /// java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } + /// java.sourceCompatibility = JavaVersion.VERSION_21 + /// sourceCompatibility = '17' + /// targetCompatibility = 1.8 + /// + /// The toolchain is checked first because it pins the JDK Gradle actually compiles with, whereas + /// source/target compatibility only constrain the bytecode level. + /// + private static string? DetectFromGradle(string buildScriptPath) + { + if (!File.Exists(buildScriptPath)) + { + return null; + } + + string contents; + try + { + contents = File.ReadAllText(buildScriptPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + + var script = StripComments(contents); + + if (FirstActiveMatch(ToolchainRegex(), script) is { } toolchain) + { + return Normalize(toolchain.Groups[1].Value); + } + + if (FirstActiveMatch(JavaVersionEnumRegex(), script) is { } enumMatch) + { + return Normalize(enumMatch.Groups[1].Value.Replace('_', '.')); + } + + if (FirstActiveMatch(CompatibilityRegex(), script) is { } compatibility) + { + return Normalize(compatibility.Groups[1].Value); + } + + return null; + } + + /// + /// The first match that starts outside a string literal, or null when every match is inside one. + /// + /// + /// The patterns run over the raw script rather than a parsed model, so a version-shaped fragment quoted + /// inside a string would otherwise be read as a declaration and win by appearing first: + /// + /// println("JavaLanguageVersion.of(17)") + /// java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } + /// + /// That selects a Java 17 image for a Java 21 application, which fails at runtime with + /// UnsupportedClassVersionError rather than at publish time. + /// + /// Only the match's start is tested, never the whole match. A declaration always begins outside the + /// quotes while its value may legitimately sit inside them — sourceCompatibility = '17' is the + /// ordinary Groovy spelling — so rejecting matches that merely overlap a literal would stop detecting + /// the quoted form that exists to read. + /// + /// + private static Match? FirstActiveMatch(Regex regex, GradleScript script) + { + for (var match = regex.Match(script.Text); match.Success; match = match.NextMatch()) + { + if (!script.IsInsideStringLiteral(match.Index)) + { + return match; + } + } + + return null; + } + + /// + /// A Gradle build script with its comments removed, and the string literals that survived located. + /// + /// The script with every comment blanked out. + /// + /// The half-open interiors of the string literals in , ascending and + /// non-overlapping, which lets a lookup binary search rather than rescan. + /// + private readonly record struct GradleScript(string Text, ImmutableArray StringSpans) + { + internal bool IsInsideStringLiteral(int offset) + { + // Ascending and non-overlapping, so the last span starting at or before the offset is the only + // one that can contain it. + var low = 0; + var high = StringSpans.Length - 1; + var candidate = -1; + + while (low <= high) + { + var middle = low + ((high - low) / 2); + + if (StringSpans[middle].Start.Value <= offset) + { + candidate = middle; + low = middle + 1; + } + else + { + high = middle - 1; + } + } + + return candidate >= 0 && offset < StringSpans[candidate].End.Value; + } + } + + /// + /// Blanks out line and block comments so a commented-out setting cannot be mistaken for an active one, + /// and locates the string literals that survive so a quoted one cannot be either. + /// + /// + /// The version patterns are applied to the raw script rather than a parsed model, so without this a + /// leftover line wins over the setting that is actually in effect: + /// + /// java { + /// toolchain { + /// // languageVersion = JavaLanguageVersion.of(17) + /// languageVersion = JavaLanguageVersion.of(21) + /// } + /// } + /// + /// String literals are tracked so that the // inside a repository URL, and any /* inside + /// a string, are not treated as comment starts — the latter would otherwise swallow the rest of the + /// file. Single, double, and triple-quoted forms are recognized, covering both the Groovy and the + /// Kotlin DSL. Groovy's slashy strings (/pattern/) are not recognized; they do not appear in the + /// toolchain or compatibility declarations this reads. + /// + private static GradleScript StripComments(string contents) + { + var builder = new StringBuilder(contents.Length); + var spans = ImmutableArray.CreateBuilder(); + var index = 0; + + while (index < contents.Length) + { + var current = contents[index]; + + if (current is '/' && index + 1 < contents.Length) + { + if (contents[index + 1] is '/') + { + while (index < contents.Length && contents[index] is not ('\n' or '\r')) + { + index++; + } + + continue; + } + + if (contents[index + 1] is '*') + { + var end = contents.IndexOf("*/", index + 2, StringComparison.Ordinal); + index = end < 0 ? contents.Length : end + 2; + // A newline stands in for the comment so that the text on either side of a block + // comment cannot be joined into a single line and match as one declaration. + builder.Append('\n'); + continue; + } + } + + if (current is '"' or '\'') + { + var delimiter = new string(current, contents.AsSpan(index).StartsWith(new string(current, 3), StringComparison.Ordinal) ? 3 : 1); + builder.Append(delimiter); + index += delimiter.Length; + + // Recorded in output coordinates, and covering only the interior: a declaration starts + // outside the quotes, so keeping the delimiters active is what lets the value of + // sourceCompatibility = '17' stay readable while its surroundings stay inert. + var interiorStart = builder.Length; + + // The literal's contents are copied through unchanged - only comments are removed - so a + // version written as a quoted string, such as sourceCompatibility = '17', still matches. + while (index < contents.Length) + { + if (contents[index] is '\\' && index + 1 < contents.Length) + { + builder.Append(contents, index, 2); + index += 2; + continue; + } + + if (contents.AsSpan(index).StartsWith(delimiter, StringComparison.Ordinal)) + { + spans.Add(new Range(interiorStart, builder.Length)); + builder.Append(delimiter); + index += delimiter.Length; + break; + } + + builder.Append(contents[index]); + index++; + } + + // An unterminated literal runs to the end of the file, which is what the Groovy and Kotlin + // compilers see too. Closing it at the last character keeps the rest of the script inert + // rather than letting a stray quote re-activate it. + if (index >= contents.Length && (spans.Count == 0 || spans[^1].End.Value != builder.Length)) + { + spans.Add(new Range(interiorStart, builder.Length)); + } + + continue; + } + + builder.Append(current); + index++; + } + + return new GradleScript(builder.ToString(), spans.ToImmutable()); + } + + /// + /// Maps a declared release to the numeric form used in container image tags. + /// + /// + /// Java 8 and earlier are written 1.8 in build files but tagged 8 in images + /// (eclipse-temurin:8-jre). Anything that is not a plain release number is rejected so a + /// property reference such as ${java.version} cannot end up inside a FROM instruction. + /// + private static string? Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + value = value.Trim(); + + if (value.StartsWith("1.", StringComparison.Ordinal)) + { + value = value[2..]; + } + + return value.Length > 0 && value.All(char.IsAsciiDigit) ? value : null; + } + + // Matches: languageVersion = JavaLanguageVersion.of(21) and languageVersion.set(JavaLanguageVersion.of(21)) + [GeneratedRegex(@"JavaLanguageVersion\.of\(\s*(\d+)\s*\)")] + private static partial Regex ToolchainRegex(); + + // Matches: sourceCompatibility = JavaVersion.VERSION_21 and VERSION_1_8 + [GeneratedRegex(@"JavaVersion\.VERSION_(\d+(?:_\d+)?)")] + private static partial Regex JavaVersionEnumRegex(); + + // Matches: sourceCompatibility = '17' targetCompatibility = 17 sourceCompatibility = "1.8" + [GeneratedRegex(@"(?:source|target)Compatibility\s*(?:=|\.set\()\s*['""]?(\d+(?:\.\d+)?)['""]?")] + private static partial Regex CompatibilityRegex(); +} diff --git a/src/Aspire.Hosting.Java/JavaWrapperValidationAnnotation.cs b/src/Aspire.Hosting.Java/JavaWrapperValidationAnnotation.cs new file mode 100644 index 00000000000..907198c91d0 --- /dev/null +++ b/src/Aspire.Hosting.Java/JavaWrapperValidationAnnotation.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Java; + +/// +/// Marks that a resource has already registered a deferred check that its build tool wrapper exists. +/// +/// +/// The check cannot run while the AppHost is being authored, because WithWrapperPath may still +/// supply a wrapper that the default location lacks. This annotation keeps the subscription from being +/// registered more than once per tool when several builder methods ask for the same validation. +/// +internal sealed record JavaWrapperValidationAnnotation(JavaBuildTool Tool) : IResourceAnnotation; diff --git a/src/Aspire.Hosting.Java/README.md b/src/Aspire.Hosting.Java/README.md new file mode 100644 index 00000000000..0f4619735f4 --- /dev/null +++ b/src/Aspire.Hosting.Java/README.md @@ -0,0 +1,313 @@ +# Java hosting integration + +Use this integration to model, configure, and orchestrate a Java application resource in an Aspire solution. + +## Getting started + +### Prerequisites + +A **JDK** must be available on the PATH of the machine running the AppHost, for every resource that runs a +JVM locally. Aspire does not install one. `AddJavaContainer` is the exception, because it runs a prebuilt +image and the JDK comes from inside that image. Any JDK the application itself targets will do — a service +built for Java 17 needs only a Java 17 JDK. + +Writing the **AppHost itself in Java** is a separate requirement: that needs **JDK 25 or later**, because the +AppHost is a compact source file with an instance `main` method, finalized in Java 25 by +[JEP 512](https://openjdk.org/jeps/512), and is compiled with `--release 25`. On an older JDK it fails to +compile with `error: release version 25 not supported`. This applies only to a Java AppHost; the Java +resources it orchestrates are unaffected, and a C#, TypeScript or Python AppHost can host Java resources on +any JDK. + +A **Maven or Gradle wrapper** (`mvnw`/`gradlew`) checked into the project is required only by the resources +that invoke one: the `WithMavenGoal`/`WithGradleTask` launch modes, and the `WithMavenBuild`/`WithGradleBuild` +build steps. A resource that runs a prebuilt JAR launches `java -jar` directly and needs no wrapper. + +Where a wrapper is used it needs nothing else installed, and Aspire deliberately does not fall back to a +globally installed `mvn` or `gradle`, because the wrapper pins the tool version in the repository so the +AppHost, CI, and the published container image all build with the same one. Add a wrapper with +`mvn -N wrapper:wrapper` or `gradle wrapper`, or select one elsewhere with `WithWrapperPath(...)`. + +For VS Code debugging, install +[Language Support for Java](https://marketplace.visualstudio.com/items?itemName=redhat.java) and +[Debugger for Java](https://marketplace.visualstudio.com/items?itemName=vscjava.vscode-java-debug). + +### Add the integration + +From your AppHost directory, add the `Aspire.Hosting.Java` integration with the Aspire CLI: + +```bash +aspire add Aspire.Hosting.Java +``` + +## Usage example + +Then, in the AppHost, add a Java application resource and reference it from another resource with either C# or TypeScript: + +**C#** + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +// Detects Maven or Gradle from the build file, builds the app, launches it through the Spring Boot +// plugin, and declares an HTTP endpoint through SERVER_PORT, which is the port Spring Boot listens on. +var catalog = builder.AddSpringBootApp("catalog", "../catalog") + .WithExternalHttpEndpoints(); + +builder.AddProject("frontend") + .WithReference(catalog) + .WaitFor(catalog); + +builder.Build().Run(); +``` + +**TypeScript** + +```typescript +import { createBuilder } from "./.aspire/modules/aspire.mjs"; + +const builder = await createBuilder(); + +const catalog = await builder.addSpringBootApp("catalog", "../catalog"); +await catalog.withExternalHttpEndpoints(); + +const orders = await builder.addSpringBootApp("orders", "../orders"); +await orders.withReference(catalog); +await orders.waitFor(catalog); + +await builder.build().run(); +``` + +`appDirectory` is the process working directory and the publish build context, so everything the build +needs must live inside it. + +### Spring Boot + +`AddSpringBootApp` is `AddJavaApp` with the four calls every Spring Boot service repeats already made. +It reads the build file in the directory to decide between Maven and Gradle, so the AppHost never +restates something the project already declares: + +```csharp +builder.AddSpringBootApp("catalog", "../catalog"); +``` + +is the same as: + +```csharp +builder.AddJavaApp("catalog", "../catalog") + .WithMavenBuild("-B", "-ntp", "-DskipTests", "package") + .WithMavenGoal("spring-boot:run") + .WithHttpEndpoint(env: "SERVER_PORT"); +``` + +The build skips tests, because it runs every time the AppHost starts and a full suite in front of every +debug session gets old quickly. Call `WithMavenBuild`/`WithGradleBuild` afterwards to choose your own +arguments. + +No health check is added: `/actuator/health` only responds when the application depends on +`spring-boot-starter-actuator`, so adding one unconditionally would leave every other application +permanently unhealthy. Add `.WithHttpHealthCheck("/actuator/health")` when the actuator is present. + +Use `AddJavaApp` directly for anything else: a different Spring Boot plugin goal, a project laid out so +the build file is not in the app directory, or a framework that is not Spring Boot. + +### Quarkus + +`AddQuarkusApp` is the Quarkus equivalent, and detects the build tool the same way: + +```csharp +builder.AddQuarkusApp("inventory", "../inventory"); +``` + +is the same as: + +```csharp +builder.AddJavaApp("inventory", "../inventory") + .WithMavenBuild("-B", "-ntp", "-DskipTests", "package") + .WithMavenGoal("quarkus:dev") + .WithEnvironment("QUARKUS_PROFILE", "dev") + .WithEnvironment("QUARKUS_OBSERVABILITY_ENABLED", "false") + .WithHttpEndpoint(env: "QUARKUS_HTTP_PORT"); +``` + +The application runs in Quarkus dev mode, so live coding works while the AppHost is running. Quarkus Dev +Services stay enabled but do not activate for anything Aspire supplies, because a Dev Service only starts +when the configuration it would provide is absent — and `WithReference` supplies it. + +The observability Dev Service is the exception, and is turned off. Left on, an application that depends on +`quarkus-opentelemetry` pulls `grafana/otel-lgtm` (roughly 600 MB), starts it through Testcontainers, and +then repoints the exporter at that container — so telemetry never reaches the Aspire dashboard and a +container is left behind. Aspire is already the observability stack, so the Dev Service has nothing to add. + +`AddQuarkusApp` also mirrors the OTLP endpoint, protocol, headers and service name onto the +`QUARKUS_OTEL_*` environment variables. `quarkus-opentelemetry` reads its own `quarkus.otel.*` +configuration and ignores the standard `OTEL_*` names, so without this it keeps exporting to its +`localhost:4317` default and every export fails. No Java agent is needed for a Quarkus application, and +`WithOtelAgent` should not be combined with the extension. + +`QUARKUS_PROFILE=dev` is set as an environment variable rather than left to the goal, because the VS Code +debugger launches the packaged application directly rather than through `quarkus:dev`; setting it here +means both resolve the same `%dev.` configuration. It is not set when publishing, where the image must +resolve production configuration. + +No health check is added, for the same reason as Spring Boot: `/q/health` only responds when the +application depends on `quarkus-smallrye-health`. Add `.WithHttpHealthCheck("/q/health")` when that +extension is present. + +A **published** container needs one thing more, in the application's own `application.properties`: + +```properties +quarkus.otel.exporter.otlp.endpoint=${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317} +quarkus.otel.exporter.otlp.protocol=${OTEL_EXPORTER_OTLP_PROTOCOL:grpc} +``` + +The compute environment supplies `OTEL_*` from a callback it appends while preparing the deployment +target, which is after every callback the AppHost registered, so there is nothing left for the mirroring +to copy. These are [SmallRye config expressions](https://smallrye.io/smallrye-config/Main/config/expressions/), +expanded inside the JVM at startup, so it does not matter when the variable was set. They cannot be passed +as environment variables instead, because Docker Compose interpolates `${...}` in its own file and rejects +this form. The defaults match the extension's own, so the application still runs outside Aspire. + +A Quarkus application described with `AddJavaApp` rather than `AddQuarkusApp` gets no mirroring at all and +exports to the extension's `localhost:4317` default. Use `AddQuarkusApp`, or attach the agent with +`WithOtelAgent()`. + +### Launch modes + +A resource runs in exactly one of three ways, and configuring a second one throws: + +| Mode | How to select it | What runs | +| --- | --- | --- | +| Prebuilt JAR | `AddJavaApp(name, appDirectory, jarPath)` | `java -jar ` | +| Maven goal | `WithMavenGoal("spring-boot:run")` | `mvnw spring-boot:run` | +| Gradle task | `WithGradleTask("bootRun")` | `gradlew bootRun` | + +Arguments passed to `AddJavaApp` or `WithArgs(...)` belong to the application. Arguments for the build +tool are passed to `WithMavenGoal`/`WithGradleTask`, which keeps the two sets separable — the IDE needs +to drop the wrapper's arguments when it launches the JVM directly to debug it. + +Prebuilt JAR mode describes how the resource *runs*. It does not by itself mean the JAR is published +as-is: a JAR path in a directory that also has a `pom.xml` or a Gradle build file is built inside the +image and the path selects the artifact. See [Publishing](#publishing). + +### Running an image someone else built + +When the application ships as a container image — built by a separate pipeline, or by a team that hands +you an image rather than source — use `AddJavaContainer` instead. Aspire runs the image as-is and +never rebuilds it, so the JAR, the JDK, and any OpenTelemetry agent all come from the image: + +```csharp +builder.AddJavaContainer("catalog", "mycompany/catalog", "1.4.0") + .WithHttpEndpoint(targetPort: 8080) + .WithReference(db) + .WithJvmArgs("-Xmx512m"); +``` + +No endpoint is declared for you, because the port is a property of the image; 8080 is the default for +Spring Boot and Quarkus. `WithOtelAgent` does not apply here — it copies an agent out of the build +context, and there is no build. If the image already carries the agent, turn it on with +`WithJvmArgs("-javaagent:/app/opentelemetry-javaagent.jar")`. + +### Building before running + +```csharp +builder.AddJavaApp("worker", "../worker", "target/worker.jar") + .WithMavenBuild("-B", "-ntp", "-DskipTests", "package"); +``` + +`WithMavenBuild`/`WithGradleBuild` add a child resource that runs before the application starts, and the +application waits for it to succeed. The same arguments also drive the container build during +`aspire publish`, so they should produce the deployable artifact — `build -x test` rather than `classes` +for Gradle, for example. + +### Options + +| Method | Effect | +| --- | --- | +| `WithMavenGoal(string goal, params string[] args)` | Launches through `mvnw` with the given goal | +| `WithGradleTask(string task, params string[] args)` | Launches through `gradlew` with the given task | +| `WithMavenBuild(params string[] args)` | Builds with Maven before the app runs, and in the published container | +| `WithGradleBuild(params string[] args)` | Builds with Gradle before the app runs, and in the published container | +| `WithWrapperPath(string wrapperPath)` | Selects a custom wrapper path. May be called before or after the build tool is configured. Must stay inside the app directory for an app that will be published, because that directory is the container build context | +| `WithMainClass(string mainClass)` | The fully qualified class the IDE launches when debugging | +| `WithJarArtifact(string jarPath)` | Names the JAR the container build should deploy, when the build produces more than one | +| `WithJvmArgs(params string[] args)` | Appends JVM arguments through `JAVA_TOOL_OPTIONS`. Also available on `AddJavaContainer` | +| `WithOtelAgent(string agentPath)` | Runs the app under the [OpenTelemetry Java agent](https://github.com/open-telemetry/opentelemetry-java-instrumentation). The agent is not downloaded for you: fetch it as a build dependency so it exists before the application starts | +| `WithOtelAgent()` | Same, with the agent at `target/agent/` (Maven) or `build/agent/` (Gradle) | + +### Debugging + +Debugging is enabled automatically by `AddJavaApp` — use the normal Aspire "Start Debugging" flow in +VS Code. The IDE launches the JVM directly rather than through the wrapper, because `spring-boot:run` +and `bootRun` fork a second JVM that a debugger attached to the wrapper would never see. Set +`WithMainClass(...)` to say which class to launch; for a prebuilt JAR the archive is put on the +debugger's classpath and its manifest's `Main-Class` is launched, which is what `java -jar` does. + +### Publishing + +`aspire publish` and `aspire deploy` build the app into a container. An app that runs should publish with +no extra configuration: if the app directory contains a `Dockerfile` it is used as-is, otherwise one is +generated that builds the project inside the container. The container runs as an unprivileged numeric +user (`USER 999:999`, with no passwd entry), and the JVM is PID 1 so it receives `SIGTERM` directly and +shutdown hooks run. + +The generated build stage reuses the wrapper and the arguments from `WithMavenBuild`/`WithGradleBuild`, +falling back to `package` or `build` when neither is configured. Dependency caches are kept in a BuildKit +cache mount rather than baked into a layer. + +A JAR path is not on its own taken to mean the JAR is prebuilt, because it is equally how a Maven or +Gradle application names the artifact its own build produces. So when the app directory also holds a +`pom.xml` or a Gradle build file, the image builds the project and the path selects the artifact out of +the build output. Only a directory with no build file at all — and no `WithMavenBuild`/`WithGradleBuild` +and no `WithMavenGoal`/`WithGradleTask` — publishes as a straight `COPY` of the JAR. + +That is deliberately not symmetric with run mode, which execs `java -jar` against whatever is on disk. +Build output directories are conventionally ignored by source control, so copying `target/app.jar` out of +a source checkout would publish a locally built and possibly stale artifact when the developer happens to +have one, and fail the image build outright in a clean clone or on CI where it does not exist yet. + +The deployed JAR is selected in this order: an explicit `WithJarArtifact(...)`; for a Quarkus application +whichever artifact `quarkus-artifact.properties` names, together with the dependency directory it needs; +the JAR path given to `AddJavaApp`, when there was one; and otherwise whichever JAR the build produced, +ignoring `-plain`, `-sources`, and `-javadoc` artifacts. Only that last case can be ambiguous — a shade +plugin leaves `original-*.jar` beside the shaded JAR — and then the build fails naming the candidates, so +select one with `WithJarArtifact(...)`. + +All three Quarkus packaging types are handled without configuration: `fast-jar` (the default) stages the +whole `target/quarkus-app` directory, `legacy-jar` stages the runner alongside its sibling `target/lib`, +and `uber-jar` stages the single self-contained runner. The first two are useless without the +dependencies beside them, because the runner's manifest `Class-Path` names them relatively. + +#### Base images + +The Java release is read from `pom.xml` or the Gradle build file, and defaults to 21. + +| Stage | Default | +| --- | --- | +| Build | `docker.io/library/eclipse-temurin:{version}-jdk` | +| Runtime | `docker.io/library/eclipse-temurin:{version}-jre` | + +A plain JDK image is enough for the build stage because the build always runs through the project's own +wrapper, and the wrapper downloads the Maven or Gradle version the repository pins. + +Override both stages in a single call: + +```csharp +builder.AddJavaApp("catalog", "../catalog") + .WithDockerfileBaseImage( + buildImage: "example/java-build:latest", + runtimeImage: "example/java-runtime:latest"); +``` + +## Additional documentation + +* https://aspire.dev/integrations/gallery/ +* https://aspire.dev/integrations/frameworks/java/ +* [Aspire documentation](https://aspire.dev/) +* [Maven Wrapper](https://maven.apache.org/wrapper/) +* [Gradle Wrapper](https://docs.gradle.org/current/userguide/gradle_wrapper.html) + +## Feedback & contributing + +https://github.com/microsoft/aspire + +_*Java is a registered trademark of Oracle and/or its affiliates. Apache Maven and Apache Tomcat are trademarks of the Apache Software Foundation. Gradle is a trademark of Gradle, Inc. Spring Boot is a trademark of Broadcom Inc. and/or its subsidiaries. Quarkus is a trademark of Red Hat, Inc._ diff --git a/src/Aspire.Hosting.Java/WrapperAnnotation.cs b/src/Aspire.Hosting.Java/WrapperAnnotation.cs new file mode 100644 index 00000000000..a341591cbac --- /dev/null +++ b/src/Aspire.Hosting.Java/WrapperAnnotation.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Java; + +/// +/// Stores the wrapper path for Maven or Gradle build tools. +/// +internal sealed record WrapperAnnotation(string WrapperPath) : IResourceAnnotation; diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs index 6ccc245d316..efa85832fea 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs @@ -436,7 +436,8 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) var targetComputeEnvironment = OwningComputeEnvironment ?? this; // Create a Kubernetes resource for the dashboard if enabled - if (DashboardEnabled && Dashboard?.Resource is KubernetesAspireDashboardResource dashboard) + if (DashboardEnabled && Dashboard?.Resource is KubernetesAspireDashboardResource dashboard && + !dashboard.Annotations.OfType().Any(a => a.ComputeEnvironment == targetComputeEnvironment)) { var dashboardService = await environmentContext.CreateKubernetesResourceAsync(dashboard, executionContext, cancellationToken).ConfigureAwait(false); dashboardService.AddPrintSummaryStep(); @@ -467,6 +468,28 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) continue; } + // Use the resource's actual compute environment (which may be a parent + // like AzureKubernetesEnvironmentResource) so that GetDeploymentTargetAnnotation + // can match it correctly during publish. + var computeEnvForAnnotation = resourceComputeEnvironment ?? targetComputeEnvironment; + + // This step is reachable from two pipeline executions: it is RequiredBy + // "before-start" (so it runs during AppHost startup) and it is also part of the + // publish/deploy DAG. Adding a second DeploymentTargetAnnotation on the second + // pass makes ResourceExtensions.GetDeploymentTargetAnnotation throw, so the step + // has to be idempotent. Skipping early also avoids re-running ConfigureOtlp, + // which would append duplicate environment variables. The already-created + // KubernetesResource is still recorded so ingress processing below sees it. + if (r.Annotations.OfType().FirstOrDefault(a => a.ComputeEnvironment == computeEnvForAnnotation) is { } existingTarget) + { + if (existingTarget.DeploymentTarget is KubernetesResource existingServiceResource) + { + deploymentTargets[r] = existingServiceResource; + } + + continue; + } + // Configure OTLP for resources if dashboard is enabled if (DashboardEnabled && Dashboard?.Resource.OtlpGrpcEndpoint is EndpointReference otlpGrpcEndpoint) { @@ -502,10 +525,6 @@ private async Task PrepareDeploymentTargetsAsync(PipelineStepContext context) serviceResource.AddPrintSummaryStep(); // Add deployment target annotation to the resource. - // Use the resource's actual compute environment (which may be a parent - // like AzureKubernetesEnvironmentResource) so that GetDeploymentTargetAnnotation - // can match it correctly during publish. - var computeEnvForAnnotation = resourceComputeEnvironment ?? targetComputeEnvironment; r.Annotations.Add(new DeploymentTargetAnnotation(serviceResource) { ComputeEnvironment = computeEnvForAnnotation, @@ -996,6 +1015,12 @@ private async Task BuildGatewayObjects( var gatewayName = gatewayResource.Name.ToKubernetesResourceName(); + // This whole method re-runs when the deployment-target step executes a second time (once for + // "before-start", once in the publish/deploy DAG). GeneratedGateway is assigned so it replaces + // itself, but GeneratedHttpRoutes is appended to — without clearing, every route is emitted + // twice and the chart renders duplicate HTTPRoute objects with identical names. + gatewayResource.GeneratedHttpRoutes.Clear(); + var gateway = new GatewayV1 { Metadata = { Name = gatewayName } diff --git a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs index fde1a9c7e98..108b001f902 100644 --- a/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs +++ b/src/Aspire.Hosting.RemoteHost/CodeGeneration/CodeGenerationDiagnostic.cs @@ -113,6 +113,24 @@ internal static class CodeGenerationDiagnosticBuilder private const string RemediationHint = "Run 'aspire update' to align the installed Aspire CLI with the configured SDK version, then retry."; + private const string AssemblySkewMessage = + "This usually means the Aspire AppHost server is older than the Aspire packages the AppHost references. " + + "Run 'aspire update --self' to update the Aspire CLI, or set 'sdkVersion' in aspire.config.json to the CLI's version."; + + /// + /// Returns an actionable explanation when is a reflection-load + /// failure, or when it is not. + /// + /// + /// The AppHost server loads integration assemblies compiled against its own contract assemblies. + /// When the server is older, a type or member the integration references is simply absent and the + /// runtime reports a bare load failure that never says the CLI and the packages disagree, which is + /// the only actionable part. Detection is by exception type rather than message text so it still + /// works under a localized runtime. + /// + internal static string? TryDescribeAssemblySkew(Exception exception) + => FindReflectionLoadException(exception) is null ? null : AssemblySkewMessage; + /// /// Inspects the supplied exception (and any inner exceptions) and, if it looks like a /// reflection/load failure, returns a carrying a @@ -182,6 +200,11 @@ internal static CodeGenerationDiagnostic BuildDiagnostic(Exception exception, As /// Walks the exception chain and returns the first inner exception that looks like a /// reflection-load failure, or if none is found. /// + /// + /// is fanned out explicitly because its + /// is only the first of its children, so a load failure + /// raised by any later parallel operation would otherwise be invisible. + /// internal static Exception? FindReflectionLoadException(Exception? exception) { for (var current = exception; current is not null; current = current.InnerException) @@ -200,6 +223,17 @@ internal static CodeGenerationDiagnostic BuildDiagnostic(Exception exception, As // failure — fall through and return it from the IsReflectionLoadException check below. } + if (current is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions) + { + if (FindReflectionLoadException(inner) is { } found) + { + return found; + } + } + } + if (IsReflectionLoadException(current)) { return current; diff --git a/src/Aspire.Hosting.RemoteHost/RemoteAppHostService.cs b/src/Aspire.Hosting.RemoteHost/RemoteAppHostService.cs index e29816a7af7..c905193e23c 100644 --- a/src/Aspire.Hosting.RemoteHost/RemoteAppHostService.cs +++ b/src/Aspire.Hosting.RemoteHost/RemoteAppHostService.cs @@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using Aspire.Hosting.RemoteHost.Ats; +using Aspire.Hosting.RemoteHost.CodeGeneration; using Aspire.Hosting.RemoteHost.Diagnostics; using Microsoft.Extensions.Logging; using StreamJsonRpc; @@ -155,11 +156,20 @@ public bool CancelToken(string tokenId) { activity.SetError(ex); _logger.LogError(ex, " invokeCapability({CapabilityId}) Exception: {ExceptionType} - {Message}", capabilityId, ex.GetType().Name, ex.Message); + + // A reflection-load failure while dispatching a capability means the server binary and the + // integration assemblies disagree on a contract. That is a version-skew problem the user + // can act on, but the runtime's own message never says so, so the explanation is appended + // here rather than left for the CLI to infer from the message text. + var message = CodeGenerationDiagnosticBuilder.TryDescribeAssemblySkew(ex) is { } skewExplanation + ? $"{ex.Message} {skewExplanation}" + : ex.Message; + // Wrap unexpected errors var error = new AtsError { Code = AtsErrorCodes.InternalError, - Message = ex.Message, + Message = message, Capability = capabilityId }; return new JsonObject diff --git a/src/Aspire.Hosting.Tasks/Aspire.Hosting.Tasks.csproj b/src/Aspire.Hosting.Tasks/Aspire.Hosting.Tasks.csproj index 4169bf26b91..f20abea7c7f 100644 --- a/src/Aspire.Hosting.Tasks/Aspire.Hosting.Tasks.csproj +++ b/src/Aspire.Hosting.Tasks/Aspire.Hosting.Tasks.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs b/src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs index b02a44255c7..104650b73c2 100644 --- a/src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs +++ b/src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs @@ -141,8 +141,8 @@ public override bool Execute() private void SetOutputs(BundleResolution resolution) { DcpDir = EnsureTrailingDirectorySeparator(resolution.DcpDir); - AspireDashboardDir = EnsureTrailingDirectorySeparator(resolution.ManagedDir); - AspireDashboardPath = resolution.ManagedPath; + AspireDashboardDir = EnsureTrailingDirectorySeparator(resolution.DashboardDir); + AspireDashboardPath = resolution.DashboardPath; AspireTerminalHostDir = EnsureTrailingDirectorySeparator(resolution.ManagedDir); AspireTerminalHostPath = resolution.ManagedPath; AspireTerminalHostInvocationArgs = "terminalhost"; @@ -231,12 +231,29 @@ private static bool TryResolveBundleRoot(string bundleRoot, out BundleResolution var managedDir = Path.Combine(bundleRoot, "managed"); var managedPath = Path.Combine(managedDir, IsWindows() ? "aspire-managed.exe" : "aspire-managed"); + // Current bundles isolate the Native AOT Dashboard and its static assets in dashboard/. + // Preserve compatibility with transitional bundles that placed Aspire.Dashboard in managed/; + // if neither executable exists, resolution below falls back to the older aspire-managed + // dispatcher, which launches the Dashboard through its "dashboard" subcommand. + var dashboardDir = Path.Combine(bundleRoot, "dashboard"); + var dashboardPath = Path.Combine(dashboardDir, IsWindows() ? "Aspire.Dashboard.exe" : "Aspire.Dashboard"); + if (!File.Exists(dashboardPath)) + { + dashboardDir = managedDir; + dashboardPath = Path.Combine(managedDir, IsWindows() ? "Aspire.Dashboard.exe" : "Aspire.Dashboard"); + } + if (!File.Exists(dcpPath) || !File.Exists(managedPath)) { return false; } - resolution = new BundleResolution(dcpDir, managedDir, managedPath); + resolution = new BundleResolution( + dcpDir, + managedDir, + managedPath, + dashboardDir, + File.Exists(dashboardPath) ? dashboardPath : managedPath); return true; } @@ -494,12 +511,16 @@ or PathTooLongException or System.Security.SecurityException; } - private sealed class BundleResolution(string dcpDir, string managedDir, string managedPath) + private sealed class BundleResolution(string dcpDir, string managedDir, string managedPath, string dashboardDir, string dashboardPath) { public string DcpDir { get; } = dcpDir; public string ManagedDir { get; } = managedDir; public string ManagedPath { get; } = managedPath; + + public string DashboardDir { get; } = dashboardDir; + + public string DashboardPath { get; } = dashboardPath; } } diff --git a/src/Aspire.Hosting.Tasks/RunAspireCliCommand.cs b/src/Aspire.Hosting.Tasks/RunAspireCliCommand.cs index 331b43a4bdd..09fb42346d9 100644 --- a/src/Aspire.Hosting.Tasks/RunAspireCliCommand.cs +++ b/src/Aspire.Hosting.Tasks/RunAspireCliCommand.cs @@ -17,9 +17,7 @@ public sealed class RunAspireCliCommand : Microsoft.Build.Utilities.Task private const string ArgumentValueMetadataName = "Value"; private const string CommandShimPathEnvironmentVariable = "__ASPIRE_MSBUILD_COMMAND_PATH"; private const string CommandShimArgumentEnvironmentVariablePrefix = "__ASPIRE_MSBUILD_COMMAND_ARGUMENT_"; -#if NETFRAMEWORK - private const int ProcessTreeTerminationTimeoutMilliseconds = 5_000; -#endif + private const int ProcessTerminationTimeoutMilliseconds = 5_000; /// /// Gets or sets the executable or command shim to run. @@ -59,6 +57,11 @@ public sealed class RunAspireCliCommand : Microsoft.Build.Utilities.Task [Output] public string? FailureMessage { get; set; } + /// + /// Overrides process termination for tests that must keep a process alive after termination is requested. + /// + internal Func? TestTerminateProcess { get; set; } + public override bool Execute() { if (string.IsNullOrWhiteSpace(FileName)) @@ -100,25 +103,36 @@ public override bool Execute() if (!process.WaitForExit(TimeoutMilliseconds)) { TimedOut = true; - if (!TerminateProcess(process)) + var processExited = false; + if (TestTerminateProcess?.Invoke(process) ?? TerminateProcess(process)) { - return true; + processExited = process.WaitForExit(ProcessTerminationTimeoutMilliseconds); + if (!processExited) + { + FailureMessage ??= $"The command '{FileName}' timed out after {TimeoutMilliseconds} milliseconds and did not exit within {ProcessTerminationTimeoutMilliseconds} milliseconds after termination was requested."; + } } - } - process.WaitForExit(); + if (processExited) + { + WaitForProcessOutput(standardOutputTask, standardErrorTask); + } + + // WaitForExit reports only the root process's exit after Kill(entireProcessTree: true). + // A surviving descendant can retain inherited pipe handles, so timeout cleanup must + // never wait indefinitely for the redirected readers to reach EOF. + // See https://learn.microsoft.com/dotnet/api/system.diagnostics.process.kill#remarks. + LogProcessOutputIfCompleted(standardOutputTask); + LogProcessOutputIfCompleted(standardErrorTask); + FailureMessage ??= $"The command timed out after {TimeoutMilliseconds} milliseconds."; + return true; + } var standardOutput = standardOutputTask.GetAwaiter().GetResult(); var standardError = standardErrorTask.GetAwaiter().GetResult(); LogProcessOutput(standardOutput); LogProcessOutput(standardError); - if (TimedOut) - { - FailureMessage ??= $"The command timed out after {TimeoutMilliseconds} milliseconds."; - return true; - } - ExitCode = process.ExitCode; return true; } @@ -222,7 +236,7 @@ private bool TerminateProcessTree(Process process) var standardOutputTask = taskKill.StandardOutput.ReadToEndAsync(); var standardErrorTask = taskKill.StandardError.ReadToEndAsync(); - if (!taskKill.WaitForExit(ProcessTreeTerminationTimeoutMilliseconds)) + if (!taskKill.WaitForExit(ProcessTerminationTimeoutMilliseconds)) { try { @@ -233,13 +247,13 @@ private bool TerminateProcessTree(Process process) // The helper exited between the timeout and termination attempt. } - if (taskKill.HasExited || taskKill.WaitForExit(ProcessTreeTerminationTimeoutMilliseconds)) + if (taskKill.HasExited || taskKill.WaitForExit(ProcessTerminationTimeoutMilliseconds)) { _ = standardOutputTask.GetAwaiter().GetResult(); _ = standardErrorTask.GetAwaiter().GetResult(); } - FailureMessage = $"The process-tree terminator '{taskKillPath}' did not exit within {ProcessTreeTerminationTimeoutMilliseconds} milliseconds."; + FailureMessage = $"The process-tree terminator '{taskKillPath}' did not exit within {ProcessTerminationTimeoutMilliseconds} milliseconds."; return false; } @@ -273,6 +287,40 @@ private void LogProcessOutput(string output) } } + private static void WaitForProcessOutput(Task standardOutputTask, Task standardErrorTask) + { + try + { + _ = Task.WaitAll([standardOutputTask, standardErrorTask], ProcessTerminationTimeoutMilliseconds); + } + catch + { + // Draining outputs is best-effort and a failure to do so (including a timeout) should not interrupt the caller. + } + } + + private void LogProcessOutputIfCompleted(Task outputTask) + { + if (outputTask.Status == TaskStatus.RanToCompletion) + { + var output = outputTask.GetAwaiter().GetResult(); + if (!string.IsNullOrEmpty(output)) + { + LogProcessOutput(output); + } + + return; + } + + // "Observe" task exceptions (for tasks that exceed the wait timeout and eventually fail) so that + // UnobservedTaskException (if enabled) does not bring down the process. + _ = outputTask.ContinueWith( + static completedTask => _ = completedTask.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + private static string GetArgumentValue(ITaskItem argument) { var value = argument.GetMetadata(ArgumentValueMetadataName); diff --git a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj index 132cfa53dbc..49a983d2f81 100644 --- a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj +++ b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs index 739f7af180b..ec794134ac1 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -12,6 +13,129 @@ namespace Aspire.Hosting.Testing; /// public static class DistributedApplicationHostingTestingExtensions { + private const string DashboardResourceName = "aspire-dashboard"; + + /// + /// Gets the URL for the running Aspire dashboard. + /// + /// The distributed application. + /// A token used to cancel the operation. + /// + /// An absolute that can be opened in a browser. The URL authenticates the browser when + /// authentication is enabled and otherwise points directly to the dashboard. + /// + /// + /// + /// Enable dashboard support with when creating + /// the testing builder. + /// + /// + /// This method does not start the distributed application. Call + /// before requesting the dashboard URL. + /// + /// + /// This method waits for the dashboard resource to become healthy. Pass a cancellation token when the wait must + /// be bounded. When authentication is enabled, the returned URI contains an authentication credential and should + /// be treated as sensitive. + /// + /// + /// Thrown when is . + /// + /// Thrown when the application is in publish mode, the dashboard is disabled, the application has not started, + /// or a dashboard URL is unavailable. + /// + /// Thrown when the dashboard reaches a terminal failure state. + /// Thrown when is canceled. + /// Thrown when the distributed application has been disposed. + /// + /// + /// var options = new DistributedApplicationTestingBuilderOptions + /// { + /// EnableDashboard = true + /// }; + /// + /// var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.MyAppHost_AppHost>(options, []); + /// await using var app = await builder.BuildAsync(); + /// await app.StartAsync(); + /// + /// var dashboardUrl = await app.GetDashboardUrlAsync(); + /// + /// + [AspireExportIgnore(Reason = "Use the exported getDashboardUrl overload without a cancellation token.")] + public static async Task GetDashboardUrlAsync( + this DistributedApplication app, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(app); + + var executionContext = app.Services.GetRequiredService(); + if (executionContext.IsPublishMode) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlPublishModeExceptionMessage); + } + + var applicationOptions = app.Services.GetRequiredService(); + if (applicationOptions.DisableDashboard) + { + throw new InvalidOperationException(Properties.Resources.DashboardDisabledExceptionMessage); + } + + ThrowIfNotStarted(app, Properties.Resources.DashboardUrlApplicationNotStartedExceptionMessage); + cancellationToken.ThrowIfCancellationRequested(); + + await app.ResourceNotifications.WaitForResourceHealthyAsync( + DashboardResourceName, + WaitBehavior.StopOnResourceUnavailable, + cancellationToken).ConfigureAwait(false); + + var applicationModel = app.Services.GetRequiredService(); + if (!applicationModel.Resources.TryGetByName(DashboardResourceName, out var resource) || + resource is not IResourceWithEndpoints dashboardResource) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); + } + + var httpsEndpoint = dashboardResource.GetEndpoint("https"); + var httpEndpoint = dashboardResource.GetEndpoint("http"); + var dashboardEndpoint = httpsEndpoint.Exists ? httpsEndpoint : httpEndpoint; + if (!dashboardEndpoint.Exists) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); + } + + var dashboardUrl = await EndpointHostHelpers.GetUrlWithTargetHostAsync(dashboardEndpoint, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(dashboardUrl)) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); + } + + var browserToken = app.Services.GetRequiredService()["AppHost:BrowserToken"]; + if (!string.IsNullOrEmpty(browserToken)) + { + dashboardUrl = $"{dashboardUrl.TrimEnd('/')}/login?t={Uri.EscapeDataString(browserToken)}"; + } + + if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri)) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); + } + + return dashboardUri; + } + + /// + /// Gets the URL for the running Aspire dashboard. + /// + /// + /// An absolute that can be opened in a browser. When authentication is enabled, the URI + /// contains an authentication credential and should be treated as sensitive. + /// + [AspireExport("getDashboardUrl")] + internal static Task GetDashboardUrlAsyncExport(this DistributedApplication app) + { + return app.GetDashboardUrlAsync(default); + } + /// /// Creates an configured to communicate with the specified resource. /// @@ -127,7 +251,7 @@ internal static Uri GetEndpointForNetworkExport(this DistributedApplication app, static IResource GetResource(DistributedApplication app, string resourceName) { - ThrowIfNotStarted(app); + ThrowIfNotStarted(app, Properties.Resources.ApplicationNotStartedExceptionMessage); var applicationModel = app.Services.GetRequiredService(); if (!applicationModel.Resources.TryGetByName(resourceName, out var resource)) @@ -166,12 +290,12 @@ static string GetEndpointUriStringCore(DistributedApplication app, string resour return endpoint.Url; } - static void ThrowIfNotStarted(DistributedApplication app) + static void ThrowIfNotStarted(DistributedApplication app, string exceptionMessage) { var lifetime = app.Services.GetRequiredService(); if (!lifetime.ApplicationStarted.IsCancellationRequested) { - throw new InvalidOperationException(Properties.Resources.ApplicationNotStartedExceptionMessage); + throw new InvalidOperationException(exceptionMessage); } } diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 21ac4d57fab..d98508f1ac0 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -22,6 +22,7 @@ namespace Aspire.Hosting.Testing; /// public static class DistributedApplicationTestingBuilder { + /// /// Creates a new instance of . /// @@ -37,6 +38,42 @@ public static Task CreateAsync CreateAsync(typeof(TEntryPoint), cancellationToken); + /// + /// Creates a new instance of using the specified testing options. + /// + /// + /// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used. + /// + /// The options that configure behavior selected while the underlying builder is constructed. + /// The command line arguments to pass to the entry point. + /// The . + /// + /// A new instance of . + /// + /// + /// Thrown when or is , or when + /// contains a value. + /// + /// Thrown when contains an empty value. + /// + /// Thrown when is enabled in publish mode. + /// + /// + /// The parameter is required so calls such as CreateAsync<TEntryPoint>(default) + /// continue to bind to the existing cancellation-token overload. + /// + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")] + public static Task CreateAsync( + DistributedApplicationTestingBuilderOptions options, + string[] args, + CancellationToken cancellationToken = default) + where TEntryPoint : class + { + ArgumentNullException.ThrowIfNull(options); + + return CreateAsync(typeof(TEntryPoint), options, args, cancellationToken); + } + /// /// Creates a new instance of . /// @@ -49,6 +86,40 @@ public static Task CreateAsync CreateAsync(Type entryPoint, CancellationToken cancellationToken = default) => CreateAsync(entryPoint, [], cancellationToken); + /// + /// Creates a new instance of using the specified testing options. + /// + /// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used. + /// The options that configure behavior selected while the underlying builder is constructed. + /// The command line arguments to pass to the entry point. + /// The . + /// + /// A new instance of . + /// + /// + /// Thrown when , , or is + /// , or when contains a value. + /// + /// Thrown when contains an empty value. + /// + /// Thrown when is enabled in publish mode. + /// + /// + /// The parameter is required so calls such as CreateAsync(entryPoint, default) + /// continue to bind to the existing cancellation-token overload. + /// + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")] + public static Task CreateAsync( + Type entryPoint, + DistributedApplicationTestingBuilderOptions options, + string[] args, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + + return CreateAsyncCore(entryPoint, args, options, (_, __) => { }, cancellationToken); + } + /// /// Creates a new instance of . /// @@ -106,13 +177,29 @@ public static Task CreateAsync [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")] public static async Task CreateAsync(Type entryPoint, string[] args, Action configureBuilder, CancellationToken cancellationToken = default) + => await CreateAsyncCore(entryPoint, args, testingOptions: null, configureBuilder, cancellationToken).ConfigureAwait(false); + + private static async Task CreateAsyncCore( + Type entryPoint, + string[] args, + DistributedApplicationTestingBuilderOptions? testingOptions, + Action configureBuilder, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(entryPoint); ThrowIfNullOrContainsIsNullOrEmpty(args); ArgumentNullException.ThrowIfNull(configureBuilder, nameof(configureBuilder)); - var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, configureBuilder); - return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); + var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, testingOptions, configureBuilder); + try + { + return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + await factory.DisposeAsync().ConfigureAwait(false); + throw; + } } /// @@ -125,6 +212,35 @@ public static async Task CreateAsync(Type public static IDistributedApplicationTestingBuilder Create(params string[] args) => Create(args, (_, __) => { }); + /// + /// Creates a new instance of using the specified testing options. + /// + /// The options that configure behavior selected while the underlying builder is constructed. + /// The command line arguments to use when building the distributed application. + /// + /// A new instance of . + /// + /// + /// Thrown when or is , or when + /// contains a value. + /// + /// Thrown when contains an empty value. + /// + /// Thrown when is enabled in publish mode. + /// + /// + /// The parameter is required so calls such as Create(default) continue to bind to + /// the existing command-line-arguments overload. + /// + public static IDistributedApplicationTestingBuilder Create( + DistributedApplicationTestingBuilderOptions options, + string[] args) + { + ArgumentNullException.ThrowIfNull(options); + + return CreateCore(args, options, (_, __) => { }); + } + /// /// Creates a new instance of . /// @@ -134,11 +250,18 @@ public static IDistributedApplicationTestingBuilder Create(params string[] args) /// A new instance of . /// public static IDistributedApplicationTestingBuilder Create(string[] args, Action configureBuilder) + => CreateCore(args, testingOptions: null, configureBuilder); + + private static IDistributedApplicationTestingBuilder CreateCore( + string[] args, + DistributedApplicationTestingBuilderOptions? testingOptions, + Action configureBuilder, + Assembly? appHostAssembly = null) { ThrowIfNullOrContainsIsNullOrEmpty(args); ArgumentNullException.ThrowIfNull(configureBuilder); - return new TestingBuilder(args, configureBuilder); + return new TestingBuilder(args, testingOptions, configureBuilder, appHostAssembly); } /// @@ -154,11 +277,162 @@ internal static IDistributedApplicationTestingBuilder Create( string[] args, Action configureBuilder, Assembly appHostAssembly) + => CreateCore(args, testingOptions: null, configureBuilder, appHostAssembly); + + private static bool IsDashboardTestingEnabled(DistributedApplicationTestingBuilderOptions? testingOptions) { - ThrowIfNullOrContainsIsNullOrEmpty(args); - ArgumentNullException.ThrowIfNull(configureBuilder); + // Only the explicit option turns on the hardened testing defaults below. Setting + // DistributedApplicationOptions.DisableDashboard = false through the configureBuilder callback is the older, + // already-shipped spelling of "run a dashboard", and it has to keep the behavior it shipped with: callers use + // it to exercise dashboard behavior against configuration they chose themselves (fixed URLs, anonymous + // access, an ambient browser token), and in publish mode it is simply ignored because no dashboard resource + // is ever added. Treating it as equivalent to the option silently rewrote that configuration and turned + // publish-mode callers into an InvalidOperationException. + return testingOptions?.EnableDashboard == true; + } + + private static void ConfigureDashboardTesting( + DistributedApplicationOptions applicationOptions, + HostApplicationBuilderSettings hostBuilderOptions, + DistributedApplicationTestingBuilderOptions? testingOptions, + out DashboardTestingState dashboardTestingState) + { + if (!IsDashboardTestingEnabled(testingOptions)) + { + dashboardTestingState = default; + return; + } + + var browserToken = TokenGenerator.GenerateToken(); + var resourceServiceApiKey = TokenGenerator.GenerateToken(); + + dashboardTestingState = new DashboardTestingState( + Enabled: true, + BrowserToken: browserToken, + ResourceServiceApiKey: resourceServiceApiKey); + + applicationOptions.DisableDashboard = false; + + // Command-line configuration has higher precedence than the configuration sources supplied through + // HostApplicationBuilderSettings. Append this after the AppHost callback so creation-time arguments and + // configuration cannot select anonymous dashboard authentication before testing defaults are reapplied. + // + // The browser token rides along on the same mechanism, and deliberately not on the in-memory collection + // below, for two reasons. DistributedApplicationBuilder resolves the token during construction and freezes + // it into AppHost:BrowserToken, so it has to be visible before the builder is created and there has to be + // exactly one value. And turning off anonymous access only closes the door if a credential actually exists: + // without this, an ambient ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN on a CI agent would share a single known + // token across every test application running there. + // + // The resource service API key is seeded the same way and for the same second reason. The builder generates + // one when configuration does not supply it, but an ambient ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY would + // otherwise be adopted, giving every test application on that agent the same resource-service credential. + // + // DistributedApplicationFactory assigns the same array instance to both properties before the AppHost + // callback runs, and that callback is free to replace either one. Read whichever instance it left in place, + // and both when they diverged, so caller arguments are appended to rather than silently dropped. + string[] existingArgs = ReferenceEquals(hostBuilderOptions.Args, applicationOptions.Args) + ? [.. hostBuilderOptions.Args ?? []] + : [.. hostBuilderOptions.Args ?? [], .. applicationOptions.Args ?? []]; + + hostBuilderOptions.Args = + [ + .. existingArgs, + $"{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=false", + $"{KnownConfigNames.DashboardFrontendBrowserToken}={browserToken}", + $"{KnownConfigNames.DashboardResourceServiceClientApiKey}={resourceServiceApiKey}" + ]; + applicationOptions.Args = hostBuilderOptions.Args; + + hostBuilderOptions.Configuration ??= new(); + AddDashboardTestingConfiguration(hostBuilderOptions.Configuration); + } + + private static void ConfigureDashboardTesting(IDistributedApplicationBuilder builder, DashboardTestingState dashboardTestingState) + { + if (!dashboardTestingState.Enabled) + { + return; + } + + if (builder.ExecutionContext.IsPublishMode) + { + throw new InvalidOperationException(Properties.Resources.DashboardTestingPublishModeExceptionMessage); + } + + // Apply these after the builder has loaded environment variables and command-line arguments so test + // automation cannot accidentally opt back into fixed ports, anonymous access, or interactivity. + // Callers can still override runtime settings through the returned builder; constructor-time service + // selection, including dashboard authentication, has already completed. + AddDashboardTestingConfiguration(builder.Configuration); + + // Restore the generated browser token if something cleared it. DistributedApplicationBuilder freezes the + // token into AppHost:BrowserToken during construction, but DashboardOptions does not read that key until the + // application starts, so AppHost code running between those two points can blank it. DashboardEventHandlers + // treats a null or empty token as a request for Unsecured frontend authentication, which would silently + // downgrade the authenticated default this opt-in promises, so the guard mirrors that same emptiness check + // and leaves any non-empty token, including a deliberately chosen one, alone. This runs after the AppHost + // entry point has finished configuring, because the builder is not handed back until it reaches Build(), and + // before the caller sees the builder, so a test that wants the anonymous dashboard can still choose it + // through the returned builder. + if (string.IsNullOrEmpty(builder.Configuration["AppHost:BrowserToken"])) + { + builder.Configuration["AppHost:BrowserToken"] = dashboardTestingState.BrowserToken; + } - return new TestingBuilder(args, configureBuilder, appHostAssembly); + // Restore resource-service authentication for the same reason and through the same window. The builder + // freezes AppHost:ResourceService:AuthMode and :ApiKey during construction, but DashboardServiceHost does + // not bind that section into ResourceServiceOptions until the application starts, so AppHost code running in + // between can downgrade either one. ResourceServiceApiKeyAuthenticationHandler only checks the API key + // header when AuthMode is ApiKey and otherwise authenticates every request, and ValidateResourceServiceOptions + // likewise stops requiring a key once the mode is Unsecured, so clearing either value alone is enough to + // expose the resource model on the loopback resource-service endpoint. Both halves are restored together + // because either one on its own leaves the endpoint unauthenticated. Unlike the browser token there is no + // "deliberately chosen" value to preserve: a caller who supplied their own key still ends up in ApiKey mode + // with that key, so this only rewrites configuration that would have been unauthenticated. + if (!string.Equals(builder.Configuration["AppHost:ResourceService:AuthMode"], "ApiKey", StringComparison.OrdinalIgnoreCase) + || string.IsNullOrEmpty(builder.Configuration["AppHost:ResourceService:ApiKey"])) + { + builder.Configuration["AppHost:ResourceService:AuthMode"] = "ApiKey"; + builder.Configuration["AppHost:ResourceService:ApiKey"] = dashboardTestingState.ResourceServiceApiKey; + } + + // Enabling the dashboard makes the hosting default wait indefinitely when a dependency becomes unavailable, + // which would hang a test run instead of failing it. A later user registration can still override this. + builder.Services.Configure( + options => options.DefaultWaitBehavior = WaitBehavior.StopOnResourceUnavailable); + } + + private static void AddDashboardTestingConfiguration(IConfigurationBuilder configuration) + { + configuration.AddInMemoryCollection(new Dictionary + { + ["DcpPublisher:RandomizePorts"] = "true", + + // Empty means "not configured", which is how the product asks for a dynamically assigned port: + // ConfigureDefaultDashboardOptions normalizes blank to null, and DashboardEventHandlers then creates + // the endpoint with port: null. Writing an explicit "http://127.0.0.1:0" instead would parse to the + // fixed port 0 and only behave dynamically while DcpPublisher:RandomizePorts stays true, which a test + // is free to turn off. These endpoints still bind to loopback because EndpointAnnotation.TargetHost + // defaults to localhost. + [KnownAspNetCoreConfigNames.Urls] = string.Empty, + [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = string.Empty, + [KnownConfigNames.DashboardOtlpHttpEndpointUrl] = string.Empty, + + // The resource service reads its port directly and has first-class handling for port 0. + [KnownConfigNames.ResourceServiceEndpointUrl] = "http://127.0.0.1:0", + + [KnownConfigNames.AllowUnsecuredTransport] = "true", + [KnownConfigNames.DashboardUnsecuredAllowAnonymous] = "false", + [KnownConfigNames.InteractivityEnabled] = "false", + + // The AppHost and child dashboard each write a startup summary. Under the testing host, both flow into + // test and CI output, so withhold the live browser credential from each while keeping their endpoint + // lines. The ASPIRE_DASHBOARD-prefixed value is copied to the child process by DashboardEventHandlers. + // Tests get the credential through GetDashboardUrlAsync instead. + ["AppHost:SuppressDashboardLoginUrlInStartupSummary"] = "true", + [KnownConfigNames.DashboardSuppressBrowserTokenInOutput] = "true" + }); } private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) @@ -178,14 +452,29 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) } } - private sealed class SuspendingDistributedApplicationFactory(Type entryPoint, string[] args, Action configureBuilder) + /// + /// The dashboard testing configuration resolved during builder construction. Carried as a single value so the + /// pre-construction and post-construction halves of the configuration cannot drift apart. + /// + private readonly record struct DashboardTestingState(bool Enabled, string? BrowserToken, string? ResourceServiceApiKey); + + private sealed class SuspendingDistributedApplicationFactory( + Type entryPoint, + string[] args, + DistributedApplicationTestingBuilderOptions? testingOptions, + Action configureBuilder) : DistributedApplicationFactory(entryPoint, args) { private readonly SemaphoreSlim _continueBuilding = new(0); + // Resolved while the builder is being constructed, because dashboard services and dashboard authentication + // are selected during construction and the post-construction half of the configuration has to agree with it. + private DashboardTestingState _dashboardTestingState; + public async Task CreateBuilderAsync(CancellationToken cancellationToken) { var innerBuilder = await ResolveBuilderAsync(cancellationToken).ConfigureAwait(false); + ConfigureDashboardTesting(innerBuilder, _dashboardTestingState); return new Builder(this, innerBuilder); } @@ -193,11 +482,7 @@ protected override void OnBuilderCreating(DistributedApplicationOptions applicat { base.OnBuilderCreating(applicationOptions, hostOptions); configureBuilder(applicationOptions, hostOptions); - } - - protected override void OnBuilderCreated(DistributedApplicationBuilder applicationBuilder) - { - base.OnBuilderCreated(applicationBuilder); + ConfigureDashboardTesting(applicationOptions, hostOptions, testingOptions, out _dashboardTestingState); } protected override void OnBuilding(DistributedApplicationBuilder applicationBuilder) @@ -323,18 +608,21 @@ public async Task StopAsync(CancellationToken cancellationToken) private sealed class TestingBuilder( string[] args, + DistributedApplicationTestingBuilderOptions? testingOptions, Action configureBuilder, Assembly? appHostAssembly = null) : IDistributedApplicationTestingBuilder { - private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, configureBuilder, appHostAssembly); + private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, testingOptions, configureBuilder, appHostAssembly); private DistributedApplication? _app; private static DistributedApplicationBuilder CreateInnerBuilder( string[] args, + DistributedApplicationTestingBuilderOptions? testingOptions, Action configureBuilder, Assembly? appHostAssembly = null) { + var dashboardTestingState = default(DashboardTestingState); var builder = TestingBuilderFactory.CreateBuilder(args, onConstructing: (applicationOptions, hostBuilderOptions) => { Assembly appAssembly; @@ -347,9 +635,15 @@ private static DistributedApplicationBuilder CreateInnerBuilder( appAssembly = FindApplicationAssembly(); } - DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, appAssembly, configureBuilder); + DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, appAssembly, (options, settings) => + { + configureBuilder(options, settings); + ConfigureDashboardTesting(options, settings, testingOptions, out dashboardTestingState); + }); }); + ConfigureDashboardTesting(builder, dashboardTestingState); + if (!builder.Configuration.GetValue(KnownConfigNames.TestingDisableHttpClient, false)) { builder.Services.AddHttpClient(); diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs new file mode 100644 index 00000000000..49cc6cc7999 --- /dev/null +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Testing; + +/// +/// Options that control how an is created. +/// +/// +/// These options are applied while the underlying distributed application builder is being constructed, because the +/// dashboard services themselves are selected during construction and cannot be added afterwards. The settings those +/// services read - port allocation, interactivity, and the generated browser token - remain +/// adjustable through the returned builder until the application is built. +/// +/// +/// The following example creates a testing builder that runs the dashboard: +/// +/// var options = new DistributedApplicationTestingBuilderOptions +/// { +/// EnableDashboard = true +/// }; +/// +/// var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.MyAppHost_AppHost>(options, []); +/// await using var app = await builder.BuildAsync(); +/// await app.StartAsync(); +/// +/// // Open this in a browser to inspect the running application. +/// var dashboardUrl = await app.GetDashboardUrlAsync(); +/// +/// +public sealed class DistributedApplicationTestingBuilderOptions +{ + /// + /// Gets or sets a value indicating whether the Aspire dashboard runs alongside the application under test. + /// + /// + /// to run the dashboard; otherwise, . + /// The default is . + /// + /// + /// + /// When the dashboard runs, it listens on loopback endpoints using dynamically assigned ports, so concurrent + /// test applications do not compete for a fixed port. Each application generates its own browser token instead + /// of reusing an ambient dashboard credential. Dashboard instances in the same browser still share the dashboard + /// authentication cookie, so use separate browser contexts when tests require isolation between dashboards. Use + /// to obtain a browser-ready + /// URL for the running dashboard. + /// + /// + /// Enabling the dashboard sets to + /// after AppHost configuration. To use a different default + /// for WaitFor, configure through the returned builder + /// before building the application. + /// + /// + /// The dashboard process remains reachable when a debugger pauses the test process, but resource state changes, + /// logs, and commands flow through the paused process and do not update until execution resumes. + /// + /// + public bool EnableDashboard { get; set; } +} diff --git a/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs b/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs index 43c44729bc4..77556d413fb 100644 --- a/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs +++ b/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs @@ -69,6 +69,51 @@ internal static string ApplicationNotStartedExceptionMessage { } } + /// + /// Looks up a localized string similar to The dashboard is not enabled for this application.. + /// + internal static string DashboardDisabledExceptionMessage { + get { + return ResourceManager.GetString("DashboardDisabledExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The application must be started before retrieving the dashboard URL.. + /// + internal static string DashboardUrlApplicationNotStartedExceptionMessage { + get { + return ResourceManager.GetString("DashboardUrlApplicationNotStartedExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The dashboard URL is not available in publish mode.. + /// + internal static string DashboardUrlPublishModeExceptionMessage { + get { + return ResourceManager.GetString("DashboardUrlPublishModeExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The dashboard URL is not available.. + /// + internal static string DashboardUrlUnavailableExceptionMessage { + get { + return ResourceManager.GetString("DashboardUrlUnavailableExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dashboard testing is not supported in publish mode.. + /// + internal static string DashboardTestingPublishModeExceptionMessage { + get { + return ResourceManager.GetString("DashboardTestingPublishModeExceptionMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Endpoint '{0}' for resource '{1}' not found.. /// diff --git a/src/Aspire.Hosting.Testing/Properties/Resources.resx b/src/Aspire.Hosting.Testing/Properties/Resources.resx index ec59974c7d9..cc5dcc3d91e 100644 --- a/src/Aspire.Hosting.Testing/Properties/Resources.resx +++ b/src/Aspire.Hosting.Testing/Properties/Resources.resx @@ -120,6 +120,21 @@ The application must be started before resolving endpoints or connection strings. + + The dashboard is not enabled for this application. + + + The application must be started before retrieving the dashboard URL. + + + The dashboard URL is not available in publish mode. + + + The dashboard URL is not available. + + + Dashboard testing is not supported in publish mode. + Endpoint '{0}' for resource '{1}' not found. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf index 61efe72e431..7bc577a4364 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf @@ -7,6 +7,31 @@ Aplikace musí být spuštěna před překladem koncových bodů nebo připojovacích řetězců. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. Koncový bod {0} pro prostředek {1} se nenašel. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf index e2c744e2440..86d7f8093d8 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf @@ -7,6 +7,31 @@ Die Anwendung muss gestartet werden, bevor Endpunkte oder Verbindungszeichenfolgen aufgelöst werden können. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. Der Endpunkt "{0}" für die Ressource "{1}" wurde nicht gefunden. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf index 1804c647edf..063c8087209 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf @@ -7,6 +7,31 @@ La aplicación debe iniciarse antes de resolver los puntos de conexión o las cadenas de conexión. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. No se encuentra el de punto de conexión '{0}' para el recurso '{1}'. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf index 313b33dc41c..888554c6c5c 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf @@ -7,6 +7,31 @@ L’application doit être démarrée avant de résoudre les points de terminaison ou les chaînes de connexion. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. Le point de terminaison « {0} » de la ressource « {1} » est introuvable. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf index e71c7e91baa..9a04bc110fe 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf @@ -7,6 +7,31 @@ È necessario avviare l'applicazione prima di risolvere endpoint o stringhe di connessione. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. L'endpoint '{0}' per la risorsa '{1}' non è stato trovato. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf index 6b22c3d35d7..9823cb7f2ba 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf @@ -7,6 +7,31 @@ エンドポイントまたは接続文字列を解決する前に、アプリケーションを起動する必要があります。 + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. リソース '{1}' のエンドポイント '{0}' が見つかりません。 diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf index d4b32f6bf43..fa3e4495858 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf @@ -7,6 +7,31 @@ 엔드포인트 또는 연결 문자열을 확인하려면 먼저 애플리케이션을 시작해야 합니다. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. '{1}' 리소스에 대한 '{0}' 엔드포인트를 찾을 수 없습니다. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf index 4af037877ef..5580e892519 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf @@ -7,6 +7,31 @@ Aplikacja musi zostać uruchomiona przed rozpoznaniem punktów końcowych lub parametrów połączenia. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. Nie znaleziono punktu końcowego „{0}” dla zasobu „{1}”. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf index 4bae1d1e624..d51ea9f9546 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf @@ -7,6 +7,31 @@ O aplicativo deve ser iniciado antes de resolver pontos de extremidade ou cadeias de conexão. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. O ponto de extremidade “{0}” para o recurso “{1}” não foi encontrado. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf index 9df64bff376..0dda672f576 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf @@ -7,6 +7,31 @@ Перед разрешением конечных точек или строк подключения необходимо запустить приложение. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. Конечная точка "{0}" для ресурса "{1}" не найдена. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf index 3e26aac0c56..88c297690f7 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf @@ -7,6 +7,31 @@ Uç noktalarını veya bağlantı dizelerini çözümlemeden önce uygulama başlatmalıdır. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. '{1}' kaynağı için '{0}' uç noktası bulunamadı. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf index 162d75096bb..2929dbe5bcd 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf @@ -7,6 +7,31 @@ 必须先启动应用程序,然后才能解析终结点或连接字符串。 + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. 找不到资源“{1}”的终结点“{0}”。 diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf index bac7f8875b3..646e0225a6d 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf @@ -7,6 +7,31 @@ 必須先啟動應用程式,才能解析端點或連接字串。 + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. 找不到資源 '{1}' 的端點 '{0}'。 diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs index 9a955056b39..1c6c50316d9 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs @@ -6,7 +6,8 @@ namespace Aspire.Hosting.ApplicationModel; /// -/// Provides helper methods for validating localhost addresses. +/// Provides helper methods for validating localhost addresses and for resolving endpoint URLs +/// against a configured target host. /// public static class EndpointHostHelpers { @@ -120,8 +121,11 @@ public static bool IsLocalhostOrLocalhostTld([NotNullWhen(true)] Uri? uri) /// The endpoint reference. /// A cancellation token. /// The URL with the appropriate hostname. - internal static async ValueTask GetUrlWithTargetHostAsync(EndpointReference endpoint, CancellationToken cancellationToken = default) + /// Thrown when is null. + public static async ValueTask GetUrlWithTargetHostAsync(EndpointReference endpoint, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(endpoint); + var allocatedUrl = await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(allocatedUrl)) @@ -132,8 +136,13 @@ public static bool IsLocalhostOrLocalhostTld([NotNullWhen(true)] Uri? uri) // If the configured TargetHost is a localhost TLD (e.g., aspire-dashboard.dev.localhost), // we need to use that instead of the allocated address (localhost) since the TLD hostname // is what the user expects to see and use in the browser. + // + // Only do this when the resolved address is itself loopback. The same endpoint resolves to a + // routable address (e.g. container1.dev.internal) when the reference carries a non-local + // ContextNetworkID, and a *.localhost name always resolves to the caller's own loopback, so + // substituting there would point a container at itself. var targetHost = endpoint.EndpointAnnotation.TargetHost; - if (IsLocalhostTld(targetHost) && Uri.TryCreate(allocatedUrl, UriKind.Absolute, out var uri)) + if (IsLocalhostTld(targetHost) && Uri.TryCreate(allocatedUrl, UriKind.Absolute, out var uri) && uri.IsLoopback) { return $"{uri.Scheme}://{targetHost}:{uri.Port}"; } diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 4dce6f2a906..769ff3529de 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -134,6 +134,7 @@ + diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 27c7395f501..0ef0c70732a 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -182,7 +182,7 @@ private static (string NetCoreVersion, string AspNetCoreVersion) GetFallbackFram return (FallbackNetCoreVersion, FallbackAspNetCoreVersion); } - private string CreateCustomRuntimeConfig(string dashboardPath) + private string ResolveDashboardRuntimeConfig(string dashboardPath) { // Find the dashboard runtimeconfig.json string originalRuntimeConfig; @@ -232,46 +232,9 @@ private string CreateCustomRuntimeConfig(string dashboardPath) return customConfigPath; } - // Read the original runtime config - var originalConfigText = File.ReadAllText(originalRuntimeConfig); - var configJson = JsonNode.Parse(originalConfigText)?.AsObject(); - - if (configJson is null) - { - throw new DistributedApplicationException($"Failed to parse dashboard runtime config: {originalRuntimeConfig}"); - } - - // Get AppHost framework versions from its runtimeconfig.json - var (netCoreVersion, aspNetCoreVersion) = GetAppHostFrameworkVersions(); - - // Update the framework versions - if (configJson["runtimeOptions"]?.AsObject() is { } runtimeOptions && - runtimeOptions["frameworks"]?.AsArray() is { } frameworks) - { - foreach (var framework in frameworks) - { - if (framework?.AsObject() is { } frameworkObj && - frameworkObj["name"]?.GetValue() is { } name) - { - switch (name) - { - case "Microsoft.NETCore.App": - frameworkObj["version"] = netCoreVersion; - break; - case "Microsoft.AspNetCore.App": - frameworkObj["version"] = aspNetCoreVersion; - break; - } - } - } - } - - // Create a temporary file for the custom runtime config - var tempPath = directoryService.TempDirectory.CreateTempFile("runtimeconfig.json").Path; - File.WriteAllText(tempPath, configJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); - - _customRuntimeConfigPath = tempPath; - return tempPath; + // The Dashboard can target a newer runtime than the AppHost. Rewriting this file with the + // AppHost's framework versions can make a net11 Dashboard launch on a net8 runtime. + return originalRuntimeConfig; } private void AddDashboardResource(DistributedApplicationModel model) @@ -296,32 +259,21 @@ private void AddDashboardResource(DistributedApplicationModel model) args.Insert(0, "dashboard"); })); } + else if (GetManagedDashboardAssemblyPath(fullyQualifiedDashboardPath) is null) + { + // Native AOT publishes only the platform executable. There is no managed assembly or + // runtimeconfig to rewrite, and launching through dotnet would bypass the native image. + dashboardResource = new ExecutableResource( + KnownResourceNames.AspireDashboard, + fullyQualifiedDashboardPath, + dashboardWorkingDirectory ?? ""); + } else { - // Non-bundle: run via dotnet exec with custom runtime config - // Create custom runtime config with AppHost's framework versions - var customRuntimeConfigPath = CreateCustomRuntimeConfig(fullyQualifiedDashboardPath); - - string dashboardDll; - if (string.Equals(".dll", Path.GetExtension(fullyQualifiedDashboardPath), StringComparison.OrdinalIgnoreCase)) - { - dashboardDll = fullyQualifiedDashboardPath; - } - else - { - // For executables with separate DLLs - var directory = Path.GetDirectoryName(fullyQualifiedDashboardPath)!; - var fileName = Path.GetFileName(fullyQualifiedDashboardPath); - var baseName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) - ? fileName.Substring(0, fileName.Length - 4) - : fileName; - dashboardDll = Path.Combine(directory, $"{baseName}.dll"); - } - - if (!File.Exists(dashboardDll)) - { - distributedApplicationLogger.LogError("Dashboard DLL not found: {Path}", dashboardDll); - } + // Non-bundle: run via dotnet exec with the Dashboard's runtime config. The Dashboard can + // target a newer runtime than the AppHost, so its framework versions must be preserved. + var dashboardRuntimeConfigPath = ResolveDashboardRuntimeConfig(fullyQualifiedDashboardPath); + var dashboardDll = GetManagedDashboardAssemblyPath(fullyQualifiedDashboardPath)!; dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, "dotnet", dashboardWorkingDirectory ?? ""); @@ -329,7 +281,7 @@ private void AddDashboardResource(DistributedApplicationModel model) { args.Add("exec"); args.Add("--runtimeconfig"); - args.Add(customRuntimeConfigPath); + args.Add(dashboardRuntimeConfigPath); args.Add(dashboardDll); })); } @@ -341,6 +293,23 @@ private void AddDashboardResource(DistributedApplicationModel model) model.Resources.Insert(0, dashboardResource); } + private static string? GetManagedDashboardAssemblyPath(string dashboardPath) + { + if (string.Equals(".dll", Path.GetExtension(dashboardPath), StringComparison.OrdinalIgnoreCase)) + { + return dashboardPath; + } + + var directory = Path.GetDirectoryName(dashboardPath)!; + var fileName = Path.GetFileName(dashboardPath); + var baseName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? fileName[..^4] + : fileName; + var assemblyPath = Path.Combine(directory, $"{baseName}.dll"); + + return File.Exists(assemblyPath) ? assemblyPath : null; + } + private void ConfigureAspireDashboardResource(IResource dashboardResource) { // The dashboard resource can be visible during development. We don't want people to be able to stop the dashboard from inside the dashboard. @@ -542,7 +511,13 @@ private async Task DashboardStarted(ResourceReadyEvent @event, CancellationToken () => GetEndpointUrlAsync(dashboardResource, KnownEndpointNames.OtlpHttpEndpointName, cancellationToken), options.OtlpHttpEndpointUrl).ConfigureAwait(false); - LoggingHelpers.WriteDashboardSummary(distributedApplicationLogger, dashboardUrl, otlpGrpcUrl, otlpHttpUrl, browserToken, isContainer: false); + // Withholding the token drops the login URL from the summary and the separate "Login to the dashboard at" + // line, leaving the dashboard and OTLP endpoints. Testing sets this because its token is a live + // credential for a dashboard the test already has a supported accessor for, and the AppHost logger in + // that mode is test and CI output. + var summaryToken = options.SuppressLoginUrlInStartupSummary ? null : browserToken; + + LoggingHelpers.WriteDashboardSummary(distributedApplicationLogger, dashboardUrl, otlpGrpcUrl, otlpHttpUrl, summaryToken, isContainer: false); } private async ValueTask ResolveUrlAsync(Func> resolveCallback, string? configuredUrl) diff --git a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs index 5972cd59936..0473d0fdd19 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs @@ -18,6 +18,17 @@ internal class DashboardOptions public string? ApiKey { get; set; } public string AspNetCoreEnvironment { get; set; } = "Production"; public bool? TelemetryOptOut { get; set; } + + /// + /// Suppresses the login URL, and only the login URL, from the AppHost's dashboard startup summary. + /// + /// + /// Set by Aspire.Hosting.Testing when it runs a dashboard for a test application. That dashboard's + /// browser token is generated per application and handed to the test through + /// GetDashboardUrlAsync, so writing it to the AppHost logger only publishes a live credential + /// into test and CI output. The dashboard and OTLP endpoint lines are still written. + /// + public bool SuppressLoginUrlInStartupSummary { get; set; } } internal class ConfigureDefaultDashboardOptions(IConfiguration configuration, IOptions dcpOptions) : IConfigureOptions @@ -36,6 +47,8 @@ public void Configure(DashboardOptions options) options.AspNetCoreEnvironment = configuration[KnownAspNetCoreConfigNames.Environment] ?? "Production"; + options.SuppressLoginUrlInStartupSummary = bool.TryParse(configuration["AppHost:SuppressDashboardLoginUrlInStartupSummary"], out var suppressLoginUrl) && suppressLoginUrl; + options.TelemetryOptOut = bool.TryParse(configuration["ASPIRE_DASHBOARD_TELEMETRY_OPTOUT"], out var telemetryOptOut) ? telemetryOptOut : null; diff --git a/src/Aspire.Hosting/Dcp/DcpOptions.cs b/src/Aspire.Hosting/Dcp/DcpOptions.cs index 2595301eeed..150498e93d7 100644 --- a/src/Aspire.Hosting/Dcp/DcpOptions.cs +++ b/src/Aspire.Hosting/Dcp/DcpOptions.cs @@ -32,7 +32,7 @@ internal sealed class DcpOptions /// Optional path to a folder containing the Aspire Dashboard binaries. /// /// - /// When running the playground applications in this repo: ..\..\..\artifacts\bin\Aspire.Dashboard\Debug\net8.0\Aspire.Dashboard.dll + /// When running the playground applications in this repo: ..\..\..\artifacts\bin\Aspire.Dashboard\Debug\net11.0\Aspire.Dashboard.dll /// public string? DashboardPath { get; set; } @@ -281,11 +281,10 @@ public void Configure(DcpOptions options) // 4. Runtime inference from DashboardPath (below): only fires when none of the // above produced a value, which happens when the AppHost was built on a // machine where ResolveAspireCliBundle could not locate the bundle, but at - // runtime the launching CLI did set ASPIRE_DASHBOARD_PATH. Since 13.4 the - // bundle ships a single multi-mode aspire-managed exe that dispatches to - // dashboard / terminalhost via a leading subcommand arg, so reusing - // DashboardPath as the terminal host (with "terminalhost" as the dispatch - // arg) is correct. + // runtime the launching CLI did set ASPIRE_DASHBOARD_PATH. This fallback + // applies to legacy bundles where that path points to the multi-mode + // aspire-managed executable. New bundles ship a separate Native AOT + // Dashboard and provide terminal-host metadata directly. // // Note: if both the dashboard and terminal host paths end up empty, .WithTerminal() // resources will fail at start time; see TerminalHostFailureDiagnosticService for diff --git a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs index beb3f135c95..c1103c7ab9a 100644 --- a/src/Aspire.Hosting/Dcp/ExecutableCreator.cs +++ b/src/Aspire.Hosting/Dcp/ExecutableCreator.cs @@ -733,7 +733,19 @@ private async Task BuildExecutableConfiguration(Rendere if (certificateTrustConfiguration.CustomBundlesFactories.Count > 0) { - Directory.CreateDirectory(customBundleOutputPath); + if (OperatingSystem.IsWindows()) + { + Directory.CreateDirectory(customBundleOutputPath); + } + else + { + // Owner-only, matching the server-auth directory below. A custom bundle is a trust + // store: anything that can write to it chooses which roots the process trusts. The + // enclosing temp directory is already owner-only, but that only holds for the + // session-scoped path - a Lifetime.Persistent executable writes outside it, where + // the default umask would leave this directory group- and world-readable. + Directory.CreateDirectory(customBundleOutputPath, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead); + } } foreach (var bundleFactory in certificateTrustConfiguration.CustomBundlesFactories) @@ -834,12 +846,17 @@ bool showLaunchToolArgsInCommandLine omittedLaunchToolArgumentCount = Math.Max(0, omittedLaunchToolArgumentCount - 1); } - var dotnetProjectLaunchResourceArgumentIndex = FindExecutableAnnotatedDotnetProjectLaunchArgumentIndex( + var (dotnetProjectLaunchResourceArgumentIndex, canReuseArgsForProcessFallback) = AnalyzeExecutableAnnotatedDotnetProjectArguments( er.ModelResource, appHostArgList); + var dotnetProjectApplicationArgumentBoundaryIndex = + dotnetProjectLaunchResourceArgumentIndex is { } boundarySearchStartIndex + ? appHostArgList.FindIndex( + boundarySearchStartIndex + 1, + static argument => string.Equals(argument.Value, "--", StringComparison.Ordinal)) + : -1; var launchArgs = new List(); int? dotnetProjectLaunchArgumentIndex = null; - var canReuseArgsForProcessFallback = true; var nextExecutableArgumentIndex = executableArgumentStartIndex; List? projectLaunchProfileArgs = null; var includeProfileArgsInSpec = false; @@ -892,11 +909,13 @@ LaunchArgument CreateLaunchArgument(string value, bool isSensitive, bool executa if (projectLaunchProfileArgs.Count > 0 && ordinaryAppHostArgumentCount > 0 && launchToolArgumentCount == 0 && - HasDotnetApplicationArgumentBoundary()) + HasDotnetApplicationArgumentBoundary() && + dotnetProjectApplicationArgumentBoundaryIndex < 0) { // A prepared project command or explicit `dotnet run`/`dotnet watch` invocation needs a // double-dash before application arguments. Custom IDE launchers receive raw application - // arguments instead. + // arguments instead. An explicit command can already contain that boundary, in which case + // its launch-profile arguments are inserted after the existing separator below. projectLaunchProfileArgs.Insert(0, "--"); } } @@ -911,12 +930,20 @@ bool HasDotnetApplicationArgumentBoundary() return dotnetProjectLaunchResourceArgumentIndex is { } index && index >= omittedLaunchToolArgumentCount; } } - // Project launch-profile arguments are application arguments. When a custom launch-tool declaration replaces - // the implicit `dotnet run` scaffold, keep its prefix first and insert profile arguments before ordinary - // app-host arguments. Without such a declaration, preserve the existing profile-before-app-host ordering. - var projectLaunchProfileArgumentInsertIndex = launchToolArgumentCount > 0 - ? Math.Min(launchToolArgumentCount, appHostArgList.Count) - : 0; + // Project launch-profile arguments are application arguments. Place them after an explicit executable + // `dotnet run`/`dotnet watch` command so its SDK options stay before the `--` separator. If the command + // already has a separator, put profile arguments before its existing application arguments. When a custom + // launch-tool declaration replaces the command, keep its prefix first and insert profile arguments before + // ordinary app-host arguments. Without either form, preserve the existing profile-before-app-host ordering. + var projectLaunchProfileArgumentInsertIndex = + dotnetProjectLaunchResourceArgumentIndex is { } projectLaunchResourceArgumentIndex && + projectLaunchResourceArgumentIndex >= omittedLaunchToolArgumentCount + ? dotnetProjectApplicationArgumentBoundaryIndex >= 0 + ? dotnetProjectApplicationArgumentBoundaryIndex + 1 + : appHostArgList.Count + : launchToolArgumentCount > 0 + ? Math.Min(launchToolArgumentCount, appHostArgList.Count) + : 0; // Launch tool arguments (the tool-invocation prefix such as `run ./cmd/api`) are the leading app-host args, // and the two decisions about them are independent: @@ -957,30 +984,76 @@ bool HasDotnetApplicationArgumentBoundary() return (launchArgs, dotnetProjectLaunchArgumentIndex, canReuseArgsForProcessFallback); } - private static int? FindExecutableAnnotatedDotnetProjectLaunchArgumentIndex( + private static (int? ProjectLaunchArgumentIndex, bool CanReuseArgsForProcessFallback) AnalyzeExecutableAnnotatedDotnetProjectArguments( IResource resource, IReadOnlyList<(string Value, bool IsSensitive)> appHostArgs) { if (!IsExecutableAnnotatedDotnetProject(resource)) { - return null; + return (null, true); } - // Recognize the project-launching SDK verb only immediately after the dotnet executable: + // Recognize the project-launching SDK verb after supported non-terminating command prefixes: // dotnet run ... - // dotnet watch ... - // Later values belong to another SDK command or the launched application, for example: + // dotnet [env:ASPNETCORE_ENVIRONMENT=Development] [env:DOTNET_ENVIRONMENT=Development] --diagnostics run ... + // dotnet -d watch ... + // The System.CommandLine environment directive works for built-in SDK commands such as run, but .NET 10 + // does not resolve the external watch command through it. Response files are also valid CLI inputs, but they + // can expand to arbitrary options, commands, or application paths, so do not skip an opaque @file token: + // dotnet @options.rsp run ... + // Do not skip arbitrary options. Runtime options introduce an application path rather than an SDK command: + // dotnet --roll-forward LatestMajor app.dll run + // Other later values can also belong to another SDK command or the launched application: // dotnet tool run // dotnet exec app.dll watch - // They must not be interpreted as the top-level project-launch verb. - // See https://learn.microsoft.com/dotnet/core/tools/dotnet-run and - // https://learn.microsoft.com/dotnet/core/tools/dotnet-watch. - if (appHostArgs.Count > 0 && appHostArgs[0].Value is "run" or "watch") + // Those later run/watch values must not be interpreted as the top-level project-launch verb. + // See https://learn.microsoft.com/dotnet/core/tools/dotnet, + // https://learn.microsoft.com/dotnet/core/tools/dotnet-run, and + // https://learn.microsoft.com/dotnet/core/tools/dotnet-watch. The environment directive is defined at + // https://github.com/dotnet/command-line-api/blob/main/src/System.CommandLine/EnvironmentVariablesDirective.cs. + var projectLaunchArgumentIndex = 0; + var hasEnvironmentVariableDirective = false; + while (projectLaunchArgumentIndex < appHostArgs.Count && + IsDotnetEnvironmentVariableDirective(appHostArgs[projectLaunchArgumentIndex].Value)) { - return 0; + hasEnvironmentVariableDirective = true; + projectLaunchArgumentIndex++; } - return null; + while (projectLaunchArgumentIndex < appHostArgs.Count && + IsDotnetSdkDiagnosticOption(appHostArgs[projectLaunchArgumentIndex].Value)) + { + projectLaunchArgumentIndex++; + } + + if (projectLaunchArgumentIndex < appHostArgs.Count) + { + var candidate = appHostArgs[projectLaunchArgumentIndex].Value; + if (candidate == "run") + { + return (projectLaunchArgumentIndex, true); + } + + if (candidate == "watch") + { + return hasEnvironmentVariableDirective + ? (null, false) + : (projectLaunchArgumentIndex, true); + } + } + + return (null, true); + } + + private static bool IsDotnetEnvironmentVariableDirective(string argument) + { + return string.Equals(argument, "[env]", StringComparison.OrdinalIgnoreCase) || + argument.StartsWith("[env:", StringComparison.OrdinalIgnoreCase) && argument.EndsWith(']'); + } + + private static bool IsDotnetSdkDiagnosticOption(string argument) + { + return argument is "-d" or "--diagnostics"; } private static bool IsExecutableAnnotatedDotnetProject(IResource resource) @@ -997,21 +1070,6 @@ private void AddDotnetProjectLaunchArgsForExecutableAnnotatedProject(List? launchProfileArgs = null; - var firstExecutableArgumentIndex = launchArgs.FindIndex(static argument => argument.Executable); - if (firstExecutableArgumentIndex >= 0 && - projectLaunchIndex > firstExecutableArgumentIndex && - string.Equals(launchArgs[firstExecutableArgumentIndex].Value, "--", StringComparison.Ordinal)) - { - // Executable launch-profile args were composed before the caller-provided project launch command. - // Preserve any non-executable launch-tool display prefix, then move the profile segment after - // the project launch command so the SDK parses it as application arguments. - var launchProfileArgumentCount = projectLaunchIndex - firstExecutableArgumentIndex; - launchProfileArgs = launchArgs.GetRange(firstExecutableArgumentIndex, launchProfileArgumentCount); - launchArgs.RemoveRange(firstExecutableArgumentIndex, launchProfileArgumentCount); - projectLaunchIndex -= launchProfileArgumentCount; - } - var argsToInsert = new List(); if (!string.IsNullOrEmpty(_distributedApplicationOptions.Configuration) && !ContainsDotnetProjectLaunchOption(launchArgs, "--configuration", "-c")) @@ -1025,7 +1083,7 @@ private void AddDotnetProjectLaunchArgsForExecutableAnnotatedProject(List new LaunchArgument(argument, IsSensitive: false, Executable: true, Display: false, EffectiveArgumentIndex: null))); } - if (launchProfileArgs is not null) - { - // Launch profile args were originally before the app host args, separated by `--`. - // Once this path preserves the caller-provided project launch command, those args must - // move after the inserted SDK options so the SDK parses them as application args. - launchArgs.AddRange(launchProfileArgs); - } - ReindexExecutableLaunchArgs(launchArgs, executableArgumentStartIndex); } diff --git a/src/Aspire.Managed/Aspire.Managed.csproj b/src/Aspire.Managed/Aspire.Managed.csproj index 7721ea4fd14..ced4d105e7d 100644 --- a/src/Aspire.Managed/Aspire.Managed.csproj +++ b/src/Aspire.Managed/Aspire.Managed.csproj @@ -33,7 +33,6 @@ - diff --git a/src/Aspire.Managed/Program.cs b/src/Aspire.Managed/Program.cs index f5ea46b2c5a..73bd2ddfb0a 100644 --- a/src/Aspire.Managed/Program.cs +++ b/src/Aspire.Managed/Program.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Dashboard; using Aspire.Managed.NuGet.Commands; using Aspire.TerminalHost; using Aspire.Shared; @@ -22,43 +21,12 @@ return args switch { - ["dashboard", .. var rest] => await RunDashboard(rest).ConfigureAwait(false), ["server", .. var rest] => await RunServer(rest).ConfigureAwait(false), ["nuget", .. var rest] => await RunNuGet(rest).ConfigureAwait(false), ["terminalhost", .. var rest] => await RunTerminalHost(rest).ConfigureAwait(false), _ => ShowUsage() }; -static async Task RunDashboard(string[] args) -{ - var options = new WebApplicationOptions - { - Args = args, - ContentRootPath = AppContext.BaseDirectory - }; - - var app = new DashboardWebApplication(options: options); - - // Tear the dashboard down if the launching CLI dies so a hard-killed `aspire dashboard run` (or the - // profiling collector, which also launches aspire-managed dashboard) cannot leave an orphaned - // dashboard process behind. No-op when ASPIRE_CLI_PID is not set — either the dashboard is launched - // directly (so the embedded/in-process dashboard is unaffected), or on Windows where the CLI relies - // on the kernel kill-on-close job instead (see LayoutProcessRunner). - using var shutdownCts = new CancellationTokenSource(); - var parentWatchdog = ParentProcessWatchdog.Start(shutdownCts); - try - { - return await app.RunAsync(shutdownCts.Token).ConfigureAwait(false); - } - finally - { - if (parentWatchdog is not null) - { - await parentWatchdog.DisposeAsync().ConfigureAwait(false); - } - } -} - static async Task RunServer(string[] args) { await Aspire.Hosting.RemoteHost.RemoteHostServer.RunAsync(args).ConfigureAwait(false); @@ -97,6 +65,6 @@ static async Task RunTerminalHost(string[] args) static int ShowUsage() { - Console.Error.WriteLine($"Usage: {AppDomain.CurrentDomain.FriendlyName} [args...]"); + Console.Error.WriteLine($"Usage: {AppDomain.CurrentDomain.FriendlyName} [args...]"); return 1; } diff --git a/src/Aspire.TypeSystem/RuntimeSpec.cs b/src/Aspire.TypeSystem/RuntimeSpec.cs index d4339d16471..d9823788c68 100644 --- a/src/Aspire.TypeSystem/RuntimeSpec.cs +++ b/src/Aspire.TypeSystem/RuntimeSpec.cs @@ -111,4 +111,56 @@ public sealed class CommandSpec /// These are merged with any environment variables provided by the caller. /// public Dictionary? EnvironmentVariables { get; init; } + + /// + /// Gets an optional incremental-build check. When set, the command is skipped if its stamp file + /// is newer than every declared input. Null means the command always runs. + /// + public CommandUpToDateCheck? UpToDateCheck { get; init; } +} + +/// +/// Declares the inputs and stamp file that let the CLI skip a command whose work is already done. +/// +/// +/// This exists for compilers that have no incremental mode of their own. javac given an +/// explicit list of source files recompiles all of them every time, so an AppHost that has not +/// changed still pays a full compile of the generated SDK on every launch. Toolchains that are +/// already incremental (cargo, for instance) do not need this. +/// +public sealed class CommandUpToDateCheck +{ + /// + /// Gets the inputs to compare against the stamp file, relative to the working directory unless + /// absolute. Supports the same placeholders as . + /// + /// + /// An entry is either a file, a directory, or a directory suffixed with /**. A plain + /// directory is scanned one level deep; only the /** form recurses. That distinction is + /// what keeps the check cheap: the AppHost directory can be declared as an input for the sources + /// that sit beside the AppHost without walking sibling trees such as node_modules. + /// Entries that do not exist are ignored, so a spec can name a path that only some layouts have. + /// + public required string[] Inputs { get; init; } + + /// + /// Gets the file extensions, including the leading dot, that identify input files. + /// Null or empty means every file found under counts. + /// + /// + /// Restricting by extension is what keeps a command's own outputs from invalidating it when they + /// land beside its inputs — .class files written next to the .java files they were + /// compiled from, for example. + /// + public string[]? FileExtensions { get; init; } + + /// + /// Gets the file, relative to the working directory unless absolute, written after the command + /// succeeds and compared against the inputs on the next launch. + /// + /// + /// Place this with the command's outputs so that deleting them — mvn clean, or removing the + /// class output directory — also invalidates the check. + /// + public required string StampFile { get; init; } } diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj b/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj index 808c03d8955..d374a509f9e 100644 --- a/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj @@ -4,13 +4,14 @@ $(AllTargetFrameworks) true $(ComponentAzurePackageTags) cosmos cosmosdb data database db nosql cloud - A client for Azure Cosmos DB that integrates with Aspire, including logging and telemetry. + A client for Azure Cosmos DB that integrates with Aspire, including health checks, logging, and telemetry. $(SharedDir)AzureCosmosDB_256x.png $(NoWarn);SYSLIB1100;SYSLIB1101 + @@ -22,6 +23,7 @@ + diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs b/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs index 455992fdae4..85a07656cb9 100644 --- a/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Microsoft.Extensions.Hosting; @@ -16,6 +17,7 @@ namespace Microsoft.Extensions.Hosting; public static class AspireMicrosoftAzureCosmosExtensions { private const string DefaultConfigSectionName = "Aspire:Microsoft:Azure:Cosmos"; + private const string HealthCheckName = "Microsoft.Azure.Cosmos"; /// /// Registers as a singleton in the services provided by the . @@ -36,6 +38,7 @@ public static void AddAzureCosmosClient( var settings = builder.GetSettings(connectionName, configureSettings); var clientOptions = builder.GetClientOptions(settings, configureClientOptions); builder.Services.AddSingleton(sp => GetCosmosClient(connectionName, settings, clientOptions)); + builder.AddCosmosHealthCheck(settings, serviceKey: null); } /// @@ -100,6 +103,7 @@ public static void AddKeyedAzureCosmosClient( var client = GetCosmosClient(name, settings, clientOptions); return client; }); + builder.AddCosmosHealthCheck(settings, serviceKey: name); } /// @@ -185,6 +189,24 @@ public static CosmosDatabaseBuilder AddKeyedAzureCosmosDatabase( return cosmosDatabaseBuilder; } + private static void AddCosmosHealthCheck(this IHostApplicationBuilder builder, MicrosoftAzureCosmosSettings settings, object? serviceKey) + { + if (settings.DisableHealthChecks) + { + return; + } + + builder.TryAddHealthCheck(new HealthCheckRegistration( + serviceKey is null ? HealthCheckName : $"{HealthCheckName}_{serviceKey}", + sp => new AzureCosmosDbHealthCheck( + serviceKey is null + ? sp.GetRequiredService() + : sp.GetRequiredKeyedService(serviceKey)), + failureStatus: default, + tags: default, + timeout: default)); + } + internal static CosmosConnectionInfo? GetCosmosConnectionInfo(this IHostApplicationBuilder builder, string connectionName) { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/AzureCosmosDbHealthCheck.cs b/src/Components/Aspire.Microsoft.Azure.Cosmos/AzureCosmosDbHealthCheck.cs new file mode 100644 index 00000000000..fd655c1cbdb --- /dev/null +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/AzureCosmosDbHealthCheck.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Microsoft.Azure.Cosmos; + +/// +/// A health check for Azure Cosmos DB that verifies the account is reachable by reading its +/// account properties. Modeled on the account-level probe in +/// AspNetCore.Diagnostics.HealthChecks' AzureCosmosDbHealthCheck. +/// +internal sealed class AzureCosmosDbHealthCheck : IHealthCheck +{ + private readonly CosmosClient _cosmosClient; + + public AzureCosmosDbHealthCheck(CosmosClient cosmosClient) + { + ArgumentNullException.ThrowIfNull(cosmosClient); + _cosmosClient = cosmosClient; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + // ReadAccountAsync() doesn't accept a CancellationToken, so honor cancellation via WaitAsync. + await _cosmosClient.ReadAccountAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); + } + } +} diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/ConfigurationSchema.json b/src/Components/Aspire.Microsoft.Azure.Cosmos/ConfigurationSchema.json index 98a8e0111aa..75a8096d77d 100644 --- a/src/Components/Aspire.Microsoft.Azure.Cosmos/ConfigurationSchema.json +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/ConfigurationSchema.json @@ -39,6 +39,11 @@ "type": "string", "description": "Gets or sets the name of the database to connect to." }, + "DisableHealthChecks": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the Azure Cosmos DB health check is disabled or not.", + "default": false + }, "DisableTracing": { "type": "boolean", "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not.", diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/MicrosoftAzureCosmosSettings.cs b/src/Components/Aspire.Microsoft.Azure.Cosmos/MicrosoftAzureCosmosSettings.cs index 796246399ee..f54803ec597 100644 --- a/src/Components/Aspire.Microsoft.Azure.Cosmos/MicrosoftAzureCosmosSettings.cs +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/MicrosoftAzureCosmosSettings.cs @@ -25,6 +25,14 @@ public sealed class MicrosoftAzureCosmosSettings /// public string? ContainerName { get; set; } + /// + /// Gets or sets a boolean value that indicates whether the Azure Cosmos DB health check is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableHealthChecks { get; set; } + /// /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. /// diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/README.md b/src/Components/Aspire.Microsoft.Azure.Cosmos/README.md index f881a2a5ca1..ab4c0322b77 100644 --- a/src/Components/Aspire.Microsoft.Azure.Cosmos/README.md +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/README.md @@ -1,6 +1,6 @@ # Aspire.Microsoft.Azure.Cosmos library -Registers [CosmosClient](https://learn.microsoft.com/dotnet/api/microsoft.azure.cosmos.cosmosclient) as a singleton in the DI container for connecting to Azure Cosmos DB. Enables corresponding logging and telemetry. +Registers [CosmosClient](https://learn.microsoft.com/dotnet/api/microsoft.azure.cosmos.cosmosclient) as a singleton in the DI container for connecting to Azure Cosmos DB. Enables corresponding health check, logging and telemetry. ## Getting started @@ -108,6 +108,32 @@ You can also setup the [CosmosClientOptions](https://learn.microsoft.com/dotnet/ builder.AddAzureCosmosClient("cosmosConnectionName", configureClientOptions: clientOptions => clientOptions.ApplicationName = "myapp"); ``` +## Health checks + +By default, the Aspire Microsoft Azure Cosmos DB integration registers a health check that verifies the Cosmos DB account is reachable. The health check is included in the application's `/health` endpoint, so dependents that `WaitFor` the service's HTTP health (or Kubernetes readiness probes) won't report healthy while Cosmos DB is unreachable. + +The health check can be disabled by setting `MicrosoftAzureCosmosSettings.DisableHealthChecks` to `true`, either through configuration: + +```json +{ + "Aspire": { + "Microsoft": { + "Azure": { + "Cosmos": { + "DisableHealthChecks": true + } + } + } + } +} +``` + +or in code: + +```csharp +builder.AddAzureCosmosClient("cosmosConnectionName", settings => settings.DisableHealthChecks = true); +``` + ## AppHost extensions In your AppHost project, install the Aspire Azure CosmosDB Hosting library with [NuGet](https://www.nuget.org): diff --git a/src/Components/Aspire.Seq/ConfigurationSchema.json b/src/Components/Aspire.Seq/ConfigurationSchema.json index 23425a2154f..b48f1a943eb 100644 --- a/src/Components/Aspire.Seq/ConfigurationSchema.json +++ b/src/Components/Aspire.Seq/ConfigurationSchema.json @@ -46,6 +46,13 @@ }, "description": "Gets or sets the BatchExportProcessor options. Ignored unless ExportProcessorType is Batch." }, + "Compression": { + "enum": [ + "None", + "GZip" + ], + "description": "Gets or sets the compression method to use when sending telemetry." + }, "Endpoint": { "type": "string", "format": "uri", @@ -105,6 +112,13 @@ }, "description": "Gets or sets the BatchExportProcessor options. Ignored unless ExportProcessorType is Batch." }, + "Compression": { + "enum": [ + "None", + "GZip" + ], + "description": "Gets or sets the compression method to use when sending telemetry." + }, "Endpoint": { "type": "string", "format": "uri", diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index 29f63980d66..3a0625728e2 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -6,7 +6,7 @@ These integrations should follow the [Aspire Client Integration Requirements](#a | --------------------------------------- | :---------------------------------: | :-----------------------: | :----------------------------------------------------: | :-------------------------: | :-----------------: | :-----------------: | :-----------------: | :-----------------------------: | | Npgsql | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Npgsql.EntityFrameworkCore.PostgreSQL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Microsoft.Azure.Cosmos | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Microsoft.Azure.Cosmos | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Microsoft.Data.SqlClient | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | | Microsoft.EntityFramework.Cosmos | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | Microsoft.EntityFrameworkCore.SqlServer | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | diff --git a/src/Shared/BundleDiscovery.cs b/src/Shared/BundleDiscovery.cs index 8670995d311..5f475a0de52 100644 --- a/src/Shared/BundleDiscovery.cs +++ b/src/Shared/BundleDiscovery.cs @@ -33,7 +33,7 @@ internal static class BundleDiscovery /// /// Environment variable for overriding the Dashboard path. - /// Still used by DcpOptions/DashboardEventHandlers — value now points to aspire-managed exe. + /// Used by DcpOptions and DashboardEventHandlers to locate the native Dashboard executable. /// public const string DashboardPathEnvVar = "ASPIRE_DASHBOARD_PATH"; @@ -88,9 +88,14 @@ internal static class BundleDiscovery /// public const string ManagedDirectoryName = "managed"; + /// + /// Directory name for the Dashboard executable and static assets in the bundle layout. + /// + public const string DashboardDirectoryName = "dashboard"; + /// /// Directory name for the single top-level reparse point that links to the - /// active versioned bundle directory. Components (managed/ and dcp/) + /// active versioned bundle directory. Components (managed/, dashboard/, and dcp/) /// are resolved as subdirectories of this link target. /// public const string BundleDirectoryName = "bundle"; @@ -104,6 +109,11 @@ internal static class BundleDiscovery /// public const string ManagedExecutableName = "aspire-managed"; + /// + /// Executable name for the Native AOT Dashboard. + /// + public const string DashboardExecutableName = "Aspire.Dashboard"; + // ═══════════════════════════════════════════════════════════════════════ // DISCOVERY METHODS // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/Shared/IConfigurationExtensions.cs b/src/Shared/IConfigurationExtensions.cs index 0d085511087..9af1cdda292 100644 --- a/src/Shared/IConfigurationExtensions.cs +++ b/src/Shared/IConfigurationExtensions.cs @@ -20,7 +20,7 @@ internal sealed class AspireExportIgnoreAttribute : Attribute [AspireExportIgnore(Reason = "Internal IConfiguration helper — use the dedicated ATS configuration exports instead.")] internal static class IConfigurationExtensions { -#if !CLI +#if !CLI && !ASPIRE_DASHBOARD public static T GetValue(this IConfiguration configuration, string primaryKey, string secondaryKey, T defaultValue) { var primaryValue = configuration.GetValue(typeof(T), primaryKey, null); diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 6fa02c4bc7d..8aa855a3b3c 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -10,6 +10,7 @@ internal static class KnownConfigNames public const string DashboardOtlpGrpcEndpointUrl = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"; public const string DashboardOtlpHttpEndpointUrl = "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"; public const string DashboardFrontendBrowserToken = "ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN"; + public const string DashboardSuppressBrowserTokenInOutput = "ASPIRE_DASHBOARD_SUPPRESS_BROWSER_TOKEN_IN_OUTPUT"; public const string DashboardResourceServiceClientApiKey = "ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY"; public const string DashboardUnsecuredAllowAnonymous = "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"; public const string DashboardCorsAllowedOrigins = "ASPIRE_DASHBOARD_CORS_ALLOWED_ORIGINS"; diff --git a/src/Shared/Otlp/Serialization/OtlpJsonSerializerContext.cs b/src/Shared/Otlp/Serialization/OtlpJsonSerializerContext.cs index 6fe46ed0144..0aeed58081b 100644 --- a/src/Shared/Otlp/Serialization/OtlpJsonSerializerContext.cs +++ b/src/Shared/Otlp/Serialization/OtlpJsonSerializerContext.cs @@ -106,4 +106,14 @@ internal sealed partial class OtlpJsonSerializerContext : JsonSerializerContext Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, TypeInfoResolver = Default }; + + /// + /// Gets source-generated metadata bound to . + /// + public static OtlpJsonSerializerContext DefaultContext { get; } = new(DefaultOptions); + + /// + /// Gets source-generated metadata bound to . + /// + public static OtlpJsonSerializerContext IndentedContext { get; } = new(IndentedOptions); } diff --git a/src/Shared/PackageUpdateHelpers.cs b/src/Shared/PackageUpdateHelpers.cs index d5335b94750..8c15c67360b 100644 --- a/src/Shared/PackageUpdateHelpers.cs +++ b/src/Shared/PackageUpdateHelpers.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using Semver; @@ -129,7 +130,7 @@ public static List ParsePackageSearchResults(string stdout, string { var foundPackages = new List(); - using var document = JsonDocument.Parse(stdout); + using var document = JsonDocument.Parse(ExtractJsonPayload(stdout, IsPackageSearchPayload)); if (!document.RootElement.TryGetProperty("searchResult", out var searchResultsArray)) { return []; @@ -162,4 +163,92 @@ public static List ParsePackageSearchResults(string stdout, string return foundPackages; } + + // `dotnet package search --format json` is expected to write a single JSON object to stdout, but NuGet + // credential providers use an inherited stdout handle. Their diagnostics can therefore appear before or after + // the payload while the command still exits 0, and can themselves contain braces or complete JSON objects: + // + // [CredentialProvider]Acquiring token for request {42} + // {"error":{"packages":[]}} + // {"version":2,"problems":[],"searchResult":[{"sourceName":"azure-default","packages":[ ... ]}]} + // [CredentialProvider]VstsCredentialProvider - Acquired bearer token using 'MSAL Silent' + // + // Parse each complete object candidate and validate the expected payload shape so a diagnostic object cannot be + // mistaken for the payload. Return only the consumed object so trailing provider output is excluded. + // See https://github.com/microsoft/aspire/issues/19339. + internal static string ExtractJsonPayload(string stdout, Func isExpectedPayload) + { + var utf8 = Encoding.UTF8.GetBytes(stdout); + var searchOffset = 0; + + while (searchOffset < utf8.Length) + { + var relativeCandidateOffset = utf8.AsSpan(searchOffset).IndexOf((byte)'{'); + if (relativeCandidateOffset < 0) + { + break; + } + + var candidateOffset = searchOffset + relativeCandidateOffset; + var candidate = utf8.AsSpan(candidateOffset); + var reader = new Utf8JsonReader(candidate); + + try + { + if (JsonDocument.TryParseValue(ref reader, out var document) && document is not null) + { + var consumed = checked((int)reader.BytesConsumed); + using (document) + { + if (isExpectedPayload(document.RootElement)) + { + return Encoding.UTF8.GetString(candidate[..consumed]); + } + } + + // Do not inspect nested objects inside a complete diagnostic object. A nested object could + // coincidentally have the expected shape even though its containing diagnostic is not the payload. + searchOffset = candidateOffset + consumed; + continue; + } + } + catch (JsonException) + { + // A diagnostic can contain an unmatched brace or other non-JSON fragment. Advance past this brace + // and keep looking for the package-search payload. + } + + searchOffset = candidateOffset + 1; + } + + // Preserve the existing behavior when no expected payload is present: callers parse the original output + // and surface the same JsonException (or handle a valid object with no results) as before. + return stdout; + } + + private static bool IsPackageSearchPayload(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("version", out var version) || + version.ValueKind != JsonValueKind.Number || + !root.TryGetProperty("searchResult", out var searchResults) || + searchResults.ValueKind != JsonValueKind.Array) + { + return false; + } + + foreach (var sourceResult in searchResults.EnumerateArray()) + { + if (sourceResult.ValueKind != JsonValueKind.Object || + !sourceResult.TryGetProperty("sourceName", out var sourceName) || + sourceName.ValueKind != JsonValueKind.String || + !sourceResult.TryGetProperty("packages", out var packages) || + packages.ValueKind != JsonValueKind.Array) + { + return false; + } + } + + return true; + } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index 2d9ed07054a..d46ef25fd88 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -173,6 +173,7 @@ public async Task AgentInitCommand_DefaultSelection_InstallsDefaultSkills() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); var strategy = CliInstallStrategy.Detect(output.WriteLine); + RequireCurrentAspireSkillsBundle(strategy); var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); @@ -190,10 +191,8 @@ public async Task AgentInitCommand_DefaultSelection_InstallsDefaultSkills() // Create .vscode folder so the scanner detects VS Code environment Directory.CreateDirectory(vscodePath); - await SeedAspireSkillsBundleCacheAsync(auto, workspace, counter); - // Run aspire agent init and accept the default location and skills. The cache - // fixture above keeps this independent from the unpublished npm package. + // Run aspire agent init and accept the default location and skills. await auto.TypeAsync("aspire agent init"); await auto.EnterAsync(); await auto.WaitUntilTextAsync("workspace:", timeout: TimeSpan.FromSeconds(30)); @@ -207,7 +206,7 @@ await auto.WaitUntilAsync( s => s.ContainsText("skills should be installed"), timeout: TimeSpan.FromSeconds(30), description: "skill selection prompt"); // Playwright and dotnet-inspect are not pre-selected, so just accept - // the default Aspire skills from the seeded bundle. + // the default Aspire skills from the installed CLI's embedded bundle. await auto.EnterAsync(); await auto.WaitUntilTextAsync("configuration complete", timeout: TimeSpan.FromSeconds(30)); await auto.WaitForSuccessPromptAsync(counter); @@ -215,10 +214,10 @@ await auto.WaitUntilAsync( // Verify skill files were created (skills are now installed at .agents/skills/ by StandardLocationAgentEnvironmentScanner) var skillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md"); var fileContent = File.ReadAllText(skillFilePath); - Assert.Contains("aspire start", fileContent); + Assert.Contains("name: aspire", fileContent); var deploymentSkillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire-deployment", "SKILL.md"); var deploymentFileContent = File.ReadAllText(deploymentSkillFilePath); - Assert.Contains("Aspire Deployment", deploymentFileContent); + Assert.Contains("name: aspire-deployment", deploymentFileContent); } /// @@ -234,24 +233,19 @@ public async Task AgentInit_NonInteractive_BundleOnlySkillsNotInCatalog() { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); var strategy = CliInstallStrategy.Detect(output.WriteLine); + RequireCurrentAspireSkillsBundle(strategy); var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); - var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); - var counter = new SequenceCounter(); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); - // Seed the user-level cache with the six bundle skills so the CLI exercises the cached - // path without needing the unpublished npm package. The fixture mirrors the published - // bundle's manifest shape. - await SeedAspireSkillsBundleCacheAsync(auto, workspace, counter); - // The names below are the ones the original bug hid from the CLI. Naming them explicitly // (rather than `--skills all`) avoids pulling in playwright/dotnet-inspect, which would // attempt real npm registry calls inside the container, and keeps the assertion narrowly @@ -270,166 +264,15 @@ public async Task AgentInit_NonInteractive_BundleOnlySkillsNotInCatalog() { var skillFile = Path.Combine(skillsRoot, skillName, "SKILL.md"); Assert.True(File.Exists(skillFile), $"Expected {skillName} SKILL.md at {skillFile}"); + Assert.Contains($"name: {skillName}", File.ReadAllText(skillFile)); } - - await auto.TypeAsync("exit"); - await auto.EnterAsync(); - - await pendingRun; } - private static async Task SeedAspireSkillsBundleCacheAsync(Hex1bTerminalAutomator auto, TemporaryWorkspace workspace, SequenceCounter counter) + private static void RequireCurrentAspireSkillsBundle(CliInstallStrategy strategy) { - const string aspireSkillsVersion = "0.0.1"; - var scriptPath = Path.Combine(workspace.WorkspaceRoot.FullName, "seed-aspire-skills-cache.sh"); - var script = - $$""" - #!/usr/bin/env bash - set -euo pipefail - - cache="$HOME/.aspire/cache/aspire-skills/{{aspireSkillsVersion}}" - rm -rf "$cache" - mkdir -p \ - "$cache/skills/aspire/references" \ - "$cache/skills/aspire/evals" \ - "$cache/skills/aspireify" \ - "$cache/skills/aspire-deployment/references" \ - "$cache/skills/aspire-init" \ - "$cache/skills/aspire-monitoring" \ - "$cache/skills/aspire-orchestration" - - cat > "$cache/skills/aspire/SKILL.md" <<'SKILL' - --- - name: aspire - description: "Aspire CLI commands and workflows for distributed apps" - --- - - # Aspire Skill - - Use `aspire start` to start an Aspire app. - SKILL - printf '%s\n' '# App commands' > "$cache/skills/aspire/references/app-commands.md" - printf '%s\n' '{}' > "$cache/skills/aspire/evals/evals.json" - - cat > "$cache/skills/aspireify/SKILL.md" <<'SKILL' - --- - name: aspireify - description: "One-time setup: wire up AppHost with discovered projects" - --- - - # Aspireify - SKILL - - cat > "$cache/skills/aspire-deployment/SKILL.md" <<'SKILL' - --- - name: aspire-deployment - description: "Aspire deployment target selection, preflight, publish, and deploy workflows" - --- - - # Aspire Deployment - SKILL - printf '%s\n' '# Preflight' > "$cache/skills/aspire-deployment/references/preflight.md" - - cat > "$cache/skills/aspire-init/SKILL.md" <<'SKILL' - --- - name: aspire-init - description: "First-run flow for adding Aspire to a repo" - --- - - # Aspire Init - SKILL - - cat > "$cache/skills/aspire-monitoring/SKILL.md" <<'SKILL' - --- - name: aspire-monitoring - description: "Observe Aspire apps with logs, traces, metrics, and resource state" - --- - - # Aspire Monitoring - SKILL - - cat > "$cache/skills/aspire-orchestration/SKILL.md" <<'SKILL' - --- - name: aspire-orchestration - description: "Manage Aspire AppHost lifecycle and resource commands" - --- - - # Aspire Orchestration - SKILL - - aspire_skill_hash="$(sha512sum "$cache/skills/aspire/SKILL.md" | awk '{print $1}')" - aspire_commands_hash="$(sha512sum "$cache/skills/aspire/references/app-commands.md" | awk '{print $1}')" - aspire_evals_hash="$(sha512sum "$cache/skills/aspire/evals/evals.json" | awk '{print $1}')" - aspireify_skill_hash="$(sha512sum "$cache/skills/aspireify/SKILL.md" | awk '{print $1}')" - deployment_skill_hash="$(sha512sum "$cache/skills/aspire-deployment/SKILL.md" | awk '{print $1}')" - deployment_preflight_hash="$(sha512sum "$cache/skills/aspire-deployment/references/preflight.md" | awk '{print $1}')" - init_skill_hash="$(sha512sum "$cache/skills/aspire-init/SKILL.md" | awk '{print $1}')" - monitoring_skill_hash="$(sha512sum "$cache/skills/aspire-monitoring/SKILL.md" | awk '{print $1}')" - orchestration_skill_hash="$(sha512sum "$cache/skills/aspire-orchestration/SKILL.md" | awk '{print $1}')" - - cat > "$cache/skill-manifest.json" <=0.0.0 <999.0.0", - "aspireSdk": ">=0.0.0 <999.0.0" - }, - "skills": [ - { - "name": "aspire", - "description": "Aspire CLI commands and workflows for distributed apps", - "installExcludedRelativePaths": ["evals"], - "files": [ - { "relativePath": "SKILL.md", "sha512": "$aspire_skill_hash" }, - { "relativePath": "references/app-commands.md", "sha512": "$aspire_commands_hash" }, - { "relativePath": "evals/evals.json", "sha512": "$aspire_evals_hash" } - ] - }, - { - "name": "aspireify", - "description": "One-time setup: wire up AppHost with discovered projects", - "files": [ - { "relativePath": "SKILL.md", "sha512": "$aspireify_skill_hash" } - ] - }, - { - "name": "aspire-deployment", - "description": "Aspire deployment target selection, preflight, publish, and deploy workflows", - "files": [ - { "relativePath": "SKILL.md", "sha512": "$deployment_skill_hash" }, - { "relativePath": "references/preflight.md", "sha512": "$deployment_preflight_hash" } - ] - }, - { - "name": "aspire-init", - "description": "First-run flow for adding Aspire to a repo", - "files": [ - { "relativePath": "SKILL.md", "sha512": "$init_skill_hash" } - ] - }, - { - "name": "aspire-monitoring", - "description": "Observe Aspire apps with logs, traces, metrics, and resource state", - "files": [ - { "relativePath": "SKILL.md", "sha512": "$monitoring_skill_hash" } - ] - }, - { - "name": "aspire-orchestration", - "description": "Manage Aspire AppHost lifecycle and resource commands", - "files": [ - { "relativePath": "SKILL.md", "sha512": "$orchestration_skill_hash" } - ] - } - ] - } - JSON - """; - - await File.WriteAllTextAsync(scriptPath, script.ReplaceLineEndings("\n")); - - var containerScriptPath = CliE2ETestHelpers.ToContainerPath(scriptPath, workspace); - await auto.RunCommandAsync($"bash {AspireCliShellCommandHelpers.QuoteBashArg(containerScriptPath)}", counter, TimeSpan.FromSeconds(30)); - await auto.RunCommandAsync($"export aspireSkillsVersion={AspireCliShellCommandHelpers.QuoteBashArg(aspireSkillsVersion)}", counter, TimeSpan.FromSeconds(30)); + Assert.SkipWhen( + strategy.Mode == CliInstallMode.InstallScript || + (strategy.Mode == CliInstallMode.DotnetTool && strategy.NupkgSourcePath is null), + "This test validates the current Aspire CLI's embedded skills bundle. Use a local or PR CLI build instead of a released CLI."); } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs index f59f0a530f1..5e06e9d0fb8 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigDiscoveryTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text; using System.Text.Json; using Aspire.Cli.EndToEnd.Tests.Helpers; using Hex1b.Automation; @@ -21,6 +22,110 @@ namespace Aspire.Cli.EndToEnd.Tests; /// public sealed class ConfigDiscoveryTests(ITestOutputHelper output) { + /// + /// Verifies that an explicit alternate AppHost can run without replacing the + /// workspace default or recreating a local config beside the alternate AppHost. + /// + [CaptureWorkspaceOnFailure] + [Fact] + public async Task RunWithExplicitAlternateAppHost_PreservesExistingDefault() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal( + repoRoot, strategy, output, + mountDockerSocket: true, + workspace: workspace); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + await auto.AspireNewCSharpEmptyAppHostAsync("Primary", counter); + await auto.AspireNewCSharpEmptyAppHostAsync("Alternate", counter); + + var workspaceRoot = workspace.WorkspaceRoot.FullName; + var primaryConfigPath = Path.Combine(workspaceRoot, "Primary", "aspire.config.json"); + var alternateConfigPath = Path.Combine(workspaceRoot, "Alternate", "aspire.config.json"); + var alternateAppHostPath = Path.Combine(workspaceRoot, "Alternate", "apphost.cs"); + var alternateRunMarkerPath = Path.Combine(workspaceRoot, "Alternate", "alternate-run-marker.txt"); + var workspaceConfigPath = Path.Combine(workspaceRoot, "aspire.config.json"); + + Assert.True(File.Exists(primaryConfigPath), $"Expected Primary config at: {primaryConfigPath}"); + Assert.True(File.Exists(alternateConfigPath), $"Expected Alternate config at: {alternateConfigPath}"); + Assert.True(File.Exists(alternateAppHostPath), $"Expected Alternate AppHost source file to exist at: {alternateAppHostPath}"); + + // `aspire new` scaffolds a config next to each AppHost. Deleting both leaves the workspace-root + // config written below as the only configured default, which is what this test protects. + File.Delete(primaryConfigPath); + File.Delete(alternateConfigPath); + + // Patch only the alternate AppHost so the marker proves `--apphost Alternate/apphost.cs` actually + // launched the override. Without it, a silent fallback to the configured Primary AppHost would + // leave the config untouched and pass the persistence assertion vacuously. + var alternateAppHostSource = File.ReadAllText(alternateAppHostPath); + Assert.Contains("var builder = DistributedApplication.CreateBuilder(args);", alternateAppHostSource); + File.WriteAllText( + alternateAppHostPath, + alternateAppHostSource.Replace( + "var builder = DistributedApplication.CreateBuilder(args);", + $$""" + System.IO.File.WriteAllText({{JsonSerializer.Serialize(CliE2ETestHelpers.ToContainerPath(alternateRunMarkerPath, workspace))}}, "alternate"); + + var builder = DistributedApplication.CreateBuilder(args); + """)); + + // Compared byte-for-byte after the run, so an explicit `--apphost` must not rewrite, reformat, or + // re-point this file. Written with '\n' so the comparison is stable on Windows checkouts too. + const string expectedWorkspaceConfigContent = + "{\n" + + " \"appHost\": {\n" + + " \"path\": \"Primary/apphost.cs\",\n" + + " \"language\": \"csharp\"\n" + + " }\n" + + "}"; + var originalWorkspaceConfigBytes = Encoding.UTF8.GetBytes(expectedWorkspaceConfigContent); + File.WriteAllBytes(workspaceConfigPath, originalWorkspaceConfigBytes); + + await auto.TypeAsync(CliE2EAutomatorHelpers.GetAspireRunCommand("--apphost Alternate/apphost.cs")); + await auto.EnterAsync(); + + await auto.WaitUntilAsync( + s => + { + // An explicit `--apphost` must resolve without asking. Failing fast here reports the actual + // defect instead of stalling for the whole startup budget on a prompt nothing will answer. + if (s.ContainsText("Select an AppHost to use:")) + { + throw new InvalidOperationException( + "aspire run prompted for an AppHost even though --apphost Alternate/apphost.cs was supplied."); + } + + if (s.ContainsText("ERR:")) + { + throw new InvalidOperationException( + "aspire run failed before the explicit alternate AppHost became ready."); + } + + return s.ContainsText("Press CTRL+C to stop the AppHost and exit."); + }, + timeout: CliE2EAutomatorHelpers.AspireRunReadyTimeout, + description: "Press CTRL+C message from aspire run for the explicit alternate AppHost"); + + await auto.Ctrl().KeyAsync(Hex1bKey.C); + await auto.WaitForSuccessPromptAsync(counter); + + Assert.True(File.Exists(alternateRunMarkerPath), $"Expected the Alternate AppHost to run and write: {alternateRunMarkerPath}"); + Assert.Equal("alternate", File.ReadAllText(alternateRunMarkerPath)); + Assert.Equal(originalWorkspaceConfigBytes, File.ReadAllBytes(workspaceConfigPath)); + Assert.False(File.Exists(alternateConfigPath), $"Explicit alternate AppHost should not create a local config at: {alternateConfigPath}"); + } + /// /// Verifies that running aspire run from a parent directory discovers the /// existing aspire.config.json next to the apphost rather than creating a diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index c6c6b22d50c..3c75984098d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -43,9 +43,12 @@ internal static class CliE2EAutomatorHelpers /// Builds the shell command that launches aspire run with an explicit AppHost startup budget so cold /// daily-feed restores don't trip the CLI's default 120s startup timeout. See . /// - internal static string GetAspireRunCommand() + /// Extra arguments appended after aspire run, for example --apphost <path>. + internal static string GetAspireRunCommand(string? additionalArguments = null) { - return $"ASPIRE_CLI_START_TIMEOUT={AspireRunStartupBudgetSeconds.ToString(CultureInfo.InvariantCulture)} aspire run"; + var command = $"ASPIRE_CLI_START_TIMEOUT={AspireRunStartupBudgetSeconds.ToString(CultureInfo.InvariantCulture)} aspire run"; + + return string.IsNullOrEmpty(additionalArguments) ? command : $"{command} {additionalArguments}"; } /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs index 1d75f2efaaa..c3f1bcea601 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs @@ -44,11 +44,6 @@ public async Task RestoreGeneratesSdkFiles() await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("aspire add Aspire.Hosting.SqlServer"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("aspire restore"); await auto.EnterAsync(); await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3)); @@ -60,18 +55,45 @@ public async Task RestoreGeneratesSdkFiles() throw new InvalidOperationException($".aspire/modules directory was not created at {modulesDir}"); } + // The second integration is added only after `.aspire/modules` is already populated. A + // restore that treats a non-empty modules directory as up to date would leave the stale + // SDK in place and still report success, so the SqlServer assertions below are what + // actually prove the refresh. Adding both integrations before the first restore would + // generate everything in one pass and never exercise that path. + var codegenHashPath = Path.Combine(modulesDir, ".codegen-hash"); + var initialCodegenHash = File.Exists(codegenHashPath) ? File.ReadAllText(codegenHashPath) : null; + + var builderPath = Path.Combine(modulesDir, "aspire", "IDistributedApplicationBuilder.java"); + if (File.Exists(builderPath) && File.ReadAllText(builderPath).Contains("addSqlServer")) + { + throw new InvalidOperationException("Baseline SDK already exposes addSqlServer; test cannot verify refresh behavior after adding Aspire.Hosting.SqlServer."); + } + + await auto.TypeAsync("aspire add Aspire.Hosting.SqlServer"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); + await auto.WaitForSuccessPromptAsync(counter); + + await auto.TypeAsync("aspire restore"); + await auto.EnterAsync(); + await auto.WaitUntilTextAsync("SDK code restored successfully", timeout: TimeSpan.FromMinutes(3)); + await auto.WaitForSuccessPromptAsync(counter); + + // Generated sources are laid out by package: every file declares `package aspire;`, so javac + // expects them under a matching `aspire/` directory. Only sources.txt sits at the modules root, + // because the compiler is driven as `javac @.aspire/modules/sources.txt` from the AppHost directory. var expectedFiles = new[] { - "Aspire.java", - "AspireClient.java", - "DistributedApplication.java", - "IDistributedApplicationBuilder.java", + "aspire/Aspire.java", + "aspire/AspireClient.java", + "aspire/DistributedApplication.java", + "aspire/IDistributedApplicationBuilder.java", "sources.txt" }; foreach (var file in expectedFiles) { - var filePath = Path.Combine(modulesDir, file); + var filePath = Path.Combine(modulesDir, Path.Combine(file.Split('/'))); if (!File.Exists(filePath)) { throw new InvalidOperationException($"Expected generated file not found: {filePath}"); @@ -84,14 +106,20 @@ public async Task RestoreGeneratesSdkFiles() } } - var builderJava = File.ReadAllText(Path.Combine(modulesDir, "IDistributedApplicationBuilder.java")); + var builderJava = File.ReadAllText(Path.Combine(modulesDir, "aspire", "IDistributedApplicationBuilder.java")); if (!builderJava.Contains("addRedis")) { throw new InvalidOperationException("IDistributedApplicationBuilder.java does not contain addRedis from Aspire.Hosting.Redis"); } if (!builderJava.Contains("addSqlServer")) { - throw new InvalidOperationException("IDistributedApplicationBuilder.java does not contain addSqlServer from Aspire.Hosting.SqlServer"); + throw new InvalidOperationException("IDistributedApplicationBuilder.java does not contain addSqlServer from Aspire.Hosting.SqlServer; restore did not refresh an already-populated .aspire/modules"); + } + + var restoredCodegenHash = File.Exists(codegenHashPath) ? File.ReadAllText(codegenHashPath) : null; + if (initialCodegenHash == restoredCodegenHash) + { + throw new InvalidOperationException(".aspire/modules/.codegen-hash did not change after adding Aspire.Hosting.SqlServer and running aspire restore."); } } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs index dae0162680d..66f912ef21f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.ExceptionServices; +using System.Text.Json; using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Resources; using Aspire.TestUtilities; @@ -90,6 +92,129 @@ public async Task StopWithNoRunningAppHostExitsSuccessfully() await auto.WaitForSuccessPromptAsync(counter); } + [Fact] + [CaptureWorkspaceOnFailure] + public async Task LinkedWorktreeExplicitIsolationAndPrimaryStopLeavesItRunning() + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var projectSuffix = Guid.NewGuid().ToString("N")[..6]; + var projectName = $"WorktreeIsolation_{projectSuffix}"; + var linkedWorktreeName = $"{projectName} linked"; + var linkedWorktreeRelativePath = $".worktrees/{linkedWorktreeName}"; + + var workspace = TemporaryWorkspace.Create(output); + var containerWorkspace = $"/workspace/{workspace.WorkspaceRoot.Name}"; + var primaryCheckout = $"{containerWorkspace}/{projectName}"; + var linkedCheckout = $"{primaryCheckout}/{linkedWorktreeRelativePath}"; + + var bothInstancesPath = Path.Combine(workspace.WorkspaceRoot.FullName, "both-instances.json"); + var remainingInstancePath = Path.Combine(workspace.WorkspaceRoot.FullName, "remaining-instance.json"); + var stoppedInstancesPath = Path.Combine(workspace.WorkspaceRoot.FullName, "stopped-instances.json"); + var bothInstancesContainerPath = CliE2ETestHelpers.ToContainerPath(bothInstancesPath, workspace); + var remainingInstanceContainerPath = CliE2ETestHelpers.ToContainerPath(remainingInstancePath, workspace); + var stoppedInstancesContainerPath = CliE2ETestHelpers.ToContainerPath(stoppedInstancesPath, workspace); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken); + + await auto.PrepareDockerEnvironmentAsync(counter, workspace, enableDcpDiagnostics: true); + await auto.InstallAspireCliAsync(strategy, counter); + await auto.AspireNewCSharpEmptyAppHostAsync(projectName, counter); + + // Create both checkouts through git inside the container. A host-created worktree would + // write absolute .git admin paths in the host namespace, which are invalid in this mount. + await auto.RunCommandAsync($"cd {AspireCliShellCommandHelpers.QuoteBashArg(projectName)}", counter); + await auto.RunCommandAsync( + "git init -b main && " + + "git config user.name 'Aspire CLI E2E' && " + + "git config user.email 'aspire-cli-e2e@example.invalid' && " + + "git add . && " + + "git commit -m 'Initial AppHost'", + counter); + + Exception? primaryFailure = null; + try + { + // Direct CLI launches intentionally remain explicit-only. Isolate the linked checkout + // while this single container keeps ~/.aspire/backchannels shared for structured discovery. + await auto.AspireStartAsync(counter); + await auto.RunCommandAsync( + $"git worktree add {AspireCliShellCommandHelpers.QuoteBashArg(linkedWorktreeRelativePath)} -b linked-worktree", + counter); + await auto.RunCommandAsync($"cd {AspireCliShellCommandHelpers.QuoteBashArg(linkedCheckout)}", counter); + await auto.AspireStartAsync(counter, isolated: true); + + await auto.RunCommandAsync( + $"aspire ps --format json > {AspireCliShellCommandHelpers.QuoteBashArg(bothInstancesContainerPath)}", + counter, + TimeSpan.FromMinutes(1)); + + var bothInstances = ReadRunningAppHosts(bothInstancesPath); + Assert.Equal(2, bothInstances.Count); + + var primaryInstance = Assert.Single(bothInstances, instance => + string.Equals(Path.GetDirectoryName(instance.AppHostPath), primaryCheckout, StringComparison.Ordinal)); + var linkedInstance = Assert.Single(bothInstances, instance => + string.Equals(Path.GetDirectoryName(instance.AppHostPath), linkedCheckout, StringComparison.Ordinal)); + + Assert.NotEqual(primaryInstance.AppHostPid, linkedInstance.AppHostPid); + Assert.NotEqual(primaryInstance.DashboardUri.Port, linkedInstance.DashboardUri.Port); + + // The linked checkout is nested beneath the primary workspace, so path containment + // alone would stop both. Worktree-aware scoping must select only the primary instance. + await auto.RunCommandAsync($"cd {AspireCliShellCommandHelpers.QuoteBashArg(primaryCheckout)}", counter); + await auto.AspireStopAsync(counter); + await auto.RunCommandAsync( + $"aspire ps --format json > {AspireCliShellCommandHelpers.QuoteBashArg(remainingInstanceContainerPath)}", + counter, + TimeSpan.FromMinutes(1)); + + var remainingInstance = Assert.Single(ReadRunningAppHosts(remainingInstancePath)); + Assert.Equal(linkedInstance.AppHostPath, remainingInstance.AppHostPath); + Assert.Equal(linkedInstance.AppHostPid, remainingInstance.AppHostPid); + Assert.Equal(linkedInstance.DashboardUri, remainingInstance.DashboardUri); + + await auto.RunCommandAsync($"cd {AspireCliShellCommandHelpers.QuoteBashArg(linkedCheckout)}", counter); + await auto.AspireStopAsync(counter); + await auto.RunCommandAsync( + $"aspire ps --format json > {AspireCliShellCommandHelpers.QuoteBashArg(stoppedInstancesContainerPath)}", + counter, + TimeSpan.FromMinutes(1)); + + Assert.Empty(ReadRunningAppHosts(stoppedInstancesPath)); + } + catch (Exception exception) + { + primaryFailure = exception; + } + + try + { + await CleanupLinkedWorktreeAppHostsAsync(auto, counter, projectName); + } + catch (Exception cleanupFailure) + { + if (primaryFailure is null) + { + throw; + } + + // Keep the proof assertion as the test failure while making a teardown problem visible + // in both xUnit output and the exception data captured in diagnostics. + output.WriteLine($"Linked-worktree cleanup also failed: {cleanupFailure}"); + primaryFailure.Data["LinkedWorktreeCleanupFailure"] = cleanupFailure.ToString(); + } + + if (primaryFailure is not null) + { + ExceptionDispatchInfo.Capture(primaryFailure).Throw(); + } + } + [Fact] [CaptureWorkspaceOnFailure] public async Task StartLaunchesDetachedCliAndAppHostOutsideLauncherProcessGroupAndSession() @@ -293,4 +418,91 @@ await auto.WaitUntilAsync(snapshot => await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromMinutes(1)); } + + private static async Task CleanupLinkedWorktreeAppHostsAsync( + Hex1bTerminalAutomator auto, + SequenceCounter counter, + string projectName) + { + // A detached CLI can still be finishing startup when an earlier proof assertion fails. + // Retry the supported stop path, then independently prove that the CLI registry and Docker + // network no longer contain anything from this AppHost before allowing teardown to pass. + await auto.RunCommandAsync( + "stopped=0; " + + "for attempt in 1 2 3; do " + + "if aspire stop --all; then stopped=1; break; " + + "else echo \"aspire stop --all attempt $attempt failed\" >&2; sleep 2; fi; " + + "done; " + + "[ \"$stopped\" -eq 1 ]", + counter, + TimeSpan.FromMinutes(3)); + await auto.RunCommandAsync( + "stopped=0; " + + "for attempt in $(seq 1 24); do " + + "if aspire ps --format json | tr -d '[:space:]' | grep -qx '\\[\\]'; then stopped=1; break; fi; " + + "sleep 5; " + + "done; " + + "if [ \"$stopped\" -eq 1 ]; then true; else aspire ps --format json; false; fi", + counter, + TimeSpan.FromMinutes(3)); + + // Docker network deletion is asynchronous after the last DCP process exits. Require five + // consecutive empty samples so a briefly empty list cannot let teardown race a late network + // creation. Capture `docker network ls` before testing its output so command failure cannot + // be mistaken for an empty list. First force one short, subshell-scoped retry and then run + // another command, proving the persistent Hex1b shell survived the nonzero sample. + await auto.ExecuteCommandUntilOutputAsync( + counter, + "r=$((r+1));([ $r -gt 1 ]||exit 1;echo retry-sampled)", + "retry-sampled", + timeout: TimeSpan.FromSeconds(30), + retryInterval: TimeSpan.FromSeconds(1)); + await auto.RunCommandAsync( + "[ \"$r\" -ge 2 ]&&echo shell-usable-after-retry&&unset r", + counter); + + // Keep the stabilization command compact because the retry helper locates output relative + // to the command row, which a wrapped Hex1b command obscures. The subshell scopes every + // nonzero exit to this sample rather than terminating the persistent interactive shell. + var quotedProjectName = AspireCliShellCommandHelpers.QuoteBashArg(projectName); + await auto.ExecuteCommandUntilOutputAsync( + counter, + "(for i in {1..5};do " + + $"n=$(docker network ls -q -f name={quotedProjectName})||exit 2;" + + "[ -z \"$n\" ]||exit 1;sleep 1;done;echo stable-empty)", + "stable-empty", + timeout: TimeSpan.FromMinutes(5), + retryInterval: TimeSpan.FromSeconds(8)); + } + + private static IReadOnlyList ReadRunningAppHosts(string path) + { + using var document = JsonDocument.Parse(File.ReadAllText(path)); + Assert.Equal(JsonValueKind.Array, document.RootElement.ValueKind); + + var instances = new List(); + foreach (var element in document.RootElement.EnumerateArray()) + { + // `aspire ps --format json` emits entries shaped like: + // { "appHostPath": "/workspace/app/app.csproj", "appHostPid": 123, + // "status": "running", "dashboardUrl": "https://localhost:17234/login?t=..." } + var appHostPath = element.GetProperty("appHostPath").GetString(); + var appHostPid = element.GetProperty("appHostPid").GetInt32(); + var status = element.GetProperty("status").GetString(); + var dashboardUrl = element.GetProperty("dashboardUrl").GetString(); + + Assert.False(string.IsNullOrWhiteSpace(appHostPath)); + Assert.True(appHostPid > 0); + Assert.Equal("running", status); + Assert.True( + Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri) && dashboardUri.Port > 0, + $"Expected a dashboard URL with an explicit port, got '{dashboardUrl}'."); + + instances.Add(new RunningAppHostInfo(appHostPath, appHostPid, dashboardUri!)); + } + + return instances; + } + + private sealed record RunningAppHostInfo(string AppHostPath, int AppHostPid, Uri DashboardUri); } diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsBundleTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsBundleTests.cs index 74de8df5c7d..9259db294c9 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsBundleTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsBundleTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Compression; using System.Security.Cryptography; using System.Text.Json; using Aspire.Cli.Agents; @@ -12,6 +13,9 @@ public class AspireSkillsBundleTests { private const string AspireSkillDescription = "Aspire CLI commands and workflows for distributed apps"; private const string AspireifySkillDescription = "One-time setup: wire up AppHost with discovered projects"; + private const string TestSha512 = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + + private static readonly AspireSkillsBundleProvider s_bundleProvider = new(); private static SkillDefinition AspireSkillDefinition => SkillDefinition.CreateAspireSkillsBundle( CommonAgentApplicators.AspireSkillName, @@ -36,9 +40,8 @@ public async Task LoadAsync_ValidatesManifestAndReturnsInstallableFiles() ["evals/evals.json"] = "{}" }); - var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); + var bundle = await LoadBundleAsync(s_bundleProvider, bundleDirectory); var files = await bundle.GetSkillFilesAsync(AspireSkillDefinition, CancellationToken.None); - Assert.Equal(AspireSkillsInstaller.Version, bundle.Version); Assert.Contains(files, file => file.RelativePath == "SKILL.md"); Assert.Contains(files, file => file.RelativePath == Path.Combine("references", "app-commands.md")); @@ -50,6 +53,38 @@ public async Task LoadAsync_ValidatesManifestAndReturnsInstallableFiles() } } + [Fact] + public async Task LoadAsync_RetainsInstallableFilesAfterSourceDirectoryIsDeleted() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync(bundleDirectory, new Dictionary + { + ["SKILL.md"] = CreateSkillFileContent(), + ["references/app-commands.md"] = "# App commands" + }); + + var bundle = await LoadBundleAsync(s_bundleProvider, bundleDirectory); + Directory.Delete(bundleDirectory, recursive: true); + + var files = await bundle.GetSkillFilesAsync(AspireSkillDefinition, CancellationToken.None); + + Assert.Collection( + files, + skillFile => Assert.Equal(CreateSkillFileContent(), skillFile.Content), + referenceFile => Assert.Equal("# App commands", referenceFile.Content)); + } + finally + { + if (Directory.Exists(bundleDirectory)) + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + } + [Fact] public async Task GetSkillDefinitions_ReturnsManifestSkills() { @@ -63,7 +98,7 @@ public async Task GetSkillDefinitions_ReturnsManifestSkills() ["references/app-commands.md"] = "# App commands" }); - var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); + var bundle = await LoadBundleAsync(s_bundleProvider, bundleDirectory); var skill = Assert.Single(bundle.GetSkillDefinitions()); Assert.Equal(CommonAgentApplicators.AspireSkillName, skill.Name); @@ -89,9 +124,9 @@ public async Task LoadAsync_ThrowsWhenHashDoesNotMatch() await CreateBundleAsync(bundleDirectory, new Dictionary { ["SKILL.md"] = CreateSkillFileContent() - }, hashOverride: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"); + }, hashOverride: TestSha512); - var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None)); + var exception = await Assert.ThrowsAsync(() => LoadBundleAsync(s_bundleProvider, bundleDirectory)); Assert.Contains("failed SHA-512 verification", exception.Message); } @@ -104,10 +139,6 @@ public async Task LoadAsync_ThrowsWhenHashDoesNotMatch() [Fact] public async Task LoadAsync_ValidatesLegacySha256PerFileHashes() { - // The attestation-verified v0.0.1 bundle embedded in the CLI predates the SHA-512 switch and carries - // SHA-256 per-file hashes. Its exact bytes can't be re-hashed without invalidating the published - // attestation, so ValidateFile must keep accepting SHA-256 for such bundles until a SHA-512 release - // is re-embedded. var bundleDirectory = CreateTempDirectory(); var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); Directory.CreateDirectory(skillDirectory); @@ -116,7 +147,7 @@ public async Task LoadAsync_ValidatesLegacySha256PerFileHashes() try { - var manifest = new SkillBundleManifest + await WriteManifestAsync(bundleDirectory, new SkillBundleManifest { Version = AspireSkillsInstaller.Version, Supports = CreateSupports(), @@ -136,12 +167,11 @@ public async Task LoadAsync_ValidatesLegacySha256PerFileHashes() ] } ] - }; - - await WriteManifestAsync(bundleDirectory, manifest); + }); - var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); + var bundle = await LoadBundleAsync(s_bundleProvider, bundleDirectory); var skill = Assert.Single(bundle.GetSkillDefinitions()); + Assert.Equal(CommonAgentApplicators.AspireSkillName, skill.Name); } finally @@ -160,7 +190,7 @@ public async Task LoadAsync_ThrowsWhenNoPerFileHashSpecified() try { - var manifest = new SkillBundleManifest + await WriteManifestAsync(bundleDirectory, new SkillBundleManifest { Version = AspireSkillsInstaller.Version, Supports = CreateSupports(), @@ -179,11 +209,10 @@ public async Task LoadAsync_ThrowsWhenNoPerFileHashSpecified() ] } ] - }; - - await WriteManifestAsync(bundleDirectory, manifest); + }); - var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None)); + var exception = await Assert.ThrowsAsync( + () => LoadBundleAsync(s_bundleProvider, bundleDirectory)); Assert.Contains("SHA-512 or SHA-256", exception.Message, StringComparison.Ordinal); } @@ -193,6 +222,85 @@ public async Task LoadAsync_ThrowsWhenNoPerFileHashSpecified() } } + [Fact] + public async Task LoadAsync_ThrowsWhenManifestIsMalformed() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skill-manifest.json"), "{"); + + var exception = await Assert.ThrowsAsync( + () => LoadBundleAsync(s_bundleProvider, bundleDirectory)); + + Assert.Equal("Aspire skills bundle manifest is invalid.", exception.Message); + Assert.IsType(exception.InnerException); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Fact] + public async Task LoadAsync_ThrowsWhenManifestContainsNullSkill() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await WriteManifestAsync(bundleDirectory, new SkillBundleManifest + { + Version = AspireSkillsInstaller.Version, + Supports = CreateSupports(), + Skills = [null] + }); + + var exception = await Assert.ThrowsAsync( + () => LoadBundleAsync(s_bundleProvider, bundleDirectory)); + + Assert.Equal("Aspire skills bundle manifest contains an empty skill entry.", exception.Message); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Fact] + public async Task LoadAsync_ThrowsWhenManifestContainsNullFile() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await WriteManifestAsync(bundleDirectory, new SkillBundleManifest + { + Version = AspireSkillsInstaller.Version, + Supports = CreateSupports(), + Skills = + [ + new SkillBundleSkill + { + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, + Files = [null] + } + ] + }); + + var exception = await Assert.ThrowsAsync( + () => LoadBundleAsync(s_bundleProvider, bundleDirectory)); + + Assert.Equal("Aspire skills bundle skill 'aspire' contains an empty file entry.", exception.Message); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + [Fact] public async Task LoadAsync_ThrowsWhenSkillDescriptionExceedsAgentHostLimit() { @@ -205,7 +313,7 @@ public async Task LoadAsync_ThrowsWhenSkillDescriptionExceedsAgentHostLimit() ["SKILL.md"] = CreateSkillFileContent(description: new string('a', 1025)) }); - var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None)); + var exception = await Assert.ThrowsAsync(() => LoadBundleAsync(s_bundleProvider, bundleDirectory)); Assert.Contains("description", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("1024", exception.Message, StringComparison.Ordinal); @@ -217,14 +325,13 @@ public async Task LoadAsync_ThrowsWhenSkillDescriptionExceedsAgentHostLimit() } [Fact] - public async Task LoadAsync_ThrowsWhenSkillNamesDifferOnlyByCase() + public async Task LoadAsync_ThrowsWhenSkillNamesAreDuplicated() { var bundleDirectory = CreateTempDirectory(); try { await WriteSkillAsync(bundleDirectory, CommonAgentApplicators.AspireSkillName, CreateSkillFileContent()); - await WriteSkillAsync(bundleDirectory, "Aspire", CreateSkillFileContent("Aspire")); var manifest = new SkillBundleManifest { @@ -233,13 +340,13 @@ public async Task LoadAsync_ThrowsWhenSkillNamesDifferOnlyByCase() Skills = [ CreateManifestSkill(bundleDirectory, CommonAgentApplicators.AspireSkillName, AspireSkillDescription), - CreateManifestSkill(bundleDirectory, "Aspire", AspireSkillDescription) + CreateManifestSkill(bundleDirectory, CommonAgentApplicators.AspireSkillName, AspireSkillDescription) ] }; await WriteManifestAsync(bundleDirectory, manifest); - var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None)); + var exception = await Assert.ThrowsAsync(() => LoadBundleAsync(s_bundleProvider, bundleDirectory)); Assert.Contains("duplicate skill", exception.Message, StringComparison.OrdinalIgnoreCase); } @@ -249,6 +356,152 @@ public async Task LoadAsync_ThrowsWhenSkillNamesDifferOnlyByCase() } } + [Fact] + public async Task LoadAsync_ThrowsWhenSkillFileDoesNotDeclareFrontmatterName() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync(bundleDirectory, new Dictionary + { + ["SKILL.md"] = """ + --- + description: "Aspire CLI commands and workflows for distributed apps" + --- + + # Aspire + """ + }); + + var exception = await Assert.ThrowsAsync( + () => LoadBundleAsync(s_bundleProvider, bundleDirectory)); + + Assert.Contains("must define a frontmatter name", exception.Message, StringComparison.Ordinal); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Fact] + public async Task LoadAsync_ThrowsWhenSkillFileFrontmatterNameDoesNotMatchManifest() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync(bundleDirectory, new Dictionary + { + ["SKILL.md"] = CreateSkillFileContent(name: CommonAgentApplicators.AspireifySkillName) + }); + + var exception = await Assert.ThrowsAsync( + () => LoadBundleAsync(s_bundleProvider, bundleDirectory)); + + Assert.Contains("must match its manifest and directory name", exception.Message, StringComparison.Ordinal); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Theory] + [InlineData("Aspire")] + [InlineData("aspire_skill")] + [InlineData("-aspire")] + [InlineData("aspire-")] + [InlineData("aspire--skill")] + [InlineData("..")] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] + public async Task LoadAsync_ThrowsWhenSkillNameViolatesAgentSkillsSpecification(string skillName) + { + var bundleDirectory = CreateTempDirectory(); + + try + { + var manifest = new SkillBundleManifest + { + Version = AspireSkillsInstaller.Version, + Supports = CreateSupports(), + Skills = + [ + new SkillBundleSkill + { + Name = skillName, + Description = AspireSkillDescription, + Files = + [ + new SkillBundleFile + { + RelativePath = "SKILL.md", + Sha512 = TestSha512 + } + ] + } + ] + }; + await WriteManifestAsync(bundleDirectory, manifest); + + var exception = await Assert.ThrowsAsync( + () => LoadBundleAsync(s_bundleProvider, bundleDirectory)); + + Assert.Contains("must be 1-64 characters", exception.Message, StringComparison.Ordinal); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Fact] + public async Task LoadAsync_ThrowsWhenSkillDoesNotContainSkillFile() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync(bundleDirectory, new Dictionary + { + ["references/app-commands.md"] = "# App commands" + }); + + var exception = await Assert.ThrowsAsync( + () => LoadBundleAsync(s_bundleProvider, bundleDirectory)); + + Assert.Contains("must contain SKILL.md", exception.Message); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Fact] + public async Task LoadAsync_ThrowsWhenSkillFileIsExcludedFromInstallation() + { + var bundleDirectory = CreateTempDirectory(); + + try + { + await CreateBundleAsync( + bundleDirectory, + new Dictionary { ["SKILL.md"] = CreateSkillFileContent() }, + installExcludedRelativePaths: ["SKILL.md"]); + + var exception = await Assert.ThrowsAsync( + () => LoadBundleAsync(s_bundleProvider, bundleDirectory)); + + Assert.Contains("cannot exclude SKILL.md", exception.Message); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + [Fact] public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() { @@ -273,7 +526,7 @@ public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() new SkillBundleFile { RelativePath = "../SKILL.md", - Sha512 = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + Sha512 = TestSha512 } ] } @@ -282,7 +535,61 @@ public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() await WriteManifestAsync(bundleDirectory, manifest); - var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None)); + var exception = await Assert.ThrowsAsync(() => LoadBundleAsync(s_bundleProvider, bundleDirectory)); + + Assert.Contains("is not safe", exception.Message); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Theory] + [InlineData("references/file:stream.md")] + [InlineData("references/file?.md")] + [InlineData("references/file\u0001.md")] + public async Task LoadAsync_ThrowsWhenFilePathIsNotPortable(string relativePath) + { + var bundleDirectory = CreateTempDirectory(); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); + Directory.CreateDirectory(skillDirectory); + var skillPath = Path.Combine(skillDirectory, "SKILL.md"); + await File.WriteAllTextAsync(skillPath, CreateSkillFileContent()); + + try + { + var manifest = new SkillBundleManifest + { + Version = AspireSkillsInstaller.Version, + Supports = CreateSupports(), + Skills = + [ + new SkillBundleSkill + { + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, + Files = + [ + new SkillBundleFile + { + RelativePath = "SKILL.md", + Sha512 = ComputeSha512(skillPath) + }, + new SkillBundleFile + { + RelativePath = relativePath, + Sha512 = TestSha512 + } + ] + } + ] + }; + + await WriteManifestAsync(bundleDirectory, manifest); + + var exception = await Assert.ThrowsAsync( + () => LoadBundleAsync(s_bundleProvider, bundleDirectory)); Assert.Contains("is not safe", exception.Message); } @@ -292,6 +599,36 @@ public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() } } + [Fact] + public async Task CreateAsync_ThrowsWhenArchiveEntryPathIsNotPortable() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var archivePath = Path.Combine(rootDirectory, "bundle.zip"); + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry("bundle/references/file:stream.md"); + await using var stream = entry.Open(); + await using var writer = new StreamWriter(stream); + await writer.WriteAsync("# Reference"); + } + + var exception = await Assert.ThrowsAsync(() => s_bundleProvider.CreateAsync( + new FileInfo(archivePath), + new DirectoryInfo(Path.Combine(rootDirectory, "staged")), + ComputeSha512(archivePath), + CancellationToken.None)); + + Assert.Contains("is not safe", exception.Message); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + [Fact] public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() { @@ -325,7 +662,7 @@ public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() """; await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skill-manifest.json"), manifestJson); - var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); + var bundle = await LoadBundleAsync(s_bundleProvider, bundleDirectory); var files = await bundle.GetSkillFilesAsync(AspireifySkillDefinition, CancellationToken.None); var skillFile = Assert.Single(files); @@ -372,7 +709,7 @@ public async Task LoadAsync_ThrowsWhenSupportsAreMissing() await WriteManifestAsync(bundleDirectory, manifest); - var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None)); + var exception = await Assert.ThrowsAsync(() => LoadBundleAsync(s_bundleProvider, bundleDirectory)); Assert.Contains("supported Aspire versions", exception.Message); } @@ -394,11 +731,8 @@ await CreateBundleAsync( new Dictionary { ["SKILL.md"] = CreateSkillFileContent() }, supports: new SkillBundleSupports { AspireCli = ">=99.0.0 <100.0.0" }); - var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync( - new DirectoryInfo(bundleDirectory), - currentCliVersion: "13.4.0", - currentSdkVersion: "13.4.0", - CancellationToken.None)); + var bundleProvider = new AspireSkillsBundleProvider("13.4.0", "13.4.0"); + var exception = await Assert.ThrowsAsync(() => LoadBundleAsync(bundleProvider, bundleDirectory)); Assert.Contains("supports Aspire CLI versions", exception.Message); } @@ -420,11 +754,8 @@ await CreateBundleAsync( new Dictionary { ["SKILL.md"] = CreateSkillFileContent() }, supports: new SkillBundleSupports { AspireCli = ">=13.4.0 <13.5.0" }); - var bundle = await AspireSkillsBundle.LoadAsync( - new DirectoryInfo(bundleDirectory), - currentCliVersion: "13.4.0-pr.17323.gf2228d9b", - currentSdkVersion: "13.4.0", - CancellationToken.None); + var bundleProvider = new AspireSkillsBundleProvider("13.4.0-pr.17323.gf2228d9b", "13.4.0"); + var bundle = await LoadBundleAsync(bundleProvider, bundleDirectory); Assert.Equal(AspireSkillsInstaller.Version, bundle.Version); } @@ -446,12 +777,8 @@ await CreateBundleAsync( new Dictionary { ["SKILL.md"] = CreateSkillFileContent() }, supports: new SkillBundleSupports { AspireCli = ">=13.4.0 <13.5.0" }); - var bundle = await AspireSkillsBundle.LoadAsync( - new DirectoryInfo(bundleDirectory), - currentCliVersion: "13.5.0-pr.17553.gca8e5ace", - currentSdkVersion: "13.5.0", - skipCompatibilityCheck: true, - CancellationToken.None); + var bundleProvider = new AspireSkillsBundleProvider("13.5.0-pr.17553.gca8e5ace", "13.5.0"); + var bundle = await LoadBundleAsync(bundleProvider, bundleDirectory, skipCompatibilityCheck: true); Assert.Equal(AspireSkillsInstaller.Version, bundle.Version); } @@ -477,12 +804,11 @@ await CreateBundleAsync( var skillPath = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); await File.WriteAllTextAsync(skillPath, "tampered"); - await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync( - new DirectoryInfo(bundleDirectory), - currentCliVersion: "13.5.0", - currentSdkVersion: "13.5.0", - skipCompatibilityCheck: true, - CancellationToken.None)); + var bundleProvider = new AspireSkillsBundleProvider("13.5.0", "13.5.0"); + await Assert.ThrowsAsync(() => LoadBundleAsync( + bundleProvider, + bundleDirectory, + skipCompatibilityCheck: true)); } finally { @@ -490,18 +816,30 @@ await Assert.ThrowsAsync(() => AspireSkillsBundle.Loa } } + private static Task LoadBundleAsync( + AspireSkillsBundleProvider bundleProvider, + string bundleDirectory, + bool skipCompatibilityCheck = false) + { + return bundleProvider.LoadAsync( + new DirectoryInfo(bundleDirectory), + CancellationToken.None, + skipCompatibilityCheck); + } + private static async Task CreateBundleAsync( string bundleDirectory, Dictionary files, string? hashOverride = null, - SkillBundleSupports? supports = null) + SkillBundleSupports? supports = null, + IReadOnlyList? installExcludedRelativePaths = null) { var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); Directory.CreateDirectory(skillDirectory); foreach (var (relativePath, content) in files) { - var fullPath = Path.Combine(skillDirectory, AspireSkillsBundle.NormalizeRelativePath(relativePath)); + var fullPath = Path.Combine(skillDirectory, AspireSkillsBundleProvider.NormalizeRelativePath(relativePath)); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); await File.WriteAllTextAsync(fullPath, content); } @@ -516,12 +854,12 @@ private static async Task CreateBundleAsync( { Name = CommonAgentApplicators.AspireSkillName, Description = AspireSkillDescription, - InstallExcludedRelativePaths = ["evals"], + InstallExcludedRelativePaths = installExcludedRelativePaths?.ToArray() ?? ["evals"], Files = files .Select(file => new SkillBundleFile { RelativePath = file.Key, - Sha512 = hashOverride ?? ComputeSha512(Path.Combine(skillDirectory, AspireSkillsBundle.NormalizeRelativePath(file.Key))) + Sha512 = hashOverride ?? ComputeSha512(Path.Combine(skillDirectory, AspireSkillsBundleProvider.NormalizeRelativePath(file.Key))) }) .ToArray() } diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs index 0a9da2c45bd..6040bdd7905 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs @@ -14,14 +14,16 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; namespace Aspire.Cli.Tests.Agents; public class AspireSkillsInstallerTests { private const string AspireSkillDescription = "Aspire CLI commands and workflows for distributed apps"; - + private const string CacheLockRetryLogMessage = "Acquiring the Aspire skills cache lock"; private const string GitHubReleaseAssetBuildType = "https://actions.github.io/buildtypes/workflow/v1"; [Fact] @@ -32,16 +34,23 @@ public async Task InstallAsync_WhenValidBundleIsCached_UsesCacheWithoutNetwork() try { var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); - var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); - await CreateCachedBundleAsync(cachedBundleDirectory); var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); - var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, embeddedBundleProvider.Metadata!.Sha512!); + await CreateCachedBundleAsync(cachedBundleDirectory, archiveSha512: embeddedBundleProvider.Metadata!.Sha512); + var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled.")); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + embeddedBundleProvider: embeddedBundleProvider, + features: features); var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); - Assert.False(embeddedBundleProvider.OpenArchiveCalled); + Assert.True(Directory.Exists(cachedBundleDirectory)); + Assert.False(embeddedBundleProvider.CreateBundleCalled); } finally { @@ -49,8 +58,382 @@ public async Task InstallAsync_WhenValidBundleIsCached_UsesCacheWithoutNetwork() } } + [Theory] + [InlineData(unchecked((int)0x80070020), true, true)] + [InlineData(unchecked((int)0x80070021), true, true)] + [InlineData(unchecked((int)0x80070005), true, false)] + [InlineData(unchecked((int)0x80070070), true, false)] + [InlineData(11, false, true)] + [InlineData(35, false, true)] + [InlineData(2, false, false)] + [InlineData(28, false, false)] + public void IsCacheLockContention_OnlyMatchesPlatformLockErrors(int hresult, bool isWindows, bool expected) + { + var exception = new IOException("Cache lock failed.", hresult); + + Assert.Equal(expected, AspireSkillsInstaller.IsCacheLockContention(exception, isWindows)); + } + [Fact] - public async Task InstallAsync_WhenCachedBundleLastUsedCannotBeTouched_UsesCacheWithoutNetwork() + public async Task InstallAsync_WhenRequiredCacheLockExceedsCleanupRetryBudget_WaitsForRelease() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills"); + Directory.CreateDirectory(cacheRoot); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var cleanupRetryBudgetExceeded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var retryCount = 0; + var sink = new TestSink(); + sink.MessageLogged += context => + { + if (context.Message?.Contains(CacheLockRetryLogMessage, StringComparison.Ordinal) == true && + Interlocked.Increment(ref retryCount) == 4) + { + cleanupRetryBudgetExceeded.TrySetResult(); + } + }; + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var installer = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider, + features: features, + logger: logger); + + var cacheLockPath = Path.Combine(cacheRoot, $".{AspireSkillsInstaller.Version}.lock"); + await using var cacheLock = new FileStream( + cacheLockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.Asynchronous); + + var installTask = installer.InstallAsync(CancellationToken.None); + var completedTask = await Task.WhenAny(cleanupRetryBudgetExceeded.Task, installTask).WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Same(cleanupRetryBudgetExceeded.Task, completedTask); + Assert.False(installTask.IsCompleted); + + await cacheLock.DisposeAsync(); + var result = await installTask.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubDigestMatchesCachedBundle_UsesCacheWithoutDownloadingAsset() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var archiveBytes = await CreateBundleArchiveBytesAsync(); + var archiveSha256 = ComputeSha256(archiveBytes); + var archiveSha512 = ComputeSha512(archiveBytes); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, archiveSha512); + await CreateCachedBundleAsync( + cachedBundleDirectory, + archiveSha512: archiveSha512, + githubArchiveSha256: archiveSha256, + githubAttestationVerified: true); + var assetDownloadRequested = false; + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) + { + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + $"sha256:{archiveSha256}")); + } + + assetDownloadRequested = true; + return new HttpResponseMessage(HttpStatusCode.InternalServerError); + }); + var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.False(assetDownloadRequested); + Assert.False(attestationVerifier.VerifyCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubDigestMatchesUnverifiedCache_DownloadsAttestsAndReplacesCache() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var archiveBytes = await CreateBundleArchiveBytesAsync(skillBody: "# Downloaded GitHub"); + var archiveSha256 = ComputeSha256(archiveBytes); + var archiveSha512 = ComputeSha512(archiveBytes); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, archiveSha512); + await CreateCachedBundleAsync( + cachedBundleDirectory, + archiveSha512: archiveSha512, + githubArchiveSha256: archiveSha256, + skillBody: "# Unverified Cache"); + var assetDownloadRequested = false; + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) + { + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + $"sha256:{archiveSha256}")); + } + + assetDownloadRequested = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(archiveBytes) + }; + }); + var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(assetDownloadRequested); + Assert.True(attestationVerifier.VerifyCalled); + Assert.True(File.Exists(Path.Combine( + cachedBundleDirectory, + AspireSkillsInstaller.GitHubAttestationVerifiedFileName))); + var skill = Assert.Single(result.Bundle.GetSkillDefinitions()); + var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None)); + Assert.Contains("# Downloaded GitHub", skillFile.Content, StringComparison.Ordinal); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenUnverifiedArchiveContainsAttestationMarker_DoesNotTrustCachedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var archiveBytes = await CreateBundleArchiveBytesAsync(includeGitHubAttestationMarker: true); + var archiveSha256 = ComputeSha256(archiveBytes); + var archiveSha512 = ComputeSha512(archiveBytes); + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) + { + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + $"sha256:{archiveSha256}")); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(archiveBytes) + }; + }); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AspireSkillsInstaller.DisablePackageValidationKey] = "true" + }) + .Build(); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + configuration: configuration); + + var firstResult = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, firstResult.Status); + Assert.False(File.Exists(Path.Combine( + GetBundleCacheDirectory(executionContext, archiveSha512), + AspireSkillsInstaller.GitHubAttestationVerifiedFileName))); + + var secondInstaller = CreateInstaller(executionContext); + var secondResult = await secondInstaller.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Failed, secondResult.Status); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubDigestChangesForCachedVersion_DownloadsAndKeepsBothCaches() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var archiveBytes = await CreateBundleArchiveBytesAsync(skillBody: "# Downloaded GitHub"); + var archiveSha256 = ComputeSha256(archiveBytes); + var archiveSha512 = ComputeSha512(archiveBytes); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var staleArchiveSha256 = new string('0', 64); + var staleArchiveSha512 = new string('0', 128); + var staleCachedBundleDirectory = GetBundleCacheDirectory(executionContext, staleArchiveSha512); + await CreateCachedBundleAsync( + staleCachedBundleDirectory, + archiveSha512: staleArchiveSha512, + githubArchiveSha256: staleArchiveSha256, + githubAttestationVerified: true, + skillBody: "# Stale GitHub"); + var assetDownloadRequested = false; + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) + { + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + $"sha256:{archiveSha256}")); + } + + assetDownloadRequested = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(archiveBytes) + }; + }); + var installer = CreateInstaller(executionContext, httpMessageHandler: handler); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(assetDownloadRequested); + Assert.True(Directory.Exists(staleCachedBundleDirectory)); + var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, archiveSha512); + Assert.Equal( + archiveSha512, + await File.ReadAllTextAsync(Path.Combine( + cachedBundleDirectory, + AspireSkillsInstaller.ArchiveSha512FileName))); + Assert.Equal( + archiveSha256, + await File.ReadAllTextAsync(Path.Combine( + cachedBundleDirectory, + AspireSkillsInstaller.GitHubArchiveSha256FileName))); + var skill = Assert.Single(result.Bundle.GetSkillDefinitions()); + var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None)); + Assert.Contains("# Downloaded GitHub", skillFile.Content, StringComparison.Ordinal); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenEmbeddedArchiveHashChangesForCachedVersion_KeepsBothCaches() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var oldEmbeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(new SkillBundleSupports + { + AspireCli = ">=0.0.1 <0.0.2", + AspireSdk = ">=0.0.1 <0.0.2" + }); + var oldInstaller = CreateInstaller( + executionContext, + embeddedBundleProvider: oldEmbeddedBundleProvider, + features: features); + + var oldResult = await oldInstaller.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, oldResult.Status); + Assert.NotNull(oldResult.Bundle); + var oldCachedBundleDirectory = GetBundleCacheDirectory( + executionContext, + oldEmbeddedBundleProvider.Metadata!.Sha512!); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + Assert.NotEqual(oldEmbeddedBundleProvider.Metadata!.Sha512, embeddedBundleProvider.Metadata!.Sha512); + var installer = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + Assert.True(Directory.Exists(oldCachedBundleDirectory)); + var cachedBundleDirectory = GetBundleCacheDirectory( + executionContext, + embeddedBundleProvider.Metadata.Sha512!); + Assert.Equal( + embeddedBundleProvider.Metadata.Sha512, + await File.ReadAllTextAsync(Path.Combine( + cachedBundleDirectory, + AspireSkillsInstaller.ArchiveSha512FileName))); + Assert.NotEmpty(await oldResult.Bundle.GetSkillFilesAsync( + oldResult.Bundle.GetSkillDefinitions()[0], + CancellationToken.None)); + + var restoredEmbeddedBundleProvider = new TestEmbeddedAspireSkillsBundleProvider + { + Metadata = oldEmbeddedBundleProvider.Metadata, + ArchiveBytes = oldEmbeddedBundleProvider.ArchiveBytes + }; + var restoredInstaller = CreateInstaller( + executionContext, + embeddedBundleProvider: restoredEmbeddedBundleProvider, + features: features); + + var restoredResult = await restoredInstaller.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, restoredResult.Status); + Assert.False(restoredEmbeddedBundleProvider.CreateBundleCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenCachedBundleDoesNotHaveArchiveHash_ReplacesLegacyCache() { var rootDirectory = CreateTempDirectory(); @@ -59,13 +442,28 @@ public async Task InstallAsync_WhenCachedBundleLastUsedCannotBeTouched_UsesCache var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); await CreateCachedBundleAsync(cachedBundleDirectory); - Directory.CreateDirectory(Path.Combine(cachedBundleDirectory, ".lastused")); - var installer = CreateInstaller(executionContext); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider, + features: features); var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + Assert.False(File.Exists(Path.Combine(cachedBundleDirectory, "skill-manifest.json"))); + Assert.False(Directory.Exists(Path.Combine(cachedBundleDirectory, "skills"))); + var bundleCacheDirectory = GetBundleCacheDirectory( + executionContext, + embeddedBundleProvider.Metadata!.Sha512!); + Assert.Equal( + embeddedBundleProvider.Metadata.Sha512, + await File.ReadAllTextAsync(Path.Combine( + bundleCacheDirectory, + AspireSkillsInstaller.ArchiveSha512FileName))); } finally { @@ -74,19 +472,839 @@ public async Task InstallAsync_WhenCachedBundleLastUsedCannotBeTouched_UsesCache } [Fact] - public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndNoCache_ReturnsFailure() + public async Task InstallAsync_WhenCachedManifestIsMalformed_ReplacesCache() { var rootDirectory = CreateTempDirectory(); try { var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); - var installer = CreateInstaller(executionContext); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, embeddedBundleProvider.Metadata!.Sha512!); + await CreateCachedBundleAsync( + cachedBundleDirectory, + archiveSha512: embeddedBundleProvider.Metadata!.Sha512); + await File.WriteAllTextAsync(Path.Combine(cachedBundleDirectory, "skill-manifest.json"), "{"); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider, + features: features); var result = await installer.InstallAsync(CancellationToken.None); - Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); - Assert.Contains("GitHub", result.Message); + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + var skill = Assert.Single(result.Bundle.GetSkillDefinitions()); + var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None)); + Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenCachedBundleLastUsedCannotBeTouched_UsesCacheWithoutNetwork() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, embeddedBundleProvider.Metadata!.Sha512!); + await CreateCachedBundleAsync(cachedBundleDirectory, archiveSha512: embeddedBundleProvider.Metadata!.Sha512); + Directory.CreateDirectory(Path.Combine(cachedBundleDirectory, ".lastused")); + var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled.")); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(Directory.Exists(cachedBundleDirectory)); + Assert.False(embeddedBundleProvider.CreateBundleCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenStaleCacheLastUsedIsOutOfRange_IgnoresInvalidMarker() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, embeddedBundleProvider.Metadata!.Sha512!); + await CreateCachedBundleAsync(cachedBundleDirectory, archiveSha512: embeddedBundleProvider.Metadata!.Sha512); + var staleArchiveSha512 = new string('a', 128); + var staleCacheDirectory = Path.Combine( + executionContext.CacheDirectory.FullName, + "aspire-skills", + "9.9.9", + staleArchiveSha512); + await CreateCachedBundleAsync(staleCacheDirectory, archiveSha512: staleArchiveSha512); + await File.WriteAllTextAsync( + Path.Combine(staleCacheDirectory, ".lastused"), + long.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); + var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled.")); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.False(embeddedBundleProvider.CreateBundleCalled); + Assert.True(Directory.Exists(staleCacheDirectory)); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_RemovesStaleLegacyCacheForOtherVersion() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills"); + var staleVersionDirectory = Path.Combine(cacheRoot, "9.9.9"); + await CreateCachedBundleAsync(staleVersionDirectory); + await WriteLastUsedAsync(staleVersionDirectory, DateTimeOffset.UtcNow.AddDays(-1)); + + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var currentCacheDirectory = GetBundleCacheDirectory( + executionContext, + embeddedBundleProvider.Metadata!.Sha512!); + await CreateCachedBundleAsync( + currentCacheDirectory, + archiveSha512: embeddedBundleProvider.Metadata.Sha512); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AspireSkillsInstaller.MaxCacheAgeKey] = "60" + }) + .Build(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + configuration: configuration, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.False(Directory.Exists(staleVersionDirectory)); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_RemovesEmptyVersionDirectoryAfterStaleDigestCleanup() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills"); + var staleVersionDirectory = Path.Combine(cacheRoot, "9.9.9"); + var staleArchiveSha512 = new string('a', 128); + var staleCacheDirectory = Path.Combine(staleVersionDirectory, staleArchiveSha512); + await CreateCachedBundleAsync(staleCacheDirectory, archiveSha512: staleArchiveSha512); + await WriteLastUsedAsync(staleCacheDirectory, DateTimeOffset.UtcNow.AddDays(-1)); + + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var currentCacheDirectory = GetBundleCacheDirectory( + executionContext, + embeddedBundleProvider.Metadata!.Sha512!); + await CreateCachedBundleAsync( + currentCacheDirectory, + archiveSha512: embeddedBundleProvider.Metadata.Sha512); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AspireSkillsInstaller.MaxCacheAgeKey] = "60" + }) + .Build(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + configuration: configuration, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.False(Directory.Exists(staleVersionDirectory)); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenStaleVersionLockIsReleasedDuringBackoff_RechecksLastUsed() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills"); + Directory.CreateDirectory(cacheRoot); + const string staleVersion = "9.9.9"; + var staleArchiveSha512 = new string('a', 128); + var staleCacheDirectory = Path.Combine(cacheRoot, staleVersion, staleArchiveSha512); + await CreateCachedBundleAsync(staleCacheDirectory, archiveSha512: staleArchiveSha512); + await WriteLastUsedAsync(staleCacheDirectory, DateTimeOffset.UtcNow.AddDays(-1)); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AspireSkillsInstaller.MaxCacheAgeKey] = "60" + }) + .Build(); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var retryObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sink = new TestSink(); + sink.MessageLogged += context => + { + if (context.Message?.Contains(CacheLockRetryLogMessage, StringComparison.Ordinal) == true) + { + retryObserved.TrySetResult(); + } + }; + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var installer = CreateInstaller( + executionContext, + configuration: configuration, + embeddedBundleProvider: embeddedBundleProvider, + features: features, + logger: logger); + + var staleVersionLockPath = Path.Combine(cacheRoot, $".{staleVersion}.lock"); + await using var staleVersionLock = new FileStream( + staleVersionLockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.Asynchronous); + + var installTask = installer.InstallAsync(CancellationToken.None); + await retryObserved.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await WriteLastUsedAsync(staleCacheDirectory, DateTimeOffset.UtcNow); + await staleVersionLock.DisposeAsync(); + var result = await installTask.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.True(Directory.Exists(staleCacheDirectory)); + Assert.Contains(sink.Writes, context => + context.Message?.Contains(CacheLockRetryLogMessage, StringComparison.Ordinal) == true); + Assert.Equal(0, sink.Writes.Count(context => + context.Message?.Contains("Skipping cleanup", StringComparison.Ordinal) == true)); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenStaleVersionLockExceedsRetryBudget_SkipsLockedVersion() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills"); + Directory.CreateDirectory(cacheRoot); + const string staleVersion = "9.9.9"; + var staleArchiveSha512 = new string('a', 128); + var staleCacheDirectory = Path.Combine(cacheRoot, staleVersion, staleArchiveSha512); + await CreateCachedBundleAsync(staleCacheDirectory, archiveSha512: staleArchiveSha512); + await WriteLastUsedAsync(staleCacheDirectory, DateTimeOffset.UtcNow.AddDays(-1)); + + var staleVersionLockPath = Path.Combine(cacheRoot, $".{staleVersion}.lock"); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AspireSkillsInstaller.MaxCacheAgeKey] = "60" + }) + .Build(); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var sink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var installer = CreateInstaller( + executionContext, + configuration: configuration, + embeddedBundleProvider: embeddedBundleProvider, + features: features, + logger: logger); + + await using (var staleVersionLock = new FileStream( + staleVersionLockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.Asynchronous)) + { + var result = await installer.InstallAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.True(Directory.Exists(staleCacheDirectory)); + Assert.Equal(3, sink.Writes.Count(context => + context.Message?.Contains(CacheLockRetryLogMessage, StringComparison.Ordinal) == true)); + Assert.Equal(1, sink.Writes.Count(context => + context.Message?.Contains("Skipping cleanup", StringComparison.Ordinal) == true)); + } + + var cleanupResult = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, cleanupResult.Status); + Assert.False(Directory.Exists(staleCacheDirectory)); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_RemovesOnlyStaleDigestCachesForCurrentVersion() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var currentCacheDirectory = GetBundleCacheDirectory( + executionContext, + embeddedBundleProvider.Metadata!.Sha512!); + await CreateCachedBundleAsync( + currentCacheDirectory, + archiveSha512: embeddedBundleProvider.Metadata.Sha512); + + var staleArchiveSha512 = new string('a', 128); + var staleCacheDirectory = GetBundleCacheDirectory(executionContext, staleArchiveSha512); + await CreateCachedBundleAsync(staleCacheDirectory, archiveSha512: staleArchiveSha512); + await WriteLastUsedAsync(staleCacheDirectory, DateTimeOffset.UtcNow.AddDays(-1)); + + var recentArchiveSha512 = new string('b', 128); + var recentCacheDirectory = GetBundleCacheDirectory(executionContext, recentArchiveSha512); + await CreateCachedBundleAsync(recentCacheDirectory, archiveSha512: recentArchiveSha512); + await WriteLastUsedAsync(recentCacheDirectory, DateTimeOffset.UtcNow); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [AspireSkillsInstaller.MaxCacheAgeKey] = "60" + }) + .Build(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + configuration: configuration, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.True(Directory.Exists(currentCacheDirectory)); + Assert.False(Directory.Exists(staleCacheDirectory)); + Assert.True(Directory.Exists(recentCacheDirectory)); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_LeavesVersionLockFileAsStableSynchronizationAnchor() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.True(File.Exists(Path.Combine( + executionContext.CacheDirectory.FullName, + "aspire-skills", + $".{AspireSkillsInstaller.Version}.lock"))); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenCacheLockIsContendedAndCancellationWasRequested_ThrowsCancellation() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills"); + Directory.CreateDirectory(cacheRoot); + using var cacheLock = new FileStream( + Path.Combine(cacheRoot, $".{AspireSkillsInstaller.Version}.lock"), + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var retryObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sink = new TestSink(); + sink.MessageLogged += context => + { + if (context.Message?.Contains(CacheLockRetryLogMessage, StringComparison.Ordinal) == true) + { + retryObserved.TrySetResult(); + } + }; + var logger = new TestLogger(new TestLoggerFactory(sink, enabled: true)); + var installer = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider, + features: features, + logger: logger); + using var cancellationTokenSource = new CancellationTokenSource(); + + var installTask = installer.InstallAsync(cancellationTokenSource.Token); + await retryObserved.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.False(installTask.IsCompleted); + cancellationTokenSource.Cancel(); + await Assert.ThrowsAnyAsync( + () => installTask); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_RemovesOnlyStaleTemporaryCacheDirectories() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills"); + Directory.CreateDirectory(cacheRoot); + var temporaryDirectoryPrefixes = new[] { ".github-", ".embedded-", ".extract-", ".stage-" }; + List staleDirectories = []; + List recentDirectories = []; + foreach (var prefix in temporaryDirectoryPrefixes) + { + var staleDirectory = Directory.CreateDirectory(Path.Combine(cacheRoot, $"{prefix}stale")).FullName; + Directory.SetLastWriteTimeUtc(staleDirectory, DateTime.UtcNow.AddDays(-8)); + staleDirectories.Add(staleDirectory); + recentDirectories.Add(Directory.CreateDirectory(Path.Combine(cacheRoot, $"{prefix}recent")).FullName); + } + + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var installer = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.All(staleDirectories, directory => Assert.False(Directory.Exists(directory))); + Assert.All(recentDirectories, directory => Assert.True(Directory.Exists(directory))); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenStaleTemporaryDirectoryIsActive_DoesNotDeleteIt() + { + var rootDirectory = CreateTempDirectory(); + var verificationStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var verificationGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task? firstInstallTask = null; + + try + { + var archiveBytes = await CreateBundleArchiveBytesAsync(); + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) + { + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + $"sha256:{ComputeSha256(archiveBytes)}")); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(archiveBytes) + }; + }); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var attestationVerifier = new TestGitHubArtifactAttestationVerifier + { + VerificationStarted = verificationStarted, + VerificationGate = verificationGate.Task + }; + var firstInstaller = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier); + + firstInstallTask = firstInstaller.InstallAsync(CancellationToken.None); + await verificationStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + var cacheRoot = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills"); + var activeTemporaryDirectory = Assert.Single(Directory.GetDirectories(cacheRoot, ".github-*")); + Directory.SetLastWriteTimeUtc(activeTemporaryDirectory, DateTime.UtcNow.AddDays(-8)); + + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); + var secondInstaller = CreateInstaller( + executionContext, + embeddedBundleProvider: embeddedBundleProvider, + features: features); + + var secondResult = await secondInstaller.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, secondResult.Status); + Assert.True(Directory.Exists(activeTemporaryDirectory)); + + verificationGate.SetResult(); + var firstResult = await firstInstallTask.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(AspireSkillsInstallStatus.Installed, firstResult.Status); + Assert.False(Directory.Exists(activeTemporaryDirectory)); + Assert.False(File.Exists($"{activeTemporaryDirectory}.lock")); + } + finally + { + verificationGate.TrySetResult(); + if (firstInstallTask is { IsCompleted: false }) + { + await firstInstallTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndNoCache_ReturnsFailure() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var installer = CreateInstaller(executionContext); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); + Assert.Contains("GitHub", result.Message); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndEmbeddedBundleMatches_UsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubResponseEndsEarly_UsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var handler = new MockHttpMessageHandler(new HttpIOException(HttpRequestError.ResponseEnded)); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubRequestTimesOut_UsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var handler = new MockHttpMessageHandler( + new TaskCanceledException("The request timed out.", new TimeoutException())); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubRequestIsCanceledByCaller_ThrowsCancellation() + { + var rootDirectory = CreateTempDirectory(); + + try + { + using var cancellationTokenSource = new CancellationTokenSource(); + var requestObserved = false; + var handler = new MockHttpMessageHandler(_ => + { + requestObserved = true; + cancellationTokenSource.Cancel(); + throw new OperationCanceledException(cancellationTokenSource.Token); + }); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var installer = CreateInstaller(executionContext, httpMessageHandler: handler); + + await Assert.ThrowsAnyAsync( + () => installer.InstallAsync(cancellationTokenSource.Token)); + Assert.True(requestObserved); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubIsUnavailable_UsesVerifiedGitHubCache() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var verifiedArchiveSha512 = new string('a', 128); + var verifiedGitHubCacheDirectory = GetBundleCacheDirectory(executionContext, verifiedArchiveSha512); + await CreateCachedBundleAsync( + verifiedGitHubCacheDirectory, + archiveSha512: verifiedArchiveSha512, + githubAttestationVerified: true, + skillBody: "# Verified GitHub"); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.False(embeddedBundleProvider.CreateBundleCalled); + var skill = Assert.Single(result.Bundle.GetSkillDefinitions()); + var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None)); + Assert.Contains("# Verified GitHub", skillFile.Content, StringComparison.Ordinal); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubIsUnavailable_UsesMostRecentCompatibleVerifiedCache() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var compatibleArchiveSha512 = new string('a', 128); + var compatibleCacheDirectory = GetBundleCacheDirectory(executionContext, compatibleArchiveSha512); + await CreateCachedBundleAsync( + compatibleCacheDirectory, + archiveSha512: compatibleArchiveSha512, + githubAttestationVerified: true, + skillBody: "# Compatible GitHub"); + await WriteLastUsedAsync(compatibleCacheDirectory, DateTimeOffset.UtcNow.AddMinutes(-1)); + + var incompatibleArchiveSha512 = new string('b', 128); + var incompatibleCacheDirectory = GetBundleCacheDirectory(executionContext, incompatibleArchiveSha512); + await CreateCachedBundleAsync( + incompatibleCacheDirectory, + supports: new SkillBundleSupports + { + AspireCli = ">=99.0.0 <100.0.0", + AspireSdk = ">=99.0.0 <100.0.0" + }, + archiveSha512: incompatibleArchiveSha512, + githubAttestationVerified: true, + skillBody: "# Incompatible GitHub"); + await WriteLastUsedAsync(incompatibleCacheDirectory, DateTimeOffset.UtcNow); + + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.False(embeddedBundleProvider.CreateBundleCalled); + var skill = Assert.Single(result.Bundle.GetSkillDefinitions()); + var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None)); + Assert.Contains("# Compatible GitHub", skillFile.Content, StringComparison.Ordinal); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubDigestIdentifiesStaleCacheAndAssetIsUnavailable_UsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var currentArchiveBytes = await CreateBundleArchiveBytesAsync(skillBody: "# Current GitHub"); + var currentArchiveSha256 = ComputeSha256(currentArchiveBytes); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var staleArchiveSha256 = new string('a', 64); + var staleArchiveSha512 = new string('a', 128); + var verifiedGitHubCacheDirectory = GetBundleCacheDirectory(executionContext, staleArchiveSha512); + await CreateCachedBundleAsync( + verifiedGitHubCacheDirectory, + archiveSha512: staleArchiveSha512, + githubArchiveSha256: staleArchiveSha256, + githubAttestationVerified: true, + skillBody: "# Stale GitHub"); + var assetDownloadRequested = false; + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) + { + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + $"sha256:{currentArchiveSha256}")); + } + + assetDownloadRequested = true; + return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + }); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(assetDownloadRequested); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + var skill = Assert.Single(result.Bundle.GetSkillDefinitions()); + var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None)); + Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal); } finally { @@ -94,22 +1312,61 @@ public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndNoCache_ReturnsF } } - [Fact] - public async Task InstallAsync_WhenGitHubReleaseIsUnavailableAndEmbeddedBundleMatches_UsesEmbeddedBundle() + [Theory] + [InlineData(null, false)] + [InlineData("not-a-sha256-digest", false)] + [InlineData(null, true)] + public async Task InstallAsync_WhenReleaseDigestCannotIdentifyUnavailableAsset_UsesEmbeddedBundle( + string? releaseDigest, + bool assetRequestTimesOut) { var rootDirectory = CreateTempDirectory(); try { var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var staleArchiveSha512 = new string('a', 128); + var verifiedGitHubCacheDirectory = GetBundleCacheDirectory(executionContext, staleArchiveSha512); + await CreateCachedBundleAsync( + verifiedGitHubCacheDirectory, + archiveSha512: staleArchiveSha512, + githubArchiveSha256: new string('a', 64), + githubAttestationVerified: true, + skillBody: "# Stale GitHub"); + var assetDownloadRequested = false; + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) + { + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + releaseDigest)); + } + + assetDownloadRequested = true; + if (assetRequestTimesOut) + { + throw new TaskCanceledException("The request timed out.", new TimeoutException()); + } + + return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + }); var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); - var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); - Assert.True(embeddedBundleProvider.OpenArchiveCalled); + Assert.True(assetDownloadRequested); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + var skill = Assert.Single(result.Bundle.GetSkillDefinitions()); + var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None)); + Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal); } finally { @@ -141,7 +1398,7 @@ public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabled_SkipsGitHubAndUs Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); - Assert.True(embeddedBundleProvider.OpenArchiveCalled); + Assert.True(embeddedBundleProvider.CreateBundleCalled); Assert.False(attestationVerifier.VerifyCalled); } finally @@ -151,29 +1408,26 @@ public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabled_SkipsGitHubAndUs } [Fact] - public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabledAndCacheExists_UsesCacheWithoutNetwork() + public async Task EmbeddedAspireSkillsBundleProvider_CreatesBundle() { var rootDirectory = CreateTempDirectory(); try { - var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); - var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); - await CreateCachedBundleAsync(cachedBundleDirectory); - var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); - var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when cache is used.")); - var features = new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, false); - var installer = CreateInstaller( - executionContext, - httpMessageHandler: handler, - githubArtifactAttestationVerifier: attestationVerifier, - features: features); - - var result = await installer.InstallAsync(CancellationToken.None); - - Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); - Assert.NotNull(result.Bundle); - Assert.False(attestationVerifier.VerifyCalled); + var bundleDirectory = new DirectoryInfo(Path.Combine(rootDirectory, "bundle")); + var provider = new EmbeddedAspireSkillsBundleProvider( + new AspireSkillsBundleProvider(), + NullLogger.Instance); + + var metadata = Assert.IsType(provider.Metadata); + var bundle = await provider.CreateBundleAsync(bundleDirectory, CancellationToken.None); + + Assert.NotNull(bundle); + Assert.Equal(AspireSkillsInstaller.Version, bundle.Version); + Assert.Equal(AspireSkillsInstaller.Version, metadata.Version); + Assert.Equal(AspireSkillsInstaller.GitHubRepository, metadata.Repository); + Assert.Matches("^[0-9a-f]{128}$", metadata.Sha512); + AssertNoTemporaryEntries(rootDirectory, "embedded"); } finally { @@ -182,129 +1436,131 @@ public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabledAndCacheExists_Us } [Fact] - public void EmbeddedAspireSkillsBundleProvider_OpensSnapshotResource() + public async Task EmbeddedAspireSkillsBundleProvider_WhenTemporaryArchiveIsLocked_CanPromoteBundle() { - var provider = new EmbeddedAspireSkillsBundleProvider(NullLogger.Instance); + Assert.SkipUnless(OperatingSystem.IsWindows(), "This test validates Windows delete-sharing behavior."); - var metadata = Assert.IsType(provider.Metadata); - using var archiveStream = Assert.IsAssignableFrom(provider.OpenArchive()); + var rootDirectory = CreateTempDirectory(); + ArchiveLockingAspireSkillsBundleProvider? lockingBundleProvider = null; - Assert.Equal(AspireSkillsInstaller.Version, metadata.Version); - Assert.Equal(AspireSkillsInstaller.GitHubRepository, metadata.Repository); - Assert.Equal(metadata.Sha512, ComputeSha512(archiveStream)); + try + { + var stageDirectory = new DirectoryInfo(Path.Combine(rootDirectory, ".stage-test")); + var targetDirectory = Path.Combine(rootDirectory, "cached"); + lockingBundleProvider = new ArchiveLockingAspireSkillsBundleProvider(new AspireSkillsBundleProvider()); + var provider = new EmbeddedAspireSkillsBundleProvider( + lockingBundleProvider, + NullLogger.Instance); + + var bundle = await provider.CreateBundleAsync(stageDirectory, CancellationToken.None); + + Assert.NotNull(bundle); + Assert.NotEmpty(Directory.GetDirectories(rootDirectory, ".embedded-*")); + Directory.Move(stageDirectory.FullName, targetDirectory); + Assert.True(File.Exists(Path.Combine(targetDirectory, "skill-manifest.json"))); + } + finally + { + lockingBundleProvider?.Dispose(); + Directory.Delete(rootDirectory, recursive: true); + } } - private static string ComputeSha512(Stream stream) + [Fact] + public async Task AspireSkillsBundleProvider_WhenValidationFails_RemovesExtractionDirectory() { - return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); + var rootDirectory = CreateTempDirectory(); + + try + { + var archivePath = Path.Combine(rootDirectory, "bundle.tgz"); + var archiveBytes = await CreateBundleArchiveBytesAsync( + new SkillBundleSupports { AspireCli = ">=99.0.0 <100.0.0" }); + await File.WriteAllBytesAsync(archivePath, archiveBytes); + + var bundleDirectory = new DirectoryInfo(Path.Combine(rootDirectory, "bundle")); + var provider = new AspireSkillsBundleProvider("13.4.0", "13.4.0"); + + var exception = await Assert.ThrowsAsync(() => provider.CreateAsync( + new FileInfo(archivePath), + bundleDirectory, + ComputeSha512(archiveBytes), + CancellationToken.None)); + + Assert.Contains("supports Aspire CLI versions", exception.Message); + AssertNoTemporaryEntries(rootDirectory, "extract"); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } } [Fact] - public async Task EmbeddedAspireSkillsBundle_ArchiveIsSha512_AndPerFileHashesVerify() + public async Task AspireSkillsBundleProvider_DoesNotUseExistingDestinationFilesToValidateArchive() { - var provider = new EmbeddedAspireSkillsBundleProvider(NullLogger.Instance); - var metadata = Assert.IsType(provider.Metadata); - Assert.NotNull(metadata.Version); - Assert.NotNull(metadata.AssetName); - Assert.NotNull(metadata.Sha512); + var rootDirectory = CreateTempDirectory(); - var extractRoot = CreateTempDirectory(); try { - // Stage the embedded archive to disk and confirm its full-file SHA-512 matches the metadata - // the installer trusts before extraction (mirrors AspireSkillsInstaller.ValidateArchiveSha512). - // The full-file archive checksum is always SHA-512, independent of the per-file manifest below. - var archivePath = Path.Combine(extractRoot, metadata.AssetName!); - await using (var archiveStream = Assert.IsAssignableFrom(provider.OpenArchive())) - await using (var fileStream = File.Create(archivePath)) - { - await archiveStream.CopyToAsync(fileStream); - } - - Assert.Equal(128, AspireSkillsBundle.NormalizeSha512(metadata.Sha512!).Length); - Assert.Equal(AspireSkillsBundle.NormalizeSha512(metadata.Sha512!), ComputeSha512(archivePath)); - - // Extract the .tgz and independently recompute every per-file hash from the internal - // skill-manifest.json. The embedded snapshot is the exact attestation-verified release asset, - // so its per-file manifest uses whatever digest that release shipped (SHA-256 for v0.0.1; - // SHA-512 once a signed SHA-512 release is re-embedded). This self-computes from whatever is - // embedded, so it stays valid across that transition. - var contentDir = Path.Combine(extractRoot, "content"); - Directory.CreateDirectory(contentDir); - await using (var fileStream = File.OpenRead(archivePath)) - await using (var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress)) - { - await TarFile.ExtractToDirectoryAsync(gzipStream, contentDir, overwriteFiles: true, CancellationToken.None); - } - - var manifestPath = Directory.EnumerateFiles(contentDir, "skill-manifest.json", SearchOption.AllDirectories).Single(); - var bundleRoot = Path.GetDirectoryName(manifestPath)!; + var archivePath = Path.Combine(rootDirectory, "bundle.tgz"); + var archiveBytes = await CreateBundleArchiveBytesAsync(includeSkillFile: false); + await File.WriteAllBytesAsync(archivePath, archiveBytes); - SkillBundleManifest? manifest; - await using (var manifestStream = File.OpenRead(manifestPath)) - { - manifest = await JsonSerializer.DeserializeAsync(manifestStream, AspireSkillsJsonSerializerContext.Default.SkillBundleManifest); - } + var bundleDirectory = Path.Combine(rootDirectory, "bundle"); + await CreateCachedBundleAsync(bundleDirectory); + var provider = new AspireSkillsBundleProvider(); - Assert.NotNull(manifest); - Assert.NotEmpty(manifest!.Skills); - - var validatedFileCount = 0; - foreach (var skill in manifest.Skills) - { - Assert.NotEmpty(skill.Files); - foreach (var file in skill.Files) - { - var filePath = Path.Combine(bundleRoot, "skills", skill.Name!, AspireSkillsBundle.NormalizeRelativePath(file.RelativePath)); - - // Verify against the digest the manifest actually carries. SHA-512 is preferred and is - // what new builds emit; SHA-256 is the accepted fallback for the pre-switch attested bundle. - if (!string.IsNullOrWhiteSpace(file.Sha512)) - { - var normalizedHash = AspireSkillsBundle.NormalizeSha512(file.Sha512); - Assert.Equal(128, normalizedHash.Length); - Assert.Equal(normalizedHash, ComputeSha512(filePath)); - } - else - { - var normalizedHash = AspireSkillsBundle.NormalizeSha256(file.Sha256!); - Assert.Equal(64, normalizedHash.Length); - Assert.Equal(normalizedHash, ComputeSha256(filePath)); - } - - validatedFileCount++; - } - } + var exception = await Assert.ThrowsAsync(() => provider.CreateAsync( + new FileInfo(archivePath), + new DirectoryInfo(bundleDirectory), + ComputeSha512(archiveBytes), + CancellationToken.None)); - Assert.True(validatedFileCount > 0); + Assert.Contains("was not found", exception.Message); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } - // Run the production loader end-to-end over the extracted bundle. Per-file verification lives in - // AspireSkillsBundle.ValidateFile, so a clean load is the same check the runtime performs before - // caching the embedded snapshot. - var bundle = await AspireSkillsBundle.LoadAsync( - new DirectoryInfo(bundleRoot), - metadata.Version!, - metadata.Version!, - skipCompatibilityCheck: true, - CancellationToken.None); + [Fact] + public async Task EmbeddedAspireSkillsBundleProvider_WhenCancelled_RemovesTemporaryArchive() + { + var rootDirectory = CreateTempDirectory(); - Assert.Equal(metadata.Version, bundle.Version); - Assert.NotEmpty(bundle.GetSkillDefinitions()); + try + { + var bundleDirectory = new DirectoryInfo(Path.Combine(rootDirectory, "bundle")); + var provider = new EmbeddedAspireSkillsBundleProvider( + new AspireSkillsBundleProvider(), + NullLogger.Instance); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync(() => provider.CreateBundleAsync( + bundleDirectory, + cancellationTokenSource.Token)); + + AssertNoTemporaryEntries(rootDirectory, "embedded"); } finally { - Directory.Delete(extractRoot, recursive: true); + Directory.Delete(rootDirectory, recursive: true); } } [Fact] - public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() + public async Task InstallAsync_WhenGitHubReleaseDigestIsMissing_DownloadsAndCachesComputedDigests() { var rootDirectory = CreateTempDirectory(); try { var archiveBytes = await CreateBundleArchiveBytesAsync(); + var archiveSha256 = ComputeSha256(archiveBytes); + var archiveSha512 = ComputeSha512(archiveBytes); Uri? releaseRequestUri = null; Uri? assetRequestUri = null; var handler = new MockHttpMessageHandler(request => @@ -312,7 +1568,9 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) { releaseRequestUri = request.RequestUri; - return CreateJsonResponse(CreateGitHubReleaseJson("aspire-skills-v0.0.1.tgz", "https://downloads.example.test/aspire-skills-v0.0.1.tgz")); + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz")); } assetRequestUri = request.RequestUri; @@ -340,11 +1598,125 @@ public async Task InstallAsync_WhenGitHubReleaseAssetIsAvailable_UsesGitHub() Assert.Equal(AspireSkillsInstaller.ExpectedWorkflowPath, attestationVerifier.ExpectedWorkflowPath); Assert.Equal(GitHubReleaseAssetBuildType, attestationVerifier.ExpectedBuildType); Assert.Equal(AspireSkillsInstaller.Version, attestationVerifier.ExpectedVersion); - Assert.False(embeddedBundleProvider.OpenArchiveCalled); + Assert.False(embeddedBundleProvider.CreateBundleCalled); Assert.NotNull(releaseRequestUri); Assert.NotNull(assetRequestUri); Assert.Contains("/microsoft/aspire-skills/releases/tags/v0.0.1", releaseRequestUri.AbsolutePath); Assert.Equal("https://downloads.example.test/aspire-skills-v0.0.1.tgz", assetRequestUri.AbsoluteUri); + var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, archiveSha512); + Assert.Equal( + archiveSha512, + await File.ReadAllTextAsync(Path.Combine( + cachedBundleDirectory, + AspireSkillsInstaller.ArchiveSha512FileName))); + Assert.Equal( + archiveSha256, + await File.ReadAllTextAsync(Path.Combine( + cachedBundleDirectory, + AspireSkillsInstaller.GitHubArchiveSha256FileName))); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenGitHubReleaseDigestDoesNotMatchDownload_UsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var archiveBytes = await CreateBundleArchiveBytesAsync(skillBody: "# Downloaded GitHub"); + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) + { + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + $"sha256:{new string('0', 64)}")); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(archiveBytes) + }; + }); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.False(attestationVerifier.VerifyCalled); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + var skill = Assert.Single(result.Bundle.GetSkillDefinitions()); + var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None)); + Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenDownloadedManifestIsMalformed_DoesNotUseVerifiedOfflineCache() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var malformedArchiveBytes = await CreateBundleArchiveBytesAsync(malformedManifest: true); + var malformedArchiveSha256 = ComputeSha256(malformedArchiveBytes); + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) + { + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + $"sha256:{malformedArchiveSha256}")); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(malformedArchiveBytes) + }; + }); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var verifiedArchiveSha512 = new string('a', 128); + await CreateCachedBundleAsync( + GetBundleCacheDirectory(executionContext, verifiedArchiveSha512), + archiveSha512: verifiedArchiveSha512, + githubAttestationVerified: true, + skillBody: "# Verified Offline"); + var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + githubArtifactAttestationVerifier: attestationVerifier, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(attestationVerifier.VerifyCalled); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + var skill = Assert.Single(result.Bundle.GetSkillDefinitions()); + var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None)); + Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal); } finally { @@ -359,12 +1731,17 @@ public async Task InstallAsync_WhenGitHubAttestationFails_FallsBackToEmbeddedBun try { - var archiveBytes = await CreateBundleArchiveBytesAsync(); + var archiveBytes = await CreateBundleArchiveBytesAsync(skillBody: "# Downloaded GitHub"); + var archiveSha256 = ComputeSha256(archiveBytes); + var archiveSha512 = ComputeSha512(archiveBytes); var handler = new MockHttpMessageHandler(request => { if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) { - return CreateJsonResponse(CreateGitHubReleaseJson("aspire-skills-v0.0.1.tgz", "https://downloads.example.test/aspire-skills-v0.0.1.tgz")); + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + $"sha256:{archiveSha256}")); } return new HttpResponseMessage(HttpStatusCode.OK) @@ -373,6 +1750,12 @@ public async Task InstallAsync_WhenGitHubAttestationFails_FallsBackToEmbeddedBun }; }); var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cachedGitHubDirectory = GetBundleCacheDirectory(executionContext, archiveSha512); + await CreateCachedBundleAsync( + cachedGitHubDirectory, + archiveSha512: archiveSha512, + githubArchiveSha256: archiveSha256, + skillBody: "# Cached GitHub"); var attestationVerifier = new TestGitHubArtifactAttestationVerifier { Result = new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.WorkflowMismatch } @@ -389,7 +1772,13 @@ public async Task InstallAsync_WhenGitHubAttestationFails_FallsBackToEmbeddedBun Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); Assert.True(attestationVerifier.VerifyCalled); - Assert.True(embeddedBundleProvider.OpenArchiveCalled); + Assert.True(embeddedBundleProvider.CreateBundleCalled); + Assert.False(File.Exists(Path.Combine( + cachedGitHubDirectory, + AspireSkillsInstaller.GitHubAttestationVerifiedFileName))); + var skill = Assert.Single(result.Bundle.GetSkillDefinitions()); + var skillFile = Assert.Single(await result.Bundle.GetSkillFilesAsync(skill, CancellationToken.None)); + Assert.Contains("# Aspire", skillFile.Content, StringComparison.Ordinal); } finally { @@ -420,7 +1809,7 @@ public async Task InstallAsync_WhenVersionOverrideDoesNotMatchEmbeddedBundle_Doe var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); - Assert.False(embeddedBundleProvider.OpenArchiveCalled); + Assert.False(embeddedBundleProvider.CreateBundleCalled); } finally { @@ -442,7 +1831,7 @@ public async Task InstallAsync_WhenEmbeddedArchiveHashDoesNotMatch_ReturnsFailur Repository = AspireSkillsInstaller.GitHubRepository, Tag = $"v{AspireSkillsInstaller.Version}", AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz", - Sha512 = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + Sha512 = new string('0', 128) }; var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); var installer = CreateInstaller( @@ -454,8 +1843,8 @@ public async Task InstallAsync_WhenEmbeddedArchiveHashDoesNotMatch_ReturnsFailur Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); Assert.NotNull(result.Message); Assert.Contains("SHA-512", result.Message, StringComparison.Ordinal); - Assert.Contains("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", result.Message, StringComparison.Ordinal); - Assert.True(embeddedBundleProvider.OpenArchiveCalled); + Assert.Contains(new string('0', 128), result.Message, StringComparison.Ordinal); + Assert.True(embeddedBundleProvider.CreateBundleCalled); } finally { @@ -488,7 +1877,7 @@ public async Task InstallAsync_WhenEmbeddedBundleSupportsRangeExcludesCurrentCli Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); - Assert.True(embeddedBundleProvider.OpenArchiveCalled); + Assert.True(embeddedBundleProvider.CreateBundleCalled); } finally { @@ -497,34 +1886,88 @@ public async Task InstallAsync_WhenEmbeddedBundleSupportsRangeExcludesCurrentCli } [Fact] - public async Task InstallAsync_WhenCachedBundleSupportsRangeExcludesCurrentCli_StillUsesCache() + public async Task InstallAsync_WhenCachedEmbeddedBundleSupportsRangeExcludesCurrentCli_StillUsesCache() { var rootDirectory = CreateTempDirectory(); try { var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); - var cachedBundleDirectory = Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills", AspireSkillsInstaller.Version); - // The cache is written by the installer itself (either from GitHub or from the - // embedded snapshot), so the bundle's `supports` range is not the right - // invalidation signal — bundle version is. A stale `supports` on a cached entry - // must not force a re-install on every invocation. + // embedded snapshot), so the bundle's `supports` range is not an invalidation + // signal. A stale `supports` on a cached entry with the expected version and + // archive digest must not force a re-install on every invocation. + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, embeddedBundleProvider.Metadata!.Sha512!); await CreateCachedBundleAsync( cachedBundleDirectory, supports: new SkillBundleSupports { AspireCli = ">=0.0.1 <0.0.2", AspireSdk = ">=0.0.1 <0.0.2" - }); - var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + }, + archiveSha512: embeddedBundleProvider.Metadata!.Sha512); var installer = CreateInstaller(executionContext, embeddedBundleProvider: embeddedBundleProvider); var result = await installer.InstallAsync(CancellationToken.None); Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); - Assert.False(embeddedBundleProvider.OpenArchiveCalled); + Assert.False(embeddedBundleProvider.CreateBundleCalled); + } + finally + { + Directory.Delete(rootDirectory, recursive: true); + } + } + + [Fact] + public async Task InstallAsync_WhenCachedGitHubBundleSupportsRangeExcludesCurrentCli_UsesEmbeddedBundle() + { + var rootDirectory = CreateTempDirectory(); + + try + { + var archiveSha256 = new string('a', 64); + var archiveSha512 = new string('a', 128); + var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); + var cachedBundleDirectory = GetBundleCacheDirectory(executionContext, archiveSha512); + await CreateCachedBundleAsync( + cachedBundleDirectory, + supports: new SkillBundleSupports + { + AspireCli = ">=99.0.0 <100.0.0", + AspireSdk = ">=99.0.0 <100.0.0" + }, + archiveSha512: archiveSha512, + githubArchiveSha256: archiveSha256, + githubAttestationVerified: true); + var assetDownloadRequested = false; + var handler = new MockHttpMessageHandler(request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/releases/tags/v0.0.1", StringComparison.Ordinal)) + { + return CreateJsonResponse(CreateGitHubReleaseJson( + "aspire-skills-v0.0.1.tgz", + "https://downloads.example.test/aspire-skills-v0.0.1.tgz", + $"sha256:{archiveSha256}")); + } + + assetDownloadRequested = true; + return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + }); + var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var installer = CreateInstaller( + executionContext, + httpMessageHandler: handler, + embeddedBundleProvider: embeddedBundleProvider); + + var result = await installer.InstallAsync(CancellationToken.None); + + Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); + Assert.NotNull(result.Bundle); + Assert.True(assetDownloadRequested); + Assert.True(embeddedBundleProvider.CreateBundleCalled); } finally { @@ -538,11 +1981,13 @@ private static AspireSkillsInstaller CreateInstaller( TestGitHubArtifactAttestationVerifier? githubArtifactAttestationVerifier = null, IConfiguration? configuration = null, IEmbeddedAspireSkillsBundleProvider? embeddedBundleProvider = null, - IFeatures? features = null) + IFeatures? features = null, + ILogger? logger = null) { return new AspireSkillsInstaller( githubArtifactAttestationVerifier ?? new TestGitHubArtifactAttestationVerifier(), new MockHttpClientFactory(httpMessageHandler ?? new MockHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound))), + new AspireSkillsBundleProvider(executionContext), embeddedBundleProvider ?? new TestEmbeddedAspireSkillsBundleProvider(), new TestInteractionService(), executionContext, @@ -552,23 +1997,29 @@ private static AspireSkillsInstaller CreateInstaller( // exercise the production default (flag off) pass an empty TestFeatures. features ?? new TestFeatures().SetFeature(KnownFeatures.AspireSkillsRemoteFetchEnabled, true), TestTelemetryHelper.CreateInitializedTelemetry(), - NullLogger.Instance); + logger ?? NullLogger.Instance); } - private static async Task CreateCachedBundleAsync(string bundleDirectory, SkillBundleSupports? supports = null) + private static async Task CreateCachedBundleAsync( + string bundleDirectory, + SkillBundleSupports? supports = null, + string? archiveSha512 = null, + string? githubArchiveSha256 = null, + bool githubAttestationVerified = false, + string skillBody = "# Aspire") { var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); Directory.CreateDirectory(skillDirectory); var skillPath = Path.Combine(skillDirectory, "SKILL.md"); await File.WriteAllTextAsync(skillPath, - """ + $$""" --- name: aspire description: "Aspire CLI commands and workflows for distributed apps" --- - # Aspire + {{skillBody}} """); var manifest = new SkillBundleManifest @@ -595,6 +2046,32 @@ await File.WriteAllTextAsync(skillPath, var manifestJson = JsonSerializer.Serialize(manifest, AspireSkillsJsonSerializerContext.Default.SkillBundleManifest); await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skill-manifest.json"), manifestJson); + + if (archiveSha512 is not null) + { + await File.WriteAllTextAsync(Path.Combine(bundleDirectory, AspireSkillsInstaller.ArchiveSha512FileName), archiveSha512); + } + + if (githubArchiveSha256 is not null) + { + await File.WriteAllTextAsync( + Path.Combine(bundleDirectory, AspireSkillsInstaller.GitHubArchiveSha256FileName), + githubArchiveSha256); + } + + if (githubAttestationVerified) + { + await File.WriteAllTextAsync( + Path.Combine(bundleDirectory, AspireSkillsInstaller.GitHubAttestationVerifiedFileName), + string.Empty); + } + } + + private static Task WriteLastUsedAsync(string cacheDirectory, DateTimeOffset lastUsed) + { + return File.WriteAllTextAsync( + Path.Combine(cacheDirectory, ".lastused"), + lastUsed.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)); } private static SkillBundleSupports CreateSupports() @@ -606,14 +2083,33 @@ private static SkillBundleSupports CreateSupports() }; } - private static async Task CreateBundleArchiveBytesAsync(SkillBundleSupports? supports = null) + private static async Task CreateBundleArchiveBytesAsync( + SkillBundleSupports? supports = null, + string skillBody = "# Aspire", + bool includeGitHubAttestationMarker = false, + bool malformedManifest = false, + bool includeSkillFile = true) { var rootDirectory = CreateTempDirectory(); try { var bundleDirectory = Path.Combine(rootDirectory, $"aspire-skills-v{AspireSkillsInstaller.Version}"); - await CreateCachedBundleAsync(bundleDirectory, supports); + await CreateCachedBundleAsync(bundleDirectory, supports, skillBody: skillBody); + if (includeGitHubAttestationMarker) + { + await File.WriteAllTextAsync( + Path.Combine(bundleDirectory, AspireSkillsInstaller.GitHubAttestationVerifiedFileName), + string.Empty); + } + if (malformedManifest) + { + await File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skill-manifest.json"), "{"); + } + if (!includeSkillFile) + { + File.Delete(Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md")); + } await using var archiveStream = new MemoryStream(); await using (var gzipStream = new GZipStream(archiveStream, CompressionLevel.SmallestSize, leaveOpen: true)) @@ -629,16 +2125,15 @@ private static async Task CreateBundleArchiveBytesAsync(SkillBundleSuppo } } - private static string ComputeSha512(string path) + private static string ComputeSha256(byte[] bytes) { - using var stream = File.OpenRead(path); - return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); + return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); } - private static string ComputeSha256(string path) + private static string ComputeSha512(string path) { using var stream = File.OpenRead(path); - return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); } private static string ComputeSha512(byte[] bytes) @@ -646,6 +2141,21 @@ private static string ComputeSha512(byte[] bytes) return Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant(); } + private static string GetVersionCacheDirectory(CliExecutionContext executionContext) + { + return Path.Combine( + executionContext.CacheDirectory.FullName, + "aspire-skills", + AspireSkillsInstaller.Version); + } + + private static string GetBundleCacheDirectory(CliExecutionContext executionContext, string archiveSha512) + { + return Path.Combine( + GetVersionCacheDirectory(executionContext), + AspireSkillsBundleProvider.NormalizeSha512(archiveSha512)); + } + private static async Task CreateEmbeddedBundleProviderAsync(SkillBundleSupports? supports = null) { var archiveBytes = await CreateBundleArchiveBytesAsync(supports); @@ -671,19 +2181,21 @@ private static HttpResponseMessage CreateJsonResponse(string json) }; } - private static string CreateGitHubReleaseJson(string assetName, string downloadUrl) + private static string CreateGitHubReleaseJson(string assetName, string downloadUrl, string? digest = null) { - return $$""" + return JsonSerializer.Serialize(new + { + tag_name = $"v{AspireSkillsInstaller.Version}", + assets = new[] { - "tag_name": "v{{AspireSkillsInstaller.Version}}", - "assets": [ + new { - "name": "{{assetName}}", - "browser_download_url": "{{downloadUrl}}" + name = assetName, + browser_download_url = downloadUrl, + digest } - ] } - """; + }); } private static string CreateTempDirectory() @@ -691,6 +2203,12 @@ private static string CreateTempDirectory() return Directory.CreateTempSubdirectory("aspire-skills-installer-test-").FullName; } + private static void AssertNoTemporaryEntries(string rootDirectory, string prefix) + { + Assert.Empty(Directory.GetDirectories(rootDirectory, $".{prefix}-*")); + Assert.Empty(Directory.GetFiles(rootDirectory, $".{prefix}-*.lock")); + } + private sealed class TestGitHubArtifactAttestationVerifier : IGitHubArtifactAttestationVerifier { public bool VerifyCalled { get; private set; } @@ -711,7 +2229,11 @@ private sealed class TestGitHubArtifactAttestationVerifier : IGitHubArtifactAtte Provenance = new NpmProvenanceData { SourceRepository = AspireSkillsInstaller.ExpectedSourceRepository } }; - public Task VerifyAsync( + public TaskCompletionSource? VerificationStarted { get; init; } + + public Task? VerificationGate { get; init; } + + public async Task VerifyAsync( string repository, string artifactPath, string expectedSourceRepository, @@ -727,22 +2249,92 @@ public Task VerifyAsync( ExpectedBuildType = expectedBuildType; ExpectedVersion = expectedVersion; - return Task.FromResult(Result); + VerificationStarted?.TrySetResult(); + if (VerificationGate is not null) + { + await VerificationGate.WaitAsync(cancellationToken); + } + + return Result; } } private sealed class TestEmbeddedAspireSkillsBundleProvider : IEmbeddedAspireSkillsBundleProvider { + private readonly IAspireSkillsBundleProvider _bundleProvider = new AspireSkillsBundleProvider(); + public EmbeddedAspireSkillsBundleMetadata? Metadata { get; set; } public byte[]? ArchiveBytes { get; init; } - public bool OpenArchiveCalled { get; private set; } + public bool CreateBundleCalled { get; private set; } + + public async Task CreateBundleAsync( + DirectoryInfo bundleDirectory, + CancellationToken cancellationToken) + { + CreateBundleCalled = true; + if (ArchiveBytes is null || string.IsNullOrWhiteSpace(Metadata?.Sha512)) + { + return null; + } + + Directory.CreateDirectory(bundleDirectory.FullName); + var archivePath = Path.Combine(bundleDirectory.FullName, $".test-embedded-{Guid.NewGuid():N}.tgz"); + try + { + await File.WriteAllBytesAsync(archivePath, ArchiveBytes, cancellationToken); + return await _bundleProvider.CreateAsync( + new FileInfo(archivePath), + bundleDirectory, + Metadata.Sha512, + cancellationToken, + skipCompatibilityCheck: true); + } + finally + { + File.Delete(archivePath); + } + } + } + + private sealed class ArchiveLockingAspireSkillsBundleProvider(IAspireSkillsBundleProvider inner) : IAspireSkillsBundleProvider, IDisposable + { + private FileStream? _archiveLock; + + public async Task CreateAsync( + FileInfo archive, + DirectoryInfo bundleDirectory, + string expectedArchiveSha512, + CancellationToken cancellationToken, + bool skipCompatibilityCheck = false) + { + _archiveLock = new FileStream( + archive.FullName, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + + return await inner.CreateAsync( + archive, + bundleDirectory, + expectedArchiveSha512, + cancellationToken, + skipCompatibilityCheck); + } + + public Task LoadAsync( + DirectoryInfo bundleDirectory, + CancellationToken cancellationToken, + bool skipCompatibilityCheck = false) + { + return inner.LoadAsync(bundleDirectory, cancellationToken, skipCompatibilityCheck); + } - public Stream? OpenArchive() + public void Dispose() { - OpenArchiveCalled = true; - return ArchiveBytes is null ? null : new MemoryStream(ArchiveBytes, writable: false); + _archiveLock?.Dispose(); } } + } diff --git a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs index 383a39e3af0..2c48e51da72 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/AppHostConnectionResolverTests.cs @@ -324,6 +324,156 @@ public async Task ResolveConnectionAsync_WithSymlinkedProjectPath_ResolvesToCano Assert.False(File.Exists(socketPath)); } + [Fact] + public async Task ResolveConnectionAsync_RestrictedToWorktreeWithAppHostOutsideWorkingDirectory_PromptsForSelection() + { + // `aspire stop` restricts selection to the current worktree, but an AppHost that merely + // lives outside the working directory (running `aspire stop` from repo/tests while the + // AppHost is at repo/AppHost) is still in that worktree and must stay selectable. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".git")); + var workingDirectory = workspace.WorkspaceRoot.CreateSubdirectory("tests"); + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + workingDirectory, + homeDirectory: workspace.WorkspaceRoot); + + var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj"); + var connection = CreateOutOfScopeConnection(appHostPath); + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash1", "socket-sibling", connection); + var interactionService = new TestInteractionService(); + + var resolver = new AppHostConnectionResolver( + monitor, + interactionService, + new TestProjectLocator(), + executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), + NullLogger.Instance, + new ProfilingTelemetry(new ConfigurationBuilder().Build())); + + var result = await resolver.ResolveConnectionAsync( + projectFile: null, + "Scanning", + "Select", + SharedCommandStrings.AppHostNotRunning, + TestContext.Current.CancellationToken, + restrictToCurrentWorktree: true); + + Assert.True(result.Success); + Assert.Same(connection, result.Connection); + Assert.Equal(SharedCommandStrings.NoInScopeAppHostsShowingAll, Assert.Single(interactionService.DisplayedMessages).Message); + } + + [Fact] + public async Task ResolveConnectionAsync_RestrictedToWorktreeWithAppHostInDifferentWorktree_ReturnsWorktreeSpecificError() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostPath = CreateLinkedWorktreeAppHostPath(workspace.WorkspaceRoot); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash1", "socket-worktree", CreateOutOfScopeConnection(appHostPath)); + var interactionService = new TestInteractionService + { + PromptForSelectionCallback = (_, _, _, _) => + throw new InvalidOperationException("AppHosts in another worktree must not be offered when selection is restricted.") + }; + + var resolver = new AppHostConnectionResolver( + monitor, + interactionService, + new TestProjectLocator(), + executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), + NullLogger.Instance, + new ProfilingTelemetry(new ConfigurationBuilder().Build())); + + var result = await resolver.ResolveConnectionAsync( + projectFile: null, + "Scanning", + "Select", + SharedCommandStrings.AppHostNotRunning, + TestContext.Current.CancellationToken, + restrictToCurrentWorktree: true); + + Assert.False(result.Success); + // "Use 'aspire run' to start one first" would be wrong: an AppHost is running, just not here. + Assert.Equal(SharedCommandStrings.AppHostNotRunningInCurrentWorktree, result.ErrorMessage); + Assert.Equal(CliExitCodes.FailedToFindProject, result.ExitCode); + } + + [Fact] + public async Task ResolveConnectionAsync_UnrestrictedWithAppHostInDifferentWorktree_PromptsForSelection() + { + // Only `aspire stop` opts into the worktree restriction; every other connection command + // keeps offering whatever is running. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostPath = CreateLinkedWorktreeAppHostPath(workspace.WorkspaceRoot); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + + var connection = CreateOutOfScopeConnection(appHostPath); + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash1", "socket-worktree", connection); + + var resolver = new AppHostConnectionResolver( + monitor, + new TestInteractionService(), + new TestProjectLocator(), + executionContext, + TestHelpers.CreateInteractiveHostEnvironment(), + NullLogger.Instance, + new ProfilingTelemetry(new ConfigurationBuilder().Build())); + + var result = await resolver.ResolveConnectionAsync( + projectFile: null, + "Scanning", + "Select", + SharedCommandStrings.AppHostNotRunning, + TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Same(connection, result.Connection); + } + + [Fact] + public async Task ResolveConnectionAsync_NonInteractiveRestrictedToWorktree_ReturnsNotFoundError() + { + // A host that cannot prompt gets the same not-found answer it always did, even when the + // AppHost would have been selectable interactively. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".git")); + var workingDirectory = workspace.WorkspaceRoot.CreateSubdirectory("tests"); + var executionContext = TestExecutionContextHelper.CreateExecutionContext( + workingDirectory, + homeDirectory: workspace.WorkspaceRoot); + + var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj"); + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash1", "socket-sibling", CreateOutOfScopeConnection(appHostPath)); + + var resolver = new AppHostConnectionResolver( + monitor, + new TestInteractionService(), + new TestProjectLocator(), + executionContext, + TestHelpers.CreateNonInteractiveHostEnvironment(), + NullLogger.Instance, + new ProfilingTelemetry(new ConfigurationBuilder().Build())); + + var result = await resolver.ResolveConnectionAsync( + projectFile: null, + "Scanning", + "Select", + SharedCommandStrings.AppHostNotRunning, + TestContext.Current.CancellationToken, + restrictToCurrentWorktree: true); + + Assert.False(result.Success); + Assert.Equal(SharedCommandStrings.AppHostNotRunning, result.ErrorMessage); + Assert.Equal(CliExitCodes.FailedToFindProject, result.ExitCode); + } + private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory) { return TestExecutionContextHelper.CreateExecutionContext( @@ -331,6 +481,33 @@ private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingD homeDirectory: workingDirectory); } + private static TestAppHostAuxiliaryBackchannel CreateOutOfScopeConnection(string appHostPath) + { + return new TestAppHostAuxiliaryBackchannel + { + IsInScope = false, + AppHostInfo = new AppHostInformation + { + AppHostPath = appHostPath, + ProcessId = Environment.ProcessId + } + }; + } + + /// + /// Creates a linked worktree nested under and returns an + /// AppHost path inside it. The primary checkout gets a real .git directory so the + /// ancestor walk stops there instead of escaping into whatever repo owns the temp directory. + /// + private static string CreateLinkedWorktreeAppHostPath(DirectoryInfo primaryCheckout) + { + var commonGitDirectory = Path.Combine(primaryCheckout.FullName, ".git"); + Directory.CreateDirectory(commonGitDirectory); + var worktreeRoot = Directory.CreateDirectory(Path.Combine(primaryCheckout.FullName, ".worktrees", "feature")).FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, commonGitDirectory); + return Path.Combine(worktreeRoot, "AppHost", "AppHost.csproj"); + } + private static FileInfo CreateProjectFile(DirectoryInfo workingDirectory, string directoryName, string fileName) { var directory = workingDirectory.CreateSubdirectory(directoryName); diff --git a/tests/Aspire.Cli.Tests/Backchannel/AuxiliaryBackchannelMonitorTests.cs b/tests/Aspire.Cli.Tests/Backchannel/AuxiliaryBackchannelMonitorTests.cs index 645eb6eeaea..f2879c21bab 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/AuxiliaryBackchannelMonitorTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/AuxiliaryBackchannelMonitorTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Backchannel; +using Aspire.Cli.Tests.TestServices; namespace Aspire.Cli.Tests.Backchannel; @@ -61,4 +62,74 @@ public void IsAppHostInScopeOfDirectory_NullOrEmptyAppHostPath_IsNotInScope() Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(null, Path.GetTempPath())); Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(string.Empty, Path.GetTempPath())); } + + [Fact] + public void IsAppHostInScopeOfDirectory_NestedLinkedWorktree_IsNotInScopeOfPrimary() + { + var tempRoot = Directory.CreateTempSubdirectory("aspire-scope-worktree-"); + try + { + var primaryRoot = tempRoot.FullName; + Directory.CreateDirectory(Path.Combine(primaryRoot, ".git")); + var worktreeRoot = Directory.CreateDirectory(Path.Combine(primaryRoot, ".worktrees", "feature")).FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, Path.Combine(primaryRoot, ".git")); + + var primaryAppHost = Path.Combine(primaryRoot, "AppHost.csproj"); + var nestedAppHost = Path.Combine(worktreeRoot, "AppHost.csproj"); + + Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(primaryAppHost, primaryRoot)); + Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(nestedAppHost, primaryRoot)); + Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(nestedAppHost, worktreeRoot)); + Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(primaryAppHost, worktreeRoot)); + } + finally + { + tempRoot.Delete(recursive: true); + } + } + + [Fact] + public void IsAppHostInScopeOfDirectory_Submodule_IsInScopeOfPrimary() + { + var tempRoot = Directory.CreateTempSubdirectory("aspire-scope-submodule-"); + try + { + var primaryRoot = tempRoot.FullName; + Directory.CreateDirectory(Path.Combine(primaryRoot, ".git")); + var submoduleRoot = Directory.CreateDirectory(Path.Combine(primaryRoot, "extern", "dep")).FullName; + TestGitWorktree.WriteGitDirFile( + submoduleRoot, + Path.Combine(primaryRoot, ".git", "modules", "dep")); + + var submoduleAppHost = Path.Combine(submoduleRoot, "AppHost.csproj"); + Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(submoduleAppHost, primaryRoot)); + } + finally + { + tempRoot.Delete(recursive: true); + } + } + + [Fact] + public void IsAppHostInScopeOfDirectory_SubmoduleInsideLinkedWorktree_UsesEnclosingWorktree() + { + var tempRoot = Directory.CreateTempSubdirectory("aspire-scope-linked-submodule-"); + try + { + var primaryRoot = tempRoot.FullName; + Directory.CreateDirectory(Path.Combine(primaryRoot, ".git")); + var worktreeRoot = Directory.CreateDirectory(Path.Combine(primaryRoot, ".worktrees", "feature")).FullName; + var adminDirectory = TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, Path.Combine(primaryRoot, ".git")); + var submoduleRoot = Directory.CreateDirectory(Path.Combine(worktreeRoot, "extern", "dep")).FullName; + TestGitWorktree.WriteGitDirFile(submoduleRoot, Path.Combine(adminDirectory, "modules", "dep")); + var submoduleAppHost = Path.Combine(submoduleRoot, "AppHost.csproj"); + + Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(submoduleAppHost, worktreeRoot)); + Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(submoduleAppHost, primaryRoot)); + } + finally + { + tempRoot.Delete(recursive: true); + } + } } diff --git a/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs b/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs index 4f2bd82c881..152ac09b0d8 100644 --- a/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs @@ -24,7 +24,7 @@ public class BundleServiceIntegrationTests(ITestOutputHelper outputHelper) /// /// Verifies that a fresh extraction with a synthetic payload creates a /// versioned directory, writes a version marker, and establishes reparse - /// points for managed/ and dcp/. + /// point access to managed/, dashboard/, and dcp/. /// [Fact] public async Task ExtractAsync_FreshExtraction_CreatesVersionedLayoutAndLinks() @@ -63,6 +63,12 @@ public async Task ExtractAsync_FreshExtraction_CreatesVersionedLayoutAndLinks() BundleDiscovery.ManagedDirectoryName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); Assert.True(File.Exists(managedExe), $"managed exe should exist at {managedExe}"); + + var dashboardExe = Path.Combine(bundleLink, + BundleDiscovery.DashboardDirectoryName, + BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + Assert.True(File.Exists(dashboardExe), $"Dashboard exe should exist at {dashboardExe}"); + Assert.True(File.Exists(Path.Combine(bundleLink, BundleDiscovery.DashboardDirectoryName, "wwwroot", "index.html"))); } finally { @@ -564,6 +570,24 @@ internal static byte[] CreateFakeBundlePayload(string contentMarker = "fake-bund }; tar.WriteEntry(managedEntry); + // dashboard/ directory, executable, and static assets. + tar.WriteEntry(new PaxTarEntry(TarEntryType.Directory, $"aspire-payload/{BundleDiscovery.DashboardDirectoryName}/")); + var dashboardEntry = new PaxTarEntry( + TarEntryType.RegularFile, + $"aspire-payload/{BundleDiscovery.DashboardDirectoryName}/{BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)}") + { + DataStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("native-dashboard")) + }; + tar.WriteEntry(dashboardEntry); + tar.WriteEntry(new PaxTarEntry(TarEntryType.Directory, $"aspire-payload/{BundleDiscovery.DashboardDirectoryName}/wwwroot/")); + var dashboardAssetEntry = new PaxTarEntry( + TarEntryType.RegularFile, + $"aspire-payload/{BundleDiscovery.DashboardDirectoryName}/wwwroot/index.html") + { + DataStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("dashboard-static-asset")) + }; + tar.WriteEntry(dashboardAssetEntry); + // dcp/ directory and platform executable. tar.WriteEntry(new PaxTarEntry(TarEntryType.Directory, $"aspire-payload/{BundleDiscovery.DcpDirectoryName}/")); @@ -642,6 +666,7 @@ private sealed class TestLayoutDiscovery(string layoutRoot) : ILayoutDiscovery Components = new LayoutComponents { Managed = Path.Combine(BundleDiscovery.BundleDirectoryName, BundleDiscovery.ManagedDirectoryName), + Dashboard = Path.Combine(BundleDiscovery.BundleDirectoryName, BundleDiscovery.DashboardDirectoryName), Dcp = Path.Combine(BundleDiscovery.BundleDirectoryName, BundleDiscovery.DcpDirectoryName), } }; @@ -654,6 +679,7 @@ private sealed class TestLayoutDiscovery(string layoutRoot) : ILayoutDiscovery { LayoutComponent.Managed => Path.Combine(bundleDir, BundleDiscovery.ManagedDirectoryName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), + LayoutComponent.Dashboard => Path.Combine(bundleDir, BundleDiscovery.DashboardDirectoryName), LayoutComponent.Dcp => Path.Combine(bundleDir, BundleDiscovery.DcpDirectoryName), _ => null, }; diff --git a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs index 09cc1eb448a..0a23de995e1 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs @@ -883,8 +883,11 @@ await File.WriteAllTextAsync(skillPath, $$""" }; var manifestJson = JsonSerializer.Serialize(manifest, AspireSkillsJsonSerializerContext.Default.SkillBundleManifest); - await File.WriteAllTextAsync(Path.Combine(bundleDirectory.FullName, "skill-manifest.json"), manifestJson); - return await AspireSkillsBundle.LoadAsync(bundleDirectory, CancellationToken.None); + var manifestPath = Path.Combine(bundleDirectory.FullName, "skill-manifest.json"); + await File.WriteAllTextAsync(manifestPath, manifestJson); + return await new AspireSkillsBundleProvider().LoadAsync( + bundleDirectory, + CancellationToken.None); } private static string ComputeSha512(string path) diff --git a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs index c947ab9e44a..82a8ec0a620 100644 --- a/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AppHostLauncherTests.cs @@ -9,6 +9,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.Layout; using Aspire.Cli.Processes; +using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Tests; @@ -207,6 +208,159 @@ public async Task LaunchDetachedAsync_WaitsForReadinessRpcBeforeReportingSuccess Assert.Empty(harness.InteractionService.DisplayedErrors); } + [Theory] + [InlineData(false, null, false, "default-discovery")] + [InlineData(false, "", false, "default-discovery")] + [InlineData(true, null, false, null)] + [InlineData(true, "", false, null)] + [InlineData(false, "user-selection", false, "user-selection")] + [InlineData(true, "explicit-launch-configuration", false, "explicit-launch-configuration")] + [InlineData(true, null, true, "user-selection")] + [InlineData(true, "explicit-cli", true, "user-selection")] + public async Task LaunchDetachedAsync_PropagatesPersistentSelectionOriginToChild( + bool passedAppHost, + string? inheritedOrigin, + bool selectionWasPrompted, + string? expectedOrigin) + { + using var harness = AppHostLauncherHarness.Create(outputHelper, inheritedOrigin, selectionWasPrompted); + harness.AddConnection(new TestAppHostAuxiliaryBackchannel + { + SupportsV3 = true, + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" }, + WaitForAppHostReadyHandler = _ => Task.FromResult(new WaitForAppHostReadyResponse { IsReady = true }) + }); + + var result = await harness.Launcher.LaunchDetachedAsync( + passedAppHost ? harness.AppHostFile : null, + format: null, + isolated: false, + isExtensionHost: false, + waitForDebugger: false, + timeoutSeconds: 120, + globalArgs: [], + additionalArgs: [], + stopAfterLaunchDelay: null, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, result.ExitCode); + var hasOrigin = harness.ProcessFactory.CreatedEnvironmentVariables.TryGetValue( + KnownConfigNames.CliAppHostSelectionOrigin, + out var actualOrigin); + Assert.Equal(expectedOrigin is not null, hasOrigin); + Assert.Equal(expectedOrigin, actualOrigin); + } + + [Fact] + public async Task LaunchDetachedAsync_ExplicitFalseFromLinkedWorktree_ForwardsFalse() + { + using var harness = AppHostLauncherHarness.Create(outputHelper); + harness.SetLinkedWorktree(); + harness.AddConnection(new TestAppHostAuxiliaryBackchannel + { + SupportsV3 = true, + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" }, + WaitForAppHostReadyHandler = _ => Task.FromResult(new WaitForAppHostReadyResponse { IsReady = true }) + }); + + var result = await harness.Launcher.LaunchDetachedAsync( + harness.AppHostFile, + format: null, + isolated: false, + isExtensionHost: false, + waitForDebugger: false, + timeoutSeconds: 120, + globalArgs: [], + additionalArgs: [], + stopAfterLaunchDelay: null, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, result.ExitCode); + Assert.Equal(["--isolated", "false"], harness.ProcessFactory.CreatedArguments!.TakeLast(2)); + } + + [Fact] + public async Task LaunchDetachedAsync_OmittedFromLinkedWorktree_DoesNotInferIsolation() + { + using var harness = AppHostLauncherHarness.Create(outputHelper); + harness.SetLinkedWorktree(); + harness.AddConnection(new TestAppHostAuxiliaryBackchannel + { + SupportsV3 = true, + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" }, + WaitForAppHostReadyHandler = _ => Task.FromResult(new WaitForAppHostReadyResponse { IsReady = true }) + }); + + var result = await harness.Launcher.LaunchDetachedAsync( + harness.AppHostFile, + format: null, + isolated: null, + isExtensionHost: false, + waitForDebugger: false, + timeoutSeconds: 120, + globalArgs: [], + additionalArgs: [], + stopAfterLaunchDelay: null, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, result.ExitCode); + Assert.Equal(0, harness.ProcessFactory.CreatedArguments!.Count(argument => argument == "--isolated")); + } + + [Fact] + public async Task LaunchDetachedAsync_ExplicitTrue_ForwardsFlag() + { + using var harness = AppHostLauncherHarness.Create(outputHelper); + harness.AddConnection(new TestAppHostAuxiliaryBackchannel + { + SupportsV3 = true, + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" }, + WaitForAppHostReadyHandler = _ => Task.FromResult(new WaitForAppHostReadyResponse { IsReady = true }) + }); + + var result = await harness.Launcher.LaunchDetachedAsync( + harness.AppHostFile, + format: null, + isolated: true, + isExtensionHost: false, + waitForDebugger: false, + timeoutSeconds: 120, + globalArgs: [], + additionalArgs: [], + stopAfterLaunchDelay: null, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, result.ExitCode); + Assert.Equal("--isolated", harness.ProcessFactory.CreatedArguments![^1]); + } + + [Fact] + public async Task LaunchDetachedAsync_OmittedFromPrimaryCheckout_DoesNotForwardOption() + { + using var harness = AppHostLauncherHarness.Create(outputHelper); + harness.AddConnection(new TestAppHostAuxiliaryBackchannel + { + SupportsV3 = true, + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "https://localhost:18888/login?t=test" }, + WaitForAppHostReadyHandler = _ => Task.FromResult(new WaitForAppHostReadyResponse { IsReady = true }) + }); + + var result = await harness.Launcher.LaunchDetachedAsync( + harness.AppHostFile, + format: null, + isolated: null, + isExtensionHost: false, + waitForDebugger: false, + timeoutSeconds: 120, + globalArgs: [], + additionalArgs: [], + stopAfterLaunchDelay: null, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, result.ExitCode); + Assert.Equal(0, harness.ProcessFactory.CreatedArguments!.Count(argument => argument == "--isolated")); + } + [Fact] public async Task LaunchDetachedAsync_DeletesDeadPidSocketBeforeStartingChildProcess() { @@ -974,7 +1128,18 @@ private AppHostLauncherHarness( public TestDetachedProcessFactory ProcessFactory { get; } - public static AppHostLauncherHarness Create(ITestOutputHelper outputHelper) + public static AppHostLauncherHarness Create(ITestOutputHelper outputHelper) => + Create(outputHelper, appHostSelectionOrigin: null); + + public static AppHostLauncherHarness Create(ITestOutputHelper outputHelper, string? appHostSelectionOrigin) + { + return Create(outputHelper, appHostSelectionOrigin, selectionWasPrompted: false); + } + + public static AppHostLauncherHarness Create( + ITestOutputHelper outputHelper, + string? appHostSelectionOrigin, + bool selectionWasPrompted) { var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var homeDirectory = workspace.WorkspaceRoot.CreateSubdirectory("home"); @@ -1005,8 +1170,16 @@ public static AppHostLauncherHarness Create(ITestOutputHelper outputHelper) executionContext, new TestEnvironment(), NullLogger.Instance, TimeProvider.System); + var projectLocator = new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]) + { + WasExplicitDirectorySelectionPrompted = selectionWasPrompted + }) + }; var launcher = new AppHostLauncher( - new TestProjectLocator(), + projectLocator, executionContext, interactionService, monitor, @@ -1016,7 +1189,7 @@ public static AppHostLauncherHarness Create(ITestOutputHelper outputHelper) fileLoggerProvider, processShutdownService, processFactory, - new ConfigurationBuilder().Build(), + CreateConfiguration((KnownConfigNames.CliAppHostSelectionOrigin, appHostSelectionOrigin)), NullLogger.Instance, TimeProvider.System); @@ -1046,6 +1219,13 @@ public void AddConnection(TestAppHostAuxiliaryBackchannel connection) Monitor.AddConnection(hash, $"{socketPrefix}.sock", connection); } + public void SetLinkedWorktree() + { + TestGitWorktree.WriteLinkedWorktreeMetadata( + _workspace.WorkspaceRoot.FullName, + Path.Combine(_workspace.WorkspaceRoot.FullName, "common", ".git")); + } + public string CreateMatchingSocketFile(int pid) { var backchannelsDir = Path.Combine(_homeDirectory.FullName, ".aspire", "cli", "bch"); @@ -1087,8 +1267,13 @@ public enum ChildProcessMode private TestDetachedProcessExecution? CreatedExecution { get; set; } + public IReadOnlyList? CreatedArguments => CreatedExecution?.Arguments; + public int CreatedExecutionDisposeCount => CreatedExecution?.DisposeCount ?? 0; + public IReadOnlyDictionary CreatedEnvironmentVariables => + CreatedExecution?.EnvironmentVariables ?? throw new InvalidOperationException("Expected a detached process execution."); + public Func, string, Func?, IReadOnlyDictionary?, CancellationToken, Task>? StartHandler { get; set; } public void StopStartedProcess() diff --git a/tests/Aspire.Cli.Tests/Commands/AppHostStartupTimeoutTests.cs b/tests/Aspire.Cli.Tests/Commands/AppHostStartupTimeoutTests.cs new file mode 100644 index 00000000000..ffb2bcae61b --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/AppHostStartupTimeoutTests.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Commands; +using Aspire.Hosting; +using Microsoft.Extensions.Configuration; + +namespace Aspire.Cli.Tests.Commands; + +public class AppHostStartupTimeoutTests +{ + [Fact] + public void BackchannelConnectionTimeoutUsesLongerConfiguredAppHostStartupTimeout() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [CliConfigNames.AppHostStartupTimeout] = "86400" + }) + .Build(); + + var timeout = AppHostStartupTimeout.GetBackchannelConnectionTimeout(configuration); + + Assert.Equal(TimeSpan.FromSeconds(86400), timeout); + } + + [Fact] + public void BackchannelConnectionTimeoutUsesDefaultAppHostStartupTimeout() + { + var configuration = new ConfigurationBuilder().Build(); + + var timeout = AppHostStartupTimeout.GetBackchannelConnectionTimeout(configuration); + + Assert.Equal(TimeSpan.FromSeconds(120), timeout); + } + + [Fact] + public void ExplicitBackchannelConnectionTimeoutOverridesAppHostStartupTimeout() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [CliConfigNames.AppHostStartupTimeout] = "180", + [KnownConfigNames.CliBackchannelConnectTimeoutSeconds] = "5" + }) + .Build(); + + var timeout = AppHostStartupTimeout.GetBackchannelConnectionTimeout(configuration); + + Assert.Equal(TimeSpan.FromSeconds(5), timeout); + } + + [Theory] + [InlineData("Infinity")] + [InlineData("1e30")] + public void InvalidExplicitBackchannelConnectionTimeoutUsesAppHostStartupTimeout(string configuredValue) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [CliConfigNames.AppHostStartupTimeout] = "180", + [KnownConfigNames.CliBackchannelConnectTimeoutSeconds] = configuredValue + }) + .Build(); + + var timeout = AppHostStartupTimeout.GetBackchannelConnectionTimeout(configuration); + + Assert.Equal(TimeSpan.FromSeconds(180), timeout); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs index 5254acba1e3..cd356189f5c 100644 --- a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs @@ -25,6 +25,12 @@ public void ConfigInfo_AdvertisesLsJsonStream() Assert.Contains(KnownCapabilities.LsJsonStream, KnownCapabilities.GetAdvertisedCapabilities()); } + [Fact] + public void ConfigInfo_AdvertisesIsolatedLaunch() + { + Assert.Contains(KnownCapabilities.IsolatedLaunch, KnownCapabilities.GetAdvertisedCapabilities()); + } + [Fact] public void ConfigInfoJson_UsesCamelCasePropertyNames() { diff --git a/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs index cae8f2a1d40..b89d6299e56 100644 --- a/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs @@ -147,6 +147,29 @@ public async Task DashboardRunCommand_DefaultOptions_DoesNotEmitAllowAnonymous() Assert.DoesNotContain(capturedArgs, arg => arg.Contains("ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS")); } + [Fact] + public async Task DashboardRunCommand_NativeDashboard_DoesNotUseManagedSubcommand() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + string[]? capturedArgs = null; + var (services, dashboardPath, executionFactory) = CreateServicesWithLayout(workspace, useNativeDashboard: true); + executionFactory.AssertionCallback = (args, _, _, _) => { capturedArgs = args; }; + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard run"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotNull(capturedArgs); + Assert.DoesNotContain("dashboard", capturedArgs); + Assert.Equal( + BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName), + Path.GetFileName(dashboardPath)); + } + [Fact] public async Task DashboardRunCommand_BundleAvailableWithinDelay_DoesNotDisplayBundleStatus() { @@ -594,21 +617,33 @@ public void RenderDashboardSummary_RendersLogsPathAsClickableFileLink() TerminalLinkAssert.ContainsLink(outputString, fileUri, logFilePath); } - private (IServiceCollection Services, string ManagedPath, TestProcessExecutionFactory ExecutionFactory) CreateServicesWithLayout( + private (IServiceCollection Services, string ExecutablePath, TestProcessExecutionFactory ExecutionFactory) CreateServicesWithLayout( TemporaryWorkspace workspace, TestInteractionService? interactionService = null, - TestBundleService? bundleService = null) + TestBundleService? bundleService = null, + bool useNativeDashboard = false) { var layoutDir = Path.Combine(workspace.WorkspaceRoot.FullName, "layout"); var managedDir = Path.Combine(layoutDir, "managed"); Directory.CreateDirectory(managedDir); var managedPath = Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName("aspire-managed")); File.WriteAllText(managedPath, "fake"); + var dashboardDir = Path.Combine(layoutDir, BundleDiscovery.DashboardDirectoryName); + var dashboardPath = Path.Combine(dashboardDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + if (useNativeDashboard) + { + Directory.CreateDirectory(dashboardDir); + File.WriteAllText(dashboardPath, "fake native dashboard"); + } var layout = new LayoutConfiguration { LayoutPath = layoutDir, - Components = new LayoutComponents { Managed = "managed" } + Components = new LayoutComponents + { + Dashboard = BundleDiscovery.DashboardDirectoryName, + Managed = BundleDiscovery.ManagedDirectoryName + } }; bundleService ??= new TestBundleService(isBundle: true); bundleService.Layout = layout; @@ -629,7 +664,7 @@ public void RenderDashboardSummary_RendersLogsPathAsClickableFileLink() } }); - return (services, managedPath, executionFactory); + return (services, useNativeDashboard ? dashboardPath : managedPath, executionFactory); } private static IEnvironment CreateEnvironment(Dictionary envVars) diff --git a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs index aded5fbe535..83cfb3e0503 100644 --- a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; using Aspire.Cli.Interaction; +using Aspire.Cli.Projects; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Tests.TestServices; using Microsoft.Extensions.DependencyInjection; @@ -68,6 +69,86 @@ public async Task DeployCommandInExtensionForwardsResolvedAspireHome() capturedOptions.EnvironmentVariables); } + [Theory] + [InlineData("deploy", "deploy", null, false, "default-discovery")] + [InlineData("deploy", "deploy", null, true, "explicit-cli")] + [InlineData("publish", "publish", null, false, "default-discovery")] + [InlineData("publish", "publish", null, true, "explicit-cli")] + [InlineData("do", "do deploy", "deploy", false, "default-discovery")] + [InlineData("do", "do deploy", "deploy", true, "explicit-cli")] + public async Task PipelineCommands_WhenDelegatingToExtension_CarryAppHostSelectionOrigin( + string commandName, + string commandText, + string? expectedCommandArg, + bool explicitAppHost, + string expectedOrigin) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, ""); + var expectedAspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, ".home", ".aspire"); + + string? workingDirectory = null; + string? projectFile = null; + bool? debug = null; + DebugSessionOptions? capturedOptions = null; + + var projectLocator = new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (passedProjectFile, _, _, _) => + { + var selectedProjectFile = passedProjectFile ?? appHostFile; + return Task.FromResult(new AppHostProjectSearchResult(selectedProjectFile, [selectedProjectFile])); + } + }; + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => projectLocator; + options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel(); + options.InteractionServiceFactory = sp => + { + var interactionService = new TestExtensionInteractionService(sp); + interactionService.StartDebugSessionCallback = (capturedWorkingDirectory, capturedProjectFile, capturedDebug, debugSessionOptions) => + { + workingDirectory = capturedWorkingDirectory; + projectFile = capturedProjectFile; + debug = capturedDebug; + capturedOptions = debugSessionOptions; + }; + return interactionService; + }; + }); + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var invocation = explicitAppHost ? $"{commandText} --apphost \"{appHostFile.FullName}\"" : commandText; + + var result = command.Parse(invocation); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Equal(workspace.WorkspaceRoot.FullName, workingDirectory); + Assert.Equal(appHostFile.FullName, projectFile); + Assert.True(debug); + Assert.NotNull(capturedOptions); + Assert.Equal(commandName, capturedOptions.Command); + if (expectedCommandArg is null) + { + Assert.Null(capturedOptions.Args); + } + else + { + Assert.NotNull(capturedOptions.Args); + Assert.Equal([expectedCommandArg], capturedOptions.Args); + } + Assert.Equal( + new Dictionary + { + [KnownConfigNames.AspireHome] = expectedAspireHome + }, + capturedOptions.EnvironmentVariables); + Assert.Equal(expectedOrigin, capturedOptions.AppHostSelectionOrigin); + } + [Fact] public async Task DeployCommandFailsWithInvalidProjectFile() { diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs index 3bcf863f8b1..b74aea09084 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs @@ -82,6 +82,7 @@ public class NewCommandTemplateConfigPersistenceTests(ITestOutputHelper outputHe PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.RustEmptyAppHost, ["--localhost-tld", "false"]), PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.PythonStarter, ["--localhost-tld", "false", "--use-redis-cache", "false"]), PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.GoStarter, ["--localhost-tld", "false"]), + PrDogfoodNewTemplateCase.CliConfig(KnownTemplateId.JavaStarter, ["--localhost-tld", "false"]), PrDogfoodNewTemplateCase.DotNet("aspire-starter", ["--localhost-tld", "false", "--use-redis-cache", "false"]), PrDogfoodNewTemplateCase.DotNet("aspire-ts-cs-starter", ["--localhost-tld", "false", "--use-redis-cache", "false"]), PrDogfoodNewTemplateCase.DotNet(KnownTemplateId.DotNetEmptyAppHost, ["--localhost-tld", "false"]), @@ -123,6 +124,7 @@ public static TheoryData PrDogfoodNewTemplateCases() [InlineData(KnownTemplateId.TypeScriptStarter, "apphost.mts")] [InlineData(KnownTemplateId.PythonStarter, "apphost.mts")] [InlineData(KnownTemplateId.GoStarter, "apphost.go")] + [InlineData(KnownTemplateId.JavaStarter, "AppHost.java")] public async Task ChannelPinningTemplate_IdentityNotRegistered_DoesNotPinChannel(string templateId, string _) { var persisted = await ScaffoldAndReadPersistedChannelAsync( @@ -150,6 +152,7 @@ public async Task ChannelPinningTemplate_IdentityNotRegistered_DoesNotPinChannel [InlineData(KnownTemplateId.TypeScriptStarter, "apphost.mts")] [InlineData(KnownTemplateId.PythonStarter, "apphost.mts")] [InlineData(KnownTemplateId.GoStarter, "apphost.go")] + [InlineData(KnownTemplateId.JavaStarter, "AppHost.java")] public async Task ChannelPinningTemplate_IdentityMatchesRegisteredChannel_PinsThatChannel(string templateId, string _) { var persisted = await ScaffoldAndReadPersistedChannelAsync( @@ -176,6 +179,7 @@ public async Task ChannelPinningTemplate_IdentityMatchesRegisteredChannel_PinsTh [InlineData(KnownTemplateId.TypeScriptStarter, "apphost.mts")] [InlineData(KnownTemplateId.PythonStarter, "apphost.mts")] [InlineData(KnownTemplateId.GoStarter, "apphost.go")] + [InlineData(KnownTemplateId.JavaStarter, "AppHost.java")] public async Task ChannelPinningTemplate_ExplicitChannelArg_OverridesIdentityAndPersists(string templateId, string _) { var persisted = await ScaffoldAndReadPersistedChannelAsync( diff --git a/tests/Aspire.Cli.Tests/Commands/ParseResultHelperTests.cs b/tests/Aspire.Cli.Tests/Commands/ParseResultHelperTests.cs new file mode 100644 index 00000000000..129ef15456d --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/ParseResultHelperTests.cs @@ -0,0 +1,329 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; +using ParseResult = System.CommandLine.ParseResult; + +namespace Aspire.Cli.Tests.Commands; + +internal static class ParseResultHelperTestData +{ + public static TheoryData ProjectionCases => new() + { + { + ["run", "--apphost=selector", "--isolated=false", "--debug", "--", "--apphost", "app-value"], + false, + ["--isolated", "false", "--debug", "--", "--apphost", "app-value"], + 3 + }, + { + ["start", "--debug", "--detach", "--unknown", "value"], + false, + ["--debug", "--", "--detach", "--unknown", "value"], + 1 + }, + { + ["run", "--debug", "--secret", "value"], + false, + ["--debug", "--", "--secret", "value"], + 1 + }, + { + ["start", "--log-level", "Debug", "--unknown", "value"], + false, + ["--log-level", "Debug", "--", "--unknown", "value"], + 2 + }, + { + ["run", "--project=selector", "--debug"], + false, + ["--debug"], + 1 + }, + { + // Repeating a single-value option produces a parse error, but the parse tree + // still owns both value tokens and the projector must exclude both occurrences. + ["run", "--apphost=first", "--apphost", "second", "--debug"], + true, + ["--debug"], + 1 + }, + { + ["run", "--apphost", "same-value", "--", "same-value"], + false, + ["--", "same-value"], + 0 + }, + { + ["start", "--debug", "--before", "before-value", "--", "after-value", "--after"], + false, + ["--debug", "--", "--before", "before-value", "after-value", "--after"], + 1 + }, + { + ["start", "--capture-profile-output=--", "--", "--custom-arg"], + false, + [$"--capture-profile-output={new FileInfo("--").FullName}", "--", "--custom-arg"], + 1 + }, + { + ["start", "--debug", "--"], + false, + ["--debug"], + 1 + } + }; +} + +public sealed class ParseResultHelperTests : IDisposable +{ + private readonly TemporaryWorkspace _workspace; + private readonly ServiceProvider _provider; + private readonly RootCommand _command; + + public ParseResultHelperTests(ITestOutputHelper outputHelper) + { + _workspace = TemporaryWorkspace.CreateForCli(outputHelper); + _provider = CliTestHelper.CreateServiceCollection(_workspace, outputHelper).BuildServiceProvider(); + _command = _provider.GetRequiredService(); + } + + [Theory] + [MemberData(nameof(ParseResultHelperTestData.ProjectionCases), MemberType = typeof(ParseResultHelperTestData))] + public void GetForwardedArguments_ProjectsTokens( + string[] commandLine, + bool expectParseErrors, + string[] expectedTokens, + int expectedOptionCount) + { + var parseResult = _command.Parse(commandLine); + + Assert.Equal(expectParseErrors, parseResult.Errors.Count > 0); + var forwardedArguments = GetForwardedArguments(parseResult); + + Assert.Equal(expectedTokens, forwardedArguments.Tokens); + Assert.Equal(expectedOptionCount, forwardedArguments.OptionCount); + } + + [Fact] + public void GetForwardedArguments_NormalizesSingleFileSystemInfoValue() + { + var relativePath = Path.Combine("Profile Output", "profile.zip"); + var parseResult = _command.Parse(["run", "--capture-profile-output", relativePath]); + + Assert.Empty(parseResult.Errors); + var forwardedArguments = GetForwardedArguments(parseResult); + + Assert.Equal(["--capture-profile-output", new FileInfo(relativePath).FullName], forwardedArguments.Tokens); + Assert.Equal(2, forwardedArguments.OptionCount); + } + + [Fact] + public void GetForwardedArguments_PreservesInvalidFileSystemInfoValue() + { + var parseResult = _command.Parse(["run", "--capture-profile-output", ""]); + + Assert.NotEmpty(parseResult.Errors); + var forwardedArguments = GetForwardedArguments(parseResult); + + Assert.Equal(["--capture-profile-output", ""], forwardedArguments.Tokens); + Assert.Equal(2, forwardedArguments.OptionCount); + } + + [Fact] + public void GetForwardedArguments_NormalizesLiteralDoubleDashFileSystemInfoValue() + { + var parseResult = _command.Parse(["run", "--capture-profile-output=--"]); + + Assert.Empty(parseResult.Errors); + var forwardedArguments = GetForwardedArguments(parseResult); + + Assert.Equal([$"--capture-profile-output={new FileInfo("--").FullName}"], forwardedArguments.Tokens); + Assert.Equal(1, forwardedArguments.OptionCount); + } + + [Fact] + public void GetForwardedArguments_PreservesLiteralDoubleDashStringValue() + { + var valueOption = new System.CommandLine.Option("--value"); + var command = new System.CommandLine.Command("test"); + command.Options.Add(valueOption); + var parseResult = command.Parse(["test", "--value=--"]); + + Assert.Empty(parseResult.Errors); + var forwardedArguments = ParseResultHelper.GetForwardedArguments(parseResult); + + Assert.Equal(["--value=--"], forwardedArguments.Tokens); + Assert.Equal(1, forwardedArguments.OptionCount); + } + + [Fact] + public void ForwardedArguments_RoundTripsOptionShapedMultiValue() + { + var valuesOption = new System.CommandLine.Option("--values") + { + Arity = System.CommandLine.ArgumentArity.ZeroOrMore, + AllowMultipleArgumentsPerToken = true, + }; + var flagOption = new System.CommandLine.Option("--flag"); + var command = new System.CommandLine.Command("test"); + command.Options.Add(valuesOption); + command.Options.Add(flagOption); + + var parentParseResult = command.Parse(["test", "--values=--flag"]); + + Assert.Empty(parentParseResult.Errors); + var parentValues = Assert.IsType(parentParseResult.GetValue(valuesOption)); + Assert.Equal(["--flag"], parentValues); + Assert.False(parentParseResult.GetValue(flagOption)); + + var forwardedArguments = ParseResultHelper.GetForwardedArguments(parentParseResult); + var childParseResult = command.Parse(["test", .. forwardedArguments.Tokens]); + + Assert.Empty(childParseResult.Errors); + Assert.Equal(parentValues, Assert.IsType(childParseResult.GetValue(valuesOption))); + Assert.Equal(parentParseResult.GetValue(flagOption), childParseResult.GetValue(flagOption)); + Assert.Equal(["--values=--flag"], forwardedArguments.Tokens); + } + + [Fact] + public void GetForwardedArguments_ExcludesOptionAliases() + { + var parseResult = _command.Parse(["run", "-d", "--isolated"]); + + Assert.Empty(parseResult.Errors); + var forwardedArguments = ParseResultHelper.GetForwardedArguments( + parseResult, + RootCommand.DebugOption); + + Assert.Equal(["--isolated"], forwardedArguments.Tokens); + Assert.Equal(1, forwardedArguments.OptionCount); + } + + [Fact] + public void ForwardedArguments_RoundTripsChildRunSemantics() + { + var parseResult = _command.Parse( + ["run", "--apphost=selector", "--isolated=false", "--debug", "--", "--apphost", "app-value"]); + var forwardedArguments = GetForwardedArguments(parseResult); + + forwardedArguments.InsertCliOption("--non-interactive", "--capture-profile"); + + Assert.Equal(5, forwardedArguments.OptionCount); + Assert.Equal( + ["--isolated", "false", "--debug", "--non-interactive", "--capture-profile", "--", "--apphost", "app-value"], + forwardedArguments.Tokens); + + var childParseResult = _command.Parse(["run", .. forwardedArguments.Tokens]); + + Assert.Empty(childParseResult.Errors); + Assert.False(childParseResult.GetValue(AppHostLauncher.s_isolatedOption)); + Assert.True(childParseResult.GetValue(RootCommand.DebugOption)); + Assert.True(childParseResult.GetValue(RootCommand.NonInteractiveOption)); + Assert.True(childParseResult.GetValue(RootCommand.CaptureProfileOption)); + Assert.Null(childParseResult.GetValue(AppHostLauncher.s_appHostOption)); + Assert.Equal(["--apphost", "app-value"], childParseResult.UnmatchedTokens); + } + + [Fact] + public void ForwardedArguments_RoundTripsDelimiterFreeAppHostArgumentsAfterSeparator() + { + var parseResult = _command.Parse(["run", "--debug", "--secret", "value"]); + var forwardedArguments = GetForwardedArguments(parseResult); + + forwardedArguments.InsertCliOption("--non-interactive", "--capture-profile"); + + Assert.Equal(3, forwardedArguments.OptionCount); + Assert.Equal( + ["--debug", "--non-interactive", "--capture-profile", "--", "--secret", "value"], + forwardedArguments.Tokens); + + var childParseResult = _command.Parse(["run", .. forwardedArguments.Tokens]); + + Assert.Empty(childParseResult.Errors); + Assert.True(childParseResult.GetValue(RootCommand.DebugOption)); + Assert.True(childParseResult.GetValue(RootCommand.NonInteractiveOption)); + Assert.True(childParseResult.GetValue(RootCommand.CaptureProfileOption)); + Assert.Equal(["--secret", "value"], childParseResult.UnmatchedTokens); + } + + [Fact] + public void GetLoggableArguments_RedactsForwardedTokensWithoutSeparator() + { + var parseResult = _command.Parse(["run", "--ApiKey", "sk-live-secret"]); + var loggable = ParseResultHelper.GetLoggableArguments(parseResult); + + Assert.Equal("run ", loggable); + Assert.DoesNotContain("sk-live-secret", loggable, StringComparison.Ordinal); + Assert.Equal("aspire run ", $"aspire {loggable}"); + } + + [Fact] + public void GetLoggableArguments_PreservesRecognizedOptionsAndRedactsTheRest() + { + var parseResult = _command.Parse(["run", "--debug", "--secret", "value"]); + var loggable = ParseResultHelper.GetLoggableArguments(parseResult); + + Assert.Equal("run --debug ", loggable); + Assert.DoesNotContain("--secret", loggable, StringComparison.Ordinal); + } + + [Fact] + public void GetLoggableArguments_PreservesSeparatorAndRedactsTrailingTokens() + { + var parseResult = _command.Parse(["run", "--", "--ApiKey", "secret"]); + var loggable = ParseResultHelper.GetLoggableArguments(parseResult); + + Assert.Equal("run -- ", loggable); + Assert.DoesNotContain("secret", loggable, StringComparison.Ordinal); + } + + [Fact] + public void GetLoggableArguments_PreservesCommandArguments() + { + var parseResult = _command.Parse(["add", "Aspire.Hosting.Redis"]); + + Assert.Equal("add Aspire.Hosting.Redis", ParseResultHelper.GetLoggableArguments(parseResult)); + } + + [Fact] + public void GetLoggableArguments_PreservesRecognizedOptionValues() + { + var parseResult = _command.Parse(["run", "--apphost", "path/to/App.csproj"]); + + Assert.Equal("run --apphost path/to/App.csproj", ParseResultHelper.GetLoggableArguments(parseResult)); + } + + [Fact] + public void GetLoggableArguments_ReturnsEmptyStringForBareInvocation() + { + Assert.Equal(string.Empty, ParseResultHelper.GetLoggableArguments(_command.Parse([]))); + + // System.CommandLine keeps the first argument when it already names the root command, so + // the synthesized and the user-supplied forms must both render as an empty argument list. + Assert.Equal(string.Empty, ParseResultHelper.GetLoggableArguments(_command.Parse([_command.Name]))); + } + + [Fact] + public void GetLoggableArguments_RedactsRepeatedValueOnlyInForwardedPosition() + { + var parseResult = _command.Parse(["run", "--apphost", "same-value", "--", "same-value"]); + var loggable = ParseResultHelper.GetLoggableArguments(parseResult); + + Assert.Equal("run --apphost same-value -- ", loggable); + } + + public void Dispose() + { + _provider.Dispose(); + _workspace.Dispose(); + } + + private static ForwardedArguments GetForwardedArguments(ParseResult parseResult) + => ParseResultHelper.GetForwardedArguments( + parseResult, + AppHostLauncher.s_appHostOption.InnerOption, + AppHostLauncher.s_appHostOption.LegacyOption); +} diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 565c328ab48..8c22b36b487 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -14,6 +14,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; using Aspire.Cli.NuGet; +using Aspire.Cli.Profiling; using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; @@ -924,6 +925,46 @@ public async Task RunCommand_DetachedChild_DoesNotStartCliMetadataPrefetching() Assert.False(cliPrefetchStarted); } + [Fact] + public async Task RunCommand_DetachedChild_PreservesAppHostArgumentsAfterSingleSeparator() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateAppHostFile(workspace); + var expectedAppHostArguments = new[] { "true", "false", string.Empty, "--detach", "--option-shaped" }; + var projectLocator = new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) + }; + var processFactory = new TestProcessExecutionFactory + { + DefaultExitCode = CliExitCodes.FailedToDotnetRunAppHost + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => projectLocator; + }); + services.Replace(ServiceDescriptor.Singleton(processFactory)); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse( + [ + "run", + "--detach", + "--apphost", appHostFile.FullName, + "--no-build", + "--", + .. expectedAppHostArguments + ]); + + Assert.Empty(result.Errors); + Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, await result.InvokeAsync().DefaultTimeout()); + + AssertDetachedChildArguments(command, processFactory.LastArguments, expectedAppHostArguments); + } + [Fact] public async Task RunCommand_WithoutDetachFlag_ShowsUpdateNotification() { @@ -3600,6 +3641,208 @@ async static IAsyncEnumerable ThrowConnectionLostAfterOneEn } } + [Fact] + public void DebugSessionOptions_SerializesAppHostSelectionOriginUsingWireContract() + { + var options = new DebugSessionOptions + { + AppHostSelectionOrigin = DebugSessionOptions.ExplicitCliAppHostSelectionOrigin + }; + + var json = JsonSerializer.Serialize(options, BackchannelJsonSerializerContext.Default.DebugSessionOptions); + using var document = JsonDocument.Parse(json); + + Assert.Equal("explicit-cli", document.RootElement.GetProperty("appHostSelectionOrigin").GetString()); + } + + [Theory] + [InlineData(false, "default-discovery")] + [InlineData(true, "explicit-cli")] + public async Task RunCommand_WhenDelegatingToExtension_CarriesAppHostSelectionOrigin(bool explicitAppHost, string expectedOrigin) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); + var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, ""); + + string? workingDirectory = null; + string? projectFile = null; + bool? debug = null; + DebugSessionOptions? options = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, testOptions => + { + testOptions.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel(); + testOptions.InteractionServiceFactory = sp => + { + var service = new TestExtensionInteractionService(sp); + service.StartDebugSessionCallback = (wd, pf, dbg, debugSessionOptions) => + { + workingDirectory = wd; + projectFile = pf; + debug = dbg; + options = debugSessionOptions; + }; + return service; + }; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse(explicitAppHost ? $"run --apphost \"{appHostFile.FullName}\"" : "run"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Equal(workspace.WorkspaceRoot.FullName, workingDirectory); + Assert.Equal(explicitAppHost ? appHostFile.FullName : null, projectFile); + Assert.False(debug); + Assert.NotNull(options); + Assert.Equal("run", options.Command); + Assert.Empty(options.Args!); + Assert.Equal(expectedOrigin, options.AppHostSelectionOrigin); + } + + [Fact] + public async Task RunCommand_WhenRunningInExtension_ForwardsExplicitArgumentsInSemanticOrder() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + TestGitWorktree.WriteLinkedWorktreeMetadata( + workspace.WorkspaceRoot.FullName, + Path.Combine(workspace.WorkspaceRoot.FullName, "common", ".git")); + var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("App Host"); + var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); + File.WriteAllText(appHostFile.FullName, ""); + var relativeCapturePath = Path.Combine("Profile Output", "profile.zip"); + + bool? debug = null; + DebugSessionOptions? options = null; + using var provider = CliTestHelper.CreateExtensionServiceProvider(workspace, outputHelper, (_, _, dbg, debugSessionOptions) => + { + debug = dbg; + options = debugSessionOptions; + }); + + var command = provider.GetRequiredService(); + var result = command.Parse( + [ + "run", + "--apphost", appHostFile.FullName, + "--debug", + "--capture-profile", + "--detach=false", + "--no-build", + "--isolated=false", + "--format=table", + "--wait-for-debugger", + "--capture-profile-output", relativeCapturePath, + "--non-interactive=false", + "--log-level", "Debug", + "--start-debug-session", + "--capture-profile-delay=1", + "--", + "--custom-arg", "value" + ]); + + Assert.Empty(result.Errors); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.True(debug); + Assert.NotNull(options); + Assert.Equal("run", options.Command); + Assert.NotNull(options.Args); + Assert.Equal( + [ + "--debug", + "--capture-profile", + "--no-build", + "--isolated", "false", + "--wait-for-debugger", + "--capture-profile-output", new FileInfo(relativeCapturePath).FullName, + "--log-level", "Debug", + "--capture-profile-delay", "1", + "--", + "--custom-arg", "value" + ], + options.Args); + } + + [Fact] + public async Task RunCommand_WhenRunningInExtensionInLinkedWorktree_DoesNotInferIsolation() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + TestGitWorktree.WriteLinkedWorktreeMetadata( + workspace.WorkspaceRoot.FullName, + Path.Combine(workspace.WorkspaceRoot.FullName, "common", ".git")); + + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + File.WriteAllText(appHostFile.FullName, ""); + + DebugSessionOptions? options = null; + using var provider = CliTestHelper.CreateExtensionServiceProvider( + workspace, + outputHelper, + (_, _, _, debugSessionOptions) => options = debugSessionOptions); + var command = provider.GetRequiredService(); + var result = command.Parse(["run", "--apphost", appHostFile.FullName, "--debug", "--", "--custom-arg", "value"]); + + Assert.Empty(result.Errors); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotNull(options); + Assert.NotNull(options.Args); + Assert.Equal(["--debug", "--", "--custom-arg", "value"], options.Args); + } + + [Fact] + public async Task RunCommand_WhenRunningInExtension_SynthesizesSeparatorBeforeDelimiterFreeAppHostArguments() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + TestGitWorktree.WriteLinkedWorktreeMetadata( + workspace.WorkspaceRoot.FullName, + Path.Combine(workspace.WorkspaceRoot.FullName, "common", ".git")); + + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + File.WriteAllText(appHostFile.FullName, ""); + + DebugSessionOptions? options = null; + using var provider = CliTestHelper.CreateExtensionServiceProvider( + workspace, + outputHelper, + (_, _, _, debugSessionOptions) => options = debugSessionOptions); + var command = provider.GetRequiredService(); + var result = command.Parse(["run", "--apphost", appHostFile.FullName, "--debug", "--secret", "value"]); + + Assert.Empty(result.Errors); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotNull(options); + Assert.NotNull(options.Args); + Assert.Equal(["--debug", "--", "--secret", "value"], options.Args); + + var childParseResult = command.Parse(["run", .. options.Args]); + + Assert.Empty(childParseResult.Errors); + Assert.True(childParseResult.GetValue(RootCommand.DebugOption)); + Assert.False(childParseResult.GetValue(AppHostLauncher.s_isolatedOption)); + Assert.Equal(["--secret", "value"], childParseResult.UnmatchedTokens); + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + [InlineData(true, false)] + public async Task DelegatedCommands_RecordTransferStateOnlyAfterSuccessfulHandoff( + bool captureProfile, + bool handoffSucceeds) + { + await AssertProfileTransferAsync("run", captureProfile, handoffSucceeds); + await AssertProfileTransferAsync("start", captureProfile, handoffSucceeds); + } + [Fact] public async Task RunCommand_NonInteractive_SkipsExtensionDelegation() { @@ -3803,6 +4046,43 @@ public async Task RunCommand_RecordsRunAppHostTelemetryActivity(bool detached, b Assert.Equal("certificate_trust_failed", tags[TelemetryConstants.Tags.ErrorType]); } + private async Task AssertProfileTransferAsync( + string commandName, + bool captureProfile, + bool handoffSucceeds) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + File.WriteAllText(appHostFile.FullName, ""); + + ProfileCaptureState? captureState = null; + using var provider = CliTestHelper.CreateExtensionServiceProvider(workspace, outputHelper, (_, _, _, _) => + { + Assert.NotNull(captureState); + Assert.False(captureState.IsTransferred); + if (!handoffSucceeds) + { + throw new InvalidOperationException("Extension handoff failed."); + } + }); + + captureState = provider.GetRequiredService(); + var command = provider.GetRequiredService(); + var args = new List { commandName, "--apphost", appHostFile.FullName }; + if (captureProfile) + { + args.Add("--capture-profile"); + } + + var result = command.Parse(args); + + Assert.Empty(result.Errors); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(handoffSucceeds ? CliExitCodes.Success : CliExitCodes.InvalidCommand, exitCode); + Assert.Equal(handoffSucceeds, captureState.IsTransferred); + } + private static long GetProcessStartTimeUnixMilliseconds(Process process) { var startTime = ProcessStartTimeHelper.TryGetProcessStartTimeUnixMilliseconds(process.Id); @@ -3810,4 +4090,39 @@ private static long GetProcessStartTimeUnixMilliseconds(Process process) return startTime.Value; } + private static FileInfo CreateAppHostFile(TemporaryWorkspace workspace) + { + var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); + var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); + File.WriteAllText(appHostFile.FullName, ""); + + return appHostFile; + } + + private static void AssertDetachedChildArguments(RootCommand command, string[]? childArguments, string[] expectedAppHostArguments) + { + var forwardedArguments = ExtractForwardedRunArguments(Assert.IsType(childArguments)); + var separatorIndex = Array.IndexOf(forwardedArguments, "--"); + var noBuildIndex = Array.IndexOf(forwardedArguments, "--no-build"); + + Assert.Equal(1, forwardedArguments.Count(argument => argument == "--")); + Assert.True(separatorIndex > 0, "Expected a single child/AppHost separator."); + Assert.True(noBuildIndex > 0, "Expected detached child arguments to include --no-build."); + Assert.Equal(1, forwardedArguments.Count(argument => argument == "--no-build")); + Assert.Equal(["--no-build", "--", .. expectedAppHostArguments], forwardedArguments[noBuildIndex..]); + Assert.DoesNotContain("--detach", forwardedArguments.Take(separatorIndex)); + var childParseResult = command.Parse(["run", .. forwardedArguments[noBuildIndex..]]); + + Assert.Empty(childParseResult.Errors); + Assert.Equal(expectedAppHostArguments, childParseResult.UnmatchedTokens); + } + + private static string[] ExtractForwardedRunArguments(string[] childArguments) + { + var runIndex = Array.IndexOf(childArguments, "run"); + Assert.True(runIndex >= 0, "Expected detached child arguments to include the run command."); + + return childArguments[runIndex..]; + } + } diff --git a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs index 8882b3a183c..6fff7af3993 100644 --- a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs @@ -141,6 +141,45 @@ public void StartCommand_ForwardsUnmatchedTokensToAppHost() Assert.Contains("value", result.UnmatchedTokens); } + [Fact] + public async Task StartCommand_DetachedChild_PreservesAppHostArgumentsAfterSingleSeparator() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateAppHostFile(workspace); + var expectedAppHostArguments = new[] { "true", "false", string.Empty, "--detach", "--option-shaped" }; + var projectLocator = new TestProjectLocator + { + UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => + Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) + }; + var processFactory = new TestProcessExecutionFactory + { + DefaultExitCode = CliExitCodes.FailedToDotnetRunAppHost + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => projectLocator; + }); + services.Replace(ServiceDescriptor.Singleton(processFactory)); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse( + [ + "start", + "--apphost", appHostFile.FullName, + "--no-build", + "--", + .. expectedAppHostArguments + ]); + + Assert.Empty(result.Errors); + Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, await result.InvokeAsync().DefaultTimeout()); + + AssertDetachedChildArguments(command, processFactory.LastArguments, expectedAppHostArguments); + } + [Fact] public async Task StartCommand_WhenMultipleProjectFilesFound_NonInteractive_ReturnsNonZeroExitCode() { @@ -259,37 +298,90 @@ public async Task StartCommand_LaunchFailure_DisplaysBothLogPaths() } [Fact] - public async Task StartCommand_WhenRunningInExtensionWithoutDebugSession_StartsVsCodeRunSession() + public async Task StartCommand_WhenRunningInExtension_ForwardsExplicitArgumentsInSemanticOrder() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var appHostFile = CreateAppHostFile(workspace); + var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("App Host"); + var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); + File.WriteAllText(appHostFile.FullName, ""); string? workingDirectory = null; string? projectFile = null; bool? debug = null; DebugSessionOptions? options = null; - var projectLocator = new TestProjectLocator + using var provider = CliTestHelper.CreateExtensionServiceProvider(workspace, outputHelper, (wd, pf, dbg, debugSessionOptions) => { - UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => - Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) - }; + workingDirectory = wd; + projectFile = pf; + debug = dbg; + options = debugSessionOptions; + }); + + var command = provider.GetRequiredService(); + + var result = command.Parse( + [ + "start", + "--project", appHostFile.FullName, + "--debug", + "--capture-profile", + "--format=table", + "--no-build", + "--isolated=false", + "--wait-for-debugger", + "--non-interactive=false", + "--log-level", "Debug", + "--start-debug-session", + "--capture-profile-delay=1", + "--detach", + "--unknown-option", "value" + ]); + + Assert.Empty(result.Errors); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Equal(workspace.WorkspaceRoot.FullName, workingDirectory); + Assert.Equal(appHostFile.FullName, projectFile); + Assert.True(debug); + Assert.NotNull(options); + Assert.Equal("run", options.Command); + Assert.NotNull(options.Args); + Assert.Equal( + [ + "--debug", + "--capture-profile", + "--no-build", + "--isolated", "false", + "--wait-for-debugger", + "--log-level", "Debug", + "--capture-profile-delay", "1", + "--", + "--detach", + "--unknown-option", "value" + ], + options.Args); + Assert.Equal("explicit-cli", options.AppHostSelectionOrigin); + } + + [Fact] + public async Task StartCommand_WhenRunningInExtensionWithoutAppHost_UsesDefaultDiscoverySelectionOrigin() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + string? projectFile = null; + bool? debug = null; + DebugSessionOptions? options = null; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, testOptions => { - testOptions.ProjectLocatorFactory = _ => projectLocator; testOptions.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel(); - testOptions.CliHostEnvironmentFactory = sp => - { - var configuration = sp.GetRequiredService(); - return new CliHostEnvironment(configuration, nonInteractive: false); - }; testOptions.InteractionServiceFactory = sp => { var service = new TestExtensionInteractionService(sp); - service.StartDebugSessionCallback = (wd, pf, dbg, debugSessionOptions) => + service.StartDebugSessionCallback = (_, pf, dbg, debugSessionOptions) => { - workingDirectory = wd; projectFile = pf; debug = dbg; options = debugSessionOptions; @@ -301,18 +393,16 @@ public async Task StartCommand_WhenRunningInExtensionWithoutDebugSession_StartsV using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var captureProfileOutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "profile.zip"); - var result = command.Parse($"start --apphost {appHostFile.FullName} --isolated --no-build --debug --log-level Debug --wait-for-debugger --capture-profile --capture-profile-output {captureProfileOutputPath} --capture-profile-delay 1 -- --custom-arg value"); + var result = command.Parse("start"); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); - Assert.Equal(workspace.WorkspaceRoot.FullName, workingDirectory); - Assert.Equal(appHostFile.FullName, projectFile); + Assert.Null(projectFile); Assert.False(debug); Assert.NotNull(options); Assert.Equal("run", options.Command); - Assert.NotNull(options.Args); - Assert.Equal(["--isolated", "--no-build", "--debug", "--log-level", "Debug", "--wait-for-debugger", "--capture-profile", "--capture-profile-output", captureProfileOutputPath, "--capture-profile-delay", "1", "--", "--custom-arg", "value"], options.Args); + Assert.Empty(options.Args!); + Assert.Equal("default-discovery", options.AppHostSelectionOrigin); } [Fact] @@ -324,29 +414,12 @@ public async Task StartCommand_WhenRunningInExtensionWithStartDebugSession_Start bool? debug = null; DebugSessionOptions? options = null; - var projectLocator = new TestProjectLocator + using var provider = CliTestHelper.CreateExtensionServiceProvider(workspace, outputHelper, (_, _, dbg, debugSessionOptions) => { - UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) => - Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) - }; - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, testOptions => - { - testOptions.ProjectLocatorFactory = _ => projectLocator; - testOptions.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel(); - testOptions.InteractionServiceFactory = sp => - { - var service = new TestExtensionInteractionService(sp); - service.StartDebugSessionCallback = (_, _, dbg, debugSessionOptions) => - { - debug = dbg; - options = debugSessionOptions; - }; - return service; - }; + debug = dbg; + options = debugSessionOptions; }); - using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); var result = command.Parse($"start --apphost {appHostFile.FullName} --start-debug-session"); @@ -356,7 +429,30 @@ public async Task StartCommand_WhenRunningInExtensionWithStartDebugSession_Start Assert.True(debug); Assert.NotNull(options); Assert.Equal("run", options.Command); - Assert.Null(options.Args); + Assert.NotNull(options.Args); + Assert.Empty(options.Args); + } + + [Fact] + public async Task StartCommand_WhenRunningInExtensionInLinkedWorktree_DoesNotInferIsolation() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + TestGitWorktree.WriteLinkedWorktreeMetadata( + workspace.WorkspaceRoot.FullName, + Path.Combine(workspace.WorkspaceRoot.FullName, "common", ".git")); + var appHostFile = CreateAppHostFile(workspace); + + DebugSessionOptions? options = null; + using var provider = CliTestHelper.CreateExtensionServiceProvider( + workspace, + outputHelper, + (_, _, _, debugSessionOptions) => options = debugSessionOptions); + var result = provider.GetRequiredService().Parse($"start --apphost {appHostFile.FullName}"); + + Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout()); + Assert.NotNull(options); + Assert.NotNull(options.Args); + Assert.Empty(options.Args); } [Fact] @@ -373,23 +469,20 @@ public async Task StartCommand_WhenRunningInExtensionWithDebugSession_DoesNotSta Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) }; - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, testOptions => - { - testOptions.ConfigurationCallback += config => config[KnownConfigNames.ExtensionDebugSessionId] = "existing-session"; - testOptions.ProjectLocatorFactory = _ => projectLocator; - testOptions.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel(); - testOptions.InteractionServiceFactory = sp => + using var provider = CliTestHelper.CreateExtensionServiceProvider( + workspace, + outputHelper, + (_, _, _, _) => startDebugSessionCalled = true, + configureOptions: testOptions => { - var service = new TestExtensionInteractionService(sp); - service.StartDebugSessionCallback = (_, _, _, _) => startDebugSessionCalled = true; - return service; - }; - }); - - services.Replace(ServiceDescriptor.Singleton(new TestDetachedProcessFactory(() => detachedLauncherCalled = true))); - services.Replace(ServiceDescriptor.Singleton(new InstantTimeoutTimeProvider())); - - using var provider = services.BuildServiceProvider(); + testOptions.ConfigurationCallback += config => config[KnownConfigNames.ExtensionDebugSessionId] = "existing-session"; + testOptions.ProjectLocatorFactory = _ => projectLocator; + }, + configureServices: services => + { + services.Replace(ServiceDescriptor.Singleton(new TestDetachedProcessFactory(() => detachedLauncherCalled = true))); + services.Replace(ServiceDescriptor.Singleton(new InstantTimeoutTimeProvider())); + }); var command = provider.GetRequiredService(); var result = command.Parse($"start --apphost {appHostFile.FullName}"); @@ -416,22 +509,16 @@ public async Task StartCommand_WhenRunningInExtensionWithDetachedOnlyOption_Does Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile])) }; - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, testOptions => - { - testOptions.ProjectLocatorFactory = _ => projectLocator; - testOptions.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel(); - testOptions.InteractionServiceFactory = sp => + using var provider = CliTestHelper.CreateExtensionServiceProvider( + workspace, + outputHelper, + (_, _, _, _) => startDebugSessionCalled = true, + configureOptions: testOptions => testOptions.ProjectLocatorFactory = _ => projectLocator, + configureServices: services => { - var service = new TestExtensionInteractionService(sp); - service.StartDebugSessionCallback = (_, _, _, _) => startDebugSessionCalled = true; - return service; - }; - }); - - services.Replace(ServiceDescriptor.Singleton(new TestDetachedProcessFactory(() => detachedLauncherCalled = true))); - services.Replace(ServiceDescriptor.Singleton(new InstantTimeoutTimeProvider())); - - using var provider = services.BuildServiceProvider(); + services.Replace(ServiceDescriptor.Singleton(new TestDetachedProcessFactory(() => detachedLauncherCalled = true))); + services.Replace(ServiceDescriptor.Singleton(new InstantTimeoutTimeProvider())); + }); var command = provider.GetRequiredService(); var result = command.Parse(string.Format(CultureInfo.InvariantCulture, commandTemplate, appHostFile.FullName)); @@ -442,6 +529,58 @@ public async Task StartCommand_WhenRunningInExtensionWithDetachedOnlyOption_Does Assert.True(detachedLauncherCalled); } + [Fact] + public void ResolveIsolated_LinkedWorktree_RequiresExplicitIsolation() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + TestGitWorktree.WriteLinkedWorktreeMetadata( + workspace.WorkspaceRoot.FullName, + Path.Combine(workspace.WorkspaceRoot.FullName, "common", ".git")); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + Assert.False(AppHostLauncher.ResolveIsolated(command.Parse("start"))); + Assert.True(AppHostLauncher.ResolveIsolated(command.Parse("start --isolated"))); + Assert.False(AppHostLauncher.ResolveIsolated(command.Parse("start --isolated false"))); + Assert.False(AppHostLauncher.ResolveIsolated(command.Parse("run"))); + Assert.True(AppHostLauncher.ResolveIsolated(command.Parse("run --isolated"))); + Assert.False(AppHostLauncher.ResolveIsolated(command.Parse("run --isolated false"))); + } + + [Theory] + [InlineData("start", null)] + [InlineData("start --isolated", true)] + [InlineData("start --isolated false", false)] + public void GetExplicitIsolated_PreservesOmittedAndExplicitValues(string commandLine, bool? expected) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + Assert.Equal(expected, AppHostLauncher.GetExplicitIsolated(command.Parse(commandLine))); + } + + [Fact] + public void ResolveIsolated_PrimaryCheckout_DoesNotInferIsolated() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".git")); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + + Assert.False(AppHostLauncher.ResolveIsolated(command.Parse("start"))); + Assert.True(AppHostLauncher.ResolveIsolated(command.Parse("start --isolated"))); + Assert.False(AppHostLauncher.ResolveIsolated(command.Parse("start --isolated false"))); + Assert.False(AppHostLauncher.ResolveIsolated(command.Parse("run"))); + Assert.True(AppHostLauncher.ResolveIsolated(command.Parse("run --isolated"))); + Assert.False(AppHostLauncher.ResolveIsolated(command.Parse("run --isolated false"))); + } + private static FileInfo CreateAppHostFile(TemporaryWorkspace workspace) { var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); @@ -451,6 +590,32 @@ private static FileInfo CreateAppHostFile(TemporaryWorkspace workspace) return appHostFile; } + private static void AssertDetachedChildArguments(RootCommand command, string[]? childArguments, string[] expectedAppHostArguments) + { + var forwardedArguments = ExtractForwardedRunArguments(Assert.IsType(childArguments)); + var separatorIndex = Array.IndexOf(forwardedArguments, "--"); + var noBuildIndex = Array.IndexOf(forwardedArguments, "--no-build"); + + Assert.Equal(1, forwardedArguments.Count(argument => argument == "--")); + Assert.True(separatorIndex > 0, "Expected a single child/AppHost separator."); + Assert.True(noBuildIndex > 0, "Expected detached child arguments to include --no-build."); + Assert.Equal(1, forwardedArguments.Count(argument => argument == "--no-build")); + Assert.Equal(["--no-build", "--", .. expectedAppHostArguments], forwardedArguments[noBuildIndex..]); + Assert.DoesNotContain("--detach", forwardedArguments.Take(separatorIndex)); + var childParseResult = command.Parse(["run", .. forwardedArguments[noBuildIndex..]]); + + Assert.Empty(childParseResult.Errors); + Assert.Equal(expectedAppHostArguments, childParseResult.UnmatchedTokens); + } + + private static string[] ExtractForwardedRunArguments(string[] childArguments) + { + var runIndex = Array.IndexOf(childArguments, "run"); + Assert.True(runIndex >= 0, "Expected detached child arguments to include the run command."); + + return childArguments[runIndex..]; + } + private sealed class TestDetachedProcessFactory(Action onStart) : IProcessExecutionFactory { public IProcessExecution CreateExecution(string fileName, string[] args, IDictionary? env, DirectoryInfo workingDirectory, ProcessInvocationOptions options) diff --git a/tests/Aspire.Cli.Tests/Commands/StopCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/StopCommandTests.cs index 7896aaeebdd..6d870e7225c 100644 --- a/tests/Aspire.Cli.Tests/Commands/StopCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/StopCommandTests.cs @@ -218,6 +218,59 @@ public async Task StopCommand_SingleAppHostIncludesIdentifierInStatusAndSuccessM Assert.Contains(interactionService.DisplayedSuccess, message => message == string.Format(CultureInfo.CurrentCulture, StopCommandStrings.AppHostStoppedSuccessfully, expectedPath)); } + [Fact] + public async Task StopCommand_WithoutAppHost_DoesNotStopNestedLinkedWorktreeInstance() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".git")); + var worktreeRoot = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".worktrees", "feature")).FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata( + worktreeRoot, + Path.Combine(workspace.WorkspaceRoot.FullName, ".git")); + + var interactionService = new TestInteractionService(); + var statusMessages = new ConcurrentQueue(); + interactionService.ShowStatusCallback = statusMessages.Enqueue; + + var primaryAppHost = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj"); + var nestedAppHost = Path.Combine(worktreeRoot, "AppHost.csproj"); + Assert.True(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(primaryAppHost, workspace.WorkspaceRoot.FullName)); + Assert.False(AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(nestedAppHost, workspace.WorkspaceRoot.FullName)); + + var monitor = new TestAuxiliaryBackchannelMonitor(); + var primaryConnection = CreateConnection( + primaryAppHost, + int.MaxValue - 7, + isInScope: AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(primaryAppHost, workspace.WorkspaceRoot.FullName)); + primaryConnection.SocketPath = CreateMatchingSocketFile(primaryAppHost, workspace, 7); + var nestedConnection = CreateConnection( + nestedAppHost, + int.MaxValue - 8, + isInScope: AuxiliaryBackchannelMonitor.IsAppHostInScopeOfDirectory(nestedAppHost, workspace.WorkspaceRoot.FullName)); + nestedConnection.SocketPath = CreateMatchingSocketFile(nestedAppHost, workspace, 8); + monitor.AddConnection("hash1", primaryConnection.SocketPath, primaryConnection); + monitor.AddConnection("hash2", nestedConnection.SocketPath, nestedConnection); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + options.CliHostEnvironmentFactory = _ => TestHelpers.CreateNonInteractiveHostEnvironment(); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("stop"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + var expectedPath = Path.Combine("AppHost", "AppHost.csproj"); + Assert.Contains(statusMessages, message => message == string.Format(CultureInfo.CurrentCulture, StopCommandStrings.StoppingAppHost, expectedPath)); + Assert.False(File.Exists(primaryConnection.SocketPath)); + Assert.True(File.Exists(nestedConnection.SocketPath)); + } + [Fact] public async Task StopCommand_WithExplicitAppHostFileDoesNotUseProjectLocatorBeforeSocketLookup() { @@ -253,16 +306,22 @@ public async Task StopCommand_WithExplicitAppHostFileDoesNotUseProjectLocatorBef } [Fact] - public async Task StopCommand_SingleOutOfScopeAppHostUsesFullPathInMessages() + public async Task StopCommand_SingleAppHostInDifferentWorktreeWithoutOverrideDoesNotPromptOrStop() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var outOfScopeDir = workspace.CreateDirectory("out-of-scope"); + var commonGitDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, ".git"); + Directory.CreateDirectory(commonGitDirectory); + var worktreeRoot = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".worktrees", "feature")).FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, commonGitDirectory); + var interactionService = new TestInteractionService(); + interactionService.PromptForSelectionCallback = (_, _, _, _) => + throw new InvalidOperationException("AppHosts in another worktree must not be offered by aspire stop without --apphost or --all."); var statusMessages = new ConcurrentQueue(); interactionService.ShowStatusCallback = statusMessages.Enqueue; var monitor = new TestAuxiliaryBackchannelMonitor(); - var appHostPath = Path.Combine(outOfScopeDir.FullName, "App1", "App1.AppHost", "App1.AppHost.csproj"); + var appHostPath = Path.Combine(worktreeRoot, "App1", "App1.AppHost", "App1.AppHost.csproj"); var connection = CreateConnection(appHostPath, int.MaxValue - 6, isInScope: false); connection.SocketPath = CreateMatchingSocketFile(appHostPath, workspace, 6); monitor.AddConnection("hash1", connection.SocketPath, connection); @@ -280,10 +339,53 @@ public async Task StopCommand_SingleOutOfScopeAppHostUsesFullPathInMessages() var exitCode = await result.InvokeAsync().DefaultTimeout(); - Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Equal(CliExitCodes.FailedToFindProject, exitCode); + Assert.Equal([SharedCommandStrings.ScanningForRunningAppHosts], statusMessages); + Assert.Empty(interactionService.DisplayedSuccess); + // The AppHost is running, just not in this worktree, so the message must not say + // "start one first" and must name the flags that can reach across worktrees. + Assert.Equal(SharedCommandStrings.AppHostNotRunningInCurrentWorktree, Assert.Single(interactionService.DisplayedErrors)); + Assert.True(File.Exists(connection.SocketPath)); + } + + [Fact] + public async Task StopCommand_SingleAppHostOutsideWorkingDirectoryInSameWorktreePromptsAndStops() + { + // Regression coverage for `aspire stop` run from a sibling directory (repo/tests) while the + // AppHost lives elsewhere in the same checkout. Scope hides it from the direct match, but + // it is still the user's own worktree, so it must be offered. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var interactionService = new TestInteractionService(); + var statusMessages = new ConcurrentQueue(); + interactionService.ShowStatusCallback = statusMessages.Enqueue; + + var monitor = new TestAuxiliaryBackchannelMonitor(); + var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost", "App1.AppHost.csproj"); + var connection = CreateConnection(appHostPath, int.MaxValue - 6, isInScope: false); + connection.SocketPath = CreateMatchingSocketFile(appHostPath, workspace, 6); + monitor.AddConnection("hash1", connection.SocketPath, connection); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment(); + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("stop"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Collection( + interactionService.DisplayedMessages.Select(message => message.Message), + message => Assert.Equal(SharedCommandStrings.NoInScopeAppHostsShowingAll, message), + message => Assert.Equal(string.Format(CultureInfo.CurrentCulture, StopCommandStrings.FoundRunningAppHost, appHostPath), message), + message => Assert.Equal(string.Format(CultureInfo.CurrentCulture, StopCommandStrings.SendingStopSignal, appHostPath), message)); Assert.Contains(statusMessages, message => message == string.Format(CultureInfo.CurrentCulture, StopCommandStrings.StoppingAppHost, appHostPath)); - Assert.Contains(interactionService.DisplayedSuccess, message => message == string.Format(CultureInfo.CurrentCulture, StopCommandStrings.AppHostStoppedSuccessfully, appHostPath)); + Assert.False(File.Exists(connection.SocketPath)); } [Fact] diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index d8877b342f5..7d5e40ca82f 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -78,6 +78,40 @@ public async Task DotNetCliCorrectlyAppliesNoLaunchProfileArgumentWhenSpecifiedI Assert.Equal(42, exitCode); } + [Fact] + public async Task RunAsyncPreservesApplicationArgumentBoundaries() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file."); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); + + var appHostArguments = new[] { "--custom", "value with spaces", "", "literal \"quote\"", @"C:\tools\backslash\path" }; + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = DotNetCliRunnerTestHelper.Create( + provider, + executionContext, + (args, _, _, _) => Assert.Equal( + ["run", "--project", projectFile.FullName, "--", .. appHostArguments], + args), + 42); + + var exitCode = await runner.RunAsync( + projectFile, + watch: false, + noBuild: false, + noRestore: false, + appHostArguments, + env: null, + backchannelCompletionSource: null, + new ProcessInvocationOptions(), + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(42, exitCode); + } + [Fact] public async Task RunAppHostCommandAsyncUsesNativeCommandWithoutRunDelimiter() { @@ -1000,47 +1034,6 @@ public async Task RunAsyncFailsBackchannelWhenExtensionLaunchedAppHostDoesNotCon Assert.Contains("Timed out waiting for AppHost backchannel", exception.Message); } - [Fact] - public void BackchannelConnectionTimeoutUsesLongerConfiguredAppHostStartupTimeout() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [CliConfigNames.AppHostStartupTimeout] = "86400" - }) - .Build(); - - var timeout = DotNetCliRunner.GetBackchannelConnectionTimeout(configuration); - - Assert.Equal(TimeSpan.FromSeconds(86400), timeout); - } - - [Fact] - public void BackchannelConnectionTimeoutUsesDefaultAppHostStartupTimeout() - { - var configuration = new ConfigurationBuilder().Build(); - - var timeout = DotNetCliRunner.GetBackchannelConnectionTimeout(configuration); - - Assert.Equal(TimeSpan.FromSeconds(120), timeout); - } - - [Fact] - public void ExplicitBackchannelConnectionTimeoutOverridesAppHostStartupTimeout() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [CliConfigNames.AppHostStartupTimeout] = "180", - [KnownConfigNames.CliBackchannelConnectTimeoutSeconds] = "5" - }) - .Build(); - - var timeout = DotNetCliRunner.GetBackchannelConnectionTimeout(configuration); - - Assert.Equal(TimeSpan.FromSeconds(5), timeout); - } - [Fact] public async Task RunAsyncFailsBackchannelWhenAppHostExitsWithSuccessBeforeBackchannelConnects() { diff --git a/tests/Aspire.Cli.Tests/DotNet/ProcessInvocationOptionsTests.cs b/tests/Aspire.Cli.Tests/DotNet/ProcessInvocationOptionsTests.cs index b2cfa135056..68ec824666f 100644 --- a/tests/Aspire.Cli.Tests/DotNet/ProcessInvocationOptionsTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/ProcessInvocationOptionsTests.cs @@ -28,6 +28,9 @@ public void Clone_CopiesEveryProperty() IsolateConsole = true, KillOnParentExit = true, Detached = true, + // Internal properties are included below, so they need non-default values too. + DetachedUnixLauncherPathOverride = "detached-launcher-override", + AppHostArgumentStartIndex = 3, EnvironmentVariableFilter = _ => false, GracefulShutdownSignaler = new RecordingGracefulSignaler(), ShutdownService = new TestGracefulShutdownWindow(), @@ -54,6 +57,8 @@ public void Clone_CopiesEveryProperty() private static IEnumerable GetSettableProperties() => typeof(ProcessInvocationOptions) - .GetProperties(BindingFlags.Public | BindingFlags.Instance) + // NonPublic is included so internal options — such as the redaction boundary carried for + // direct AppHost launches — are held to the same Clone() coverage as public ones. + .GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) .Where(p => p is { CanRead: true, CanWrite: true }); } diff --git a/tests/Aspire.Cli.Tests/Git/GitWorktreeTests.cs b/tests/Aspire.Cli.Tests/Git/GitWorktreeTests.cs new file mode 100644 index 00000000000..e837f0becff --- /dev/null +++ b/tests/Aspire.Cli.Tests/Git/GitWorktreeTests.cs @@ -0,0 +1,498 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Git; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Utils; +using Aspire.Hosting.Utils; + +namespace Aspire.Cli.Tests.Git; + +public class GitWorktreeTests(ITestOutputHelper outputHelper) +{ + [Fact] + public void TryGetLinkedWorktreeRoot_PrimaryCheckout_ReturnsNull() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".git")); + var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj"); + + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot(appHostPath)); + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot(workspace.WorkspaceRoot.FullName)); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_StandardCommonGitDirectory_ReturnsWorktreeRoot() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var worktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "worktree")).FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata( + worktreeRoot, + Path.Combine(fixtureRoot, "primary", ".git")); + var appHostPath = Path.Combine(worktreeRoot, "AppHost", "AppHost.csproj"); + + var linkedRoot = GitWorktree.TryGetLinkedWorktreeRoot(appHostPath); + + Assert.NotNull(linkedRoot); + Assert.Equal(PathNormalizer.ResolveSymlinks(worktreeRoot), linkedRoot); + } + + [Theory] + [InlineData("repo.git")] + [InlineData("separate-git")] + public void TryGetLinkedWorktreeRoot_NonDotGitCommonDirectory_ReturnsWorktreeRoot(string commonGitDirectoryName) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var worktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "worktree")).FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata( + worktreeRoot, + Path.Combine(fixtureRoot, commonGitDirectoryName)); + + var linkedRoot = GitWorktree.TryGetLinkedWorktreeRoot(worktreeRoot); + + Assert.NotNull(linkedRoot); + Assert.Equal(PathNormalizer.ResolveSymlinks(worktreeRoot), linkedRoot); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_RelativeGitDir_ReturnsWorktreeRoot() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var worktreeRoot = workspace.WorkspaceRoot.FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata( + worktreeRoot, + Path.Combine(worktreeRoot, "primary", ".git"), + useRelativePaths: true); + + var linkedRoot = GitWorktree.TryGetLinkedWorktreeRoot(worktreeRoot); + + Assert.NotNull(linkedRoot); + Assert.Equal(PathNormalizer.ResolveSymlinks(worktreeRoot), linkedRoot); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_CheckoutAliasWithRelativeMetadata_ReturnsCanonicalWorktreeRoot() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var worktreeRoot = Directory.CreateDirectory( + Path.Combine(fixtureRoot, "physical", "checkouts", "feature")).FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata( + worktreeRoot, + Path.Combine(fixtureRoot, "physical", "primary", ".git"), + useRelativePaths: true); + var aliasRoot = Path.Combine(fixtureRoot, "aliases", "feature"); + Directory.CreateDirectory(Path.GetDirectoryName(aliasRoot)!); + + try + { + try + { + ReparsePoint.CreateOrReplace(aliasRoot, worktreeRoot); + } + catch (UnauthorizedAccessException ex) + { + Assert.Skip($"Cannot create a directory symlink or junction in this environment: {ex.Message}"); + } + catch (IOException ex) + { + Assert.Skip($"Directory symlink or junction creation failed in this environment: {ex.Message}"); + } + + var linkedRoot = GitWorktree.TryGetLinkedWorktreeRoot(aliasRoot); + + Assert.NotNull(linkedRoot); + Assert.Equal(PathNormalizer.ResolveSymlinks(worktreeRoot), linkedRoot); + } + finally + { + ReparsePoint.RemoveIfExists(aliasRoot); + } + } + + [Fact] + public void TryGetLinkedWorktreeRoot_CaseVariantCheckoutPath_ReturnsFilesystemCanonicalCasing() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var worktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "worktree")).FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata( + worktreeRoot, + Path.Combine(fixtureRoot, "primary", ".git")); + var caseVariantRoot = Path.Combine(fixtureRoot, "WORKTREE"); + if (!Directory.Exists(caseVariantRoot)) + { + Assert.Skip("The test requires a case-insensitive filesystem."); + } + + var linkedRoot = GitWorktree.TryGetLinkedWorktreeRoot(caseVariantRoot); + + Assert.Equal(PathNormalizer.ResolveSymlinks(worktreeRoot), linkedRoot); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_AdminAliasWithRelativeBackPointer_ReturnsCanonicalWorktreeRoot() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var worktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "checkout")).FullName; + var adminDirectory = Directory.CreateDirectory( + Path.Combine(fixtureRoot, "physical", "primary", ".git", "worktrees", "feature")).FullName; + var aliasDirectory = Path.Combine(fixtureRoot, "aliases", "admin"); + Directory.CreateDirectory(Path.GetDirectoryName(aliasDirectory)!); + + try + { + try + { + ReparsePoint.CreateOrReplace(aliasDirectory, adminDirectory); + } + catch (UnauthorizedAccessException ex) + { + Assert.Skip($"Cannot create a directory symlink or junction in this environment: {ex.Message}"); + } + catch (IOException ex) + { + Assert.Skip($"Directory symlink or junction creation failed in this environment: {ex.Message}"); + } + + var gitFilePath = TestGitWorktree.WriteGitDirFile(worktreeRoot, aliasDirectory); + File.WriteAllText( + Path.Combine(adminDirectory, "gitdir"), + Path.GetRelativePath(adminDirectory, gitFilePath) + Environment.NewLine); + + var linkedRoot = GitWorktree.TryGetLinkedWorktreeRoot(worktreeRoot); + + Assert.NotNull(linkedRoot); + Assert.Equal(PathNormalizer.ResolveSymlinks(worktreeRoot), linkedRoot); + } + finally + { + ReparsePoint.RemoveIfExists(aliasDirectory); + } + } + + [Fact] + public void TryGetLinkedWorktreeRoot_GitDirWithTrailingDirectorySeparator_ReturnsWorktreeRoot() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var worktreeRoot = workspace.WorkspaceRoot.FullName; + var adminDirectory = TestGitWorktree.WriteLinkedWorktreeMetadata( + worktreeRoot, + Path.Combine(worktreeRoot, "primary", ".git")); + File.WriteAllText( + Path.Combine(worktreeRoot, ".git"), + $"gitdir: {adminDirectory}{Path.DirectorySeparatorChar}{Environment.NewLine}"); + + var linkedRoot = GitWorktree.TryGetLinkedWorktreeRoot(worktreeRoot); + + Assert.NotNull(linkedRoot); + Assert.Equal(PathNormalizer.ResolveSymlinks(worktreeRoot), linkedRoot); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_UppercaseWorktreesAdminDirectory_UsesPlatformCasing() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var worktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "worktree")).FullName; + var adminDirectory = Path.Combine(fixtureRoot, "primary", ".git", "WORKTREES", "feature"); + var gitFilePath = TestGitWorktree.WriteGitDirFile(worktreeRoot, adminDirectory); + File.WriteAllText( + Path.Combine(adminDirectory, "gitdir"), + gitFilePath + Environment.NewLine); + + var linkedRoot = GitWorktree.TryGetLinkedWorktreeRoot(worktreeRoot); + var expectedRoot = OperatingSystem.IsWindows() + ? PathNormalizer.ResolveSymlinks(worktreeRoot) + : null; + + Assert.Equal(expectedRoot, linkedRoot); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_CaseVariantBackPointer_UsesFilesystemIdentity() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var worktreeRoot = workspace.WorkspaceRoot.FullName; + var adminDirectory = TestGitWorktree.WriteLinkedWorktreeMetadata( + worktreeRoot, + Path.Combine(worktreeRoot, "primary", ".git")); + var caseVariantGitFile = Path.Combine(worktreeRoot, ".GIT"); + File.WriteAllText( + Path.Combine(adminDirectory, "gitdir"), + caseVariantGitFile + Environment.NewLine); + + var linkedRoot = GitWorktree.TryGetLinkedWorktreeRoot(worktreeRoot); + var expectedRoot = File.Exists(caseVariantGitFile) + ? PathNormalizer.ResolveSymlinks(worktreeRoot) + : null; + + Assert.Equal(expectedRoot, linkedRoot); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_SymlinkAliasBackPointer_ReturnsWorktreeRoot() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var worktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "worktree")).FullName; + var adminDirectory = TestGitWorktree.WriteLinkedWorktreeMetadata( + worktreeRoot, + Path.Combine(fixtureRoot, "primary", ".git")); + var aliasRoot = Path.Combine(fixtureRoot, "worktree-alias"); + + try + { + try + { + ReparsePoint.CreateOrReplace(aliasRoot, worktreeRoot); + } + catch (UnauthorizedAccessException ex) + { + Assert.Skip($"Cannot create a directory symlink or junction in this environment: {ex.Message}"); + } + catch (IOException ex) + { + Assert.Skip($"Directory symlink or junction creation failed in this environment: {ex.Message}"); + } + + File.WriteAllText( + Path.Combine(adminDirectory, "gitdir"), + Path.Combine(aliasRoot, ".git") + Environment.NewLine); + + var linkedRoot = GitWorktree.TryGetLinkedWorktreeRoot(worktreeRoot); + + Assert.NotNull(linkedRoot); + Assert.Equal(PathNormalizer.ResolveSymlinks(worktreeRoot), linkedRoot); + } + finally + { + ReparsePoint.RemoveIfExists(aliasRoot); + } + } + + [Fact] + public void TryGetLinkedWorktreeRoot_SubmoduleInsideLinkedWorktree_ReturnsNull() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var worktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "worktree")).FullName; + var adminDirectory = TestGitWorktree.WriteLinkedWorktreeMetadata( + worktreeRoot, + Path.Combine(fixtureRoot, "primary", ".git")); + var submoduleRoot = Directory.CreateDirectory(Path.Combine(worktreeRoot, "extern", "dep")).FullName; + TestGitWorktree.WriteGitDirFile( + submoduleRoot, + Path.Combine(adminDirectory, "modules", "dep")); + + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot(Path.Combine(submoduleRoot, "AppHost.csproj"))); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_ReciprocalBackPointerOutsideWorktrees_ReturnsNull() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var worktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "worktree")).FullName; + var adminDirectory = Path.Combine(fixtureRoot, "primary", ".git", "modules", "dep"); + var gitFilePath = TestGitWorktree.WriteGitDirFile(worktreeRoot, adminDirectory); + File.WriteAllText( + Path.Combine(adminDirectory, "gitdir"), + gitFilePath + Environment.NewLine); + + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot(worktreeRoot)); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_BackPointerToDifferentCheckout_ReturnsNull() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var commonGitDirectory = Path.Combine(fixtureRoot, "primary", ".git"); + var worktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "worktree")).FullName; + var adminDirectory = TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, commonGitDirectory); + var otherWorktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "other-worktree")).FullName; + var otherGitFile = TestGitWorktree.WriteGitDirFile( + otherWorktreeRoot, + Path.Combine(commonGitDirectory, "worktrees", "other")); + File.WriteAllText( + Path.Combine(adminDirectory, "gitdir"), + otherGitFile + Environment.NewLine); + + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot(worktreeRoot)); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_DecoyWithoutBackPointer_ReturnsNull() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var fixtureRoot = workspace.WorkspaceRoot.FullName; + var worktreeRoot = Directory.CreateDirectory(Path.Combine(fixtureRoot, "worktree")).FullName; + TestGitWorktree.WriteGitDirFile( + worktreeRoot, + Path.Combine(fixtureRoot, "primary", ".git", "worktrees", "stale")); + + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot(worktreeRoot)); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_SubmoduleGitFile_ReturnsNull() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".git")); + var submoduleRoot = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "extern", "dep")).FullName; + TestGitWorktree.WriteGitDirFile( + submoduleRoot, + Path.Combine(workspace.WorkspaceRoot.FullName, ".git", "modules", "dep")); + var appHostPath = Path.Combine(submoduleRoot, "AppHost.csproj"); + + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot(appHostPath)); + } + + [Fact] + public void TryGetLinkedWorktreeRoot_NotAGitRepo_ReturnsNull() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot(workspace.WorkspaceRoot.FullName)); + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot(null)); + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot("")); + } + + [Fact] + public void IsSameWorktreeScope_NestedLinkedWorktree_IsOutOfScopeOfPrimary() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var primaryRoot = workspace.WorkspaceRoot.FullName; + Directory.CreateDirectory(Path.Combine(primaryRoot, ".git")); + var worktreeRoot = Directory.CreateDirectory(Path.Combine(primaryRoot, ".worktrees", "feature")).FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, Path.Combine(primaryRoot, ".git")); + var nestedAppHost = Path.Combine(worktreeRoot, "AppHost.csproj"); + var primaryAppHost = Path.Combine(primaryRoot, "AppHost.csproj"); + + Assert.False(GitWorktree.IsSameWorktreeScope(nestedAppHost, primaryRoot)); + Assert.True(GitWorktree.IsSameWorktreeScope(primaryAppHost, primaryRoot)); + Assert.True(GitWorktree.IsSameWorktreeScope(nestedAppHost, worktreeRoot)); + Assert.False(GitWorktree.IsSameWorktreeScope(primaryAppHost, worktreeRoot)); + } + + [Fact] + public void IsSameWorktreeScope_SubmoduleInsideLinkedWorktree_UsesEnclosingWorktree() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var primaryRoot = workspace.WorkspaceRoot.FullName; + Directory.CreateDirectory(Path.Combine(primaryRoot, ".git")); + var worktreeRoot = Directory.CreateDirectory(Path.Combine(primaryRoot, ".worktrees", "feature")).FullName; + var adminDirectory = TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, Path.Combine(primaryRoot, ".git")); + var submoduleRoot = Directory.CreateDirectory(Path.Combine(worktreeRoot, "extern", "dep")).FullName; + TestGitWorktree.WriteGitDirFile(submoduleRoot, Path.Combine(adminDirectory, "modules", "dep")); + var submoduleAppHost = Path.Combine(submoduleRoot, "AppHost.csproj"); + + Assert.Null(GitWorktree.TryGetLinkedWorktreeRoot(submoduleAppHost)); + Assert.True(GitWorktree.IsSameWorktreeScope(submoduleAppHost, worktreeRoot)); + Assert.False(GitWorktree.IsSameWorktreeScope(submoduleAppHost, primaryRoot)); + } + + [Fact] + public void IsSameWorktreeScope_Submodule_RemainsInScopeOfPrimary() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var primaryRoot = workspace.WorkspaceRoot.FullName; + Directory.CreateDirectory(Path.Combine(primaryRoot, ".git")); + var submoduleRoot = Directory.CreateDirectory(Path.Combine(primaryRoot, "extern", "dep")).FullName; + TestGitWorktree.WriteGitDirFile( + submoduleRoot, + Path.Combine(primaryRoot, ".git", "modules", "dep")); + var submoduleAppHost = Path.Combine(submoduleRoot, "AppHost.csproj"); + + Assert.True(GitWorktree.IsSameWorktreeScope(submoduleAppHost, primaryRoot)); + } + + [Fact] + public void IsSameWorktreeScope_AppHostNestedInSamePrimaryCheckout_IsSameScope() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var primaryRoot = CreatePrimaryCheckout(workspace.WorkspaceRoot, "repoA"); + var appHostPath = Path.Combine(primaryRoot, "sub", "App", "App.csproj"); + + Assert.True(GitWorktree.IsSameWorktreeScope(appHostPath, primaryRoot)); + } + + [Fact] + public void IsSameWorktreeScope_AppHostInDifferentPrimaryCheckout_IsDifferentScope() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var repoARoot = CreatePrimaryCheckout(workspace.WorkspaceRoot, "repoA"); + var repoBRoot = CreatePrimaryCheckout(workspace.WorkspaceRoot, "repoB"); + var repoBAppHost = Path.Combine(repoBRoot, "App", "App.csproj"); + + Assert.False(GitWorktree.IsSameWorktreeScope(repoBAppHost, repoARoot)); + Assert.False(GitWorktree.IsSameWorktreeScope(Path.Combine(repoARoot, "App", "App.csproj"), repoBRoot)); + } + + [Fact] + public void IsSameWorktreeScope_SubmodulesInDifferentPrimaryCheckouts_AreDifferentScopes() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var repoARoot = CreatePrimaryCheckout(workspace.WorkspaceRoot, "repoA"); + var repoBRoot = CreatePrimaryCheckout(workspace.WorkspaceRoot, "repoB"); + var repoBSubmoduleRoot = Directory.CreateDirectory(Path.Combine(repoBRoot, "extern", "dep")).FullName; + TestGitWorktree.WriteGitDirFile( + repoBSubmoduleRoot, + Path.Combine(repoBRoot, ".git", "modules", "dep")); + var repoBSubmoduleAppHost = Path.Combine(repoBSubmoduleRoot, "AppHost.csproj"); + + Assert.True(GitWorktree.IsSameWorktreeScope(repoBSubmoduleAppHost, repoBRoot)); + Assert.False(GitWorktree.IsSameWorktreeScope(repoBSubmoduleAppHost, repoARoot)); + } + + [Fact] + public void IsSameWorktreeScope_PrimaryCheckoutAndNestedLinkedWorktree_AreDifferentScopes() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var primaryRoot = CreatePrimaryCheckout(workspace.WorkspaceRoot, "repoA"); + var worktreeRoot = Directory.CreateDirectory(Path.Combine(primaryRoot, ".worktrees", "feature")).FullName; + TestGitWorktree.WriteLinkedWorktreeMetadata(worktreeRoot, Path.Combine(primaryRoot, ".git")); + + Assert.False(GitWorktree.IsSameWorktreeScope(Path.Combine(worktreeRoot, "App", "App.csproj"), primaryRoot)); + Assert.False(GitWorktree.IsSameWorktreeScope(Path.Combine(primaryRoot, "App", "App.csproj"), worktreeRoot)); + } + + [Fact] + public void IsSameWorktreeScope_BothOutsideAnyGitRepository_IsSameScope() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var workingDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "plain")).FullName; + var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "other", "App.csproj"); + + Assert.True(GitWorktree.IsSameWorktreeScope(appHostPath, workingDirectory)); + } + + [Fact] + public void IsSameWorktreeScope_AppHostOutsideAnyGitRepository_IsDifferentScopeFromCheckout() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var primaryRoot = CreatePrimaryCheckout(workspace.WorkspaceRoot, "repoA"); + var unmanagedDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "outside")).FullName; + var unmanagedAppHost = Path.Combine(unmanagedDirectory, "App.csproj"); + + Assert.False(GitWorktree.IsSameWorktreeScope(unmanagedAppHost, primaryRoot)); + Assert.False(GitWorktree.IsSameWorktreeScope(Path.Combine(primaryRoot, "App", "App.csproj"), unmanagedDirectory)); + } + + /// + /// Creates a primary checkout (a real .git directory) under so + /// scope comparisons are deterministic regardless of whether the test host's temp directory + /// happens to sit inside a git repository. + /// + private static string CreatePrimaryCheckout(DirectoryInfo parent, string name) + { + var root = Directory.CreateDirectory(Path.Combine(parent.FullName, name)).FullName; + Directory.CreateDirectory(Path.Combine(root, ".git")); + + return root; + } +} diff --git a/tests/Aspire.Cli.Tests/Layout/LayoutConfigurationTests.cs b/tests/Aspire.Cli.Tests/Layout/LayoutConfigurationTests.cs new file mode 100644 index 00000000000..7221839b7c9 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Layout/LayoutConfigurationTests.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Layout; + +namespace Aspire.Cli.Tests.LayoutTests; + +public class LayoutConfigurationTests +{ + [Fact] + public void LayoutComponentValuesRemainStable() + { + Assert.Equal(0, (int)LayoutComponent.Cli); + Assert.Equal(1, (int)LayoutComponent.Dcp); + Assert.Equal(2, (int)LayoutComponent.Managed); + Assert.Equal(3, (int)LayoutComponent.Dashboard); + } +} \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs b/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs index 08a207b2c42..ad3e4e94ee4 100644 --- a/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs +++ b/tests/Aspire.Cli.Tests/Layout/LayoutDiscoveryReparsePointTests.cs @@ -96,12 +96,16 @@ public void DiscoverLayout_ResolvesThroughReparsePoints() var versionsDir = Path.Combine(layoutRoot, "versions", "v1"); var versionedManaged = Path.Combine(versionsDir, BundleDiscovery.ManagedDirectoryName); + var versionedDashboard = Path.Combine(versionsDir, BundleDiscovery.DashboardDirectoryName); var versionedDcp = Path.Combine(versionsDir, BundleDiscovery.DcpDirectoryName); Directory.CreateDirectory(versionedManaged); + Directory.CreateDirectory(versionedDashboard); Directory.CreateDirectory(versionedDcp); File.WriteAllText( Path.Combine(versionedManaged, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), "stub"); + var dashboardPath = Path.Combine(versionedDashboard, BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + File.WriteAllText(dashboardPath, "stub"); File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(versionedDcp), "stub"); // Create a single bundle/ link pointing at the versioned directory. @@ -119,6 +123,9 @@ public void DiscoverLayout_ResolvesThroughReparsePoints() Assert.NotNull(layout); Assert.Equal(layoutRoot, layout!.LayoutPath); + Assert.Equal( + Path.Combine(bundleLink, BundleDiscovery.DashboardDirectoryName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)), + layout.GetDashboardPath()); Assert.True(discovery.IsBundleModeAvailable()); } finally @@ -244,12 +251,17 @@ private static void CreateValidBundleLayout(string layoutRoot) { var bundleDir = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName); var managedDir = Path.Combine(bundleDir, BundleDiscovery.ManagedDirectoryName); + var dashboardDir = Path.Combine(bundleDir, BundleDiscovery.DashboardDirectoryName); var dcpDir = Path.Combine(bundleDir, BundleDiscovery.DcpDirectoryName); Directory.CreateDirectory(managedDir); + Directory.CreateDirectory(dashboardDir); Directory.CreateDirectory(dcpDir); File.WriteAllText( Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), "stub"); + File.WriteAllText( + Path.Combine(dashboardDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)), + "stub"); File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDir), "stub"); } } diff --git a/tests/Aspire.Cli.Tests/Layout/LayoutProcessRunnerTests.cs b/tests/Aspire.Cli.Tests/Layout/LayoutProcessRunnerTests.cs index 7fa45708ffb..6e6e9f0583b 100644 --- a/tests/Aspire.Cli.Tests/Layout/LayoutProcessRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/Layout/LayoutProcessRunnerTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; -using System.Reflection; using Aspire.Cli.DotNet; using Aspire.Cli.Layout; using Aspire.Cli.Tests.TestServices; @@ -53,7 +52,7 @@ public async Task RunAsync_DoesNotOverrideCallerSuppliedOrphanEnvironment() [Fact] public async Task StartAsync_InjectsOrphanDetectionEnvironment() { - // StartAsync launches long-lived children (aspire-managed dashboard for `aspire dashboard run` + // StartAsync launches long-lived children (the Native AOT Dashboard for `aspire dashboard run` // and the profiling collector), so it must stamp the same orphan-detection identity as RunAsync // or those children cannot self-terminate when the CLI is hard-killed. IDictionary? capturedEnv = null; @@ -228,81 +227,11 @@ public async Task RunAsync_WhenKillOnParentExit_DisarmsCooperativeWatchdogOnWind } } - [Fact] - public async Task RunAsync_WhenProcessDoesNotExitWithinTimeout_ThrowsTimeoutException() - { - var factory = new TestProcessExecutionFactory - { - AsyncAttemptCallback = async (_, _, cancellationToken) => - { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); - - return (0, null); - } - }; - var runner = new LayoutProcessRunner(factory); - using var cancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - - var exception = await Assert.ThrowsAsync(() => runner.RunAsync( - "aspire-managed", - ["nuget", "search", "--query", "Aspire.Hosting"], - timeout: TimeSpan.FromMilliseconds(50), - ct: cancellationSource.Token)); - - Assert.Contains("NuGet package search", exception.Message); - Assert.Contains("Aspire.Hosting", exception.Message); - Assert.Contains("timed out", exception.Message); - } - - [Theory] - [InlineData(3_600_000, "1 hour")] - [InlineData(7_200_000, "2 hours")] - [InlineData(60_000, "1 minute")] - [InlineData(180_000, "3 minutes")] - [InlineData(1_000, "1 second")] - [InlineData(2_000, "2 seconds")] - [InlineData(1, "1 millisecond")] - [InlineData(2, "2 milliseconds")] - [InlineData(1_000.4, "1 second")] - [InlineData(1.0004, "1 millisecond")] - public void BuildTimeoutMessage_FormatsTimeoutUnits(double timeoutMilliseconds, string expectedTimeoutText) - { - var timeout = TimeSpan.FromMilliseconds(timeoutMilliseconds); - var workingDirectory = new DirectoryInfo(AppContext.BaseDirectory); - - var message = BuildTimeoutMessage(timeout, workingDirectory); - - Assert.Equal($"Process 'aspire-managed' timed out after {expectedTimeoutText} in '{workingDirectory.FullName}'.", message); - } - - [Fact] - public async Task RunAsync_WhenCallerTokenIsCanceled_PropagatesCancellation() - { - var factory = new TestProcessExecutionFactory - { - AsyncAttemptCallback = async (_, _, cancellationToken) => - { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); - - return (0, null); - } - }; - var runner = new LayoutProcessRunner(factory); - - using var cancellationSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); - - await Assert.ThrowsAnyAsync(() => runner.RunAsync( - "aspire-managed", - ["nuget", "search", "--query", "Aspire.Hosting"], - timeout: TimeSpan.FromMinutes(1), - ct: cancellationSource.Token)); - } - [Fact] public async Task StartAsync_WhenKillOnParentExit_DisarmsCooperativeWatchdogOnWindowsOnly() { // Same mutual-exclusion contract as RunAsync above, for the long-lived StartAsync helpers - // (aspire-managed dashboard, profiling collector). + // (Dashboard, profiling collector). IDictionary? capturedEnv = null; ProcessInvocationOptions? capturedOptions = null; var factory = new TestProcessExecutionFactory @@ -326,12 +255,4 @@ public async Task StartAsync_WhenKillOnParentExit_DisarmsCooperativeWatchdogOnWi Assert.True(capturedEnv.ContainsKey("ASPIRE_CLI_PID")); } } - - private static string BuildTimeoutMessage(TimeSpan timeout, DirectoryInfo workingDirectory) - { - var method = typeof(LayoutProcessRunner).GetMethod("BuildTimeoutMessage", BindingFlags.NonPublic | BindingFlags.Static); - Assert.NotNull(method); - - return Assert.IsType(method.Invoke(null, new object[] { "aspire-managed", Array.Empty(), workingDirectory, timeout })); - } } diff --git a/tests/Aspire.Cli.Tests/NuGet/BundleNuGetPackageCacheTests.cs b/tests/Aspire.Cli.Tests/NuGet/BundleNuGetPackageCacheTests.cs index 21e45de25fe..7633d2e00f9 100644 --- a/tests/Aspire.Cli.Tests/NuGet/BundleNuGetPackageCacheTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/BundleNuGetPackageCacheTests.cs @@ -111,4 +111,62 @@ await cache.GetPackageVersionsAsync( Assert.True(executionFactory.LastProcessInvocationOptions?.KillOnParentExit); } + + [Fact] + public async Task GetPackageVersionsAsync_ToleratesCredentialProviderOutputAroundPayload() + { + // Credential-provider diagnostics use an inherited stdout handle, so braced text or JSON can arrive before + // the package-search payload and additional output can arrive after it. The diagnostic JSON deliberately + // has the expected root array but omits other required bundle-search fields. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var layout = new LayoutConfiguration + { + LayoutPath = workspace.WorkspaceRoot.FullName, + Components = new LayoutComponents + { + Managed = "managed" + } + }; + + var managedDirectory = workspace.WorkspaceRoot.CreateSubdirectory("managed"); + var managedPath = layout.GetManagedPath(); + Assert.NotNull(managedPath); + await File.WriteAllTextAsync(managedPath!, string.Empty); + + var bundleService = new TestBundleService(isBundle: true) + { + Layout = layout + }; + + var pollutedStdout = + " [CredentialProvider]Acquiring token for request {request-42}\n" + + """{"packages":[]}""" + "\n" + + """{"packages":[{"id":"Aspire.Hosting.Redis","version":"13.3.0","allVersions":["13.3.0","13.2.0"],"source":"nuget.org"}],"totalHits":1}""" + + "\n [CredentialProvider]VstsCredentialProvider - Acquired bearer token using 'MSAL Silent'"; + + var executionFactory = new TestProcessExecutionFactory + { + AttemptCallback = (_, _) => (0, pollutedStdout) + }; + + var cache = new BundleNuGetPackageCache( + bundleService, + new LayoutProcessRunner(executionFactory), + NullLogger.Instance, + new TestFeatures()); + + var packages = (await cache.GetPackageVersionsAsync( + workspace.WorkspaceRoot, + "Aspire.Hosting.Redis", + prerelease: false, + nugetConfigFile: null, + useCache: true, + CancellationToken.None)).OrderBy(package => package.Version).ToArray(); + + Assert.Collection( + packages, + package => Assert.Equal("13.2.0", package.Version), + package => Assert.Equal("13.3.0", package.Version)); + } } diff --git a/tests/Aspire.Cli.Tests/ProfileCaptureOptionsTests.cs b/tests/Aspire.Cli.Tests/ProfileCaptureOptionsTests.cs index 85b799cde93..e0125e0935f 100644 --- a/tests/Aspire.Cli.Tests/ProfileCaptureOptionsTests.cs +++ b/tests/Aspire.Cli.Tests/ProfileCaptureOptionsTests.cs @@ -104,45 +104,45 @@ public void TryCreate_UsesDefaultOutputFileNameWhenOutputEndsInDirectorySeparato } [Fact] - public void ResolveRepoLocalManagedPath_ReturnsNullWithoutRepoRoot() + public void ResolveRepoLocalDashboardPath_ReturnsNullWithoutRepoRoot() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var managedBaseDirectory = Directory.CreateDirectory(Path.Combine( + var dashboardBaseDirectory = Directory.CreateDirectory(Path.Combine( workspace.WorkspaceRoot.FullName, "artifacts", "bin", - "Aspire.Managed", + "Aspire.Dashboard", "Debug", - "net10.0")); - var expectedManagedPath = Path.Combine( - managedBaseDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(expectedManagedPath, string.Empty); + "net11.0")); + var expectedDashboardPath = Path.Combine( + dashboardBaseDirectory.FullName, + BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + File.WriteAllText(expectedDashboardPath, string.Empty); - var resolvedPath = ProfileCaptureService.ResolveRepoLocalManagedPath(repoRoot: null); + var resolvedPath = ProfileCaptureService.ResolveRepoLocalDashboardPath(repoRoot: null); Assert.Null(resolvedPath); } [Fact] - public void ResolveRepoLocalManagedPath_UsesOptInRepoRoot() + public void ResolveRepoLocalDashboardPath_UsesOptInRepoRoot() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var managedBaseDirectory = Directory.CreateDirectory(Path.Combine( + var dashboardBaseDirectory = Directory.CreateDirectory(Path.Combine( workspace.WorkspaceRoot.FullName, "artifacts", "bin", - "Aspire.Managed", + "Aspire.Dashboard", "Debug", - "net10.0")); - var expectedManagedPath = Path.Combine( - managedBaseDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(expectedManagedPath, string.Empty); + "net11.0")); + var expectedDashboardPath = Path.Combine( + dashboardBaseDirectory.FullName, + BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + File.WriteAllText(expectedDashboardPath, string.Empty); - var resolvedPath = ProfileCaptureService.ResolveRepoLocalManagedPath(workspace.WorkspaceRoot.FullName); + var resolvedPath = ProfileCaptureService.ResolveRepoLocalDashboardPath(workspace.WorkspaceRoot.FullName); - Assert.Equal(expectedManagedPath, resolvedPath); + Assert.Equal(expectedDashboardPath, resolvedPath); } [Fact] diff --git a/tests/Aspire.Cli.Tests/ProfileCaptureServiceTests.cs b/tests/Aspire.Cli.Tests/ProfileCaptureServiceTests.cs index b1a546f826c..ed72c64da34 100644 --- a/tests/Aspire.Cli.Tests/ProfileCaptureServiceTests.cs +++ b/tests/Aspire.Cli.Tests/ProfileCaptureServiceTests.cs @@ -110,6 +110,38 @@ public async Task StartAsync_UsesBundleLayoutManagedPath_WhenOverrideIsAbsent() Assert.Equal(managedPath, processFactory.LastFileName); } + [Fact] + public async Task StartAsync_UsesBundleLayoutDashboardPath_WhenNativeDashboardIsPresent() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + using var fileLoggerProvider = CreateFileLoggerProvider(workspace); + var layoutRoot = workspace.WorkspaceRoot.CreateSubdirectory("bundle"); + var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); + File.WriteAllText( + Path.Combine(managedDirectory.FullName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)), + string.Empty); + var dashboardDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.DashboardDirectoryName); + var dashboardPath = Path.Combine(dashboardDirectory.FullName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + File.WriteAllText(dashboardPath, string.Empty); + + var bundleService = new TestBundleService(isBundle: true) + { + Layout = new LayoutConfiguration { LayoutPath = layoutRoot.FullName } + }; + var processFactory = CreateRunningProcessFactory(); + var service = CreateService( + fileLoggerProvider, + processFactory, + new MockHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK)), + CreateConfiguration(), + bundleService); + + await using var session = await service.StartAsync(CreateOptions(workspace), s_testTimeout, s_testPollInterval, CancellationToken.None); + + Assert.Equal(dashboardPath, processFactory.LastFileName); + Assert.DoesNotContain("dashboard", Assert.IsType(processFactory.LastArguments)); + } + [Fact] public async Task StartAsync_ThrowsManagedBinaryNotFound_WhenNoManagedBinaryCanBeResolved() { diff --git a/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs b/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs index 521b0a74450..5752c78e200 100644 --- a/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs @@ -7,7 +7,7 @@ namespace Aspire.Cli.Tests.Projects; -public class DefaultLanguageDiscoveryTests +public class DefaultLanguageDiscoveryTests(ITestOutputHelper outputHelper) { [Fact] public async Task GetAvailableLanguagesAsync_ReturnsCSharpLanguage() @@ -170,6 +170,43 @@ public void GetLanguageById_ReturnsCorrectLanguage(string languageId) Assert.Equal(languageId, language.LanguageId.Value); } + /// + /// Covers the two hops an AppHost makes on the way to a project handler: the recursive scan that + /// locates the file, and the language resolution that runs on whatever the scan returned. + /// + /// + /// The two hops match differently, which is easy to misread as a gap. + /// passes each pattern to Directory.EnumerateFiles, which accepts a relative directory in the + /// pattern, so src/main/java/AppHost.java locates the conventional Maven and Gradle layout. + /// GetLanguageByFile then sees only FileInfo.Name, so that same pattern cannot match + /// there and resolution happens through the bare AppHost.java pattern instead. Both patterns + /// are therefore load-bearing, and dropping the bare one as redundant would leave a conventional + /// AppHost that is found but has no language. + /// + [Theory] + [InlineData("AppHost.java")] + [InlineData("src/main/java/AppHost.java")] + public void JavaAppHostIsBothLocatedAndResolvedInEveryLayout(string relativePath) + { + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.ExperimentalPolyglotJava, true); + var discovery = new DefaultLanguageDiscovery(features); + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var appHostPath = Path.Combine(workspace.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(appHostPath)!); + File.WriteAllText(appHostPath, "void main(String[] args) { }"); + + var java = discovery.GetLanguageById(new LanguageId(KnownLanguageId.Java)); + Assert.NotNull(java); + + var located = java.FindInDirectory(workspace.Path); + Assert.Equal(appHostPath, located); + + var language = discovery.GetLanguageByFile(new FileInfo(located!)); + Assert.NotNull(language); + Assert.Equal(KnownLanguageId.Java, language.LanguageId.Value); + } [Fact] public void GetLanguageById_ReturnsNullForUnknownLanguage() { diff --git a/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs index 80ec87a8bbb..884ee05e3d7 100644 --- a/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/DotNetAppHostProjectTests.cs @@ -1040,6 +1040,13 @@ public async Task RunAsync_ProjectAppHostUsesDirectCommandLaunchAndAppliesLaunch Assert.Equal( ["--from-msbuild", "two words", "--explicit", "1"], args); + // The direct command line carries no "--" separator, so the boundary between the + // MSBuild-supplied arguments and the user's AppHost arguments travels in the options + // and is what keeps "--explicit 1" out of logs and traces. + Assert.Equal(2, options.AppHostArgumentStartIndex); + Assert.Equal( + "--from-msbuild two words ", + AppHostArgumentRedactor.RedactFromToString(args, options.AppHostArgumentStartIndex!.Value)); Assert.NotNull(env); Assert.Equal("http", env["DOTNET_LAUNCH_PROFILE"]); Assert.Equal("http://localhost:15000", env[KnownAspNetCoreConfigNames.Urls]); diff --git a/tests/Aspire.Cli.Tests/Projects/DotNetBasedAppHostServerScaffoldCacheTests.cs b/tests/Aspire.Cli.Tests/Projects/DotNetBasedAppHostServerScaffoldCacheTests.cs new file mode 100644 index 00000000000..634396712a2 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/DotNetBasedAppHostServerScaffoldCacheTests.cs @@ -0,0 +1,173 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Configuration; +using Aspire.Cli.Projects; +using Aspire.Cli.Tests.Mcp; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Projects; + +/// +/// Guards the scaffold fingerprint cache on . +/// Scaffolding used to delete obj/ on every run, which discarded NuGet's restore output and +/// forced a full restore plus a full project-reference graph walk on every in-repo +/// aspire run. The scaffold is now rewritten only when its generated content actually +/// changes. These tests pin that contract in both directions. +/// +public class DotNetBasedAppHostServerScaffoldCacheTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task CreateProjectFiles_PreservesRestoreArtifacts_WhenScaffoldContentUnchanged() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appPath = workspace.WorkspaceRoot.FullName; + var project = CreateProject(appPath); + + IntegrationReference[] integrations = [IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")]; + + await project.CreateProjectFilesAsync(integrations); + var assetsPath = SeedRestoreArtifacts(project); + + await project.CreateProjectFilesAsync(integrations); + + Assert.True(File.Exists(assetsPath), + "obj/project.assets.json was deleted even though the scaffold content did not change."); + } + + [Fact] + public async Task CreateProjectFiles_ClearsRestoreArtifacts_WhenIntegrationSetChanges() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appPath = workspace.WorkspaceRoot.FullName; + var project = CreateProject(appPath); + + await project.CreateProjectFilesAsync([IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")]); + var assetsPath = SeedRestoreArtifacts(project); + + // A different integration set produces a different csproj, so the previous restore no + // longer describes this project and must not be reused. Aspire.Hosting.* names are + // resolved as in-repo project references and dropped when missing, so use a package that + // actually lands in the csproj as a PackageReference. + await project.CreateProjectFilesAsync( + [ + IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"), + IntegrationReference.FromPackage("Contoso.Widgets", "1.0.0"), + ]); + + Assert.False(File.Exists(assetsPath), + "obj/project.assets.json survived even though the integration set changed."); + + var csproj = await File.ReadAllTextAsync( + Path.Combine(project.ProjectModelPath, DotNetBasedAppHostServerProject.ProjectFileName)); + Assert.Contains("Contoso.Widgets", csproj); + } + + [Fact] + public async Task CreateProjectFiles_RewritesScaffold_WhenAScaffoldFileWasDeleted() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appPath = workspace.WorkspaceRoot.FullName; + var project = CreateProject(appPath); + + IntegrationReference[] integrations = [IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")]; + + await project.CreateProjectFilesAsync(integrations); + SeedRestoreArtifacts(project); + + // Simulate a hand-deleted or half-written scaffold. The fingerprint still matches, so only + // the existence guard can catch this. + var programPath = Path.Combine(project.ProjectModelPath, "Program.cs"); + File.Delete(programPath); + + await project.CreateProjectFilesAsync(integrations); + + Assert.True(File.Exists(programPath), "Program.cs was not restored after being deleted."); + } + + [Fact] + public async Task CreateProjectFiles_RewritesScaffold_WhenRestoreArtifactsAreAbsent() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appPath = workspace.WorkspaceRoot.FullName; + var project = CreateProject(appPath); + + IntegrationReference[] integrations = [IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")]; + + await project.CreateProjectFilesAsync(integrations); + + // A half-restored obj/ with no project.assets.json is what an interrupted restore leaves + // behind. The fingerprint matches, so only the assets check can force the clean rebuild. + var objPath = Path.Combine(project.ProjectModelPath, "obj"); + Directory.CreateDirectory(objPath); + var stalePath = Path.Combine(objPath, "stale.cache"); + File.WriteAllText(stalePath, "partial restore"); + + await project.CreateProjectFilesAsync(integrations); + + Assert.False(File.Exists(stalePath), + "obj/ was reused even though no project.assets.json was present to restore from."); + } + + [Fact] + public async Task CreateProjectFiles_ClearsRestoreArtifacts_WhenProjectReferencePathChanges() + { + // Pointing an integration at a local checkout is the in-repo dev-speed path: edits to that + // project flow straight into the next build without a repack. The cache must not pin an + // earlier target, so retargeting the reference has to rewrite the scaffold. + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appPath = workspace.WorkspaceRoot.FullName; + var project = CreateProject(appPath); + + var firstProject = Path.Combine(appPath, "v1", "Contoso.Widgets.csproj"); + var secondProject = Path.Combine(appPath, "v2", "Contoso.Widgets.csproj"); + + await project.CreateProjectFilesAsync( + [IntegrationReference.FromProject("Contoso.Widgets", firstProject)]); + var assetsPath = SeedRestoreArtifacts(project); + + var csprojPath = Path.Combine(project.ProjectModelPath, DotNetBasedAppHostServerProject.ProjectFileName); + Assert.Contains(firstProject, await File.ReadAllTextAsync(csprojPath)); + + await project.CreateProjectFilesAsync( + [IntegrationReference.FromProject("Contoso.Widgets", secondProject)]); + + Assert.False(File.Exists(assetsPath), + "obj/project.assets.json survived even though the project reference was retargeted."); + Assert.Contains(secondProject, await File.ReadAllTextAsync(csprojPath)); + } + + /// + /// Writes the marker file the cache uses to decide whether a usable restore already exists. + /// Real restores create far more under obj/, but project.assets.json is the file the skip path + /// checks and the one whose loss forces a full restore. + /// + private static string SeedRestoreArtifacts(DotNetBasedAppHostServerProject project) + { + var objPath = Path.Combine(project.ProjectModelPath, "obj"); + Directory.CreateDirectory(objPath); + var assetsPath = Path.Combine(objPath, "project.assets.json"); + File.WriteAllText(assetsPath, "{}"); + return assetsPath; + } + + private static DotNetBasedAppHostServerProject CreateProject(string appPath) + { + // Pin ProjectModelPath inside the workspace so test artifacts don't bleed into the user's + // ~/.aspire/hosts directory. + var projectModelPath = Path.Combine(appPath, ".aspire_server"); + + return new DotNetBasedAppHostServerProject( + appPath, + socketPath: "test.sock", + repoRoot: appPath, + new TestDotNetCliRunner(), + MockPackagingServiceFactory.Create(), + new TestProcessExecutionFactory(), + new TestEnvironment(), + NullLogger.Instance, + projectModelPath); + } +} diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 9be46b6fd95..d0afbb45056 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -45,6 +45,118 @@ public void Dispose() GC.SuppressFinalize(this); } + [Fact] + public async Task PruneObsoleteGeneratedFilesAsync_RemovesFilesTheGeneratorNoLongerProduces() + { + var outputPath = Path.Combine(_workspace.WorkspaceRoot.FullName, ".aspire", "modules"); + Directory.CreateDirectory(Path.Combine(outputPath, "aspire")); + var kept = Path.Combine(outputPath, "aspire", "Kept.java"); + var obsolete = Path.Combine(outputPath, "aspire", "RemovedResource.java"); + await File.WriteAllTextAsync(kept, "class Kept { }"); + await File.WriteAllTextAsync(obsolete, "class RemovedResource { }"); + + // The first generation records what it wrote. + await GuestAppHostProject.PruneObsoleteGeneratedFilesAsync( + outputPath, + [Path.Combine("aspire", "Kept.java"), Path.Combine("aspire", "RemovedResource.java")], + CancellationToken.None); + + // Dropping the package that produced RemovedResource.java means the generator stops emitting it. + // javac compiles everything under the source root, so leaving it behind fails the build with a + // reference to a type that no longer exists. + await GuestAppHostProject.PruneObsoleteGeneratedFilesAsync( + outputPath, + [Path.Combine("aspire", "Kept.java")], + CancellationToken.None); + + Assert.True(File.Exists(kept)); + Assert.False(File.Exists(obsolete)); + } + + [Fact] + public async Task PruneObsoleteGeneratedFilesAsync_LeavesFilesTheGeneratorNeverWrote() + { + var outputPath = Path.Combine(_workspace.WorkspaceRoot.FullName, ".aspire", "modules"); + Directory.CreateDirectory(outputPath); + var handWritten = Path.Combine(outputPath, "HandWritten.java"); + await File.WriteAllTextAsync(handWritten, "class HandWritten { }"); + + // Only files a previous generation recorded are eligible, so nothing Aspire did not write is + // ever deleted - including on the very first run, where there is no manifest at all. + await GuestAppHostProject.PruneObsoleteGeneratedFilesAsync(outputPath, ["Generated.java"], CancellationToken.None); + await GuestAppHostProject.PruneObsoleteGeneratedFilesAsync(outputPath, ["Generated.java"], CancellationToken.None); + + Assert.True(File.Exists(handWritten)); + } + + [Fact] + public async Task WriteGeneratedFileAsync_WhenContentIsUnchanged_LeavesTimestampAlone() + { + var filePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "Generated.java"); + await File.WriteAllTextAsync(filePath, "class Generated { }"); + var originalWriteTime = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(filePath, originalWriteTime); + + var written = await GuestAppHostProject.WriteGeneratedFileAsync(filePath, "class Generated { }", preserveUnchangedFiles: true, CancellationToken.None); + + Assert.False(written); + // The timestamp is the contract: every downstream incremental build decides what to recompile + // from it, so regenerating identical content must not look like a change. + Assert.Equal(originalWriteTime, File.GetLastWriteTimeUtc(filePath)); + } + + [Fact] + public async Task WriteGeneratedFileAsync_WhenContentDiffers_WritesFile() + { + var filePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "Generated.java"); + await File.WriteAllTextAsync(filePath, "class Generated { }"); + File.SetLastWriteTimeUtc(filePath, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + var written = await GuestAppHostProject.WriteGeneratedFileAsync(filePath, "class Generated { int x; }", preserveUnchangedFiles: true, CancellationToken.None); + + Assert.True(written); + Assert.Equal("class Generated { int x; }", await File.ReadAllTextAsync(filePath)); + Assert.True(File.GetLastWriteTimeUtc(filePath) > new DateTime(2020, 1, 2, 0, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public async Task WriteGeneratedFileAsync_WhenFileIsMissing_WritesFile() + { + var filePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "New.java"); + + var written = await GuestAppHostProject.WriteGeneratedFileAsync(filePath, "class New { }", preserveUnchangedFiles: true, CancellationToken.None); + + Assert.True(written); + Assert.Equal("class New { }", await File.ReadAllTextAsync(filePath)); + } + + [Fact] + public async Task WriteGeneratedFileAsync_WhenNotPreservingUnchangedFiles_RewritesIdenticalContent() + { + var filePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "aspire_app.py"); + await File.WriteAllTextAsync(filePath, "def add_redis(): ..."); + var originalWriteTime = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(filePath, originalWriteTime); + + var written = await GuestAppHostProject.WriteGeneratedFileAsync(filePath, "def add_redis(): ...", preserveUnchangedFiles: false, CancellationToken.None); + + // Languages that install the generated sources into an environment (uv/pip for Python) + // rebuild off the source timestamp, so an unchanged file must still be rewritten or the + // stale install is silently reused. + Assert.True(written); + Assert.True(File.GetLastWriteTimeUtc(filePath) > originalWriteTime); + } + + [Fact] + public void JavaIsTheOnlyLanguageThatPreservesUnchangedGeneratedFiles() + { + var languages = DefaultLanguageDiscovery.AllLanguages; + + Assert.Equal( + ["Java"], + languages.Where(language => language.PreserveUnchangedGeneratedFiles).Select(language => language.CodeGenerator)); + } + [Fact] public void AspireJsonConfiguration_LoadOrCreate_SetsDefaultSdkVersion() { @@ -482,6 +594,32 @@ public void CreateGuestEnvironmentVariables_IgnoresLaunchProfileEnvironmentVaria Assert.False(envVars.ContainsKey("ASPIRE_ENVIRONMENT")); } + [Fact] + public void CreateGuestEnvironmentVariables_ForwardsAppHostArgumentsForGuestsThatCannotReadArgv() + { + var envVars = GuestAppHostProject.CreateGuestEnvironmentVariables( + contextEnvironmentVariables: new Dictionary(), + launchProfileEnvironmentVariables: null, + inheritedEnvironmentVariables: new Dictionary(), + args: ["--operation", "publish", "--step", "publish", "--output-path", "/tmp/out dir"]); + + Assert.Equal( + "--operation\npublish\n--step\npublish\n--output-path\n/tmp/out dir", + envVars["ASPIRE_APPHOST_ARGS"]); + } + + [Fact] + public void CreateGuestEnvironmentVariables_DoesNotForwardAppHostArgumentsWhenThereAreNone() + { + var envVars = GuestAppHostProject.CreateGuestEnvironmentVariables( + contextEnvironmentVariables: new Dictionary(), + launchProfileEnvironmentVariables: null, + inheritedEnvironmentVariables: new Dictionary(), + args: []); + + Assert.False(envVars.ContainsKey("ASPIRE_APPHOST_ARGS")); + } + [Fact] public void CreateGuestEnvironmentVariables_EnvironmentArgumentTakesPrecedenceOverLaunchProfileEnvironmentVariables() { @@ -959,6 +1097,41 @@ public async Task StartBackchannelConnectionAsync_WhenGuestServerExitsBeforeBack Assert.Equal(expectedMessage, exception.Message); } + [Fact] + public async Task StartBackchannelConnectionAsync_UsesConfiguredBackchannelTimeout() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [KnownConfigNames.CliBackchannelConnectTimeoutSeconds] = "0" + }) + .Build(); + var backchannel = new TestAppHostBackchannel + { + ConnectAsyncCallback = (_, _) => throw new SocketException((int)SocketError.ConnectionRefused) + }; + var project = CreateGuestAppHostProject(backchannel: backchannel, configuration: configuration); + var serverSession = new FakeAppHostServerSession(); + var backchannelCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + + try + { + await InvokeStartBackchannelConnectionAsync( + project, + serverSession, + backchannelCompletionSource, + cancellationSource.Token); + } + catch (OperationCanceledException) when (cancellationSource.IsCancellationRequested) + { + } + + Assert.True(backchannelCompletionSource.Task.IsFaulted, "The configured immediate timeout should fault the guest backchannel wait before test cancellation."); + var exception = await Assert.ThrowsAsync(() => backchannelCompletionSource.Task); + Assert.Contains("after 0 seconds", exception.Message); + } + [Fact] public async Task RunAsync_PassesWorkloadIdToAppHostServerEnvironment() { @@ -1283,8 +1456,11 @@ private GuestAppHostProject CreateGuestAppHostProject( bool identityOverridden = false, string languageId = "typescript/nodejs", IEnvironment? environment = null, - DirectoryInfo? homeDirectory = null) + DirectoryInfo? homeDirectory = null, + IConfiguration? configuration = null) { + var effectiveConfiguration = configuration ?? _configuration; + var language = new LanguageInfo( LanguageId: languageId, DisplayName: "TypeScript (Node.js)", @@ -1316,8 +1492,8 @@ private GuestAppHostProject CreateGuestAppHostProject( certificateService: new TestCertificateService(), runner: new TestDotNetCliRunner(), packagingService: new TestPackagingService(), - configuration: _configuration, - features: new Features(_configuration, NullLogger.Instance), + configuration: effectiveConfiguration, + features: new Features(effectiveConfiguration, NullLogger.Instance), languageDiscovery: new TestLanguageDiscovery(), executionContext: executionContext, environment: environment ?? new TestEnvironment(), @@ -1552,7 +1728,8 @@ await project.ConfigureCertificateBundleEnvironmentAsync( private static async Task InvokeStartBackchannelConnectionAsync( GuestAppHostProject project, IAppHostServerSession serverSession, - TaskCompletionSource backchannelCompletionSource) + TaskCompletionSource backchannelCompletionSource, + CancellationToken cancellationToken = default) { var method = typeof(GuestAppHostProject).GetMethod( "StartBackchannelConnectionAsync", @@ -1565,7 +1742,7 @@ private static async Task InvokeStartBackchannelConnectionAsync( backchannelCompletionSource, false, default(System.Diagnostics.ActivityContext), - CancellationToken.None + cancellationToken ])); await task.DefaultTimeout(); } diff --git a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs index 5904e690193..28b8ef0a5f8 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestRuntimeTests.cs @@ -1044,6 +1044,418 @@ public async Task RunAsync_NoMigrationFiles_ExecutesNormally() Assert.Equal("test-cmd", launcher.LastCommand); } + [Fact] + public async Task RunAsync_WhenPreExecuteHasNoStamp_RunsCommandAndWritesStamp() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + + var runtime = CreateRuntime(CreateUpToDateSpec()); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.Contains(launcher.Calls, call => call.Command == "javac"); + Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "classes", ".aspire-compile-stamp"))); + } + + [Fact] + public async Task RunAsync_WhenStampIsNewerThanInputs_SkipsPreExecute() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(1)); + + var runtime = CreateRuntime(CreateUpToDateSpec()); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.DoesNotContain(launcher.Calls, call => call.Command == "javac"); + Assert.Contains(launcher.Calls, call => call.Command == "java"); + } + + [Theory] + [InlineData("AppHost.java")] + [InlineData("Helper.java")] + [InlineData(".aspire/modules/com/example/Generated.java")] + [InlineData("src/main/java/com/example/Service.java")] + public async Task RunAsync_WhenAnyJavaInputIsNewerThanStamp_RunsPreExecute(string relativeInput) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow); + + var input = Path.Combine(workspace.WorkspaceRoot.FullName, relativeInput.Replace('/', Path.DirectorySeparatorChar)); + File.SetLastWriteTimeUtc(input, DateTime.UtcNow.AddMinutes(1)); + + var runtime = CreateRuntime(CreateUpToDateSpec()); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.Contains(launcher.Calls, call => call.Command == "javac"); + } + + [Fact] + public async Task RunAsync_UpToDateCheckIgnoresFilesOutsideTheDeclaredExtensions() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + + // A class file is an output of the compile, not an input to it. Treating it as an input would + // make the check permanently stale: every compile rewrites these and would invalidate itself. + // The file is created before the stamp because its *appearance* is a change to the directory, + // which the check does notice; what must not register is the rewrite of one already there. + var classFile = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.class"); + File.WriteAllText(classFile, ""); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow); + File.SetLastWriteTimeUtc(classFile, DateTime.UtcNow.AddMinutes(1)); + + var runtime = CreateRuntime(CreateUpToDateSpec()); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.DoesNotContain(launcher.Calls, call => call.Command == "javac"); + } + + [Theory] + [InlineData("Helper.java")] + [InlineData(".aspire/modules/com/example/Generated.java")] + [InlineData("src/main/java/com/example/Service.java")] + public async Task RunAsync_WhenAJavaInputIsDeleted_RunsPreExecute(string relativeInput) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + BackdateWorkspace(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-2)); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-1)); + + // Deleting a source leaves every surviving input older than the stamp, so a check that only + // compares file timestamps sees nothing at all. The class compiled from the deleted source is + // still in the output directory and still on the runtime classpath, so the AppHost goes on + // running against a type its own sources no longer define. + File.Delete(Path.Combine(workspace.WorkspaceRoot.FullName, relativeInput.Replace('/', Path.DirectorySeparatorChar))); + + var runtime = CreateRuntime(CreateUpToDateSpec()); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.Contains(launcher.Calls, call => call.Command == "javac"); + } + + [Fact] + public async Task RunAsync_WhenAnExplicitlyNamedInputChanges_RunsPreExecuteRegardlessOfItsExtension() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + var pom = Path.Combine(workspace.WorkspaceRoot.FullName, "pom.xml"); + File.WriteAllText(pom, ""); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow); + + // The extension filter exists to keep a directory scan from picking up a command's own + // outputs. A file the spec names outright is not a scan result - it was declared as an input + // on purpose, and for Java that is how a changed pom.xml or build.gradle reaches the check. + File.SetLastWriteTimeUtc(pom, DateTime.UtcNow.AddMinutes(1)); + + var runtime = CreateRuntime(CreateUpToDateSpec(extraInputs: ["pom.xml"])); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.Contains(launcher.Calls, call => call.Command == "javac"); + } + + [Fact] + public async Task RunAsync_WhenTheStagedDependencySetChanges_RunsPreExecute() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + var dependencies = Path.Combine(workspace.WorkspaceRoot.FullName, "target", "dependency"); + Directory.CreateDirectory(dependencies); + File.WriteAllText(Path.Combine(dependencies, "guava-32.0.0.jar"), ""); + + // Age the workspace before stamping so the restage below is genuinely newer. Stamping at + // DateTime.UtcNow leaves the staged directory and the stamp within one clock tick on Windows, + // where UtcNow advances in ~15ms steps, and the directory timestamp never compares greater. + BackdateWorkspace(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-2)); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-1)); + + // Bumping a dependency stages a differently-named JAR. Nothing under the source roots changes, + // so without the staged set as an input the AppHost keeps running bytecode compiled against + // the API of the version that is no longer on the classpath. + File.Delete(Path.Combine(dependencies, "guava-32.0.0.jar")); + File.WriteAllText(Path.Combine(dependencies, "guava-33.0.0.jar"), ""); + + var runtime = CreateRuntime(CreateUpToDateSpec(extraInputs: [Path.Combine("target", "dependency")])); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.Contains(launcher.Calls, call => call.Command == "javac"); + } + + [Fact] + public async Task RunAsync_WhenAStagedDependencyIsRestagedInPlace_SkipsPreExecute() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + var dependencies = Path.Combine(workspace.WorkspaceRoot.FullName, "target", "dependency"); + Directory.CreateDirectory(dependencies); + var jar = Path.Combine(dependencies, "guava-32.0.0.jar"); + File.WriteAllText(jar, ""); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow); + + // Dependency staging runs on every launch, so the JARs themselves can be rewritten with fresh + // timestamps without the resolved set having changed. Reacting to that would recompile on + // every single launch, which is the cost this check exists to avoid. + File.SetLastWriteTimeUtc(jar, DateTime.UtcNow.AddMinutes(1)); + + var runtime = CreateRuntime(CreateUpToDateSpec(extraInputs: [Path.Combine("target", "dependency")])); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.DoesNotContain(launcher.Calls, call => call.Command == "javac"); + } + + [Fact] + public async Task RunAsync_UpToDateCheckDoesNotRecurseIntoNonRecursiveInputs() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + + // A directory input without the recursive marker is scanned top-level only, so churn deeper + // inside it neither invalidates the command nor has to be walked. The tree is in place before + // the stamp, as it would be in a real workspace; what must not register is the churn *inside* + // it afterwards. + var unrelated = Path.Combine(workspace.WorkspaceRoot.FullName, "vendor", "nested", "Vendored.java"); + Directory.CreateDirectory(Path.GetDirectoryName(unrelated)!); + File.WriteAllText(unrelated, ""); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow); + File.SetLastWriteTimeUtc(unrelated, DateTime.UtcNow.AddMinutes(1)); + + var spec = CreateTestSpec( + execute: new CommandSpec { Command = "java", Args = ["AppHost"] }, + preExecute: + [ + new CommandSpec + { + Command = "javac", + Args = ["-d", "classes", "{appHostFile}"], + UpToDateCheck = new CommandUpToDateCheck + { + Inputs = ["{appHostFile}", "vendor"], + FileExtensions = [".java"], + StampFile = Path.Combine("classes", ".aspire-compile-stamp") + } + } + ]); + + var runtime = CreateRuntime(spec); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.DoesNotContain(launcher.Calls, call => call.Command == "javac"); + } + + [Fact] + public async Task RunAsync_UpToDateCheckSeesAnEditToANestedPackageUnderTheAppHostDirectory() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + + // javac is given no -classpath and no -sourcepath, so its source path defaults to the user + // class path, which defaults to the current directory. A helper class in a package beside the + // AppHost is therefore compiled implicitly, and its .class lands in the output directory the + // AppHost runs from. Rewriting that file in place moves neither the AppHost directory's mtime + // nor any top-level file, so a check that does not descend keeps stale bytecode. + var nested = Path.Combine(workspace.WorkspaceRoot.FullName, "config", "Resources.java"); + Directory.CreateDirectory(Path.GetDirectoryName(nested)!); + File.WriteAllText(nested, "class Resources { }"); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow); + File.SetLastWriteTimeUtc(nested, DateTime.UtcNow.AddMinutes(1)); + + var runtime = CreateRuntime(CreateUpToDateSpec()); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.Contains(launcher.Calls, call => call.Command == "javac"); + } + + [Fact] + public async Task RunAsync_UpToDateCheckIgnoresChurnInDirectoriesThatCannotHoldJavaPackages() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + + // The output directory holds the stamp itself, and a dependency or tooling directory is not a + // source root, so neither may drag the compile back out of date once it has settled. + var churn = new[] + { + Path.Combine(workspace.WorkspaceRoot.FullName, "classes", "config", "Resources.class"), + Path.Combine(workspace.WorkspaceRoot.FullName, "node_modules", "vendor", "Vendored.java"), + Path.Combine(workspace.WorkspaceRoot.FullName, ".gradle", "caches", "Cached.java") + }; + + foreach (var path in churn) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, ""); + } + + // Age the workspace before stamping. Stamping at DateTime.UtcNow leaves the real inputs and + // the stamp within one clock tick on Windows, where UtcNow advances in ~15ms steps, so an + // input can compare newer than the stamp and fail the check for a reason this test is not about. + BackdateWorkspace(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-2)); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(-1)); + + foreach (var path in churn) + { + File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddMinutes(1)); + + // Directories need Directory.SetLastWriteTimeUtc: the File overload opens the path without + // FILE_FLAG_BACKUP_SEMANTICS, which Windows refuses for a directory handle. + Directory.SetLastWriteTimeUtc(Path.GetDirectoryName(path)!, DateTime.UtcNow.AddMinutes(1)); + } + + var runtime = CreateRuntime(CreateUpToDateSpec()); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.DoesNotContain(launcher.Calls, call => call.Command == "javac"); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Directory permissions cannot be revoked this way on Windows, and root ignores them on Unix.")] + public async Task RunAsync_UpToDateCheckTreatsAnUnreadableInputTreeAsOutOfDateInsteadOfThrowing() + { + Assert.SkipWhen(Environment.GetEnvironmentVariable("USER") == "root", "root bypasses directory permissions, so the traversal never fails."); + + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + WriteStamp(workspace.WorkspaceRoot, DateTime.UtcNow.AddMinutes(1)); + + // A recursive input containing a directory this user cannot traverse. EnumerateFiles is lazy, + // so the UnauthorizedAccessException is raised while the foreach pulls from the enumerator -- + // enumerating outside the guarding try let it escape and abort AppHost startup entirely. + var unreadable = Path.Combine(workspace.WorkspaceRoot.FullName, "src", "main", "java", "locked"); + Directory.CreateDirectory(unreadable); + File.WriteAllText(Path.Combine(unreadable, "Hidden.java"), "class Hidden { }"); + SetUnixFileModeForTest(unreadable, UnixFileMode.None); + + try + { + var runtime = CreateRuntime(CreateUpToDateSpec()); + var launcher = new RecordingLauncher(); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + // Falling back to running the compile is the safe answer: an unreadable tree cannot be + // proven unchanged. + Assert.Contains(launcher.Calls, call => call.Command == "javac"); + } + finally + { + // Restore traversal so TemporaryWorkspace can delete the tree. + SetUnixFileModeForTest(unreadable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + private static void SetUnixFileModeForTest(string path, UnixFileMode mode) + { + // The caller guards on platform, but the analyzer cannot see through [SkipOnPlatform]. +#pragma warning disable CA1416 + File.SetUnixFileMode(path, mode); +#pragma warning restore CA1416 + } + + [Fact] + public async Task RunAsync_WhenPreExecuteFails_DoesNotWriteStamp() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostFile = CreateUpToDateWorkspace(workspace.WorkspaceRoot); + + var runtime = CreateRuntime(CreateUpToDateSpec()); + var launcher = new RecordingLauncher(); + launcher.ExitCodes.Enqueue(1); + + await runtime.RunAsync(appHostFile, workspace.WorkspaceRoot, new Dictionary(), watchMode: false, launcher, CancellationToken.None); + + Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "classes", ".aspire-compile-stamp"))); + } + + private static RuntimeSpec CreateUpToDateSpec(string[]? extraInputs = null) + { + return CreateTestSpec( + execute: new CommandSpec { Command = "java", Args = ["AppHost"] }, + preExecute: + [ + new CommandSpec + { + Command = "javac", + Args = ["-d", "classes", "{appHostFile}"], + UpToDateCheck = new CommandUpToDateCheck + { + Inputs = ["{appHostFile}", "./**", ".aspire/modules/**", "src/main/java/**", .. extraInputs ?? []], + FileExtensions = [".java"], + StampFile = Path.Combine("classes", ".aspire-compile-stamp") + } + } + ]); + } + + private static FileInfo CreateUpToDateWorkspace(DirectoryInfo root) + { + var appHostFile = new FileInfo(Path.Combine(root.FullName, "AppHost.java")); + File.WriteAllText(appHostFile.FullName, "class AppHost { }"); + File.WriteAllText(Path.Combine(root.FullName, "Helper.java"), "class Helper { }"); + + var generated = Path.Combine(root.FullName, ".aspire", "modules", "com", "example"); + Directory.CreateDirectory(generated); + File.WriteAllText(Path.Combine(generated, "Generated.java"), "class Generated { }"); + + var sources = Path.Combine(root.FullName, "src", "main", "java", "com", "example"); + Directory.CreateDirectory(sources); + File.WriteAllText(Path.Combine(sources, "Service.java"), "class Service { }"); + + Directory.CreateDirectory(Path.Combine(root.FullName, "classes")); + + return appHostFile; + } + + /// + /// Ages every file and directory in the workspace so a later stamp can postdate all of them, which + /// is what a workspace looks like after a successful compile. + /// + private static void BackdateWorkspace(DirectoryInfo root, DateTime timestampUtc) + { + foreach (var file in Directory.EnumerateFiles(root.FullName, "*", SearchOption.AllDirectories)) + { + File.SetLastWriteTimeUtc(file, timestampUtc); + } + + // Directories come second: creating the files above moved their parents' timestamps. + foreach (var directory in Directory.EnumerateDirectories(root.FullName, "*", SearchOption.AllDirectories)) + { + Directory.SetLastWriteTimeUtc(directory, timestampUtc); + } + + Directory.SetLastWriteTimeUtc(root.FullName, timestampUtc); + } + + private static void WriteStamp(DirectoryInfo root, DateTime timestampUtc) + { + var stamp = Path.Combine(root.FullName, "classes", ".aspire-compile-stamp"); + Directory.CreateDirectory(Path.GetDirectoryName(stamp)!); + File.WriteAllText(stamp, ""); + File.SetLastWriteTimeUtc(stamp, timestampUtc); + } + private sealed class RecordingLauncher : IGuestProcessLauncher { public List<(string Command, string[] Args)> Calls { get; } = []; diff --git a/tests/Aspire.Cli.Tests/Projects/JavaAppHostToolchainResolverTests.cs b/tests/Aspire.Cli.Tests/Projects/JavaAppHostToolchainResolverTests.cs new file mode 100644 index 00000000000..c4b060f5267 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Projects/JavaAppHostToolchainResolverTests.cs @@ -0,0 +1,585 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Projects; +using Aspire.TypeSystem; + +namespace Aspire.Cli.Tests.Projects; + +public class JavaAppHostToolchainResolverTests(ITestOutputHelper outputHelper) +{ + private static string WriteWrapper(string directory, string wrapperName) + { + var path = Path.Combine(directory, wrapperName); + File.WriteAllText(path, ""); + + return path; + } + + /// + /// Asserts the wrapper invocation, accounting for Windows running the batch wrapper through the + /// command interpreter rather than launching it directly. + /// + private static void AssertWrapperInvocation(string wrapperPath, string appHostDirectory, string[] toolArgs, CommandSpec actual) + { + if (OperatingSystem.IsWindows()) + { + Assert.Equal(Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", actual.Command); + // "call" precedes the wrapper so the first token on the command line is never a quote, + // which is what stops cmd.exe from stripping quotes around a path containing a space. + Assert.Equal( + ["/c", "call", Path.GetRelativePath(appHostDirectory, wrapperPath), .. toolArgs], + actual.Args); + + return; + } + + Assert.Equal("sh", actual.Command); + Assert.Equal([wrapperPath, .. toolArgs], actual.Args); + } + + private static RuntimeSpec CreateJavacRuntimeSpec() + { + return new RuntimeSpec + { + Language = "java", + DisplayName = "Java", + CodeGenLanguage = "Java", + DetectionPatterns = ["AppHost.java", "src/main/java/AppHost.java"], + InstallDependencies = null, + ExtensionLaunchCapability = "java", + // Mirrors JavaLanguageSupport.GetRuntimeSpec: the build-tool compile command is derived + // from this one, so it has to have the same shape for the derivation to be meaningful. + PreExecute = + [ + new CommandSpec + { + Command = "javac", + Args = ["--release", "25", "-d", ".java-build", "@.aspire/modules/sources.txt", "{appHostFile}"] + } + ], + Execute = new CommandSpec + { + Command = "java", + Args = ["-cp", ".java-build", "AppHost"] + } + }; + } + + [Fact] + public void Resolve_WithNoBuildFile_UsesJavacSoAJdkIsTheOnlyRequirement() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + Assert.Equal(JavaAppHostToolchain.Javac, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot).Toolchain); + } + + [Fact] + public void Resolve_WithPomXml_UsesMaven() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + + Assert.Equal(JavaAppHostToolchain.Maven, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot).Toolchain); + } + + [Theory] + [InlineData("build.gradle")] + [InlineData("build.gradle.kts")] + public void Resolve_WithGradleBuildFile_UsesGradle(string buildFileName) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, buildFileName), ""); + + Assert.Equal(JavaAppHostToolchain.Gradle, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot).Toolchain); + } + + [Fact] + public void Resolve_WithBothBuildFiles_PrefersMaven() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), ""); + + Assert.Equal(JavaAppHostToolchain.Maven, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot).Toolchain); + } + + [Fact] + public void Resolve_IgnoresABuildFileInTheParentDirectory() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + + var appHostDirectory = workspace.CreateDirectory("apphost"); + + // A pom.xml above the AppHost usually belongs to an unrelated project that merely contains the + // AppHost folder, so inheriting it would build the wrong thing. + Assert.Equal(JavaAppHostToolchain.Javac, JavaAppHostToolchainResolver.Resolve(appHostDirectory).Toolchain); + } + + [Fact] + public void ApplyToRuntimeSpec_ForJavac_LeavesTheSpecUntouched() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var baseSpec = CreateJavacRuntimeSpec(); + + // Existing single-file AppHosts must keep working byte for byte; adopting a build tool is opt-in. + Assert.Same(baseSpec, JavaAppHostToolchainResolver.ApplyToRuntimeSpec(baseSpec, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), workspace.WorkspaceRoot)); + } + + [Fact] + public void ApplyToRuntimeSpec_ForMaven_RestoresCompilesThenLaunchesAPlainJvm() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + var wrapper = WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"); + + var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), workspace.WorkspaceRoot); + + Assert.Equal("Java (Maven)", spec.DisplayName); + + AssertWrapperInvocation( + wrapper, + workspace.Path, + ["-B", "-q", "dependency:copy-dependencies", $"-DoutputDirectory={Path.Combine("target", "aspire-deps")}", "-DincludeScope=runtime"], + spec.InstallDependencies!); + + var compile = Assert.Single(spec.PreExecute!); + // Compilation stays with javac even under Maven: the build tool cannot be told about the + // generated SDK under .aspire/modules from the command line. The javac options and source + // arguments are inherited from the base spec so the two toolchains cannot drift. + Assert.Equal("javac", compile.Command); + Assert.Equal( + [ + "--release", "25", + "-classpath", Path.Combine("target", "aspire-deps", "*"), + "-sourcepath", $".{Path.PathSeparator}{Path.Combine("src", "main", "java")}", + "-d", Path.Combine("target", "classes"), + "@.aspire/modules/sources.txt", + "{appHostFile}" + ], + compile.Args); + + // The AppHost is launched directly rather than through mvn exec:java so console signals reach + // it, and without a {args} placeholder so the CLI appends real argv entries. + Assert.Equal("java", spec.Execute.Command); + Assert.Equal( + ["-cp", $"{Path.Combine("target", "classes")}{Path.PathSeparator}{Path.Combine("target", "aspire-deps", "*")}", "AppHost"], + spec.Execute.Args); + Assert.DoesNotContain(spec.Execute.Args, arg => arg.Contains("{args}", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("pom.xml", "target", new[] { "pom.xml" })] + [InlineData("build.gradle", "build", new[] { "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "gradle/libs.versions.toml" })] + public void ApplyToRuntimeSpec_DeclaresTheBuildDescriptorsAndStagedDependenciesAsCompileInputs( + string buildFileName, + string outputDirectoryName, + string[] expectedDescriptors) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + File.WriteAllText(Path.Combine(workspace.Path, buildFileName), ""); + WriteWrapper(workspace.Path, buildFileName == "pom.xml" + ? OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw" + : OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew"); + + var baseSpec = CreateJavacRuntimeSpec(); + var baseCompile = baseSpec.PreExecute![0]; + var specWithCheck = new RuntimeSpec + { + Language = baseSpec.Language, + DisplayName = baseSpec.DisplayName, + CodeGenLanguage = baseSpec.CodeGenLanguage, + DetectionPatterns = baseSpec.DetectionPatterns, + InstallDependencies = baseSpec.InstallDependencies, + ExtensionLaunchCapability = baseSpec.ExtensionLaunchCapability, + PreExecute = + [ + new CommandSpec + { + Command = baseCompile.Command, + Args = baseCompile.Args, + UpToDateCheck = new CommandUpToDateCheck + { + Inputs = ["{appHostFile}", ".", ".aspire/modules/**", "src/main/java/**"], + FileExtensions = [".java"], + StampFile = Path.Combine(".java-build", ".aspire-compile-stamp") + } + } + ], + Execute = baseSpec.Execute + }; + + var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(specWithCheck, JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), workspace.WorkspaceRoot); + + // A dependency bump touches no Java source: the descriptor changes and a differently-named JAR + // is staged. Neither reaches a check whose inputs are only source roots, so the AppHost keeps + // running bytecode compiled against the version that is no longer on the classpath. + var inputs = Assert.Single(spec.PreExecute!).UpToDateCheck!.Inputs; + foreach (var descriptor in expectedDescriptors) + { + Assert.Contains(descriptor.Replace('/', Path.DirectorySeparatorChar), inputs); + } + + Assert.Contains(Path.Combine(outputDirectoryName, "aspire-deps"), inputs); + } + + [Fact] + public void ApplyToRuntimeSpec_ForGradle_UsesTheGradleOutputLayout() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), ""); + var wrapper = WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew"); + + var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), workspace.WorkspaceRoot); + + Assert.Equal("Java (Gradle)", spec.DisplayName); + AssertWrapperInvocation( + wrapper, + workspace.Path, + // CombineProjectPath returns the path with this platform's separators, so the expected value + // has to be converted too or the comparison fails on Windows. + ["-q", "--init-script", JavaAppHostToolchainResolver.GradleInitScriptRelativePath.Replace('/', Path.DirectorySeparatorChar), "aspireCopyDependencies"], + spec.InstallDependencies!); + + var compile = Assert.Single(spec.PreExecute!); + Assert.Equal("javac", compile.Command); + Assert.Equal( + [ + "--release", "25", + "-classpath", Path.Combine("build", "aspire-deps", "*"), + "-sourcepath", $".{Path.PathSeparator}{Path.Combine("src", "main", "java")}", + "-d", Path.Combine("build", "classes", "java", "main"), + "@.aspire/modules/sources.txt", + "{appHostFile}" + ], + compile.Args); + + Assert.Equal("java", spec.Execute.Command); + Assert.Equal( + ["-cp", $"{Path.Combine("build", "classes", "java", "main")}{Path.PathSeparator}{Path.Combine("build", "aspire-deps", "*")}", "AppHost"], + spec.Execute.Args); + } + + [Fact] + public void ApplyToRuntimeSpec_PreservesTheExtensionLaunchCapabilitySoTheAppHostStaysDebuggable() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"); + + var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), workspace.WorkspaceRoot); + + Assert.Equal("java", spec.ExtensionLaunchCapability); + } + + [Theory] + [InlineData(true, "mvnw", "mvn -N wrapper:wrapper")] + [InlineData(false, "gradlew", "gradle wrapper")] + public void GetToolInvocation_WithoutAWrapper_IsRejected(bool useMaven, string wrapperName, string generateCommand) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var toolchain = useMaven ? JavaAppHostToolchain.Maven : JavaAppHostToolchain.Gradle; + + // A globally installed tool is deliberately not used: the wrapper pins the version the repository + // builds with, and falling back silently would make the AppHost build machine-dependent. + var ex = Assert.Throws( + () => JavaAppHostToolchainResolver.GetToolInvocation(workspace.WorkspaceRoot, workspace.WorkspaceRoot, toolchain)); + + Assert.Contains(wrapperName, ex.Message); + Assert.Contains(generateCommand, ex.Message); + } + + [Theory] + [InlineData(true, "mvnw", "mvnw.cmd")] + [InlineData(false, "gradlew", "gradlew.bat")] + public void GetToolInvocation_WithAWrapper_UsesIt(bool useMaven, string wrapperName, string windowsWrapperName) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var toolchain = useMaven ? JavaAppHostToolchain.Maven : JavaAppHostToolchain.Gradle; + + var expectedWrapper = OperatingSystem.IsWindows() ? windowsWrapperName : wrapperName; + var wrapperPath = Path.Combine(workspace.Path, expectedWrapper); + File.WriteAllText(wrapperPath, ""); + + var invocation = JavaAppHostToolchainResolver.GetToolInvocation(workspace.WorkspaceRoot, workspace.WorkspaceRoot, toolchain); + + if (OperatingSystem.IsWindows()) + { + // The wrappers are batch files, which produce no output when launched directly with + // redirected stdout, so the command interpreter runs them instead. "call" leads so the + // wrapper is never the first token: cmd strips the first and last quote on the line when + // the first token is quoted, which would mangle a path containing a space. + Assert.Equal(Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", invocation.Command); + Assert.Equal(["/c", "call", expectedWrapper], invocation.PrefixArgs); + } + else + { + // Run through "sh" rather than executed directly so a wrapper checked out without its + // executable bit still works. The absolute path is kept because the process starts without + // a shell, so a bare "mvnw" would be looked up on PATH and never found. + Assert.Equal("sh", invocation.Command); + Assert.Equal([wrapperPath], invocation.PrefixArgs); + } + } + + [Theory] + [InlineData(true, "mvnw", "mvnw.cmd")] + [InlineData(false, "gradlew", "gradlew.bat")] + public void GetToolInvocation_InAMultiModuleBuild_UsesTheWrapperAtTheBuildRoot(bool useMaven, string wrapperName, string windowsWrapperName) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var toolchain = useMaven ? JavaAppHostToolchain.Maven : JavaAppHostToolchain.Gradle; + + // The standard multi-module layout: one wrapper beside the aggregator POM or settings file at + // the repository root, and the AppHost in a module that carries only its own build file. + // Requiring a wrapper beside the module would reject every such repository. + WriteBuildRootMarker(workspace.Path, useMaven); + var expectedWrapper = OperatingSystem.IsWindows() ? windowsWrapperName : wrapperName; + var rootWrapperPath = Path.Combine(workspace.Path, expectedWrapper); + File.WriteAllText(rootWrapperPath, ""); + + var moduleDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "apphost")); + File.WriteAllText(Path.Combine(moduleDirectory.FullName, useMaven ? "pom.xml" : "build.gradle"), ""); + + var invocation = JavaAppHostToolchainResolver.GetToolInvocation(moduleDirectory, moduleDirectory, toolchain); + + if (OperatingSystem.IsWindows()) + { + Assert.Equal(["/c", "call", Path.Combine("..", expectedWrapper)], invocation.PrefixArgs); + } + else + { + Assert.Equal([rootWrapperPath], invocation.PrefixArgs); + } + } + + [Theory] + [InlineData(true, "mvnw", "mvnw.cmd")] + [InlineData(false, "gradlew", "gradlew.bat")] + public void GetToolInvocation_DoesNotCrossACheckoutBoundaryLookingForAWrapper(bool useMaven, string wrapperName, string windowsWrapperName) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var toolchain = useMaven ? JavaAppHostToolchain.Maven : JavaAppHostToolchain.Gradle; + + // A wrapper in an outer repository must not be adopted by an inner checkout: a submodule or a + // nested clone pins its own build tool version, and the outer one is a different project. + WriteBuildRootMarker(workspace.Path, useMaven); + File.WriteAllText(Path.Combine(workspace.Path, OperatingSystem.IsWindows() ? windowsWrapperName : wrapperName), ""); + + var innerCheckout = Directory.CreateDirectory(Path.Combine(workspace.Path, "inner")); + Directory.CreateDirectory(Path.Combine(innerCheckout.FullName, ".git")); + File.WriteAllText(Path.Combine(innerCheckout.FullName, useMaven ? "pom.xml" : "build.gradle"), ""); + + var ex = Assert.Throws( + () => JavaAppHostToolchainResolver.GetToolInvocation(innerCheckout, innerCheckout, toolchain)); + + Assert.Contains(innerCheckout.FullName, ex.Message); + } + + private static void WriteBuildRootMarker(string directory, bool useMaven) + { + if (useMaven) + { + File.WriteAllText(Path.Combine(directory, "pom.xml"), ""); + } + else + { + File.WriteAllText(Path.Combine(directory, "settings.gradle"), ""); + } + } + + [Fact] + public async Task EnsureToolchainFilesExistAsync_ForGradle_WritesTheInitScriptAndOverwritesAStaleOne() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), ""); + WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew"); + var scriptPath = Path.Combine(workspace.Path, ".aspire", "aspire-gradle-init.gradle"); + + // The .aspire directory does not exist yet, which is why this is not a RuntimeSpec migration file. + await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), CancellationToken.None); + + Assert.Contains("aspireCopyDependencies", await File.ReadAllTextAsync(scriptPath)); + + await File.WriteAllTextAsync(scriptPath, "// stale"); + await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), CancellationToken.None); + + Assert.Contains("aspireCopyDependencies", await File.ReadAllTextAsync(scriptPath)); + } + + [Fact] + public async Task EnsureToolchainFilesExistAsync_ForGradle_StagesWithSyncSoUpgradedDependenciesDoNotAccumulate() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), ""); + WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew"); + + await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), CancellationToken.None); + + var script = await File.ReadAllTextAsync(Path.Combine(workspace.Path, ".aspire", "aspire-gradle-init.gradle")); + + // The whole directory is the classpath, so a Copy would leave the previous version of an + // upgraded dependency behind and load it alongside the new one. + Assert.Contains("tasks.register(\"aspireCopyDependencies\", Sync)", script); + } + + [Fact] + public async Task ClearStagedDependencies_ForMaven_RemovesPreviouslyStagedDependencies() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + + var dependencyDirectory = Path.Combine(workspace.Path, "target", "aspire-deps"); + Directory.CreateDirectory(dependencyDirectory); + await File.WriteAllTextAsync(Path.Combine(dependencyDirectory, "library-1.0.jar"), ""); + + JavaAppHostToolchainResolver.ClearStagedDependencies(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot)); + + // dependency:copy-dependencies only ever adds, so library-1.0.jar would survive an upgrade to + // library-2.0.jar and both would be on the AppHost's dir/* classpath. + Assert.False(Directory.Exists(dependencyDirectory)); + } + + [Fact] + public void ClearStagedDependencies_ForMaven_WithNothingStagedYet_DoesNotThrow() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + + JavaAppHostToolchainResolver.ClearStagedDependencies(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot)); + + Assert.False(Directory.Exists(Path.Combine(workspace.Path, "target"))); + } + + [Fact] + public async Task ClearStagedDependencies_ForGradle_LeavesTheDirectoryToTheSyncTask() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), ""); + WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew"); + + var dependencyDirectory = Path.Combine(workspace.Path, "build", "aspire-deps"); + Directory.CreateDirectory(dependencyDirectory); + await File.WriteAllTextAsync(Path.Combine(dependencyDirectory, "library-1.0.jar"), ""); + + JavaAppHostToolchainResolver.ClearStagedDependencies(JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot)); + + // Sync prunes the destination itself and stays incremental, so deleting it here would only + // force Gradle to recopy everything. + Assert.True(Directory.Exists(dependencyDirectory)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task EnsureToolchainFilesExistAsync_ForANonGradleToolchain_WritesNothing(bool useJavac) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + if (!useJavac) + { + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + } + + await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync( + JavaAppHostToolchainResolver.Resolve(workspace.WorkspaceRoot), + CancellationToken.None); + + Assert.Empty(workspace.WorkspaceRoot.GetDirectories()); + } + + [Theory] + [InlineData("pom.xml", true)] + [InlineData("build.gradle", false)] + public void Resolve_WithTheConventionalSourceLayout_FindsTheBuildFileAtTheProjectRoot(string buildFileName, bool expectMaven) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var expected = expectMaven ? JavaAppHostToolchain.Maven : JavaAppHostToolchain.Gradle; + File.WriteAllText(Path.Combine(workspace.Path, buildFileName), ""); + var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "src", "main", "java")); + + // src/main/java is a build tool's source root by convention, so the build file above it is + // this project's, not an unrelated one that happens to contain the AppHost. + var resolution = JavaAppHostToolchainResolver.Resolve(appHostDirectory); + + Assert.Equal(expected, resolution.Toolchain); + Assert.Equal(workspace.Path, resolution.ProjectDirectory.FullName); + } + + [Fact] + public void Resolve_WithABuildFileThreeLevelsUpThatIsNotASourceRoot_UsesJavac() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "a", "b", "c")); + + Assert.Equal(JavaAppHostToolchain.Javac, JavaAppHostToolchainResolver.Resolve(appHostDirectory).Toolchain); + } + + [Fact] + public void ApplyToRuntimeSpec_WithTheConventionalSourceLayout_PointsTheToolAtTheProjectRoot() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"); + var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "src", "main", "java")); + + var resolution = JavaAppHostToolchainResolver.Resolve(appHostDirectory); + var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), resolution, appHostDirectory); + + // Commands run from the AppHost directory, so Maven has to be pointed back at the project root + // and the classpath has to climb back out of src/main/java. + var toProjectRoot = Path.Combine("..", "..", ".."); + Assert.Contains("-f", spec.InstallDependencies!.Args); + Assert.Contains(Path.Combine(toProjectRoot, "pom.xml"), spec.InstallDependencies.Args); + + // -DoutputDirectory is deliberately *not* rewritten: Maven resolves a relative outputDirectory + // against the project's base directory, not the working directory, so climbing out of + // src/main/java here would stage the jars three levels above the project. + Assert.Contains($"-DoutputDirectory={Path.Combine("target", "aspire-deps")}", spec.InstallDependencies.Args); + + // javac and java both resolve their paths against the working directory, so these do climb out. + Assert.Equal( + [ + "--release", "25", + "-classpath", Path.Combine(toProjectRoot, "target", "aspire-deps", "*"), + "-sourcepath", $".{Path.PathSeparator}{Path.Combine("src", "main", "java")}", + "-d", Path.Combine(toProjectRoot, "target", "classes"), + "@.aspire/modules/sources.txt", + "{appHostFile}" + ], + Assert.Single(spec.PreExecute!).Args); + Assert.Equal( + [ + "-cp", + $"{Path.Combine(toProjectRoot, "target", "classes")}{Path.PathSeparator}{Path.Combine(toProjectRoot, "target", "aspire-deps", "*")}", + "AppHost" + ], + spec.Execute.Args); + } + + [Fact] + public async Task EnsureToolchainFilesExistAsync_WithTheConventionalSourceLayout_WritesTheScriptNextToTheBuildFile() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + File.WriteAllText(Path.Combine(workspace.Path, "build.gradle"), ""); + WriteWrapper(workspace.Path, OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew"); + var appHostDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "src", "main", "java")); + + var resolution = JavaAppHostToolchainResolver.Resolve(appHostDirectory); + await JavaAppHostToolchainResolver.EnsureToolchainFilesExistAsync(resolution, CancellationToken.None); + + // The --init-script argument is resolved relative to the AppHost directory, so the script has to + // be where that argument points: alongside the build file it augments. + var scriptPath = Path.Combine(workspace.Path, ".aspire", "aspire-gradle-init.gradle"); + Assert.Contains("aspireCopyDependencies", await File.ReadAllTextAsync(scriptPath)); + + var spec = JavaAppHostToolchainResolver.ApplyToRuntimeSpec(CreateJavacRuntimeSpec(), resolution, appHostDirectory); + var initScriptArgument = spec.InstallDependencies!.Args[Array.IndexOf(spec.InstallDependencies.Args, "--init-script") + 1]; + Assert.Equal(scriptPath, Path.GetFullPath(Path.Combine(appHostDirectory.FullName, initScriptArgument))); + } +} diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 4a25552a341..63f802e2a4b 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -8,6 +8,7 @@ using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; +using Aspire.Cli.Resources; using Aspire.Cli.Tests.Mcp; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; @@ -23,6 +24,341 @@ public class PrebuiltAppHostServerTests(ITestOutputHelper outputHelper) { private const string NuGetOrgSource = "https://api.nuget.org/v3/index.json"; + [Fact] + public async Task WriteIfChangedAsync_LeavesAnIdenticalFileAlone() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var path = Path.Combine(workspace.WorkspaceRoot.FullName, "IntegrationRestore.csproj"); + await PrebuiltAppHostServer.WriteIfChangedAsync(path, "", CancellationToken.None); + var firstWrite = File.GetLastWriteTimeUtc(path); + + // MSBuild rebuilds when an input timestamp moves, so an identical rewrite must not touch the file. + File.SetLastWriteTimeUtc(path, firstWrite.AddMinutes(-5)); + var backdated = File.GetLastWriteTimeUtc(path); + await PrebuiltAppHostServer.WriteIfChangedAsync(path, "", CancellationToken.None); + + Assert.Equal(backdated, File.GetLastWriteTimeUtc(path)); + Assert.Equal("", await File.ReadAllTextAsync(path)); + } + + [Fact] + public async Task WriteIfChangedAsync_WritesWhenContentDiffers() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var path = Path.Combine(workspace.WorkspaceRoot.FullName, "IntegrationRestore.csproj"); + await PrebuiltAppHostServer.WriteIfChangedAsync(path, "", CancellationToken.None); + await PrebuiltAppHostServer.WriteIfChangedAsync(path, "", CancellationToken.None); + + Assert.Equal("", await File.ReadAllTextAsync(path)); + } + + [Fact] + public async Task WriteIfChangedAsync_RewritesAFileItCannotRead() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Read permission is removed with POSIX file modes."); + + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var path = Path.Combine(workspace.WorkspaceRoot.FullName, "IntegrationRestore.csproj"); + await File.WriteAllTextAsync(path, ""); + + // A generated file the CLI owns but momentarily cannot read -- an antivirus scanner holding it + // open on Windows, a mode the user changed on POSIX -- must be rewritten, not turned into a + // launch failure. The write below is what surfaces a genuinely unrecoverable error. + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserWrite); + } + + await PrebuiltAppHostServer.WriteIfChangedAsync(path, "", CancellationToken.None); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + Assert.Equal("", await File.ReadAllTextAsync(path)); + } + + [Fact] + public void CanSkipIntegrationRestore_RestoresWhenNothingHasBeenRestoredYet() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + Assert.False(PrebuiltAppHostServer.CanSkipIntegrationRestore(workspace.WorkspaceRoot.FullName, "fingerprint", NullLogger.Instance)); + } + + [Fact] + public void CanSkipIntegrationRestore_RestoresWhenTheAssetsAreMissing() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + WriteRestoreState(workspace.WorkspaceRoot.FullName, fingerprint: "fingerprint", writeAssets: false); + + Assert.False(PrebuiltAppHostServer.CanSkipIntegrationRestore(workspace.WorkspaceRoot.FullName, "fingerprint", NullLogger.Instance)); + } + + [Fact] + public void CanSkipIntegrationRestore_RestoresWhenTheFingerprintChanged() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + WriteRestoreState(workspace.WorkspaceRoot.FullName, fingerprint: "old-fingerprint"); + + Assert.False(PrebuiltAppHostServer.CanSkipIntegrationRestore(workspace.WorkspaceRoot.FullName, "new-fingerprint", NullLogger.Instance)); + } + + [Fact] + public void CanSkipIntegrationRestore_SkipsWhenTheFingerprintMatches() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + WriteRestoreState(workspace.WorkspaceRoot.FullName, fingerprint: "fingerprint"); + + Assert.True(PrebuiltAppHostServer.CanSkipIntegrationRestore(workspace.WorkspaceRoot.FullName, "fingerprint", NullLogger.Instance)); + } + + [Fact] + public async Task ComputeRestoreInputsAsync_FingerprintChangesWhenAReferencedProjectChanges() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var referencedProject = Path.Combine(workspace.WorkspaceRoot.FullName, "Aspire.Hosting.Java.csproj"); + await File.WriteAllTextAsync(referencedProject, """"""); + var projectRefs = new List { IntegrationReference.FromProject("Aspire.Hosting.Java", referencedProject) }; + + var before = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + + // A referenced project bumping its own dependency changes the closure that restore resolves + // without changing a byte of the generated project file. + await File.WriteAllTextAsync(referencedProject, """"""); + var after = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + + Assert.NotEqual(before.Fingerprint, after.Fingerprint); + } + + [Fact] + public async Task ComputeRestoreInputsAsync_FingerprintChangesWhenACentrallyManagedVersionChanges() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var referencedProject = Path.Combine(workspace.WorkspaceRoot.FullName, "Aspire.Hosting.Java.csproj"); + // Under central package management the reference carries no version at all: the version lives + // in Directory.Packages.props, which MSBuild imports automatically. Bumping it there changes + // what restore resolves while every hashed file stays byte-for-byte identical. + await File.WriteAllTextAsync(referencedProject, """"""); + var packagesProps = Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"); + await File.WriteAllTextAsync(packagesProps, """"""); + var projectRefs = new List { IntegrationReference.FromProject("Aspire.Hosting.Java", referencedProject) }; + + var before = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + + await File.WriteAllTextAsync(packagesProps, """"""); + var after = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + + Assert.NotEqual(before.Fingerprint, after.Fingerprint); + } + + [Fact] + public async Task ComputeRestoreInputsAsync_FingerprintChangesWhenATransitivelyReferencedProjectChanges() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var leaf = Path.Combine(workspace.WorkspaceRoot.FullName, "Leaf.csproj"); + await File.WriteAllTextAsync(leaf, """"""); + var referencedProject = Path.Combine(workspace.WorkspaceRoot.FullName, "Aspire.Hosting.Java.csproj"); + await File.WriteAllTextAsync(referencedProject, """"""); + var projectRefs = new List { IntegrationReference.FromProject("Aspire.Hosting.Java", referencedProject) }; + + var before = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + + // Restore resolves the whole graph, not just its first level, so a package bump two hops out + // changes the closure exactly as much as one hop out does. + await File.WriteAllTextAsync(leaf, """"""); + var after = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + + Assert.NotEqual(before.Fingerprint, after.Fingerprint); + } + + [Fact] + public async Task ComputeRestoreInputsAsync_IsNotEligibleForSkipWhenATransitivelyReferencedProjectFloats() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var leaf = Path.Combine(workspace.WorkspaceRoot.FullName, "Leaf.csproj"); + await File.WriteAllTextAsync(leaf, """"""); + var referencedProject = Path.Combine(workspace.WorkspaceRoot.FullName, "Aspire.Hosting.Java.csproj"); + await File.WriteAllTextAsync(referencedProject, """"""); + var projectRefs = new List { IntegrationReference.FromProject("Aspire.Hosting.Java", referencedProject) }; + + var inputs = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + + Assert.False(inputs.IsEligibleForSkip); + } + + [Fact] + public async Task ComputeRestoreInputsAsync_ToleratesAProjectReferenceCycle() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var first = Path.Combine(workspace.WorkspaceRoot.FullName, "First.csproj"); + var second = Path.Combine(workspace.WorkspaceRoot.FullName, "Second.csproj"); + // MSBuild rejects a cycle, but the fingerprint is computed before anything validates the + // graph, so walking it has to terminate on its own rather than hang the launch. + await File.WriteAllTextAsync(first, """"""); + await File.WriteAllTextAsync(second, """"""); + var projectRefs = new List { IntegrationReference.FromProject("First", first) }; + + var inputs = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + + Assert.NotEmpty(inputs.Fingerprint); + } + + [Fact] + public async Task ComputeRestoreInputsAsync_FingerprintIsStableForUnchangedInputs() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var referencedProject = Path.Combine(workspace.WorkspaceRoot.FullName, "Aspire.Hosting.Java.csproj"); + await File.WriteAllTextAsync(referencedProject, ""); + var projectRefs = new List { IntegrationReference.FromProject("Aspire.Hosting.Java", referencedProject) }; + + var first = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + var second = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + + Assert.Equal(first, second); + } + + [Fact] + public async Task ComputeRestoreInputsAsync_IsNotEligibleForSkipWhenAReferencedProjectFloats() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var referencedProject = Path.Combine(workspace.WorkspaceRoot.FullName, "Aspire.Hosting.Java.csproj"); + // The generated project pins exact versions, so the only float is inside the referenced + // project. Restore must still run because the feed can resolve it to a different package. + await File.WriteAllTextAsync(referencedProject, """"""); + var projectRefs = new List { IntegrationReference.FromProject("Aspire.Hosting.Java", referencedProject) }; + + var inputs = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", [], projectRefs, CancellationToken.None); + + Assert.False(inputs.IsEligibleForSkip); + } + + [Fact] + public async Task ComputeRestoreInputsAsync_IsEligibleForSkipWhenEveryVersionIsExact() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var referencedProject = Path.Combine(workspace.WorkspaceRoot.FullName, "Aspire.Hosting.Java.csproj"); + await File.WriteAllTextAsync(referencedProject, """"""); + var projectRefs = new List { IntegrationReference.FromProject("Aspire.Hosting.Java", referencedProject) }; + var packageRefs = new List { IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.5.0") }; + + var inputs = await PrebuiltAppHostServer.ComputeRestoreInputsAsync("", packageRefs, projectRefs, CancellationToken.None); + + Assert.True(inputs.IsEligibleForSkip); + } + + [Theory] + [InlineData("""""")] + [InlineData("""""")] + [InlineData("""""")] + [InlineData("""""")] + [InlineData("""""")] + public void HasFloatingVersionAttribute_DetectsVersionsThatCanResolveDifferently(string projectText) + { + Assert.True(PrebuiltAppHostServer.HasFloatingVersionAttribute(projectText)); + } + + [Theory] + [InlineData("""""")] + [InlineData("""""")] + // ToolsVersion ends in "Version" but is not a package version; the word boundary must exclude it. + [InlineData("""""")] + [InlineData("")] + public void HasFloatingVersionAttribute_IsFalseForExactVersions(string projectText) + { + Assert.False(PrebuiltAppHostServer.HasFloatingVersionAttribute(projectText)); + } + + [Theory] + [InlineData("13.4.*")] + [InlineData("[13.4,14)")] + [InlineData("(13.4,)")] + public void HasFloatingPackageVersion_DetectsVersionsThatCanResolveDifferently(string version) + { + var packageRefs = new List { IntegrationReference.FromPackage("Aspire.Hosting.Redis", version) }; + + Assert.True(PrebuiltAppHostServer.HasFloatingPackageVersion(packageRefs)); + } + + [Fact] + public void HasFloatingPackageVersion_IsFalseForExactVersions() + { + var packageRefs = new List + { + IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.5.0"), + IntegrationReference.FromPackage("Aspire.Hosting.Java", "13.5.0-preview.1.25000.1") + }; + + Assert.False(PrebuiltAppHostServer.HasFloatingPackageVersion(packageRefs)); + } + + [Theory] + [InlineData("error NETSDK1004: Assets file '/tmp/obj/project.assets.json' not found. Run a NuGet package restore.")] + [InlineData("/tmp/obj/project.assets.json' doesn't have a target for 'net10.0'.")] + public void ShouldRetryWithRestore_RetriesWhenTheAssetsAreMissingOrStale(string line) + { + var output = new OutputCollector(); + output.AppendOutput(line); + + Assert.True(PrebuiltAppHostServer.ShouldRetryWithRestore(output)); + } + + [Theory] + [InlineData("error NETSDK1064: Package Aspire.Hosting.Redis, version 13.5.0 was not found. It might have been deleted since NuGet restore. Otherwise, NuGet restore might have only partially completed, which might have been due to maximum path length restrictions.")] + [InlineData("error NU1101: Unable to find package Aspire.Hosting.Java. No packages exist with this id in source(s): dotnet-public")] + [InlineData("error NU1102: Unable to find package Aspire.Hosting with version (>= 13.6.0-dev)")] + public void ShouldRetryWithRestore_RetriesWhenThePackageCacheIsMissingPackages(string line) + { + var output = new OutputCollector(); + output.AppendOutput(line); + + Assert.True(PrebuiltAppHostServer.ShouldRetryWithRestore(output)); + } + + [Fact] + public void ShouldRetryWithRestore_DoesNotRetryAnOrdinaryCompileFailure() + { + var output = new OutputCollector(); + output.AppendOutput("Program.cs(3,1): error CS0103: The name 'Foo' does not exist in the current context"); + + Assert.False(PrebuiltAppHostServer.ShouldRetryWithRestore(output)); + } + + [Fact] + public void GetIntegrationBuildFailureMessage_ExplainsAPackageDowngrade() + { + var output = new OutputCollector(); + // Localized MSBuild output: the error code is the only stable part to match on. + output.AppendOutput("IntegrationRestore.csproj : error NU1605: Advertencia como error: Degradación del paquete detectada: Aspire.Hosting de 13.6.0-dev a 13.5.0."); + + var message = PrebuiltAppHostServer.GetIntegrationBuildFailureMessage(output); + + Assert.Contains("aspire.config.json", message, StringComparison.Ordinal); + Assert.Contains(VersionHelper.GetDefaultTemplateVersion(), message, StringComparison.Ordinal); + } + + [Fact] + public void GetIntegrationBuildFailureMessage_FallsBackForOtherFailures() + { + var output = new OutputCollector(); + output.AppendOutput("Program.cs(3,1): error CS0103: The name 'Foo' does not exist in the current context"); + + Assert.Equal(ErrorStrings.IntegrationBuildFailed, PrebuiltAppHostServer.GetIntegrationBuildFailureMessage(output)); + } + + private static void WriteRestoreState(string restoreDir, string fingerprint, bool writeAssets = true) + { + var objDir = Path.Combine(restoreDir, "obj"); + Directory.CreateDirectory(objDir); + if (writeAssets) + { + File.WriteAllText(Path.Combine(objDir, "project.assets.json"), "{}"); + } + + File.WriteAllText(Path.Combine(objDir, "aspire-restore.stamp"), fingerprint); + } + [Fact] public void GenerateIntegrationProjectFile_WithPackagesOnly_ProducesPackageReferences() { @@ -2491,6 +2827,7 @@ private static LayoutConfiguration CreateBundleLayout(TemporaryWorkspace workspa { var layoutRoot = workspace.CreateDirectory("layout"); var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); + layoutRoot.CreateSubdirectory(BundleDiscovery.DashboardDirectoryName); File.WriteAllText( Path.Combine( managedDirectory.FullName, @@ -2606,6 +2943,43 @@ private static string GetArgumentValue(IReadOnlyList arguments, string o return arguments[optionIndex + 1]; } + [Fact] + public void CreateStartInfo_WithNativeDashboard_SetsTerminalHostFromManagedLayout() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var layout = CreateBundleLayout(workspace); + var dashboardDirectory = Assert.IsType(layout.GetComponentPath(LayoutComponent.Dashboard)); + var dashboardPath = Path.Combine( + dashboardDirectory, + BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + File.WriteAllText(dashboardPath, string.Empty); + var server = CreatePrebuiltAppHostServer(workspace, layout: layout); + + var startInfo = server.CreateStartInfo(123); + + Assert.Equal(dashboardPath, startInfo.Environment[BundleDiscovery.DashboardPathEnvVar]); + Assert.Equal(layout.GetManagedPath(), startInfo.Environment[BundleDiscovery.TerminalHostPathEnvVar]); + Assert.Equal("terminalhost", startInfo.Environment[BundleDiscovery.TerminalHostInvocationArgsEnvVar]); + } + + [Fact] + public void CreateStartInfo_WithTerminalHostOverrides_PreservesOverrides() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var layout = CreateBundleLayout(workspace); + var server = CreatePrebuiltAppHostServer(workspace, layout: layout); + var environmentVariables = new Dictionary + { + [BundleDiscovery.TerminalHostPathEnvVar] = "custom-terminal-host", + [BundleDiscovery.TerminalHostInvocationArgsEnvVar] = "custom-args" + }; + + var startInfo = server.CreateStartInfo(123, environmentVariables); + + Assert.Equal("custom-terminal-host", startInfo.Environment[BundleDiscovery.TerminalHostPathEnvVar]); + Assert.Equal("custom-args", startInfo.Environment[BundleDiscovery.TerminalHostInvocationArgsEnvVar]); + } + [Fact] public void CreateStartInfo_SetsCliLogFilePathEnvironmentVariable() { diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index f00b77458f9..03a6120ce2f 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -53,8 +53,41 @@ await File.WriteAllTextAsync( return appHostFile; } + [Theory] + [InlineData("explicit-launch-configuration")] + [InlineData("explicit-cli")] + public async Task UseOrFindAppHostProjectFilePreservesExistingDefaultForInvocationScopedSelection(string selectionOrigin) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var firstAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("FirstAppHost"); + var firstAppHostProjectFile = new FileInfo(Path.Combine(firstAppHostDirectory.FullName, "FirstAppHost.csproj")); + await File.WriteAllTextAsync(firstAppHostProjectFile.FullName, "Not a real apphost"); + + var secondAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("SecondAppHost"); + var secondAppHostProjectFile = new FileInfo(Path.Combine(secondAppHostDirectory.FullName, "SecondAppHost.csproj")); + await File.WriteAllTextAsync(secondAppHostProjectFile.FullName, "Not a real apphost"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + const string originalConfig = """{"appHost":{"path":"FirstAppHost/FirstAppHost.csproj"}}"""; + await File.WriteAllTextAsync(configPath, originalConfig); + + var projectLocator = CreateProjectLocator( + CreateExecutionContext(workspace.WorkspaceRoot), + configuration: CreateSelectionOrigin(selectionOrigin)); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + secondAppHostProjectFile, + MultipleAppHostProjectsFoundBehavior.Prompt, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(secondAppHostProjectFile.FullName, result.SelectedProjectFile?.FullName); + Assert.Equal(originalConfig, await File.ReadAllTextAsync(configPath)); + } + [Fact] - public async Task UseOrFindAppHostProjectFilePreservesExistingDefaultForLaunchConfiguration() + public async Task UseOrFindAppHostProjectFilePreservesExistingDefaultForExplicitAppHost() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); @@ -70,9 +103,38 @@ public async Task UseOrFindAppHostProjectFilePreservesExistingDefaultForLaunchCo const string originalConfig = """{"appHost":{"path":"FirstAppHost/FirstAppHost.csproj"}}"""; await File.WriteAllTextAsync(configPath, originalConfig); + var projectLocator = CreateProjectLocator(CreateExecutionContext(workspace.WorkspaceRoot)); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + secondAppHostProjectFile, + MultipleAppHostProjectsFoundBehavior.Prompt, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(secondAppHostProjectFile.FullName, result.SelectedProjectFile?.FullName); + Assert.Equal(originalConfig, await File.ReadAllTextAsync(configPath)); + } + + [Fact] + public async Task UseOrFindAppHostProjectFilePreservesExistingDefaultForEmptySelectionOrigin() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var firstAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("FirstAppHost"); + var firstAppHostProjectFile = new FileInfo(Path.Combine(firstAppHostDirectory.FullName, "FirstAppHost.csproj")); + await File.WriteAllTextAsync(firstAppHostProjectFile.FullName, "Not a real apphost"); + + var secondAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("SecondAppHost"); + var secondAppHostProjectFile = new FileInfo(Path.Combine(secondAppHostDirectory.FullName, "SecondAppHost.csproj")); + await File.WriteAllTextAsync(secondAppHostProjectFile.FullName, "Not a real apphost"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + var originalConfig = """{"appHost":{"path":"FirstAppHost/FirstAppHost.csproj"}}"""; + await File.WriteAllTextAsync(configPath, originalConfig); + var projectLocator = CreateProjectLocator( CreateExecutionContext(workspace.WorkspaceRoot), - configuration: CreateLaunchConfigurationOrigin()); + configuration: CreateSelectionOrigin("")); var result = await projectLocator.UseOrFindAppHostProjectFileAsync( secondAppHostProjectFile, @@ -84,6 +146,103 @@ public async Task UseOrFindAppHostProjectFilePreservesExistingDefaultForLaunchCo Assert.Equal(originalConfig, await File.ReadAllTextAsync(configPath)); } + [Theory] + [InlineData("user-selection")] + [InlineData("default-discovery")] + public async Task UseOrFindAppHostProjectFileReplacesExistingDefaultForPersistentSelectionOrigin(string selectionOrigin) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var firstAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("FirstAppHost"); + var firstAppHostProjectFile = new FileInfo(Path.Combine(firstAppHostDirectory.FullName, "FirstAppHost.csproj")); + await File.WriteAllTextAsync(firstAppHostProjectFile.FullName, "Not a real apphost"); + + var secondAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("SecondAppHost"); + var secondAppHostProjectFile = new FileInfo(Path.Combine(secondAppHostDirectory.FullName, "SecondAppHost.csproj")); + await File.WriteAllTextAsync(secondAppHostProjectFile.FullName, "Not a real apphost"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """{"appHost":{"path":"FirstAppHost/FirstAppHost.csproj"}}"""); + + var projectLocator = CreateProjectLocator( + CreateExecutionContext(workspace.WorkspaceRoot), + configuration: CreateSelectionOrigin(selectionOrigin)); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + secondAppHostProjectFile, + MultipleAppHostProjectsFoundBehavior.Prompt, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(secondAppHostProjectFile.FullName, result.SelectedProjectFile?.FullName); + Assert.Equal("SecondAppHost/SecondAppHost.csproj", ReadConfiguredAppHostPath(configPath)); + } + + [Theory] + [InlineData(null)] + [InlineData("explicit-cli")] + [InlineData("user-selection")] + public async Task UseOrFindAppHostProjectFilePersistsSelectionFromExplicitDirectoryPrompt(string? selectionOrigin) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var firstAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("FirstAppHost"); + var firstAppHostProjectFile = new FileInfo(Path.Combine(firstAppHostDirectory.FullName, "FirstAppHost.csproj")); + await File.WriteAllTextAsync(firstAppHostProjectFile.FullName, "Not a real apphost"); + + var alternativesDirectory = workspace.WorkspaceRoot.CreateSubdirectory("Alternatives"); + var firstAlternative = new FileInfo(Path.Combine(alternativesDirectory.FullName, "FirstAlternative.csproj")); + await File.WriteAllTextAsync(firstAlternative.FullName, "Not a real apphost"); + var secondAlternative = new FileInfo(Path.Combine(alternativesDirectory.FullName, "SecondAlternative.csproj")); + await File.WriteAllTextAsync(secondAlternative.FullName, "Not a real apphost"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """{"appHost":{"path":"FirstAppHost/FirstAppHost.csproj"}}"""); + + var projectLocator = CreateProjectLocator( + CreateExecutionContext(workspace.WorkspaceRoot), + configuration: selectionOrigin is null ? null : CreateSelectionOrigin(selectionOrigin)); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + new FileInfo(alternativesDirectory.FullName), + MultipleAppHostProjectsFoundBehavior.Prompt, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(firstAlternative.FullName, result.SelectedProjectFile?.FullName); + Assert.True(result.WasExplicitDirectorySelectionPrompted); + Assert.Equal("Alternatives/FirstAlternative.csproj", ReadConfiguredAppHostPath(configPath)); + } + + [Fact] + public async Task UseOrFindAppHostProjectFilePreservesExistingDefaultForSingleAppHostDirectory() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var firstAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("FirstAppHost"); + var firstAppHostProjectFile = new FileInfo(Path.Combine(firstAppHostDirectory.FullName, "FirstAppHost.csproj")); + await File.WriteAllTextAsync(firstAppHostProjectFile.FullName, "Not a real apphost"); + + var alternativeDirectory = workspace.WorkspaceRoot.CreateSubdirectory("Alternative"); + var alternativeAppHost = new FileInfo(Path.Combine(alternativeDirectory.FullName, "Alternative.csproj")); + await File.WriteAllTextAsync(alternativeAppHost.FullName, "Not a real apphost"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + var originalConfig = """{"appHost":{"path":"FirstAppHost/FirstAppHost.csproj"}}"""; + await File.WriteAllTextAsync(configPath, originalConfig); + + var projectLocator = CreateProjectLocator(CreateExecutionContext(workspace.WorkspaceRoot)); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + new FileInfo(alternativeDirectory.FullName), + MultipleAppHostProjectsFoundBehavior.Prompt, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(alternativeAppHost.FullName, result.SelectedProjectFile?.FullName); + Assert.Equal(originalConfig, await File.ReadAllTextAsync(configPath)); + } + [Fact] public async Task UseOrFindAppHostProjectFileEstablishesDefaultForLaunchConfiguration() { @@ -108,6 +267,28 @@ public async Task UseOrFindAppHostProjectFileEstablishesDefaultForLaunchConfigur Assert.Equal("AppHost/AppHost.csproj", ReadConfiguredAppHostPath(configPath)); } + [Fact] + public async Task UseOrFindAppHostProjectFileEstablishesDefaultForExplicitAppHost() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost"); + var appHostProjectFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostProjectFile.FullName, "Not a real apphost"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + var projectLocator = CreateProjectLocator(CreateExecutionContext(workspace.WorkspaceRoot)); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + appHostProjectFile, + MultipleAppHostProjectsFoundBehavior.Prompt, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(appHostProjectFile.FullName, result.SelectedProjectFile?.FullName); + Assert.Equal("AppHost/AppHost.csproj", ReadConfiguredAppHostPath(configPath)); + } + [Fact] public async Task ConcurrentLaunchConfigurationsEstablishWorkspaceDefaultOnce() { @@ -179,8 +360,10 @@ public void PersistedAppHostPathComparisonIsCaseInsensitiveOnlyOnWindows() Assert.False(ProjectLocator.IsSamePersistedAppHostPath(configuredPath, selectedPath, TestEnvironment.CreateMacOS())); } - [Fact] - public async Task UseOrFindAppHostProjectFileReplacesDeletedDefaultForLaunchConfiguration() + [Theory] + [InlineData("explicit-launch-configuration")] + [InlineData("explicit-cli")] + public async Task UseOrFindAppHostProjectFileReplacesDeletedDefaultForInvocationScopedSelection(string selectionOrigin) { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); @@ -198,8 +381,73 @@ public async Task UseOrFindAppHostProjectFileReplacesDeletedDefaultForLaunchConf var projectLocator = CreateProjectLocator( CreateExecutionContext(workspace.WorkspaceRoot), + configuration: CreateSelectionOrigin(selectionOrigin)); + + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + secondAppHostProjectFile, + MultipleAppHostProjectsFoundBehavior.Prompt, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(secondAppHostProjectFile.FullName, result.SelectedProjectFile?.FullName); + Assert.Equal("SecondAppHost/SecondAppHost.csproj", ReadConfiguredAppHostPath(configPath)); + } + + [Fact] + public async Task UseOrFindAppHostProjectFilePreservesInvalidExistingDefaultForLaunchConfiguration() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var firstAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("FirstAppHost"); + var firstAppHostProjectFile = new FileInfo(Path.Combine(firstAppHostDirectory.FullName, "FirstAppHost.csproj")); + await File.WriteAllTextAsync(firstAppHostProjectFile.FullName, "Not a real apphost"); + + var secondAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("SecondAppHost"); + var secondAppHostProjectFile = new FileInfo(Path.Combine(secondAppHostDirectory.FullName, "SecondAppHost.csproj")); + await File.WriteAllTextAsync(secondAppHostProjectFile.FullName, "Not a real apphost"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + const string originalConfig = """{"appHost":{"path":"FirstAppHost/FirstAppHost.csproj"}}"""; + await File.WriteAllTextAsync(configPath, originalConfig); + + var projectFactory = new TestAppHostProjectFactory + { + ValidateAppHostCallback = file => new AppHostValidationResult(IsValid: file.FullName == secondAppHostProjectFile.FullName) + }; + var projectLocator = CreateProjectLocator( + CreateExecutionContext(workspace.WorkspaceRoot), + projectFactory: projectFactory, configuration: CreateLaunchConfigurationOrigin()); + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( + projectFile: null, + MultipleAppHostProjectsFoundBehavior.Throw, + createSettingsFile: true, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(secondAppHostProjectFile.FullName, result.SelectedProjectFile?.FullName); + Assert.Equal(originalConfig, await File.ReadAllTextAsync(configPath)); + } + + [Fact] + public async Task UseOrFindAppHostProjectFileReplacesDeletedDefaultForExplicitAppHost() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var firstAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("FirstAppHost"); + var firstAppHostProjectFile = new FileInfo(Path.Combine(firstAppHostDirectory.FullName, "FirstAppHost.csproj")); + await File.WriteAllTextAsync(firstAppHostProjectFile.FullName, "Not a real apphost"); + + var secondAppHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("SecondAppHost"); + var secondAppHostProjectFile = new FileInfo(Path.Combine(secondAppHostDirectory.FullName, "SecondAppHost.csproj")); + await File.WriteAllTextAsync(secondAppHostProjectFile.FullName, "Not a real apphost"); + + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); + await File.WriteAllTextAsync(configPath, """{"appHost":{"path":"FirstAppHost/FirstAppHost.csproj"}}"""); + firstAppHostProjectFile.Delete(); + + var projectLocator = CreateProjectLocator(CreateExecutionContext(workspace.WorkspaceRoot)); + var result = await projectLocator.UseOrFindAppHostProjectFileAsync( secondAppHostProjectFile, MultipleAppHostProjectsFoundBehavior.Prompt, @@ -211,11 +459,16 @@ public async Task UseOrFindAppHostProjectFileReplacesDeletedDefaultForLaunchConf } private static IConfiguration CreateLaunchConfigurationOrigin() + { + return CreateSelectionOrigin("explicit-launch-configuration"); + } + + private static IConfiguration CreateSelectionOrigin(string selectionOrigin) { return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - [KnownConfigNames.CliAppHostSelectionOrigin] = "explicit-launch-configuration" + [KnownConfigNames.CliAppHostSelectionOrigin] = selectionOrigin }) .Build(); } diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ScaffoldingServiceTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ScaffoldingServiceTests.cs index bf6e1f60753..ef8fba0f58e 100644 --- a/tests/Aspire.Cli.Tests/Scaffolding/ScaffoldingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Scaffolding/ScaffoldingServiceTests.cs @@ -262,6 +262,151 @@ public void MergeGitIgnoreContent_AppendsMissingEntriesWithoutOverwritingExistin lines); } + [Fact] + public void GetConflictingScaffoldFiles_TreatsVsCodeSettingsAsMergeable() + { + // Almost every existing repository opened in VS Code already has a .vscode/settings.json, + // and the Java scaffold writes one. Reporting it as a conflict aborts init entirely. + var rootDirectory = Directory.CreateTempSubdirectory(); + + try + { + Directory.CreateDirectory(Path.Combine(rootDirectory.FullName, ".vscode")); + File.WriteAllText(Path.Combine(rootDirectory.FullName, ".vscode", "settings.json"), "{}"); + + var conflicts = ScaffoldingService.GetConflictingScaffoldFiles( + rootDirectory.FullName, + [".vscode/settings.json"]); + + Assert.Empty(conflicts); + } + finally + { + rootDirectory.Delete(recursive: true); + } + } + + [Fact] + public void MergeVsCodeSettingsContent_AddsMissingSettingsAndKeepsTheDeveloperValues() + { + var existingContent = """ + { + "editor.formatOnSave": true, + "java.compile.nullAnalysis.mode": "automatic" + } + """; + var scaffoldContent = """ + { + "java.project.sourcePaths": [".", ".aspire/modules"], + "java.compile.nullAnalysis.mode": "disabled" + } + """; + + var merged = JsonNode.Parse( + ScaffoldingService.MergeVsCodeSettingsContent(existingContent, scaffoldContent))!.AsObject(); + + Assert.True(merged["editor.formatOnSave"]!.GetValue()); + // A setting the developer chose is theirs, so the scaffold does not overwrite it. + Assert.Equal("automatic", merged["java.compile.nullAnalysis.mode"]!.GetValue()); + Assert.Equal([".", ".aspire/modules"], merged["java.project.sourcePaths"]!.AsArray().Select(v => v!.GetValue())); + } + + [Fact] + public void MergeVsCodeSettingsContent_UnionsSourcePathsWithoutDroppingExistingEntries() + { + var existingContent = """ + { + "java.project.sourcePaths": ["src/main/java", "."] + } + """; + var scaffoldContent = """ + { + "java.project.sourcePaths": [".", ".aspire/modules"] + } + """; + + var merged = JsonNode.Parse( + ScaffoldingService.MergeVsCodeSettingsContent(existingContent, scaffoldContent))!.AsObject(); + + Assert.Equal( + ["src/main/java", ".", ".aspire/modules"], + merged["java.project.sourcePaths"]!.AsArray().Select(v => v!.GetValue())); + } + + [Fact] + public void MergeVsCodeSettingsContent_ReadsSettingsThatUseCommentsAndTrailingCommas() + { + // VS Code settings are JSONC, and its own UI writes the "// Place your settings" header. + var existingContent = """ + { + // Place your settings in this file. + "editor.tabSize": 4, + } + """; + var scaffoldContent = """ + { + "java.project.sourcePaths": ["."] + } + """; + + var merged = JsonNode.Parse( + ScaffoldingService.MergeVsCodeSettingsContent(existingContent, scaffoldContent))!.AsObject(); + + Assert.Equal(4, merged["editor.tabSize"]!.GetValue()); + Assert.Equal(["."], merged["java.project.sourcePaths"]!.AsArray().Select(v => v!.GetValue())); + } + + [Fact] + public void MergeVsCodeSettingsContent_PreservesSettingsThatCannotBeParsed() + { + // A settings.json broken mid-edit, or one using a JSONC construct the parser does not accept. + // Overwriting it discards editor configuration the developer may have accumulated for years, + // and `aspire init` gives no warning that it happened. + var existingContent = """ + { + "editor.formatOnSave": true, + "java.project.sourcePaths": ["src/main/java" + } + """; + var scaffoldContent = """ + { + "java.project.sourcePaths": [".", ".aspire/modules"] + } + """; + + var merged = ScaffoldingService.MergeVsCodeSettingsContent(existingContent, scaffoldContent); + + Assert.Equal(existingContent, merged); + } + + [Fact] + public void MergeVsCodeSettingsContent_LeavesTheFileAloneWhenEverySettingIsAlreadyPresent() + { + // Nothing to add means nothing is rewritten, so comments and formatting survive re-running init. + var existingContent = """ + { + // keep me + "java.project.sourcePaths": [".", ".aspire/modules"] + } + """; + var scaffoldContent = """ + { + "java.project.sourcePaths": [".", ".aspire/modules"] + } + """; + + Assert.Equal(existingContent, ScaffoldingService.MergeVsCodeSettingsContent(existingContent, scaffoldContent)); + } + + [Fact] + public void MergeVsCodeSettingsContent_KeepsTheExistingFileWhenItIsNotUsableJson() + { + // Preferring the scaffold here would silently discard the developer's whole settings file. + var merged = ScaffoldingService.MergeVsCodeSettingsContent("not json at all", """{"a": 1}"""); + + Assert.Equal("not json at all", merged); + } + [Fact] public void MergeGitIgnoreContent_DoesNotAddDuplicateAspireEntryWhenEquivalentEntryAlreadyExists() { diff --git a/tests/Aspire.Cli.Tests/Snapshots/JavaAppHostTemplateTests.JavaStarterAppHost_MatchesTheScaffoldedSource.verified.java b/tests/Aspire.Cli.Tests/Snapshots/JavaAppHostTemplateTests.JavaStarterAppHost_MatchesTheScaffoldedSource.verified.java new file mode 100644 index 00000000000..74c746e0ffb --- /dev/null +++ b/tests/Aspire.Cli.Tests/Snapshots/JavaAppHostTemplateTests.JavaStarterAppHost_MatchesTheScaffoldedSource.verified.java @@ -0,0 +1,34 @@ +import aspire.*; + +final class AppHost { + void main() throws Exception { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + // The Spring Boot API in ./api. Maven is detected from its pom.xml, so the AppHost never + // names a build tool: addSpringBootApp builds the project, launches it through + // spring-boot:run, and declares an HTTP endpoint by setting SERVER_PORT. + JavaAppResource api = builder.addSpringBootApp("api", "./api"); + + // Attaches target/agent/opentelemetry-javaagent.jar, which the POM downloads during the + // build. The agent instruments Spring's web and HTTP client layers, so traces, metrics, and + // logs reach the Aspire dashboard without any code in the service. + api.withOtelAgentDefaultPath(); + + // Holds anything that waits on the API back until Spring reports it can serve traffic, + // rather than merely until the process exists. + api.withHttpHealthCheck(new WithHttpHealthCheckOptions().path("/actuator/health")); + api.withExternalHttpEndpoints(); + + ViteAppResource frontend = builder.addViteApp("frontend", "./frontend"); + frontend.withReference(api); + frontend.waitFor(api); + + // In publish and deploy mode the frontend's build output is copied into the API container at + // ./static, which application.properties adds to the Spring resource locations. That puts the + // SPA and /api/weatherforecast on one origin. During local development this does nothing and + // Vite serves the frontend, proxying /api to the API instead. + api.publishWithContainerFiles(frontend, "./static"); + + builder.build().run(); + } +} diff --git a/tests/Aspire.Cli.Tests/Telemetry/ProfilingTelemetryTests.cs b/tests/Aspire.Cli.Tests/Telemetry/ProfilingTelemetryTests.cs index f9afbe7eb78..dc84d8a7623 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/ProfilingTelemetryTests.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/ProfilingTelemetryTests.cs @@ -66,7 +66,7 @@ public void ProcessSpansUseConsistentExecutableAndArgumentTags() using (var dotnetActivity = profilingTelemetry.StartDotNetProcess("run", null, new DirectoryInfo(workingDirectory), new ProcessInvocationOptions())) { - dotnetActivity.SetDotNetResolvedExecutable("dotnet", ["run", "--project", "AppHost"], msBuildServer: null); + dotnetActivity.SetDotNetResolvedExecutable("dotnet", ["run", "--project", "AppHost"], appHostArgumentStartIndex: null, msBuildServer: null); } using (profilingTelemetry.StartGitCommand("ls-files", "git", ["ls-files", "--cached"], new DirectoryInfo(workingDirectory))) @@ -114,6 +114,52 @@ public void ProcessSpansUseConsistentExecutableAndArgumentTags() }); } + [Fact] + public void ProcessSpansRedactForwardedAppHostArguments() + { + var startedActivities = new ConcurrentQueue(); + using var profilingTelemetry = new ProfilingTelemetry(CreateConfiguration( + (ProfilingTelemetry.EnvironmentVariables.Enabled, "true"), + (ProfilingTelemetry.EnvironmentVariables.SessionId, "session-1"))); + using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource, onActivityStarted: startedActivities.Enqueue); + var aspirePath = Path.Combine("tools", "aspire"); + var workingDirectory = Directory.GetCurrentDirectory(); + + using (profilingTelemetry.StartDetachedSpawnChild(aspirePath, ["run", "--apphost", "AppHost", "--", "--ApiKey", "sk-live-secret"], childCommand: "run")) + { + } + + using (var dotnetActivity = profilingTelemetry.StartDotNetProcess("run", null, new DirectoryInfo(workingDirectory), new ProcessInvocationOptions())) + { + dotnetActivity.SetDotNetResolvedExecutable("dotnet", ["run", "--project", "AppHost", "--", "--ConnectionStrings:db", "Server=db;Password=hunter2"], appHostArgumentStartIndex: null, msBuildServer: null); + } + + // A direct AppHost launch has no separator to key off, so the caller supplies the boundary. + using (var directActivity = profilingTelemetry.StartDotNetProcess("run", null, new DirectoryInfo(workingDirectory), new ProcessInvocationOptions())) + { + directActivity.SetDotNetResolvedExecutable("AppHost", ["--from-msbuild", "value", "--ApiKey", "sk-live-secret"], appHostArgumentStartIndex: 2, msBuildServer: null); + } + + var sessionActivities = GetSessionActivities(startedActivities, "session-1"); + Assert.Collection( + sessionActivities, + spawnActivity => + { + Assert.Equal(new[] { "run", "--apphost", "AppHost", "--", "", "" }, Assert.IsType(spawnActivity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgs))); + Assert.Equal(6, spawnActivity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgsCount)); + }, + dotnetActivity => + { + Assert.Equal(new[] { "run", "--project", "AppHost", "--", "", "" }, Assert.IsType(dotnetActivity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgs))); + Assert.Equal(6, dotnetActivity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgsCount)); + }, + directActivity => + { + Assert.Equal(new[] { "--from-msbuild", "value", "", "" }, Assert.IsType(directActivity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgs))); + Assert.Equal(4, directActivity.GetTagItem(ProfilingTelemetry.Tags.ProcessCommandArgsCount)); + }); + } + [Fact] public void StartAddCommand_CreatesAddSpecificSpans() { diff --git a/tests/Aspire.Cli.Tests/Templating/JavaAppHostTemplateTests.cs b/tests/Aspire.Cli.Tests/Templating/JavaAppHostTemplateTests.cs new file mode 100644 index 00000000000..b229f5e50b8 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/JavaAppHostTemplateTests.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests.Templating; + +public class JavaAppHostTemplateTests +{ + /// + /// The Java runtime spec launches the AppHost with java -cp .java-build AppHost, which names + /// the class in the default package. A package declaration compiles the class into a + /// subdirectory instead, so the launch fails with ClassNotFoundException: AppHost even though + /// compilation succeeded. + /// + [Fact] + public void JavaStarterAppHost_IsInTheDefaultPackageSoTheRunnerCanLoadIt() + { + var lines = File.ReadAllLines(GetJavaStarterAppHostPath()); + + Assert.DoesNotContain(lines, line => line.TrimStart().StartsWith("package ", StringComparison.Ordinal)); + } + + /// + /// Without a package declaration the AppHost no longer shares a package with the generated SDK, so + /// it has to import it explicitly or every generated type fails to resolve. + /// + [Fact] + public void JavaStarterAppHost_ImportsTheGeneratedSdkPackage() + { + var lines = File.ReadAllLines(GetJavaStarterAppHostPath()); + + Assert.Contains("import aspire.*;", lines.Select(line => line.Trim())); + } + + /// + /// The generated SDK declares package aspire;, so an editor can only resolve it when + /// .aspire/modules is a source root. javac does not need this because the CLI names every + /// generated file explicitly in its argument file, but the Java language server builds from the + /// project model instead and reports "package aspire does not exist" against an AppHost that runs + /// perfectly well. The template recommends the Java extension pack, so the scaffolded project has + /// to arrive with the source root already registered. + /// + [Fact] + public void JavaStarterTemplate_RegistersTheGeneratedSdkAsASourceRoot() + { + var path = Path.Combine(GetJavaStarterDirectory(), ".vscode", "settings.json"); + Assert.True(File.Exists(path), $"Expected the java-starter VS Code settings at {path}"); + + using var document = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)); + var sourcePaths = document.RootElement + .GetProperty("java.project.sourcePaths") + .EnumerateArray() + .Select(element => element.GetString() ?? string.Empty) + .ToArray(); + + Assert.Equal([".", ".aspire/modules"], sourcePaths); + } + + /// + /// The three tests above each assert one property the runner depends on, so between them a + /// template that had lost its entire class body still passed: nothing checked that the file + /// declares an AppHost at all, or that the sample it scaffolds still builds a real application. + /// Snapshotting the whole file closes that gap and makes any edit to the first thing a Java user + /// ever sees a deliberate, reviewable change. + /// + [Fact] + public async Task JavaStarterAppHost_MatchesTheScaffoldedSource() + { + await Verify(File.ReadAllText(GetJavaStarterAppHostPath()), extension: "java"); + } + + /// + /// The Java starter began life as a copy of the TypeScript starter, so it scaffolded an Express + /// API and a React frontend and no Java at all beyond the AppHost. Every other same-language + /// starter ships a service written in its own language — go-starter has api/main.go — and a Java + /// user picking the Java template has no reason to expect otherwise. This asserts the template + /// actually contains a buildable Java service. + /// + [Fact] + public void JavaStarterTemplate_ScaffoldsAJavaService() + { + var apiDirectory = Path.Combine(GetJavaStarterDirectory(), "api"); + + var pomPath = Path.Combine(apiDirectory, "pom.xml"); + Assert.True(File.Exists(pomPath), $"Expected the java-starter API's Maven project at {pomPath}"); + Assert.Contains("spring-boot-starter-parent", File.ReadAllText(pomPath), StringComparison.Ordinal); + + var javaSources = Directory + .EnumerateFiles(apiDirectory, "*.java", SearchOption.AllDirectories) + .Select(path => Path.GetFileName(path)) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(["ApiApplication.java", "WeatherForecastController.java"], javaSources); + } + + /// + /// The generated Java SDK surface is built from the packages in aspire.config.json. While + /// the starter listed only Aspire.Hosting.JavaScript, a freshly scaffolded Java project had no + /// addSpringBootApp, addJavaApp, or addQuarkusApp to call, so the AppHost + /// could not add a Java resource until the user hand-edited the config. + /// + [Fact] + public void JavaStarterTemplate_DeclaresTheJavaHostingPackage() + { + var path = Path.Combine(GetJavaStarterDirectory(), "aspire.config.json"); + + using var document = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)); + var packages = document.RootElement + .GetProperty("packages") + .EnumerateObject() + .Select(package => package.Name) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(["Aspire.Hosting.Java", "Aspire.Hosting.JavaScript"], packages); + } + + /// + /// The AppHost never names a build tool: addSpringBootApp resolves one from the project, and the + /// resolver prefers a wrapper over whatever mvn happens to be on PATH so the scaffolded + /// project builds identically on a machine with no Maven installed. That only holds if the + /// wrapper is actually in the template. The wrapper is script-only by design — a + /// distributionType of anything else requires committing maven-wrapper.jar. + /// + [Fact] + public void JavaStarterTemplate_ShipsAScriptOnlyMavenWrapper() + { + var apiDirectory = Path.Combine(GetJavaStarterDirectory(), "api"); + + foreach (var relativePath in new[] { "mvnw", "mvnw.cmd" }) + { + var path = Path.Combine(apiDirectory, relativePath); + Assert.True(File.Exists(path), $"Expected the java-starter Maven wrapper at {path}"); + } + + var propertiesPath = Path.Combine(apiDirectory, ".mvn", "wrapper", "maven-wrapper.properties"); + Assert.True(File.Exists(propertiesPath), $"Expected the java-starter wrapper properties at {propertiesPath}"); + Assert.Contains("distributionType=only-script", File.ReadAllText(propertiesPath), StringComparison.Ordinal); + } + + /// + /// The Vite dev server proxies /api to the endpoint variable Aspire injects for + /// frontend.withReference(api), which is named after the referenced resource and its + /// endpoint — API_HTTP for a resource called api with the default http + /// endpoint. Renaming the resource in AppHost.java silently breaks the proxy because + /// vite.config.ts falls back to an undefined target, so the two are pinned together here. + /// + [Fact] + public void JavaStarterTemplate_FrontendProxiesToTheApiResourceEndpointVariable() + { + var appHost = File.ReadAllText(GetJavaStarterAppHostPath()); + Assert.Contains("addSpringBootApp(\"api\", \"./api\")", appHost, StringComparison.Ordinal); + + var viteConfig = File.ReadAllText(Path.Combine(GetJavaStarterDirectory(), "frontend", "vite.config.ts")); + Assert.Contains("process.env.API_HTTPS || process.env.API_HTTP", viteConfig, StringComparison.Ordinal); + } + + private static string GetJavaStarterAppHostPath() + { + var path = Path.Combine(GetJavaStarterDirectory(), "AppHost.java"); + Assert.True(File.Exists(path), $"Expected the java-starter AppHost at {path}"); + + return path; + } + + private static string GetJavaStarterDirectory() + => Path.Combine(GetRepoRoot(), "src", "Aspire.Cli", "Templating", "Templates", "java-starter"); + + private static string GetRepoRoot() + => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); +} diff --git a/tests/Aspire.Cli.Tests/Templating/JavaStarterScaffoldTests.cs b/tests/Aspire.Cli.Tests/Templating/JavaStarterScaffoldTests.cs new file mode 100644 index 00000000000..ba17bf554ef --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/JavaStarterScaffoldTests.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Commands; +using Aspire.Cli.Projects; +using Aspire.Cli.Templating; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Templating; + +/// +/// Drives aspire new aspire-java-starter through the real CliTemplateFactory writer so +/// the assertions are about what actually lands on disk, with only +/// faked so the run stops before it reaches the network, +/// the dotnet CLI, npm, or the Java toolchain. +/// +public class JavaStarterScaffoldTests(ITestOutputHelper outputHelper) +{ + /// + /// Embedded resources carry no file mode, so every scaffolded file used to land at the default + /// 0644. The Java starter ships a Maven wrapper the README and the Java tooling both invoke as + /// ./mvnw, which fails with "Permission denied" against a non-executable file. The hosting + /// resolver launches the wrapper through sh and so survives either way, which is exactly + /// why this needs its own test: the AppHost keeps working while everything a developer types by + /// hand does not. + /// + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "File modes are a Unix concept; the wrapper is invoked as mvnw.cmd on Windows.")] + public async Task JavaStarter_ScaffoldsAnExecutableMavenWrapper() + { + using var scaffold = await ScaffoldJavaStarterAsync(); + var outputDirectory = scaffold.OutputDirectory; + + var wrapperPath = Path.Combine(outputDirectory, "api", "mvnw"); + Assert.True(File.Exists(wrapperPath), $"Expected the scaffolded Maven wrapper at {wrapperPath}"); + + var mode = GetUnixFileMode(wrapperPath); + Assert.True(mode.HasFlag(UnixFileMode.UserExecute), $"Expected the scaffolded Maven wrapper to be executable, but its mode was {mode}."); + } + +#pragma warning disable CA1416 // Only reached from a test skipped on Windows, which the analyzer cannot see through. + private static UnixFileMode GetUnixFileMode(string path) => File.GetUnixFileMode(path); +#pragma warning restore CA1416 + + /// + /// The Java service, its wrapper, and the frontend all have to survive the scaffold together. + /// Asserting the whole relative-path set rather than a handful of probes means a template file + /// silently dropped from the embedded resource glob fails here instead of at a user's first + /// aspire run. + /// + [Fact] + public async Task JavaStarter_ScaffoldsTheJavaServiceAlongsideTheFrontend() + { + using var scaffold = await ScaffoldJavaStarterAsync(); + var outputDirectory = scaffold.OutputDirectory; + + var apiFiles = Directory + .EnumerateFiles(Path.Combine(outputDirectory, "api"), "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(outputDirectory, path).Replace(Path.DirectorySeparatorChar, '/')) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + [ + "api/.mvn/wrapper/maven-wrapper.properties", + "api/mvnw", + "api/mvnw.cmd", + "api/pom.xml", + "api/src/main/java/com/example/api/ApiApplication.java", + "api/src/main/java/com/example/api/WeatherForecastController.java", + "api/src/main/resources/application.properties", + ], + apiFiles); + + Assert.True(File.Exists(Path.Combine(outputDirectory, "frontend", "vite.config.ts"))); + Assert.True(File.Exists(Path.Combine(outputDirectory, "AppHost.java"))); + } + + private async Task ScaffoldJavaStarterAsync() + { + // The workspace is handed back to the caller rather than disposed here: disposal deletes the + // directory, so every assertion about the scaffolded files has to run before it happens. + var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.EnabledFeatures = [KnownFeatures.ExperimentalPolyglotJava]; + }); + + // PrepareAsync returning failure stops the run right after the template tree is written, + // which is the only side-effect these tests inspect. + services.AddSingleton(_ => new TestAppHostServerProjectFactory + { + CreateAsyncCallback = (path, _) => + Task.FromResult(new FakeFailingAppHostServerProject(path)) + }); + + using var serviceProvider = services.BuildServiceProvider(); + var newCommand = serviceProvider.GetRequiredService(); + + const string outputDirectoryName = "JavaStarterOut"; + + // An explicit --version short-circuits template version resolution, which would otherwise + // reach for the network. These tests are about the files the writer puts on disk, and the + // version only reaches aspire.config.json. + var parseResult = newCommand.Parse( + $"new {KnownTemplateId.JavaStarter} --name {outputDirectoryName} --output ./{outputDirectoryName} --version 13.0.0 --localhost-tld false"); + _ = await parseResult.InvokeAsync().DefaultTimeout(); + + return new ScaffoldedTemplate(workspace, Path.Combine(workspace.WorkspaceRoot.FullName, outputDirectoryName)); + } + + private sealed class ScaffoldedTemplate(TemporaryWorkspace workspace, string outputDirectory) : IDisposable + { + public string OutputDirectory { get; } = outputDirectory; + + public void Dispose() => workspace.Dispose(); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs index 6588fdab6a4..9c97dac87ba 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs @@ -72,7 +72,7 @@ public async Task InstallAsync(CancellationToken canc } await EnsureBundleAsync(cancellationToken); - var bundle = await AspireSkillsBundle.LoadAsync(_bundleDirectory, cancellationToken); + var bundle = await new AspireSkillsBundleProvider().LoadAsync(_bundleDirectory, cancellationToken); return AspireSkillsInstallResult.Installed(bundle); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestGitWorktree.cs b/tests/Aspire.Cli.Tests/TestServices/TestGitWorktree.cs new file mode 100644 index 00000000000..2bad0a6c00b --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestGitWorktree.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests.TestServices; + +internal static class TestGitWorktree +{ + public static string WriteLinkedWorktreeMetadata( + string worktreeRoot, + string commonGitDirectory, + string worktreeName = "feature", + bool useRelativePaths = false) + { + var adminDirectory = Path.Combine(Path.GetFullPath(commonGitDirectory), "worktrees", worktreeName); + Directory.CreateDirectory(adminDirectory); + + var gitFilePath = WriteGitDirFile(worktreeRoot, adminDirectory, useRelativePaths); + var backPointer = useRelativePaths + ? Path.GetRelativePath(adminDirectory, gitFilePath) + : gitFilePath; + File.WriteAllText(Path.Combine(adminDirectory, "gitdir"), backPointer + Environment.NewLine); + + return adminDirectory; + } + + public static string WriteGitDirFile(string checkoutRoot, string gitDirectory, bool useRelativePath = false) + { + var fullCheckoutRoot = Path.GetFullPath(checkoutRoot); + var fullGitDirectory = Path.GetFullPath(gitDirectory); + Directory.CreateDirectory(fullCheckoutRoot); + Directory.CreateDirectory(fullGitDirectory); + + var gitFilePath = Path.Combine(fullCheckoutRoot, ".git"); + var pointer = useRelativePath + ? Path.GetRelativePath(fullCheckoutRoot, fullGitDirectory) + : fullGitDirectory; + File.WriteAllText(gitFilePath, $"gitdir: {pointer}{Environment.NewLine}"); + + return gitFilePath; + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/AppHostArgumentRedactorTests.cs b/tests/Aspire.Cli.Tests/Utils/AppHostArgumentRedactorTests.cs new file mode 100644 index 00000000000..e7a84e8465e --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/AppHostArgumentRedactorTests.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class AppHostArgumentRedactorTests +{ + [Fact] + public void Redact_NoSeparator_ReturnsArgumentsUnchanged() + { + string[] args = ["run", "--project", "AppHost.csproj"]; + + Assert.Equal(args, AppHostArgumentRedactor.Redact(args)); + } + + [Fact] + public void Redact_EmptyArguments_ReturnsEmpty() + { + Assert.Empty(AppHostArgumentRedactor.Redact([])); + } + + [Fact] + public void Redact_SeparatorWithTrailingTokens_RedactsEveryForwardedToken() + { + string[] args = ["run", "--project", "AppHost.csproj", "--", "--ConnectionStrings:db", "Server=db;Password=hunter2"]; + + Assert.Equal( + ["run", "--project", "AppHost.csproj", "--", "", ""], + AppHostArgumentRedactor.Redact(args)); + } + + [Fact] + public void Redact_SeparatorAsLastToken_KeepsSeparatorAndRedactsNothing() + { + string[] args = ["run", "--"]; + + Assert.Equal(["run", "--"], AppHostArgumentRedactor.Redact(args)); + } + + [Fact] + public void Redact_SeparatorAsOnlyToken_ReturnsSeparator() + { + Assert.Equal(["--"], AppHostArgumentRedactor.Redact(["--"])); + } + + [Fact] + public void Redact_LiteralSeparatorAfterFirstSeparator_IsRedacted() + { + // Only the first "--" is the CLI/AppHost boundary. A later "--" is AppHost input, so it is + // redacted like every other forwarded token. + string[] args = ["run", "--", "--secret", "--", "value"]; + + Assert.Equal( + ["run", "--", "", "", ""], + AppHostArgumentRedactor.Redact(args)); + } + + [Fact] + public void Redact_SeparatorAsFirstToken_RedactsRemainder() + { + string[] args = ["--", "--ApiKey", "sk-live-secret"]; + + Assert.Equal(["--", "", ""], AppHostArgumentRedactor.Redact(args)); + } + + [Fact] + public void Redact_TokenContainingSeparatorAsSubstring_IsNotTreatedAsSeparator() + { + // "--foo" and "--=x" are not the bare separator, so nothing is redacted. + string[] args = ["run", "--foo", "--=x"]; + + Assert.Equal(["run", "--foo", "--=x"], AppHostArgumentRedactor.Redact(args)); + } + + [Fact] + public void RedactToString_JoinsRedactedTokensWithSpaces() + { + string[] args = ["run", "--project", "AppHost.csproj", "--", "--ApiKey", "sk-live-secret"]; + + Assert.Equal( + "run --project AppHost.csproj -- ", + AppHostArgumentRedactor.RedactToString(args)); + } + + [Fact] + public void RedactToString_NoSeparator_JoinsArgumentsVerbatim() + { + Assert.Equal("run --project AppHost.csproj", AppHostArgumentRedactor.RedactToString(["run", "--project", "AppHost.csproj"])); + } + + [Fact] + public void RedactFrom_RedactsEveryTokenFromIndex() + { + // A direct AppHost launch has no separator: the leading arguments come from MSBuild + // RunArguments and the launch profile, and the tail is user-supplied AppHost input. + string[] args = ["--from-msbuild", "value", "--ApiKey", "sk-live-secret"]; + + Assert.Equal( + ["--from-msbuild", "value", "", ""], + AppHostArgumentRedactor.RedactFrom(args, 2)); + } + + [Fact] + public void RedactFrom_IndexAtOrPastEnd_ReturnsArgumentsUnchanged() + { + string[] args = ["--from-msbuild", "value"]; + + Assert.Equal(args, AppHostArgumentRedactor.RedactFrom(args, 2)); + Assert.Equal(args, AppHostArgumentRedactor.RedactFrom(args, 5)); + } + + [Fact] + public void RedactFrom_ZeroIndex_RedactsEveryToken() + { + Assert.Equal( + ["", ""], + AppHostArgumentRedactor.RedactFrom(["--ApiKey", "sk-live-secret"], 0)); + } + + [Fact] + public void RedactFromToString_JoinsRedactedTokensWithSpaces() + { + string[] args = ["--from-msbuild", "value", "--ApiKey", "sk-live-secret"]; + var redacted = AppHostArgumentRedactor.RedactFromToString(args, 2); + + Assert.Equal("--from-msbuild value ", redacted); + Assert.DoesNotContain("sk-live-secret", redacted, StringComparison.Ordinal); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 976349ca58b..d2e600065a0 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -56,6 +56,27 @@ public static InstallSidecarReader CreateSidecarReader(ITestOutputHelper outputH return new InstallSidecarReader(loggerFactory.CreateLogger()); } + public static ServiceProvider CreateExtensionServiceProvider( + TemporaryWorkspace workspace, + ITestOutputHelper outputHelper, + Action startDebugSessionCallback, + Action? configureOptions = null, + Action? configureServices = null) + { + var services = CreateServiceCollection(workspace, outputHelper, testOptions => + { + configureOptions?.Invoke(testOptions); + testOptions.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel(); + testOptions.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp) + { + StartDebugSessionCallback = startDebugSessionCallback + }; + }); + configureServices?.Invoke(services); + + return services.BuildServiceProvider(); + } + public static IServiceCollection CreateServiceCollection(TemporaryWorkspace workspace, ITestOutputHelper outputHelper, Action? configure = null) { var options = new CliServiceCollectionTestOptions(outputHelper, workspace.WorkspaceRoot); @@ -201,6 +222,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work // pattern as production wiring in Program.cs. services.AddSingleton(_ => new IdentityChannelReader(typeof(Program).Assembly)); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // AppHost project handlers - must match Program.cs registration pattern diff --git a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj index 40b2a70805e..e4a3b5e69a8 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj +++ b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj @@ -1,11 +1,12 @@  - $(DefaultTargetFramework) + net11.0 $(NoWarn);CS8002 + $(WarningsNotAsErrors);ASPDEPR004;ASPDEPR008;SYSLIB0057 diff --git a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj index dc05e129303..1c803741c9e 100644 --- a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj +++ b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj @@ -1,11 +1,12 @@ - $(DefaultTargetFramework) + net11.0 $(NoWarn);CS8002 + $(WarningsNotAsErrors);ASPDEPR004;ASPDEPR008;SYSLIB0057 true true diff --git a/tests/Aspire.Dashboard.Tests/BlazorAssetsTests.cs b/tests/Aspire.Dashboard.Tests/BlazorAssetsTests.cs index 35de4a93e22..1b8844c25c6 100644 --- a/tests/Aspire.Dashboard.Tests/BlazorAssetsTests.cs +++ b/tests/Aspire.Dashboard.Tests/BlazorAssetsTests.cs @@ -7,20 +7,14 @@ namespace Aspire.Dashboard.Tests; public class BlazorAssetsTests { - [Theory] - [InlineData("10")] - [InlineData("11")] - public void BlazorWebJs_DoesNotSendUnsupportedKeyboardEventProperties(string runtimeMajorVersion) + [Fact] + public void BlazorScript_UsesTargetFrameworkStaticWebAsset() { - var blazorWebJsPath = Path.Combine(GetRepoRoot(), "src", "Aspire.Dashboard", "wwwroot", "framework", $"blazor.web.{runtimeMajorVersion}.js"); - Assert.True(File.Exists(blazorWebJsPath), $"Expected generated Blazor asset at {blazorWebJsPath}"); + var blazorScriptPath = Path.Combine(GetRepoRoot(), "src", "Aspire.Dashboard", "Components", "BlazorScript.razor"); - var blazorWebJs = File.ReadAllText(blazorWebJsPath); + var blazorScript = File.ReadAllText(blazorScriptPath).Trim(); - Assert.Contains("keydown", blazorWebJs, StringComparison.Ordinal); - Assert.False( - blazorWebJs.Contains("isComposing", StringComparison.Ordinal), - "The dashboard Blazor script must not emit KeyboardEvent.isComposing because the server event parser rejects the unknown property."); + Assert.Equal("""""", blazorScript); } private static string GetRepoRoot() diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 325df3ad705..1730cc32748 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using System.Net.Http.Json; using System.Web; using Aspire.Dashboard.Authentication; using Aspire.Dashboard.Configuration; diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index 987e350a11c..62d4a44b50c 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -67,7 +67,7 @@ public async Task EndPointAccessors_AppStarted_EndPointPortsAssigned() [Fact] public async Task RunAsync_TokenCancelled_ShutsDownAndReturnsZero() { - // The standalone `aspire-managed dashboard` process relies on RunAsync honoring its cancellation + // The standalone Dashboard process relies on RunAsync honoring its cancellation // token so the parent-liveness watchdog can tear the dashboard down when the launching CLI dies. // Verify a running dashboard shuts down gracefully and reports success when the token is cancelled. var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(testOutputHelper); @@ -295,7 +295,7 @@ public async Task Configuration_FileConfigDirectoryDoesntExist_Error() }).DefaultTimeout(); // Assert - Assert.Contains(fileConfigDirectory, ex.Message); + Assert.Contains("The root directory for the FileProvider doesn't exist", ex.Message); } [Fact] @@ -917,7 +917,9 @@ public async Task EndPointAccessors_AppStarted_BrowserGet_Success() // Assert response.EnsureSuccessStatusCode(); - Assert.NotEmpty(response.Headers.GetValues(HeaderNames.ContentSecurityPolicy).Single()); + var contentSecurityPolicyValues = response.Headers.GetValues(HeaderNames.ContentSecurityPolicy).ToList(); + Assert.NotEmpty(contentSecurityPolicyValues); + Assert.All(contentSecurityPolicyValues, Assert.NotEmpty); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs index bcd5435727e..9114eae9527 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using System.Net.Http.Json; using Aspire.Dashboard.Api; using Aspire.Dashboard.Configuration; using Aspire.Hosting; diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 3bd41abf4a8..4574c466909 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Threading.Channels; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; @@ -153,6 +154,840 @@ public async Task SubscribeResources_HasInitialData_InitialDataReturned() Assert.Single(initialData); } + [Fact] + public async Task GetResources_ReplicaRunning_ReturnsParentWithReplicaState() + { + await using var instance = CreateResourceServiceClient(); + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Running"); + + instance.SetInitialDataReceived([parent, child]); + + var resources = instance.GetResources(); + + var updatedParent = Assert.Single(resources, r => r.Name == parent.Name); + Assert.Equal("Running", updatedParent.State); + Assert.Equal(KnownResourceState.Running, updatedParent.KnownState); + } + + [Fact] + public async Task GetResources_HiddenReplica_DoesNotHideParent() + { + await using var instance = CreateResourceServiceClient(); + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Hidden"); + + instance.SetInitialDataReceived([parent, child]); + + var resources = instance.GetResources(); + + var updatedParent = Assert.Single(resources, r => r.Name == parent.Name); + Assert.Equal("Scaled to zero", updatedParent.State); + Assert.Null(updatedParent.KnownState); + Assert.False(updatedParent.IsResourceHidden(showHiddenResources: false)); + + var updatedChild = Assert.Single(resources, r => r.Name == child.Name); + Assert.Equal("Hidden", updatedChild.State); + Assert.Equal(KnownResourceState.Hidden, updatedChild.KnownState); + Assert.True(updatedChild.IsResourceHidden(showHiddenResources: false)); + } + + [Fact] + public async Task GetResources_HiddenParentWithRunningReplica_RemainsHidden() + { + await using var instance = CreateResourceServiceClient(); + var parent = CreateResource("syndule-api", "Azure Container App", "Hidden"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Running"); + + instance.SetInitialDataReceived([parent, child]); + + var resources = instance.GetResources(); + + var updatedParent = Assert.Single(resources, r => r.Name == parent.Name); + Assert.Equal("Hidden", updatedParent.State); + Assert.Equal(KnownResourceState.Hidden, updatedParent.KnownState); + Assert.True(updatedParent.IsResourceHidden(showHiddenResources: false)); + } + + [Fact] + public async Task GetResources_ChildResourceWithDifferentDisplayName_DoesNotUpdateParentState() + { + await using var instance = CreateResourceServiceClient(); + var parent = CreateResource("worker", "Project", "Scaled to zero"); + var child = CreateChild(parent, "worker-migration", "worker-migration", "Running"); + + instance.SetInitialDataReceived([parent, child]); + + var resources = instance.GetResources(); + + var updatedParent = Assert.Single(resources, r => r.Name == parent.Name); + Assert.Equal("Scaled to zero", updatedParent.State); + Assert.Null(updatedParent.KnownState); + } + + [Fact] + public async Task GetResources_MultipleRunningReplicas_UsesLeastHealthyReplicaForParentHealth() + { + await using var instance = CreateResourceServiceClient(); + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var healthyChild = CreateReplicaChild(parent, "syndule-api--0000001", "Running", Aspire.DashboardService.Proto.V1.HealthStatus.Healthy); + var unhealthyChild = CreateReplicaChild(parent, "syndule-api--0000002", "Running", Aspire.DashboardService.Proto.V1.HealthStatus.Unhealthy); + + instance.SetInitialDataReceived([parent, healthyChild, unhealthyChild]); + + var resources = instance.GetResources(); + + var updatedParent = Assert.Single(resources, r => r.Name == parent.Name); + Assert.Equal("Running", updatedParent.State); + Assert.Equal(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy, updatedParent.HealthStatus); + Assert.Equal("syndule-api--0000002", Assert.Single(updatedParent.HealthReports).Name); + } + + [Fact] + public async Task SubscribeResources_ReplicaUpdated_EmitsParentStateChange() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Scaled to zero"); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, child } + } + }); + + var (_, subscription) = await subscribeTask.DefaultTimeout(); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Upsert = CreateReplicaChild(parent, child.Name, "Running") + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + var updatedParent = Assert.Single(enumerator.Current, c => c.ChangeType == ResourceViewModelChangeType.Upsert && c.Resource.Name == parent.Name).Resource; + Assert.Equal("Running", updatedParent.State); + Assert.Equal(KnownResourceState.Running, updatedParent.KnownState); + } + + [Fact] + public async Task SubscribeResources_HiddenParentReplicaUpdated_RemainsHidden() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Hidden"); + var child = CreateReplicaChildWithoutState(parent, "syndule-api--0000007"); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, child } + } + }); + + var (initialData, subscription) = await subscribeTask.DefaultTimeout(); + var initialParent = Assert.Single(initialData, r => r.Name == parent.Name); + Assert.Equal("Hidden", initialParent.State); + Assert.Equal(KnownResourceState.Hidden, initialParent.KnownState); + Assert.True(initialParent.IsResourceHidden(showHiddenResources: false)); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Upsert = CreateReplicaChild(parent, child.Name, "Running") + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + + var updatedParent = Assert.Single(instance.GetResources(), r => r.Name == parent.Name); + Assert.Equal("Hidden", updatedParent.State); + Assert.Equal(KnownResourceState.Hidden, updatedParent.KnownState); + Assert.True(updatedParent.IsResourceHidden(showHiddenResources: false)); + + Assert.Collection( + enumerator.Current, + change => + { + Assert.Equal(ResourceViewModelChangeType.Upsert, change.ChangeType); + Assert.Equal(child.Name, change.Resource.Name); + Assert.Equal("Running", change.Resource.State); + }); + } + + [Fact] + public async Task SubscribeResources_ReplicaHealthChanged_EmitsParentHealthChange() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Running", Aspire.DashboardService.Proto.V1.HealthStatus.Healthy); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, child } + } + }); + + var (initialData, subscription) = await subscribeTask.DefaultTimeout(); + var initialParent = Assert.Single(initialData, r => r.Name == parent.Name); + Assert.Equal(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy, initialParent.HealthStatus); + + // The replica stays in the same state and only its health report changes. The parent row still has to + // be re-emitted, because the parent projects the replica's health as well as its state. + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Upsert = CreateReplicaChild(parent, child.Name, "Running", Aspire.DashboardService.Proto.V1.HealthStatus.Unhealthy) + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + var updatedParent = Assert.Single(enumerator.Current, c => c.ChangeType == ResourceViewModelChangeType.Upsert && c.Resource.Name == parent.Name).Resource; + Assert.Equal("Running", updatedParent.State); + Assert.Equal(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy, updatedParent.HealthStatus); + Assert.Equal(child.Name, Assert.Single(updatedParent.HealthReports).Name); + } + + [Fact] + public async Task SubscribeResources_ReplicaStateOwnedPropertyChanged_EmitsParentChange() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Exited"); + child.Properties.Add(new ResourceProperty + { + Name = KnownProperties.Resource.ExitCode, + Value = Value.ForNumber(0) + }); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, child } + } + }); + + var (_, subscription) = await subscribeTask.DefaultTimeout(); + + // Only the exit code moves. State, health, and timestamps are all unchanged, so the parent row is + // re-emitted purely because a state-owned property the parent projects has a different value. + var updatedChild = CreateReplicaChild(parent, child.Name, "Exited"); + updatedChild.Properties.Add(new ResourceProperty + { + Name = KnownProperties.Resource.ExitCode, + Value = Value.ForNumber(137) + }); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Upsert = updatedChild + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + var updatedParent = Assert.Single(enumerator.Current, c => c.ChangeType == ResourceViewModelChangeType.Upsert && c.Resource.Name == parent.Name).Resource; + Assert.Equal("Exited", updatedParent.State); + Assert.Equal(137d, updatedParent.Properties[KnownProperties.Resource.ExitCode].Value.NumberValue); + } + + [Fact] + public async Task SubscribeResources_ReplicaStateStyleChanged_EmitsParentChange() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Running"); + child.StateStyle = "info"; + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, child } + } + }); + + var (initialData, subscription) = await subscribeTask.DefaultTimeout(); + var initialParent = Assert.Single(initialData, r => r.Name == parent.Name); + Assert.Equal("info", initialParent.StateStyle); + + var updatedChild = CreateReplicaChild(parent, child.Name, "Running"); + updatedChild.CreatedAt = child.CreatedAt; + updatedChild.StateStyle = "warning"; + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Upsert = updatedChild + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + var updatedParent = Assert.Single(enumerator.Current, c => c.ChangeType == ResourceViewModelChangeType.Upsert && c.Resource.Name == parent.Name).Resource; + Assert.Equal("Running", updatedParent.State); + Assert.Equal("warning", updatedParent.StateStyle); + } + + [Fact] + public async Task SubscribeResources_ReplicaStartedAtChanged_EmitsParentChange() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Running"); + var initialStartedAt = new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc); + child.StartedAt = Timestamp.FromDateTime(initialStartedAt); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, child } + } + }); + + var (initialData, subscription) = await subscribeTask.DefaultTimeout(); + var initialParent = Assert.Single(initialData, r => r.Name == parent.Name); + Assert.Equal(initialStartedAt, initialParent.StartTimeStamp); + + var updatedStartedAt = initialStartedAt.AddMinutes(1); + var updatedChild = CreateReplicaChild(parent, child.Name, "Running"); + updatedChild.CreatedAt = child.CreatedAt; + updatedChild.StartedAt = Timestamp.FromDateTime(updatedStartedAt); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Upsert = updatedChild + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + var updatedParent = Assert.Single(enumerator.Current, c => c.ChangeType == ResourceViewModelChangeType.Upsert && c.Resource.Name == parent.Name).Resource; + Assert.Equal("Running", updatedParent.State); + Assert.Equal(updatedStartedAt, updatedParent.StartTimeStamp); + } + + [Fact] + public async Task SubscribeResources_ReplicaStoppedAtChanged_EmitsParentChange() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Exited"); + var initialStoppedAt = new DateTime(2026, 8, 9, 12, 1, 0, DateTimeKind.Utc); + child.StoppedAt = Timestamp.FromDateTime(initialStoppedAt); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, child } + } + }); + + var (initialData, subscription) = await subscribeTask.DefaultTimeout(); + var initialParent = Assert.Single(initialData, r => r.Name == parent.Name); + Assert.Equal(initialStoppedAt, initialParent.StopTimeStamp); + + var updatedStoppedAt = initialStoppedAt.AddMinutes(1); + var updatedChild = CreateReplicaChild(parent, child.Name, "Exited"); + updatedChild.CreatedAt = child.CreatedAt; + updatedChild.StoppedAt = Timestamp.FromDateTime(updatedStoppedAt); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Upsert = updatedChild + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + var updatedParent = Assert.Single(enumerator.Current, c => c.ChangeType == ResourceViewModelChangeType.Upsert && c.Resource.Name == parent.Name).Resource; + Assert.Equal("Exited", updatedParent.State); + Assert.Equal(updatedStoppedAt, updatedParent.StopTimeStamp); + } + + [Fact] + public async Task SubscribeResources_ReplicaDeleted_EmitsParentFallbackState() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Running"); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, child } + } + }); + + var (initialData, subscription) = await subscribeTask.DefaultTimeout(); + var initialParent = Assert.Single(initialData, r => r.Name == parent.Name); + Assert.Equal("Running", initialParent.State); + Assert.Equal(KnownResourceState.Running, initialParent.KnownState); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Delete = new ResourceDeletion + { + ResourceName = child.Name, + ResourceType = child.ResourceType + } + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + var fallbackParent = Assert.Single(enumerator.Current, c => c.ChangeType == ResourceViewModelChangeType.Upsert && c.Resource.Name == parent.Name).Resource; + Assert.Equal("Scaled to zero", fallbackParent.State); + Assert.Null(fallbackParent.KnownState); + } + + [Fact] + public async Task SubscribeResources_ReplicaStateOwnedPropertyMetadataChanged_EmitsParentChange() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Exited"); + child.Properties.Add(new ResourceProperty + { + Name = KnownProperties.Resource.ExitCode, + Value = Value.ForNumber(137) + }); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, child } + } + }); + + var (initialData, subscription) = await subscribeTask.DefaultTimeout(); + var initialParent = Assert.Single(initialData, r => r.Name == parent.Name); + Assert.False(initialParent.Properties[KnownProperties.Resource.ExitCode].IsHighlighted); + + // The exit code value is unchanged; only its presentation metadata moves. The parent projects the + // replica's whole property, so this is still a visible change to the parent's resource details. + var updatedChild = CreateReplicaChild(parent, child.Name, "Exited"); + updatedChild.Properties.Add(new ResourceProperty + { + Name = KnownProperties.Resource.ExitCode, + Value = Value.ForNumber(137), + IsHighlighted = true + }); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Upsert = updatedChild + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + var updatedParent = Assert.Single(enumerator.Current, c => c.ChangeType == ResourceViewModelChangeType.Upsert && c.Resource.Name == parent.Name).Resource; + Assert.Equal(137d, updatedParent.Properties[KnownProperties.Resource.ExitCode].Value.NumberValue); + Assert.True(updatedParent.Properties[KnownProperties.Resource.ExitCode].IsHighlighted); + } + + [Fact] + public async Task SubscribeResources_NonProjectedReplicaUpdate_EmitsReplicaOnly() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + + var updatedReplica = CreateReplicaChild(parent, "syndule-api--0000002", "Running"); + updatedReplica.StartedAt = Timestamp.FromDateTime(new DateTime(2026, 8, 9, 12, 5, 0, DateTimeKind.Utc)); + updatedReplica.Properties.Add(new ResourceProperty + { + Name = "replica.metadata", + Value = Value.ForString("before") + }); + + var selectedReplica = CreateReplicaChild(parent, "syndule-api--0000001", "Running"); + selectedReplica.StartedAt = Timestamp.FromDateTime(new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc)); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, updatedReplica, selectedReplica } + } + }); + + var (initialData, subscription) = await subscribeTask.DefaultTimeout(); + var initialParent = Assert.Single(initialData, r => r.Name == parent.Name); + Assert.Equal(selectedReplica.StartedAt?.ToDateTime(), initialParent.StartTimeStamp); + + var updatedReplicaSnapshot = CreateReplicaChild(parent, updatedReplica.Name, "Running"); + updatedReplicaSnapshot.CreatedAt = updatedReplica.CreatedAt; + updatedReplicaSnapshot.StartedAt = updatedReplica.StartedAt; + updatedReplicaSnapshot.Properties.Add(new ResourceProperty + { + Name = "replica.metadata", + Value = Value.ForString("after") + }); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Upsert = updatedReplicaSnapshot + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Collection(enumerator.Current, + change => + { + Assert.Equal(ResourceViewModelChangeType.Upsert, change.ChangeType); + Assert.Equal(updatedReplica.Name, change.Resource.Name); + Assert.Equal("after", change.Resource.Properties["replica.metadata"].Value.StringValue); + Assert.Equal(updatedReplica.StartedAt?.ToDateTime(), change.Resource.StartTimeStamp); + }); + } + + [Fact] + public async Task SubscribeResources_ReplicaSelection_FallsBackThroughPriorityTiers() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var runningReplica = CreateReplicaChild(parent, "syndule-api--0000004", "Running"); + var transitoryReplica = CreateReplicaChild(parent, "syndule-api--0000003", "Starting"); + var failedReplica = CreateReplicaChild(parent, "syndule-api--0000002", "FailedToStart"); + var otherReplica = CreateReplicaChild(parent, "syndule-api--0000001", "CustomState"); + var noStateReplica = CreateReplicaChildWithoutState(parent, "syndule-api--0000000"); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, runningReplica, transitoryReplica, failedReplica, otherReplica, noStateReplica } + } + }); + + var (initialData, subscription) = await subscribeTask.DefaultTimeout(); + var initialParent = Assert.Single(initialData, r => r.Name == parent.Name); + Assert.Equal("Running", initialParent.State); + Assert.Equal(KnownResourceState.Running, initialParent.KnownState); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + await AssertParentStateAfterDeleteAsync(runningReplica, "Starting", KnownResourceState.Starting); + await AssertParentStateAfterDeleteAsync(transitoryReplica, "FailedToStart", KnownResourceState.FailedToStart); + await AssertParentStateAfterDeleteAsync(failedReplica, "CustomState", null); + await AssertParentStateAfterDeleteAsync(otherReplica, "Scaled to zero", null); + + async Task AssertParentStateAfterDeleteAsync(Resource replicaToDelete, string expectedState, KnownResourceState? expectedKnownState) + { + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Delete = new ResourceDeletion + { + ResourceName = replicaToDelete.Name, + ResourceType = replicaToDelete.ResourceType + } + } + } + } + }); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Collection(enumerator.Current.OrderBy(c => c.Resource.Name, StringComparer.Ordinal), + change => + { + Assert.Equal(ResourceViewModelChangeType.Upsert, change.ChangeType); + Assert.Equal(parent.Name, change.Resource.Name); + Assert.Equal(expectedState, change.Resource.State); + Assert.Equal(expectedKnownState, change.Resource.KnownState); + }, + change => + { + Assert.Equal(ResourceViewModelChangeType.Delete, change.ChangeType); + Assert.Equal(replicaToDelete.Name, change.Resource.Name); + }); + } + } + + [Theory] + [InlineData("Starting", "Waiting", "Waiting", KnownResourceState.Waiting)] + [InlineData("FailedToStart", "RuntimeUnhealthy", "RuntimeUnhealthy", KnownResourceState.RuntimeUnhealthy)] + [InlineData("Exited", "CustomState", "CustomState", null)] + public async Task GetResources_SameTierReplicaSelection_TieBreaksByResourceName( + string firstReplicaState, + string secondReplicaState, + string expectedState, + KnownResourceState? expectedKnownState) + { + await using var instance = CreateResourceServiceClient(); + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var firstReplica = CreateReplicaChild(parent, "syndule-api--0000002", firstReplicaState); + var secondReplica = CreateReplicaChild(parent, "syndule-api--0000001", secondReplicaState); + + instance.SetInitialDataReceived([parent, firstReplica, secondReplica]); + + var resources = instance.GetResources(); + + var updatedParent = Assert.Single(resources, r => r.Name == parent.Name); + Assert.Equal(expectedState, updatedParent.State); + Assert.Equal(expectedKnownState, updatedParent.KnownState); + } + + [Fact] + public async Task SubscribeResources_EmptyInitialData_EmitsDeletesForPreviousResources() + { + var resourceUpdates = Channel.CreateUnbounded(); + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = resourceUpdates }); + + IDashboardClient client = instance; + var parent = CreateResource("syndule-api", "Azure Container App", "Scaled to zero"); + var child = CreateReplicaChild(parent, "syndule-api--0000007", "Running"); + + var subscribeTask = client.SubscribeResourcesAsync(CancellationToken.None); + + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData + { + Resources = { parent, child } + } + }); + + var (initialData, subscription) = await subscribeTask.DefaultTimeout(); + Assert.Equal(2, initialData.Length); + + // Reconnecting to an AppHost that reports no resources replaces the whole model, so subscribers have + // to be told the rows they are showing are gone. A later upsert follows so the assertion below reads a + // batch either way and reports what was actually emitted instead of timing out. + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + InitialData = new InitialResourceData() + }); + + var laterResource = CreateResource("syndule-worker", "Project", "Running"); + resourceUpdates.Writer.TryWrite(new WatchResourcesUpdate + { + Changes = new WatchResourcesChanges + { + Value = + { + new WatchResourcesChange + { + Upsert = laterResource + } + } + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.GetAsyncEnumerator(cts.Token); + + // Outgoing changes are coalesced on a read interval, so the deletes and the trailing upsert can + // arrive in one batch or two. Read until the upsert lands, then assert on everything seen. + var received = new List(); + while (!received.Any(c => c.Resource.Name == laterResource.Name)) + { + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + received.AddRange(enumerator.Current); + } + + Assert.Collection(received.OrderBy(c => c.Resource.Name, StringComparer.Ordinal), + change => + { + Assert.Equal(ResourceViewModelChangeType.Delete, change.ChangeType); + Assert.Equal(parent.Name, change.Resource.Name); + }, + change => + { + Assert.Equal(ResourceViewModelChangeType.Delete, change.ChangeType); + Assert.Equal(child.Name, change.Resource.Name); + }, + change => + { + Assert.Equal(ResourceViewModelChangeType.Upsert, change.ChangeType); + Assert.Equal(laterResource.Name, change.Resource.Name); + }); + } + [Fact] public async Task SubscribeInteractions_OnCancel_ChannelRemoved() { @@ -522,6 +1357,7 @@ private sealed class MockDashboardServiceClient : Aspire.DashboardService.Proto. public bool FailOnExecuteResourceCommand { get; init; } public bool CancelExecuteResourceCommandOnCallCancellation { get; init; } public string MinDashboardVersion { get; init; } = ""; + public Channel? ResourceUpdates { get; init; } public override AsyncDuplexStreamingCall WatchInteractions(CallOptions options) { @@ -607,9 +1443,12 @@ private static async Task WaitForCallCancellationAsync( public override AsyncServerStreamingCall WatchResources(WatchResourcesRequest request, CallOptions options) { - var reader = FailOnWatchResources - ? (IAsyncStreamReader)new FailingAsyncStreamReader() - : new AsyncStreamReader(); + IAsyncStreamReader reader = FailOnWatchResources switch + { + true => new FailingAsyncStreamReader(), + false when ResourceUpdates is not null => new ChannelAsyncStreamReader(ResourceUpdates, options.CancellationToken), + false => new AsyncStreamReader() + }; return new AsyncServerStreamingCall( reader, @@ -620,6 +1459,24 @@ public override AsyncServerStreamingCall WatchResources(Wa } } + private sealed class ChannelAsyncStreamReader(Channel channel, CancellationToken cancellationToken) : IAsyncStreamReader + { + public T Current { get; private set; } = default!; + + public async Task MoveNext(CancellationToken cancellationTokenFromCall) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cancellationTokenFromCall); + + if (await channel.Reader.WaitToReadAsync(cts.Token) && channel.Reader.TryRead(out var item)) + { + Current = item; + return true; + } + + return false; + } + } + private sealed class FailingAsyncStreamReader : IAsyncStreamReader { public T Current { get; } = default!; @@ -660,6 +1517,77 @@ private DashboardClient CreateResourceServiceClient() return new DashboardClient(NullLoggerFactory.Instance, _configuration, _dashboardOptions, new MockKnownPropertyLookup(), new TestStringLocalizer()); } + private static Resource CreateResource(string name, string resourceType, string state) + { + return new Resource + { + Name = name, + ResourceType = resourceType, + DisplayName = name, + Uid = name, + CreatedAt = Timestamp.FromDateTime(DateTime.UtcNow), + State = state + }; + } + + private static Resource CreateReplicaChild(Resource parent, string name, string state, Aspire.DashboardService.Proto.V1.HealthStatus? healthStatus = null) + { + return CreateChild(parent, name, parent.DisplayName, state, healthStatus); + } + + private static Resource CreateReplicaChildWithoutState(Resource parent, string name) + { + return new Resource + { + Name = name, + ResourceType = parent.ResourceType, + DisplayName = parent.DisplayName, + Uid = name, + CreatedAt = Timestamp.FromDateTime(DateTime.UtcNow), + Properties = + { + new ResourceProperty + { + Name = KnownProperties.Resource.ParentName, + Value = Value.ForString(parent.Name) + } + } + }; + } + + private static Resource CreateChild(Resource parent, string name, string displayName, string state, Aspire.DashboardService.Proto.V1.HealthStatus? healthStatus = null) + { + var resource = new Resource + { + Name = name, + ResourceType = parent.ResourceType, + DisplayName = displayName, + Uid = name, + CreatedAt = Timestamp.FromDateTime(DateTime.UtcNow), + State = state, + Properties = + { + new ResourceProperty + { + Name = KnownProperties.Resource.ParentName, + Value = Value.ForString(parent.Name) + } + } + }; + + if (healthStatus is not null) + { + resource.HealthReports.Add(new HealthReport + { + Key = name, + Status = healthStatus.Value, + Description = name + }); + } + + return resource; + } + private static CommandViewModel CreateCommand() { return new CommandViewModel( diff --git a/tests/Aspire.Dashboard.Tests/Model/InputViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/InputViewModelTests.cs index 9b049c1161f..8be7d65e7f8 100644 --- a/tests/Aspire.Dashboard.Tests/Model/InputViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/InputViewModelTests.cs @@ -414,13 +414,12 @@ public void InputViewModel_File_SetFileReferencesSerializesToValue() var viewModel = new InputViewModel(input); viewModel.SetFileReferences([ - new FileReferenceViewModel { Id = "abc123", Name = "readme.txt" } + new FileReferenceViewModel { Id = "abc123", Name = "résumé.txt" } ]); Assert.Single(viewModel.FileReferences); - Assert.Equal("readme.txt", viewModel.FileReferences[0].Name); - Assert.Contains("abc123", viewModel.Value); - Assert.Contains("readme.txt", viewModel.Value); + Assert.Equal("résumé.txt", viewModel.FileReferences[0].Name); + Assert.Equal("[{\"Id\":\"abc123\",\"Name\":\"résumé.txt\"}]", viewModel.Value); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs index 94f797a135a..a7dc16d8d95 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceViewModelTests.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; +using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -157,6 +158,56 @@ public void ToViewModel_CopiesProperties() }); } + [Fact] + public void WithStateFrom_ClonesProjectedStateOwnedProperties() + { + var replicaStateProperty = new ResourcePropertyViewModel( + KnownProperties.Resource.State, + Value.ForString(KnownResourceState.Running.ToString()), + isValueSensitive: true, + knownProperty: null, + sortOrder: 0, + displayName: "State", + isHighlighted: true) + { + IsValueMasked = false + }; + + var parent = ModelTestHelpers.CreateResource( + resourceName: "api", + state: KnownResourceState.Starting, + properties: new Dictionary + { + [KnownProperties.Resource.State] = new( + KnownProperties.Resource.State, + Value.ForString(KnownResourceState.Starting.ToString()), + isValueSensitive: true, + knownProperty: null, + sortOrder: 0, + displayName: "State", + isHighlighted: true) + }); + + var replica = ModelTestHelpers.CreateResource( + resourceName: "api-a1b2c3", + displayName: "api", + state: KnownResourceState.Running, + properties: new Dictionary + { + [KnownProperties.Resource.State] = replicaStateProperty + }); + + var projectedParent = parent.WithStateFrom(replica); + var projectedStateProperty = projectedParent.Properties[KnownProperties.Resource.State]; + + Assert.NotSame(replicaStateProperty, projectedStateProperty); + Assert.False(projectedStateProperty.IsValueMasked); + + replicaStateProperty.IsValueMasked = true; + + Assert.False(projectedStateProperty.IsValueMasked); + } + [Fact] public void ToViewModel_ProducerSuppliedPropertyMetadata_DoesNotRequireKnownProperty() { diff --git a/tests/Aspire.Dashboard.Tests/Telemetry/DashboardTelemetryServiceTests.cs b/tests/Aspire.Dashboard.Tests/Telemetry/DashboardTelemetryServiceTests.cs index 2ee77e5f8a5..2a6213adee6 100644 --- a/tests/Aspire.Dashboard.Tests/Telemetry/DashboardTelemetryServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Telemetry/DashboardTelemetryServiceTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using System.Net.Http.Json; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Aspire.Dashboard.Configuration; diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs index 6b3178f7169..9db97fd1bbe 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs @@ -7,6 +7,7 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.AppContainers; +using System.Runtime.CompilerServices; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; @@ -572,4 +573,34 @@ public async Task WithAzureContainerRegistry_PublishSucceeds_WhenDefaultRegistry await Verify(mainBicep, "bicep") .AppendContentAsFile(envBicep, "bicep"); } -} \ No newline at end of file + + [Fact] + public async Task RunningTheDeploymentTargetHooksTwiceLeavesOneTargetPerEnvironment() + { + // The prepare-azure-container-apps step declares RequiredBySteps = [BeforeStart], so it runs + // once in the BeforeStart pipeline and again in the publish DAG. Without a guard the second + // pass adds a second DeploymentTargetAnnotation, and GetDeploymentTargetAnnotation then throws + // on the ambiguity, which fails every publish that goes through both paths. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var environment = builder.AddAzureContainerAppEnvironment("env"); + + builder.AddContainer("api", "nginx") + .WithHttpEndpoint(port: 8080, targetPort: 80, name: "http") + .WithComputeEnvironment(environment); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + var api = model.Resources.First(r => r.Name == "api"); + + Assert.Single(api.Annotations.OfType()); + Assert.Same(environment.Resource, api.GetDeploymentTargetAnnotation()!.ComputeEnvironment); + } + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] + private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); +} diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/AtsJavaCodeGeneratorTests.cs b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/AtsJavaCodeGeneratorTests.cs index 52fdec0f088..5e201ec496d 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/AtsJavaCodeGeneratorTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/AtsJavaCodeGeneratorTests.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Reflection; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.RemoteHost; using Aspire.TypeSystem; @@ -32,16 +34,44 @@ public async Task GenerateDistributedApplication_WithTestTypes_GeneratesCorrectO var files = _generator.GenerateDistributedApplication(atsContext); // Assert - Assert.Contains("Aspire.java", files.Keys); - Assert.Contains("AspireClient.java", files.Keys); - Assert.Contains("HandleWrapperBase.java", files.Keys); - Assert.Contains("TestRedisResource.java", files.Keys); + Assert.Contains("aspire/Aspire.java", files.Keys); + Assert.Contains("aspire/AspireClient.java", files.Keys); + Assert.Contains("aspire/HandleWrapperBase.java", files.Keys); + Assert.Contains("aspire/TestRedisResource.java", files.Keys); Assert.Contains("sources.txt", files.Keys); await Verify(JoinGeneratedFiles(files), extension: "java") .UseFileName("AtsGeneratedAspire"); } + [Fact] + public void GenerateDistributedApplication_DeclaresNumericParametersAsNumber() + { + var atsContext = CreateContextFromTestAssembly(); + + var files = _generator.GenerateDistributedApplication(atsContext); + var generated = JoinGeneratedFiles(files); + + // ATS collapses every numeric to one Number type, so a C# int parameter such as a port or an exit + // code reaches the generator as a floating-point type. Java refuses to convert an int literal to a + // Double - widening then boxing is not a conversion the language performs - so declaring these as + // Double makes `targetPort(8080)` and `waitForCompletion(job, 0)` fail to compile with + // "int cannot be converted to Double". java.lang.Number accepts int, long and double literals, + // boxed values and null alike. + // https://docs.oracle.com/javase/specs/jls/se21/html/jls-5.html#jls-5.3 + Assert.Contains("public TestRedisResource addTestRedis(String name, Number port)", generated, StringComparison.Ordinal); + Assert.Contains("private Map counts;", generated, StringComparison.Ordinal); + Assert.Contains("public Map getCounts() { return counts; }", generated, StringComparison.Ordinal); + + // Casting the deserialized map to Map was also simply wrong: a JSON integer + // deserializes to Integer, so the first read of such an entry threw ClassCastException. + Assert.Contains("value.setCounts((Map) countsValue);", generated, StringComparison.Ordinal); + + // Double.parseDouble in the hand-written JSON reader is the one legitimate use, so the check is + // scoped to declarations rather than the whole text. + Assert.Empty(Regex.Matches(generated, @"\bDouble\b(?!\.)")); + } + [Fact] public void GenerateDistributedApplication_WithTestTypes_IncludesExportedValues() { @@ -51,7 +81,7 @@ public void GenerateDistributedApplication_WithTestTypes_IncludesExportedValues( Assert.Contains(atsContext.ExportedValues, value => string.Join(".", value.PathSegments) == "TestConfigs.Profiles.Development"); var files = _generator.GenerateDistributedApplication(atsContext); - var testConfigsJava = files["TestConfigs.java"]; + var testConfigsJava = files["aspire/TestConfigs.java"]; Assert.Contains("public final class TestConfigs", testConfigsJava); Assert.Contains("static final TestConfigDto Default", testConfigsJava); @@ -188,7 +218,7 @@ public void GeneratedCode_HostingAssembly_FluentBuilderMethods_ReturnConcreteBui var atsContext = CreateContextFromBothAssemblies(); var files = _generator.GenerateDistributedApplication(atsContext); - var containerResourceJava = files["ContainerResource.java"]; + var containerResourceJava = files["aspire/ContainerResource.java"]; Assert.Contains("public ContainerResource withReference(IResource source, WithReferenceOptions options)", containerResourceJava); Assert.Contains("public ContainerResource waitFor(IResource dependency)", containerResourceJava); @@ -251,7 +281,7 @@ public void TwoPassScanning_GeneratesDerivedResourceInheritance() var atsContext = CreateContextFromBothAssemblies(); var files = _generator.GenerateDistributedApplication(atsContext); - var testRedisJava = files["TestRedisResource.java"]; + var testRedisJava = files["aspire/TestRedisResource.java"]; Assert.Contains("public class TestRedisResource extends ContainerResource", testRedisJava); } @@ -265,7 +295,7 @@ public async Task TwoPassScanning_GeneratesWithEnvironmentOnTestRedisBuilder() // Generate Java var files = _generator.GenerateDistributedApplication(atsContext); - var testRedisJava = files["TestRedisResource.java"]; + var testRedisJava = files["aspire/TestRedisResource.java"]; // Verify withEnvironment appears (method should exist for resources that support it) Assert.Contains("withEnvironment", testRedisJava); @@ -282,8 +312,8 @@ public void GeneratedCode_UsesCamelCaseMethodNames() var atsContext = CreateContextFromBothAssemblies(); var files = _generator.GenerateDistributedApplication(atsContext); - var builderJava = files["IDistributedApplicationBuilder.java"]; - var testRedisJava = files["TestRedisResource.java"]; + var builderJava = files["aspire/IDistributedApplicationBuilder.java"]; + var testRedisJava = files["aspire/TestRedisResource.java"]; // Java uses camelCase for methods Assert.Contains("addContainer", builderJava); @@ -297,7 +327,7 @@ public void GeneratedCode_HasCreateBuilderMethod() var atsContext = CreateContextFromBothAssemblies(); var files = _generator.GenerateDistributedApplication(atsContext); - var distributedApplicationJava = files["DistributedApplication.java"]; + var distributedApplicationJava = files["aspire/DistributedApplication.java"]; Assert.Contains("createBuilder", distributedApplicationJava); } @@ -309,7 +339,7 @@ public void GeneratedCode_HasPublicAspireClass() var atsContext = CreateContextFromBothAssemblies(); var files = _generator.GenerateDistributedApplication(atsContext); - var aspireJava = files["Aspire.java"]; + var aspireJava = files["aspire/Aspire.java"]; Assert.Contains("public class Aspire", aspireJava); } @@ -320,7 +350,7 @@ public void GeneratedTransport_HandlesJsonRpcArrayCallbackParameters() var atsContext = CreateContextFromBothAssemblies(); var files = _generator.GenerateDistributedApplication(atsContext); - var aspireClientJava = files["AspireClient.java"]; + var aspireClientJava = files["aspire/AspireClient.java"]; Assert.Contains("private String getCallbackId(Object params)", aspireClientJava); Assert.Contains("if (params instanceof List list && !list.isEmpty())", aspireClientJava); @@ -333,14 +363,139 @@ public void GeneratedDtoValues_AreSerializedAsMaps() var atsContext = CreateContextFromTestAssembly(); var files = _generator.GenerateDistributedApplication(atsContext); - var aspireClientJava = files["AspireClient.java"]; - var testConfigDtoJava = files["TestConfigDto.java"]; + var aspireClientJava = files["aspire/AspireClient.java"]; + var testConfigDtoJava = files["aspire/TestConfigDto.java"]; - Assert.Contains("interface JsonSerializable", files["JsonSerializable.java"]); + Assert.Contains("interface JsonSerializable", files["aspire/JsonSerializable.java"]); Assert.Contains("if (value instanceof JsonSerializable jsonSerializable)", aspireClientJava); Assert.Contains("public class TestConfigDto implements JsonSerializable", testConfigDtoJava); } + [Fact] + public void GeneratedCode_SuppressesWarningsOnEveryGeneratedType() + { + var atsContext = CreateContextFromBothAssemblies(); + + var files = _generator.GenerateDistributedApplication(atsContext); + + // Every token is load-bearing, because the two compilers that see this code disagree on what + // "all" covers. ECJ (which the Java language server, and so VS Code, compiles with) honours + // "all" and never reports it back as unnecessary. javac ignores "all" for its -Xlint + // categories, so "unchecked" and "serial" have to be named for `gradle build` and + // `mvn compile` to stay quiet. + var javaFiles = files.Where(kvp => kvp.Key.EndsWith(".java", StringComparison.Ordinal)).ToList(); + Assert.NotEmpty(javaFiles); + + var unsuppressed = javaFiles + .Where(kvp => !kvp.Value.Contains("@SuppressWarnings({\"all\", \"unchecked\", \"serial\"})", StringComparison.Ordinal)) + .Select(kvp => kvp.Key) + .ToList(); + + Assert.Empty(unsuppressed); + } + + [Fact] + public void GeneratedCode_DoesNotEmitWildcardImports() + { + var atsContext = CreateContextFromBothAssemblies(); + + var files = _generator.GenerateDistributedApplication(atsContext); + + // A wildcard import cannot be filtered per file, so it lands in all of the generated files and + // is unused in nearly every one. That is what produced 300 "The import java.util is never + // used" warnings in the user's Problems panel before imports became explicit. + var withWildcards = files + .Where(kvp => kvp.Key.EndsWith(".java", StringComparison.Ordinal)) + .Where(kvp => Regex.IsMatch(kvp.Value, @"^import\s+[\w.]+\.\*;", RegexOptions.Multiline)) + .Select(kvp => kvp.Key) + .ToList(); + + Assert.Empty(withWildcards); + } + + [Fact] + public void GeneratedCode_OnlyImportsTypesTheFileReferences() + { + var atsContext = CreateContextFromBothAssemblies(); + + var files = _generator.GenerateDistributedApplication(atsContext); + + // The generator emits one large compilation unit and then splits it per top-level declaration. + // Without per-file filtering the whole import block is copied into all ~226 files, so this + // pins the filtering rather than the split. + var offenders = new List(); + + foreach (var (path, content) in files.Where(kvp => kvp.Key.EndsWith(".java", StringComparison.Ordinal))) + { + foreach (Match import in Regex.Matches(content, @"^import\s+(?:static\s+)?([\w.]+)\.(\w+);", RegexOptions.Multiline)) + { + var simpleName = import.Groups[2].Value; + var body = content[(import.Index + import.Length)..]; + + if (!Regex.IsMatch(body, $@"\b{Regex.Escape(simpleName)}\b")) + { + offenders.Add($"{path}: {import.Groups[0].Value}"); + } + } + } + + Assert.Empty(offenders); + } + + [Fact] + public void GeneratedCode_RegistersCollectionWrappersWithoutRawTypes() + { + var atsContext = CreateContextFromBothAssemblies(); + + var files = _generator.GenerateDistributedApplication(atsContext); + var registrations = files["aspire/AspireRegistrations.java"]; + + // The factory target is BiFunction, so a raw AspireList/AspireDict + // here raises a rawtypes warning in the consumer's IDE. The diamond infers the erased-equivalent + // element type instead. + Assert.DoesNotContain("new AspireList(h, c)", registrations, StringComparison.Ordinal); + Assert.DoesNotContain("new AspireDict(h, c)", registrations, StringComparison.Ordinal); + } + + [Fact] + public void GeneratedCollectionWrappers_ExposeTheSameOperationsAsTheOtherLanguages() + { + var atsContext = CreateContextFromBothAssemblies(); + + var files = _generator.GenerateDistributedApplication(atsContext); + var aspireList = files["aspire/AspireList.java"]; + var aspireDict = files["aspire/AspireDict.java"]; + + // The Go and TypeScript SDKs agree on this set, and the host exports every one of them from + // Aspire.Hosting/Ats/CollectionExports.cs. Java shipped AspireList with no operations at all, + // which made any list-valued property unusable from a Java AppHost. + string[] listCapabilities = + [ + "Aspire.Hosting/List.length", + "Aspire.Hosting/List.get", + "Aspire.Hosting/List.add", + "Aspire.Hosting/List.removeAt", + "Aspire.Hosting/List.clear", + "Aspire.Hosting/List.toArray", + ]; + + string[] dictCapabilities = + [ + "Aspire.Hosting/Dict.count", + "Aspire.Hosting/Dict.get", + "Aspire.Hosting/Dict.set", + "Aspire.Hosting/Dict.remove", + "Aspire.Hosting/Dict.has", + "Aspire.Hosting/Dict.keys", + "Aspire.Hosting/Dict.values", + "Aspire.Hosting/Dict.clear", + "Aspire.Hosting/Dict.toObject", + ]; + + Assert.All(listCapabilities, capability => Assert.Contains(capability, aspireList, StringComparison.Ordinal)); + Assert.All(dictCapabilities, capability => Assert.Contains(capability, aspireDict, StringComparison.Ordinal)); + } + private static string JoinGeneratedFiles(Dictionary files) { return string.Join( @@ -405,4 +560,145 @@ private static (Assembly testAssembly, Assembly hostingAssembly) LoadBothAssembl var hostingAssembly = typeof(DistributedApplication).Assembly; return (testAssembly, hostingAssembly); } + + [Theory] + [InlineData("Default", "default_", "Default_")] + [InlineData("Package", "package_", "Package_")] + [InlineData("Class", "class_", "Class_")] + [InlineData("Native", "native_", "Native_")] + public void GenerateDistributedApplication_EscapesDtoPropertiesNamedAfterJavaKeywords( + string propertyName, + string expectedField, + string expectedAccessorSuffix) + { + // A DTO property whose name lowercases to a Java keyword produces source that javac rejects + // outright - `private String default;` is not parseable - so the generated SDK would fail to + // compile the first time any integration exposes such a property. Rust escapes every generated + // identifier through SanitizeIdentifier (r# prefix); Java only escaped type names, leaving + // fields, accessors and parameters unescaped. + // + // `Class` additionally collides with the final java.lang.Object.getClass(), which cannot be + // overridden, so the accessor name has to follow the escaped field rather than the raw property. + var context = CreateContextWithSingleDtoProperty(propertyName); + + var generated = JoinGeneratedFiles(_generator.GenerateDistributedApplication(context)); + + Assert.Contains($"private String {expectedField};", generated); + Assert.Contains($"public String get{expectedAccessorSuffix}() {{ return {expectedField}; }}", generated); + Assert.Contains($"public void set{expectedAccessorSuffix}(String value) {{ this.{expectedField} = value; }}", generated); + + // The transport key stays the original property name: escaping is a Java source concern and + // must not change the shape of the wire payload the .NET host reads and writes. + Assert.Contains($"map.get(\"{propertyName}\")", generated); + Assert.Contains($"map.put(\"{propertyName}\", AspireClient.serializeValue({expectedField}))", generated); + } + + [Fact] + public void DtoPropertyWithDictionaryNestedInArrayCastsToTheFieldType() + { + // The field type comes from MapDtoPropertyTypeToJava (Map/List) while the fromMap cast used to + // fall through to MapTypeRefToJava, which renders a mutable dictionary as AspireDict. AspireDict + // extends HandleWrapperBase and does not implement Map, so javac rejected the generated SDK + // outright — and the CLI compiles every generated source in one javac invocation, so a single + // bad property broke `aspire run` for the whole Java AppHost. + var context = CreateContextWithSingleDtoProperty( + "MetadataArray", + new AtsTypeRef { TypeId = "array", Category = AtsTypeCategory.Array, ElementType = MutableStringDict() }); + + var generated = JoinGeneratedFiles(_generator.GenerateDistributedApplication(context)); + + Assert.Contains("private Map[] metadataArray;", generated); + Assert.Contains("value.setMetadataArray((Map[]) metadataArrayValue);", generated); + } + + [Fact] + public void DtoPropertyWithDictionaryNestedInListCastsToTheFieldType() + { + var context = CreateContextWithSingleDtoProperty( + "Entries", + new AtsTypeRef + { + TypeId = "list", + Category = AtsTypeCategory.List, + IsReadOnly = false, + ElementType = MutableStringDict() + }); + + var generated = JoinGeneratedFiles(_generator.GenerateDistributedApplication(context)); + + Assert.Contains("private List> entries;", generated); + Assert.Contains("item0 -> (Map) item0", generated); + } + + [Fact] + public void ExportedDtoValueInitializerCallsTheEscapedSetter() + { + // The DTO's setter is generated from the keyword-escaped field (setDefault_), but the exported + // value initializer derived its call from the raw property name (setDefault). javac rejects the + // mismatch with `cannot find symbol`, and because the CLI compiles the whole generated SDK in a + // single javac invocation, one keyword-named property on any exported value breaks `aspire run` + // for the entire Java AppHost - and the user cannot fix generated code. + var context = CreateContextWithSingleDtoProperty("Default"); + var exported = new AtsExportedValueInfo + { + OwningAssemblyName = TestTypesAssemblyName, + PathSegments = ["Probes", "Sample"], + Value = new JsonObject { ["Default"] = "probe" }, + Type = new AtsTypeRef { TypeId = "KeywordProbe", Category = AtsTypeCategory.Dto } + }; + context = new AtsContext + { + Capabilities = context.Capabilities, + HandleTypes = context.HandleTypes, + EnumTypes = context.EnumTypes, + DtoTypes = context.DtoTypes, + ExportedValues = [exported] + }; + + var generated = JoinGeneratedFiles(_generator.GenerateDistributedApplication(context)); + + Assert.Contains("setDefault_(\"probe\")", generated); + } + + private static AtsContext CreateContextWithSingleDtoProperty(string propertyName) + { + return CreateContextWithSingleDtoProperty( + propertyName, + new AtsTypeRef { TypeId = "string", Category = AtsTypeCategory.Primitive }); + } + + private static AtsContext CreateContextWithSingleDtoProperty(string propertyName, AtsTypeRef propertyType) + { + return new AtsContext + { + Capabilities = [], + HandleTypes = [], + EnumTypes = [], + DtoTypes = + [ + new AtsDtoTypeInfo + { + Name = "KeywordProbe", + TypeId = "KeywordProbe", + Properties = + [ + new AtsDtoPropertyInfo + { + Name = propertyName, + Type = propertyType + } + ] + } + ] + }; + } + + private static AtsTypeRef MutableStringDict() => new() + { + TypeId = "dict", + Category = AtsTypeCategory.Dict, + IsReadOnly = false, + KeyType = new AtsTypeRef { TypeId = "string", Category = AtsTypeCategory.Primitive }, + ValueType = new AtsTypeRef { TypeId = "string", Category = AtsTypeCategory.Primitive } + }; } diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/JavaLanguageSupportDetectionTests.cs b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/JavaLanguageSupportDetectionTests.cs new file mode 100644 index 00000000000..3b2030ef4dc --- /dev/null +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/JavaLanguageSupportDetectionTests.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.CodeGeneration.Java.Tests; + +/// +/// Covers the layouts a Java AppHost is allowed to take on disk. +/// +/// +/// Java is the only language whose detection patterns include a path rather than a bare file name, +/// because a Maven or Gradle AppHost lives at the build tool's source root instead of the project root. +/// The pattern list is therefore load-bearing in two different ways, and these assert the one that is +/// path-aware: resolves each pattern against the directory. +/// The CLI's own matching sees only a file name, which the bare AppHost.java pattern covers, and +/// is asserted by DefaultLanguageDiscoveryTests.JavaAppHostIsBothLocatedAndResolvedInEveryLayout. +/// +public class JavaLanguageSupportDetectionTests +{ + [Theory] + [InlineData("AppHost.java")] + [InlineData("src/main/java/AppHost.java")] + public void DetectFindsTheAppHostInEverySupportedLayout(string relativePath) + { + using var workspace = new TempDirectory(); + var expected = relativePath.Replace('/', Path.DirectorySeparatorChar); + var appHostPath = Path.Combine(workspace.Path, expected); + Directory.CreateDirectory(Path.GetDirectoryName(appHostPath)!); + File.WriteAllText(appHostPath, "void main(String[] args) { }"); + + var result = new JavaLanguageSupport().Detect(workspace.Path); + + Assert.True(result.IsValid); + Assert.Equal("java", result.Language); + // Reported relative to the directory that was scanned, and separated for the current platform, + // because the caller combines it back onto that directory to open the file. + Assert.Equal(expected, result.AppHostFile); + } + + /// + /// The flat layout is listed first, so it wins when a project somehow has both. + /// + [Fact] + public void DetectPrefersTheFlatLayoutWhenBothArePresent() + { + using var workspace = new TempDirectory(); + File.WriteAllText(Path.Combine(workspace.Path, "AppHost.java"), "void main(String[] args) { }"); + var nested = Directory.CreateDirectory(Path.Combine(workspace.Path, "src", "main", "java")); + File.WriteAllText(Path.Combine(nested.FullName, "AppHost.java"), "void main(String[] args) { }"); + + Assert.Equal("AppHost.java", new JavaLanguageSupport().Detect(workspace.Path).AppHostFile); + } + + [Fact] + public void DetectReportsNotFoundForADirectoryWithNoAppHost() + { + using var workspace = new TempDirectory(); + File.WriteAllText(Path.Combine(workspace.Path, "pom.xml"), ""); + + var result = new JavaLanguageSupport().Detect(workspace.Path); + + Assert.False(result.IsValid); + Assert.Null(result.AppHostFile); + } + + /// + /// A Java source file that is not the AppHost must not be mistaken for one, including at the source + /// root where the conventional AppHost would otherwise sit. + /// + [Theory] + [InlineData("Application.java")] + [InlineData("src/main/java/Application.java")] + public void DetectIgnoresOtherJavaSources(string relativePath) + { + using var workspace = new TempDirectory(); + var path = Path.Combine(workspace.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, "class Application { }"); + + Assert.False(new JavaLanguageSupport().Detect(workspace.Path).IsValid); + } + + private sealed class TempDirectory : IDisposable + { + private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("aspire-java-detect"); + + public string Path => _directory.FullName; + + public void Dispose() + { + try + { + _directory.Delete(recursive: true); + } + catch (IOException) + { + // A leftover temp directory is not worth failing a test over. + } + } + } +} diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java index 23b785361f7..de6b53971c8 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java @@ -1,12 +1,13 @@ -// ===== Aspire.java ===== +// ===== aspire/Aspire.java ===== // Aspire.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Main entry point for Aspire SDK. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class Aspire { /** Connect to the AppHost server. */ public static AspireClient connect() throws Exception { @@ -35,8 +36,10 @@ public static IDistributedApplicationBuilder createBuilder(CreateBuilderOptions resolvedOptions.putAll(options.toMap()); } if (resolvedOptions.get("Args") == null) { - // Note: Java doesn't have easy access to command line args from here - resolvedOptions.put("Args", new String[0]); + String forwardedArgs = System.getenv("ASPIRE_APPHOST_ARGS"); + resolvedOptions.put("Args", forwardedArgs == null || forwardedArgs.isEmpty() + ? new String[0] + : forwardedArgs.split("\n", -1)); } if (resolvedOptions.get("ProjectDirectory") == null) { String projectDirectory = System.getenv("ASPIRE_PROJECT_DIRECTORY"); @@ -57,93 +60,106 @@ public static IDistributedApplicationBuilder createBuilder(CreateBuilderOptions } } -// ===== AspireAction0.java ===== +// ===== aspire/AspireAction0.java ===== // AspireAction0.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireAction0 { void invoke(); } -// ===== AspireAction1.java ===== +// ===== aspire/AspireAction1.java ===== // AspireAction1.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireAction1 { void invoke(T1 arg1); } -// ===== AspireAction2.java ===== +// ===== aspire/AspireAction2.java ===== // AspireAction2.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireAction2 { void invoke(T1 arg1, T2 arg2); } -// ===== AspireAction3.java ===== +// ===== aspire/AspireAction3.java ===== // AspireAction3.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireAction3 { void invoke(T1 arg1, T2 arg2, T3 arg3); } -// ===== AspireAction4.java ===== +// ===== aspire/AspireAction4.java ===== // AspireAction4.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireAction4 { void invoke(T1 arg1, T2 arg2, T3 arg3, T4 arg4); } -// ===== AspireClient.java ===== +// ===== aspire/AspireClient.java ===== // AspireClient.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.io.*; -import java.net.*; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; /** * AspireClient handles JSON-RPC communication with the AppHost server. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AspireClient { private static final boolean DEBUG = System.getenv("ASPIRE_DEBUG") != null; private final String socketPath; private OutputStream outputStream; private InputStream inputStream; + // Windows only. inputStream/outputStream are built from this file's descriptor, so the + // RandomAccessFile has to outlive connect() -- closing it would close the descriptor they share + // and break the connection. Holding it in a field also makes that lifetime explicit to a + // compiler's resource-leak analysis, which cannot see the escape through FileDescriptor. + private RandomAccessFile namedPipe; private final AtomicInteger requestId = new AtomicInteger(0); private final Map> callbacks = new ConcurrentHashMap<>(); private final Map> cancellations = new ConcurrentHashMap<>(); private Runnable disconnectHandler; - private volatile boolean connected = false; // Handle wrapper factory registry private static final Map> handleWrappers = new ConcurrentHashMap<>(); @@ -165,7 +181,6 @@ public void connect() throws IOException { connectUnixSocket(); } - connected = true; debug("Connected successfully"); } @@ -180,10 +195,10 @@ private void connectWindowsNamedPipe() throws IOException { debug("Opening Windows named pipe: " + pipePath); // Use RandomAccessFile to open the named pipe - RandomAccessFile pipe = new RandomAccessFile(pipePath, "rw"); - + namedPipe = new RandomAccessFile(pipePath, "rw"); + // Create streams from the RandomAccessFile - FileDescriptor fd = pipe.getFD(); + FileDescriptor fd = namedPipe.getFD(); inputStream = new FileInputStream(fd); outputStream = new FileOutputStream(fd); @@ -345,7 +360,6 @@ private Object readResponse(int expectedId) throws IOException { @SuppressWarnings("unchecked") private Map readMessage() throws IOException { // Read headers - StringBuilder headerBuilder = new StringBuilder(); int contentLength = -1; while (true) { @@ -575,7 +589,6 @@ private Object unwrapResult(Object value) { } private void handleDisconnect() { - connected = false; if (disconnectHandler != null) { disconnectHandler.run(); } @@ -899,16 +912,19 @@ private void debug(String message) { } } -// ===== AspireDict.java ===== +// ===== aspire/AspireDict.java ===== // AspireDict.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * AspireDict is a handle-backed dictionary with lazy handle resolution. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AspireDict extends HandleWrapperBase { private final String getterCapabilityId; private Handle resolvedHandle; @@ -980,83 +996,116 @@ public boolean containsKey(K key) { return Boolean.TRUE.equals(result); } + /** + * Gets the keys in the dictionary. + * + * @return a snapshot of the keys + */ @SuppressWarnings("unchecked") public List keys() { Object result = getClient().invokeCapability("Aspire.Hosting/Dict.keys", Map.of("dict", ensureHandle().toJson())); return (List) result; } + + /** + * Gets the values in the dictionary. + * + * @return a snapshot of the values + */ + @SuppressWarnings("unchecked") + public List values() { + Object result = getClient().invokeCapability("Aspire.Hosting/Dict.values", Map.of("dict", ensureHandle().toJson())); + return (List) result; + } + + /** + * Removes all entries from the dictionary. + */ + public void clear() { + getClient().invokeCapability("Aspire.Hosting/Dict.clear", Map.of("dict", ensureHandle().toJson())); + } + + /** + * Copies the dictionary into a plain {@link Map}. Later mutations of the + * underlying app model are not reflected in the returned copy. + * + * @return a snapshot of the dictionary contents + */ + @SuppressWarnings("unchecked") + public Map toMap() { + Object result = getClient().invokeCapability("Aspire.Hosting/Dict.toObject", Map.of("dict", ensureHandle().toJson())); + return (Map) result; + } } -// ===== AspireFunc0.java ===== +// ===== aspire/AspireFunc0.java ===== // AspireFunc0.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireFunc0 { R invoke(); } -// ===== AspireFunc1.java ===== +// ===== aspire/AspireFunc1.java ===== // AspireFunc1.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireFunc1 { R invoke(T1 arg1); } -// ===== AspireFunc2.java ===== +// ===== aspire/AspireFunc2.java ===== // AspireFunc2.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireFunc2 { R invoke(T1 arg1, T2 arg2); } -// ===== AspireFunc3.java ===== +// ===== aspire/AspireFunc3.java ===== // AspireFunc3.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireFunc3 { R invoke(T1 arg1, T2 arg2, T3 arg3); } -// ===== AspireFunc4.java ===== +// ===== aspire/AspireFunc4.java ===== // AspireFunc4.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireFunc4 { R invoke(T1 arg1, T2 arg2, T3 arg3, T4 arg4); } -// ===== AspireList.java ===== +// ===== aspire/AspireList.java ===== // AspireList.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * AspireList is a handle-backed list with lazy handle resolution. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AspireList extends HandleWrapperBase { private final String getterCapabilityId; private Handle resolvedHandle; @@ -1090,17 +1139,86 @@ private Handle ensureHandle() { } return resolvedHandle; } + + /** + * Gets the number of elements in the list. + * + * @return the element count + */ + public int size() { + Object result = getClient().invokeCapability("Aspire.Hosting/List.length", Map.of("list", ensureHandle().toJson())); + return ((Number) result).intValue(); + } + + /** + * Gets the element at the specified index. + * + * @param index the zero-based index + * @return the element at {@code index} + */ + @SuppressWarnings("unchecked") + public T get(int index) { + Map args = new HashMap<>(); + args.put("list", ensureHandle().toJson()); + args.put("index", index); + return (T) getClient().invokeCapability("Aspire.Hosting/List.get", args); + } + + /** + * Appends an element to the end of the list. + * + * @param item the element to append + */ + public void add(T item) { + Map args = new HashMap<>(); + args.put("list", ensureHandle().toJson()); + args.put("item", AspireClient.serializeValue(item)); + getClient().invokeCapability("Aspire.Hosting/List.add", args); + } + + /** + * Removes the element at the specified index. + * + * @param index the zero-based index + * @return {@code true} if an element was removed + */ + public boolean remove(int index) { + Map args = new HashMap<>(); + args.put("list", ensureHandle().toJson()); + args.put("index", index); + Object result = getClient().invokeCapability("Aspire.Hosting/List.removeAt", args); + return Boolean.TRUE.equals(result); + } + + /** + * Removes all elements from the list. + */ + public void clear() { + getClient().invokeCapability("Aspire.Hosting/List.clear", Map.of("list", ensureHandle().toJson())); + } + + /** + * Copies the list into a plain {@link List}. Later mutations of the underlying + * app model are not reflected in the returned copy. + * + * @return a snapshot of the list contents + */ + @SuppressWarnings("unchecked") + public List toList() { + Object result = getClient().invokeCapability("Aspire.Hosting/List.toArray", Map.of("list", ensureHandle().toJson())); + return (List) result; + } } -// ===== AspireRegistrations.java ===== +// ===== aspire/AspireRegistrations.java ===== // AspireRegistrations.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.List; /** Static initializer to register handle wrappers. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AspireRegistrations { static { AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCallbackContext", (h, c) -> new TestCallbackContext(h, c)); @@ -1116,9 +1234,9 @@ public class AspireRegistrations { AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource", (h, c) -> new ITestVaultResource(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", (h, c) -> new IDistributedApplicationBuilder(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", (h, c) -> new IResourceWithEnvironment(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/List", (h, c) -> new AspireList(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/List", (h, c) -> new AspireList<>(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict<>(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict<>(h, c)); } static void ensureRegistered() { @@ -1126,16 +1244,15 @@ static void ensureRegistered() { } } -// ===== AspireUnion.java ===== +// ===== aspire/AspireUnion.java ===== // AspireUnion.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - /** * Represents a runtime union value for generated Java APIs. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class AspireUnion { private final Object value; @@ -1175,16 +1292,15 @@ public String toString() { } } -// ===== BaseRegistrations.java ===== +// ===== aspire/BaseRegistrations.java ===== // BaseRegistrations.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - /** * Registers runtime-owned wrappers defined in Base.java. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class BaseRegistrations { private BaseRegistrations() { } @@ -1197,22 +1313,18 @@ static void ensureRegistered() { } } -// ===== CancellationToken.java ===== +// ===== aspire/CancellationToken.java ===== // CancellationToken.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.io.*; -import java.net.*; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; /** * CancellationToken for cancelling operations. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CancellationToken { private volatile boolean cancelled = false; private final List listeners = new CopyOnWriteArrayList<>(); @@ -1265,22 +1377,15 @@ void onCancel(Runnable listener) { } } -// ===== CapabilityError.java ===== +// ===== aspire/CapabilityError.java ===== // CapabilityError.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.io.*; -import java.net.*; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; - /** * CapabilityError represents an error from a capability invocation. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CapabilityError extends RuntimeException { private final String code; private final Object data; @@ -1295,22 +1400,18 @@ public class CapabilityError extends RuntimeException { Object getData() { return data; } } -// ===== Handle.java ===== +// ===== aspire/Handle.java ===== // Handle.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.io.*; -import java.net.*; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** * Handle represents a remote object reference. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class Handle { private final String id; private final String typeId; @@ -1336,16 +1437,15 @@ public String toString() { } } -// ===== HandleWrapperBase.java ===== +// ===== aspire/HandleWrapperBase.java ===== // HandleWrapperBase.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - /** * HandleWrapperBase is the base class for all handle wrappers. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HandleWrapperBase { private final Handle handle; private final AspireClient client; @@ -1364,15 +1464,16 @@ AspireClient getClient() { } } -// ===== IDistributedApplicationBuilder.java ===== +// ===== aspire/IDistributedApplicationBuilder.java ===== // IDistributedApplicationBuilder.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IDistributedApplicationBuilder extends HandleWrapperBase { IDistributedApplicationBuilder(Handle handle, AspireClient client) { super(handle, client); @@ -1383,7 +1484,7 @@ public TestRedisResource addTestRedis(String name) { } /** Adds a test Redis resource from ATS documentation. */ - public TestRedisResource addTestRedis(String name, Double port) { + public TestRedisResource addTestRedis(String name, Number port) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("name", AspireClient.serializeValue(name)); @@ -1405,15 +1506,13 @@ public TestVaultResource addTestVault(String name) { } -// ===== IResource.java ===== +// ===== aspire/IResource.java ===== // IResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IResource extends ResourceBuilderBase { IResource(Handle handle, AspireClient client) { super(handle, client); @@ -1421,15 +1520,13 @@ public class IResource extends ResourceBuilderBase { } -// ===== IResourceWithConnectionString.java ===== +// ===== aspire/IResourceWithConnectionString.java ===== // IResourceWithConnectionString.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IResourceWithConnectionString extends ResourceBuilderBase { IResourceWithConnectionString(Handle handle, AspireClient client) { super(handle, client); @@ -1437,15 +1534,13 @@ public class IResourceWithConnectionString extends ResourceBuilderBase { } -// ===== IResourceWithEnvironment.java ===== +// ===== aspire/IResourceWithEnvironment.java ===== // IResourceWithEnvironment.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IResourceWithEnvironment extends HandleWrapperBase { IResourceWithEnvironment(Handle handle, AspireClient client) { super(handle, client); @@ -1453,15 +1548,13 @@ public class IResourceWithEnvironment extends HandleWrapperBase { } -// ===== ITestVaultResource.java ===== +// ===== aspire/ITestVaultResource.java ===== // ITestVaultResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ITestVaultResource extends ResourceBuilderBase { ITestVaultResource(Handle handle, AspireClient client) { super(handle, client); @@ -1469,34 +1562,33 @@ public class ITestVaultResource extends ResourceBuilderBase { } -// ===== JsonSerializable.java ===== +// ===== aspire/JsonSerializable.java ===== // JsonSerializable.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.io.*; -import java.net.*; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; +import java.util.Map; +@SuppressWarnings({"all", "unchecked", "serial"}) public interface JsonSerializable { Map toMap(); } -// ===== ReferenceExpression.java ===== +// ===== aspire/ReferenceExpression.java ===== // ReferenceExpression.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * ReferenceExpression represents a reference expression. * Supports value mode (format + value providers), conditional mode, and handle mode. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ReferenceExpression { private final String format; private final Object[] valueProviders; @@ -1614,31 +1706,31 @@ private static Object extractValueProvider(Object value) { } } -// ===== ResourceBuilderBase.java ===== +// ===== aspire/ResourceBuilderBase.java ===== // ResourceBuilderBase.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - /** * ResourceBuilderBase extends HandleWrapperBase for resource builders. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceBuilderBase extends HandleWrapperBase { ResourceBuilderBase(Handle handle, AspireClient client) { super(handle, client); } } -// ===== TestCallbackContext.java ===== +// ===== aspire/TestCallbackContext.java ===== // TestCallbackContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCallbackContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestCallbackContext extends HandleWrapperBase { TestCallbackContext(Handle handle, AspireClient client) { super(handle, client); @@ -1699,15 +1791,15 @@ public TestCallbackContext setCancellationToken(CancellationToken value) { } -// ===== TestCollectionContext.java ===== +// ===== aspire/TestCollectionContext.java ===== // TestCollectionContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.List; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCollectionContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestCollectionContext extends HandleWrapperBase { TestCollectionContext(Handle handle, AspireClient client) { super(handle, client); @@ -1733,15 +1825,16 @@ public AspireDict metadata() { } -// ===== TestConfigDto.java ===== +// ===== aspire/TestConfigDto.java ===== // TestConfigDto.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** TestConfigDto DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestConfigDto implements JsonSerializable { private String name; private double port; @@ -1781,14 +1874,12 @@ public Map toMap() { } } -// ===== TestConfigs.java ===== +// ===== aspire/TestConfigs.java ===== // TestConfigs.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) public final class TestConfigs { private TestConfigs() { } @@ -1807,15 +1898,16 @@ private Profiles() { } } -// ===== TestDatabaseResource.java ===== +// ===== aspire/TestDatabaseResource.java ===== // TestDatabaseResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestDatabaseResource extends ResourceBuilderBase { TestDatabaseResource(Handle handle, AspireClient client) { super(handle, client); @@ -2105,7 +2197,7 @@ public TestDatabaseResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private TestDatabaseResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private TestDatabaseResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -2131,7 +2223,7 @@ public TestDatabaseResource withMergeLoggingPath(String logLevel, String logPath } /** Configures resource logging with file path */ - private TestDatabaseResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private TestDatabaseResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -2173,15 +2265,17 @@ public TestDatabaseResource withMergeRouteMiddleware(String path, String method, } -// ===== TestDeeplyNestedDto.java ===== +// ===== aspire/TestDeeplyNestedDto.java ===== // TestDeeplyNestedDto.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** TestDeeplyNestedDto DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestDeeplyNestedDto implements JsonSerializable { private Map> nestedData; private Map[] metadataArray; @@ -2197,7 +2291,7 @@ public static TestDeeplyNestedDto fromMap(Map map) { var nestedDataValue = map.get("NestedData"); value.setNestedData((Map>) nestedDataValue); var metadataArrayValue = map.get("MetadataArray"); - value.setMetadataArray((AspireDict[]) metadataArrayValue); + value.setMetadataArray((Map[]) metadataArrayValue); return value; } @@ -2209,15 +2303,16 @@ public Map toMap() { } } -// ===== TestEnvironmentContext.java ===== +// ===== aspire/TestEnvironmentContext.java ===== // TestEnvironmentContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestEnvironmentContext extends HandleWrapperBase { TestEnvironmentContext(Handle handle, AspireClient client) { super(handle, client); @@ -2276,15 +2371,16 @@ public TestEnvironmentContext setPriority(double value) { } -// ===== TestMutableCollectionContext.java ===== +// ===== aspire/TestMutableCollectionContext.java ===== // TestMutableCollectionContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestMutableCollectionContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestMutableCollectionContext extends HandleWrapperBase { TestMutableCollectionContext(Handle handle, AspireClient client) { super(handle, client); @@ -2309,8 +2405,8 @@ public TestMutableCollectionContext setTags(AspireList value) { } /** Gets the Counts property */ - private AspireDict countsField; - public AspireDict counts() { + private AspireDict countsField; + public AspireDict counts() { if (countsField == null) { countsField = new AspireDict<>(getHandle(), getClient(), "Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestMutableCollectionContext.counts"); } @@ -2318,7 +2414,7 @@ public AspireDict counts() { } /** Sets the Counts property */ - public TestMutableCollectionContext setCounts(AspireDict value) { + public TestMutableCollectionContext setCounts(AspireDict value) { Map reqArgs = new HashMap<>(); reqArgs.put("context", AspireClient.serializeValue(getHandle())); reqArgs.put("value", AspireClient.serializeValue(value)); @@ -2328,20 +2424,22 @@ public TestMutableCollectionContext setCounts(AspireDict value) } -// ===== TestNestedDto.java ===== +// ===== aspire/TestNestedDto.java ===== // TestNestedDto.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** TestNestedDto DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestNestedDto implements JsonSerializable { private String id; private TestConfigDto config; private List tags; - private Map counts; + private Map counts; public String getId() { return id; } public void setId(String value) { this.id = value; } @@ -2349,8 +2447,8 @@ public class TestNestedDto implements JsonSerializable { public void setConfig(TestConfigDto value) { this.config = value; } public List getTags() { return tags; } public void setTags(List value) { this.tags = value; } - public Map getCounts() { return counts; } - public void setCounts(Map value) { this.counts = value; } + public Map getCounts() { return counts; } + public void setCounts(Map value) { this.counts = value; } @SuppressWarnings("unchecked") public static TestNestedDto fromMap(Map map) { @@ -2362,7 +2460,7 @@ public static TestNestedDto fromMap(Map map) { var tagsValue = map.get("Tags"); value.setTags(((List) tagsValue).stream().map(item0 -> (String) item0).toList()); var countsValue = map.get("Counts"); - value.setCounts((Map) countsValue); + value.setCounts((Map) countsValue); return value; } @@ -2376,15 +2474,13 @@ public Map toMap() { } } -// ===== TestPersistenceMode.java ===== +// ===== aspire/TestPersistenceMode.java ===== // TestPersistenceMode.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** TestPersistenceMode enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum TestPersistenceMode implements WireValueEnum { NONE("None"), VOLUME("Volume"), @@ -2406,15 +2502,16 @@ public static TestPersistenceMode fromValue(String value) { } } -// ===== TestRedisResource.java ===== +// ===== aspire/TestRedisResource.java ===== // TestRedisResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestRedisResource extends ResourceBuilderBase { TestRedisResource(Handle handle, AspireClient client) { super(handle, client); @@ -2840,7 +2937,7 @@ public TestRedisResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private TestRedisResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private TestRedisResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -2866,7 +2963,7 @@ public TestRedisResource withMergeLoggingPath(String logLevel, String logPath) { } /** Configures resource logging with file path */ - private TestRedisResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private TestRedisResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -2908,15 +3005,16 @@ public TestRedisResource withMergeRouteMiddleware(String path, String method, St } -// ===== TestResourceContext.java ===== +// ===== aspire/TestResourceContext.java ===== // TestResourceContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestResourceContext extends HandleWrapperBase { TestResourceContext(Handle handle, AspireClient client) { super(handle, client); @@ -2982,15 +3080,13 @@ public boolean validateAsync() { } -// ===== TestResourceStatus.java ===== +// ===== aspire/TestResourceStatus.java ===== // TestResourceStatus.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** TestResourceStatus enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum TestResourceStatus implements WireValueEnum { PENDING("Pending"), RUNNING("Running"), @@ -3013,15 +3109,16 @@ public static TestResourceStatus fromValue(String value) { } } -// ===== TestVaultResource.java ===== +// ===== aspire/TestVaultResource.java ===== // TestVaultResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestVaultResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestVaultResource extends ResourceBuilderBase { TestVaultResource(Handle handle, AspireClient client) { super(handle, client); @@ -3299,7 +3396,7 @@ public TestVaultResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private TestVaultResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private TestVaultResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -3325,7 +3422,7 @@ public TestVaultResource withMergeLoggingPath(String logLevel, String logPath) { } /** Configures resource logging with file path */ - private TestVaultResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private TestVaultResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -3367,29 +3464,26 @@ public TestVaultResource withMergeRouteMiddleware(String path, String method, St } -// ===== WireValueEnum.java ===== +// ===== aspire/WireValueEnum.java ===== // WireValueEnum.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - /** * Marker interface for generated enums that need a transport value distinct from Enum.name(). */ +@SuppressWarnings({"all", "unchecked", "serial"}) public interface WireValueEnum { String getValue(); } -// ===== WithDataVolumeOptions.java ===== +// ===== aspire/WithDataVolumeOptions.java ===== // WithDataVolumeOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithDataVolume. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithDataVolumeOptions { private String name; private Boolean isReadOnly; @@ -3408,18 +3502,16 @@ public WithDataVolumeOptions isReadOnly(Boolean value) { } -// ===== WithMergeLoggingOptions.java ===== +// ===== aspire/WithMergeLoggingOptions.java ===== // WithMergeLoggingOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithMergeLogging. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithMergeLoggingOptions { private Boolean enableConsole; - private Double maxFiles; + private Number maxFiles; public Boolean getEnableConsole() { return enableConsole; } public WithMergeLoggingOptions enableConsole(Boolean value) { @@ -3427,26 +3519,24 @@ public WithMergeLoggingOptions enableConsole(Boolean value) { return this; } - public Double getMaxFiles() { return maxFiles; } - public WithMergeLoggingOptions maxFiles(Double value) { + public Number getMaxFiles() { return maxFiles; } + public WithMergeLoggingOptions maxFiles(Number value) { this.maxFiles = value; return this; } } -// ===== WithMergeLoggingPathOptions.java ===== +// ===== aspire/WithMergeLoggingPathOptions.java ===== // WithMergeLoggingPathOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithMergeLoggingPath. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithMergeLoggingPathOptions { private Boolean enableConsole; - private Double maxFiles; + private Number maxFiles; public Boolean getEnableConsole() { return enableConsole; } public WithMergeLoggingPathOptions enableConsole(Boolean value) { @@ -3454,23 +3544,21 @@ public WithMergeLoggingPathOptions enableConsole(Boolean value) { return this; } - public Double getMaxFiles() { return maxFiles; } - public WithMergeLoggingPathOptions maxFiles(Double value) { + public Number getMaxFiles() { return maxFiles; } + public WithMergeLoggingPathOptions maxFiles(Number value) { this.maxFiles = value; return this; } } -// ===== WithOptionalStringOptions.java ===== +// ===== aspire/WithOptionalStringOptions.java ===== // WithOptionalStringOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithOptionalString. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithOptionalStringOptions { private String value; private Boolean enabled; @@ -3490,51 +3578,51 @@ public WithOptionalStringOptions enabled(Boolean value) { } // ===== sources.txt ===== -.aspire/modules/Aspire.java -.aspire/modules/AspireAction0.java -.aspire/modules/AspireAction1.java -.aspire/modules/AspireAction2.java -.aspire/modules/AspireAction3.java -.aspire/modules/AspireAction4.java -.aspire/modules/AspireClient.java -.aspire/modules/AspireDict.java -.aspire/modules/AspireFunc0.java -.aspire/modules/AspireFunc1.java -.aspire/modules/AspireFunc2.java -.aspire/modules/AspireFunc3.java -.aspire/modules/AspireFunc4.java -.aspire/modules/AspireList.java -.aspire/modules/AspireRegistrations.java -.aspire/modules/AspireUnion.java -.aspire/modules/BaseRegistrations.java -.aspire/modules/CancellationToken.java -.aspire/modules/CapabilityError.java -.aspire/modules/Handle.java -.aspire/modules/HandleWrapperBase.java -.aspire/modules/IDistributedApplicationBuilder.java -.aspire/modules/IResource.java -.aspire/modules/IResourceWithConnectionString.java -.aspire/modules/IResourceWithEnvironment.java -.aspire/modules/ITestVaultResource.java -.aspire/modules/JsonSerializable.java -.aspire/modules/ReferenceExpression.java -.aspire/modules/ResourceBuilderBase.java -.aspire/modules/TestCallbackContext.java -.aspire/modules/TestCollectionContext.java -.aspire/modules/TestConfigDto.java -.aspire/modules/TestConfigs.java -.aspire/modules/TestDatabaseResource.java -.aspire/modules/TestDeeplyNestedDto.java -.aspire/modules/TestEnvironmentContext.java -.aspire/modules/TestMutableCollectionContext.java -.aspire/modules/TestNestedDto.java -.aspire/modules/TestPersistenceMode.java -.aspire/modules/TestRedisResource.java -.aspire/modules/TestResourceContext.java -.aspire/modules/TestResourceStatus.java -.aspire/modules/TestVaultResource.java -.aspire/modules/WireValueEnum.java -.aspire/modules/WithDataVolumeOptions.java -.aspire/modules/WithMergeLoggingOptions.java -.aspire/modules/WithMergeLoggingPathOptions.java -.aspire/modules/WithOptionalStringOptions.java +.aspire/modules/aspire/Aspire.java +.aspire/modules/aspire/AspireAction0.java +.aspire/modules/aspire/AspireAction1.java +.aspire/modules/aspire/AspireAction2.java +.aspire/modules/aspire/AspireAction3.java +.aspire/modules/aspire/AspireAction4.java +.aspire/modules/aspire/AspireClient.java +.aspire/modules/aspire/AspireDict.java +.aspire/modules/aspire/AspireFunc0.java +.aspire/modules/aspire/AspireFunc1.java +.aspire/modules/aspire/AspireFunc2.java +.aspire/modules/aspire/AspireFunc3.java +.aspire/modules/aspire/AspireFunc4.java +.aspire/modules/aspire/AspireList.java +.aspire/modules/aspire/AspireRegistrations.java +.aspire/modules/aspire/AspireUnion.java +.aspire/modules/aspire/BaseRegistrations.java +.aspire/modules/aspire/CancellationToken.java +.aspire/modules/aspire/CapabilityError.java +.aspire/modules/aspire/Handle.java +.aspire/modules/aspire/HandleWrapperBase.java +.aspire/modules/aspire/IDistributedApplicationBuilder.java +.aspire/modules/aspire/IResource.java +.aspire/modules/aspire/IResourceWithConnectionString.java +.aspire/modules/aspire/IResourceWithEnvironment.java +.aspire/modules/aspire/ITestVaultResource.java +.aspire/modules/aspire/JsonSerializable.java +.aspire/modules/aspire/ReferenceExpression.java +.aspire/modules/aspire/ResourceBuilderBase.java +.aspire/modules/aspire/TestCallbackContext.java +.aspire/modules/aspire/TestCollectionContext.java +.aspire/modules/aspire/TestConfigDto.java +.aspire/modules/aspire/TestConfigs.java +.aspire/modules/aspire/TestDatabaseResource.java +.aspire/modules/aspire/TestDeeplyNestedDto.java +.aspire/modules/aspire/TestEnvironmentContext.java +.aspire/modules/aspire/TestMutableCollectionContext.java +.aspire/modules/aspire/TestNestedDto.java +.aspire/modules/aspire/TestPersistenceMode.java +.aspire/modules/aspire/TestRedisResource.java +.aspire/modules/aspire/TestResourceContext.java +.aspire/modules/aspire/TestResourceStatus.java +.aspire/modules/aspire/TestVaultResource.java +.aspire/modules/aspire/WireValueEnum.java +.aspire/modules/aspire/WithDataVolumeOptions.java +.aspire/modules/aspire/WithMergeLoggingOptions.java +.aspire/modules/aspire/WithMergeLoggingPathOptions.java +.aspire/modules/aspire/WithOptionalStringOptions.java diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 6738d5a5c1a..828cd02be6a 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -1,12 +1,13 @@ -// ===== AddContainerOptions.java ===== +// ===== aspire/AddContainerOptions.java ===== // AddContainerOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** AddContainerOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AddContainerOptions implements JsonSerializable { private String image; private String tag; @@ -34,15 +35,13 @@ public Map toMap() { } } -// ===== AddDockerfileOptions.java ===== +// ===== aspire/AddDockerfileOptions.java ===== // AddDockerfileOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for AddDockerfile. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class AddDockerfileOptions { private String dockerfilePath; private String stage; @@ -61,15 +60,13 @@ public AddDockerfileOptions stage(String value) { } -// ===== AddParameterOptions.java ===== +// ===== aspire/AddParameterOptions.java ===== // AddParameterOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for AddParameter. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class AddParameterOptions { private String value; private Boolean publishValueAsDefault; @@ -95,15 +92,13 @@ public AddParameterOptions secret(Boolean value) { } -// ===== AddParameterWithGeneratedValueOptions.java ===== +// ===== aspire/AddParameterWithGeneratedValueOptions.java ===== // AddParameterWithGeneratedValueOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for AddParameterWithGeneratedValue. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class AddParameterWithGeneratedValueOptions { private Boolean secret; private Boolean persist; @@ -122,15 +117,13 @@ public AddParameterWithGeneratedValueOptions persist(Boolean value) { } -// ===== AddStepOptions.java ===== +// ===== aspire/AddStepOptions.java ===== // AddStepOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for AddStep. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class AddStepOptions { private String[] dependsOn; private String[] requiredBy; @@ -149,15 +142,16 @@ public AddStepOptions requiredBy(String[] value) { } -// ===== AfterPublishEvent.java ===== +// ===== aspire/AfterPublishEvent.java ===== // AfterPublishEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Publishing.AfterPublishEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AfterPublishEvent extends HandleWrapperBase { AfterPublishEvent(Handle handle, AspireClient client) { super(handle, client); @@ -181,15 +175,16 @@ public DistributedApplicationModel model() { } -// ===== AfterResourcesCreatedEvent.java ===== +// ===== aspire/AfterResourcesCreatedEvent.java ===== // AfterResourcesCreatedEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.AfterResourcesCreatedEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AfterResourcesCreatedEvent extends HandleWrapperBase { AfterResourcesCreatedEvent(Handle handle, AspireClient client) { super(handle, client); @@ -213,15 +208,16 @@ public DistributedApplicationModel model() { } -// ===== Aspire.java ===== +// ===== aspire/Aspire.java ===== // Aspire.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Main entry point for Aspire SDK. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class Aspire { /** Connect to the AppHost server. */ public static AspireClient connect() throws Exception { @@ -250,8 +246,10 @@ public static IDistributedApplicationBuilder createBuilder(CreateBuilderOptions resolvedOptions.putAll(options.toMap()); } if (resolvedOptions.get("Args") == null) { - // Note: Java doesn't have easy access to command line args from here - resolvedOptions.put("Args", new String[0]); + String forwardedArgs = System.getenv("ASPIRE_APPHOST_ARGS"); + resolvedOptions.put("Args", forwardedArgs == null || forwardedArgs.isEmpty() + ? new String[0] + : forwardedArgs.split("\n", -1)); } if (resolvedOptions.get("ProjectDirectory") == null) { String projectDirectory = System.getenv("ASPIRE_PROJECT_DIRECTORY"); @@ -272,93 +270,106 @@ public static IDistributedApplicationBuilder createBuilder(CreateBuilderOptions } } -// ===== AspireAction0.java ===== +// ===== aspire/AspireAction0.java ===== // AspireAction0.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireAction0 { void invoke(); } -// ===== AspireAction1.java ===== +// ===== aspire/AspireAction1.java ===== // AspireAction1.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireAction1 { void invoke(T1 arg1); } -// ===== AspireAction2.java ===== +// ===== aspire/AspireAction2.java ===== // AspireAction2.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireAction2 { void invoke(T1 arg1, T2 arg2); } -// ===== AspireAction3.java ===== +// ===== aspire/AspireAction3.java ===== // AspireAction3.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireAction3 { void invoke(T1 arg1, T2 arg2, T3 arg3); } -// ===== AspireAction4.java ===== +// ===== aspire/AspireAction4.java ===== // AspireAction4.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireAction4 { void invoke(T1 arg1, T2 arg2, T3 arg3, T4 arg4); } -// ===== AspireClient.java ===== +// ===== aspire/AspireClient.java ===== // AspireClient.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.io.*; -import java.net.*; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; /** * AspireClient handles JSON-RPC communication with the AppHost server. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AspireClient { private static final boolean DEBUG = System.getenv("ASPIRE_DEBUG") != null; private final String socketPath; private OutputStream outputStream; private InputStream inputStream; + // Windows only. inputStream/outputStream are built from this file's descriptor, so the + // RandomAccessFile has to outlive connect() -- closing it would close the descriptor they share + // and break the connection. Holding it in a field also makes that lifetime explicit to a + // compiler's resource-leak analysis, which cannot see the escape through FileDescriptor. + private RandomAccessFile namedPipe; private final AtomicInteger requestId = new AtomicInteger(0); private final Map> callbacks = new ConcurrentHashMap<>(); private final Map> cancellations = new ConcurrentHashMap<>(); private Runnable disconnectHandler; - private volatile boolean connected = false; // Handle wrapper factory registry private static final Map> handleWrappers = new ConcurrentHashMap<>(); @@ -380,7 +391,6 @@ public void connect() throws IOException { connectUnixSocket(); } - connected = true; debug("Connected successfully"); } @@ -395,10 +405,10 @@ private void connectWindowsNamedPipe() throws IOException { debug("Opening Windows named pipe: " + pipePath); // Use RandomAccessFile to open the named pipe - RandomAccessFile pipe = new RandomAccessFile(pipePath, "rw"); - + namedPipe = new RandomAccessFile(pipePath, "rw"); + // Create streams from the RandomAccessFile - FileDescriptor fd = pipe.getFD(); + FileDescriptor fd = namedPipe.getFD(); inputStream = new FileInputStream(fd); outputStream = new FileOutputStream(fd); @@ -560,7 +570,6 @@ private Object readResponse(int expectedId) throws IOException { @SuppressWarnings("unchecked") private Map readMessage() throws IOException { // Read headers - StringBuilder headerBuilder = new StringBuilder(); int contentLength = -1; while (true) { @@ -790,7 +799,6 @@ private Object unwrapResult(Object value) { } private void handleDisconnect() { - connected = false; if (disconnectHandler != null) { disconnectHandler.run(); } @@ -1114,16 +1122,19 @@ private void debug(String message) { } } -// ===== AspireDict.java ===== +// ===== aspire/AspireDict.java ===== // AspireDict.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * AspireDict is a handle-backed dictionary with lazy handle resolution. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AspireDict extends HandleWrapperBase { private final String getterCapabilityId; private Handle resolvedHandle; @@ -1195,83 +1206,116 @@ public boolean containsKey(K key) { return Boolean.TRUE.equals(result); } + /** + * Gets the keys in the dictionary. + * + * @return a snapshot of the keys + */ @SuppressWarnings("unchecked") public List keys() { Object result = getClient().invokeCapability("Aspire.Hosting/Dict.keys", Map.of("dict", ensureHandle().toJson())); return (List) result; } + + /** + * Gets the values in the dictionary. + * + * @return a snapshot of the values + */ + @SuppressWarnings("unchecked") + public List values() { + Object result = getClient().invokeCapability("Aspire.Hosting/Dict.values", Map.of("dict", ensureHandle().toJson())); + return (List) result; + } + + /** + * Removes all entries from the dictionary. + */ + public void clear() { + getClient().invokeCapability("Aspire.Hosting/Dict.clear", Map.of("dict", ensureHandle().toJson())); + } + + /** + * Copies the dictionary into a plain {@link Map}. Later mutations of the + * underlying app model are not reflected in the returned copy. + * + * @return a snapshot of the dictionary contents + */ + @SuppressWarnings("unchecked") + public Map toMap() { + Object result = getClient().invokeCapability("Aspire.Hosting/Dict.toObject", Map.of("dict", ensureHandle().toJson())); + return (Map) result; + } } -// ===== AspireFunc0.java ===== +// ===== aspire/AspireFunc0.java ===== // AspireFunc0.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireFunc0 { R invoke(); } -// ===== AspireFunc1.java ===== +// ===== aspire/AspireFunc1.java ===== // AspireFunc1.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireFunc1 { R invoke(T1 arg1); } -// ===== AspireFunc2.java ===== +// ===== aspire/AspireFunc2.java ===== // AspireFunc2.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireFunc2 { R invoke(T1 arg1, T2 arg2); } -// ===== AspireFunc3.java ===== +// ===== aspire/AspireFunc3.java ===== // AspireFunc3.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireFunc3 { R invoke(T1 arg1, T2 arg2, T3 arg3); } -// ===== AspireFunc4.java ===== +// ===== aspire/AspireFunc4.java ===== // AspireFunc4.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) @FunctionalInterface public interface AspireFunc4 { R invoke(T1 arg1, T2 arg2, T3 arg3, T4 arg4); } -// ===== AspireList.java ===== +// ===== aspire/AspireList.java ===== // AspireList.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * AspireList is a handle-backed list with lazy handle resolution. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AspireList extends HandleWrapperBase { private final String getterCapabilityId; private Handle resolvedHandle; @@ -1305,17 +1349,86 @@ private Handle ensureHandle() { } return resolvedHandle; } + + /** + * Gets the number of elements in the list. + * + * @return the element count + */ + public int size() { + Object result = getClient().invokeCapability("Aspire.Hosting/List.length", Map.of("list", ensureHandle().toJson())); + return ((Number) result).intValue(); + } + + /** + * Gets the element at the specified index. + * + * @param index the zero-based index + * @return the element at {@code index} + */ + @SuppressWarnings("unchecked") + public T get(int index) { + Map args = new HashMap<>(); + args.put("list", ensureHandle().toJson()); + args.put("index", index); + return (T) getClient().invokeCapability("Aspire.Hosting/List.get", args); + } + + /** + * Appends an element to the end of the list. + * + * @param item the element to append + */ + public void add(T item) { + Map args = new HashMap<>(); + args.put("list", ensureHandle().toJson()); + args.put("item", AspireClient.serializeValue(item)); + getClient().invokeCapability("Aspire.Hosting/List.add", args); + } + + /** + * Removes the element at the specified index. + * + * @param index the zero-based index + * @return {@code true} if an element was removed + */ + public boolean remove(int index) { + Map args = new HashMap<>(); + args.put("list", ensureHandle().toJson()); + args.put("index", index); + Object result = getClient().invokeCapability("Aspire.Hosting/List.removeAt", args); + return Boolean.TRUE.equals(result); + } + + /** + * Removes all elements from the list. + */ + public void clear() { + getClient().invokeCapability("Aspire.Hosting/List.clear", Map.of("list", ensureHandle().toJson())); + } + + /** + * Copies the list into a plain {@link List}. Later mutations of the underlying + * app model are not reflected in the returned copy. + * + * @return a snapshot of the list contents + */ + @SuppressWarnings("unchecked") + public List toList() { + Object result = getClient().invokeCapability("Aspire.Hosting/List.toArray", Map.of("list", ensureHandle().toJson())); + return (List) result; + } } -// ===== AspireRegistrations.java ===== +// ===== aspire/AspireRegistrations.java ===== // AspireRegistrations.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.List; /** Static initializer to register handle wrappers. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class AspireRegistrations { static { AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", (h, c) -> new IDistributedApplicationBuilder(h, c)); @@ -1430,11 +1543,11 @@ public class AspireRegistrations { AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.ITestVaultResource", (h, c) -> new ITestVaultResource(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IContainerFilesDestinationResource", (h, c) -> new IContainerFilesDestinationResource(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IComputeResource", (h, c) -> new IComputeResource(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/List", (h, c) -> new AspireList(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/List", (h, c) -> new AspireList(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict(h, c)); - AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/List", (h, c) -> new AspireList<>(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict<>(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/List", (h, c) -> new AspireList<>(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict<>(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting/Dict", (h, c) -> new AspireDict<>(h, c)); } static void ensureRegistered() { @@ -1442,16 +1555,15 @@ static void ensureRegistered() { } } -// ===== AspireUnion.java ===== +// ===== aspire/AspireUnion.java ===== // AspireUnion.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - /** * Represents a runtime union value for generated Java APIs. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class AspireUnion { private final Object value; @@ -1491,16 +1603,15 @@ public String toString() { } } -// ===== BaseRegistrations.java ===== +// ===== aspire/BaseRegistrations.java ===== // BaseRegistrations.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - /** * Registers runtime-owned wrappers defined in Base.java. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class BaseRegistrations { private BaseRegistrations() { } @@ -1513,15 +1624,16 @@ static void ensureRegistered() { } } -// ===== BeforePublishEvent.java ===== +// ===== aspire/BeforePublishEvent.java ===== // BeforePublishEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Publishing.BeforePublishEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class BeforePublishEvent extends HandleWrapperBase { BeforePublishEvent(Handle handle, AspireClient client) { super(handle, client); @@ -1545,15 +1657,16 @@ public DistributedApplicationModel model() { } -// ===== BeforeResourceStartedEvent.java ===== +// ===== aspire/BeforeResourceStartedEvent.java ===== // BeforeResourceStartedEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.BeforeResourceStartedEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class BeforeResourceStartedEvent extends HandleWrapperBase { BeforeResourceStartedEvent(Handle handle, AspireClient client) { super(handle, client); @@ -1577,15 +1690,16 @@ public IServiceProvider services() { } -// ===== BeforeStartEvent.java ===== +// ===== aspire/BeforeStartEvent.java ===== // BeforeStartEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.BeforeStartEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class BeforeStartEvent extends HandleWrapperBase { BeforeStartEvent(Handle handle, AspireClient client) { super(handle, client); @@ -1609,15 +1723,16 @@ public DistributedApplicationModel model() { } -// ===== BoolInteractionResult.java ===== +// ===== aspire/BoolInteractionResult.java ===== // BoolInteractionResult.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** BoolInteractionResult DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class BoolInteractionResult implements JsonSerializable { private boolean canceled; private Boolean value; @@ -1645,15 +1760,13 @@ public Map toMap() { } } -// ===== BuildOptions.java ===== +// ===== aspire/BuildOptions.java ===== // BuildOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for Build. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class BuildOptions { private ILogger resourceLogger; private CancellationToken cancellationToken; @@ -1672,15 +1785,17 @@ public BuildOptions cancellationToken(CancellationToken value) { } -// ===== CSharpAppResource.java ===== +// ===== aspire/CSharpAppResource.java ===== // CSharpAppResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.CSharpAppResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CSharpAppResource extends ProjectResource { CSharpAppResource(Handle handle, AspireClient client) { super(handle, client); @@ -1986,6 +2101,10 @@ public CSharpAppResource withReference(IResource source) { return withReference(AspireUnion.of(source)); } + public CSharpAppResource withReference(ResourceBuilderBase source) { + return withReference(new IResource(source.getHandle(), source.getClient())); + } + public CSharpAppResource withReference(EndpointReference source, WithReferenceOptions options) { return withReference(AspireUnion.of(source), options); } @@ -2140,7 +2259,7 @@ public CSharpAppResource withEndpoint() { } /** Adds a network endpoint */ - private CSharpAppResource withEndpointImpl(Double port, Double targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { + private CSharpAppResource withEndpointImpl(Number port, Number targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -2195,7 +2314,7 @@ public CSharpAppResource withHttpEndpoint() { } /** Adds an HTTP endpoint */ - private CSharpAppResource withHttpEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private CSharpAppResource withHttpEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -2232,7 +2351,7 @@ public CSharpAppResource withHttpsEndpoint() { } /** Adds an HTTPS endpoint */ - private CSharpAppResource withHttpsEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private CSharpAppResource withHttpsEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -2423,7 +2542,7 @@ public CSharpAppResource waitForCompletion(ResourceBuilderBase dependency) { } /** Waits for the dependency resource to enter the Exited or Finished state before starting the resource. */ - public CSharpAppResource waitForCompletion(IResource dependency, Double exitCode) { + public CSharpAppResource waitForCompletion(IResource dependency, Number exitCode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("dependency", AspireClient.serializeValue(dependency)); @@ -2434,7 +2553,7 @@ public CSharpAppResource waitForCompletion(IResource dependency, Double exitCode return this; } - public CSharpAppResource waitForCompletion(ResourceBuilderBase dependency, Double exitCode) { + public CSharpAppResource waitForCompletion(ResourceBuilderBase dependency, Number exitCode) { return waitForCompletion(new IResource(dependency.getHandle(), dependency.getClient()), exitCode); } @@ -2460,7 +2579,7 @@ public CSharpAppResource withHttpHealthCheck() { } /** Adds a health check to the resource which is mapped to a specific endpoint. */ - private CSharpAppResource withHttpHealthCheckImpl(String path, Double statusCode, String endpointName) { + private CSharpAppResource withHttpHealthCheckImpl(String path, Number statusCode, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (path != null) { @@ -2711,7 +2830,7 @@ public CSharpAppResource withHttpProbe(ProbeType probeType) { } /** Adds an HTTP health probe to the resource */ - private CSharpAppResource withHttpProbeImpl(ProbeType probeType, String path, Double initialDelaySeconds, Double periodSeconds, Double timeoutSeconds, Double failureThreshold, Double successThreshold, String endpointName) { + private CSharpAppResource withHttpProbeImpl(ProbeType probeType, String path, Number initialDelaySeconds, Number periodSeconds, Number timeoutSeconds, Number failureThreshold, Number successThreshold, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("probeType", AspireClient.serializeValue(probeType)); @@ -2768,7 +2887,7 @@ public CSharpAppResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private CSharpAppResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private CSharpAppResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -3265,7 +3384,7 @@ public CSharpAppResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private CSharpAppResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private CSharpAppResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -3291,7 +3410,7 @@ public CSharpAppResource withMergeLoggingPath(String logLevel, String logPath) { } /** Configures resource logging with file path */ - private CSharpAppResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private CSharpAppResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -3333,22 +3452,18 @@ public CSharpAppResource withMergeRouteMiddleware(String path, String method, St } -// ===== CancellationToken.java ===== +// ===== aspire/CancellationToken.java ===== // CancellationToken.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.io.*; -import java.net.*; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; /** * CancellationToken for cancelling operations. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CancellationToken { private volatile boolean cancelled = false; private final List listeners = new CopyOnWriteArrayList<>(); @@ -3401,22 +3516,15 @@ void onCancel(Runnable listener) { } } -// ===== CapabilityError.java ===== +// ===== aspire/CapabilityError.java ===== // CapabilityError.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.io.*; -import java.net.*; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; - /** * CapabilityError represents an error from a capability invocation. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CapabilityError extends RuntimeException { private final String code; private final Object data; @@ -3431,15 +3539,16 @@ public class CapabilityError extends RuntimeException { Object getData() { return data; } } -// ===== CertificateTrustExecutionConfigurationContext.java ===== +// ===== aspire/CertificateTrustExecutionConfigurationContext.java ===== // CertificateTrustExecutionConfigurationContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** CertificateTrustExecutionConfigurationContext DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CertificateTrustExecutionConfigurationContext implements JsonSerializable { private ReferenceExpression certificateBundlePath; private ReferenceExpression certificateDirectoriesPath; @@ -3479,15 +3588,16 @@ public Map toMap() { } } -// ===== CertificateTrustExecutionConfigurationExportData.java ===== +// ===== aspire/CertificateTrustExecutionConfigurationExportData.java ===== // CertificateTrustExecutionConfigurationExportData.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** CertificateTrustExecutionConfigurationExportData DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CertificateTrustExecutionConfigurationExportData implements JsonSerializable { private CertificateTrustScope scope; private String[] certificateSubjects; @@ -3521,15 +3631,13 @@ public Map toMap() { } } -// ===== CertificateTrustScope.java ===== +// ===== aspire/CertificateTrustScope.java ===== // CertificateTrustScope.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** CertificateTrustScope enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum CertificateTrustScope implements WireValueEnum { NONE("None"), APPEND("Append"), @@ -3552,15 +3660,16 @@ public static CertificateTrustScope fromValue(String value) { } } -// ===== CommandLineArgsCallbackContext.java ===== +// ===== aspire/CommandLineArgsCallbackContext.java ===== // CommandLineArgsCallbackContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CommandLineArgsCallbackContext extends HandleWrapperBase { CommandLineArgsCallbackContext(Handle handle, AspireClient client) { super(handle, client); @@ -3600,15 +3709,16 @@ public DistributedApplicationExecutionContext executionContext() { } -// ===== CommandLineArgsEditor.java ===== +// ===== aspire/CommandLineArgsEditor.java ===== // CommandLineArgsEditor.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsEditor. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CommandLineArgsEditor extends HandleWrapperBase { CommandLineArgsEditor(Handle handle, AspireClient client) { super(handle, client); @@ -3656,15 +3766,17 @@ public void add(AspireUnion value) { } -// ===== CommandOptions.java ===== +// ===== aspire/CommandOptions.java ===== // CommandOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; /** CommandOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CommandOptions implements JsonSerializable { private String description; private Object parameter; @@ -3749,15 +3861,16 @@ public Map toMap() { } } -// ===== CommandProgressOptions.java ===== +// ===== aspire/CommandProgressOptions.java ===== // CommandProgressOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** CommandProgressOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CommandProgressOptions implements JsonSerializable { private String message; private String title; @@ -3791,15 +3904,16 @@ public Map toMap() { } } -// ===== CommandResultData.java ===== +// ===== aspire/CommandResultData.java ===== // CommandResultData.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** CommandResultData DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CommandResultData implements JsonSerializable { private String value; private CommandResultFormat format; @@ -3833,15 +3947,13 @@ public Map toMap() { } } -// ===== CommandResultFormat.java ===== +// ===== aspire/CommandResultFormat.java ===== // CommandResultFormat.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** CommandResultFormat enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum CommandResultFormat implements WireValueEnum { TEXT("Text"), JSON("Json"), @@ -3863,15 +3975,13 @@ public static CommandResultFormat fromValue(String value) { } } -// ===== CompleteStepMarkdownOptions.java ===== +// ===== aspire/CompleteStepMarkdownOptions.java ===== // CompleteStepMarkdownOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for CompleteStepMarkdown. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class CompleteStepMarkdownOptions { private String completionState; private CancellationToken cancellationToken; @@ -3890,15 +4000,13 @@ public CompleteStepMarkdownOptions cancellationToken(CancellationToken value) { } -// ===== CompleteStepOptions.java ===== +// ===== aspire/CompleteStepOptions.java ===== // CompleteStepOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for CompleteStep. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class CompleteStepOptions { private String completionState; private CancellationToken cancellationToken; @@ -3917,15 +4025,13 @@ public CompleteStepOptions cancellationToken(CancellationToken value) { } -// ===== CompleteTaskMarkdownOptions.java ===== +// ===== aspire/CompleteTaskMarkdownOptions.java ===== // CompleteTaskMarkdownOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for CompleteTaskMarkdown. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class CompleteTaskMarkdownOptions { private String completionState; private CancellationToken cancellationToken; @@ -3944,15 +4050,13 @@ public CompleteTaskMarkdownOptions cancellationToken(CancellationToken value) { } -// ===== CompleteTaskOptions.java ===== +// ===== aspire/CompleteTaskOptions.java ===== // CompleteTaskOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for CompleteTask. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class CompleteTaskOptions { private String completionMessage; private String completionState; @@ -3978,15 +4082,16 @@ public CompleteTaskOptions cancellationToken(CancellationToken value) { } -// ===== ConnectionStringAvailableEvent.java ===== +// ===== aspire/ConnectionStringAvailableEvent.java ===== // ConnectionStringAvailableEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ConnectionStringAvailableEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ConnectionStringAvailableEvent extends HandleWrapperBase { ConnectionStringAvailableEvent(Handle handle, AspireClient client) { super(handle, client); @@ -4010,15 +4115,16 @@ public IServiceProvider services() { } -// ===== ContainerBuildOptionsCallbackContext.java ===== +// ===== aspire/ContainerBuildOptionsCallbackContext.java ===== // ContainerBuildOptionsCallbackContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerBuildOptionsCallbackContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerBuildOptionsCallbackContext extends HandleWrapperBase { ContainerBuildOptionsCallbackContext(Handle handle, AspireClient client) { super(handle, client); @@ -4168,15 +4274,16 @@ public ContainerBuildOptionsCallbackContext setLocalImageTag(String value) { } -// ===== ContainerFileSystemCallbackContext.java ===== +// ===== aspire/ContainerFileSystemCallbackContext.java ===== // ContainerFileSystemCallbackContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerFileSystemCallbackContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerFileSystemCallbackContext extends HandleWrapperBase { ContainerFileSystemCallbackContext(Handle handle, AspireClient client) { super(handle, client); @@ -4214,7 +4321,7 @@ public ContainerFileSystemItem createFile(String name) { } /** Creates a container file entry with inline contents or a host source path. */ - private ContainerFileSystemItem createFileImpl(String name, String contents, String sourcePath, Double owner, Double group, Double mode, Boolean continueOnError) { + private ContainerFileSystemItem createFileImpl(String name, String contents, String sourcePath, Number owner, Number group, Number mode, Boolean continueOnError) { Map reqArgs = new HashMap<>(); reqArgs.put("context", AspireClient.serializeValue(getHandle())); reqArgs.put("name", AspireClient.serializeValue(name)); @@ -4256,7 +4363,7 @@ public ContainerFileSystemItem createCertificateFile(String name) { } /** Creates a PEM container certificate file entry with the OpenSSL subject-hash symlink. */ - private ContainerFileSystemItem createCertificateFileImpl(String name, String contents, String sourcePath, Double owner, Double group, Double mode, Boolean continueOnError) { + private ContainerFileSystemItem createCertificateFileImpl(String name, String contents, String sourcePath, Number owner, Number group, Number mode, Boolean continueOnError) { Map reqArgs = new HashMap<>(); reqArgs.put("context", AspireClient.serializeValue(getHandle())); reqArgs.put("name", AspireClient.serializeValue(name)); @@ -4295,7 +4402,7 @@ public ContainerFileSystemItem createDirectory(String name, ContainerFileSystemI } /** Creates a container directory entry containing the specified child entries. */ - private ContainerFileSystemItem createDirectoryImpl(String name, ContainerFileSystemItem[] entries, Double owner, Double group, Double mode) { + private ContainerFileSystemItem createDirectoryImpl(String name, ContainerFileSystemItem[] entries, Number owner, Number group, Number mode) { Map reqArgs = new HashMap<>(); reqArgs.put("context", AspireClient.serializeValue(getHandle())); reqArgs.put("name", AspireClient.serializeValue(name)); @@ -4315,15 +4422,13 @@ private ContainerFileSystemItem createDirectoryImpl(String name, ContainerFileSy } -// ===== ContainerFileSystemItem.java ===== +// ===== aspire/ContainerFileSystemItem.java ===== // ContainerFileSystemItem.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerFileSystemItem. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerFileSystemItem extends HandleWrapperBase { ContainerFileSystemItem(Handle handle, AspireClient client) { super(handle, client); @@ -4331,26 +4436,27 @@ public class ContainerFileSystemItem extends HandleWrapperBase { } -// ===== ContainerFilesOptions.java ===== +// ===== aspire/ContainerFilesOptions.java ===== // ContainerFilesOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** ContainerFilesOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerFilesOptions implements JsonSerializable { - private Double defaultOwner; - private Double defaultGroup; - private Double umask; + private Number defaultOwner; + private Number defaultGroup; + private Number umask; - public Double getDefaultOwner() { return defaultOwner; } - public void setDefaultOwner(Double value) { this.defaultOwner = value; } - public Double getDefaultGroup() { return defaultGroup; } - public void setDefaultGroup(Double value) { this.defaultGroup = value; } - public Double getUmask() { return umask; } - public void setUmask(Double value) { this.umask = value; } + public Number getDefaultOwner() { return defaultOwner; } + public void setDefaultOwner(Number value) { this.defaultOwner = value; } + public Number getDefaultGroup() { return defaultGroup; } + public void setDefaultGroup(Number value) { this.defaultGroup = value; } + public Number getUmask() { return umask; } + public void setUmask(Number value) { this.umask = value; } @SuppressWarnings("unchecked") public static ContainerFilesOptions fromMap(Map map) { @@ -4373,15 +4479,13 @@ public Map toMap() { } } -// ===== ContainerImageDestination.java ===== +// ===== aspire/ContainerImageDestination.java ===== // ContainerImageDestination.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** ContainerImageDestination enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum ContainerImageDestination implements WireValueEnum { REGISTRY("Registry"), ARCHIVE("Archive"); @@ -4402,15 +4506,13 @@ public static ContainerImageDestination fromValue(String value) { } } -// ===== ContainerImageFormat.java ===== +// ===== aspire/ContainerImageFormat.java ===== // ContainerImageFormat.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** ContainerImageFormat enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum ContainerImageFormat implements WireValueEnum { DOCKER("Docker"), OCI("Oci"); @@ -4431,15 +4533,16 @@ public static ContainerImageFormat fromValue(String value) { } } -// ===== ContainerImagePushOptions.java ===== +// ===== aspire/ContainerImagePushOptions.java ===== // ContainerImagePushOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerImagePushOptions. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerImagePushOptions extends HandleWrapperBase { ContainerImagePushOptions(Handle handle, AspireClient client) { super(handle, client); @@ -4481,15 +4584,16 @@ public ContainerImagePushOptions setRemoteImageTag(String value) { } -// ===== ContainerImagePushOptionsCallbackContext.java ===== +// ===== aspire/ContainerImagePushOptionsCallbackContext.java ===== // ContainerImagePushOptionsCallbackContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerImagePushOptionsCallbackContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerImagePushOptionsCallbackContext extends HandleWrapperBase { ContainerImagePushOptionsCallbackContext(Handle handle, AspireClient client) { super(handle, client); @@ -4521,15 +4625,16 @@ public ContainerImagePushOptions options() { } -// ===== ContainerImageReference.java ===== +// ===== aspire/ContainerImageReference.java ===== // ContainerImageReference.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerImageReference. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerImageReference extends HandleWrapperBase { ContainerImageReference(Handle handle, AspireClient client) { super(handle, client); @@ -4553,15 +4658,13 @@ public String valueExpression() { } -// ===== ContainerLifetime.java ===== +// ===== aspire/ContainerLifetime.java ===== // ContainerLifetime.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** ContainerLifetime enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum ContainerLifetime implements WireValueEnum { SESSION("Session"), PERSISTENT("Persistent"); @@ -4582,15 +4685,16 @@ public static ContainerLifetime fromValue(String value) { } } -// ===== ContainerMountAnnotation.java ===== +// ===== aspire/ContainerMountAnnotation.java ===== // ContainerMountAnnotation.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerMountAnnotation. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerMountAnnotation extends HandleWrapperBase { ContainerMountAnnotation(Handle handle, AspireClient client) { super(handle, client); @@ -4630,15 +4734,13 @@ public boolean isReadOnly() { } -// ===== ContainerMountType.java ===== +// ===== aspire/ContainerMountType.java ===== // ContainerMountType.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** ContainerMountType enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum ContainerMountType implements WireValueEnum { BIND_MOUNT("BindMount"), VOLUME("Volume"); @@ -4659,15 +4761,16 @@ public static ContainerMountType fromValue(String value) { } } -// ===== ContainerPortReference.java ===== +// ===== aspire/ContainerPortReference.java ===== // ContainerPortReference.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerPortReference. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerPortReference extends HandleWrapperBase { ContainerPortReference(Handle handle, AspireClient client) { super(handle, client); @@ -4691,15 +4794,16 @@ public String valueExpression() { } -// ===== ContainerRegistryResource.java ===== +// ===== aspire/ContainerRegistryResource.java ===== // ContainerRegistryResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerRegistryResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerRegistryResource extends ResourceBuilderBase { ContainerRegistryResource(Handle handle, AspireClient client) { super(handle, client); @@ -5061,7 +5165,7 @@ public ContainerRegistryResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private ContainerRegistryResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private ContainerRegistryResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -5474,7 +5578,7 @@ public ContainerRegistryResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private ContainerRegistryResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private ContainerRegistryResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -5500,7 +5604,7 @@ public ContainerRegistryResource withMergeLoggingPath(String logLevel, String lo } /** Configures resource logging with file path */ - private ContainerRegistryResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private ContainerRegistryResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -5542,15 +5646,17 @@ public ContainerRegistryResource withMergeRouteMiddleware(String path, String me } -// ===== ContainerResource.java ===== +// ===== aspire/ContainerResource.java ===== // ContainerResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ContainerResource extends ResourceBuilderBase { ContainerResource(Handle handle, AspireClient client) { super(handle, client); @@ -6120,6 +6226,10 @@ public ContainerResource withReference(IResource source) { return withReference(AspireUnion.of(source)); } + public ContainerResource withReference(ResourceBuilderBase source) { + return withReference(new IResource(source.getHandle(), source.getClient())); + } + public ContainerResource withReference(EndpointReference source, WithReferenceOptions options) { return withReference(AspireUnion.of(source), options); } @@ -6274,7 +6384,7 @@ public ContainerResource withEndpoint() { } /** Adds a network endpoint */ - private ContainerResource withEndpointImpl(Double port, Double targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { + private ContainerResource withEndpointImpl(Number port, Number targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -6329,7 +6439,7 @@ public ContainerResource withHttpEndpoint() { } /** Adds an HTTP endpoint */ - private ContainerResource withHttpEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private ContainerResource withHttpEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -6366,7 +6476,7 @@ public ContainerResource withHttpsEndpoint() { } /** Adds an HTTPS endpoint */ - private ContainerResource withHttpsEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private ContainerResource withHttpsEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -6543,7 +6653,7 @@ public ContainerResource waitForCompletion(ResourceBuilderBase dependency) { } /** Waits for the dependency resource to enter the Exited or Finished state before starting the resource. */ - public ContainerResource waitForCompletion(IResource dependency, Double exitCode) { + public ContainerResource waitForCompletion(IResource dependency, Number exitCode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("dependency", AspireClient.serializeValue(dependency)); @@ -6554,7 +6664,7 @@ public ContainerResource waitForCompletion(IResource dependency, Double exitCode return this; } - public ContainerResource waitForCompletion(ResourceBuilderBase dependency, Double exitCode) { + public ContainerResource waitForCompletion(ResourceBuilderBase dependency, Number exitCode) { return waitForCompletion(new IResource(dependency.getHandle(), dependency.getClient()), exitCode); } @@ -6580,7 +6690,7 @@ public ContainerResource withHttpHealthCheck() { } /** Adds a health check to the resource which is mapped to a specific endpoint. */ - private ContainerResource withHttpHealthCheckImpl(String path, Double statusCode, String endpointName) { + private ContainerResource withHttpHealthCheckImpl(String path, Number statusCode, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (path != null) { @@ -6831,7 +6941,7 @@ public ContainerResource withHttpProbe(ProbeType probeType) { } /** Adds an HTTP health probe to the resource */ - private ContainerResource withHttpProbeImpl(ProbeType probeType, String path, Double initialDelaySeconds, Double periodSeconds, Double timeoutSeconds, Double failureThreshold, Double successThreshold, String endpointName) { + private ContainerResource withHttpProbeImpl(ProbeType probeType, String path, Number initialDelaySeconds, Number periodSeconds, Number timeoutSeconds, Number failureThreshold, Number successThreshold, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("probeType", AspireClient.serializeValue(probeType)); @@ -6888,7 +6998,7 @@ public ContainerResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private ContainerResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private ContainerResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -7402,7 +7512,7 @@ public ContainerResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private ContainerResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private ContainerResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -7428,7 +7538,7 @@ public ContainerResource withMergeLoggingPath(String logLevel, String logPath) { } /** Configures resource logging with file path */ - private ContainerResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private ContainerResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -7470,15 +7580,13 @@ public ContainerResource withMergeRouteMiddleware(String path, String method, St } -// ===== ContainerTargetPlatform.java ===== +// ===== aspire/ContainerTargetPlatform.java ===== // ContainerTargetPlatform.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** ContainerTargetPlatform enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum ContainerTargetPlatform implements WireValueEnum { LINUX_AMD64("LinuxAmd64"), LINUX_ARM64("LinuxArm64"), @@ -7504,15 +7612,16 @@ public static ContainerTargetPlatform fromValue(String value) { } } -// ===== CreateBuilderOptions.java ===== +// ===== aspire/CreateBuilderOptions.java ===== // CreateBuilderOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** CreateBuilderOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CreateBuilderOptions implements JsonSerializable { private String[] args; private String projectDirectory; @@ -7576,21 +7685,19 @@ public Map toMap() { } } -// ===== CreateCertificateFileOptions.java ===== +// ===== aspire/CreateCertificateFileOptions.java ===== // CreateCertificateFileOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for CreateCertificateFile. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class CreateCertificateFileOptions { private String contents; private String sourcePath; - private Double owner; - private Double group; - private Double mode; + private Number owner; + private Number group; + private Number mode; private Boolean continueOnError; public String getContents() { return contents; } @@ -7605,20 +7712,20 @@ public CreateCertificateFileOptions sourcePath(String value) { return this; } - public Double getOwner() { return owner; } - public CreateCertificateFileOptions owner(Double value) { + public Number getOwner() { return owner; } + public CreateCertificateFileOptions owner(Number value) { this.owner = value; return this; } - public Double getGroup() { return group; } - public CreateCertificateFileOptions group(Double value) { + public Number getGroup() { return group; } + public CreateCertificateFileOptions group(Number value) { this.group = value; return this; } - public Double getMode() { return mode; } - public CreateCertificateFileOptions mode(Double value) { + public Number getMode() { return mode; } + public CreateCertificateFileOptions mode(Number value) { this.mode = value; return this; } @@ -7631,15 +7738,13 @@ public CreateCertificateFileOptions continueOnError(Boolean value) { } -// ===== CreateChoiceInputOptions.java ===== +// ===== aspire/CreateChoiceInputOptions.java ===== // CreateChoiceInputOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for CreateChoiceInput. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class CreateChoiceInputOptions { private InteractionChoiceOption[] choices; private CreateInteractionInputOptions options; @@ -7658,55 +7763,51 @@ public CreateChoiceInputOptions options(CreateInteractionInputOptions value) { } -// ===== CreateDirectoryOptions.java ===== +// ===== aspire/CreateDirectoryOptions.java ===== // CreateDirectoryOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for CreateDirectory. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class CreateDirectoryOptions { - private Double owner; - private Double group; - private Double mode; + private Number owner; + private Number group; + private Number mode; - public Double getOwner() { return owner; } - public CreateDirectoryOptions owner(Double value) { + public Number getOwner() { return owner; } + public CreateDirectoryOptions owner(Number value) { this.owner = value; return this; } - public Double getGroup() { return group; } - public CreateDirectoryOptions group(Double value) { + public Number getGroup() { return group; } + public CreateDirectoryOptions group(Number value) { this.group = value; return this; } - public Double getMode() { return mode; } - public CreateDirectoryOptions mode(Double value) { + public Number getMode() { return mode; } + public CreateDirectoryOptions mode(Number value) { this.mode = value; return this; } } -// ===== CreateFileOptions.java ===== +// ===== aspire/CreateFileOptions.java ===== // CreateFileOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for CreateFile. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class CreateFileOptions { private String contents; private String sourcePath; - private Double owner; - private Double group; - private Double mode; + private Number owner; + private Number group; + private Number mode; private Boolean continueOnError; public String getContents() { return contents; } @@ -7721,20 +7822,20 @@ public CreateFileOptions sourcePath(String value) { return this; } - public Double getOwner() { return owner; } - public CreateFileOptions owner(Double value) { + public Number getOwner() { return owner; } + public CreateFileOptions owner(Number value) { this.owner = value; return this; } - public Double getGroup() { return group; } - public CreateFileOptions group(Double value) { + public Number getGroup() { return group; } + public CreateFileOptions group(Number value) { this.group = value; return this; } - public Double getMode() { return mode; } - public CreateFileOptions mode(Double value) { + public Number getMode() { return mode; } + public CreateFileOptions mode(Number value) { this.mode = value; return this; } @@ -7747,15 +7848,16 @@ public CreateFileOptions continueOnError(Boolean value) { } -// ===== CreateInteractionInputOptions.java ===== +// ===== aspire/CreateInteractionInputOptions.java ===== // CreateInteractionInputOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** CreateInteractionInputOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class CreateInteractionInputOptions implements JsonSerializable { private String label; private String description; @@ -7765,8 +7867,8 @@ public class CreateInteractionInputOptions implements JsonSerializable { private String value; private Boolean allowCustomChoice; private Boolean disabled; - private Double maxLength; - private Double maxFileSize; + private Number maxLength; + private Number maxFileSize; private Boolean allowMultipleFiles; private String fileFilter; @@ -7786,10 +7888,10 @@ public class CreateInteractionInputOptions implements JsonSerializable { public void setAllowCustomChoice(Boolean value) { this.allowCustomChoice = value; } public Boolean getDisabled() { return disabled; } public void setDisabled(Boolean value) { this.disabled = value; } - public Double getMaxLength() { return maxLength; } - public void setMaxLength(Double value) { this.maxLength = value; } - public Double getMaxFileSize() { return maxFileSize; } - public void setMaxFileSize(Double value) { this.maxFileSize = value; } + public Number getMaxLength() { return maxLength; } + public void setMaxLength(Number value) { this.maxLength = value; } + public Number getMaxFileSize() { return maxFileSize; } + public void setMaxFileSize(Number value) { this.maxFileSize = value; } public Boolean getAllowMultipleFiles() { return allowMultipleFiles; } public void setAllowMultipleFiles(Boolean value) { this.allowMultipleFiles = value; } public String getFileFilter() { return fileFilter; } @@ -7843,15 +7945,16 @@ public Map toMap() { } } -// ===== DistributedApplication.java ===== +// ===== aspire/DistributedApplication.java ===== // DistributedApplication.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.DistributedApplication. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DistributedApplication extends HandleWrapperBase { DistributedApplication(Handle handle, AspireClient client) { super(handle, client); @@ -7892,15 +7995,13 @@ public static IDistributedApplicationBuilder CreateBuilder(CreateBuilderOptions } -// ===== DistributedApplicationEventSubscription.java ===== +// ===== aspire/DistributedApplicationEventSubscription.java ===== // DistributedApplicationEventSubscription.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationEventSubscription. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DistributedApplicationEventSubscription extends HandleWrapperBase { DistributedApplicationEventSubscription(Handle handle, AspireClient client) { super(handle, client); @@ -7908,15 +8009,16 @@ public class DistributedApplicationEventSubscription extends HandleWrapperBase { } -// ===== DistributedApplicationExecutionContext.java ===== +// ===== aspire/DistributedApplicationExecutionContext.java ===== // DistributedApplicationExecutionContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DistributedApplicationExecutionContext extends HandleWrapperBase { DistributedApplicationExecutionContext(Handle handle, AspireClient client) { super(handle, client); @@ -7989,15 +8091,13 @@ public boolean isRunMode() { } -// ===== DistributedApplicationExecutionContextOptions.java ===== +// ===== aspire/DistributedApplicationExecutionContextOptions.java ===== // DistributedApplicationExecutionContextOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContextOptions. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DistributedApplicationExecutionContextOptions extends HandleWrapperBase { DistributedApplicationExecutionContextOptions(Handle handle, AspireClient client) { super(handle, client); @@ -8005,15 +8105,16 @@ public class DistributedApplicationExecutionContextOptions extends HandleWrapper } -// ===== DistributedApplicationModel.java ===== +// ===== aspire/DistributedApplicationModel.java ===== // DistributedApplicationModel.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.DistributedApplicationModel. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DistributedApplicationModel extends HandleWrapperBase { DistributedApplicationModel(Handle handle, AspireClient client) { super(handle, client); @@ -8038,15 +8139,13 @@ public IResource findResourceByName(String name) { } -// ===== DistributedApplicationOperation.java ===== +// ===== aspire/DistributedApplicationOperation.java ===== // DistributedApplicationOperation.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** DistributedApplicationOperation enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum DistributedApplicationOperation implements WireValueEnum { RUN("Run"), PUBLISH("Publish"); @@ -8067,15 +8166,13 @@ public static DistributedApplicationOperation fromValue(String value) { } } -// ===== DistributedApplicationResourceEventSubscription.java ===== +// ===== aspire/DistributedApplicationResourceEventSubscription.java ===== // DistributedApplicationResourceEventSubscription.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationResourceEventSubscription. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DistributedApplicationResourceEventSubscription extends DistributedApplicationEventSubscription { DistributedApplicationResourceEventSubscription(Handle handle, AspireClient client) { super(handle, client); @@ -8083,15 +8180,16 @@ public class DistributedApplicationResourceEventSubscription extends Distributed } -// ===== DockerfileBuilder.java ===== +// ===== aspire/DockerfileBuilder.java ===== // DockerfileBuilder.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.Docker.DockerfileBuilder. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DockerfileBuilder extends HandleWrapperBase { DockerfileBuilder(Handle handle, AspireClient client) { super(handle, client); @@ -8163,15 +8261,16 @@ public DockerfileBuilder addContainerFilesStages(ResourceBuilderBase resource, H } -// ===== DockerfileBuilderCallbackContext.java ===== +// ===== aspire/DockerfileBuilderCallbackContext.java ===== // DockerfileBuilderCallbackContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.DockerfileBuilderCallbackContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DockerfileBuilderCallbackContext extends HandleWrapperBase { DockerfileBuilderCallbackContext(Handle handle, AspireClient client) { super(handle, client); @@ -8211,15 +8310,16 @@ public CancellationToken cancellationToken() { } -// ===== DockerfileFactoryContext.java ===== +// ===== aspire/DockerfileFactoryContext.java ===== // DockerfileFactoryContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.DockerfileFactoryContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DockerfileFactoryContext extends HandleWrapperBase { DockerfileFactoryContext(Handle handle, AspireClient client) { super(handle, client); @@ -8235,15 +8335,16 @@ public IResource resource() { } -// ===== DockerfileStage.java ===== +// ===== aspire/DockerfileStage.java ===== // DockerfileStage.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.Docker.DockerfileStage. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DockerfileStage extends HandleWrapperBase { DockerfileStage(Handle handle, AspireClient client) { super(handle, client); @@ -8426,15 +8527,17 @@ public DockerfileStage addContainerFiles(ResourceBuilderBase resource, String ro } -// ===== DotnetToolResource.java ===== +// ===== aspire/DotnetToolResource.java ===== // DotnetToolResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.DotnetToolResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DotnetToolResource extends ExecutableResource { DotnetToolResource(Handle handle, AspireClient client) { super(handle, client); @@ -8788,6 +8891,10 @@ public DotnetToolResource withReference(IResource source) { return withReference(AspireUnion.of(source)); } + public DotnetToolResource withReference(ResourceBuilderBase source) { + return withReference(new IResource(source.getHandle(), source.getClient())); + } + public DotnetToolResource withReference(EndpointReference source, WithReferenceOptions options) { return withReference(AspireUnion.of(source), options); } @@ -8942,7 +9049,7 @@ public DotnetToolResource withEndpoint() { } /** Adds a network endpoint */ - private DotnetToolResource withEndpointImpl(Double port, Double targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { + private DotnetToolResource withEndpointImpl(Number port, Number targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -8997,7 +9104,7 @@ public DotnetToolResource withHttpEndpoint() { } /** Adds an HTTP endpoint */ - private DotnetToolResource withHttpEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private DotnetToolResource withHttpEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -9034,7 +9141,7 @@ public DotnetToolResource withHttpsEndpoint() { } /** Adds an HTTPS endpoint */ - private DotnetToolResource withHttpsEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private DotnetToolResource withHttpsEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -9211,7 +9318,7 @@ public DotnetToolResource waitForCompletion(ResourceBuilderBase dependency) { } /** Waits for the dependency resource to enter the Exited or Finished state before starting the resource. */ - public DotnetToolResource waitForCompletion(IResource dependency, Double exitCode) { + public DotnetToolResource waitForCompletion(IResource dependency, Number exitCode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("dependency", AspireClient.serializeValue(dependency)); @@ -9222,7 +9329,7 @@ public DotnetToolResource waitForCompletion(IResource dependency, Double exitCod return this; } - public DotnetToolResource waitForCompletion(ResourceBuilderBase dependency, Double exitCode) { + public DotnetToolResource waitForCompletion(ResourceBuilderBase dependency, Number exitCode) { return waitForCompletion(new IResource(dependency.getHandle(), dependency.getClient()), exitCode); } @@ -9248,7 +9355,7 @@ public DotnetToolResource withHttpHealthCheck() { } /** Adds a health check to the resource which is mapped to a specific endpoint. */ - private DotnetToolResource withHttpHealthCheckImpl(String path, Double statusCode, String endpointName) { + private DotnetToolResource withHttpHealthCheckImpl(String path, Number statusCode, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (path != null) { @@ -9499,7 +9606,7 @@ public DotnetToolResource withHttpProbe(ProbeType probeType) { } /** Adds an HTTP health probe to the resource */ - private DotnetToolResource withHttpProbeImpl(ProbeType probeType, String path, Double initialDelaySeconds, Double periodSeconds, Double timeoutSeconds, Double failureThreshold, Double successThreshold, String endpointName) { + private DotnetToolResource withHttpProbeImpl(ProbeType probeType, String path, Number initialDelaySeconds, Number periodSeconds, Number timeoutSeconds, Number failureThreshold, Number successThreshold, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("probeType", AspireClient.serializeValue(probeType)); @@ -9556,7 +9663,7 @@ public DotnetToolResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private DotnetToolResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private DotnetToolResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -10044,7 +10151,7 @@ public DotnetToolResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private DotnetToolResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private DotnetToolResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -10070,7 +10177,7 @@ public DotnetToolResource withMergeLoggingPath(String logLevel, String logPath) } /** Configures resource logging with file path */ - private DotnetToolResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private DotnetToolResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -10112,15 +10219,16 @@ public DotnetToolResource withMergeRouteMiddleware(String path, String method, S } -// ===== DynamicLoadingOptions.java ===== +// ===== aspire/DynamicLoadingOptions.java ===== // DynamicLoadingOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** DynamicLoadingOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class DynamicLoadingOptions implements JsonSerializable { private Boolean alwaysLoadOnStart; private String[] dependsOnInputs; @@ -10148,15 +10256,13 @@ public Map toMap() { } } -// ===== EndpointProperty.java ===== +// ===== aspire/EndpointProperty.java ===== // EndpointProperty.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** EndpointProperty enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum EndpointProperty implements WireValueEnum { URL("Url"), HOST("Host"), @@ -10183,15 +10289,16 @@ public static EndpointProperty fromValue(String value) { } } -// ===== EndpointReference.java ===== +// ===== aspire/EndpointReference.java ===== // EndpointReference.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class EndpointReference extends HandleWrapperBase { EndpointReference(Handle handle, AspireClient client) { super(handle, client); @@ -10353,15 +10460,16 @@ public ReferenceExpression getTlsValue(ReferenceExpression enabledValue, Referen } -// ===== EndpointReferenceExpression.java ===== +// ===== aspire/EndpointReferenceExpression.java ===== // EndpointReferenceExpression.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class EndpointReferenceExpression extends HandleWrapperBase { EndpointReferenceExpression(Handle handle, AspireClient client) { super(handle, client); @@ -10393,15 +10501,16 @@ public String valueExpression() { } -// ===== EndpointUpdateContext.java ===== +// ===== aspire/EndpointUpdateContext.java ===== // EndpointUpdateContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointUpdateContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class EndpointUpdateContext extends HandleWrapperBase { EndpointUpdateContext(Handle handle, AspireClient client) { super(handle, client); @@ -10587,15 +10696,16 @@ public EndpointUpdateContext setTlsEnabled(boolean value) { } -// ===== EnvironmentCallbackContext.java ===== +// ===== aspire/EnvironmentCallbackContext.java ===== // EnvironmentCallbackContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.EnvironmentCallbackContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class EnvironmentCallbackContext extends HandleWrapperBase { EnvironmentCallbackContext(Handle handle, AspireClient client) { super(handle, client); @@ -10635,15 +10745,16 @@ public DistributedApplicationExecutionContext executionContext() { } -// ===== EnvironmentEditor.java ===== +// ===== aspire/EnvironmentEditor.java ===== // EnvironmentEditor.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.EnvironmentEditor. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class EnvironmentEditor extends HandleWrapperBase { EnvironmentEditor(Handle handle, AspireClient client) { super(handle, client); @@ -10692,15 +10803,16 @@ public void set(String name, AspireUnion value) { } -// ===== EventingSubscriberRegistrationContext.java ===== +// ===== aspire/EventingSubscriberRegistrationContext.java ===== // EventingSubscriberRegistrationContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Ats.EventingSubscriberRegistrationContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class EventingSubscriberRegistrationContext extends HandleWrapperBase { EventingSubscriberRegistrationContext(Handle handle, AspireClient client) { super(handle, client); @@ -10788,15 +10900,17 @@ public CancellationToken cancellationToken() { } -// ===== ExecutableResource.java ===== +// ===== aspire/ExecutableResource.java ===== // ExecutableResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ExecutableResource extends ResourceBuilderBase { ExecutableResource(Handle handle, AspireClient client) { super(handle, client); @@ -11099,6 +11213,10 @@ public ExecutableResource withReference(IResource source) { return withReference(AspireUnion.of(source)); } + public ExecutableResource withReference(ResourceBuilderBase source) { + return withReference(new IResource(source.getHandle(), source.getClient())); + } + public ExecutableResource withReference(EndpointReference source, WithReferenceOptions options) { return withReference(AspireUnion.of(source), options); } @@ -11253,7 +11371,7 @@ public ExecutableResource withEndpoint() { } /** Adds a network endpoint */ - private ExecutableResource withEndpointImpl(Double port, Double targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { + private ExecutableResource withEndpointImpl(Number port, Number targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -11308,7 +11426,7 @@ public ExecutableResource withHttpEndpoint() { } /** Adds an HTTP endpoint */ - private ExecutableResource withHttpEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private ExecutableResource withHttpEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -11345,7 +11463,7 @@ public ExecutableResource withHttpsEndpoint() { } /** Adds an HTTPS endpoint */ - private ExecutableResource withHttpsEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private ExecutableResource withHttpsEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -11522,7 +11640,7 @@ public ExecutableResource waitForCompletion(ResourceBuilderBase dependency) { } /** Waits for the dependency resource to enter the Exited or Finished state before starting the resource. */ - public ExecutableResource waitForCompletion(IResource dependency, Double exitCode) { + public ExecutableResource waitForCompletion(IResource dependency, Number exitCode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("dependency", AspireClient.serializeValue(dependency)); @@ -11533,7 +11651,7 @@ public ExecutableResource waitForCompletion(IResource dependency, Double exitCod return this; } - public ExecutableResource waitForCompletion(ResourceBuilderBase dependency, Double exitCode) { + public ExecutableResource waitForCompletion(ResourceBuilderBase dependency, Number exitCode) { return waitForCompletion(new IResource(dependency.getHandle(), dependency.getClient()), exitCode); } @@ -11559,7 +11677,7 @@ public ExecutableResource withHttpHealthCheck() { } /** Adds a health check to the resource which is mapped to a specific endpoint. */ - private ExecutableResource withHttpHealthCheckImpl(String path, Double statusCode, String endpointName) { + private ExecutableResource withHttpHealthCheckImpl(String path, Number statusCode, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (path != null) { @@ -11810,7 +11928,7 @@ public ExecutableResource withHttpProbe(ProbeType probeType) { } /** Adds an HTTP health probe to the resource */ - private ExecutableResource withHttpProbeImpl(ProbeType probeType, String path, Double initialDelaySeconds, Double periodSeconds, Double timeoutSeconds, Double failureThreshold, Double successThreshold, String endpointName) { + private ExecutableResource withHttpProbeImpl(ProbeType probeType, String path, Number initialDelaySeconds, Number periodSeconds, Number timeoutSeconds, Number failureThreshold, Number successThreshold, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("probeType", AspireClient.serializeValue(probeType)); @@ -11867,7 +11985,7 @@ public ExecutableResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private ExecutableResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private ExecutableResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -12355,7 +12473,7 @@ public ExecutableResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private ExecutableResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private ExecutableResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -12381,7 +12499,7 @@ public ExecutableResource withMergeLoggingPath(String logLevel, String logPath) } /** Configures resource logging with file path */ - private ExecutableResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private ExecutableResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -12423,15 +12541,15 @@ public ExecutableResource withMergeRouteMiddleware(String path, String method, S } -// ===== ExecuteCommandAsyncOptions.java ===== +// ===== aspire/ExecuteCommandAsyncOptions.java ===== // ExecuteCommandAsyncOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.Map; /** Options for ExecuteCommandAsync. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class ExecuteCommandAsyncOptions { private Map arguments; private CancellationToken cancellationToken; @@ -12450,15 +12568,16 @@ public ExecuteCommandAsyncOptions cancellationToken(CancellationToken value) { } -// ===== ExecuteCommandContext.java ===== +// ===== aspire/ExecuteCommandContext.java ===== // ExecuteCommandContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ExecuteCommandContext extends HandleWrapperBase { ExecuteCommandContext(Handle handle, AspireClient client) { super(handle, client); @@ -12506,15 +12625,16 @@ public InteractionInputCollection arguments() { } -// ===== ExecuteCommandResult.java ===== +// ===== aspire/ExecuteCommandResult.java ===== // ExecuteCommandResult.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** ExecuteCommandResult DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ExecuteCommandResult implements JsonSerializable { private boolean success; private Boolean canceled; @@ -12560,15 +12680,16 @@ public Map toMap() { } } -// ===== ExternalServiceResource.java ===== +// ===== aspire/ExternalServiceResource.java ===== // ExternalServiceResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ExternalServiceResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ExternalServiceResource extends ResourceBuilderBase { ExternalServiceResource(Handle handle, AspireClient client) { super(handle, client); @@ -12625,7 +12746,7 @@ public ExternalServiceResource withHttpHealthCheck() { } /** Adds an HTTP health check to the external service for polyglot app hosts. */ - private ExternalServiceResource withHttpHealthCheckImpl(String path, Double statusCode, String endpointName) { + private ExternalServiceResource withHttpHealthCheckImpl(String path, Number statusCode, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (path != null) { @@ -12959,7 +13080,7 @@ public ExternalServiceResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private ExternalServiceResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private ExternalServiceResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -13372,7 +13493,7 @@ public ExternalServiceResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private ExternalServiceResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private ExternalServiceResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -13398,7 +13519,7 @@ public ExternalServiceResource withMergeLoggingPath(String logLevel, String logP } /** Configures resource logging with file path */ - private ExternalServiceResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private ExternalServiceResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -13440,15 +13561,16 @@ public ExternalServiceResource withMergeRouteMiddleware(String path, String meth } -// ===== GenerateParameterDefault.java ===== +// ===== aspire/GenerateParameterDefault.java ===== // GenerateParameterDefault.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** GenerateParameterDefault DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class GenerateParameterDefault implements JsonSerializable { private double minLength; private boolean lower; @@ -13518,22 +13640,18 @@ public Map toMap() { } } -// ===== Handle.java ===== +// ===== aspire/Handle.java ===== // Handle.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.io.*; -import java.net.*; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** * Handle represents a remote object reference. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class Handle { private final String id; private final String typeId; @@ -13559,16 +13677,15 @@ public String toString() { } } -// ===== HandleWrapperBase.java ===== +// ===== aspire/HandleWrapperBase.java ===== // HandleWrapperBase.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - /** * HandleWrapperBase is the base class for all handle wrappers. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HandleWrapperBase { private final Handle handle; private final AspireClient client; @@ -13587,15 +13704,16 @@ AspireClient getClient() { } } -// ===== HealthCheckResult.java ===== +// ===== aspire/HealthCheckResult.java ===== // HealthCheckResult.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** HealthCheckResult DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HealthCheckResult implements JsonSerializable { private HealthStatus status; private String description; @@ -13629,15 +13747,13 @@ public Map toMap() { } } -// ===== HealthStatus.java ===== +// ===== aspire/HealthStatus.java ===== // HealthStatus.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** HealthStatus enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum HealthStatus implements WireValueEnum { UNHEALTHY("Unhealthy"), DEGRADED("Degraded"), @@ -13659,15 +13775,17 @@ public static HealthStatus fromValue(String value) { } } -// ===== HttpCommandExportOptions.java ===== +// ===== aspire/HttpCommandExportOptions.java ===== // HttpCommandExportOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; /** HttpCommandExportOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HttpCommandExportOptions implements JsonSerializable { private CommandOptions commandOptions; private String description; @@ -13750,15 +13868,16 @@ public Map toMap() { } } -// ===== HttpCommandPrepareRequestContext.java ===== +// ===== aspire/HttpCommandPrepareRequestContext.java ===== // HttpCommandPrepareRequestContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.HttpCommandPrepareRequestContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HttpCommandPrepareRequestContext extends HandleWrapperBase { HttpCommandPrepareRequestContext(Handle handle, AspireClient client) { super(handle, client); @@ -13798,15 +13917,16 @@ public InteractionInputCollection arguments() { } -// ===== HttpCommandRequestExportData.java ===== +// ===== aspire/HttpCommandRequestExportData.java ===== // HttpCommandRequestExportData.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** HttpCommandRequestExportData DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HttpCommandRequestExportData implements JsonSerializable { private String methodName; private Map headers; @@ -13846,15 +13966,13 @@ public Map toMap() { } } -// ===== HttpCommandResultMode.java ===== +// ===== aspire/HttpCommandResultMode.java ===== // HttpCommandResultMode.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** HttpCommandResultMode enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum HttpCommandResultMode implements WireValueEnum { NONE("None"), AUTO("Auto"), @@ -13877,15 +13995,16 @@ public static HttpCommandResultMode fromValue(String value) { } } -// ===== HttpsCertificateConfigurationCallbackAnnotationContext.java ===== +// ===== aspire/HttpsCertificateConfigurationCallbackAnnotationContext.java ===== // HttpsCertificateConfigurationCallbackAnnotationContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.HttpsCertificateConfigurationCallbackAnnotationContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HttpsCertificateConfigurationCallbackAnnotationContext extends HandleWrapperBase { HttpsCertificateConfigurationCallbackAnnotationContext(Handle handle, AspireClient client) { super(handle, client); @@ -13965,15 +14084,16 @@ public EnvironmentEditor environment() { } -// ===== HttpsCertificateExecutionConfigurationContext.java ===== +// ===== aspire/HttpsCertificateExecutionConfigurationContext.java ===== // HttpsCertificateExecutionConfigurationContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** HttpsCertificateExecutionConfigurationContext DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HttpsCertificateExecutionConfigurationContext implements JsonSerializable { private ReferenceExpression certificatePath; private ReferenceExpression keyPath; @@ -14013,15 +14133,16 @@ public Map toMap() { } } -// ===== HttpsCertificateExecutionConfigurationExportData.java ===== +// ===== aspire/HttpsCertificateExecutionConfigurationExportData.java ===== // HttpsCertificateExecutionConfigurationExportData.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** HttpsCertificateExecutionConfigurationExportData DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HttpsCertificateExecutionConfigurationExportData implements JsonSerializable { private String subject; private String thumbprint; @@ -14085,15 +14206,16 @@ public Map toMap() { } } -// ===== HttpsCertificateInfo.java ===== +// ===== aspire/HttpsCertificateInfo.java ===== // HttpsCertificateInfo.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** HttpsCertificateInfo DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HttpsCertificateInfo implements JsonSerializable { private String subject; private String issuer; @@ -14127,15 +14249,16 @@ public Map toMap() { } } -// ===== HttpsEndpointUpdateCallbackContext.java ===== +// ===== aspire/HttpsEndpointUpdateCallbackContext.java ===== // HttpsEndpointUpdateCallbackContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.HttpsEndpointUpdateCallbackContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class HttpsEndpointUpdateCallbackContext extends HandleWrapperBase { HttpsEndpointUpdateCallbackContext(Handle handle, AspireClient client) { super(handle, client); @@ -14175,15 +14298,16 @@ public CancellationToken cancellationToken() { } -// ===== IAspireStore.java ===== +// ===== aspire/IAspireStore.java ===== // IAspireStore.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IAspireStore. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IAspireStore extends HandleWrapperBase { IAspireStore(Handle handle, AspireClient client) { super(handle, client); @@ -14209,15 +14333,13 @@ public String getFileNameWithContent(String filenameTemplate, String sourceFilen } -// ===== IComputeEnvironmentResource.java ===== +// ===== aspire/IComputeEnvironmentResource.java ===== // IComputeEnvironmentResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IComputeEnvironmentResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IComputeEnvironmentResource extends ResourceBuilderBase { IComputeEnvironmentResource(Handle handle, AspireClient client) { super(handle, client); @@ -14225,15 +14347,13 @@ public class IComputeEnvironmentResource extends ResourceBuilderBase { } -// ===== IComputeResource.java ===== +// ===== aspire/IComputeResource.java ===== // IComputeResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IComputeResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IComputeResource extends HandleWrapperBase { IComputeResource(Handle handle, AspireClient client) { super(handle, client); @@ -14241,15 +14361,16 @@ public class IComputeResource extends HandleWrapperBase { } -// ===== IConfiguration.java ===== +// ===== aspire/IConfiguration.java ===== // IConfiguration.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Microsoft.Extensions.Configuration.Abstractions/Microsoft.Extensions.Configuration.IConfiguration. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IConfiguration extends HandleWrapperBase { IConfiguration(Handle handle, AspireClient client) { super(handle, client); @@ -14301,15 +14422,16 @@ public boolean exists(String key) { } -// ===== IConfigurationSection.java ===== +// ===== aspire/IConfigurationSection.java ===== // IConfigurationSection.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Microsoft.Extensions.Configuration.Abstractions/Microsoft.Extensions.Configuration.IConfigurationSection. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IConfigurationSection extends HandleWrapperBase { IConfigurationSection(Handle handle, AspireClient client) { super(handle, client); @@ -14350,15 +14472,13 @@ public IConfigurationSection setValue(String value) { } -// ===== IContainerFilesDestinationResource.java ===== +// ===== aspire/IContainerFilesDestinationResource.java ===== // IContainerFilesDestinationResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IContainerFilesDestinationResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IContainerFilesDestinationResource extends HandleWrapperBase { IContainerFilesDestinationResource(Handle handle, AspireClient client) { super(handle, client); @@ -14366,15 +14486,16 @@ public class IContainerFilesDestinationResource extends HandleWrapperBase { } -// ===== IDistributedApplicationBuilder.java ===== +// ===== aspire/IDistributedApplicationBuilder.java ===== // IDistributedApplicationBuilder.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IDistributedApplicationBuilder extends HandleWrapperBase { IDistributedApplicationBuilder(Handle handle, AspireClient client) { super(handle, client); @@ -14864,7 +14985,7 @@ public TestRedisResource addTestRedis(String name) { } /** Adds a test Redis resource from ATS documentation. */ - public TestRedisResource addTestRedis(String name, Double port) { + public TestRedisResource addTestRedis(String name, Number port) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("name", AspireClient.serializeValue(name)); @@ -14886,15 +15007,13 @@ public TestVaultResource addTestVault(String name) { } -// ===== IDistributedApplicationEvent.java ===== +// ===== aspire/IDistributedApplicationEvent.java ===== // IDistributedApplicationEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IDistributedApplicationEvent extends HandleWrapperBase { IDistributedApplicationEvent(Handle handle, AspireClient client) { super(handle, client); @@ -14902,15 +15021,16 @@ public class IDistributedApplicationEvent extends HandleWrapperBase { } -// ===== IDistributedApplicationEventing.java ===== +// ===== aspire/IDistributedApplicationEventing.java ===== // IDistributedApplicationEventing.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEventing. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IDistributedApplicationEventing extends HandleWrapperBase { IDistributedApplicationEventing(Handle handle, AspireClient client) { super(handle, client); @@ -14926,15 +15046,16 @@ public void unsubscribe(DistributedApplicationEventSubscription subscription) { } -// ===== IDistributedApplicationPipeline.java ===== +// ===== aspire/IDistributedApplicationPipeline.java ===== // IDistributedApplicationPipeline.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Pipelines.IDistributedApplicationPipeline. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IDistributedApplicationPipeline extends HandleWrapperBase { IDistributedApplicationPipeline(Handle handle, AspireClient client) { super(handle, client); @@ -14998,15 +15119,13 @@ public void configure(AspireAction1 callback) { } -// ===== IDistributedApplicationResourceEvent.java ===== +// ===== aspire/IDistributedApplicationResourceEvent.java ===== // IDistributedApplicationResourceEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Wrapper for Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationResourceEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IDistributedApplicationResourceEvent extends HandleWrapperBase { IDistributedApplicationResourceEvent(Handle handle, AspireClient client) { super(handle, client); @@ -15014,15 +15133,16 @@ public class IDistributedApplicationResourceEvent extends HandleWrapperBase { } -// ===== IExecutionConfigurationBuilder.java ===== +// ===== aspire/IExecutionConfigurationBuilder.java ===== // IExecutionConfigurationBuilder.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.IExecutionConfigurationBuilder. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class IExecutionConfigurationBuilder extends HandleWrapperBase { IExecutionConfigurationBuilder(Handle handle, AspireClient client) { super(handle, client); @@ -15102,15 +15222,16 @@ public IExecutionConfigurationBuilder withCertificateTrustConfig(AspireFunc1 toMap() { } } -// ===== InputType.java ===== +// ===== aspire/InputType.java ===== // InputType.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** InputType enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum InputType implements WireValueEnum { TEXT("Text"), SECRET_TEXT("SecretText"), @@ -16362,15 +16470,16 @@ public static InputType fromValue(String value) { } } -// ===== InputsDialogValidationContext.java ===== +// ===== aspire/InputsDialogValidationContext.java ===== // InputsDialogValidationContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.InputsDialogValidationContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class InputsDialogValidationContext extends HandleWrapperBase { InputsDialogValidationContext(Handle handle, AspireClient client) { super(handle, client); @@ -16411,15 +16520,16 @@ public void addValidationError(String inputName, String errorMessage) { } -// ===== InputsInteractionResult.java ===== +// ===== aspire/InputsInteractionResult.java ===== // InputsInteractionResult.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Ats.InputsInteractionResult. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class InputsInteractionResult extends HandleWrapperBase { InputsInteractionResult(Handle handle, AspireClient client) { super(handle, client); @@ -16443,15 +16553,16 @@ public InteractionInputCollection inputs() { } -// ===== InteractionChoiceOption.java ===== +// ===== aspire/InteractionChoiceOption.java ===== // InteractionChoiceOption.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** InteractionChoiceOption DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class InteractionChoiceOption implements JsonSerializable { private String value; private String label; @@ -16479,15 +16590,16 @@ public Map toMap() { } } -// ===== InteractionInput.java ===== +// ===== aspire/InteractionInput.java ===== // InteractionInput.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** InteractionInput DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class InteractionInput implements JsonSerializable { private String name; private String label; @@ -16500,10 +16612,10 @@ public class InteractionInput implements JsonSerializable { private String placeholder; private Boolean allowCustomChoice; private boolean disabled; - private Double maxLength; + private Number maxLength; private Boolean allowMultipleFiles; private String fileFilter; - private Double maxFileSize; + private Number maxFileSize; public String getName() { return name; } public void setName(String value) { this.name = value; } @@ -16527,14 +16639,14 @@ public class InteractionInput implements JsonSerializable { public void setAllowCustomChoice(Boolean value) { this.allowCustomChoice = value; } public boolean getDisabled() { return disabled; } public void setDisabled(boolean value) { this.disabled = value; } - public Double getMaxLength() { return maxLength; } - public void setMaxLength(Double value) { this.maxLength = value; } + public Number getMaxLength() { return maxLength; } + public void setMaxLength(Number value) { this.maxLength = value; } public Boolean getAllowMultipleFiles() { return allowMultipleFiles; } public void setAllowMultipleFiles(Boolean value) { this.allowMultipleFiles = value; } public String getFileFilter() { return fileFilter; } public void setFileFilter(String value) { this.fileFilter = value; } - public Double getMaxFileSize() { return maxFileSize; } - public void setMaxFileSize(Double value) { this.maxFileSize = value; } + public Number getMaxFileSize() { return maxFileSize; } + public void setMaxFileSize(Number value) { this.maxFileSize = value; } @SuppressWarnings("unchecked") public static InteractionInput fromMap(Map map) { @@ -16593,15 +16705,16 @@ public Map toMap() { } } -// ===== InteractionInputBuilder.java ===== +// ===== aspire/InteractionInputBuilder.java ===== // InteractionInputBuilder.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Ats.InteractionInputBuilder. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class InteractionInputBuilder extends HandleWrapperBase { InteractionInputBuilder(Handle handle, AspireClient client) { super(handle, client); @@ -16650,15 +16763,16 @@ public InteractionInputBuilder withDynamicLoading(AspireAction1 toMap() { } } -// ===== InteractionLoadingInput.java ===== +// ===== aspire/InteractionLoadingInput.java ===== // InteractionLoadingInput.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Ats.InteractionLoadingInput. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class InteractionLoadingInput extends HandleWrapperBase { InteractionLoadingInput(Handle handle, AspireClient client) { super(handle, client); @@ -16838,15 +16956,16 @@ public void setValue(String value) { } -// ===== InteractionMessageBoxOptions.java ===== +// ===== aspire/InteractionMessageBoxOptions.java ===== // InteractionMessageBoxOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** InteractionMessageBoxOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class InteractionMessageBoxOptions implements JsonSerializable { private String primaryButtonText; private String secondaryButtonText; @@ -16898,15 +17017,16 @@ public Map toMap() { } } -// ===== InteractionNotificationOptions.java ===== +// ===== aspire/InteractionNotificationOptions.java ===== // InteractionNotificationOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** InteractionNotificationOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class InteractionNotificationOptions implements JsonSerializable { private String primaryButtonText; private String secondaryButtonText; @@ -16970,15 +17090,17 @@ public Map toMap() { } } -// ===== InteractionProgressOptions.java ===== +// ===== aspire/InteractionProgressOptions.java ===== // InteractionProgressOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; /** InteractionProgressOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class InteractionProgressOptions implements JsonSerializable { private String title; private String primaryButtonText; @@ -17020,32 +17142,28 @@ public Map toMap() { } } -// ===== JsonSerializable.java ===== +// ===== aspire/JsonSerializable.java ===== // JsonSerializable.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.io.*; -import java.net.*; -import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.concurrent.*; -import java.util.concurrent.atomic.*; -import java.util.function.*; +import java.util.Map; +@SuppressWarnings({"all", "unchecked", "serial"}) public interface JsonSerializable { Map toMap(); } -// ===== LogFacade.java ===== +// ===== aspire/LogFacade.java ===== // LogFacade.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.LogFacade. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class LogFacade extends HandleWrapperBase { LogFacade(Handle handle, AspireClient client) { super(handle, client); @@ -17085,15 +17203,13 @@ public void debug(String message) { } -// ===== MessageIntent.java ===== +// ===== aspire/MessageIntent.java ===== // MessageIntent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** MessageIntent enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum MessageIntent implements WireValueEnum { NONE("None"), SUCCESS("Success"), @@ -17118,15 +17234,13 @@ public static MessageIntent fromValue(String value) { } } -// ===== OtlpProtocol.java ===== +// ===== aspire/OtlpProtocol.java ===== // OtlpProtocol.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** OtlpProtocol enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum OtlpProtocol implements WireValueEnum { GRPC("Grpc"), HTTP_PROTOBUF("HttpProtobuf"), @@ -17148,15 +17262,16 @@ public static OtlpProtocol fromValue(String value) { } } -// ===== ParameterCustomInputOptions.java ===== +// ===== aspire/ParameterCustomInputOptions.java ===== // ParameterCustomInputOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** ParameterCustomInputOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ParameterCustomInputOptions implements JsonSerializable { private InputType inputType; private String label; @@ -17167,7 +17282,7 @@ public class ParameterCustomInputOptions implements JsonSerializable { private String placeholder; private Boolean allowCustomChoice; private Boolean disabled; - private Double maxLength; + private Number maxLength; public InputType getInputType() { return inputType; } public void setInputType(InputType value) { this.inputType = value; } @@ -17187,8 +17302,8 @@ public class ParameterCustomInputOptions implements JsonSerializable { public void setAllowCustomChoice(Boolean value) { this.allowCustomChoice = value; } public Boolean getDisabled() { return disabled; } public void setDisabled(Boolean value) { this.disabled = value; } - public Double getMaxLength() { return maxLength; } - public void setMaxLength(Double value) { this.maxLength = value; } + public Number getMaxLength() { return maxLength; } + public void setMaxLength(Number value) { this.maxLength = value; } @SuppressWarnings("unchecked") public static ParameterCustomInputOptions fromMap(Map map) { @@ -17232,15 +17347,16 @@ public Map toMap() { } } -// ===== ParameterResource.java ===== +// ===== aspire/ParameterResource.java ===== // ParameterResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ParameterResource extends ResourceBuilderBase { ParameterResource(Handle handle, AspireClient client) { super(handle, client); @@ -17627,7 +17743,7 @@ public ParameterResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private ParameterResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private ParameterResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -18040,7 +18156,7 @@ public ParameterResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private ParameterResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private ParameterResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -18066,7 +18182,7 @@ public ParameterResource withMergeLoggingPath(String logLevel, String logPath) { } /** Configures resource logging with file path */ - private ParameterResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private ParameterResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -18108,15 +18224,16 @@ public ParameterResource withMergeRouteMiddleware(String path, String method, St } -// ===== PipelineConfigurationContext.java ===== +// ===== aspire/PipelineConfigurationContext.java ===== // PipelineConfigurationContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Pipelines.PipelineConfigurationContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class PipelineConfigurationContext extends HandleWrapperBase { PipelineConfigurationContext(Handle handle, AspireClient client) { super(handle, client); @@ -18149,15 +18266,16 @@ public PipelineStep[] getSteps(String tag) { } -// ===== PipelineContext.java ===== +// ===== aspire/PipelineContext.java ===== // PipelineContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Pipelines.PipelineContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class PipelineContext extends HandleWrapperBase { PipelineContext(Handle handle, AspireClient client) { super(handle, client); @@ -18224,15 +18342,16 @@ public PipelineSummary summary() { } -// ===== PipelineEditor.java ===== +// ===== aspire/PipelineEditor.java ===== // PipelineEditor.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Pipelines.PipelineEditor. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class PipelineEditor extends HandleWrapperBase { PipelineEditor(Handle handle, AspireClient client) { super(handle, client); @@ -18257,15 +18376,16 @@ public PipelineStep[] stepsByTag(String tag) { } -// ===== PipelineStep.java ===== +// ===== aspire/PipelineStep.java ===== // PipelineStep.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Pipelines.PipelineStep. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class PipelineStep extends HandleWrapperBase { PipelineStep(Handle handle, AspireClient client) { super(handle, client); @@ -18340,15 +18460,16 @@ public void addTag(String tag) { } -// ===== PipelineStepContext.java ===== +// ===== aspire/PipelineStepContext.java ===== // PipelineStepContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Pipelines.PipelineStepContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class PipelineStepContext extends HandleWrapperBase { PipelineStepContext(Handle handle, AspireClient client) { super(handle, client); @@ -18420,15 +18541,16 @@ public PipelineSummary summary() { } -// ===== PipelineStepFactoryContext.java ===== +// ===== aspire/PipelineStepFactoryContext.java ===== // PipelineStepFactoryContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Pipelines.PipelineStepFactoryContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class PipelineStepFactoryContext extends HandleWrapperBase { PipelineStepFactoryContext(Handle handle, AspireClient client) { super(handle, client); @@ -18452,15 +18574,16 @@ public IResource resource() { } -// ===== PipelineSummary.java ===== +// ===== aspire/PipelineSummary.java ===== // PipelineSummary.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.Pipelines.PipelineSummary. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class PipelineSummary extends HandleWrapperBase { PipelineSummary(Handle handle, AspireClient client) { super(handle, client); @@ -18486,15 +18609,13 @@ public void addMarkdown(String key, String markdownString) { } -// ===== ProbeType.java ===== +// ===== aspire/ProbeType.java ===== // ProbeType.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** ProbeType enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum ProbeType implements WireValueEnum { STARTUP("Startup"), READINESS("Readiness"), @@ -18516,15 +18637,17 @@ public static ProbeType fromValue(String value) { } } -// ===== ProcessCommandExportOptions.java ===== +// ===== aspire/ProcessCommandExportOptions.java ===== // ProcessCommandExportOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; /** ProcessCommandExportOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ProcessCommandExportOptions implements JsonSerializable { private String executablePath; private String[] arguments; @@ -18535,7 +18658,7 @@ public class ProcessCommandExportOptions implements JsonSerializable { private Boolean killEntireProcessTree; private AspireFunc1 createProcessSpec; private CommandOptions commandOptions; - private Double maxOutputLineCount; + private Number maxOutputLineCount; private Boolean displayImmediately; private double[] successExitCodes; @@ -18557,8 +18680,8 @@ public class ProcessCommandExportOptions implements JsonSerializable { public void setCreateProcessSpec(AspireFunc1 value) { this.createProcessSpec = value; } public CommandOptions getCommandOptions() { return commandOptions; } public void setCommandOptions(CommandOptions value) { this.commandOptions = value; } - public Double getMaxOutputLineCount() { return maxOutputLineCount; } - public void setMaxOutputLineCount(Double value) { this.maxOutputLineCount = value; } + public Number getMaxOutputLineCount() { return maxOutputLineCount; } + public void setMaxOutputLineCount(Number value) { this.maxOutputLineCount = value; } public Boolean getDisplayImmediately() { return displayImmediately; } public void setDisplayImmediately(Boolean value) { this.displayImmediately = value; } public double[] getSuccessExitCodes() { return successExitCodes; } @@ -18613,25 +18736,26 @@ public Map toMap() { } } -// ===== ProcessCommandResultExportOptions.java ===== +// ===== aspire/ProcessCommandResultExportOptions.java ===== // ProcessCommandResultExportOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** ProcessCommandResultExportOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ProcessCommandResultExportOptions implements JsonSerializable { private CommandOptions commandOptions; - private Double maxOutputLineCount; + private Number maxOutputLineCount; private Boolean displayImmediately; private double[] successExitCodes; public CommandOptions getCommandOptions() { return commandOptions; } public void setCommandOptions(CommandOptions value) { this.commandOptions = value; } - public Double getMaxOutputLineCount() { return maxOutputLineCount; } - public void setMaxOutputLineCount(Double value) { this.maxOutputLineCount = value; } + public Number getMaxOutputLineCount() { return maxOutputLineCount; } + public void setMaxOutputLineCount(Number value) { this.maxOutputLineCount = value; } public Boolean getDisplayImmediately() { return displayImmediately; } public void setDisplayImmediately(Boolean value) { this.displayImmediately = value; } public double[] getSuccessExitCodes() { return successExitCodes; } @@ -18661,15 +18785,16 @@ public Map toMap() { } } -// ===== ProcessCommandSpecExportData.java ===== +// ===== aspire/ProcessCommandSpecExportData.java ===== // ProcessCommandSpecExportData.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** ProcessCommandSpecExportData DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ProcessCommandSpecExportData implements JsonSerializable { private String executablePath; private String[] arguments; @@ -18727,15 +18852,16 @@ public Map toMap() { } } -// ===== ProgressContext.java ===== +// ===== aspire/ProgressContext.java ===== // ProgressContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ProgressContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ProgressContext extends HandleWrapperBase { ProgressContext(Handle handle, AspireClient client) { super(handle, client); @@ -18751,15 +18877,17 @@ public CancellationToken cancellationToken() { } -// ===== ProjectResource.java ===== +// ===== aspire/ProjectResource.java ===== // ProjectResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ProjectResource extends ResourceBuilderBase { ProjectResource(Handle handle, AspireClient client) { super(handle, client); @@ -19065,6 +19193,10 @@ public ProjectResource withReference(IResource source) { return withReference(AspireUnion.of(source)); } + public ProjectResource withReference(ResourceBuilderBase source) { + return withReference(new IResource(source.getHandle(), source.getClient())); + } + public ProjectResource withReference(EndpointReference source, WithReferenceOptions options) { return withReference(AspireUnion.of(source), options); } @@ -19219,7 +19351,7 @@ public ProjectResource withEndpoint() { } /** Adds a network endpoint */ - private ProjectResource withEndpointImpl(Double port, Double targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { + private ProjectResource withEndpointImpl(Number port, Number targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -19274,7 +19406,7 @@ public ProjectResource withHttpEndpoint() { } /** Adds an HTTP endpoint */ - private ProjectResource withHttpEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private ProjectResource withHttpEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -19311,7 +19443,7 @@ public ProjectResource withHttpsEndpoint() { } /** Adds an HTTPS endpoint */ - private ProjectResource withHttpsEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private ProjectResource withHttpsEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -19502,7 +19634,7 @@ public ProjectResource waitForCompletion(ResourceBuilderBase dependency) { } /** Waits for the dependency resource to enter the Exited or Finished state before starting the resource. */ - public ProjectResource waitForCompletion(IResource dependency, Double exitCode) { + public ProjectResource waitForCompletion(IResource dependency, Number exitCode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("dependency", AspireClient.serializeValue(dependency)); @@ -19513,7 +19645,7 @@ public ProjectResource waitForCompletion(IResource dependency, Double exitCode) return this; } - public ProjectResource waitForCompletion(ResourceBuilderBase dependency, Double exitCode) { + public ProjectResource waitForCompletion(ResourceBuilderBase dependency, Number exitCode) { return waitForCompletion(new IResource(dependency.getHandle(), dependency.getClient()), exitCode); } @@ -19539,7 +19671,7 @@ public ProjectResource withHttpHealthCheck() { } /** Adds a health check to the resource which is mapped to a specific endpoint. */ - private ProjectResource withHttpHealthCheckImpl(String path, Double statusCode, String endpointName) { + private ProjectResource withHttpHealthCheckImpl(String path, Number statusCode, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (path != null) { @@ -19790,7 +19922,7 @@ public ProjectResource withHttpProbe(ProbeType probeType) { } /** Adds an HTTP health probe to the resource */ - private ProjectResource withHttpProbeImpl(ProbeType probeType, String path, Double initialDelaySeconds, Double periodSeconds, Double timeoutSeconds, Double failureThreshold, Double successThreshold, String endpointName) { + private ProjectResource withHttpProbeImpl(ProbeType probeType, String path, Number initialDelaySeconds, Number periodSeconds, Number timeoutSeconds, Number failureThreshold, Number successThreshold, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("probeType", AspireClient.serializeValue(probeType)); @@ -19847,7 +19979,7 @@ public ProjectResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private ProjectResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private ProjectResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -20344,7 +20476,7 @@ public ProjectResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private ProjectResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private ProjectResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -20370,7 +20502,7 @@ public ProjectResource withMergeLoggingPath(String logLevel, String logPath) { } /** Configures resource logging with file path */ - private ProjectResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private ProjectResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -20412,15 +20544,16 @@ public ProjectResource withMergeRouteMiddleware(String path, String method, Stri } -// ===== ProjectResourceOptions.java ===== +// ===== aspire/ProjectResourceOptions.java ===== // ProjectResourceOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ProjectResourceOptions. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ProjectResourceOptions extends HandleWrapperBase { ProjectResourceOptions(Handle handle, AspireClient client) { super(handle, client); @@ -20479,15 +20612,13 @@ public ProjectResourceOptions setExcludeKestrelEndpoints(boolean value) { } -// ===== PromptConfirmationOptions.java ===== +// ===== aspire/PromptConfirmationOptions.java ===== // PromptConfirmationOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for PromptConfirmation. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class PromptConfirmationOptions { private InteractionMessageBoxOptions options; private CancellationToken cancellationToken; @@ -20506,15 +20637,13 @@ public PromptConfirmationOptions cancellationToken(CancellationToken value) { } -// ===== PromptInputOptions.java ===== +// ===== aspire/PromptInputOptions.java ===== // PromptInputOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for PromptInput. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class PromptInputOptions { private InteractionInputsDialogOptions options; private CancellationToken cancellationToken; @@ -20533,15 +20662,13 @@ public PromptInputOptions cancellationToken(CancellationToken value) { } -// ===== PromptInputsOptions.java ===== +// ===== aspire/PromptInputsOptions.java ===== // PromptInputsOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for PromptInputs. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class PromptInputsOptions { private InteractionInputsDialogOptions options; private CancellationToken cancellationToken; @@ -20560,15 +20687,13 @@ public PromptInputsOptions cancellationToken(CancellationToken value) { } -// ===== PromptMessageBoxOptions.java ===== +// ===== aspire/PromptMessageBoxOptions.java ===== // PromptMessageBoxOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for PromptMessageBox. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class PromptMessageBoxOptions { private InteractionMessageBoxOptions options; private CancellationToken cancellationToken; @@ -20587,15 +20712,13 @@ public PromptMessageBoxOptions cancellationToken(CancellationToken value) { } -// ===== PromptNotificationOptions.java ===== +// ===== aspire/PromptNotificationOptions.java ===== // PromptNotificationOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for PromptNotification. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class PromptNotificationOptions { private InteractionNotificationOptions options; private CancellationToken cancellationToken; @@ -20614,15 +20737,13 @@ public PromptNotificationOptions cancellationToken(CancellationToken value) { } -// ===== PromptProgressOptions.java ===== +// ===== aspire/PromptProgressOptions.java ===== // PromptProgressOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for PromptProgress. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class PromptProgressOptions { private InteractionProgressOptions options; private CancellationToken cancellationToken; @@ -20641,15 +20762,13 @@ public PromptProgressOptions cancellationToken(CancellationToken value) { } -// ===== ProtocolType.java ===== +// ===== aspire/ProtocolType.java ===== // ProtocolType.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** ProtocolType enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum ProtocolType implements WireValueEnum { IP("IP"), IPV6_HOP_BY_HOP_OPTIONS("IPv6HopByHopOptions"), @@ -20693,15 +20812,13 @@ public static ProtocolType fromValue(String value) { } } -// ===== PublishResourceUpdateOptions.java ===== +// ===== aspire/PublishResourceUpdateOptions.java ===== // PublishResourceUpdateOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for PublishResourceUpdate. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class PublishResourceUpdateOptions { private String state; private String stateStyle; @@ -20720,15 +20837,16 @@ public PublishResourceUpdateOptions stateStyle(String value) { } -// ===== ReferenceEnvironmentInjectionOptions.java ===== +// ===== aspire/ReferenceEnvironmentInjectionOptions.java ===== // ReferenceEnvironmentInjectionOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** ReferenceEnvironmentInjectionOptions DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ReferenceEnvironmentInjectionOptions implements JsonSerializable { private boolean connectionString; private boolean connectionProperties; @@ -20768,17 +20886,21 @@ public Map toMap() { } } -// ===== ReferenceExpression.java ===== +// ===== aspire/ReferenceExpression.java ===== // ReferenceExpression.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * ReferenceExpression represents a reference expression. * Supports value mode (format + value providers), conditional mode, and handle mode. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ReferenceExpression { private final String format; private final Object[] valueProviders; @@ -20896,15 +21018,16 @@ private static Object extractValueProvider(Object value) { } } -// ===== ReferenceExpressionBuilder.java ===== +// ===== aspire/ReferenceExpressionBuilder.java ===== // ReferenceExpressionBuilder.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpressionBuilder. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ReferenceExpressionBuilder extends HandleWrapperBase { ReferenceExpressionBuilder(Handle handle, AspireClient client) { super(handle, client); @@ -20966,15 +21089,16 @@ public ReferenceExpression build() { } -// ===== RequiredCommandValidationContext.java ===== +// ===== aspire/RequiredCommandValidationContext.java ===== // RequiredCommandValidationContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.RequiredCommandValidationContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class RequiredCommandValidationContext extends HandleWrapperBase { RequiredCommandValidationContext(Handle handle, AspireClient client) { super(handle, client); @@ -21023,15 +21147,16 @@ public RequiredCommandValidationResult failure(String validationMessage) { } -// ===== RequiredCommandValidationResult.java ===== +// ===== aspire/RequiredCommandValidationResult.java ===== // RequiredCommandValidationResult.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.RequiredCommandValidationResult. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class RequiredCommandValidationResult extends HandleWrapperBase { RequiredCommandValidationResult(Handle handle, AspireClient client) { super(handle, client); @@ -21055,31 +21180,31 @@ public String validationMessage() { } -// ===== ResourceBuilderBase.java ===== +// ===== aspire/ResourceBuilderBase.java ===== // ResourceBuilderBase.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - /** * ResourceBuilderBase extends HandleWrapperBase for resource builders. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceBuilderBase extends HandleWrapperBase { ResourceBuilderBase(Handle handle, AspireClient client) { super(handle, client); } } -// ===== ResourceCommandService.java ===== +// ===== aspire/ResourceCommandService.java ===== // ResourceCommandService.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceCommandService. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceCommandService extends HandleWrapperBase { ResourceCommandService(Handle handle, AspireClient client) { super(handle, client); @@ -21105,6 +21230,10 @@ public ExecuteCommandResult executeCommandAsync(IResource resource, String comma return executeCommandAsync(AspireUnion.of(resource), commandName); } + public ExecuteCommandResult executeCommandAsync(ResourceBuilderBase resource, String commandName) { + return executeCommandAsync(new IResource(resource.getHandle(), resource.getClient()), commandName); + } + /** Executes a command for the specified resource. */ public ExecuteCommandResult executeCommandAsync(AspireUnion resource, String commandName, ExecuteCommandAsyncOptions optionsBag) { var arguments = optionsBag == null ? null : optionsBag.getArguments(); @@ -21134,15 +21263,13 @@ private ExecuteCommandResult executeCommandAsyncImpl(AspireUnion resource, Strin } -// ===== ResourceCommandState.java ===== +// ===== aspire/ResourceCommandState.java ===== // ResourceCommandState.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** ResourceCommandState enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum ResourceCommandState implements WireValueEnum { ENABLED("Enabled"), DISABLED("Disabled"), @@ -21164,15 +21291,13 @@ public static ResourceCommandState fromValue(String value) { } } -// ===== ResourceCommandVisibility.java ===== +// ===== aspire/ResourceCommandVisibility.java ===== // ResourceCommandVisibility.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** ResourceCommandVisibility enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum ResourceCommandVisibility implements WireValueEnum { NONE("None"), UI("UI"), @@ -21194,15 +21319,16 @@ public static ResourceCommandVisibility fromValue(String value) { } } -// ===== ResourceEndpointsAllocatedEvent.java ===== +// ===== aspire/ResourceEndpointsAllocatedEvent.java ===== // ResourceEndpointsAllocatedEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceEndpointsAllocatedEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceEndpointsAllocatedEvent extends HandleWrapperBase { ResourceEndpointsAllocatedEvent(Handle handle, AspireClient client) { super(handle, client); @@ -21226,22 +21352,23 @@ public IServiceProvider services() { } -// ===== ResourceEventDto.java ===== +// ===== aspire/ResourceEventDto.java ===== // ResourceEventDto.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** ResourceEventDto DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceEventDto implements JsonSerializable { private String resourceName; private String resourceId; private String state; private String stateStyle; private String healthStatus; - private Double exitCode; + private Number exitCode; public String getResourceName() { return resourceName; } public void setResourceName(String value) { this.resourceName = value; } @@ -21253,8 +21380,8 @@ public class ResourceEventDto implements JsonSerializable { public void setStateStyle(String value) { this.stateStyle = value; } public String getHealthStatus() { return healthStatus; } public void setHealthStatus(String value) { this.healthStatus = value; } - public Double getExitCode() { return exitCode; } - public void setExitCode(Double value) { this.exitCode = value; } + public Number getExitCode() { return exitCode; } + public void setExitCode(Number value) { this.exitCode = value; } @SuppressWarnings("unchecked") public static ResourceEventDto fromMap(Map map) { @@ -21286,15 +21413,16 @@ public Map toMap() { } } -// ===== ResourceLoggerService.java ===== +// ===== aspire/ResourceLoggerService.java ===== // ResourceLoggerService.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceLoggerService. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceLoggerService extends HandleWrapperBase { ResourceLoggerService(Handle handle, AspireClient client) { super(handle, client); @@ -21322,15 +21450,16 @@ public void completeLogByName(String resourceName) { } -// ===== ResourceNotificationService.java ===== +// ===== aspire/ResourceNotificationService.java ===== // ResourceNotificationService.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceNotificationService. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceNotificationService extends HandleWrapperBase { ResourceNotificationService(Handle handle, AspireClient client) { super(handle, client); @@ -21426,15 +21555,16 @@ private void publishResourceUpdateImpl(IResource resource, String state, String } -// ===== ResourceReadyEvent.java ===== +// ===== aspire/ResourceReadyEvent.java ===== // ResourceReadyEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceReadyEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceReadyEvent extends HandleWrapperBase { ResourceReadyEvent(Handle handle, AspireClient client) { super(handle, client); @@ -21458,15 +21588,16 @@ public IServiceProvider services() { } -// ===== ResourceStoppedEvent.java ===== +// ===== aspire/ResourceStoppedEvent.java ===== // ResourceStoppedEvent.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceStoppedEvent. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceStoppedEvent extends HandleWrapperBase { ResourceStoppedEvent(Handle handle, AspireClient client) { super(handle, client); @@ -21490,15 +21621,16 @@ public IServiceProvider services() { } -// ===== ResourceUrlAnnotation.java ===== +// ===== aspire/ResourceUrlAnnotation.java ===== // ResourceUrlAnnotation.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** ResourceUrlAnnotation DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceUrlAnnotation implements JsonSerializable { private String url; private String displayText; @@ -21538,15 +21670,16 @@ public Map toMap() { } } -// ===== ResourceUrlsCallbackContext.java ===== +// ===== aspire/ResourceUrlsCallbackContext.java ===== // ResourceUrlsCallbackContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceUrlsCallbackContext extends HandleWrapperBase { ResourceUrlsCallbackContext(Handle handle, AspireClient client) { super(handle, client); @@ -21595,15 +21728,16 @@ public EndpointReference getEndpoint(String name) { } -// ===== ResourceUrlsEditor.java ===== +// ===== aspire/ResourceUrlsEditor.java ===== // ResourceUrlsEditor.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsEditor. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class ResourceUrlsEditor extends HandleWrapperBase { ResourceUrlsEditor(Handle handle, AspireClient client) { super(handle, client); @@ -21666,15 +21800,16 @@ public void addForEndpoint(EndpointReference endpoint, AspireUnion url, String d } -// ===== RunConfiguration.java ===== +// ===== aspire/RunConfiguration.java ===== // RunConfiguration.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** RunConfiguration DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class RunConfiguration implements JsonSerializable { private Boolean watchEnabled; @@ -21696,15 +21831,16 @@ public Map toMap() { } } -// ===== TestCallbackContext.java ===== +// ===== aspire/TestCallbackContext.java ===== // TestCallbackContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCallbackContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestCallbackContext extends HandleWrapperBase { TestCallbackContext(Handle handle, AspireClient client) { super(handle, client); @@ -21765,15 +21901,15 @@ public TestCallbackContext setCancellationToken(CancellationToken value) { } -// ===== TestCollectionContext.java ===== +// ===== aspire/TestCollectionContext.java ===== // TestCollectionContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.List; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCollectionContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestCollectionContext extends HandleWrapperBase { TestCollectionContext(Handle handle, AspireClient client) { super(handle, client); @@ -21799,15 +21935,16 @@ public AspireDict metadata() { } -// ===== TestConfigDto.java ===== +// ===== aspire/TestConfigDto.java ===== // TestConfigDto.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** TestConfigDto DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestConfigDto implements JsonSerializable { private String name; private double port; @@ -21847,14 +21984,12 @@ public Map toMap() { } } -// ===== TestConfigs.java ===== +// ===== aspire/TestConfigs.java ===== // TestConfigs.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) public final class TestConfigs { private TestConfigs() { } @@ -21873,15 +22008,17 @@ private Profiles() { } } -// ===== TestDatabaseResource.java ===== +// ===== aspire/TestDatabaseResource.java ===== // TestDatabaseResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestDatabaseResource extends ContainerResource { TestDatabaseResource(Handle handle, AspireClient client) { super(handle, client); @@ -22451,6 +22588,10 @@ public TestDatabaseResource withReference(IResource source) { return withReference(AspireUnion.of(source)); } + public TestDatabaseResource withReference(ResourceBuilderBase source) { + return withReference(new IResource(source.getHandle(), source.getClient())); + } + public TestDatabaseResource withReference(EndpointReference source, WithReferenceOptions options) { return withReference(AspireUnion.of(source), options); } @@ -22605,7 +22746,7 @@ public TestDatabaseResource withEndpoint() { } /** Adds a network endpoint */ - private TestDatabaseResource withEndpointImpl(Double port, Double targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { + private TestDatabaseResource withEndpointImpl(Number port, Number targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -22660,7 +22801,7 @@ public TestDatabaseResource withHttpEndpoint() { } /** Adds an HTTP endpoint */ - private TestDatabaseResource withHttpEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private TestDatabaseResource withHttpEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -22697,7 +22838,7 @@ public TestDatabaseResource withHttpsEndpoint() { } /** Adds an HTTPS endpoint */ - private TestDatabaseResource withHttpsEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private TestDatabaseResource withHttpsEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -22874,7 +23015,7 @@ public TestDatabaseResource waitForCompletion(ResourceBuilderBase dependency) { } /** Waits for the dependency resource to enter the Exited or Finished state before starting the resource. */ - public TestDatabaseResource waitForCompletion(IResource dependency, Double exitCode) { + public TestDatabaseResource waitForCompletion(IResource dependency, Number exitCode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("dependency", AspireClient.serializeValue(dependency)); @@ -22885,7 +23026,7 @@ public TestDatabaseResource waitForCompletion(IResource dependency, Double exitC return this; } - public TestDatabaseResource waitForCompletion(ResourceBuilderBase dependency, Double exitCode) { + public TestDatabaseResource waitForCompletion(ResourceBuilderBase dependency, Number exitCode) { return waitForCompletion(new IResource(dependency.getHandle(), dependency.getClient()), exitCode); } @@ -22911,7 +23052,7 @@ public TestDatabaseResource withHttpHealthCheck() { } /** Adds a health check to the resource which is mapped to a specific endpoint. */ - private TestDatabaseResource withHttpHealthCheckImpl(String path, Double statusCode, String endpointName) { + private TestDatabaseResource withHttpHealthCheckImpl(String path, Number statusCode, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (path != null) { @@ -23162,7 +23303,7 @@ public TestDatabaseResource withHttpProbe(ProbeType probeType) { } /** Adds an HTTP health probe to the resource */ - private TestDatabaseResource withHttpProbeImpl(ProbeType probeType, String path, Double initialDelaySeconds, Double periodSeconds, Double timeoutSeconds, Double failureThreshold, Double successThreshold, String endpointName) { + private TestDatabaseResource withHttpProbeImpl(ProbeType probeType, String path, Number initialDelaySeconds, Number periodSeconds, Number timeoutSeconds, Number failureThreshold, Number successThreshold, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("probeType", AspireClient.serializeValue(probeType)); @@ -23219,7 +23360,7 @@ public TestDatabaseResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private TestDatabaseResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private TestDatabaseResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -23733,7 +23874,7 @@ public TestDatabaseResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private TestDatabaseResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private TestDatabaseResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -23759,7 +23900,7 @@ public TestDatabaseResource withMergeLoggingPath(String logLevel, String logPath } /** Configures resource logging with file path */ - private TestDatabaseResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private TestDatabaseResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -23801,15 +23942,17 @@ public TestDatabaseResource withMergeRouteMiddleware(String path, String method, } -// ===== TestDeeplyNestedDto.java ===== +// ===== aspire/TestDeeplyNestedDto.java ===== // TestDeeplyNestedDto.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** TestDeeplyNestedDto DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestDeeplyNestedDto implements JsonSerializable { private Map> nestedData; private Map[] metadataArray; @@ -23825,7 +23968,7 @@ public static TestDeeplyNestedDto fromMap(Map map) { var nestedDataValue = map.get("NestedData"); value.setNestedData((Map>) nestedDataValue); var metadataArrayValue = map.get("MetadataArray"); - value.setMetadataArray((AspireDict[]) metadataArrayValue); + value.setMetadataArray((Map[]) metadataArrayValue); return value; } @@ -23837,15 +23980,16 @@ public Map toMap() { } } -// ===== TestEnvironmentContext.java ===== +// ===== aspire/TestEnvironmentContext.java ===== // TestEnvironmentContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestEnvironmentContext extends HandleWrapperBase { TestEnvironmentContext(Handle handle, AspireClient client) { super(handle, client); @@ -23904,15 +24048,16 @@ public TestEnvironmentContext setPriority(double value) { } -// ===== TestMutableCollectionContext.java ===== +// ===== aspire/TestMutableCollectionContext.java ===== // TestMutableCollectionContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestMutableCollectionContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestMutableCollectionContext extends HandleWrapperBase { TestMutableCollectionContext(Handle handle, AspireClient client) { super(handle, client); @@ -23937,8 +24082,8 @@ public TestMutableCollectionContext setTags(AspireList value) { } /** Gets the Counts property */ - private AspireDict countsField; - public AspireDict counts() { + private AspireDict countsField; + public AspireDict counts() { if (countsField == null) { countsField = new AspireDict<>(getHandle(), getClient(), "Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes/TestMutableCollectionContext.counts"); } @@ -23946,7 +24091,7 @@ public AspireDict counts() { } /** Sets the Counts property */ - public TestMutableCollectionContext setCounts(AspireDict value) { + public TestMutableCollectionContext setCounts(AspireDict value) { Map reqArgs = new HashMap<>(); reqArgs.put("context", AspireClient.serializeValue(getHandle())); reqArgs.put("value", AspireClient.serializeValue(value)); @@ -23956,20 +24101,22 @@ public TestMutableCollectionContext setCounts(AspireDict value) } -// ===== TestNestedDto.java ===== +// ===== aspire/TestNestedDto.java ===== // TestNestedDto.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** TestNestedDto DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestNestedDto implements JsonSerializable { private String id; private TestConfigDto config; private List tags; - private Map counts; + private Map counts; public String getId() { return id; } public void setId(String value) { this.id = value; } @@ -23977,8 +24124,8 @@ public class TestNestedDto implements JsonSerializable { public void setConfig(TestConfigDto value) { this.config = value; } public List getTags() { return tags; } public void setTags(List value) { this.tags = value; } - public Map getCounts() { return counts; } - public void setCounts(Map value) { this.counts = value; } + public Map getCounts() { return counts; } + public void setCounts(Map value) { this.counts = value; } @SuppressWarnings("unchecked") public static TestNestedDto fromMap(Map map) { @@ -23990,7 +24137,7 @@ public static TestNestedDto fromMap(Map map) { var tagsValue = map.get("Tags"); value.setTags(((List) tagsValue).stream().map(item0 -> (String) item0).toList()); var countsValue = map.get("Counts"); - value.setCounts((Map) countsValue); + value.setCounts((Map) countsValue); return value; } @@ -24004,15 +24151,13 @@ public Map toMap() { } } -// ===== TestPersistenceMode.java ===== +// ===== aspire/TestPersistenceMode.java ===== // TestPersistenceMode.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** TestPersistenceMode enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum TestPersistenceMode implements WireValueEnum { NONE("None"), VOLUME("Volume"), @@ -24034,15 +24179,17 @@ public static TestPersistenceMode fromValue(String value) { } } -// ===== TestRedisResource.java ===== +// ===== aspire/TestRedisResource.java ===== // TestRedisResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestRedisResource extends ContainerResource { TestRedisResource(Handle handle, AspireClient client) { super(handle, client); @@ -24630,6 +24777,10 @@ public TestRedisResource withReference(IResource source) { return withReference(AspireUnion.of(source)); } + public TestRedisResource withReference(ResourceBuilderBase source) { + return withReference(new IResource(source.getHandle(), source.getClient())); + } + public TestRedisResource withReference(EndpointReference source, WithReferenceOptions options) { return withReference(AspireUnion.of(source), options); } @@ -24793,7 +24944,7 @@ public TestRedisResource withEndpoint() { } /** Adds a network endpoint */ - private TestRedisResource withEndpointImpl(Double port, Double targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { + private TestRedisResource withEndpointImpl(Number port, Number targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -24848,7 +24999,7 @@ public TestRedisResource withHttpEndpoint() { } /** Adds an HTTP endpoint */ - private TestRedisResource withHttpEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private TestRedisResource withHttpEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -24885,7 +25036,7 @@ public TestRedisResource withHttpsEndpoint() { } /** Adds an HTTPS endpoint */ - private TestRedisResource withHttpsEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private TestRedisResource withHttpsEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -25062,7 +25213,7 @@ public TestRedisResource waitForCompletion(ResourceBuilderBase dependency) { } /** Waits for the dependency resource to enter the Exited or Finished state before starting the resource. */ - public TestRedisResource waitForCompletion(IResource dependency, Double exitCode) { + public TestRedisResource waitForCompletion(IResource dependency, Number exitCode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("dependency", AspireClient.serializeValue(dependency)); @@ -25073,7 +25224,7 @@ public TestRedisResource waitForCompletion(IResource dependency, Double exitCode return this; } - public TestRedisResource waitForCompletion(ResourceBuilderBase dependency, Double exitCode) { + public TestRedisResource waitForCompletion(ResourceBuilderBase dependency, Number exitCode) { return waitForCompletion(new IResource(dependency.getHandle(), dependency.getClient()), exitCode); } @@ -25099,7 +25250,7 @@ public TestRedisResource withHttpHealthCheck() { } /** Adds a health check to the resource which is mapped to a specific endpoint. */ - private TestRedisResource withHttpHealthCheckImpl(String path, Double statusCode, String endpointName) { + private TestRedisResource withHttpHealthCheckImpl(String path, Number statusCode, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (path != null) { @@ -25350,7 +25501,7 @@ public TestRedisResource withHttpProbe(ProbeType probeType) { } /** Adds an HTTP health probe to the resource */ - private TestRedisResource withHttpProbeImpl(ProbeType probeType, String path, Double initialDelaySeconds, Double periodSeconds, Double timeoutSeconds, Double failureThreshold, Double successThreshold, String endpointName) { + private TestRedisResource withHttpProbeImpl(ProbeType probeType, String path, Number initialDelaySeconds, Number periodSeconds, Number timeoutSeconds, Number failureThreshold, Number successThreshold, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("probeType", AspireClient.serializeValue(probeType)); @@ -25407,7 +25558,7 @@ public TestRedisResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private TestRedisResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private TestRedisResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -26094,7 +26245,7 @@ public TestRedisResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private TestRedisResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private TestRedisResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -26120,7 +26271,7 @@ public TestRedisResource withMergeLoggingPath(String logLevel, String logPath) { } /** Configures resource logging with file path */ - private TestRedisResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private TestRedisResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -26162,15 +26313,16 @@ public TestRedisResource withMergeRouteMiddleware(String path, String method, St } -// ===== TestResourceContext.java ===== +// ===== aspire/TestResourceContext.java ===== // TestResourceContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestResourceContext extends HandleWrapperBase { TestResourceContext(Handle handle, AspireClient client) { super(handle, client); @@ -26236,15 +26388,13 @@ public boolean validateAsync() { } -// ===== TestResourceStatus.java ===== +// ===== aspire/TestResourceStatus.java ===== // TestResourceStatus.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** TestResourceStatus enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum TestResourceStatus implements WireValueEnum { PENDING("Pending"), RUNNING("Running"), @@ -26267,15 +26417,17 @@ public static TestResourceStatus fromValue(String value) { } } -// ===== TestVaultResource.java ===== +// ===== aspire/TestVaultResource.java ===== // TestVaultResource.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestVaultResource. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class TestVaultResource extends ContainerResource { TestVaultResource(Handle handle, AspireClient client) { super(handle, client); @@ -26845,6 +26997,10 @@ public TestVaultResource withReference(IResource source) { return withReference(AspireUnion.of(source)); } + public TestVaultResource withReference(ResourceBuilderBase source) { + return withReference(new IResource(source.getHandle(), source.getClient())); + } + public TestVaultResource withReference(EndpointReference source, WithReferenceOptions options) { return withReference(AspireUnion.of(source), options); } @@ -26999,7 +27155,7 @@ public TestVaultResource withEndpoint() { } /** Adds a network endpoint */ - private TestVaultResource withEndpointImpl(Double port, Double targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { + private TestVaultResource withEndpointImpl(Number port, Number targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -27054,7 +27210,7 @@ public TestVaultResource withHttpEndpoint() { } /** Adds an HTTP endpoint */ - private TestVaultResource withHttpEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private TestVaultResource withHttpEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -27091,7 +27247,7 @@ public TestVaultResource withHttpsEndpoint() { } /** Adds an HTTPS endpoint */ - private TestVaultResource withHttpsEndpointImpl(Double port, Double targetPort, String name, String env, Boolean isProxied) { + private TestVaultResource withHttpsEndpointImpl(Number port, Number targetPort, String name, String env, Boolean isProxied) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (port != null) { @@ -27268,7 +27424,7 @@ public TestVaultResource waitForCompletion(ResourceBuilderBase dependency) { } /** Waits for the dependency resource to enter the Exited or Finished state before starting the resource. */ - public TestVaultResource waitForCompletion(IResource dependency, Double exitCode) { + public TestVaultResource waitForCompletion(IResource dependency, Number exitCode) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("dependency", AspireClient.serializeValue(dependency)); @@ -27279,7 +27435,7 @@ public TestVaultResource waitForCompletion(IResource dependency, Double exitCode return this; } - public TestVaultResource waitForCompletion(ResourceBuilderBase dependency, Double exitCode) { + public TestVaultResource waitForCompletion(ResourceBuilderBase dependency, Number exitCode) { return waitForCompletion(new IResource(dependency.getHandle(), dependency.getClient()), exitCode); } @@ -27305,7 +27461,7 @@ public TestVaultResource withHttpHealthCheck() { } /** Adds a health check to the resource which is mapped to a specific endpoint. */ - private TestVaultResource withHttpHealthCheckImpl(String path, Double statusCode, String endpointName) { + private TestVaultResource withHttpHealthCheckImpl(String path, Number statusCode, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (path != null) { @@ -27556,7 +27712,7 @@ public TestVaultResource withHttpProbe(ProbeType probeType) { } /** Adds an HTTP health probe to the resource */ - private TestVaultResource withHttpProbeImpl(ProbeType probeType, String path, Double initialDelaySeconds, Double periodSeconds, Double timeoutSeconds, Double failureThreshold, Double successThreshold, String endpointName) { + private TestVaultResource withHttpProbeImpl(ProbeType probeType, String path, Number initialDelaySeconds, Number periodSeconds, Number timeoutSeconds, Number failureThreshold, Number successThreshold, String endpointName) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("probeType", AspireClient.serializeValue(probeType)); @@ -27613,7 +27769,7 @@ public TestVaultResource withHiddenOnCompletion() { } /** Hides the resource from default resource lists after successful completion */ - private TestVaultResource withHiddenOnCompletionImpl(Double exitCode, double[] exitCodes) { + private TestVaultResource withHiddenOnCompletionImpl(Number exitCode, double[] exitCodes) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); if (exitCode != null) { @@ -28136,7 +28292,7 @@ public TestVaultResource withMergeLogging(String logLevel) { } /** Configures resource logging */ - private TestVaultResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Double maxFiles) { + private TestVaultResource withMergeLoggingImpl(String logLevel, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -28162,7 +28318,7 @@ public TestVaultResource withMergeLoggingPath(String logLevel, String logPath) { } /** Configures resource logging with file path */ - private TestVaultResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Double maxFiles) { + private TestVaultResource withMergeLoggingPathImpl(String logLevel, String logPath, Boolean enableConsole, Number maxFiles) { Map reqArgs = new HashMap<>(); reqArgs.put("builder", AspireClient.serializeValue(getHandle())); reqArgs.put("logLevel", AspireClient.serializeValue(logLevel)); @@ -28204,15 +28360,16 @@ public TestVaultResource withMergeRouteMiddleware(String path, String method, St } -// ===== UpdateCommandStateContext.java ===== +// ===== aspire/UpdateCommandStateContext.java ===== // UpdateCommandStateContext.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** Wrapper for Aspire.Hosting/Aspire.Hosting.ApplicationModel.UpdateCommandStateContext. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class UpdateCommandStateContext extends HandleWrapperBase { UpdateCommandStateContext(Handle handle, AspireClient client) { super(handle, client); @@ -28236,21 +28393,22 @@ public IServiceProvider services() { } -// ===== UpdateCommandStateResourceSnapshot.java ===== +// ===== aspire/UpdateCommandStateResourceSnapshot.java ===== // UpdateCommandStateResourceSnapshot.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; +import java.util.HashMap; +import java.util.Map; /** UpdateCommandStateResourceSnapshot DTO. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public class UpdateCommandStateResourceSnapshot implements JsonSerializable { private String resourceType; private String state; private String stateStyle; private HealthStatus healthStatus; - private Double exitCode; + private Number exitCode; public String getResourceType() { return resourceType; } public void setResourceType(String value) { this.resourceType = value; } @@ -28260,8 +28418,8 @@ public class UpdateCommandStateResourceSnapshot implements JsonSerializable { public void setStateStyle(String value) { this.stateStyle = value; } public HealthStatus getHealthStatus() { return healthStatus; } public void setHealthStatus(HealthStatus value) { this.healthStatus = value; } - public Double getExitCode() { return exitCode; } - public void setExitCode(Double value) { this.exitCode = value; } + public Number getExitCode() { return exitCode; } + public void setExitCode(Number value) { this.exitCode = value; } @SuppressWarnings("unchecked") public static UpdateCommandStateResourceSnapshot fromMap(Map map) { @@ -28290,15 +28448,13 @@ public Map toMap() { } } -// ===== UrlDisplayLocation.java ===== +// ===== aspire/UrlDisplayLocation.java ===== // UrlDisplayLocation.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** UrlDisplayLocation enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum UrlDisplayLocation implements WireValueEnum { SUMMARY_AND_DETAILS("SummaryAndDetails"), DETAILS_ONLY("DetailsOnly"); @@ -28319,15 +28475,13 @@ public static UrlDisplayLocation fromValue(String value) { } } -// ===== WaitBehavior.java ===== +// ===== aspire/WaitBehavior.java ===== // WaitBehavior.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** WaitBehavior enum. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public enum WaitBehavior implements WireValueEnum { WAIT_ON_RESOURCE_UNAVAILABLE("WaitOnResourceUnavailable"), STOP_ON_RESOURCE_UNAVAILABLE("StopOnResourceUnavailable"); @@ -28348,14 +28502,12 @@ public static WaitBehavior fromValue(String value) { } } -// ===== WellKnownPipelineSteps.java ===== +// ===== aspire/WellKnownPipelineSteps.java ===== // WellKnownPipelineSteps.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WellKnownPipelineSteps { private WellKnownPipelineSteps() { } @@ -28406,14 +28558,12 @@ private WellKnownPipelineSteps() { } } -// ===== WellKnownPipelineTags.java ===== +// ===== aspire/WellKnownPipelineTags.java ===== // WellKnownPipelineTags.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WellKnownPipelineTags { private WellKnownPipelineTags() { } @@ -28431,29 +28581,26 @@ private WellKnownPipelineTags() { } } -// ===== WireValueEnum.java ===== +// ===== aspire/WireValueEnum.java ===== // WireValueEnum.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; - /** * Marker interface for generated enums that need a transport value distinct from Enum.name(). */ +@SuppressWarnings({"all", "unchecked", "serial"}) public interface WireValueEnum { String getValue(); } -// ===== WithContainerCertificatePathsOptions.java ===== +// ===== aspire/WithContainerCertificatePathsOptions.java ===== // WithContainerCertificatePathsOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithContainerCertificatePaths. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithContainerCertificatePathsOptions { private String customCertificatesDestination; private String[] defaultCertificateBundlePaths; @@ -28479,15 +28626,13 @@ public WithContainerCertificatePathsOptions defaultCertificateDirectoryPaths(Str } -// ===== WithDataVolumeOptions.java ===== +// ===== aspire/WithDataVolumeOptions.java ===== // WithDataVolumeOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithDataVolume. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithDataVolumeOptions { private String name; private Boolean isReadOnly; @@ -28506,15 +28651,13 @@ public WithDataVolumeOptions isReadOnly(Boolean value) { } -// ===== WithDockerfileBaseImageOptions.java ===== +// ===== aspire/WithDockerfileBaseImageOptions.java ===== // WithDockerfileBaseImageOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithDockerfileBaseImage. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithDockerfileBaseImageOptions { private String buildImage; private String runtimeImage; @@ -28533,15 +28676,13 @@ public WithDockerfileBaseImageOptions runtimeImage(String value) { } -// ===== WithDockerfileOptions.java ===== +// ===== aspire/WithDockerfileOptions.java ===== // WithDockerfileOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithDockerfile. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithDockerfileOptions { private String dockerfilePath; private String stage; @@ -28560,18 +28701,16 @@ public WithDockerfileOptions stage(String value) { } -// ===== WithEndpointOptions.java ===== +// ===== aspire/WithEndpointOptions.java ===== // WithEndpointOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithEndpoint. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithEndpointOptions { - private Double port; - private Double targetPort; + private Number port; + private Number targetPort; private String scheme; private String name; private String env; @@ -28579,14 +28718,14 @@ public final class WithEndpointOptions { private Boolean isExternal; private ProtocolType protocol; - public Double getPort() { return port; } - public WithEndpointOptions port(Double value) { + public Number getPort() { return port; } + public WithEndpointOptions port(Number value) { this.port = value; return this; } - public Double getTargetPort() { return targetPort; } - public WithEndpointOptions targetPort(Double value) { + public Number getTargetPort() { return targetPort; } + public WithEndpointOptions targetPort(Number value) { this.targetPort = value; return this; } @@ -28629,21 +28768,19 @@ public WithEndpointOptions protocol(ProtocolType value) { } -// ===== WithHiddenOnCompletionOptions.java ===== +// ===== aspire/WithHiddenOnCompletionOptions.java ===== // WithHiddenOnCompletionOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithHiddenOnCompletion. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithHiddenOnCompletionOptions { - private Double exitCode; + private Number exitCode; private double[] exitCodes; - public Double getExitCode() { return exitCode; } - public WithHiddenOnCompletionOptions exitCode(Double value) { + public Number getExitCode() { return exitCode; } + public WithHiddenOnCompletionOptions exitCode(Number value) { this.exitCode = value; return this; } @@ -28656,15 +28793,13 @@ public WithHiddenOnCompletionOptions exitCodes(double[] value) { } -// ===== WithHttpEndpointCallbackOptions.java ===== +// ===== aspire/WithHttpEndpointCallbackOptions.java ===== // WithHttpEndpointCallbackOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithHttpEndpointCallback. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithHttpEndpointCallbackOptions { private String name; private Boolean createIfNotExists; @@ -28683,30 +28818,28 @@ public WithHttpEndpointCallbackOptions createIfNotExists(Boolean value) { } -// ===== WithHttpEndpointOptions.java ===== +// ===== aspire/WithHttpEndpointOptions.java ===== // WithHttpEndpointOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithHttpEndpoint. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithHttpEndpointOptions { - private Double port; - private Double targetPort; + private Number port; + private Number targetPort; private String name; private String env; private Boolean isProxied; - public Double getPort() { return port; } - public WithHttpEndpointOptions port(Double value) { + public Number getPort() { return port; } + public WithHttpEndpointOptions port(Number value) { this.port = value; return this; } - public Double getTargetPort() { return targetPort; } - public WithHttpEndpointOptions targetPort(Double value) { + public Number getTargetPort() { return targetPort; } + public WithHttpEndpointOptions targetPort(Number value) { this.targetPort = value; return this; } @@ -28731,18 +28864,16 @@ public WithHttpEndpointOptions isProxied(Boolean value) { } -// ===== WithHttpHealthCheckOptions.java ===== +// ===== aspire/WithHttpHealthCheckOptions.java ===== // WithHttpHealthCheckOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithHttpHealthCheck. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithHttpHealthCheckOptions { private String path; - private Double statusCode; + private Number statusCode; private String endpointName; public String getPath() { return path; } @@ -28751,8 +28882,8 @@ public WithHttpHealthCheckOptions path(String value) { return this; } - public Double getStatusCode() { return statusCode; } - public WithHttpHealthCheckOptions statusCode(Double value) { + public Number getStatusCode() { return statusCode; } + public WithHttpHealthCheckOptions statusCode(Number value) { this.statusCode = value; return this; } @@ -28765,22 +28896,20 @@ public WithHttpHealthCheckOptions endpointName(String value) { } -// ===== WithHttpProbeOptions.java ===== +// ===== aspire/WithHttpProbeOptions.java ===== // WithHttpProbeOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithHttpProbe. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithHttpProbeOptions { private String path; - private Double initialDelaySeconds; - private Double periodSeconds; - private Double timeoutSeconds; - private Double failureThreshold; - private Double successThreshold; + private Number initialDelaySeconds; + private Number periodSeconds; + private Number timeoutSeconds; + private Number failureThreshold; + private Number successThreshold; private String endpointName; public String getPath() { return path; } @@ -28789,32 +28918,32 @@ public WithHttpProbeOptions path(String value) { return this; } - public Double getInitialDelaySeconds() { return initialDelaySeconds; } - public WithHttpProbeOptions initialDelaySeconds(Double value) { + public Number getInitialDelaySeconds() { return initialDelaySeconds; } + public WithHttpProbeOptions initialDelaySeconds(Number value) { this.initialDelaySeconds = value; return this; } - public Double getPeriodSeconds() { return periodSeconds; } - public WithHttpProbeOptions periodSeconds(Double value) { + public Number getPeriodSeconds() { return periodSeconds; } + public WithHttpProbeOptions periodSeconds(Number value) { this.periodSeconds = value; return this; } - public Double getTimeoutSeconds() { return timeoutSeconds; } - public WithHttpProbeOptions timeoutSeconds(Double value) { + public Number getTimeoutSeconds() { return timeoutSeconds; } + public WithHttpProbeOptions timeoutSeconds(Number value) { this.timeoutSeconds = value; return this; } - public Double getFailureThreshold() { return failureThreshold; } - public WithHttpProbeOptions failureThreshold(Double value) { + public Number getFailureThreshold() { return failureThreshold; } + public WithHttpProbeOptions failureThreshold(Number value) { this.failureThreshold = value; return this; } - public Double getSuccessThreshold() { return successThreshold; } - public WithHttpProbeOptions successThreshold(Double value) { + public Number getSuccessThreshold() { return successThreshold; } + public WithHttpProbeOptions successThreshold(Number value) { this.successThreshold = value; return this; } @@ -28827,15 +28956,13 @@ public WithHttpProbeOptions endpointName(String value) { } -// ===== WithHttpsEndpointCallbackOptions.java ===== +// ===== aspire/WithHttpsEndpointCallbackOptions.java ===== // WithHttpsEndpointCallbackOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithHttpsEndpointCallback. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithHttpsEndpointCallbackOptions { private String name; private Boolean createIfNotExists; @@ -28854,30 +28981,28 @@ public WithHttpsEndpointCallbackOptions createIfNotExists(Boolean value) { } -// ===== WithHttpsEndpointOptions.java ===== +// ===== aspire/WithHttpsEndpointOptions.java ===== // WithHttpsEndpointOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithHttpsEndpoint. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithHttpsEndpointOptions { - private Double port; - private Double targetPort; + private Number port; + private Number targetPort; private String name; private String env; private Boolean isProxied; - public Double getPort() { return port; } - public WithHttpsEndpointOptions port(Double value) { + public Number getPort() { return port; } + public WithHttpsEndpointOptions port(Number value) { this.port = value; return this; } - public Double getTargetPort() { return targetPort; } - public WithHttpsEndpointOptions targetPort(Double value) { + public Number getTargetPort() { return targetPort; } + public WithHttpsEndpointOptions targetPort(Number value) { this.targetPort = value; return this; } @@ -28902,15 +29027,13 @@ public WithHttpsEndpointOptions isProxied(Boolean value) { } -// ===== WithMcpServerOptions.java ===== +// ===== aspire/WithMcpServerOptions.java ===== // WithMcpServerOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithMcpServer. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithMcpServerOptions { private String path; private String endpointName; @@ -28929,18 +29052,16 @@ public WithMcpServerOptions endpointName(String value) { } -// ===== WithMergeLoggingOptions.java ===== +// ===== aspire/WithMergeLoggingOptions.java ===== // WithMergeLoggingOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithMergeLogging. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithMergeLoggingOptions { private Boolean enableConsole; - private Double maxFiles; + private Number maxFiles; public Boolean getEnableConsole() { return enableConsole; } public WithMergeLoggingOptions enableConsole(Boolean value) { @@ -28948,26 +29069,24 @@ public WithMergeLoggingOptions enableConsole(Boolean value) { return this; } - public Double getMaxFiles() { return maxFiles; } - public WithMergeLoggingOptions maxFiles(Double value) { + public Number getMaxFiles() { return maxFiles; } + public WithMergeLoggingOptions maxFiles(Number value) { this.maxFiles = value; return this; } } -// ===== WithMergeLoggingPathOptions.java ===== +// ===== aspire/WithMergeLoggingPathOptions.java ===== // WithMergeLoggingPathOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithMergeLoggingPath. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithMergeLoggingPathOptions { private Boolean enableConsole; - private Double maxFiles; + private Number maxFiles; public Boolean getEnableConsole() { return enableConsole; } public WithMergeLoggingPathOptions enableConsole(Boolean value) { @@ -28975,23 +29094,21 @@ public WithMergeLoggingPathOptions enableConsole(Boolean value) { return this; } - public Double getMaxFiles() { return maxFiles; } - public WithMergeLoggingPathOptions maxFiles(Double value) { + public Number getMaxFiles() { return maxFiles; } + public WithMergeLoggingPathOptions maxFiles(Number value) { this.maxFiles = value; return this; } } -// ===== WithOptionalStringOptions.java ===== +// ===== aspire/WithOptionalStringOptions.java ===== // WithOptionalStringOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithOptionalString. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithOptionalStringOptions { private String value; private Boolean enabled; @@ -29010,15 +29127,13 @@ public WithOptionalStringOptions enabled(Boolean value) { } -// ===== WithPipelineStepFactoryOptions.java ===== +// ===== aspire/WithPipelineStepFactoryOptions.java ===== // WithPipelineStepFactoryOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithPipelineStepFactory. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithPipelineStepFactoryOptions { private String[] dependsOn; private String[] requiredBy; @@ -29051,15 +29166,13 @@ public WithPipelineStepFactoryOptions description(String value) { } -// ===== WithReferenceOptions.java ===== +// ===== aspire/WithReferenceOptions.java ===== // WithReferenceOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithReference. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithReferenceOptions { private String connectionName; private Boolean optional; @@ -29085,15 +29198,13 @@ public WithReferenceOptions name(String value) { } -// ===== WithVolumeOptions.java ===== +// ===== aspire/WithVolumeOptions.java ===== // WithVolumeOptions.java - GENERATED CODE - DO NOT EDIT package aspire; -import java.util.*; -import java.util.function.*; - /** Options for WithVolume. */ +@SuppressWarnings({"all", "unchecked", "serial"}) public final class WithVolumeOptions { private String name; private Boolean isReadOnly; @@ -29113,245 +29224,245 @@ public WithVolumeOptions isReadOnly(Boolean value) { } // ===== sources.txt ===== -.aspire/modules/AddContainerOptions.java -.aspire/modules/AddDockerfileOptions.java -.aspire/modules/AddParameterOptions.java -.aspire/modules/AddParameterWithGeneratedValueOptions.java -.aspire/modules/AddStepOptions.java -.aspire/modules/AfterPublishEvent.java -.aspire/modules/AfterResourcesCreatedEvent.java -.aspire/modules/Aspire.java -.aspire/modules/AspireAction0.java -.aspire/modules/AspireAction1.java -.aspire/modules/AspireAction2.java -.aspire/modules/AspireAction3.java -.aspire/modules/AspireAction4.java -.aspire/modules/AspireClient.java -.aspire/modules/AspireDict.java -.aspire/modules/AspireFunc0.java -.aspire/modules/AspireFunc1.java -.aspire/modules/AspireFunc2.java -.aspire/modules/AspireFunc3.java -.aspire/modules/AspireFunc4.java -.aspire/modules/AspireList.java -.aspire/modules/AspireRegistrations.java -.aspire/modules/AspireUnion.java -.aspire/modules/BaseRegistrations.java -.aspire/modules/BeforePublishEvent.java -.aspire/modules/BeforeResourceStartedEvent.java -.aspire/modules/BeforeStartEvent.java -.aspire/modules/BoolInteractionResult.java -.aspire/modules/BuildOptions.java -.aspire/modules/CSharpAppResource.java -.aspire/modules/CancellationToken.java -.aspire/modules/CapabilityError.java -.aspire/modules/CertificateTrustExecutionConfigurationContext.java -.aspire/modules/CertificateTrustExecutionConfigurationExportData.java -.aspire/modules/CertificateTrustScope.java -.aspire/modules/CommandLineArgsCallbackContext.java -.aspire/modules/CommandLineArgsEditor.java -.aspire/modules/CommandOptions.java -.aspire/modules/CommandProgressOptions.java -.aspire/modules/CommandResultData.java -.aspire/modules/CommandResultFormat.java -.aspire/modules/CompleteStepMarkdownOptions.java -.aspire/modules/CompleteStepOptions.java -.aspire/modules/CompleteTaskMarkdownOptions.java -.aspire/modules/CompleteTaskOptions.java -.aspire/modules/ConnectionStringAvailableEvent.java -.aspire/modules/ContainerBuildOptionsCallbackContext.java -.aspire/modules/ContainerFileSystemCallbackContext.java -.aspire/modules/ContainerFileSystemItem.java -.aspire/modules/ContainerFilesOptions.java -.aspire/modules/ContainerImageDestination.java -.aspire/modules/ContainerImageFormat.java -.aspire/modules/ContainerImagePushOptions.java -.aspire/modules/ContainerImagePushOptionsCallbackContext.java -.aspire/modules/ContainerImageReference.java -.aspire/modules/ContainerLifetime.java -.aspire/modules/ContainerMountAnnotation.java -.aspire/modules/ContainerMountType.java -.aspire/modules/ContainerPortReference.java -.aspire/modules/ContainerRegistryResource.java -.aspire/modules/ContainerResource.java -.aspire/modules/ContainerTargetPlatform.java -.aspire/modules/CreateBuilderOptions.java -.aspire/modules/CreateCertificateFileOptions.java -.aspire/modules/CreateChoiceInputOptions.java -.aspire/modules/CreateDirectoryOptions.java -.aspire/modules/CreateFileOptions.java -.aspire/modules/CreateInteractionInputOptions.java -.aspire/modules/DistributedApplication.java -.aspire/modules/DistributedApplicationEventSubscription.java -.aspire/modules/DistributedApplicationExecutionContext.java -.aspire/modules/DistributedApplicationExecutionContextOptions.java -.aspire/modules/DistributedApplicationModel.java -.aspire/modules/DistributedApplicationOperation.java -.aspire/modules/DistributedApplicationResourceEventSubscription.java -.aspire/modules/DockerfileBuilder.java -.aspire/modules/DockerfileBuilderCallbackContext.java -.aspire/modules/DockerfileFactoryContext.java -.aspire/modules/DockerfileStage.java -.aspire/modules/DotnetToolResource.java -.aspire/modules/DynamicLoadingOptions.java -.aspire/modules/EndpointProperty.java -.aspire/modules/EndpointReference.java -.aspire/modules/EndpointReferenceExpression.java -.aspire/modules/EndpointUpdateContext.java -.aspire/modules/EnvironmentCallbackContext.java -.aspire/modules/EnvironmentEditor.java -.aspire/modules/EventingSubscriberRegistrationContext.java -.aspire/modules/ExecutableResource.java -.aspire/modules/ExecuteCommandAsyncOptions.java -.aspire/modules/ExecuteCommandContext.java -.aspire/modules/ExecuteCommandResult.java -.aspire/modules/ExternalServiceResource.java -.aspire/modules/GenerateParameterDefault.java -.aspire/modules/Handle.java -.aspire/modules/HandleWrapperBase.java -.aspire/modules/HealthCheckResult.java -.aspire/modules/HealthStatus.java -.aspire/modules/HttpCommandExportOptions.java -.aspire/modules/HttpCommandPrepareRequestContext.java -.aspire/modules/HttpCommandRequestExportData.java -.aspire/modules/HttpCommandResultMode.java -.aspire/modules/HttpsCertificateConfigurationCallbackAnnotationContext.java -.aspire/modules/HttpsCertificateExecutionConfigurationContext.java -.aspire/modules/HttpsCertificateExecutionConfigurationExportData.java -.aspire/modules/HttpsCertificateInfo.java -.aspire/modules/HttpsEndpointUpdateCallbackContext.java -.aspire/modules/IAspireStore.java -.aspire/modules/IComputeEnvironmentResource.java -.aspire/modules/IComputeResource.java -.aspire/modules/IConfiguration.java -.aspire/modules/IConfigurationSection.java -.aspire/modules/IContainerFilesDestinationResource.java -.aspire/modules/IDistributedApplicationBuilder.java -.aspire/modules/IDistributedApplicationEvent.java -.aspire/modules/IDistributedApplicationEventing.java -.aspire/modules/IDistributedApplicationPipeline.java -.aspire/modules/IDistributedApplicationResourceEvent.java -.aspire/modules/IExecutionConfigurationBuilder.java -.aspire/modules/IExecutionConfigurationResult.java -.aspire/modules/IExpressionValue.java -.aspire/modules/IHostEnvironment.java -.aspire/modules/IInteractionService.java -.aspire/modules/ILogger.java -.aspire/modules/ILoggerFactory.java -.aspire/modules/IReportingStep.java -.aspire/modules/IReportingTask.java -.aspire/modules/IResource.java -.aspire/modules/IResourceWithArgs.java -.aspire/modules/IResourceWithConnectionString.java -.aspire/modules/IResourceWithContainerFiles.java -.aspire/modules/IResourceWithEndpoints.java -.aspire/modules/IResourceWithEnvironment.java -.aspire/modules/IResourceWithParent.java -.aspire/modules/IResourceWithWaitSupport.java -.aspire/modules/IServiceProvider.java -.aspire/modules/ITestVaultResource.java -.aspire/modules/IUserSecretsManager.java -.aspire/modules/IconVariant.java -.aspire/modules/ImagePullPolicy.java -.aspire/modules/InitializeResourceEvent.java -.aspire/modules/InputInteractionResult.java -.aspire/modules/InputType.java -.aspire/modules/InputsDialogValidationContext.java -.aspire/modules/InputsInteractionResult.java -.aspire/modules/InteractionChoiceOption.java -.aspire/modules/InteractionInput.java -.aspire/modules/InteractionInputBuilder.java -.aspire/modules/InteractionInputCollection.java -.aspire/modules/InteractionInputLoadContext.java -.aspire/modules/InteractionInputsDialogOptions.java -.aspire/modules/InteractionLoadingInput.java -.aspire/modules/InteractionMessageBoxOptions.java -.aspire/modules/InteractionNotificationOptions.java -.aspire/modules/InteractionProgressOptions.java -.aspire/modules/JsonSerializable.java -.aspire/modules/LogFacade.java -.aspire/modules/MessageIntent.java -.aspire/modules/OtlpProtocol.java -.aspire/modules/ParameterCustomInputOptions.java -.aspire/modules/ParameterResource.java -.aspire/modules/PipelineConfigurationContext.java -.aspire/modules/PipelineContext.java -.aspire/modules/PipelineEditor.java -.aspire/modules/PipelineStep.java -.aspire/modules/PipelineStepContext.java -.aspire/modules/PipelineStepFactoryContext.java -.aspire/modules/PipelineSummary.java -.aspire/modules/ProbeType.java -.aspire/modules/ProcessCommandExportOptions.java -.aspire/modules/ProcessCommandResultExportOptions.java -.aspire/modules/ProcessCommandSpecExportData.java -.aspire/modules/ProgressContext.java -.aspire/modules/ProjectResource.java -.aspire/modules/ProjectResourceOptions.java -.aspire/modules/PromptConfirmationOptions.java -.aspire/modules/PromptInputOptions.java -.aspire/modules/PromptInputsOptions.java -.aspire/modules/PromptMessageBoxOptions.java -.aspire/modules/PromptNotificationOptions.java -.aspire/modules/PromptProgressOptions.java -.aspire/modules/ProtocolType.java -.aspire/modules/PublishResourceUpdateOptions.java -.aspire/modules/ReferenceEnvironmentInjectionOptions.java -.aspire/modules/ReferenceExpression.java -.aspire/modules/ReferenceExpressionBuilder.java -.aspire/modules/RequiredCommandValidationContext.java -.aspire/modules/RequiredCommandValidationResult.java -.aspire/modules/ResourceBuilderBase.java -.aspire/modules/ResourceCommandService.java -.aspire/modules/ResourceCommandState.java -.aspire/modules/ResourceCommandVisibility.java -.aspire/modules/ResourceEndpointsAllocatedEvent.java -.aspire/modules/ResourceEventDto.java -.aspire/modules/ResourceLoggerService.java -.aspire/modules/ResourceNotificationService.java -.aspire/modules/ResourceReadyEvent.java -.aspire/modules/ResourceStoppedEvent.java -.aspire/modules/ResourceUrlAnnotation.java -.aspire/modules/ResourceUrlsCallbackContext.java -.aspire/modules/ResourceUrlsEditor.java -.aspire/modules/RunConfiguration.java -.aspire/modules/TestCallbackContext.java -.aspire/modules/TestCollectionContext.java -.aspire/modules/TestConfigDto.java -.aspire/modules/TestConfigs.java -.aspire/modules/TestDatabaseResource.java -.aspire/modules/TestDeeplyNestedDto.java -.aspire/modules/TestEnvironmentContext.java -.aspire/modules/TestMutableCollectionContext.java -.aspire/modules/TestNestedDto.java -.aspire/modules/TestPersistenceMode.java -.aspire/modules/TestRedisResource.java -.aspire/modules/TestResourceContext.java -.aspire/modules/TestResourceStatus.java -.aspire/modules/TestVaultResource.java -.aspire/modules/UpdateCommandStateContext.java -.aspire/modules/UpdateCommandStateResourceSnapshot.java -.aspire/modules/UrlDisplayLocation.java -.aspire/modules/WaitBehavior.java -.aspire/modules/WellKnownPipelineSteps.java -.aspire/modules/WellKnownPipelineTags.java -.aspire/modules/WireValueEnum.java -.aspire/modules/WithContainerCertificatePathsOptions.java -.aspire/modules/WithDataVolumeOptions.java -.aspire/modules/WithDockerfileBaseImageOptions.java -.aspire/modules/WithDockerfileOptions.java -.aspire/modules/WithEndpointOptions.java -.aspire/modules/WithHiddenOnCompletionOptions.java -.aspire/modules/WithHttpEndpointCallbackOptions.java -.aspire/modules/WithHttpEndpointOptions.java -.aspire/modules/WithHttpHealthCheckOptions.java -.aspire/modules/WithHttpProbeOptions.java -.aspire/modules/WithHttpsEndpointCallbackOptions.java -.aspire/modules/WithHttpsEndpointOptions.java -.aspire/modules/WithMcpServerOptions.java -.aspire/modules/WithMergeLoggingOptions.java -.aspire/modules/WithMergeLoggingPathOptions.java -.aspire/modules/WithOptionalStringOptions.java -.aspire/modules/WithPipelineStepFactoryOptions.java -.aspire/modules/WithReferenceOptions.java -.aspire/modules/WithVolumeOptions.java +.aspire/modules/aspire/AddContainerOptions.java +.aspire/modules/aspire/AddDockerfileOptions.java +.aspire/modules/aspire/AddParameterOptions.java +.aspire/modules/aspire/AddParameterWithGeneratedValueOptions.java +.aspire/modules/aspire/AddStepOptions.java +.aspire/modules/aspire/AfterPublishEvent.java +.aspire/modules/aspire/AfterResourcesCreatedEvent.java +.aspire/modules/aspire/Aspire.java +.aspire/modules/aspire/AspireAction0.java +.aspire/modules/aspire/AspireAction1.java +.aspire/modules/aspire/AspireAction2.java +.aspire/modules/aspire/AspireAction3.java +.aspire/modules/aspire/AspireAction4.java +.aspire/modules/aspire/AspireClient.java +.aspire/modules/aspire/AspireDict.java +.aspire/modules/aspire/AspireFunc0.java +.aspire/modules/aspire/AspireFunc1.java +.aspire/modules/aspire/AspireFunc2.java +.aspire/modules/aspire/AspireFunc3.java +.aspire/modules/aspire/AspireFunc4.java +.aspire/modules/aspire/AspireList.java +.aspire/modules/aspire/AspireRegistrations.java +.aspire/modules/aspire/AspireUnion.java +.aspire/modules/aspire/BaseRegistrations.java +.aspire/modules/aspire/BeforePublishEvent.java +.aspire/modules/aspire/BeforeResourceStartedEvent.java +.aspire/modules/aspire/BeforeStartEvent.java +.aspire/modules/aspire/BoolInteractionResult.java +.aspire/modules/aspire/BuildOptions.java +.aspire/modules/aspire/CSharpAppResource.java +.aspire/modules/aspire/CancellationToken.java +.aspire/modules/aspire/CapabilityError.java +.aspire/modules/aspire/CertificateTrustExecutionConfigurationContext.java +.aspire/modules/aspire/CertificateTrustExecutionConfigurationExportData.java +.aspire/modules/aspire/CertificateTrustScope.java +.aspire/modules/aspire/CommandLineArgsCallbackContext.java +.aspire/modules/aspire/CommandLineArgsEditor.java +.aspire/modules/aspire/CommandOptions.java +.aspire/modules/aspire/CommandProgressOptions.java +.aspire/modules/aspire/CommandResultData.java +.aspire/modules/aspire/CommandResultFormat.java +.aspire/modules/aspire/CompleteStepMarkdownOptions.java +.aspire/modules/aspire/CompleteStepOptions.java +.aspire/modules/aspire/CompleteTaskMarkdownOptions.java +.aspire/modules/aspire/CompleteTaskOptions.java +.aspire/modules/aspire/ConnectionStringAvailableEvent.java +.aspire/modules/aspire/ContainerBuildOptionsCallbackContext.java +.aspire/modules/aspire/ContainerFileSystemCallbackContext.java +.aspire/modules/aspire/ContainerFileSystemItem.java +.aspire/modules/aspire/ContainerFilesOptions.java +.aspire/modules/aspire/ContainerImageDestination.java +.aspire/modules/aspire/ContainerImageFormat.java +.aspire/modules/aspire/ContainerImagePushOptions.java +.aspire/modules/aspire/ContainerImagePushOptionsCallbackContext.java +.aspire/modules/aspire/ContainerImageReference.java +.aspire/modules/aspire/ContainerLifetime.java +.aspire/modules/aspire/ContainerMountAnnotation.java +.aspire/modules/aspire/ContainerMountType.java +.aspire/modules/aspire/ContainerPortReference.java +.aspire/modules/aspire/ContainerRegistryResource.java +.aspire/modules/aspire/ContainerResource.java +.aspire/modules/aspire/ContainerTargetPlatform.java +.aspire/modules/aspire/CreateBuilderOptions.java +.aspire/modules/aspire/CreateCertificateFileOptions.java +.aspire/modules/aspire/CreateChoiceInputOptions.java +.aspire/modules/aspire/CreateDirectoryOptions.java +.aspire/modules/aspire/CreateFileOptions.java +.aspire/modules/aspire/CreateInteractionInputOptions.java +.aspire/modules/aspire/DistributedApplication.java +.aspire/modules/aspire/DistributedApplicationEventSubscription.java +.aspire/modules/aspire/DistributedApplicationExecutionContext.java +.aspire/modules/aspire/DistributedApplicationExecutionContextOptions.java +.aspire/modules/aspire/DistributedApplicationModel.java +.aspire/modules/aspire/DistributedApplicationOperation.java +.aspire/modules/aspire/DistributedApplicationResourceEventSubscription.java +.aspire/modules/aspire/DockerfileBuilder.java +.aspire/modules/aspire/DockerfileBuilderCallbackContext.java +.aspire/modules/aspire/DockerfileFactoryContext.java +.aspire/modules/aspire/DockerfileStage.java +.aspire/modules/aspire/DotnetToolResource.java +.aspire/modules/aspire/DynamicLoadingOptions.java +.aspire/modules/aspire/EndpointProperty.java +.aspire/modules/aspire/EndpointReference.java +.aspire/modules/aspire/EndpointReferenceExpression.java +.aspire/modules/aspire/EndpointUpdateContext.java +.aspire/modules/aspire/EnvironmentCallbackContext.java +.aspire/modules/aspire/EnvironmentEditor.java +.aspire/modules/aspire/EventingSubscriberRegistrationContext.java +.aspire/modules/aspire/ExecutableResource.java +.aspire/modules/aspire/ExecuteCommandAsyncOptions.java +.aspire/modules/aspire/ExecuteCommandContext.java +.aspire/modules/aspire/ExecuteCommandResult.java +.aspire/modules/aspire/ExternalServiceResource.java +.aspire/modules/aspire/GenerateParameterDefault.java +.aspire/modules/aspire/Handle.java +.aspire/modules/aspire/HandleWrapperBase.java +.aspire/modules/aspire/HealthCheckResult.java +.aspire/modules/aspire/HealthStatus.java +.aspire/modules/aspire/HttpCommandExportOptions.java +.aspire/modules/aspire/HttpCommandPrepareRequestContext.java +.aspire/modules/aspire/HttpCommandRequestExportData.java +.aspire/modules/aspire/HttpCommandResultMode.java +.aspire/modules/aspire/HttpsCertificateConfigurationCallbackAnnotationContext.java +.aspire/modules/aspire/HttpsCertificateExecutionConfigurationContext.java +.aspire/modules/aspire/HttpsCertificateExecutionConfigurationExportData.java +.aspire/modules/aspire/HttpsCertificateInfo.java +.aspire/modules/aspire/HttpsEndpointUpdateCallbackContext.java +.aspire/modules/aspire/IAspireStore.java +.aspire/modules/aspire/IComputeEnvironmentResource.java +.aspire/modules/aspire/IComputeResource.java +.aspire/modules/aspire/IConfiguration.java +.aspire/modules/aspire/IConfigurationSection.java +.aspire/modules/aspire/IContainerFilesDestinationResource.java +.aspire/modules/aspire/IDistributedApplicationBuilder.java +.aspire/modules/aspire/IDistributedApplicationEvent.java +.aspire/modules/aspire/IDistributedApplicationEventing.java +.aspire/modules/aspire/IDistributedApplicationPipeline.java +.aspire/modules/aspire/IDistributedApplicationResourceEvent.java +.aspire/modules/aspire/IExecutionConfigurationBuilder.java +.aspire/modules/aspire/IExecutionConfigurationResult.java +.aspire/modules/aspire/IExpressionValue.java +.aspire/modules/aspire/IHostEnvironment.java +.aspire/modules/aspire/IInteractionService.java +.aspire/modules/aspire/ILogger.java +.aspire/modules/aspire/ILoggerFactory.java +.aspire/modules/aspire/IReportingStep.java +.aspire/modules/aspire/IReportingTask.java +.aspire/modules/aspire/IResource.java +.aspire/modules/aspire/IResourceWithArgs.java +.aspire/modules/aspire/IResourceWithConnectionString.java +.aspire/modules/aspire/IResourceWithContainerFiles.java +.aspire/modules/aspire/IResourceWithEndpoints.java +.aspire/modules/aspire/IResourceWithEnvironment.java +.aspire/modules/aspire/IResourceWithParent.java +.aspire/modules/aspire/IResourceWithWaitSupport.java +.aspire/modules/aspire/IServiceProvider.java +.aspire/modules/aspire/ITestVaultResource.java +.aspire/modules/aspire/IUserSecretsManager.java +.aspire/modules/aspire/IconVariant.java +.aspire/modules/aspire/ImagePullPolicy.java +.aspire/modules/aspire/InitializeResourceEvent.java +.aspire/modules/aspire/InputInteractionResult.java +.aspire/modules/aspire/InputType.java +.aspire/modules/aspire/InputsDialogValidationContext.java +.aspire/modules/aspire/InputsInteractionResult.java +.aspire/modules/aspire/InteractionChoiceOption.java +.aspire/modules/aspire/InteractionInput.java +.aspire/modules/aspire/InteractionInputBuilder.java +.aspire/modules/aspire/InteractionInputCollection.java +.aspire/modules/aspire/InteractionInputLoadContext.java +.aspire/modules/aspire/InteractionInputsDialogOptions.java +.aspire/modules/aspire/InteractionLoadingInput.java +.aspire/modules/aspire/InteractionMessageBoxOptions.java +.aspire/modules/aspire/InteractionNotificationOptions.java +.aspire/modules/aspire/InteractionProgressOptions.java +.aspire/modules/aspire/JsonSerializable.java +.aspire/modules/aspire/LogFacade.java +.aspire/modules/aspire/MessageIntent.java +.aspire/modules/aspire/OtlpProtocol.java +.aspire/modules/aspire/ParameterCustomInputOptions.java +.aspire/modules/aspire/ParameterResource.java +.aspire/modules/aspire/PipelineConfigurationContext.java +.aspire/modules/aspire/PipelineContext.java +.aspire/modules/aspire/PipelineEditor.java +.aspire/modules/aspire/PipelineStep.java +.aspire/modules/aspire/PipelineStepContext.java +.aspire/modules/aspire/PipelineStepFactoryContext.java +.aspire/modules/aspire/PipelineSummary.java +.aspire/modules/aspire/ProbeType.java +.aspire/modules/aspire/ProcessCommandExportOptions.java +.aspire/modules/aspire/ProcessCommandResultExportOptions.java +.aspire/modules/aspire/ProcessCommandSpecExportData.java +.aspire/modules/aspire/ProgressContext.java +.aspire/modules/aspire/ProjectResource.java +.aspire/modules/aspire/ProjectResourceOptions.java +.aspire/modules/aspire/PromptConfirmationOptions.java +.aspire/modules/aspire/PromptInputOptions.java +.aspire/modules/aspire/PromptInputsOptions.java +.aspire/modules/aspire/PromptMessageBoxOptions.java +.aspire/modules/aspire/PromptNotificationOptions.java +.aspire/modules/aspire/PromptProgressOptions.java +.aspire/modules/aspire/ProtocolType.java +.aspire/modules/aspire/PublishResourceUpdateOptions.java +.aspire/modules/aspire/ReferenceEnvironmentInjectionOptions.java +.aspire/modules/aspire/ReferenceExpression.java +.aspire/modules/aspire/ReferenceExpressionBuilder.java +.aspire/modules/aspire/RequiredCommandValidationContext.java +.aspire/modules/aspire/RequiredCommandValidationResult.java +.aspire/modules/aspire/ResourceBuilderBase.java +.aspire/modules/aspire/ResourceCommandService.java +.aspire/modules/aspire/ResourceCommandState.java +.aspire/modules/aspire/ResourceCommandVisibility.java +.aspire/modules/aspire/ResourceEndpointsAllocatedEvent.java +.aspire/modules/aspire/ResourceEventDto.java +.aspire/modules/aspire/ResourceLoggerService.java +.aspire/modules/aspire/ResourceNotificationService.java +.aspire/modules/aspire/ResourceReadyEvent.java +.aspire/modules/aspire/ResourceStoppedEvent.java +.aspire/modules/aspire/ResourceUrlAnnotation.java +.aspire/modules/aspire/ResourceUrlsCallbackContext.java +.aspire/modules/aspire/ResourceUrlsEditor.java +.aspire/modules/aspire/RunConfiguration.java +.aspire/modules/aspire/TestCallbackContext.java +.aspire/modules/aspire/TestCollectionContext.java +.aspire/modules/aspire/TestConfigDto.java +.aspire/modules/aspire/TestConfigs.java +.aspire/modules/aspire/TestDatabaseResource.java +.aspire/modules/aspire/TestDeeplyNestedDto.java +.aspire/modules/aspire/TestEnvironmentContext.java +.aspire/modules/aspire/TestMutableCollectionContext.java +.aspire/modules/aspire/TestNestedDto.java +.aspire/modules/aspire/TestPersistenceMode.java +.aspire/modules/aspire/TestRedisResource.java +.aspire/modules/aspire/TestResourceContext.java +.aspire/modules/aspire/TestResourceStatus.java +.aspire/modules/aspire/TestVaultResource.java +.aspire/modules/aspire/UpdateCommandStateContext.java +.aspire/modules/aspire/UpdateCommandStateResourceSnapshot.java +.aspire/modules/aspire/UrlDisplayLocation.java +.aspire/modules/aspire/WaitBehavior.java +.aspire/modules/aspire/WellKnownPipelineSteps.java +.aspire/modules/aspire/WellKnownPipelineTags.java +.aspire/modules/aspire/WireValueEnum.java +.aspire/modules/aspire/WithContainerCertificatePathsOptions.java +.aspire/modules/aspire/WithDataVolumeOptions.java +.aspire/modules/aspire/WithDockerfileBaseImageOptions.java +.aspire/modules/aspire/WithDockerfileOptions.java +.aspire/modules/aspire/WithEndpointOptions.java +.aspire/modules/aspire/WithHiddenOnCompletionOptions.java +.aspire/modules/aspire/WithHttpEndpointCallbackOptions.java +.aspire/modules/aspire/WithHttpEndpointOptions.java +.aspire/modules/aspire/WithHttpHealthCheckOptions.java +.aspire/modules/aspire/WithHttpProbeOptions.java +.aspire/modules/aspire/WithHttpsEndpointCallbackOptions.java +.aspire/modules/aspire/WithHttpsEndpointOptions.java +.aspire/modules/aspire/WithMcpServerOptions.java +.aspire/modules/aspire/WithMergeLoggingOptions.java +.aspire/modules/aspire/WithMergeLoggingPathOptions.java +.aspire/modules/aspire/WithOptionalStringOptions.java +.aspire/modules/aspire/WithPipelineStepFactoryOptions.java +.aspire/modules/aspire/WithReferenceOptions.java +.aspire/modules/aspire/WithVolumeOptions.java diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index 88eba357e78..6028282e647 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -915,6 +915,40 @@ public async Task MultipleComputeEnvironmentsOnlyProcessTargetedResources() Assert.Null(projectK8sResource.GetDeploymentTargetAnnotation(dockerCompose.Resource)); } + [Fact] + public async Task RunningTheDeploymentTargetHooksTwiceLeavesOneTargetPerEnvironment() + { + // The prepare-deployment-target step declares RequiredBySteps = [BeforeStart], so it runs once in + // the BeforeStart pipeline and again in the publish DAG. Without a guard the second pass adds a + // second DeploymentTargetAnnotation, and GetDeploymentTargetAnnotation then throws on the + // ambiguity, which fails every publish that goes through both paths. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var dockerCompose = builder.AddDockerComposeEnvironment("docker-compose"); + + builder.AddContainer("containerdocker", "nginx") + .WithHttpEndpoint(port: 9090, targetPort: 80, name: "http") + .WithComputeEnvironment(dockerCompose); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + var containerDockerResource = model.Resources.First(r => r.Name == "containerdocker"); + + Assert.Single(containerDockerResource.Annotations.OfType()); + + var target = containerDockerResource.GetDeploymentTargetAnnotation(); + Assert.NotNull(target); + Assert.Same(dockerCompose.Resource, target.ComputeEnvironment); + + // The surviving target must still be the configured one rather than a bare second pass. + var service = Assert.IsType(target.DeploymentTarget); + Assert.Same(containerDockerResource, service.TargetResource); + } + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); diff --git a/tests/Aspire.Hosting.Dotnet.Tests/Aspire.Hosting.Dotnet.Tests.csproj b/tests/Aspire.Hosting.Dotnet.Tests/Aspire.Hosting.Dotnet.Tests.csproj index a04d49cffda..3fc281cf152 100644 --- a/tests/Aspire.Hosting.Dotnet.Tests/Aspire.Hosting.Dotnet.Tests.csproj +++ b/tests/Aspire.Hosting.Dotnet.Tests/Aspire.Hosting.Dotnet.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs index eafd89096ff..81f57d5445f 100644 --- a/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Dotnet.Tests/DotnetProjectResourceTests.cs @@ -13,6 +13,7 @@ using Aspire.Hosting.Resources; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -912,17 +913,19 @@ private static void AssertUnsupportedPublishMessage( fragment => Assert.Contains(fragment, exception.Message)); } - private static Task ExecutePipelineAsync(DistributedApplication app) + private static async Task ExecutePipelineAsync(DistributedApplication app) { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TestConstants.LongTimeoutTimeSpan); var pipeline = app.Services.GetRequiredService(); var context = new PipelineContext( app.Services.GetRequiredService(), app.Services.GetRequiredService(), app.Services, app.Services.GetRequiredService>(), - CancellationToken.None); + cts.Token); - return pipeline.ExecuteAsync(context); + await pipeline.ExecuteAsync(context).WaitAsync(cts.Token); } private static string CreateProjectDirectory(string workspacePath) diff --git a/tests/Aspire.Hosting.Java.Tests/AddJavaAppPublishTests.cs b/tests/Aspire.Hosting.Java.Tests/AddJavaAppPublishTests.cs new file mode 100644 index 00000000000..f10061ef135 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/AddJavaAppPublishTests.cs @@ -0,0 +1,2152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREEXTENSION001 +#pragma warning disable ASPIREDOCKERFILEBUILDER001 +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES003 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; +using Aspire.Hosting.Tests.Utils; +using System.Diagnostics; +using System.Text.Json; +using Aspire.TestUtilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Java.Tests; + +public class AddJavaAppPublishTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task VerifyPublish_GeneratesAMavenBuildAndJreRuntimePair() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.StartsWith("FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build", content); + Assert.Contains("\nFROM docker.io/library/eclipse-temurin:21-jre\n", content); + await Verify(content); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Runs the generated POSIX shell the container build would run")] + public async Task VerifyPublish_TheJarSelectionCopiesAnArtifactWhoseNameContainsWhitespace() + { + var content = await PublishDockerfileAsync( + configureSource: source => WriteGradleBuild(source, ""), + configureResource: app => app.WithGradleTask("bootRun")); + + // Everything from the selection onwards, so the test runs the real generated shell rather than a + // paraphrase of it. The build invocation ahead of it needs Gradle and is not what is under test. + var selectionIndex = content.IndexOf("jars=$(ls ", StringComparison.Ordinal); + Assert.True(selectionIndex >= 0, "The generated Dockerfile no longer selects the JAR with a glob."); + var selection = content[selectionIndex..content.IndexOf('\n', selectionIndex)]; + + using var workspace = TemporaryWorkspace.Create(outputHelper); + Directory.CreateDirectory(Path.Combine(workspace.Path, "build", "libs")); + + // Gradle's archiveFileName and archivesName are freely settable, as is Maven's finalName, so this + // is a legitimate single build output rather than a malformed one. + await File.WriteAllTextAsync(Path.Combine(workspace.Path, "build", "libs", "reports service.jar"), ""); + + var destination = Path.Combine(workspace.Path, "app.jar"); + var command = selection.Replace("/build/app.jar", destination, StringComparison.Ordinal); + + using var process = Process.Start(new ProcessStartInfo("sh") + { + ArgumentList = { "-c", command }, + WorkingDirectory = workspace.Path, + RedirectStandardError = true + })!; + + await process.WaitForExitAsync(CancellationToken.None); + + Assert.Equal(string.Empty, await process.StandardError.ReadToEndAsync()); + Assert.Equal(0, process.ExitCode); + Assert.True(File.Exists(destination)); + } + + [Fact] + public async Task VerifyPublish_GeneratesAGradleBuild() + { + var content = await PublishDockerfileAsync( + configureSource: source => WriteGradleBuild(source, """ + java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } + } + """), + configureResource: app => app.WithGradleTask("bootRun")); + + Assert.StartsWith("FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:17-jdk AS build", content); + await Verify(content); + } + + [Fact] + public async Task VerifyPublish_CopiesABuildProducedOtelAgentIntoTheRuntimeImage() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app + .WithMavenGoal("spring-boot:run") + .WithOtelAgent("target/agent/opentelemetry-javaagent.jar")); + + // The agent is produced by the build, so it exists only in the build stage. Without this COPY the + // published container starts a JVM pointing at a JAR that is not in the image and dies during VM + // initialization with "Error opening zip file or JAR manifest missing". + Assert.Contains( + "COPY --from=build --chown=999:999 /app/target/agent/opentelemetry-javaagent.jar /app/agent.jar", + content); + await Verify(content); + } + + [Fact] + public async Task VerifyPublish_WithJvmArgs_ComposesWithBuildProducedOtelAgent() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + builder.AddJavaApp("worker", tempDir.Path, "target/worker.jar") + .WithMavenBuild() + .WithJvmArgs("-Xmx128m", "-javaagent:/app/coverage-agent.jar") + .WithOtelAgent("target/agent/opentelemetry-javaagent.jar"); + + var container = Assert.Single(builder.Resources.OfType()); + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + container, DistributedApplicationOperation.Publish, TestServiceProvider.Instance); + + // Java agents initialize in option order. Preserve the fluent callback order rather than + // replacing or reordering the user's agent when Aspire appends its own telemetry agent. + Assert.Equal( + "-Xmx128m -javaagent:/app/coverage-agent.jar -javaagent:/app/agent.jar", + envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task VerifyPublish_StripsExactlyOneLeadingDotSlashFromTheOtelAgentPath() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app + .WithMavenGoal("spring-boot:run") + .WithOtelAgent("./target/agent/opentelemetry-javaagent.jar")); + + Assert.Contains( + "COPY --from=build --chown=999:999 /app/target/agent/opentelemetry-javaagent.jar /app/agent.jar", + content); + } + + [Fact] + public void AnOtelAgentPathOutsideTheBuildContextIsRejected() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenGoal("spring-boot:run") + .WithOtelAgent("../agents/opentelemetry-javaagent.jar"); + + // The Docker build context is the application directory, so "../" can never be copied forward. + // Trimming the leading dots instead would emit a COPY for "agents/opentelemetry-javaagent.jar" -- + // a path the author never wrote -- and fail the container build with a confusing message. + var exception = Assert.Throws( + () => JavaDockerfileGenerator.TryGetBuildProducedAgentPath(app.Resource, out _)); + + Assert.Contains("../agents/opentelemetry-javaagent.jar", exception.Message); + Assert.Contains("outside the application directory", exception.Message); + } + + [Fact] + public async Task VerifyPublish_DoesNotCopyAnAbsoluteOtelAgentPath() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app + .WithMavenGoal("spring-boot:run") + .WithOtelAgent("/opt/otel/agent.jar")); + + // An absolute path cannot have come out of the build context, so there is nothing to copy from: + // the base image or a mount provides it. Assert on the COPY instructions the Dockerfile actually + // contains rather than on the absence of one path, so deleting the agent handling fails the test. + var copies = content + .Split('\n') + .Where(line => line.StartsWith("COPY ", StringComparison.Ordinal)) + .ToArray(); + + Assert.Equal( + [ + "COPY mvnw ./mvnw", + "COPY .mvn ./.mvn", + "COPY . .", + "COPY --from=build --chown=999:999 /build/app.jar /app/app.jar" + ], + copies); + } + + /// + /// The published image is Linux, so a Windows agent path can never resolve inside it. Left alone it + /// reaches the container as -javaagent:C:\... and the JVM dies during initialization. Rejecting + /// it on every platform matches how the jar artifact and the wrapper already behave. + /// + [Fact] + public void VerifyPublish_AWindowsAbsoluteOtelAgentIsRejectedOnEveryPlatform() + { + using var appDirectory = new TempJavaAppDirectory(); + WritePom(appDirectory.Path, javaVersion: "21"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", appDirectory.Path) + .WithMavenBuild() + .WithOtelAgent(@"C:\opt\otel\agent.jar"); + + var exception = Assert.Throws( + () => JavaDockerfileGenerator.TryGetBuildProducedAgentPath(app.Resource, out _)); + + Assert.Contains("is a Windows path", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_UsesTheWrapperWhenTheProjectShipsOne() + { + var content = await PublishDockerfileAsync( + configureSource: source => + { + WritePom(source, javaVersion: "21"); + }, + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + // sh ./mvnw rather than ./mvnw: a wrapper checked out from a Windows clone arrives without the + // executable bit, and invoking the interpreter directly does not depend on the file mode. + Assert.StartsWith("FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build", content); + Assert.Contains("sh ./mvnw -B -ntp -DskipTests package", content); + await Verify(content); + } + + [Fact] + public void PublishingAProjectWithoutAWrapperIsRejectedRatherThanUsingTheImageTool() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + File.WriteAllText(Path.Combine(sourceDir.FullName, "build.gradle"), ""); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + // No goal is configured, so nothing resolves a wrapper before the image is generated: the tool is + // detected from the build file on disk and this is the only check that runs. + var app = builder.AddJavaApp("api", sourceDir.FullName, "build/libs/api.jar"); + + var ex = Assert.Throws( + () => JavaDockerfileGenerator.ResolveContainerBuildForTesting(app.Resource, sourceDir.FullName)); + + // A globally installed Gradle in the build image would build the project with a different version + // than the developer used, which is exactly what the wrapper exists to prevent. + Assert.Contains("there is no gradlew", ex.Message); + Assert.Contains("gradle wrapper", ex.Message); + } + + [Fact] + public async Task VerifyPublish_ReusesTheArgumentsConfiguredForTheHostBuildStep() + { + // The host-side build step only runs in run mode, but the arguments it was given describe how this + // project produces a deployable artifact, so the container build runs the same ones. + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app.WithMavenGoal("spring-boot:run").WithMavenBuild("-Pprod", "package")); + + Assert.Contains("sh ./mvnw -Pprod package", content); + await Verify(content); + } + + [Fact] + public async Task VerifyPublish_DetectsTheBuildToolFromDiskWhenOnlyAJarPathWasGiven() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + jarPath: "target/worker.jar"); + + Assert.Contains("sh ./mvnw -B -ntp -DskipTests package", content); + await Verify(content); + } + + [Theory] + [InlineData("../shared/app.jar")] + [InlineData("target/../../app.jar")] + [InlineData(@"..\..\app.jar")] + [InlineData("/opt/build/app.jar")] + public void PublishingAJarPathThatEscapesTheBuildDirectoryIsRejectedRatherThanGlobbed(string jarPath) + { + // Falling back to the output glob here would publish whichever JAR the build happened to emit + // instead of the one the AppHost named, and the divergence from run mode would be silent. + using var appDirectory = new TempJavaAppDirectory(); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", appDirectory.Path, jarPath); + + var exception = Assert.Throws( + () => JavaDockerfileGenerator.TryGetBuildOutputJarPath(app.Resource, out _)); + + Assert.Contains(jarPath, exception.Message); + Assert.Contains("outside the directory the build runs in", exception.Message); + } + + [Fact] + public async Task PublishingAJarPathContainingWhitespaceQuotesItRatherThanFallingBackToTheGlob() + { + // Unlike a Dockerfile COPY, the artifact is selected by a RUN shell command, which can quote. + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + jarPath: "target/my worker.jar"); + + Assert.Contains("cp 'target/my worker.jar' /build/app.jar", content); + Assert.DoesNotContain("expected exactly one application JAR", content); + } + + [Fact] + public async Task VerifyPublish_CopiesTheExplicitArtifactWhenWithJarArtifactIsUsed() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app.WithMavenGoal("spring-boot:run").WithJarArtifact("target/custom-name.jar")); + + Assert.Contains("cp 'target/custom-name.jar' /build/app.jar", content); + // The glob selection is what WithJarArtifact exists to replace, so it must not also be emitted. + Assert.DoesNotContain("expected exactly one application JAR", content); + } + + [Fact] + public async Task VerifyPublish_FailsTheContainerBuildWhenTheJarIsAmbiguous() + { + // Spring Boot's plugin writes app.jar next to the base plugin's app-plain.jar. The -plain suffix is + // filtered, but anything still ambiguous has to stop the build rather than pick one arbitrarily and + // produce an image that exits with "no main manifest attribute". + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Contains("grep -Ev '(-plain|-sources|-javadoc)\\.jar$'", content); + Assert.Contains("exit 1", content); + } + + [Fact] + public async Task VerifyPublish_HonoursWithDockerfileBaseImage() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app + .WithMavenGoal("spring-boot:run") + .WithDockerfileBaseImage("docker.io/library/amazoncorretto:21", "docker.io/library/amazoncorretto:21-alpine")); + + Assert.StartsWith("FROM --platform=$BUILDPLATFORM docker.io/library/amazoncorretto:21 AS build", content); + Assert.Contains("\nFROM docker.io/library/amazoncorretto:21-alpine\n", content); + // No user is created in the runtime stage, so an override needs no distro-specific handling. + Assert.DoesNotContain("adduser", content, StringComparison.Ordinal); + await Verify(content); + } + + [Fact] + public async Task VerifyPublish_RunsAsANonRootUser() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + // A numeric id rather than a named account, so the same Dockerfile works on any runtime image + // including Alpine and distroless, none of which agree on the user-creation tools. + Assert.Contains("\nUSER 999:999\n", content); + Assert.DoesNotContain("useradd", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_UsesTheExecFormEntrypointSoTheJvmReceivesSigterm() + { + // With the shell form the JVM is not PID 1 and never sees SIGTERM, so Spring's shutdown hooks are + // skipped and the container is killed after the stop timeout instead. + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Contains("""ENTRYPOINT ["java","-jar","/app/app.jar"]""", content); + } + + [Fact] + public async Task VerifyPublish_EmitsABuildContextIgnoreThatExcludesBuildOutputDirectories() + { + var ignore = await PublishBuildContextIgnoreAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.NotNull(ignore); + await Verify(ignore); + } + + [Fact] + public async Task VerifyPublish_LeavesAnAuthoredDockerignoreAlone() + { + // A .dockerignore replaces the context root's .dockerignore rather than merging with it, + // so generating one would silently drop every rule the author wrote. + var ignore = await PublishBuildContextIgnoreAsync( + configureSource: source => + { + WritePom(source, javaVersion: "21"); + File.WriteAllText(Path.Combine(source, ".dockerignore"), "secrets/\n"); + }, + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Null(ignore); + } + + [Fact] + public async Task VerifyPublish_LeavesAnAuthoredDockerfileAlone() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var outputDir = workspace.CreateDirectory("output"); + + WritePom(sourceDir.FullName, javaVersion: "21"); + File.WriteAllText(Path.Combine(sourceDir.FullName, "Dockerfile"), "FROM scratch\n"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest"); + builder.AddJavaApp("api", sourceDir.FullName).WithMavenGoal("spring-boot:run"); + builder.Build().Run(); + + Assert.False(File.Exists(Path.Combine(outputDir.FullName, "api.Dockerfile"))); + } + + [Fact] + public async Task PublishingAnAuthoredDockerfileRejectsABuildProducedAgent() + { + // /app/agent.jar only exists because the generated Dockerfile copies the agent there. An + // authored Dockerfile has no reason to, so pointing JAVA_TOOL_OPTIONS at it would produce an + // image whose JVM dies during initialization with "Error opening zip file or JAR manifest + // missing" — before anything the developer could debug. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var outputDir = workspace.CreateDirectory("output"); + + WritePom(sourceDir.FullName, javaVersion: "21"); + File.WriteAllText(Path.Combine(sourceDir.FullName, "Dockerfile"), "FROM scratch\n"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest"); + builder.AddJavaApp("api", sourceDir.FullName) + .WithMavenGoal("spring-boot:run") + .WithOtelAgent("target/otel/javaagent.jar"); + + var container = Assert.Single(builder.Resources.OfType()); + + var exception = await Assert.ThrowsAsync( + async () => await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + container, DistributedApplicationOperation.Publish, TestServiceProvider.Instance)); + + Assert.Equal( + $"Java application 'api' cannot be published because it uses the Dockerfile in " + + $"'{sourceDir.FullName}' and its OpenTelemetry agent path 'target/otel/javaagent.jar' is " + + "relative to the build output. Aspire copies a build-produced agent into the image only in " + + "the Dockerfile it generates. Copy the agent in your Dockerfile and pass its path inside the " + + "image to WithOtelAgent, for example WithOtelAgent(\"/opt/otel/javaagent.jar\").", + exception.Message); + } + + [Fact] + public async Task PublishingAnAuthoredDockerfileAcceptsAnAbsoluteAgentPath() + { + // An absolute path names a location inside the image, which is the developer's to guarantee. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var outputDir = workspace.CreateDirectory("output"); + + WritePom(sourceDir.FullName, javaVersion: "21"); + File.WriteAllText(Path.Combine(sourceDir.FullName, "Dockerfile"), "FROM scratch\n"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest"); + builder.AddJavaApp("api", sourceDir.FullName) + .WithMavenGoal("spring-boot:run") + .WithOtelAgent("/opt/otel/javaagent.jar"); + + var container = Assert.Single(builder.Resources.OfType()); + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + container, DistributedApplicationOperation.Publish, TestServiceProvider.Instance); + + builder.Build().Run(); + + Assert.Equal("-javaagent:/opt/otel/javaagent.jar", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task VerifyPublish_ProducesAContainerManifestEntry() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var outputDir = workspace.CreateDirectory("output"); + + WritePom(sourceDir.FullName, javaVersion: "21"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest"); + builder.AddJavaApp("api", sourceDir.FullName) + .WithMavenGoal("spring-boot:run") + .WithHttpEndpoint(targetPort: 8080, env: "SERVER_PORT"); + + builder.Build().Run(); + + var manifest = await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "aspire-manifest.json"), TestContext.Current.CancellationToken); + + await Verify(manifest, "json") + .ScrubLinesWithReplace(line => line.Contains("\"context\"", StringComparison.Ordinal) ? " \"context\": \"{sourceDirectory}\"," : line); + } + + [Theory] + [InlineData("17", "17")] + [InlineData("21", "21")] + [InlineData("1.8", "8")] + // A property reference cannot be expanded here and must not reach a FROM instruction, so it falls back. + [InlineData("${java.version}", JavaVersionDetector.DefaultJavaVersion)] + [InlineData("", JavaVersionDetector.DefaultJavaVersion)] + public void JavaVersionDetector_ReadsTheTargetReleaseFromAPom(string properties, string expected) + { + using var appDirectory = new TempJavaAppDirectory(); + + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), $""" + + + 4.0.0 + demo + + {properties} + + + """); + + Assert.Equal(expected, JavaVersionDetector.Detect(appDirectory.Path)); + } + + [Fact] + public void JavaVersionDetector_SkipsAPropertyReferenceInFavourOfALaterLiteral() + { + // Stopping at the first would fall back to the default even though the POM plainly + // declares a target, and publish a runtime image the bytecode does not match. + using var appDirectory = new TempJavaAppDirectory(); + + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), """ + + + 4.0.0 + demo + + + + maven-compiler-plugin + ${java.release} + + + maven-compiler-plugin + 17 + + + + + """); + + Assert.Equal("17", JavaVersionDetector.Detect(appDirectory.Path)); + } + + [Fact] + public void JavaVersionDetector_IgnoresATargetOutsideAPluginConfiguration() + { + // is a common element name in other plugins. Matched anywhere, an antrun target that + // happens to hold a number would decide the runtime image. + using var appDirectory = new TempJavaAppDirectory(); + + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), """ + + + 4.0.0 + demo + + + legacy + 11 + + + + """); + + Assert.Equal(JavaVersionDetector.DefaultJavaVersion, JavaVersionDetector.Detect(appDirectory.Path)); + } + + [Theory] + [InlineData("java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }", "21")] + [InlineData("java.toolchain.languageVersion.set(JavaLanguageVersion.of(17))", "17")] + [InlineData("sourceCompatibility = JavaVersion.VERSION_1_8", "8")] + [InlineData("sourceCompatibility = JavaVersion.VERSION_21", "21")] + [InlineData("sourceCompatibility = '17'", "17")] + [InlineData("targetCompatibility = 1.8", "8")] + [InlineData("", JavaVersionDetector.DefaultJavaVersion)] + // A commented-out setting must not win over the active one below it. + [InlineData(""" + java { + toolchain { + // languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) + } + } + """, "21")] + [InlineData(""" + /* + java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } + */ + java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } + """, "21")] + // A "//" inside a string is not a comment, so the rest of that line still parses. Written on one + // line because the whole point is that the URL does not swallow what follows it. + [InlineData(""" + repositories { maven { url "https://repo.example.com/m2" } }; sourceCompatibility = '17' + """, "17")] + // A "/*" inside a string would otherwise swallow the remainder of the file. + [InlineData(""" + tasks.register("noop") { doLast { println("/*") } } + java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } + """, "21")] + // A setting that only exists inside a comment leaves nothing to detect. + [InlineData("// languageVersion = JavaLanguageVersion.of(17)", JavaVersionDetector.DefaultJavaVersion)] + // A version-shaped fragment quoted inside a string is not a declaration, and appears first, so + // reading it would select a JRE too old to run the application's own bytecode. + [InlineData(""" + tasks.register("noop") { doLast { println("JavaLanguageVersion.of(17)") } } + java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } + """, "21")] + [InlineData(""" + tasks.register("noop") { doLast { println("sourceCompatibility = JavaVersion.VERSION_17") } } + java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } + """, "21")] + [InlineData(""" + tasks.register("noop") { doLast { println("sourceCompatibility = '17'") } } + sourceCompatibility = '21' + """, "21")] + // Quoted and nothing else, so there is no declaration at all. + [InlineData("""println("JavaLanguageVersion.of(17)")""", JavaVersionDetector.DefaultJavaVersion)] + // An unterminated literal runs to the end of the file for the compiler, so a declaration after it is + // not in effect either and must not be read. + [InlineData(""" + println("oops + java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } + """, JavaVersionDetector.DefaultJavaVersion)] + public void JavaVersionDetector_ReadsTheTargetReleaseFromAGradleBuildScript(string script, string expected) + { + using var appDirectory = new TempJavaAppDirectory(); + + File.WriteAllText(Path.Combine(appDirectory.Path, "build.gradle"), script); + + Assert.Equal(expected, JavaVersionDetector.Detect(appDirectory.Path)); + } + + [Fact] + public void JavaVersionDetector_PrefersThePomWhenBothBuildFilesArePresent() + { + using var appDirectory = new TempJavaAppDirectory(); + + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), """ + 17 + """); + File.WriteAllText(Path.Combine(appDirectory.Path, "build.gradle"), "sourceCompatibility = '21'"); + + Assert.Equal("17", JavaVersionDetector.Detect(appDirectory.Path)); + } + + [Theory] + [InlineData("gradle", "21")] + [InlineData("maven", "17")] + public void JavaVersionDetector_FollowsTheResolvedBuildToolWhenBothBuildFilesArePresent(string toolName, string expected) + { + // A repository part-way through a Maven to Gradle migration keeps both build files and picks the + // tool explicitly. Reading the other tool's file would tag the image for a release the application + // was not compiled for, which surfaces at runtime as UnsupportedClassVersionError rather than as a + // build failure. + using var appDirectory = new TempJavaAppDirectory(); + + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), """ + 17 + """); + File.WriteAllText(Path.Combine(appDirectory.Path, "build.gradle"), "sourceCompatibility = '21'"); + + var tool = toolName is "gradle" ? JavaBuildTool.Gradle : JavaBuildTool.Maven; + + Assert.Equal(expected, JavaVersionDetector.Detect(appDirectory.Path, tool)); + } + + [Fact] + public void JavaVersionDetector_FallsBackToTheOtherBuildFileWhenTheResolvedToolDeclaresNothing() + { + // Gradle projects commonly leave the release to a toolchain block this does not read. The POM is + // still better evidence than the default, so the tool preference is an ordering, not a filter. + using var appDirectory = new TempJavaAppDirectory(); + + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), """ + 17 + """); + File.WriteAllText(Path.Combine(appDirectory.Path, "build.gradle"), "plugins { id 'java' }"); + + Assert.Equal("17", JavaVersionDetector.Detect(appDirectory.Path, JavaBuildTool.Gradle)); + } + + [Fact] + public void JavaVersionDetector_IgnoresReleaseAndTargetOutsideTheCompilerPlugin() + { + using var appDirectory = new TempJavaAppDirectory(); + + // maven-antrun-plugin's holds Ant XML, and any plugin may name a of its own, + // so matching either element by its parent alone picks up unrelated values. + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), """ + + + + maven-antrun-plugin + 11 + + + maven-compiler-plugin + 21 + + + + """); + + Assert.Equal("21", JavaVersionDetector.Detect(appDirectory.Path)); + } + + [Fact] + public void JavaVersionDetector_ReadsAnExecutionLevelCompilerConfiguration() + { + using var appDirectory = new TempJavaAppDirectory(); + + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), """ + + + + maven-compiler-plugin + + default-compile + 17 + + + + + """); + + Assert.Equal("17", JavaVersionDetector.Detect(appDirectory.Path)); + } + + [Fact] + public void JavaVersionDetector_FallsBackWhenOnlyAnUnrelatedPluginDeclaresATarget() + { + using var appDirectory = new TempJavaAppDirectory(); + + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), """ + + + + maven-antrun-plugin + 11 + + + + """); + + Assert.Equal(JavaVersionDetector.DefaultJavaVersion, JavaVersionDetector.Detect(appDirectory.Path)); + } + + [Fact] + public void JavaVersionDetector_FallsBackWhenThePomCannotBeParsed() + { + // A malformed POM is the build tool's problem to report; publishing must not fail before the + // container build has even started. + using var appDirectory = new TempJavaAppDirectory(); + + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), ""); + + Assert.Equal(JavaVersionDetector.DefaultJavaVersion, JavaVersionDetector.Detect(appDirectory.Path)); + } + + [Fact] + public void ResolveBuildTool_ThrowsWhenNoBuildToolCanBeFound() + { + using var appDirectory = new TempJavaAppDirectory(); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", appDirectory.Path, "target/api.jar"); + + var exception = Assert.Throws( + () => JavaDockerfileGenerator.ResolveBuildTool(app.Resource, appDirectory.Path)); + + Assert.Equal( + $"The Java application 'api' cannot be published because no build tool was found. " + + $"Add a pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts to '{appDirectory.Path}', " + + "or call WithMavenBuild or WithGradleBuild " + + "to state how the deployable JAR is produced.", + exception.Message); + } + + [Theory] + [InlineData("build.gradle")] + [InlineData("build.gradle.kts")] + [InlineData("settings.gradle")] + [InlineData("settings.gradle.kts")] + public void ResolveBuildTool_DetectsGradleFromAnyOfItsBuildFiles(string fileName) + { + using var appDirectory = new TempJavaAppDirectory(); + File.WriteAllText(Path.Combine(appDirectory.Path, fileName), ""); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", appDirectory.Path, "build/libs/api.jar"); + + var (tool, args) = JavaDockerfileGenerator.ResolveBuildTool(app.Resource, appDirectory.Path); + + Assert.Equal(JavaBuildTool.Gradle, tool); + Assert.Equal(["--no-daemon", "-x", "test", "build"], args); + } + + [Fact] + public void ResolveBuildTool_PrefersTheConfiguredBuildStepOverWhatIsOnDisk() + { + // A pom.xml can sit next to a build.gradle in a repository that is mid-migration, so an explicit + // WithGradleBuild has to win over the file that happens to be checked first. + using var appDirectory = new TempJavaAppDirectory(); + File.WriteAllText(Path.Combine(appDirectory.Path, "pom.xml"), ""); + File.WriteAllText(Path.Combine(appDirectory.Path, "build.gradle"), ""); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", appDirectory.Path).WithGradleTask("bootRun").WithGradleBuild("bootJar"); + + var (tool, args) = JavaDockerfileGenerator.ResolveBuildTool(app.Resource, appDirectory.Path); + + Assert.Equal(JavaBuildTool.Gradle, tool); + // The container build always adds --no-daemon: a daemon started inside a RUN layer dies with + // the layer, so it only costs startup time and memory. + Assert.Equal(["--no-daemon", "bootJar"], args); + } + + [Fact] + public async Task ApplicationArgumentsSurvivePublishingWhileLaunchToolArgumentsDoNot() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + WritePom(sourceDir.FullName, javaVersion: "21"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddJavaApp("worker", sourceDir.FullName, "target/worker.jar", ["--interval-seconds", "10"]) + .WithMavenBuild(); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + + // PublishAsDockerFile swaps the executable for a container that shares this annotation collection, + // so the published resource carries the same name. + var published = Assert.Single(model.Resources.OfType(), r => r.Name == "worker"); + var args = await ArgumentEvaluator.GetArgumentListAsync(published, app.Services); + + // PublishAsDockerFile clears the arguments because they routinely contain host paths, and it does so + // when AddJavaApp runs. Anything added afterwards — which is every application argument, including + // the ones passed to the jarPath overload — is appended after that clear and therefore survives, + // while the launch tool arguments registered before it do not. The image's ENTRYPOINT is the JVM, so these + // reach main(String[]) exactly as they do when the resource runs on the host. + Assert.Equal(["--interval-seconds", "10"], args); + } + + [Fact] + public void PublishingWithoutAWrapperIsRejected() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + File.WriteAllText(Path.Combine(sourceDir.FullName, "pom.xml"), ""); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", sourceDir.FullName, "target/api.jar"); + + var ex = Assert.Throws( + () => JavaDockerfileGenerator.ResolveContainerBuildForTesting(app.Resource, sourceDir.FullName)); + + Assert.Contains("there is no mvnw", ex.Message); + Assert.Contains("mvn -N wrapper:wrapper", ex.Message); + } + + [Fact] + public void PublishingWithAWrapperOutsideTheBuildContextIsRejected() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var siblingDir = workspace.CreateDirectory("sibling"); + WritePom(sourceDir.FullName, javaVersion: "21"); + WriteWrapper(siblingDir.FullName, "mvnw"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", sourceDir.FullName) + .WithMavenGoal("spring-boot:run") + .WithWrapperPath(Path.Combine("..", "sibling", "mvnw")); + + var ex = Assert.Throws( + () => JavaDockerfileGenerator.ResolveContainerBuildForTesting(app.Resource, sourceDir.FullName)); + + // Only files under the context are uploaded to the daemon, so a wrapper outside it is not in the + // image and the build would fail partway through with an opaque "not found". + Assert.Contains("is outside the build context", ex.Message); + } + + [Theory] + [InlineData("../outside.jar")] + [InlineData("./../outside.jar")] + [InlineData("..\\outside.jar")] + [InlineData("nested/../../outside.jar")] + public void PublishingAJarOutsideTheBuildContextIsRejected(string jarPath) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", sourceDir.FullName, jarPath); + + // Normalizing with TrimStart('.', '/') used to eat the traversal itself, turning "../outside.jar" + // into "outside.jar" so this check never fired and the image silently COPYd the wrong file. + var ex = Assert.Throws( + () => JavaDockerfileGenerator.TryGetPrebuiltJarPath(app.Resource, sourceDir.FullName, out _)); + + Assert.Contains("is outside the build context", ex.Message); + } + + [Theory] + [InlineData("../outside.jar")] + [InlineData("./../outside.jar")] + [InlineData("nested/../../outside.jar")] + public void PublishingAJarArtifactOutsideTheBuildContextIsRejected(string artifactPath) + { + // WithJarArtifact reaches the build stage as a cp after only the application directory has been + // copied in, so a traversal names something that is not in the image at all. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + WritePom(sourceDir.FullName, javaVersion: "21"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", sourceDir.FullName) + .WithMavenBuild() + .WithJarArtifact(artifactPath); + + var ex = Assert.Throws( + () => JavaDockerfileGenerator.ResolveContainerBuildForTesting(app.Resource, sourceDir.FullName)); + + Assert.Contains("is outside the build context", ex.Message); + } + + [Theory] + [InlineData("target/worker.jar", "target/worker.jar")] + [InlineData("./target/worker.jar", "target/worker.jar")] + [InlineData("target\\worker.jar", "target/worker.jar")] + public void PublishingAJarInsideTheBuildContextKeepsItsContextRelativePath(string jarPath, string expected) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", sourceDir.FullName, jarPath); + + // A leading "./" is a normal way to write a context-relative path and must survive the tightened + // normalization, and Windows separators still have to become POSIX ones for the container. + Assert.True(JavaDockerfileGenerator.TryGetPrebuiltJarPath(app.Resource, sourceDir.FullName, out var resolved)); + Assert.Equal(expected, resolved); + } + + [Fact] + public async Task PublishingAJarPathPrefixedWithDotSlashStillResolvesInsideTheContext() + { + var content = await PublishDockerfileAsync(jarPath: "./target/worker.jar"); + + // A leading "./" is a normal way to write a context-relative path and must survive normalization. + Assert.Contains("COPY --chown=999:999 target/worker.jar /app/app.jar", content); + } + + [Theory] + [InlineData("maven", "scripts/custom-mvnw", "scripts/custom-mvnw.cmd")] + [InlineData("gradle", "scripts/custom-gradlew", "scripts/custom-gradlew.bat")] + public async Task PublishingWithAWindowsBatchWrapperUsesThePosixSibling( + string tool, + string posixWrapper, + string windowsWrapper) + { + var content = await PublishDockerfileAsync( + configureSource: source => + { + if (tool is "maven") + { + WritePom(source, javaVersion: "21"); + } + else + { + WriteGradleBuild(source, "sourceCompatibility = '21'"); + } + + var wrapperDirectory = Path.Combine(source, "scripts"); + WriteWrapper(wrapperDirectory, Path.GetFileName(posixWrapper)); + WriteWrapper(wrapperDirectory, Path.GetFileName(windowsWrapper)); + }, + configureResource: app => (tool is "maven" + ? app.WithMavenGoal("spring-boot:run") + : app.WithGradleTask("bootRun")) + .WithWrapperPath(windowsWrapper)); + + // Selecting the batch wrapper is reasonable on Windows, but the build stage is Linux and cannot + // execute it. Maven and Gradle ship both scripts, so the POSIX sibling is used for the image. + Assert.Contains($"sh ./{posixWrapper} ", content); + await Verify(content).UseParameters(tool); + } + + [Fact] + public void PublishingWithAWindowsBatchWrapperAndNoPosixSiblingIsRejected() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + WritePom(sourceDir.FullName, javaVersion: "21"); + + // WritePom ships the POSIX wrapper because publishing normally needs one. Remove it so the batch + // wrapper really is the only one present. + File.Delete(Path.Combine(sourceDir.FullName, "mvnw")); + WriteWrapper(sourceDir.FullName, "mvnw.cmd"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", sourceDir.FullName) + .WithMavenGoal("spring-boot:run") + .WithWrapperPath("mvnw.cmd"); + + var ex = Assert.Throws( + () => JavaDockerfileGenerator.ResolveContainerBuildForTesting(app.Resource, sourceDir.FullName)); + + Assert.Contains("Windows batch script", ex.Message); + } + + [Theory] + [InlineData("maven", "mvnw", ".mvn", "maven-wrapper.properties")] + [InlineData("gradle", "gradlew", "gradle", "gradle-wrapper.properties")] + public void PublishingRejectsAWrapperWithoutItsPropertiesFile( + string tool, + string wrapperName, + string supportDirectory, + string propertiesName) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + WriteWrapper(sourceDir.FullName, wrapperName); + Directory.Delete(Path.Combine(sourceDir.FullName, supportDirectory), recursive: true); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", sourceDir.FullName); + + _ = tool is "maven" + ? app.WithMavenGoal("spring-boot:run") + : app.WithGradleTask("bootRun"); + + var ex = Assert.Throws( + () => JavaDockerfileGenerator.ResolveContainerBuildForTesting(app.Resource, sourceDir.FullName)); + + Assert.Contains($"{supportDirectory}/wrapper/{propertiesName}", ex.Message); + } + + [Fact] + public async Task PublishingHonoursAWrapperSelectedWithWithWrapperPath() + { + var content = await PublishDockerfileAsync( + configureSource: source => + { + WritePom(source, javaVersion: "21"); + Directory.CreateDirectory(Path.Combine(source, "scripts")); + WriteWrapper(Path.Combine(source, "scripts"), "custom-mvnw"); + }, + configureResource: app => app + .WithMavenGoal("spring-boot:run") + .WithWrapperPath(Path.Combine("scripts", "custom-mvnw"))); + + // Without this the container silently built with a different Maven than the host did. + Assert.Contains("sh ./scripts/custom-mvnw", content); + } + + [Fact] + public async Task VerifyPublish_WithAPrebuiltJarAndNoBuildTool_CopiesTheJarWithoutABuildStage() + { + var content = await PublishDockerfileAsync( + configureSource: source => + { + Directory.CreateDirectory(Path.Combine(source, "target")); + File.WriteAllText(Path.Combine(source, "target", "worker.jar"), ""); + }, + jarPath: Path.Combine("target", "worker.jar")); + + // A runnable application must stay publishable. Requiring a build tool here made + // AddJavaApp(name, dir, jarPath) unpublishable even though it runs. + Assert.DoesNotContain("AS build", content); + Assert.Contains("COPY --chown=999:999 target/worker.jar /app/app.jar", content); + await Verify(content); + } + + [Fact] + public async Task VerifyPublish_WithAPrebuiltJar_WithJarArtifactHasNoEffect() + { + // Nothing is built in the image, so there is no build output for WithJarArtifact to select from. + // The XML doc on WithJarArtifact promises this, and it is the one case where it does not win. + var content = await PublishDockerfileAsync( + configureSource: source => + { + Directory.CreateDirectory(Path.Combine(source, "target")); + File.WriteAllText(Path.Combine(source, "target", "worker.jar"), ""); + }, + jarPath: Path.Combine("target", "worker.jar"), + configureResource: app => app.WithJarArtifact("target/other.jar")); + + Assert.DoesNotContain("AS build", content); + Assert.Contains("COPY --chown=999:999 target/worker.jar /app/app.jar", content); + } + + [Fact] + public async Task PublishingAPrebuiltJarReincludesItAndItsDirectoriesInTheBuildContext() + { + var ignore = await PublishBuildContextIgnoreAsync( + configureSource: source => + { + Directory.CreateDirectory(Path.Combine(source, "target")); + File.WriteAllText(Path.Combine(source, "target", "worker.jar"), ""); + }, + jarPath: Path.Combine("target", "worker.jar")); + + // "target" is excluded by default because it is routinely hundreds of megabytes, and Docker does + // not descend into an excluded directory, so re-including only the JAR would never match. + Assert.NotNull(ignore); + Assert.Contains("\n!target\n", ignore); + Assert.Contains("\n!target/worker.jar\n", ignore); + } + + [Fact] + public async Task APrebuiltJarAlongsideAPomIsStillBuiltInTheImage() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + jarPath: Path.Combine("target", "api.jar")); + + // A JAR path next to a pom.xml names the artifact the build produces, not one that already exists, + // so the image has to build it rather than copy a file that is not in the context. + Assert.Contains("AS build", content); + Assert.Contains("sh ./mvnw", content); + } + + [Fact] + [RequiresFeature(TestFeature.Docker | TestFeature.ContainerImageBuild)] + [OuterloopTest("Builds and runs a Docker image to verify the generated Java Dockerfile works")] + public async Task VerifyPublish_PrebuiltJarImageBuildsAndRunsWithItsArguments() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var outputDir = workspace.CreateDirectory("output"); + + await File.WriteAllTextAsync( + Path.Combine(sourceDir.FullName, "App.java"), + """ + public class App { + public static void main(String[] args) { + System.out.println("runtime ok: " + String.join(" ", args)); + } + } + """, + TestContext.Current.CancellationToken); + + // The JAR is produced inside a JDK container so this test needs Docker and nothing else. A JDK on + // the agent would work too, but only the runtime image's JDK is guaranteed to emit class files the + // runtime image can load. + var buildJarResult = await RunDockerCommandAsync( + $"run --rm -v {sourceDir.FullName}:/work -w /work docker.io/library/eclipse-temurin:{JavaVersionDetector.DefaultJavaVersion}-jdk " + + "sh -c \"javac -d classes App.java && mkdir -p target && jar --create --file target/app.jar --main-class App -C classes .\"", + sourceDir.FullName); + Assert.True(buildJarResult.ExitCode == 0, $"Building the test JAR failed.\nStdout: {buildJarResult.Stdout}\nStderr: {buildJarResult.Stderr}"); + + using (var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest")) + { + builder.AddJavaApp("api", sourceDir.FullName, Path.Combine("target", "app.jar"), ["--greeting", "hello"]); + builder.Build().Run(); + } + + // The image never bakes in the application arguments, exactly like every other container + // resource: they are part of the deployment spec, so the manifest carries them and the runtime + // appends them to the entrypoint. Reading them back and passing them to docker run is what makes + // this test cover the published pair rather than the image alone. + using var manifest = JsonDocument.Parse( + await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "aspire-manifest.json"), TestContext.Current.CancellationToken)); + var manifestArgs = manifest.RootElement + .GetProperty("resources").GetProperty("api").GetProperty("args") + .EnumerateArray().Select(arg => arg.GetString()!).ToArray(); + + Assert.Equal(["--greeting", "hello"], manifestArgs); + + // Copied into the build context under the names docker build expects, so the generated ignore + // file is exercised too. Its target re-includes are what let the JAR into the image at all. + File.Copy( + Path.Combine(outputDir.FullName, "api.Dockerfile"), + Path.Combine(sourceDir.FullName, "Dockerfile")); + File.Copy( + Path.Combine(outputDir.FullName, "api.Dockerfile.dockerignore"), + Path.Combine(sourceDir.FullName, ".dockerignore")); + + var imageName = $"aspire-java-test-{Guid.NewGuid():N}"; + + try + { + var buildResult = await RunDockerCommandAsync($"build --network=host -t {imageName} -f Dockerfile .", sourceDir.FullName); + Assert.True(buildResult.ExitCode == 0, $"Docker build failed with exit code {buildResult.ExitCode}.\nStdout: {buildResult.Stdout}\nStderr: {buildResult.Stderr}"); + + // No network, so a passing run cannot depend on anything being downloaded at start up. + var runResult = await RunDockerCommandAsync($"run --rm --network=none {imageName} {string.Join(' ', manifestArgs)}", sourceDir.FullName); + Assert.True(runResult.ExitCode == 0, $"Docker run failed with exit code {runResult.ExitCode}.\nStdout: {runResult.Stdout}\nStderr: {runResult.Stderr}"); + + // PublishAsDockerFile clears the executable's arguments, so this also pins that the application + // arguments added after it survive publishing and reach the JAR through the entrypoint. + Assert.Contains("runtime ok: --greeting hello", runResult.Stdout); + } + finally + { + await RunDockerCommandAsync($"rmi {imageName}", sourceDir.FullName); + } + } + + [Fact] + [RequiresFeature(TestFeature.Docker | TestFeature.ContainerImageBuild)] + [OuterloopTest("Builds a real Maven wrapper project inside the generated image and runs it")] + public async Task VerifyPublish_WrapperBuiltImageBuildsTheProjectAndRunsIt() + { + // The prebuilt-JAR test above never exercises the build stage. This one covers the path almost + // every real project takes: the image runs the project's own wrapper, the wrapper downloads the + // pinned Maven, the build produces the JAR, and the runtime stage starts it as a non-root user. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var outputDir = workspace.CreateDirectory("output"); + + await File.WriteAllTextAsync( + Path.Combine(sourceDir.FullName, "pom.xml"), + $""" + + + 4.0.0 + com.example + api + 1.0.0 + + {JavaVersionDetector.DefaultJavaVersion} + UTF-8 + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + App + + + + + + + + """, + TestContext.Current.CancellationToken); + + var sourcePath = Directory.CreateDirectory(Path.Combine(sourceDir.FullName, "src", "main", "java")); + await File.WriteAllTextAsync( + Path.Combine(sourcePath.FullName, "App.java"), + """ + import java.nio.file.Files; + import java.nio.file.Path; + + public class App { + public static void main(String[] args) throws Exception { + // The runtime stage switches to a numeric UID with no /etc/passwd entry, so + // System.getProperty("user.name") reports "?" rather than an account name. Read the + // real UID from procfs instead, which is what USER actually changed. + System.out.println("wrapper build ok: uid=" + Files.getAttribute(Path.of("/proc/self"), "unix:uid")); + } + } + """, + TestContext.Current.CancellationToken); + + // Generated with a real Maven so the wrapper and its properties file are the genuine article + // rather than a stub that would never prove the download-and-unpack layer works. + var wrapperResult = await RunDockerCommandAsync( + $"run --rm -v {sourceDir.FullName}:/work -w /work docker.io/library/maven:3.9-eclipse-temurin-{JavaVersionDetector.DefaultJavaVersion} " + + "mvn -B -q -N wrapper:wrapper", + sourceDir.FullName); + Assert.True(wrapperResult.ExitCode == 0, $"Generating the Maven wrapper failed.\nStdout: {wrapperResult.Stdout}\nStderr: {wrapperResult.Stderr}"); + + using (var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest")) + { + builder.AddJavaApp("api", sourceDir.FullName) + .WithMavenBuild() + .WithJarArtifact("target/api-1.0.0.jar"); + + builder.Build().Run(); + } + + File.Copy( + Path.Combine(outputDir.FullName, "api.Dockerfile"), + Path.Combine(sourceDir.FullName, "Dockerfile")); + File.Copy( + Path.Combine(outputDir.FullName, "api.Dockerfile.dockerignore"), + Path.Combine(sourceDir.FullName, ".dockerignore")); + + var imageName = $"aspire-java-wrapper-test-{Guid.NewGuid():N}"; + + try + { + // The build stage needs the network to download Maven and the plugins, so unlike the prebuilt + // JAR test this one cannot build offline. + var buildResult = await RunDockerCommandAsync($"build --network=host -t {imageName} -f Dockerfile .", sourceDir.FullName); + Assert.True(buildResult.ExitCode == 0, $"Docker build failed with exit code {buildResult.ExitCode}.\nStdout: {buildResult.Stdout}\nStderr: {buildResult.Stderr}"); + + var runResult = await RunDockerCommandAsync($"run --rm --network=none {imageName}", sourceDir.FullName); + Assert.True(runResult.ExitCode == 0, $"Docker run failed with exit code {runResult.ExitCode}.\nStdout: {runResult.Stdout}\nStderr: {runResult.Stderr}"); + + // The runtime stage switches to an unprivileged numeric UID, so seeing 999 here proves both + // that the wrapper build produced a runnable JAR and that USER took effect. + Assert.Contains("wrapper build ok: uid=999", runResult.Stdout); + } + finally + { + await RunDockerCommandAsync($"rmi {imageName}", sourceDir.FullName); + } + } + + private static async Task<(int ExitCode, string Stdout, string Stderr)> RunDockerCommandAsync(string arguments, string workingDirectory) + { + using var process = Process.Start(new ProcessStartInfo + { + FileName = "docker", + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + + Assert.NotNull(process); + + // Both streams are read concurrently so a full pipe buffer cannot deadlock the build output. + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return (process.ExitCode, await stdoutTask, await stderrTask); + } + + [Fact] + public async Task VerifyPublish_Quarkus_StagesTheFastJarDirectoryAndRunsQuarkusRunJar() + { + var content = await PublishQuarkusDockerfileAsync(source => WritePom(source, javaVersion: "21")); + + // The fast JAR's manifest Class-Path names lib/, app/, and quarkus/ relatively, so copying only + // quarkus-run.jar produces an image that starts and immediately fails to find its own classes. + Assert.Contains("cp -r target/quarkus-app/. /build/app/", content, StringComparison.Ordinal); + Assert.Contains("COPY --from=build --chown=999:999 /build/app /app", content, StringComparison.Ordinal); + Assert.Contains("ENTRYPOINT [\"java\",\"-jar\",\"/app/quarkus-run.jar\"]", content, StringComparison.Ordinal); + await Verify(content); + } + + [Fact] + public async Task VerifyPublish_Quarkus_Gradle_StagesFromTheGradleOutputDirectory() + { + var content = await PublishQuarkusDockerfileAsync(source => WriteGradleBuild(source, """ + plugins { + id 'io.quarkus' + } + java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + } + """)); + + Assert.Contains("cp -r build/quarkus-app/. /build/app/", content, StringComparison.Ordinal); + + // The Gradle daemon outlives the RUN instruction's shell and is killed with the layer, so it only + // costs startup time here. The framework defaults do not carry the flag because run mode wants the + // daemon, which is why the container build has to add it. + Assert.Contains("sh ./gradlew --no-daemon build -x test", content, StringComparison.Ordinal); + await Verify(content); + } + + [Fact] + public async Task VerifyPublish_Quarkus_FallsBackToASingleRunnerJarForUberJarPackaging() + { + // The packaging type is chosen in application configuration, which the AppHost cannot read, so both + // shapes have to be handled in the build stage where the output already exists. + var content = await PublishQuarkusDockerfileAsync(source => WritePom(source, javaVersion: "21")); + + Assert.Contains("if [ -d target/quarkus-app ]; then", content, StringComparison.Ordinal); + Assert.Contains("cp \"$jars\" /build/app/quarkus-run.jar", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_Quarkus_StagesTheDependencyDirectoryForLegacyJarPackaging() + { + var content = await PublishQuarkusDockerfileAsync(source => WritePom(source, javaVersion: "21")); + + // legacy-jar leaves the runner beside target/lib rather than inside quarkus-app, and the runner's + // manifest names those dependencies as "lib/..." relative to itself, so the directory has to travel + // with it. Staging the runner alone produces an image that dies on startup with + // "NoClassDefFoundError: io/quarkus/runtime/Quarkus". + Assert.Contains( + "if [ -d target/lib ]; then cp -r target/lib /build/app/lib; fi", + content, + StringComparison.Ordinal); + } + + [Theory] + [InlineData("fast-jar")] + [InlineData("legacy-jar")] + [InlineData("uber-jar")] + [InlineData("fast-jar-without-metadata")] + public async Task VerifyPublish_Quarkus_StagesEveryPackagingTypeSoTheRunnerCanResolveItsDependencies(string packagingType) + { + if (OperatingSystem.IsWindows()) + { + Assert.Skip("The staging command is POSIX shell that only ever runs inside the Linux build stage."); + } + + var content = await PublishQuarkusDockerfileAsync(source => WritePom(source, javaVersion: "21")); + + // The staging command is appended last to the build stage's RUN, so it runs from the marker to the + // end of that line. Exercising the emitted shell verbatim is the only way to catch a packaging + // layout it mishandles; asserting on the string only proves the string. + var start = content.IndexOf($"mkdir -p {StagedArtifactDirectory} &&", StringComparison.Ordinal); + Assert.NotEqual(-1, start); + var end = content.IndexOf('\n', start); + var stagingCommand = content[start..(end < 0 ? content.Length : end)]; + + using var workspace = new TempJavaAppDirectory(withWrappers: false); + var stagingDirectory = Path.Combine(workspace.Path, "staged"); + + // These layouts are what the three packaging types actually write, taken from clean builds of the + // JavaQuarkus playground rather than from the documentation. + switch (packagingType) + { + case "fast-jar": + workspace.Write("target/quarkus-app/quarkus-run.jar", "runner"); + workspace.Write("target/quarkus-app/lib/dependency.jar", "dependency"); + // The thin JAR the base plugin wrote stays in the output directory in every packaging type, + // which is why the runner cannot be picked by globbing for a single JAR. + workspace.Write("target/inventory-1.0.0-SNAPSHOT.jar", "thin"); + workspace.Write("target/quarkus-artifact.properties", "type=jar\npath=quarkus-app/quarkus-run.jar\n"); + break; + case "legacy-jar": + workspace.Write("target/inventory-1.0.0-SNAPSHOT-runner.jar", "runner"); + workspace.Write("target/lib/dependency.jar", "dependency"); + workspace.Write("target/inventory-1.0.0-SNAPSHOT.jar", "thin"); + workspace.Write("target/quarkus-artifact.properties", "type=jar\npath=inventory-1.0.0-SNAPSHOT-runner.jar\n"); + break; + case "uber-jar": + workspace.Write("target/inventory-1.0.0-SNAPSHOT-runner.jar", "runner"); + workspace.Write("target/quarkus-artifact.properties", "type=jar\npath=inventory-1.0.0-SNAPSHOT-runner.jar\n"); + break; + case "fast-jar-without-metadata": + // A Quarkus old enough not to write the metadata file still has to publish, which is what + // the directory probe behind the empty case is for. + workspace.Write("target/quarkus-app/quarkus-run.jar", "runner"); + workspace.Write("target/quarkus-app/lib/dependency.jar", "dependency"); + workspace.Write("target/inventory-1.0.0-SNAPSHOT.jar", "thin"); + break; + } + + var (exitCode, _, stderr) = await RunShellAsync( + stagingCommand.Replace(StagedArtifactDirectory, stagingDirectory, StringComparison.Ordinal), + workspace.Path); + + Assert.True(exitCode == 0, $"Staging {packagingType} failed with exit code {exitCode}: {stderr}"); + + // Every packaging type has to normalise to the same shape, because the runtime stage copies one + // directory and runs one well-known file name out of it. + Assert.Equal("runner", File.ReadAllText(Path.Combine(stagingDirectory, "quarkus-run.jar"))); + + var expectedDependency = Path.Combine(stagingDirectory, "lib", "dependency.jar"); + if (packagingType is "uber-jar") + { + Assert.False(Directory.Exists(Path.Combine(stagingDirectory, "lib"))); + } + else + { + Assert.Equal("dependency", File.ReadAllText(expectedDependency)); + } + } + + private const string StagedArtifactDirectory = "/build/app"; + + private static async Task<(int ExitCode, string StandardOutput, string StandardError)> RunShellAsync(string command, string workingDirectory) + { + using var process = Process.Start(new ProcessStartInfo("/bin/sh") + { + ArgumentList = { "-c", command }, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true + }); + + Assert.NotNull(process); + + // Both streams are read concurrently so a full pipe buffer cannot deadlock the staging output. + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return (process.ExitCode, await stdoutTask, await stderrTask); + } + + [Fact] + public async Task VerifyPublish_Quarkus_WithJarArtifact_StagesThatFileInstead() + { + // Naming a single artifact is how an application that does not use the fast JAR layout at all opts + // out, so it has to keep staging as one file. + var content = await PublishQuarkusDockerfileAsync( + source => WritePom(source, javaVersion: "21"), + app => app.WithJarArtifact("target/api-runner.jar")); + + Assert.Contains("cp 'target/api-runner.jar' /build/app.jar", content, StringComparison.Ordinal); + Assert.Contains("COPY --from=build --chown=999:999 /build/app.jar /app/app.jar", content, StringComparison.Ordinal); + Assert.Contains("ENTRYPOINT [\"java\",\"-jar\",\"/app/app.jar\"]", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_InstallsUnzipWhenTheMavenWrapperPinsADistributionChecksum() + { + // mvnw downloads the .tar.gz instead of the .zip when unzip is missing, then validates it against + // the .zip's checksum and stops. The Temurin build images have no unzip and the Quarkus generator + // pins this checksum by default, so without the install the build fails for every such project. + var content = await PublishDockerfileAsync( + configureSource: source => + { + WritePom(source, javaVersion: "21"); + File.AppendAllText( + Path.Combine(source, ".mvn", "wrapper", "maven-wrapper.properties"), + "distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce\n"); + }, + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + var unzipIndex = content.IndexOf("apt-get install -y --no-install-recommends unzip", StringComparison.Ordinal); + var wrapperIndex = content.IndexOf("./mvnw -B -ntp --version", StringComparison.Ordinal); + + Assert.NotEqual(-1, unzipIndex); + // Installed before the wrapper runs, because the wrapper is what fails without it. + Assert.True(unzipIndex < wrapperIndex, "unzip must be installed before the wrapper runs"); + } + + [Fact] + public async Task VerifyPublish_DoesNotInstallUnzipWhenNoChecksumIsPinned() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + // unzip only exists to verify a pinned wrapper checksum. Assert the wrapper still runs, so this + // keeps failing if the build stage disappears rather than passing on an empty Dockerfile. + Assert.Contains("./mvnw -B -ntp --version", content, StringComparison.Ordinal); + Assert.DoesNotContain("unzip", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_RaisesTheBuildJdkWhenTheProjectTargetsAnOlderRelease() + { + // Maven 4 refuses to start on anything below Java 17, so a build stage matching an older target + // would die before compiling. The runtime stage still matches the target, because that is what the + // produced bytecode needs. + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "8", mavenVersion: "4.0.0"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Contains("FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:17-jdk AS build", content, StringComparison.Ordinal); + Assert.Contains("FROM docker.io/library/eclipse-temurin:8-jre", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_RaisesTheBuildJdkForGradle9() + { + var content = await PublishDockerfileAsync( + configureSource: source => + { + WriteGradleBuild(source, "sourceCompatibility = '11'", gradleVersion: "9.0.0"); + }, + configureResource: app => app.WithGradleTask("bootRun")); + + Assert.Contains("FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:17-jdk AS build", content, StringComparison.Ordinal); + Assert.Contains("FROM docker.io/library/eclipse-temurin:11-jre", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_KeepsTheBuildJdkOnTheTargetForAToolThatCannotRunOnANewerJdk() + { + // Gradle releases before 7.3 cannot run *on* Java 17, and are exactly what an old Java 8 project + // tends to pin. Raising the build stage unconditionally would break them. + var content = await PublishDockerfileAsync( + configureSource: source => + { + WriteGradleBuild(source, "sourceCompatibility = '8'", gradleVersion: "6.9.4"); + }, + configureResource: app => app.WithGradleTask("bootRun")); + + Assert.Contains("FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:8-jdk AS build", content, StringComparison.Ordinal); + Assert.Contains("FROM docker.io/library/eclipse-temurin:8-jre", content, StringComparison.Ordinal); + } + + [Theory] + [InlineData("target/my app.jar")] + [InlineData(@"target\my app.jar")] + public void VerifyPublish_RejectsAJarPathContainingWhitespace(string jarPath) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path, jarPath); + + // The Dockerfile builder emits the shell form of COPY, which splits on whitespace, so this would + // become three arguments and copy two paths that do not exist. + var exception = Assert.Throws( + () => JavaDockerfileGenerator.TryGetPrebuiltJarPath(app.Resource, tempDir.Path, out _)); + + Assert.Contains(jarPath, exception.Message, StringComparison.Ordinal); + Assert.Contains("contains whitespace", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void VerifyPublish_RejectsAnOtelAgentPathContainingWhitespace() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenGoal("spring-boot:run") + .WithOtelAgent("target/otel agents/javaagent.jar"); + + var exception = Assert.Throws( + () => JavaDockerfileGenerator.TryGetBuildProducedAgentPath(app.Resource, out _)); + + Assert.Contains("target/otel agents/javaagent.jar", exception.Message, StringComparison.Ordinal); + Assert.Contains("contains whitespace", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_CapsTheBuildJdkAtWhatTheGradleWrapperCanRunOn() + { + // Gradle 8.4 can target Java 21 through a toolchain but cannot run on it - that starts at 8.5 - + // so the build stage stays on 20 while the runtime image keeps the targeted 21. + // https://docs.gradle.org/current/userguide/compatibility.html + var content = await PublishDockerfileAsync( + configureSource: source => WriteGradleBuild(source, "sourceCompatibility = '21'", gradleVersion: "8.4"), + configureResource: app => app.WithGradleTask("bootRun")); + + Assert.Contains("FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:20-jdk AS build", content, StringComparison.Ordinal); + Assert.Contains("FROM docker.io/library/eclipse-temurin:21-jre", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_AcceptsTheFirstGradleReleaseThatRunsOnTheTargetedJdk() + { + var content = await PublishDockerfileAsync( + configureSource: source => WriteGradleBuild(source, "sourceCompatibility = '21'", gradleVersion: "8.5"), + configureResource: app => app.WithGradleTask("bootRun")); + + Assert.Contains("FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_DoesNotApplyTheGradleCeilingToMaven() + { + // Maven has no equivalent runtime ceiling, so a Maven wrapper never blocks a newer target. + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "25", mavenVersion: "3.9.9"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Contains("FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:25-jdk AS build", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_KeepsTheBuildJdkOnTheTargetWhenItIsNewEnough() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Contains("FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build", content, StringComparison.Ordinal); + Assert.Contains("FROM docker.io/library/eclipse-temurin:21-jre", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_BuildsOnTheTargetPlatformWhenMavenSelectsNativeDependenciesByHostArchitecture() + { + // os-maven-plugin resolves ${os.detected.classifier} from the machine running the build, so a JAR + // built on the build platform would carry the wrong native library into the runtime image and only + // fail on the first call into it. + var content = await PublishDockerfileAsync( + configureSource: source => + { + WritePom(source, javaVersion: "21"); + File.WriteAllText( + Path.Combine(source, "pom.xml"), + File.ReadAllText(Path.Combine(source, "pom.xml")).Replace( + "", + """ + + + io.netty + netty-tcnative-boringssl-static + ${os.detected.classifier} + + + + """, + StringComparison.Ordinal)); + }, + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Contains("FROM docker.io/library/eclipse-temurin:21-jdk AS build", content, StringComparison.Ordinal); + Assert.DoesNotContain("--platform=$BUILDPLATFORM", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_BuildsOnTheTargetPlatformWhenGradleUsesTheOsDetectorPlugin() + { + var content = await PublishDockerfileAsync( + configureSource: source => WriteGradleBuild(source, """ + plugins { + id 'com.google.osdetector' version '1.7.3' + } + + sourceCompatibility = '21' + """), + configureResource: app => app.WithGradleTask("bootRun")); + + Assert.Contains("FROM docker.io/library/eclipse-temurin:21-jdk AS build", content, StringComparison.Ordinal); + Assert.DoesNotContain("--platform=$BUILDPLATFORM", content, StringComparison.Ordinal); + } + + [Fact] + public void PublishingWithAWrapperPathContainingWhitespaceIsRejected() + { + // COPY separates its arguments on whitespace with no quoted form available here, so such a path + // would copy two paths that do not exist rather than the one that does. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + WritePom(sourceDir.FullName, javaVersion: "21"); + var nested = Directory.CreateDirectory(Path.Combine(sourceDir.FullName, "build tools")); + WriteWrapper(nested.FullName, "mvnw"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", sourceDir.FullName) + .WithMavenGoal("spring-boot:run") + .WithWrapperPath(Path.Combine("build tools", "mvnw")); + + var ex = Assert.Throws( + () => JavaDockerfileGenerator.ResolveContainerBuildForTesting(app.Resource, sourceDir.FullName)); + + Assert.Contains("contains whitespace", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task PublishingAfterChangingTheWorkingDirectoryIsRejected() + { + // PublishAsDockerFile fixes the build context when AddJavaApp runs, and ContextPath cannot be + // changed afterwards, so a later WithWorkingDirectory would build the image from the original + // directory while the resource points somewhere else. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var movedDir = workspace.CreateDirectory("moved"); + var outputDir = workspace.CreateDirectory("output"); + + WritePom(sourceDir.FullName, javaVersion: "21"); + WritePom(movedDir.FullName, javaVersion: "21"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest"); + builder.AddJavaApp("api", sourceDir.FullName) + .WithMavenGoal("spring-boot:run") + .WithWorkingDirectory(movedDir.FullName); + + builder.Build().Run(); + + // The failure surfaces as a missing Dockerfile because the publishing callback's exception is + // recorded against the step rather than rethrown from Run. + Assert.False( + File.Exists(Path.Combine(outputDir.FullName, "api.Dockerfile")), + "No Dockerfile should be generated from a build context the resource no longer points at."); + + await Task.CompletedTask; + } + + private async Task PublishQuarkusDockerfileAsync( + Action configureSource, + Func, IResourceBuilder>? configureResource = null) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var outputDir = workspace.CreateDirectory("output"); + + configureSource(sourceDir.FullName); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest"); + + // Assigned first because `configureResource?.Invoke(builder.AddQuarkusApp(...))` skips evaluating the + // argument entirely when the delegate is null, which would add no resource at all. + var app = builder.AddQuarkusApp("api", sourceDir.FullName); + + configureResource?.Invoke(app); + + builder.Build().Run(); + + return await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken); + } + + [Fact] + public async Task VerifyPublish_ExplicitCompilerReleaseWinsOverTheSpringBootProperty() + { + // java.version is not a Maven property. It works only because spring-boot-starter-parent maps it + // onto maven.compiler.release, and a POM that sets the real property overrides that mapping - so + // Maven emits 21 here and a Java 17 runtime image would fail with UnsupportedClassVersionError. + var content = await PublishDockerfileAsync( + configureSource: source => WritePomProperties(source, """ + 17 + 21 + """), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Contains("docker.io/library/eclipse-temurin:21-jre", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_CompilerPluginReleaseWinsOverTheSpringBootProperty() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePomWithCompilerPlugin(source, javaVersion: "17", release: "21"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Contains("docker.io/library/eclipse-temurin:21-jre", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_SpringBootPropertyStillAppliesWhenItIsTheOnlySignal() + { + // The overwhelmingly common Spring Boot POM. Reordering the precedence must not break it. + var content = await PublishDockerfileAsync( + configureSource: source => WritePomProperties(source, "17"), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Contains("docker.io/library/eclipse-temurin:17-jre", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_SpringBootPropertyResolvesThroughAnUnexpandedReference() + { + // The Spring Boot parent's own mapping, written out in the child POM. maven.compiler.release + // holds a property reference this cannot expand, so detection has to keep looking rather than + // treat the unresolved value as a version. + var content = await PublishDockerfileAsync( + configureSource: source => WritePomProperties(source, """ + 21 + ${java.version} + """), + configureResource: app => app.WithMavenGoal("spring-boot:run")); + + Assert.Contains("docker.io/library/eclipse-temurin:21-jre", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_UsesTheNamedJarWhenTheBuildRunsInTheImage() + { + // The AppHost already said which JAR matters. Falling back to the glob would fail the + // "expected exactly one" check for any build that emits a second JAR, such as a shade plugin + // leaving original-*.jar beside the shaded artifact. + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + jarPath: "target/worker.jar", + configureResource: app => app.WithMavenBuild()); + + Assert.Contains("cp 'target/worker.jar' /build/app.jar", content, StringComparison.Ordinal); + Assert.DoesNotContain("expected exactly one application JAR", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_NamedJarWindowsSeparatorsBecomePosix() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + jarPath: @"target\worker.jar", + configureResource: app => app.WithMavenBuild()); + + Assert.Contains("cp 'target/worker.jar' /build/app.jar", content, StringComparison.Ordinal); + } + + [Fact] + public void VerifyPublish_NamedJarOutsideTheContextIsRejected() + { + // Falling back to the glob would publish whichever JAR the build emitted rather than the one the + // AppHost named, so the image would quietly run different code than run mode does. + using var appDirectory = new TempJavaAppDirectory(); + WritePom(appDirectory.Path, javaVersion: "21"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", appDirectory.Path, "../shared/worker.jar").WithMavenBuild(); + + var exception = Assert.Throws( + () => JavaDockerfileGenerator.TryGetBuildOutputJarPath(app.Resource, out _)); + + Assert.Contains("../shared/worker.jar", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(@"C:\artifacts\app.jar")] + [InlineData(@"\\build-server\artifacts\app.jar")] + [InlineData("C:app.jar")] + public void VerifyPublish_AWindowsAbsoluteJarPathIsRejectedOnEveryPlatform(string jarPath) + { + // Path.IsPathRooted only applies the host's rules, so publishing an AppHost authored on Windows + // from Linux CI used to accept these as build-relative, rewrite the backslashes for the container + // and build an image against a path that cannot exist. The verdict has to be the same everywhere. + using var appDirectory = new TempJavaAppDirectory(); + WritePom(appDirectory.Path, javaVersion: "21"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", appDirectory.Path, jarPath).WithMavenBuild(); + + var exception = Assert.Throws( + () => JavaDockerfileGenerator.TryGetBuildOutputJarPath(app.Resource, out _)); + + // Keep the authored spelling in diagnostics even when the path uses separators from another host. + Assert.Contains(jarPath, exception.Message, StringComparison.Ordinal); + Assert.Contains("outside the directory the build runs in", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void VerifyPublish_AWindowsAbsoluteJarArtifactIsRejectedOnEveryPlatform() + { + using var appDirectory = new TempJavaAppDirectory(); + WritePom(appDirectory.Path, javaVersion: "21"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", appDirectory.Path).WithMavenBuild().WithJarArtifact(@"C:\out\app.jar"); + + var exception = Assert.Throws( + () => JavaDockerfileGenerator.JavaContainerBuild.Resolve(app.Resource, appDirectory.Path)); + + Assert.Contains("outside the build context", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void VerifyPublish_AWindowsAbsoluteWrapperIsRejectedOnEveryPlatform() + { + using var appDirectory = new TempJavaAppDirectory(); + WritePom(appDirectory.Path, javaVersion: "21"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var app = builder.AddJavaApp("api", appDirectory.Path).WithMavenBuild().WithWrapperPath(@"C:\tools\mvnw"); + + var exception = Assert.Throws( + () => JavaDockerfileGenerator.JavaContainerBuild.Resolve(app.Resource, appDirectory.Path)); + + Assert.Contains("outside the build context", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_ExplicitJarArtifactWinsOverTheNamedJar() + { + var content = await PublishDockerfileAsync( + configureSource: source => WritePom(source, javaVersion: "21"), + jarPath: "target/worker.jar", + configureResource: app => app.WithMavenBuild().WithJarArtifact("target/shaded.jar")); + + Assert.Contains("cp 'target/shaded.jar' /build/app.jar", content, StringComparison.Ordinal); + } + + [Fact] + public async Task VerifyPublish_MovingTheWorkingDirectoryAfterwardsFailsWithAnActionableMessage() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var elsewhere = workspace.CreateDirectory("elsewhere"); + + WritePom(sourceDir.FullName, javaVersion: "21"); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddJavaApp("api", sourceDir.FullName) + .WithMavenBuild() + .WithWorkingDirectory(elsewhere.FullName); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + + // The build context is fixed when the resource is added and cannot move afterwards, so the + // Dockerfile callback has to refuse rather than build an image from a directory the author no + // longer points at. The message is what the author acts on, so assert it names both directories. + var annotation = model.Resources + .SelectMany(resource => resource.Annotations.OfType()) + .Single(); + + var context = new DockerfileFactoryContext + { + Services = app.Services, + Resource = model.Resources.Single(resource => resource.Name == "api"), + CancellationToken = TestContext.Current.CancellationToken + }; + + var exception = await Assert.ThrowsAsync( + () => annotation.DockerfileFactory!(context)); + + Assert.Contains("its working directory was changed", exception.Message, StringComparison.Ordinal); + Assert.Contains(elsewhere.FullName, exception.Message, StringComparison.Ordinal); + Assert.Contains(sourceDir.FullName, exception.Message, StringComparison.Ordinal); + } + + private async Task PublishDockerfileAsync( + Action? configureSource = null, + string? jarPath = null, + Func, IResourceBuilder>? configureResource = null) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var outputDir = workspace.CreateDirectory("output"); + + configureSource?.Invoke(sourceDir.FullName); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest"); + + var app = jarPath is null + ? builder.AddJavaApp("api", sourceDir.FullName) + : builder.AddJavaApp("api", sourceDir.FullName, jarPath); + + configureResource?.Invoke(app); + + builder.Build().Run(); + + return await File.ReadAllTextAsync(Path.Combine(outputDir.FullName, "api.Dockerfile"), TestContext.Current.CancellationToken); + } + + private async Task PublishBuildContextIgnoreAsync( + Action? configureSource = null, + string? jarPath = null, + Func, IResourceBuilder>? configureResource = null) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sourceDir = workspace.CreateDirectory("source"); + var outputDir = workspace.CreateDirectory("output"); + + configureSource?.Invoke(sourceDir.FullName); + + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.FullName, step: "publish-manifest"); + + var app = jarPath is null + ? builder.AddJavaApp("api", sourceDir.FullName) + : builder.AddJavaApp("api", sourceDir.FullName, jarPath); + + configureResource?.Invoke(app); + + builder.Build().Run(); + + var ignorePath = Path.Combine(outputDir.FullName, "api.Dockerfile.dockerignore"); + + return File.Exists(ignorePath) + ? await File.ReadAllTextAsync(ignorePath, TestContext.Current.CancellationToken) + : null; + } + + private static void WritePom(string sourceDirectory, string javaVersion, string mavenVersion = "3.9.9") + { + // Publishing requires a wrapper, so every project that publishes has to ship one. + WriteWrapper(sourceDirectory, "mvnw", mavenVersion); + File.WriteAllText(Path.Combine(sourceDirectory, "pom.xml"), $""" + + + 4.0.0 + com.example + api + 0.0.1-SNAPSHOT + + {javaVersion} + + + """); + } + + private static void WritePomProperties(string sourceDirectory, string properties, string mavenVersion = "3.9.9") + { + WriteWrapper(sourceDirectory, "mvnw", mavenVersion); + File.WriteAllText(Path.Combine(sourceDirectory, "pom.xml"), $""" + + + 4.0.0 + com.example + api + 0.0.1-SNAPSHOT + + {properties} + + + """); + } + + private static void WritePomWithCompilerPlugin(string sourceDirectory, string javaVersion, string release, string mavenVersion = "3.9.9") + { + WriteWrapper(sourceDirectory, "mvnw", mavenVersion); + File.WriteAllText(Path.Combine(sourceDirectory, "pom.xml"), $""" + + + 4.0.0 + com.example + api + 0.0.1-SNAPSHOT + + {javaVersion} + + + + + org.apache.maven.plugins + maven-compiler-plugin + + {release} + + + + + + """); + } + + private static void WriteGradleBuild(string sourceDirectory, string contents, string gradleVersion = "8.10") + { + WriteWrapper(sourceDirectory, "gradlew", gradleVersion); + File.WriteAllText(Path.Combine(sourceDirectory, "build.gradle"), contents); + } + + private static void WriteWrapper(string sourceDirectory, string wrapperName, string? toolVersion = null) + { + Directory.CreateDirectory(sourceDirectory); + File.WriteAllText(Path.Combine(sourceDirectory, wrapperName), "#!/bin/sh\nexit 0\n"); + + // A real wrapper always ships the properties file that pins the tool version, and publishing + // requires it so the distribution can be unpacked in its own image layer. The URLs match what the + // real wrappers write, including the escaped ':' Gradle uses and the version Maven repeats. + var gradle = wrapperName.Contains("gradle", StringComparison.OrdinalIgnoreCase); + var supportDirectory = Path.Combine(sourceDirectory, gradle ? "gradle" : ".mvn", "wrapper"); + + // Versions that impose no build JDK requirement of their own, so a test that does not care about + // tool compatibility sees the targeted release in both stages. + var version = toolVersion ?? (gradle ? "8.10" : "3.9.9"); + + var distributionUrl = gradle + ? $"https\\://services.gradle.org/distributions/gradle-{version}-bin.zip" + : $"https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/{version}/apache-maven-{version}-bin.zip"; + + Directory.CreateDirectory(supportDirectory); + File.WriteAllText( + Path.Combine(supportDirectory, gradle ? "gradle-wrapper.properties" : "maven-wrapper.properties"), + $"distributionUrl={distributionUrl}\n"); + } +} diff --git a/tests/Aspire.Hosting.Java.Tests/AddJavaAppTests.cs b/tests/Aspire.Hosting.Java.Tests/AddJavaAppTests.cs new file mode 100644 index 00000000000..a03d2b8c086 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/AddJavaAppTests.cs @@ -0,0 +1,1797 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREEXTENSION001 + +using System.Diagnostics; +using System.IO.Compression; +using System.Text.Json; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Java.Tests; + +public class AddJavaAppTests +{ + // An absolute path on the current platform. Run mode resolves the agent through + // Path.GetFullPath, which rewrites a POSIX-style literal to C:\opt\... on Windows, so the + // expected value has to be platform-specific. + private static readonly string s_absoluteAgentPath = + OperatingSystem.IsWindows() ? @"C:\opt\otel\agent.jar" : "/opt/otel/agent.jar"; + + private static string AbsoluteAgentPath => s_absoluteAgentPath; + + // Publish mode emits the authored path verbatim, and it is read inside the Linux image the + // application is published to, so the absolute form is POSIX on every platform. Using the + // platform-specific literal above instead made this scenario unreachable on Windows: publishing + // rejects a Windows-rooted agent outright, which + // VerifyPublish_AWindowsAbsoluteOtelAgentIsRejectedOnEveryPlatform covers. + private const string ContainerProvidedAgentPath = "/opt/otel/agent.jar"; + + // ---- Launch mode ------------------------------------------------------- + + [Fact] + public async Task AddJavaApp_MavenGoal_LaunchesThroughTheWrapper() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + + var app = builder.AddJavaApp("api", tempDir.Path).WithMavenGoal("spring-boot:run"); + + // The command has to become the wrapper. Leaving it as "java" while the goal was still contributed + // as an argument produced the uninvokable command line "java spring-boot:run". + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultMavenWrapper), tempDir.Path, "spring-boot:run"), await ArgumentEvaluator.GetArgumentListAsync(app.Resource)); + } + + [Fact] + public async Task AddJavaApp_GradleTask_LaunchesThroughTheWrapper() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper); + + var app = builder.AddJavaApp("api", tempDir.Path).WithGradleTask("bootRun", "--no-daemon"); + + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultGradleWrapper), tempDir.Path, "bootRun", "--no-daemon"), await ArgumentEvaluator.GetArgumentListAsync(app.Resource)); + } + + [Fact] + public async Task VerifyManifest_AddJavaAppWithJar() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory, "app.jar"); + + var manifest = await ManifestUtils.GetManifest(app.Resource); + + var expected = """ + { + "type": "executable.v0", + "workingDirectory": ".", + "command": "java", + "args": [ + "-jar", + "app.jar" + ] + } + """; + Assert.Equal(expected, manifest.ToString()); + } + + [Fact] + public async Task VerifyManifest_AddJavaAppWithJarAndArgs() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory, "app.jar", ["--server.port=8080"]); + + var manifest = await ManifestUtils.GetManifest(app.Resource); + + var expected = """ + { + "type": "executable.v0", + "workingDirectory": ".", + "command": "java", + "args": [ + "-jar", + "app.jar", + "--server.port=8080" + ] + } + """; + Assert.Equal(expected, manifest.ToString()); + } + + // ---- Resource properties ------------------------------------------------ + + [Fact] + public void AddJavaApp_SetsResourceName() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("myapi", AppContext.BaseDirectory); + + Assert.Equal("myapi", app.Resource.Name); + } + + [Fact] + public void AddJavaApp_UsesJavaAsCommand() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + Assert.Equal("java", app.Resource.Command); + } + + [Fact] + public void AddJavaApp_ResolvesWorkingDirectoryFullPath() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path); + + var expectedPath = Path.GetFullPath(tempDir.Path, builder.AppHostDirectory); + Assert.Equal(expectedPath, app.Resource.WorkingDirectory); + } + + [Fact] + public void AddJavaApp_ImplementsIResourceWithServiceDiscovery() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + Assert.IsAssignableFrom(app.Resource); + } + + [Fact] + public void AddJavaApp_ImplementsIContainerFilesDestinationResource() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + Assert.IsAssignableFrom(app.Resource); + } + + [Fact] + public async Task AddJavaApp_WithoutLaunchMode_ThrowsWhenArgumentsAreGathered() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + // A bare "java" with no arguments prints the JVM usage text and exits, so the failure is raised + // where it can name the resource and the fix. + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + var exception = await Assert.ThrowsAsync( + async () => await ArgumentEvaluator.GetArgumentListAsync(app.Resource)); + + Assert.Equal( + "Java application 'api' has no launch mode configured. Call WithMavenGoal or WithGradleTask to run it through a build tool, or use the AddJavaApp overload that takes a jarPath to run a prebuilt JAR.", + exception.Message); + } + + [Fact] + public async Task AddJavaAppWithJar_ArgsAreJarAndUserArgs() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory, "app.jar", ["--port=9090"]); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + Assert.Equal(["-jar", "app.jar", "--port=9090"], args); + } + + [Fact] + public async Task AddJavaAppWithJar_NoUserArgs_OnlyJarArgs() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory, "app.jar"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + Assert.Equal(["-jar", "app.jar"], args); + } + + // ---- WithMavenGoal ------------------------------------------------------ + + [Fact] + public void WithMavenGoalShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithMavenGoal("spring-boot:run"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithMavenGoalShouldThrowWhenGoalIsNullOrEmpty() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path); + + var nullAction = () => app.WithMavenGoal(null!); + var emptyAction = () => app.WithMavenGoal(string.Empty); + + var nullEx = Assert.Throws(nullAction); + Assert.Equal("goal", nullEx.ParamName); + + var emptyEx = Assert.Throws(emptyAction); + Assert.Equal("goal", emptyEx.ParamName); + } + + [Fact] + public async Task WithMavenGoal_PassesGoalAsArgument() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenGoal("spring-boot:run"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + Assert.Contains("spring-boot:run", args); + } + + [Fact] + public async Task WithMavenGoal_WithArgs_IncludesGoalAndArgs() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenGoal("spring-boot:run", "-DskipTests"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + Assert.Contains("spring-boot:run", args); + Assert.Contains("-DskipTests", args); + } + + // ---- WithGradleTask ----------------------------------------------------- + + [Fact] + public void WithGradleTaskShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithGradleTask("bootRun"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithGradleTaskShouldThrowWhenTaskIsNullOrEmpty() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path); + + var nullAction = () => app.WithGradleTask(null!); + var emptyAction = () => app.WithGradleTask(string.Empty); + + var nullEx = Assert.Throws(nullAction); + Assert.Equal("task", nullEx.ParamName); + + var emptyEx = Assert.Throws(emptyAction); + Assert.Equal("task", emptyEx.ParamName); + } + + [Fact] + public async Task WithGradleTask_PassesTaskAsArgument() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithGradleTask("bootRun"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + Assert.Contains("bootRun", args); + } + + [Fact] + public async Task WithGradleTask_WrapperPathIsResolved() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithGradleTask("bootRun"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + // WithCommand sets the wrapper as the command, args contain only the task + var expectedWrapper = Path.GetFullPath(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultGradleWrapper)); + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, tempDir.Path, "bootRun"), args); + } + + [Fact] + public async Task WithMavenGoal_WrapperPathIsResolved() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenGoal("spring-boot:run"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + // WithCommand sets the wrapper as the command, args contain only the goal + var expectedWrapper = Path.GetFullPath(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultMavenWrapper)); + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, tempDir.Path, "spring-boot:run"), args); + } + + [Fact] + public async Task WithGradleTask_FindsTheWrapperAtTheRootOfAMultiProjectBuild() + { + // A Gradle multi-project build has exactly one gradlew, at the root next to settings.gradle. + // Subprojects never carry their own, so a resource pointed at services/api has to walk up to it. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var repo = new TempJavaAppDirectory(); + repo.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper); + File.WriteAllText(Path.Combine(repo.Path, "settings.gradle"), "include 'services:api'"); + var module = Directory.CreateDirectory(Path.Combine(repo.Path, "services", "api")).FullName; + File.WriteAllText(Path.Combine(module, "build.gradle"), ""); + + var app = builder.AddJavaApp("api", module).WithGradleTask("bootRun"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + var expectedWrapper = Path.GetFullPath(Path.Combine(repo.Path, JavaHostingExtensions.s_defaultGradleWrapper)); + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, module, "bootRun"), args); + } + + [Fact] + public async Task WithMavenGoal_FindsTheWrapperAtTheRootOfAMultiModuleBuild() + { + // Maven multi-module repositories keep mvnw next to the aggregator pom, not in each module. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var repo = new TempJavaAppDirectory(); + repo.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + File.WriteAllText(Path.Combine(repo.Path, "pom.xml"), ""); + var module = Directory.CreateDirectory(Path.Combine(repo.Path, "services", "api")).FullName; + File.WriteAllText(Path.Combine(module, "pom.xml"), ""); + + var app = builder.AddJavaApp("api", module).WithMavenGoal("spring-boot:run"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + var expectedWrapper = Path.GetFullPath(Path.Combine(repo.Path, JavaHostingExtensions.s_defaultMavenWrapper)); + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, module, "spring-boot:run"), args); + } + + [Fact] + public async Task WithGradleTask_PrefersTheWrapperInTheApplicationDirectory() + { + // A module that carries its own wrapper keeps using it, even when an ancestor has one too. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var repo = new TempJavaAppDirectory(); + repo.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper); + var module = Directory.CreateDirectory(Path.Combine(repo.Path, "services", "api")).FullName; + File.WriteAllText(Path.Combine(module, JavaHostingExtensions.s_defaultGradleWrapper), "#!/bin/sh\n"); + + var app = builder.AddJavaApp("api", module).WithGradleTask("bootRun"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + var expectedWrapper = Path.GetFullPath(Path.Combine(module, JavaHostingExtensions.s_defaultGradleWrapper)); + Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, module, "bootRun"), args); + } + + [Theory] + [InlineData("mvnw.cmd")] + [InlineData("gradlew.bat")] + public void WrapperInvocationForWindowsRunsTheWrapperThroughCall(string wrapperName) + { + // cmd strips the first and last quote on the line when the first token is quoted, so a wrapper + // path containing a space would be mangled if it led. "call" keeps a quote off the front. + var workingDirectory = Path.Combine(Path.GetTempPath(), "repo", "services", "api"); + var wrapperPath = Path.Combine(Path.GetTempPath(), "repo", "build tools", wrapperName); + + var (command, leadingArgs) = JavaHostingExtensions.WrapperInvocationFor(wrapperPath, workingDirectory, isWindows: true); + + Assert.Equal(Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", command); + Assert.Equal(["/c", "call", Path.Combine("..", "..", "build tools", wrapperName)], leadingArgs); + } + + [Fact] + public void WrapperInvocationForUnixRunsTheWrapperThroughShWithItsFullPath() + { + // A wrapper checked out on Windows arrives without its executable bit, so it is run by sh + // rather than executed. The path stays absolute because no shell resolves it. + var wrapperPath = Path.Combine(Path.GetTempPath(), "repo", "mvnw"); + + var (command, leadingArgs) = JavaHostingExtensions.WrapperInvocationFor( + wrapperPath, Path.Combine(Path.GetTempPath(), "repo", "services", "api"), isWindows: false); + + Assert.Equal("sh", command); + Assert.Equal([wrapperPath], leadingArgs); + } + + [Fact] + public async Task WithGradleTask_WithArgs_IncludesTaskAndArgs() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path) + .WithGradleTask("bootRun", "--no-daemon"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + Assert.Contains("bootRun", args); + Assert.Contains("--no-daemon", args); + } + + [Fact] + public void WithGradleTask_ThrowsWhenJarPathIsSet() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path, "app.jar"); + + var action = () => app.WithGradleTask("bootRun"); + + var exception = Assert.Throws(action); + Assert.Equal( + "WithGradleTask cannot be used when a JAR path has been specified. Use either the AddJavaApp overload that takes a jarPath, or WithGradleTask, not both.", + exception.Message); + } + + [Fact] + public void WithMavenGoal_ThrowsWhenJarPathIsSet() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path, "app.jar"); + + var action = () => app.WithMavenGoal("spring-boot:run"); + + var exception = Assert.Throws(action); + Assert.Equal( + "WithMavenGoal cannot be used when a JAR path has been specified. Use either the AddJavaApp overload that takes a jarPath, or WithMavenGoal, not both.", + exception.Message); + } + + [Fact] + public void WithGradleTask_ThrowsWhenMavenGoalIsAlreadyConfigured() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path).WithMavenGoal("spring-boot:run"); + + var action = () => app.WithGradleTask("bootRun"); + + var exception = Assert.Throws(action); + Assert.Equal( + "WithGradleTask cannot be used when the application is already configured to launch with Maven. A Java application is launched by a single build tool.", + exception.Message); + } + + [Fact] + public void WithMavenGoal_ThrowsWhenGradleTaskIsAlreadyConfigured() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path).WithGradleTask("bootRun"); + + var action = () => app.WithMavenGoal("spring-boot:run"); + + var exception = Assert.Throws(action); + Assert.Equal( + "WithMavenGoal cannot be used when the application is already configured to launch with Gradle. A Java application is launched by a single build tool.", + exception.Message); + } + + // ---- WithWrapperPath ---------------------------------------------------- + + [Fact] + public void WithWrapperPathShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithWrapperPath("custom-mvnw"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithWrapperPathShouldThrowWhenWrapperPathIsNullOrEmpty() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + var nullAction = () => app.WithWrapperPath(null!); + var emptyAction = () => app.WithWrapperPath(string.Empty); + + var nullEx = Assert.Throws(nullAction); + Assert.Equal("wrapperPath", nullEx.ParamName); + + var emptyEx = Assert.Throws(emptyAction); + Assert.Equal("wrapperPath", emptyEx.ParamName); + } + + [Fact] + public async Task WithWrapperPath_OverridesMavenDefaultWrapper() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithWrapperPath("scripts/custom-mvnw") + .WithMavenGoal("spring-boot:run"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + // WithCommand sets the custom wrapper as the command + var expectedWrapper = Path.GetFullPath(Path.Combine(tempDir.Path, "scripts/custom-mvnw")); + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, tempDir.Path, "spring-boot:run"), args); + } + + [Fact] + public async Task WithWrapperPath_OverridesGradleDefaultWrapper() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithWrapperPath("scripts/custom-gradlew") + .WithGradleTask("bootRun"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + // WithCommand sets the custom wrapper as the command + var expectedWrapper = Path.GetFullPath(Path.Combine(tempDir.Path, "scripts/custom-gradlew")); + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, tempDir.Path, "bootRun"), args); + } + + // ---- WithJvmArgs -------------------------------------------------------- + + [Fact] + public void WithJvmArgsShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithJvmArgs(["-Xmx512m"]); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithJvmArgsShouldThrowWhenArgsIsNull() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + var action = () => app.WithJvmArgs(null!); + + var exception = Assert.Throws(action); + Assert.Equal("args", exception.ParamName); + } + + [Fact] + public async Task WithJvmArgs_SetsJavaToolOptions() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithJvmArgs(["-Xmx512m", "-Xms256m"]); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("-Xmx512m -Xms256m", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task WithJvmArgs_EmptyArgs_DoesNotSetJavaToolOptions() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithJvmArgs([]); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.False(envVars.ContainsKey("JAVA_TOOL_OPTIONS")); + } + + [Fact] + public async Task WithJvmArgs_MultipleCalls_MergeValues() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithJvmArgs(["-Xmx512m"]) + .WithJvmArgs(["-Xms256m"]); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("-Xmx512m -Xms256m", envVars["JAVA_TOOL_OPTIONS"]); + } + + // ---- WithOtelAgent ------------------------------------------------------ + + [Fact] + public void WithOtelAgentShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.WithOtelAgent("/opt/otel/agent.jar"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithOtelAgentShouldThrowWhenAgentPathIsNullOrWhiteSpace() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + Assert.Throws(() => app.WithOtelAgent(" ")); + } + + [Fact] + public async Task AddJavaApp_ConfiguresOtlpExporterWithoutAnAgent() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + // The OTLP exporter is wired by AddJavaApp itself, so a Java application reports telemetry + // through Micrometer/OTel SDK instrumentation even when the Java agent is not used. + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.False(string.IsNullOrEmpty(envVars["OTEL_EXPORTER_OTLP_ENDPOINT"])); + + // The other half of what this test claims: no agent was requested, so nothing should have put + // -javaagent on the JVM. Without this the test passes just as happily when an agent is wired. + Assert.False(envVars.ContainsKey("JAVA_TOOL_OPTIONS")); + } + + [Fact] + public async Task WithOtelAgent_WithAgentPath_SetsJavaAgentInToolOptions() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithOtelAgent(AbsoluteAgentPath); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal($"-javaagent:{AbsoluteAgentPath}", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task WithOtelAgent_CalledTwice_UsesOnlyTheLastAgent() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + // The annotation replaces, so the second call has to win outright. Two -javaagent: entries + // would start the JVM with both agents attached, which double-instruments the application. + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithOtelAgent("/opt/otel/first.jar") + .WithOtelAgent(AbsoluteAgentPath); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal($"-javaagent:{AbsoluteAgentPath}", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task WithOtelAgent_WithAgentPath_CombinedWithJvmArgs() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithJvmArgs(["-Xmx512m"]) + .WithOtelAgent(AbsoluteAgentPath); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal($"-Xmx512m -javaagent:{AbsoluteAgentPath}", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task WithMavenGoal_WithoutAWrapperOnDisk_IsRejectedWhenTheResourceStarts() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(withWrappers: false); + + // A globally installed Maven is deliberately not used as a fallback: the wrapper pins the tool + // version in the repository, so the AppHost, CI, and the published image all build with the same + // one. Failing here names the fix instead of silently building with whatever is on the machine. + var app = builder.AddJavaApp("api", tempDir.Path).WithMavenGoal("spring-boot:run"); + + using var built = builder.Build(); + + var ex = await Assert.ThrowsAsync( + () => builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, built.Services), + CancellationToken.None)); + + Assert.Contains("has no mvnw", ex.Message); + Assert.Contains("mvn -N wrapper:wrapper", ex.Message); + } + + [Fact] + public async Task WithGradleTask_WithoutAWrapperOnDisk_IsRejectedWhenTheResourceStarts() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(withWrappers: false); + + var app = builder.AddJavaApp("api", tempDir.Path).WithGradleTask("bootRun"); + + using var built = builder.Build(); + + var ex = await Assert.ThrowsAsync( + () => builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, built.Services), + CancellationToken.None)); + + Assert.Contains("has no gradlew", ex.Message); + Assert.Contains("gradle wrapper", ex.Message); + } + + [Fact] + public async Task WithWrapperPath_AfterTheBuildTool_WorksWhenTheProjectHasNoDefaultWrapper() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(withWrappers: false); + + var customWrapper = Path.Combine(tempDir.Path, "tools", "mvnw"); + Directory.CreateDirectory(Path.GetDirectoryName(customWrapper)!); + File.WriteAllText(customWrapper, "#!/bin/sh\n"); + + // WithWrapperPath is documented as order-independent. Resolving the wrapper eagerly inside + // WithMavenGoal made that untrue for the one project shape where the override actually matters: + // a project whose only wrapper is the custom one threw before the override could be applied. + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenGoal("spring-boot:run") + .WithWrapperPath(Path.Combine("tools", "mvnw")); + + using var built = builder.Build(); + + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, built.Services), + CancellationToken.None); + + // Path.GetFullPath matches the normalization the resource applies, and resolves the symlinked + // temp directory the same way on both sides so the comparison is about the wrapper, not the path. + // On Unix the wrapper moves into the first argument, so asserting only the command would stop + // checking that the override was applied at all. + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal( + ExpectedWrapperInvocation.Args(Path.GetFullPath(customWrapper), Path.GetFullPath(tempDir.Path), "spring-boot:run"), + await ArgumentEvaluator.GetArgumentListAsync(app.Resource)); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Windows has no executable bit, and its wrappers are batch files sh cannot run")] + public async Task WithMavenGoal_WrapperWithoutTheExecutableBit_StillProducesALaunchableCommandLine() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(withWrappers: false); + + // Git records no executable bit on Windows, so a wrapper committed there checks out like this. + // Executing it directly fails with "permission denied"; the launch has to survive that. + var wrapper = Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultMavenWrapper); + await File.WriteAllTextAsync(wrapper, "#!/bin/sh\nexit 0\n"); + // CA1416 does not understand SkipOnPlatform, which already keeps this off Windows. +#pragma warning disable CA1416 + File.SetUnixFileMode(wrapper, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); +#pragma warning restore CA1416 + + var app = builder.AddJavaApp("api", tempDir.Path).WithMavenGoal("spring-boot:run"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + using var process = Process.Start(new ProcessStartInfo(app.Resource.Command) + { + // The goal is dropped: the point is that the wrapper runs at all, not what it does. + ArgumentList = { args[0] }, + WorkingDirectory = tempDir.Path, + RedirectStandardError = true + })!; + + await process.WaitForExitAsync(CancellationToken.None); + + Assert.Equal(string.Empty, await process.StandardError.ReadToEndAsync()); + Assert.Equal(0, process.ExitCode); + } + + [Fact] + public async Task WithWrapperPath_PointingAtAMissingFile_IsRejectedWhenTheResourceStarts() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(withWrappers: false); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenGoal("spring-boot:run") + .WithWrapperPath("tools/mvnw"); + + using var built = builder.Build(); + + var ex = await Assert.ThrowsAsync( + () => builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, built.Services), + CancellationToken.None)); + + // The override is what is wrong here, so the message points at WithWrapperPath rather than + // telling the user to generate a wrapper they did not ask for. + Assert.Contains(nameof(JavaHostingExtensions.WithWrapperPath), ex.Message); + Assert.DoesNotContain("mvn -N wrapper:wrapper", ex.Message); + } + + [Fact] + public async Task WithWrapperPath_IsHonouredEvenWhenNoWrapperExistsOnDisk() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(withWrappers: false); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithWrapperPath("/opt/maven/bin/mvn") + .WithMavenGoal("spring-boot:run"); + + // An explicit override is a deliberate choice and must win over the default wrapper probe, which + // would otherwise reject this project for shipping no mvnw. + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal( + ExpectedWrapperInvocation.Args("/opt/maven/bin/mvn", tempDir.Path, "spring-boot:run"), + await ArgumentEvaluator.GetArgumentListAsync(app.Resource)); + } + + [Fact] + public void AddJavaApp_RequestsSystemCertificateTrustScope() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + // -Djavax.net.ssl.trustStore replaces the JVM's trust anchors rather than adding to them, so the + // generated bundle has to contain the system roots as well. Under the default Append scope the + // bundle would hold only Aspire's own certificates and the JVM would stop trusting every public + // CA -- which also breaks Maven Central and Gradle distribution downloads, because + // JAVA_TOOL_OPTIONS is inherited by the build tool's JVM. + Assert.True(app.Resource.TryGetLastAnnotation(out var certAnnotation)); + Assert.Equal(CertificateTrustScope.System, certAnnotation.Scope); + } + + [Fact] + public async Task AddJavaApp_WithAppendCertificateTrustScope_DoesNotOverrideTheJvmTrustStore() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var javaApp = builder.AddJavaApp("api", AppContext.BaseDirectory); + + using var app = builder.Build(); + + Assert.True(javaApp.Resource.TryGetLastAnnotation(out var annotation)); + + var envVars = new Dictionary(); + await annotation.Callback(new CertificateTrustConfigurationCallbackAnnotationContext + { + ExecutionContext = new DistributedApplicationExecutionContext( + new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) + { + Services = app.Services + }), + Resource = javaApp.Resource, + Arguments = [], + EnvironmentVariables = envVars, + CertificateBundlePath = ReferenceExpression.Create($"/etc/ssl/aspire/bundle.p12"), + CertificateDirectoriesPath = ReferenceExpression.Create($"/etc/ssl/aspire/certs"), + Scope = CertificateTrustScope.Append, + CancellationToken = default + }); + + // Under Append the bundle holds only Aspire's own certificates. Pointing the JVM at it would + // drop every public certificate authority, so the override is skipped entirely rather than + // applied against an incomplete bundle. + Assert.Empty(envVars); + } + + [Fact] + public async Task WithOtelAgent_AgentPathContainingSpaces_IsQuotedForTheJvm() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var agentPath = OperatingSystem.IsWindows() + ? @"C:\opt\java agents\opentelemetry-javaagent.jar" + : "/opt/java agents/opentelemetry-javaagent.jar"; + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithOtelAgent(agentPath); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + // The JVM splits JAVA_TOOL_OPTIONS on whitespace but honours double quotes, so an unquoted path + // containing a space aborts startup with "Unrecognized option: agents/opentelemetry-javaagent.jar" + // before any application code runs. -javaagent: has no '=' so the whole option is quoted. + Assert.Equal($"\"-javaagent:{agentPath}\"", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task WithJvmArgs_ValueContainingSpaces_IsQuotedAfterTheAssignment() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithJvmArgs(["-Dapp.data.dir=/var/lib/my app"]); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + // For an option with an '=' only the value is quoted. Quoting the whole option would make the JVM + // treat "-Dapp.data.dir" as part of the property name and the property would silently not be set. + Assert.Equal("-Dapp.data.dir=\"/var/lib/my app\"", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task WithOtelAgent_RelativeAgentPath_IsMadeAbsoluteInRunMode() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithOtelAgent(Path.Combine("target", "agent", "opentelemetry-javaagent.jar")); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + // JAVA_TOOL_OPTIONS is inherited by every JVM started beneath the resource, and build tools start + // JVMs from directories other than the application directory. The Gradle daemon in particular + // starts from its own distribution directory, so a relative -javaagent: path fails to resolve and + // the daemon dies during VM initialization rather than reporting a normal build failure. + var expected = Path.GetFullPath( + Path.Combine(app.Resource.WorkingDirectory, "target", "agent", "opentelemetry-javaagent.jar")); + + Assert.Equal($"-javaagent:{expected}", envVars["JAVA_TOOL_OPTIONS"]); + Assert.True(Path.IsPathFullyQualified(expected)); + } + + [Fact] + public async Task WithOtelAgent_RelativeAgentPath_PointsAtContainerPathInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithOtelAgent("target/agent/opentelemetry-javaagent.jar"); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance); + + // The path has to be interpreted inside the container, so a build-machine path would be wrong. + // The generated Dockerfile copies the build-produced agent to a fixed location, and this has to + // agree with it or the container starts a JVM pointing at a JAR that is not in the image. + Assert.Equal("-javaagent:/app/agent.jar", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task WithOtelAgent_AbsoluteAgentPath_IsLeftUnchangedInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory) + .WithOtelAgent(ContainerProvidedAgentPath); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance); + + // An absolute path cannot have come out of the build context, so it is the base image's or a + // mount's responsibility and rewriting it would break that arrangement. + Assert.Equal($"-javaagent:{ContainerProvidedAgentPath}", envVars["JAVA_TOOL_OPTIONS"]); + } + + // ---- WithMavenBuild / WithGradleBuild ----------------------------------- + + [Fact] + public void WithMavenBuild_CreatesMavenBuildResourceInRunMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + builder.AddJavaApp("api", tempDir.Path) + .WithMavenBuild(); + + Assert.Contains(builder.Resources, r => r.Name == "api-maven-build"); + Assert.Equal(JavaBuildTool.Maven, Assert.IsType(builder.Resources.First(r => r.Name == "api-maven-build")).Tool); + } + + [Fact] + public void WithMavenBuild_CustomArgs_CreatesBuildResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + builder.AddJavaApp("api", tempDir.Path) + .WithMavenBuild("clean", "install", "-DskipTests"); + + var buildResource = builder.Resources.First(r => r.Name == "api-maven-build"); + Assert.Equal(JavaBuildTool.Maven, Assert.IsType(buildResource).Tool); + } + + [Fact] + public void WithGradleBuild_CreatesGradleBuildResourceInRunMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + builder.AddJavaApp("api", tempDir.Path) + .WithGradleBuild(); + + Assert.Contains(builder.Resources, r => r.Name == "api-gradle-build"); + Assert.Equal(JavaBuildTool.Gradle, Assert.IsType(builder.Resources.First(r => r.Name == "api-gradle-build")).Tool); + } + + [Fact] + public void WithGradleBuild_CustomArgs_CreatesBuildResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + builder.AddJavaApp("api", tempDir.Path) + .WithGradleBuild("clean", "assemble", "--info"); + + var buildResource = builder.Resources.First(r => r.Name == "api-gradle-build"); + Assert.Equal(JavaBuildTool.Gradle, Assert.IsType(buildResource).Tool); + } + + [Fact] + public void WithMavenBuild_DoesNotCreateBuildResourceInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + builder.AddJavaApp("api", tempDir.Path) + .WithMavenBuild(); + + Assert.DoesNotContain(builder.Resources, r => r.Name == "api-maven-build"); + } + + [Fact] + public void WithGradleBuild_DoesNotCreateBuildResourceInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + builder.AddJavaApp("api", tempDir.Path) + .WithGradleBuild(); + + Assert.DoesNotContain(builder.Resources, r => r.Name == "api-gradle-build"); + } + + [Theory] + [InlineData(nameof(JavaBuildTool.Maven), true)] + [InlineData(nameof(JavaBuildTool.Maven), false)] + [InlineData(nameof(JavaBuildTool.Gradle), true)] + [InlineData(nameof(JavaBuildTool.Gradle), false)] + public void WithBuildAndLaunch_DoesNotCreateASeparateBuildResource(string toolName, bool buildFirst) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path); + + if (toolName is nameof(JavaBuildTool.Maven)) + { + if (buildFirst) + { + app.WithMavenBuild(); + app.WithMavenGoal("spring-boot:run"); + } + else + { + app.WithMavenGoal("spring-boot:run"); + app.WithMavenBuild(); + } + } + else + { + if (buildFirst) + { + app.WithGradleBuild(); + app.WithGradleTask("bootRun"); + } + else + { + app.WithGradleTask("bootRun"); + app.WithGradleBuild(); + } + } + + Assert.Same(app.Resource, Assert.Single(builder.Resources)); + Assert.Null(Assert.Single(app.Resource.Annotations.OfType()).ResourceName); + } + + [Fact] + public void WithMavenBuild_BuildResourceHasParentRelationship() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenBuild(); + + var buildResource = builder.Resources.First(r => r.Name == "api-maven-build"); + Assert.True(buildResource.TryGetAnnotationsOfType(out var relationships)); + Assert.Contains(relationships, r => r.Type == "Parent" && r.Resource == app.Resource); + } + + [Fact] + public void WithGradleBuild_BuildResourceHasParentRelationship() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithGradleBuild(); + + var buildResource = builder.Resources.First(r => r.Name == "api-gradle-build"); + Assert.True(buildResource.TryGetAnnotationsOfType(out var relationships)); + Assert.Contains(relationships, r => r.Type == "Parent" && r.Resource == app.Resource); + } + + // ---- JAR path ----------------------------------------------------------- + + [Theory] + [InlineData("target/app.jar")] + [InlineData(@"target\app.jar")] + public async Task AddJavaApp_WithJarPath_LaunchesTheJar(string jarPath) + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory, jarPath); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + Assert.Equal("java", app.Resource.Command); + Assert.Equal(["-jar", "target/app.jar"], args); + } + + // ---- VS Code debugging -------------------------------------------------- + + [Fact] + public void AddJavaApp_InRunMode_SupportsDebugging() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + var annotation = app.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal("java", annotation!.LaunchConfigurationType); + } + + [Fact] + public void AddJavaApp_InPublishMode_DoesNotAddDebuggingAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true); + + var app = builder.AddJavaApp("api", AppContext.BaseDirectory); + + var annotation = app.Resource.Annotations.OfType().SingleOrDefault(); + Assert.Null(annotation); + } + + [Fact] + public async Task WithJvmArgs_IdeDebugLaunchUsesJavaToolOptionsOnly() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", "api"); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenGoal("spring-boot:run") + .WithJvmArgs("-javaagent:/opt/coverage-agent.jar"); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + var launchConfiguration = await GetLaunchConfigurationAsync(app); + var serializedLaunchConfiguration = JsonSerializer.SerializeToElement(launchConfiguration); + + // The IDE-launched JVM inherits the resource environment. Repeating this value as vm_args would + // attach single-instance options such as -javaagent twice and either fail or double-instrument. + Assert.Equal("-javaagent:/opt/coverage-agent.jar", envVars["JAVA_TOOL_OPTIONS"]); + Assert.False(serializedLaunchConfiguration.TryGetProperty("vm_args", out _)); + } + + // ---- Chaining multiple methods ------------------------------------------ + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void WithMavenBuild_AndWithMavenGoal_ProduceTheSameGraphInEitherOrder(bool goalFirst) + { + // The launch goal compiles the application, so the build resource is redundant either way. + // One order never creates it; the other creates it and then unwinds it by hand. Those two + // paths have to converge, or which methods a user happens to chain first decides whether an + // extra Maven build runs before every launch. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddJavaApp("api", tempDir.Path); + if (goalFirst) + { + app.WithMavenGoal("spring-boot:run").WithMavenBuild("clean", "package"); + } + else + { + app.WithMavenBuild("clean", "package").WithMavenGoal("spring-boot:run"); + } + + Assert.Equal(["api"], builder.Resources.Select(resource => resource.Name).Order()); + Assert.Empty(app.Resource.Annotations.OfType()); + Assert.Null(Assert.Single(app.Resource.Annotations.OfType()).ResourceName); + } + + [Fact] + public async Task WithMavenGoal_ThenWithJvmArgs_SetsBothConfigurations() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenGoal("spring-boot:run") + .WithJvmArgs(["-Xmx1g"]); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + Assert.Contains("spring-boot:run", args); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + Assert.Equal("-Xmx1g", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task WithGradleTask_ThenWithOtelAgent_SetsBothConfigurations() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + using var tempDir = new TempJavaAppDirectory(); + var app = builder.AddJavaApp("api", tempDir.Path) + .WithGradleTask("bootRun") + .WithOtelAgent(AbsoluteAgentPath); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + Assert.Contains("bootRun", args); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + Assert.Equal($"-javaagent:{AbsoluteAgentPath}", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public async Task WithWrapperPath_ThenWithMavenGoal_UsesCustomWrapper() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithWrapperPath("tools/mvn") + .WithMavenGoal("spring-boot:run"); + + var args = await ArgumentEvaluator.GetArgumentListAsync(app.Resource); + + // WithCommand sets the custom wrapper as the command + var expectedWrapper = Path.GetFullPath(Path.Combine(tempDir.Path, "tools/mvn")); + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(expectedWrapper, tempDir.Path, "spring-boot:run"), args); + } + + // ---- Manifest with Maven/Gradle goals ----------------------------------- + + [Fact] + public async Task VerifyManifest_WithMavenGoal() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithMavenGoal("spring-boot:run"); + + var manifest = await ManifestUtils.GetManifest(app.Resource); + + // The manifest should show the maven wrapper as the command with the goal as args. + var args = manifest?["args"]?.AsArray(); + Assert.NotNull(args); + Assert.Contains("spring-boot:run", args!.Select(a => a?.ToString())); + } + + [Fact] + public async Task VerifyManifest_WithGradleTask() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddJavaApp("api", tempDir.Path) + .WithGradleTask("bootRun"); + + var manifest = await ManifestUtils.GetManifest(app.Resource); + + var args = manifest?["args"]?.AsArray(); + Assert.NotNull(args); + Assert.Contains("bootRun", args!.Select(a => a?.ToString())); + } + + private static async Task GetLaunchConfigurationAsync(IResourceBuilder app) + { + var annotation = Assert.Single(app.Resource.Annotations.OfType()); + Assert.Equal("java", annotation.LaunchConfigurationType); + + var context = new LaunchConfigurationCallbackContext( + ExecutableLaunchMode.Debug, + app.Resource, + new Dictionary(), + CancellationToken.None); + + return Assert.IsType(await annotation.LaunchConfigurationProducer(context)); + } + + [Theory] + [InlineData("pom.xml", "maven")] + [InlineData("settings.gradle", "gradle")] + public async Task AddSpringBootApp_DebugConfigurationUsesTheDetectedBuildTool(string marker, string expectedBuildTool) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write(marker, ""); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Equal(expectedBuildTool, launchConfiguration.BuildTool); + } + + [Theory] + [InlineData("pom.xml", "target")] + [InlineData("build.gradle", "build")] + public async Task AddQuarkusApp_DebugConfigurationStartsTheFastJarTheBuildProduced(string buildFile, string outputDirectory) + { + // Quarkus' entry point lives in the fast JAR's boot classpath rather than in the project, so the + // language server's classpath cannot start it. The archive goes on the classpath and its manifest + // names io.quarkus.bootstrap.runner.QuarkusEntryPoint, exactly as `java -jar` would resolve it. + // Breakpoints still bind, because the debugger maps loaded classes back to sources by name. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write(buildFile, ""); + var runJar = WriteJarWithManifest( + tempDir.Path, + Path.Combine(outputDirectory, "quarkus-app", "quarkus-run.jar"), + "io.quarkus.bootstrap.runner.QuarkusEntryPoint"); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Equal("io.quarkus.bootstrap.runner.QuarkusEntryPoint", launchConfiguration.MainClass); + Assert.Equal([runJar], launchConfiguration.ClassPaths!); + } + + /// + /// Writes a JAR whose manifest declares . A JAR is a ZIP archive, so + /// the entry only has to exist at META-INF/MANIFEST.MF with the documented Name: value shape. + /// + private static string WriteJarWithManifest(string directory, string fileName, string? mainClass, bool wrapLongValue = false, string? startClass = null) + { + var jarPath = Path.Combine(directory, fileName); + Directory.CreateDirectory(Path.GetDirectoryName(jarPath)!); + using var archive = ZipFile.Open(jarPath, ZipArchiveMode.Create); + using var writer = new StreamWriter(archive.CreateEntry("META-INF/MANIFEST.MF").Open()); + + writer.Write("Manifest-Version: 1.0\r\n"); + if (mainClass is not null) + { + if (wrapLongValue) + { + // The manifest format limits a line to 72 bytes and continues longer values on the + // next line with a single leading space, which is not part of the value. + var split = mainClass.Length / 2; + writer.Write($"Main-Class: {mainClass[..split]}\r\n {mainClass[split..]}\r\n"); + } + else + { + writer.Write($"Main-Class: {mainClass}\r\n"); + } + } + + if (startClass is not null) + { + writer.Write($"Start-Class: {startClass}\r\n"); + } + + writer.Write("\r\n"); + + // Returned fully resolved because that is the shape the resource reports: the launch + // configuration runs the authored path through Path.GetFullPath. Path.Combine does not + // normalize separators, so a fileName such as "target/api.jar" would otherwise produce + // "C:\...\target/api.jar" on Windows and never compare equal to it. + return Path.GetFullPath(jarPath); + } + + [Fact] + public async Task AddJavaApp_WithMavenBuild_SendsNoEntryPointWhenNothingOnDiskNamesOne() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + + var app = builder.AddJavaApp("api", tempDir.Path).WithMavenGoal("spring-boot:run"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Null(launchConfiguration.MainClass); + Assert.Null(launchConfiguration.ClassPaths); + Assert.Equal("maven", launchConfiguration.BuildTool); + Assert.Equal(tempDir.Path, launchConfiguration.WorkingDirectory); + } + + [Fact] + public async Task AddJavaApp_WithJar_PutsTheArchiveOnTheClasspathAndLaunchesItsManifestMainClass() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + var jarPath = WriteJarWithManifest(tempDir.Path, "api.jar", "com.example.catalog.CatalogApplication"); + + var app = builder.AddJavaApp("api", tempDir.Path, "api.jar"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + // The archive itself is never the main class: the debug adapter documents that attribute as a + // fully qualified class name or a .java path, so a JAR path leaves it unable to resolve an + // entry point. The archive belongs on the classpath instead. + Assert.Equal("com.example.catalog.CatalogApplication", launchConfiguration.MainClass); + Assert.Equal(jarPath, Assert.Single(launchConfiguration.ClassPaths!)); + Assert.Null(launchConfiguration.BuildTool); + } + + [Fact] + public async Task AddJavaApp_WithWindowsStyleJarPath_DebugsTheArchiveOnEveryPlatform() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + var jarPath = WriteJarWithManifest(tempDir.Path, "target/api.jar", "com.example.catalog.CatalogApplication"); + + var app = builder.AddJavaApp("api", tempDir.Path, @"target\api.jar"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Equal("com.example.catalog.CatalogApplication", launchConfiguration.MainClass); + Assert.Equal(jarPath, Assert.Single(launchConfiguration.ClassPaths!)); + } + + [Fact] + public async Task AddJavaApp_WithJar_ReadsAMainClassThatTheManifestWrappedAcrossLines() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + const string MainClass = "com.example.catalog.averylongpackagename.that.forces.wrapping.CatalogApplication"; + WriteJarWithManifest(tempDir.Path, "api.jar", MainClass, wrapLongValue: true); + + var app = builder.AddJavaApp("api", tempDir.Path, "api.jar"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Equal(MainClass, launchConfiguration.MainClass); + } + + [Fact] + public async Task AddJavaApp_WithJar_WithMainClass_PrefersTheExplicitMainClassOverTheManifest() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + var jarPath = WriteJarWithManifest(tempDir.Path, "api.jar", "org.springframework.boot.loader.JarLauncher"); + + var app = builder.AddJavaApp("api", tempDir.Path, "api.jar") + .WithMainClass("com.example.catalog.CatalogApplication"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Equal("com.example.catalog.CatalogApplication", launchConfiguration.MainClass); + Assert.Equal(jarPath, Assert.Single(launchConfiguration.ClassPaths!)); + } + + [Fact] + public async Task AddJavaApp_WithJar_ThatIsMissingOrHasNoMainClass_StillSendsTheClasspath() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + WriteJarWithManifest(tempDir.Path, "no-main.jar", mainClass: null); + + var noMainClass = builder.AddJavaApp("no-main", tempDir.Path, "no-main.jar"); + var missing = builder.AddJavaApp("missing", tempDir.Path, "does-not-exist.jar"); + + // Neither case is fatal: the IDE can still resolve an entry point from the project, and + // failing the launch over an unreadable archive would be worse than letting it try. + var noMainClassConfiguration = await GetLaunchConfigurationAsync(noMainClass); + Assert.Null(noMainClassConfiguration.MainClass); + Assert.Equal(Path.Combine(tempDir.Path, "no-main.jar"), Assert.Single(noMainClassConfiguration.ClassPaths!)); + + var missingConfiguration = await GetLaunchConfigurationAsync(missing); + Assert.Null(missingConfiguration.MainClass); + Assert.Equal(Path.Combine(tempDir.Path, "does-not-exist.jar"), Assert.Single(missingConfiguration.ClassPaths!)); + } + + [Theory] + [InlineData("maven", "target")] + [InlineData("gradle", "build/libs")] + public async Task AddJavaApp_WithoutAJar_LaunchesTheStartClassOfTheRepackagedSpringBootArchive(string tool, string outputDirectory) + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(directoryName: "catalog"); + tempDir.WriteWrapper(tool == "maven" ? JavaHostingExtensions.s_defaultMavenWrapper : JavaHostingExtensions.s_defaultGradleWrapper); + + // Repackaging points Main-Class at the launcher and records the application's own entry point + // in Start-Class, so a debugger that started Main-Class would step through Spring's loader. + WriteJarWithManifest( + tempDir.Path, + Path.Combine(outputDirectory, "catalog-0.0.1-SNAPSHOT.jar"), + mainClass: "org.springframework.boot.loader.launch.JarLauncher", + startClass: "com.example.catalog.CatalogApplication"); + + var app = builder.AddJavaApp("catalog", tempDir.Path); + _ = tool == "maven" ? app.WithMavenGoal("spring-boot:run") : app.WithGradleTask("bootRun"); + + // The build file is what the project name is read from, and the directory is named to match it, + // which is the condition under which the IDE imports the project under that same name. + File.WriteAllText( + Path.Combine(tempDir.Path, tool == "maven" ? "pom.xml" : "settings.gradle"), + tool == "maven" + ? """ + + + 4.0.0 + com.example + catalog + 0.0.1-SNAPSHOT + + """ + : "rootProject.name = 'catalog'\n"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Equal("com.example.catalog.CatalogApplication", launchConfiguration.MainClass); + // The classpath stays with the language server so breakpoints bind to the source being edited + // rather than to the classes inside the archive. + Assert.Null(launchConfiguration.ClassPaths); + // Sent alongside the main class, not instead of it. Given a class and no project the adapter + // searches every project in the workspace and refuses to launch with "Main class ... isn't + // unique in the workspace" whenever the class is visible through more than one of them. + Assert.Equal("catalog", launchConfiguration.ProjectName); + } + + [Fact] + public async Task AddJavaApp_WithoutAJar_IgnoresTheArchivesABuildLeavesAlongsideTheApplication() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper); + + WriteJarWithManifest(tempDir.Path, "build/libs/orders.jar", mainClass: null, startClass: "com.example.orders.OrdersApplication"); + // The Gradle Spring Boot plugin writes the unrepackaged classes next to the real artifact, and + // Maven publishes these two whenever the corresponding plugins are bound to the build. + WriteJarWithManifest(tempDir.Path, "build/libs/orders-plain.jar", mainClass: null); + WriteJarWithManifest(tempDir.Path, "build/libs/orders-sources.jar", mainClass: null); + WriteJarWithManifest(tempDir.Path, "build/libs/orders-javadoc.jar", mainClass: null); + + var app = builder.AddJavaApp("orders", tempDir.Path).WithGradleTask("bootRun"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Equal("com.example.orders.OrdersApplication", launchConfiguration.MainClass); + } + + [Fact] + public async Task AddJavaApp_WithoutAJar_SendsNoMainClassWhenTheBuildOutputIsAmbiguous() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + + WriteJarWithManifest(tempDir.Path, "target/catalog-0.0.1-SNAPSHOT.jar", mainClass: null, startClass: "com.example.catalog.CatalogApplication"); + WriteJarWithManifest(tempDir.Path, "target/catalog-0.0.1-SNAPSHOT-shaded.jar", mainClass: null, startClass: "com.example.catalog.Other"); + + var app = builder.AddJavaApp("catalog", tempDir.Path).WithMavenGoal("spring-boot:run"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + // Picking one of two application archives would silently debug the wrong program. + Assert.Null(launchConfiguration.MainClass); + } + + [Fact] + public async Task AddJavaApp_WithoutAJar_SendsNoMainClassWhenTheArchiveOnlyNamesASpringBootLauncher() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + + WriteJarWithManifest(tempDir.Path, "target/catalog.jar", mainClass: "org.springframework.boot.loader.launch.JarLauncher"); + + var app = builder.AddJavaApp("catalog", tempDir.Path).WithMavenGoal("spring-boot:run"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + // The launcher can only start with the archive on the classpath, which this launch mode does + // not send, so reporting it would produce a JVM that fails immediately. + Assert.Null(launchConfiguration.MainClass); + } + + [Fact] + public async Task AddJavaApp_WithoutAJar_PrefersAnExplicitMainClassOverTheBuildOutput() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + + WriteJarWithManifest(tempDir.Path, "target/catalog.jar", mainClass: null, startClass: "com.example.catalog.CatalogApplication"); + + var app = builder.AddJavaApp("catalog", tempDir.Path) + .WithMavenGoal("spring-boot:run") + .WithMainClass("com.example.catalog.DebugEntryPoint"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Equal("com.example.catalog.DebugEntryPoint", launchConfiguration.MainClass); + } + + [Fact] + public async Task AddJavaApp_WithNoResolvableEntryPoint_NamesTheMavenProjectSoTheIdeDoesNotPrompt() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(directoryName: "catalog"); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + + // declares an artifactId too, and it is the one a descendant search finds first. + File.WriteAllText(Path.Combine(tempDir.Path, "pom.xml"), """ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.0 + + com.example + catalog + 0.0.1-SNAPSHOT + + """); + + var app = builder.AddJavaApp("catalog", tempDir.Path).WithMavenGoal("spring-boot:run"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Null(launchConfiguration.MainClass); + Assert.Equal("catalog", launchConfiguration.ProjectName); + } + + [Fact] + public async Task AddJavaApp_WithNoResolvableEntryPoint_NamesTheGradleProjectFromItsSettingsFile() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(directoryName: "orders"); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper); + + File.WriteAllText(Path.Combine(tempDir.Path, "settings.gradle"), "rootProject.name = 'orders'\n"); + + var app = builder.AddJavaApp("orders", tempDir.Path).WithGradleTask("bootRun"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Null(launchConfiguration.MainClass); + Assert.Equal("orders", launchConfiguration.ProjectName); + } + + [Fact] + public async Task AddJavaApp_WithNoResolvableEntryPoint_FallsBackToTheDirectoryNameGradleWouldUse() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper); + + var app = builder.AddJavaApp("orders", tempDir.Path).WithGradleTask("bootRun"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + Assert.Equal( + Path.GetFileName(tempDir.Path.TrimEnd(Path.DirectorySeparatorChar)), + launchConfiguration.ProjectName); + } + + [Fact] + public async Task AddJavaApp_WhenTheProjectDirectoryIsNamedDifferently_DoesNotGuessTheIdeProjectName() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(directoryName: "JavaSpringBoot.AppHost.Java"); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultGradleWrapper); + + File.WriteAllText( + Path.Combine(tempDir.Path, "settings.gradle"), + "rootProject.name = 'javaspringboot-apphost'\n"); + + var app = builder.AddJavaApp("apphost", tempDir.Path).WithGradleTask("bootRun"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + // The language server appends the directory when it disagrees with the declared name, importing + // this project as "javaspringboot-apphost-JavaSpringBoot.AppHost.Java". Sending the declared name + // would name a project that does not exist and fail every launch, so nothing is sent and the + // adapter resolves the entry point across the workspace as it did before. + Assert.Null(launchConfiguration.ProjectName); + } + + [Fact] + public async Task AddJavaApp_WithAnExplicitJar_DoesNotScopeResolutionToAProject() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + using var tempDir = new TempJavaAppDirectory(directoryName: "worker"); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + + File.WriteAllText(Path.Combine(tempDir.Path, "pom.xml"), """ + + + 4.0.0 + com.example + worker + 0.0.1-SNAPSHOT + + """); + + WriteJarWithManifest( + tempDir.Path, + Path.Combine("target", "worker-0.0.1-SNAPSHOT.jar"), + mainClass: "com.example.worker.Worker"); + + var app = builder + .AddJavaApp("worker", tempDir.Path, Path.Combine("target", "worker-0.0.1-SNAPSHOT.jar")) + .WithMavenBuild("package"); + + var launchConfiguration = await GetLaunchConfigurationAsync(app); + + // The archive is on the classpath, so the adapter launches from it rather than from a project + // the language server compiled. Naming a project as well would ask it to resolve the class + // somewhere it does not have to exist. + Assert.Equal("com.example.worker.Worker", launchConfiguration.MainClass); + Assert.NotNull(launchConfiguration.ClassPaths); + Assert.Null(launchConfiguration.ProjectName); + } +} diff --git a/tests/Aspire.Hosting.Java.Tests/AddJavaContainerTests.cs b/tests/Aspire.Hosting.Java.Tests/AddJavaContainerTests.cs new file mode 100644 index 00000000000..bfd9ff100cb --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/AddJavaContainerTests.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Java.Tests; + +public class AddJavaContainerTests +{ + [Fact] + public void AddJavaContainerShouldThrowWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + + var action = () => builder.AddJavaContainer("catalog", "mycompany/catalog"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AddJavaContainerShouldThrowWhenNameIsNullOrWhitespace(string? name) + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var action = () => builder.AddJavaContainer(name!, "mycompany/catalog"); + + var exception = name is null + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal("name", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AddJavaContainerShouldThrowWhenImageIsNullOrWhitespace(string? image) + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var action = () => builder.AddJavaContainer("catalog", image!); + + var exception = image is null + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal("image", exception.ParamName); + } + + [Fact] + public void AddJavaContainer_UsesTheRequestedImageAndTag() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaContainer("catalog", "mycompany/catalog", "1.4.0"); + + var image = Assert.Single(app.Resource.Annotations.OfType()); + Assert.Equal("mycompany/catalog", image.Image); + Assert.Equal("1.4.0", image.Tag); + } + + [Fact] + public void AddJavaContainer_WithoutTag_LeavesTheTagToTheContainerRuntime() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaContainer("catalog", "mycompany/catalog"); + + var image = Assert.Single(app.Resource.Annotations.OfType()); + Assert.Equal("mycompany/catalog", image.Image); + Assert.Equal("latest", image.Tag); + } + + [Fact] + public void AddJavaContainer_IsDiscoverableAndUsesTheJavaIcon() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaContainer("catalog", "mycompany/catalog"); + + Assert.IsAssignableFrom(app.Resource); + Assert.IsAssignableFrom(app.Resource); + var icon = Assert.Single(app.Resource.Annotations.OfType()); + Assert.Equal("DrinkCoffee", icon.IconName); + } + + [Fact] + public void AddJavaContainer_DeclaresNoEndpoint() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + // The port belongs to the image, so callers pick it with WithHttpEndpoint. Asserting the + // absence here is what keeps a default 8080 endpoint from being reintroduced silently. + var app = builder.AddJavaContainer("catalog", "mycompany/catalog"); + + Assert.Empty(app.Resource.Annotations.OfType()); + } + + [Fact] + public async Task AddJavaContainer_ExportsTelemetryToAspire() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost:4317"; + + var app = builder.AddJavaContainer("catalog", "mycompany/catalog"); + + // Built rather than evaluated against TestServiceProvider because resolving a container's OTLP + // endpoint needs the DCP options that decide how the container reaches the host, which is also + // why the asserted host is not the configured localhost. + using var built = builder.Build(); + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, serviceProvider: built.Services.GetRequiredService()); + + Assert.Equal("http://host.docker.internal:4317", envVars["OTEL_EXPORTER_OTLP_ENDPOINT"]); + } + + [Fact] + public async Task WithJvmArgs_AppliesToAContainerToo() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost:4317"; + + var app = builder.AddJavaContainer("catalog", "mycompany/catalog") + .WithJvmArgs(["-Xmx512m"]) + .WithJvmArgs(["-javaagent:/app/opentelemetry-javaagent.jar"]); + + using var built = builder.Build(); + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, serviceProvider: built.Services.GetRequiredService()); + + Assert.Equal("-Xmx512m -javaagent:/app/opentelemetry-javaagent.jar", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Fact] + public void AddJavaContainer_KeepsTheJvmBuiltInCertificateAuthorities() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + + var app = builder.AddJavaContainer("catalog", "mycompany/catalog"); + + // The JVM's trust store setting replaces the default authorities rather than adding to them, + // so the bundle Aspire generates has to carry the system roots as well. + var scope = Assert.Single(app.Resource.Annotations.OfType()); + Assert.Equal(CertificateTrustScope.System, scope.Scope); + } +} diff --git a/tests/Aspire.Hosting.Java.Tests/AddQuarkusAppTests.cs b/tests/Aspire.Hosting.Java.Tests/AddQuarkusAppTests.cs new file mode 100644 index 00000000000..7357cb6dcfb --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/AddQuarkusAppTests.cs @@ -0,0 +1,313 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Java.Tests; + +public class AddQuarkusAppTests +{ + [Theory] + [InlineData("pom.xml", "maven")] + [InlineData("build.gradle", "gradle")] + public void AddQuarkusApp_InADebugSession_AddsABuildTheApplicationWaitsFor(string buildFile, string toolName) + { + // Dev mode compiles the application on its way to running it, so a Quarkus resource normally has + // no build resource. A debug session is the exception: the IDE launches the packaged fast JAR + // rather than the dev-mode wrapper, and on a clean checkout nothing has written that JAR yet. + // Without a build in front of the application the debug adapter is handed no entry point and + // falls back to asking which of the workspace's main classes to start. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write(buildFile, ""); + EnterDebugSession(builder); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + var buildResource = Assert.Single( + builder.Resources.OfType(), + resource => resource.Name == $"inventory-{toolName}-build"); + + Assert.Contains( + app.Resource.Annotations.OfType(), + wait => ReferenceEquals(wait.Resource, buildResource)); + } + + [Fact] + public void AddQuarkusApp_OutsideADebugSession_AddsNoBuild() + { + // `aspire run` launches dev mode itself, which compiles the application, so a build resource in + // front of it would only repeat that work and delay every start. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + Assert.Empty(builder.Resources.OfType()); + Assert.Empty(app.Resource.Annotations.OfType()); + } + + [Fact] + public void AddSpringBootApp_InADebugSession_AddsNoBuild() + { + // Spring Boot is deliberately not given the same treatment. The debug adapter starts it from the + // classpath the Java language server already compiled, so there is no packaged artifact to wait + // for and adding a build would only delay every debug session. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + EnterDebugSession(builder); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path); + + Assert.Empty(builder.Resources.OfType()); + Assert.Empty(app.Resource.Annotations.OfType()); + } + + /// + /// Configures the builder the way an IDE that can launch Java resources does, which is what + /// SupportsDebugging reads to decide whether this run hands its resources to the IDE. + /// + private static void EnterDebugSession(IDistributedApplicationBuilder builder) + { + builder.Configuration["DEBUG_SESSION_PORT"] = "5678"; + builder.Configuration["DEBUG_SESSION_INFO"] = JsonSerializer.Serialize(new + { + protocols_supported = new[] { "2024-03-03" }, + supported_launch_configurations = new[] { "java" } + }); + } + + [Fact] + public async Task AddQuarkusApp_MavenProject_LaunchesInDevMode() + { + // Dev mode is what "run my Quarkus application locally" means: it is the only mode with live coding, + // and it is what the Quarkus documentation tells every reader to start with. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + using var application = builder.Build(); + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, application.Services), + TestContext.Current.CancellationToken); + + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultMavenWrapper), tempDir.Path, "quarkus:dev"), await ArgumentEvaluator.GetArgumentListAsync(app.Resource)); + } + + [Fact] + public async Task AddQuarkusApp_GradleProject_LaunchesInDevMode() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("build.gradle", "plugins { id 'io.quarkus' }"); + + var app = builder.AddQuarkusApp("pricing", tempDir.Path); + using var application = builder.Build(); + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, application.Services), + TestContext.Current.CancellationToken); + + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultGradleWrapper), tempDir.Path, "quarkusDev"), await ArgumentEvaluator.GetArgumentListAsync(app.Resource)); + } + + [Theory] + [InlineData("pom.xml")] + [InlineData("build.gradle")] + public void AddQuarkusApp_DoesNotCreateASeparateBuildResource(string buildFile) + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write(buildFile, ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + Assert.Same(app.Resource, Assert.Single(builder.Resources)); + } + + [Fact] + public void AddQuarkusApp_DeclaresHttpEndpointThroughQuarkusHttpPort() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + var endpoint = Assert.Single(app.Resource.Annotations.OfType()); + Assert.Equal("http", endpoint.Name); + Assert.Equal("QUARKUS_HTTP_PORT", endpoint.TargetPortEnvironmentVariable); + Assert.Null(endpoint.TargetPort); + } + + [Fact] + public void AddQuarkusApp_GradleProject_DeclaresHttpEndpointThroughQuarkusHttpPort() + { + // The endpoint has to be declared for both build tools. Attaching it to only one branch of the + // detection is an easy mistake that leaves half of all applications with no endpoint at all. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("build.gradle", "plugins { id 'io.quarkus' }"); + + var app = builder.AddQuarkusApp("pricing", tempDir.Path); + + var endpoint = Assert.Single(app.Resource.Annotations.OfType()); + Assert.Equal("QUARKUS_HTTP_PORT", endpoint.TargetPortEnvironmentVariable); + } + + [Fact] + public async Task AddQuarkusApp_SetsTheDevProfileInRunMode() + { + // The IDE launches the packaged application rather than quarkus:dev, so the profile has to be set + // as an environment variable for both to resolve the same %dev. configuration. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + TestEndpointAllocator.AllocateEndpoints(app.Resource); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("dev", envVars["QUARKUS_PROFILE"]); + } + + [Fact] + public async Task AddQuarkusApp_DoesNotSetTheDevProfileWhenPublishing() + { + // A published image runs the packaged application, which must resolve prod configuration. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance); + + Assert.False(envVars.ContainsKey("QUARKUS_PROFILE")); + } + + [Fact] + public async Task AddQuarkusApp_DisablesTheObservabilityDevServiceInRunMode() + { + // Left on, the Dev Service pulls grafana/otel-lgtm and repoints the exporter at that container, + // so every span and metric lands somewhere the Aspire dashboard cannot see. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + TestEndpointAllocator.AllocateEndpoints(app.Resource); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("false", envVars["QUARKUS_OBSERVABILITY_ENABLED"]); + } + + [Fact] + public async Task AddQuarkusApp_MirrorsTheOtlpConfigurationOntoTheNamesQuarkusReads() + { + // quarkus-opentelemetry reads quarkus.otel.*, not the standard OTEL_* names, so without the mirror + // it keeps exporting to its own localhost:4317 default. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + TestEndpointAllocator.AllocateEndpoints(app.Resource); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal(envVars["OTEL_EXPORTER_OTLP_ENDPOINT"], envVars["QUARKUS_OTEL_EXPORTER_OTLP_ENDPOINT"]); + Assert.Equal(envVars["OTEL_EXPORTER_OTLP_PROTOCOL"], envVars["QUARKUS_OTEL_EXPORTER_OTLP_PROTOCOL"]); + Assert.Equal(envVars["OTEL_SERVICE_NAME"], envVars["QUARKUS_OTEL_SERVICE_NAME"]); + Assert.Equal(envVars["OTEL_RESOURCE_ATTRIBUTES"], envVars["QUARKUS_OTEL_RESOURCE_ATTRIBUTES"]); + } + + [Fact] + public async Task AddQuarkusApp_DoesNotMirrorTheOtlpConfigurationWhenPublishing() + { + // WithOtlpExporter contributes nothing in publish mode, so there is nothing to mirror. The mirror + // must not invent a value either: an OTLP endpoint baked in here would be the AppHost's, not the + // one the compute environment goes on to supply. A deployed application maps the value in its own + // application.properties instead, which both Quarkus playgrounds do. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance); + + Assert.False(envVars.ContainsKey("QUARKUS_OTEL_EXPORTER_OTLP_ENDPOINT")); + Assert.False(envVars.ContainsKey("QUARKUS_OBSERVABILITY_ENABLED")); + } + + [Fact] + public void AddQuarkusApp_AddsNoHealthCheck() + { + // /q/health only exists with the smallrye-health extension. Adding it unconditionally would leave + // applications without that extension permanently unhealthy and stall every WaitFor on them. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + + Assert.Empty(app.Resource.Annotations.OfType()); + } + + [Fact] + public async Task AddQuarkusApp_NoBuildFile_ThrowsWhenTheResourceStarts() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path); + using var application = builder.Build(); + + var ex = await Assert.ThrowsAsync( + () => builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, application.Services), + TestContext.Current.CancellationToken)); + + Assert.Equal( + $"Directory '{tempDir.Path}' contains no pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts, " + + "so the build tool for resource 'inventory' cannot be detected. Check the path, or use AddJavaApp for an application laid out differently.", + ex.Message); + } + + [Fact] + public async Task AddQuarkusApp_RemainsAJavaAppResource() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddQuarkusApp("inventory", tempDir.Path) + .WithJvmArgs("-Xmx256m") + .WithExternalHttpEndpoints(); + + TestEndpointAllocator.AllocateEndpoints(app.Resource); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("-Xmx256m", envVars["JAVA_TOOL_OPTIONS"]); + } +} diff --git a/tests/Aspire.Hosting.Java.Tests/AddSpringBootAppTests.cs b/tests/Aspire.Hosting.Java.Tests/AddSpringBootAppTests.cs new file mode 100644 index 00000000000..14fd0a501ff --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/AddSpringBootAppTests.cs @@ -0,0 +1,391 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Java.Tests; + +public class AddSpringBootAppTests +{ + [Fact] + public async Task AddSpringBootApp_MavenProject_LaunchesThroughSpringBootRun() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path); + using var application = builder.Build(); + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, application.Services), + TestContext.Current.CancellationToken); + + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultMavenWrapper), tempDir.Path, "spring-boot:run"), await ArgumentEvaluator.GetArgumentListAsync(app.Resource)); + } + + [Fact] + public async Task AddSpringBootApp_GradleProject_LaunchesThroughBootRun() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("build.gradle", "plugins { id 'org.springframework.boot' }"); + + var app = builder.AddSpringBootApp("orders", tempDir.Path); + using var application = builder.Build(); + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, application.Services), + TestContext.Current.CancellationToken); + + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + Assert.Equal(ExpectedWrapperInvocation.Args(Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultGradleWrapper), tempDir.Path, "bootRun"), await ArgumentEvaluator.GetArgumentListAsync(app.Resource)); + } + + [Fact] + public async Task AddSpringBootApp_KotlinGradleProject_IsDetectedAsGradle() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("build.gradle.kts", "plugins { id(\"org.springframework.boot\") }"); + + var app = builder.AddSpringBootApp("orders", tempDir.Path); + using var application = builder.Build(); + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, application.Services), + TestContext.Current.CancellationToken); + + Assert.Equal(ExpectedWrapperInvocation.Command(), app.Resource.Command); + } + + [Theory] + [InlineData("build.gradle")] + [InlineData("build.gradle.kts")] + [InlineData("settings.gradle")] + [InlineData("settings.gradle.kts")] + public async Task BuildToolDetection_GradleMarkersAgreeBetweenRunAndPublish(string marker) + { + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write(marker, ""); + + using var runBuilder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + var runApp = runBuilder.AddSpringBootApp("catalog", tempDir.Path); + using var application = runBuilder.Build(); + await runBuilder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(runApp.Resource, application.Services), + TestContext.Current.CancellationToken); + var runTool = Assert.Single(runApp.Resource.Annotations.OfType()).Tool; + + using var publishBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var publishApp = publishBuilder.AddJavaApp("catalog", tempDir.Path, "build/libs/catalog.jar"); + var (publishTool, _) = JavaDockerfileGenerator.ResolveBuildTool(publishApp.Resource, tempDir.Path); + + Assert.Equal(JavaBuildTool.Gradle, runTool); + Assert.Equal(runTool, publishTool); + } + + [Theory] + [InlineData("pom.xml")] + [InlineData("build.gradle")] + public void AddSpringBootApp_DoesNotCreateASeparateBuildResource(string buildFile) + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write(buildFile, ""); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path); + + Assert.Same(app.Resource, Assert.Single(builder.Resources)); + } + + [Fact] + public async Task AddSpringBootApp_ExplicitLaunchGoalOverridesTheDetectedDefault() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path) + .WithMavenGoal("spring-boot:test-run"); + using var application = builder.Build(); + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, application.Services), + TestContext.Current.CancellationToken); + + Assert.Equal( + ExpectedWrapperInvocation.Args( + Path.Combine(tempDir.Path, JavaHostingExtensions.s_defaultMavenWrapper), + tempDir.Path, + "spring-boot:test-run"), + await ArgumentEvaluator.GetArgumentListAsync(app.Resource)); + } + + [Fact] + public async Task AddSpringBootApp_ExplicitBuildArgumentsDoNotCreateASeparateBuildResource() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + tempDir.WriteWrapper(JavaHostingExtensions.s_defaultMavenWrapper); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path) + .WithMavenBuild("verify"); + using var application = builder.Build(); + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, application.Services), + TestContext.Current.CancellationToken); + + Assert.Same(app.Resource, Assert.Single(builder.Resources)); + Assert.Equal(["verify"], Assert.Single(app.Resource.Annotations.OfType()).Args); + Assert.Equal(["spring-boot:run"], Assert.Single(app.Resource.Annotations.OfType()).Args); + } + + [Fact] + public void AddSpringBootApp_DeclaresHttpEndpointThroughServerPort() + { + // Spring Boot reads SERVER_PORT, which is how the port Aspire allocates reaches the application + // without any code in the application itself. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path); + + var endpoint = Assert.Single(app.Resource.Annotations.OfType()); + Assert.Equal("http", endpoint.Name); + Assert.Equal("SERVER_PORT", endpoint.TargetPortEnvironmentVariable); + + // Pinning a target port would make two Spring Boot services collide on a real port on the machine, + // because these run as host processes rather than containers. + Assert.Null(endpoint.TargetPort); + } + + [Fact] + public void AddSpringBootApp_AddsNoHealthCheck() + { + // /actuator/health only exists with spring-boot-starter-actuator. Adding it unconditionally would + // leave applications without that dependency permanently unhealthy and stall every WaitFor on them. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path); + + Assert.Empty(app.Resource.Annotations.OfType()); + } + + [Fact] + public async Task AddSpringBootApp_NoBuildFile_ThrowsWhenTheResourceStarts() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path); + using var application = builder.Build(); + + var ex = await Assert.ThrowsAsync( + () => builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, application.Services), + TestContext.Current.CancellationToken)); + + Assert.Equal( + $"Directory '{tempDir.Path}' contains no pom.xml, build.gradle, build.gradle.kts, settings.gradle, or settings.gradle.kts, " + + "so the build tool for resource 'catalog' cannot be detected. Check the path, or use AddJavaApp for an application laid out differently.", + ex.Message); + } + + [Fact] + public async Task BuildToolDetection_BothBuildToolsAreRejectedInRunAndPublish() + { + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + tempDir.Write("build.gradle", ""); + + using var runBuilder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + var runApp = runBuilder.AddSpringBootApp("catalog", tempDir.Path); + using var application = runBuilder.Build(); + var runException = await Assert.ThrowsAsync( + () => runBuilder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(runApp.Resource, application.Services), + TestContext.Current.CancellationToken)); + + using var publishBuilder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + var publishApp = publishBuilder.AddJavaApp("catalog", tempDir.Path, "target/catalog.jar"); + var publishException = Assert.Throws( + () => JavaDockerfileGenerator.ResolveBuildTool(publishApp.Resource, tempDir.Path)); + + Assert.Equal( + $"Directory '{tempDir.Path}' contains both Maven and Gradle build files, so the build tool for resource 'catalog' is ambiguous. " + + "Use AddJavaApp and call WithMavenBuild, WithGradleBuild, WithMavenGoal, or WithGradleTask to choose one explicitly.", + runException.Message); + Assert.Equal(runException.Message, publishException.Message); + } + + [Theory] + [InlineData("pom.xml", "target")] + [InlineData("build.gradle", "build")] + public async Task WithOtelAgent_NoPath_ResolvesTheBuildToolsOutputDirectory(string buildFile, string outputDirectory) + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write(buildFile, ""); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path).WithOtelAgent(); + using var application = builder.Build(); + await builder.Eventing.PublishAsync( + new BeforeResourceStartedEvent(app.Resource, application.Services), + TestContext.Current.CancellationToken); + + TestEndpointAllocator.AllocateEndpoints(app.Resource); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + var expected = Path.GetFullPath(Path.Combine(tempDir.Path, outputDirectory, "agent", "opentelemetry-javaagent.jar")); + Assert.Equal($"-javaagent:{expected}", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Theory] + [InlineData("pom.xml", "target")] + [InlineData("build.gradle", "build")] + public async Task WithOtelAgent_NoPath_ResolvesTheBuildToolConfiguredAfterIt(string buildFile, string outputDirectory) + { + // The build tool is deliberately configured after the agent. WithWrapperPath promises order + // independence, and the agent overload has no reason to be the one method that does not. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write(buildFile, ""); + + var app = builder.AddJavaApp("catalog", tempDir.Path).WithOtelAgent(); + + if (buildFile is "pom.xml") + { + app.WithMavenGoal("spring-boot:run").WithMavenBuild(); + } + else + { + app.WithGradleTask("bootRun").WithGradleBuild(); + } + + TestEndpointAllocator.AllocateEndpoints(app.Resource); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + var expected = Path.GetFullPath(Path.Combine(tempDir.Path, outputDirectory, "agent", "opentelemetry-javaagent.jar")); + Assert.Equal($"-javaagent:{expected}", envVars["JAVA_TOOL_OPTIONS"]); + } + + [Theory] + [InlineData("pom.xml", "maven")] + [InlineData("build.gradle", "gradle")] + public async Task WithOtelAgent_BuildProducedAgent_AddsABuildTheApplicationWaitsFor(string buildFile, string toolName) + { + // spring-boot:run and bootRun compile the application themselves, so a Spring Boot resource + // normally has no separate build resource. A build-produced agent makes one mandatory anyway: + // JAVA_TOOL_OPTIONS is read by the wrapper's own JVM, so without a build that has already + // written the agent, that JVM dies at VM initialization before the launch goal ever runs. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write(buildFile, ""); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path).WithOtelAgent(); + + var buildResource = Assert.Single( + builder.Resources.OfType(), + resource => resource.Name == $"catalog-{toolName}-build"); + + Assert.Contains( + app.Resource.Annotations.OfType(), + wait => ReferenceEquals(wait.Resource, buildResource)); + + // The build resource is the one that produces the agent, so it must not be asked to load it. + var buildEnv = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + buildResource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.DoesNotContain("JAVA_TOOL_OPTIONS", buildEnv.Keys); + } + + [Fact] + public async Task WithOtelAgent_AbsoluteAgentPath_AddsNoBuild() + { + // An absolute path is supplied by the machine rather than produced by the build, so there is + // nothing to build first and adding a resource would just slow the application down. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path) + .WithOtelAgent(Path.Combine(Path.GetTempPath(), "opentelemetry-javaagent.jar")); + + Assert.Empty(builder.Resources.OfType()); + Assert.Empty(app.Resource.Annotations.OfType()); + } + + [Fact] + public async Task WithOtelAgent_NoPath_WithoutABuild_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + + // Resolution is deferred so the build tool can be configured afterwards, so the failure for a + // resource that never configures one surfaces when the environment is evaluated. + var app = builder.AddJavaApp("api", tempDir.Path).WithOtelAgent(); + + TestEndpointAllocator.AllocateEndpoints(app.Resource); + + var ex = await Assert.ThrowsAsync( + async () => await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance)); + + Assert.Contains("has no Maven or Gradle build configured", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AddSpringBootApp_RemainsAJavaAppResource() + { + // The helper is AddJavaApp with the Spring Boot defaults applied, so every other With… method has + // to keep working on the result. + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path) + .WithJvmArgs("-Xmx256m") + .WithExternalHttpEndpoints(); + + TestEndpointAllocator.AllocateEndpoints(app.Resource); + + var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + app.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("-Xmx256m", envVars["JAVA_TOOL_OPTIONS"]); + Assert.True(Assert.Single(app.Resource.Annotations.OfType()).IsExternal); + } + + [Fact] + public void WithMavenBuildThenWithOtelAgent_CreatesTheBuildResourceTheAgentNeeds() + { + // spring-boot:run compiles on its way to running, so the first WithMavenBuild records the build + // step without adding a resource. A relative OpenTelemetry agent path then makes the build + // mandatory - nothing else writes target/agent/opentelemetry-javaagent.jar - and the second pass + // through WithJavaBuildStep is the one that has to add it. Skipping on the annotation alone left + // the annotation naming a resource that was never created, and the JVM failed at startup with + // "Error opening zip file or JAR manifest missing". + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(); + tempDir.Write("pom.xml", ""); + + var app = builder.AddSpringBootApp("catalog", tempDir.Path) + .WithMavenBuild("-DskipTests", "package"); + + Assert.DoesNotContain(builder.Resources, r => r.Name == "catalog-maven-build"); + + app.WithOtelAgent(); + + var buildResource = Assert.Single(builder.Resources, r => r.Name == "catalog-maven-build"); + Assert.Equal(JavaBuildTool.Maven, Assert.IsType(buildResource).Tool); + } +} diff --git a/tests/Aspire.Hosting.Java.Tests/Aspire.Hosting.Java.Tests.csproj b/tests/Aspire.Hosting.Java.Tests/Aspire.Hosting.Java.Tests.csproj new file mode 100644 index 00000000000..c3eb4478393 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Aspire.Hosting.Java.Tests.csproj @@ -0,0 +1,26 @@ + + + + $(DefaultTargetFramework) + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Aspire.Hosting.Java.Tests/ExpectedWrapperInvocation.cs b/tests/Aspire.Hosting.Java.Tests/ExpectedWrapperInvocation.cs new file mode 100644 index 00000000000..575f228e6b8 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/ExpectedWrapperInvocation.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Java.Tests; + +/// +/// The command line a build tool wrapper launch is expected to produce on the current platform. +/// +/// +/// Unix launches go through sh, because a wrapper committed from Windows checks out without an +/// executable bit, so the wrapper path moves from the command into the first argument. Windows launches +/// the .cmd and .bat wrappers through the command interpreter, because a batch file started +/// with redirected stdout can silently produce no output, and runs it through call so the quoted +/// wrapper path is never the first token on a line whose quotes cmd.exe would otherwise strip. +/// +/// Tests state the wrapper, the working directory, and the tool's own arguments, and let this decide the +/// shape, so neither platform is asserted against the other's command line. +/// +/// +internal static class ExpectedWrapperInvocation +{ + public static string Command() + => OperatingSystem.IsWindows() + ? Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe" + : "sh"; + + public static string[] Args(string wrapperPath, string workingDirectory, params string[] toolArgs) + => OperatingSystem.IsWindows() + ? ["/c", "call", Path.GetRelativePath(workingDirectory, wrapperPath), .. toolArgs] + : [wrapperPath, .. toolArgs]; +} diff --git a/tests/Aspire.Hosting.Java.Tests/JavaBuildToolResolverTests.cs b/tests/Aspire.Hosting.Java.Tests/JavaBuildToolResolverTests.cs new file mode 100644 index 00000000000..73193d5168f --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/JavaBuildToolResolverTests.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Java.Tests; + +public class JavaBuildToolResolverTests +{ + [Theory] + [InlineData(nameof(JavaBuildTool.Maven), false, "mvnw")] + [InlineData(nameof(JavaBuildTool.Maven), true, "mvnw.cmd")] + [InlineData(nameof(JavaBuildTool.Gradle), false, "gradlew")] + [InlineData(nameof(JavaBuildTool.Gradle), true, "gradlew.bat")] + public void ResolveWrapperPath_UsesTheRequestedPlatformsDefault( + string toolName, + bool isWindows, + string expectedWrapperName) + { + using var tempDir = new TempJavaAppDirectory(withWrappers: false); + var resource = new JavaAppResource("api", tempDir.Path); + var tool = Enum.Parse(toolName); + + var wrapperPath = JavaBuildToolResolver.ResolveWrapperPath(resource, tool, isWindows); + + Assert.Equal(Path.GetFullPath(Path.Combine(tempDir.Path, expectedWrapperName)), wrapperPath); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ResolveWrapperPath_UsesWithWrapperPathOnEveryPlatform(bool isWindows) + { + using var builder = TestDistributedApplicationBuilder.Create().WithResourceCleanUp(true); + using var tempDir = new TempJavaAppDirectory(withWrappers: false); + var app = builder.AddJavaApp("api", tempDir.Path).WithWrapperPath("tools/custom-wrapper"); + + var wrapperPath = JavaBuildToolResolver.ResolveWrapperPath(app.Resource, JavaBuildTool.Maven, isWindows); + + Assert.Equal(Path.GetFullPath(Path.Combine(tempDir.Path, "tools", "custom-wrapper")), wrapperPath); + } + + [Fact] + public void ResolveWrapperPath_UsesAnAncestorWrapperAtTheBuildRoot() + { + // A Maven multi-module repository keeps one mvnw next to the aggregator POM, so a resource + // pointed at a module has to find it there. + using var root = new TempJavaBuildRootDirectory(); + var module = root.CreateModule("catalog"); + var resource = new JavaAppResource("catalog", module); + + var wrapperPath = JavaBuildToolResolver.ResolveWrapperPath(resource, JavaBuildTool.Maven, isWindows: false); + + Assert.Equal(Path.GetFullPath(Path.Combine(root.RootPath, "mvnw")), wrapperPath); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "UnixFileMode does not describe Windows ACLs")] + public void ResolveWrapperPath_IgnoresAnAncestorWrapperInAWorldWritableDirectory() + { + // On a shared machine an application under a world-writable directory such as /tmp would + // otherwise execute an mvnw another user planted beside a pom.xml. The wrapper beside the + // application is used instead, which is the same outcome as finding no ancestor wrapper. + using var root = new TempJavaBuildRootDirectory(); + var module = root.CreateModule("catalog"); + // CA1416 does not understand SkipOnPlatform, which already keeps this off Windows. +#pragma warning disable CA1416 + File.SetUnixFileMode( + root.RootPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute); +#pragma warning restore CA1416 + var resource = new JavaAppResource("catalog", module); + + var wrapperPath = JavaBuildToolResolver.ResolveWrapperPath(resource, JavaBuildTool.Maven, isWindows: false); + + Assert.Equal(Path.GetFullPath(Path.Combine(module, "mvnw")), wrapperPath); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "UnixFileMode does not describe Windows ACLs")] + public void ResolveWrapperPath_UsesAnAncestorWrapperInAGroupWritableDirectory() + { + // Deliberately still trusted. Distributions that enable user private groups give every user a + // group of their own and a umask of 002, so an ordinary `git clone` on Ubuntu produces mode + // 775 directories. Rejecting group-writable would therefore stop resolving the aggregator + // wrapper for a large share of Linux checkouts - a hard build failure - to defend a case that + // needs a genuinely shared group. Distinguishing the two needs the directory's owner and + // group membership, which .NET does not expose portably. + using var root = new TempJavaBuildRootDirectory(); + var module = root.CreateModule("catalog"); + // CA1416 does not understand SkipOnPlatform, which already keeps this off Windows. +#pragma warning disable CA1416 + File.SetUnixFileMode( + root.RootPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); +#pragma warning restore CA1416 + var resource = new JavaAppResource("catalog", module); + + var wrapperPath = JavaBuildToolResolver.ResolveWrapperPath(resource, JavaBuildTool.Maven, isWindows: false); + + Assert.Equal(Path.GetFullPath(Path.Combine(root.RootPath, "mvnw")), wrapperPath); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "UnixFileMode does not describe Windows ACLs")] + public void ResolveWrapperPath_IgnoresAnAncestorWrapperThatIsItselfWorldWritable() + { + // Rewriting a file in place needs write permission on the file, not on its directory, so a + // safe build root still hands out an attacker-controlled script when the wrapper itself is + // world-writable. + using var root = new TempJavaBuildRootDirectory(); + var module = root.CreateModule("catalog"); + // CA1416 does not understand SkipOnPlatform, which already keeps this off Windows. +#pragma warning disable CA1416 + File.SetUnixFileMode( + Path.Combine(root.RootPath, "mvnw"), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute); +#pragma warning restore CA1416 + var resource = new JavaAppResource("catalog", module); + + var wrapperPath = JavaBuildToolResolver.ResolveWrapperPath(resource, JavaBuildTool.Maven, isWindows: false); + + Assert.Equal(Path.GetFullPath(Path.Combine(module, "mvnw")), wrapperPath); + } +} + +/// +/// A Maven multi-module layout: an aggregator POM and wrapper at the root, modules beneath it, and no +/// .git, so the ancestor walk is not stopped by a checkout boundary. +/// +internal sealed class TempJavaBuildRootDirectory : IDisposable +{ + private readonly DirectoryInfo _root = Directory.CreateTempSubdirectory("aspire-java-build-root"); + + public TempJavaBuildRootDirectory() + { + File.WriteAllText(Path.Combine(_root.FullName, "pom.xml"), ""); + File.WriteAllText(Path.Combine(_root.FullName, "mvnw"), "#!/bin/sh\n"); + } + + public string RootPath => _root.FullName; + + public string CreateModule(string name) + { + var module = _root.CreateSubdirectory(name); + File.WriteAllText(Path.Combine(module.FullName, "pom.xml"), ""); + + return module.FullName; + } + + public void Dispose() => _root.Delete(recursive: true); +} diff --git a/tests/Aspire.Hosting.Java.Tests/JavaPublicApiTests.cs b/tests/Aspire.Hosting.Java.Tests/JavaPublicApiTests.cs new file mode 100644 index 00000000000..c96c6d4265e --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/JavaPublicApiTests.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREEXTENSION001 + +namespace Aspire.Hosting.Java.Tests; + +public class JavaPublicApiTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CtorJavaAppResourceShouldThrowWhenNameIsNullOrEmpty(bool isNull) + { + var name = isNull ? null! : string.Empty; + const string workingDirectory = "/src/java-app"; + + var action = () => new JavaAppResource(name, workingDirectory); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } +} diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.PublishingWithAWindowsBatchWrapperUsesThePosixSibling_tool=gradle.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.PublishingWithAWindowsBatchWrapperUsesThePosixSibling_tool=gradle.verified.txt new file mode 100644 index 00000000000..bf19bb12bf8 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.PublishingWithAWindowsBatchWrapperUsesThePosixSibling_tool=gradle.verified.txt @@ -0,0 +1,15 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build +WORKDIR /app +ENV GRADLE_USER_HOME=/root/.gradle +COPY scripts/custom-gradlew ./scripts/custom-gradlew +COPY scripts/gradle ./scripts/gradle +RUN sed -i 's/\r$//' ./scripts/custom-gradlew && sh ./scripts/custom-gradlew --no-daemon --version +COPY . . +RUN --mount=type=cache,id=aspire-java-gradle-api,target=/root/.gradle/caches,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./scripts/custom-gradlew && sh ./scripts/custom-gradlew --no-daemon -x test build && jars=$(ls build/libs/*.jar 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\.jar$' || true) && count=$(printf '%s\n' "$jars" | grep -c . || true) && if [ "$count" != "1" ]; then echo "Aspire: expected exactly one application JAR from the build of 'api' matching build/libs/*.jar, found $count:" >&2; echo "$jars" >&2; echo "Use WithJarArtifact(\"\") to select one." >&2; exit 1; fi && cp "$jars" /build/app.jar + +FROM docker.io/library/eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build --chown=999:999 /build/app.jar /app/app.jar +USER 999:999 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.PublishingWithAWindowsBatchWrapperUsesThePosixSibling_tool=maven.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.PublishingWithAWindowsBatchWrapperUsesThePosixSibling_tool=maven.verified.txt new file mode 100644 index 00000000000..34d6ed0fc26 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.PublishingWithAWindowsBatchWrapperUsesThePosixSibling_tool=maven.verified.txt @@ -0,0 +1,14 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build +WORKDIR /app +COPY scripts/custom-mvnw ./scripts/custom-mvnw +COPY scripts/.mvn ./scripts/.mvn +RUN sed -i 's/\r$//' ./scripts/custom-mvnw && sh ./scripts/custom-mvnw -B -ntp --version +COPY . . +RUN --mount=type=cache,id=aspire-java-maven-api,target=/root/.m2/repository,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./scripts/custom-mvnw && sh ./scripts/custom-mvnw -B -ntp -DskipTests package && jars=$(ls target/*.jar 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\.jar$' || true) && count=$(printf '%s\n' "$jars" | grep -c . || true) && if [ "$count" != "1" ]; then echo "Aspire: expected exactly one application JAR from the build of 'api' matching target/*.jar, found $count:" >&2; echo "$jars" >&2; echo "Use WithJarArtifact(\"\") to select one." >&2; exit 1; fi && cp "$jars" /build/app.jar + +FROM docker.io/library/eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build --chown=999:999 /build/app.jar /app/app.jar +USER 999:999 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_CopiesABuildProducedOtelAgentIntoTheRuntimeImage.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_CopiesABuildProducedOtelAgentIntoTheRuntimeImage.verified.txt new file mode 100644 index 00000000000..ab8e96f9f5b --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_CopiesABuildProducedOtelAgentIntoTheRuntimeImage.verified.txt @@ -0,0 +1,15 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build +WORKDIR /app +COPY mvnw ./mvnw +COPY .mvn ./.mvn +RUN sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp --version +COPY . . +RUN --mount=type=cache,id=aspire-java-maven-api,target=/root/.m2/repository,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp -DskipTests package && jars=$(ls target/*.jar 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\.jar$' || true) && count=$(printf '%s\n' "$jars" | grep -c . || true) && if [ "$count" != "1" ]; then echo "Aspire: expected exactly one application JAR from the build of 'api' matching target/*.jar, found $count:" >&2; echo "$jars" >&2; echo "Use WithJarArtifact(\"\") to select one." >&2; exit 1; fi && cp "$jars" /build/app.jar + +FROM docker.io/library/eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build --chown=999:999 /build/app.jar /app/app.jar +COPY --from=build --chown=999:999 /app/target/agent/opentelemetry-javaagent.jar /app/agent.jar +USER 999:999 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_DetectsTheBuildToolFromDiskWhenOnlyAJarPathWasGiven.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_DetectsTheBuildToolFromDiskWhenOnlyAJarPathWasGiven.verified.txt new file mode 100644 index 00000000000..e866e79856f --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_DetectsTheBuildToolFromDiskWhenOnlyAJarPathWasGiven.verified.txt @@ -0,0 +1,14 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build +WORKDIR /app +COPY mvnw ./mvnw +COPY .mvn ./.mvn +RUN sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp --version +COPY . . +RUN --mount=type=cache,id=aspire-java-maven-api,target=/root/.m2/repository,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp -DskipTests package && if [ ! -f 'target/worker.jar' ]; then echo "Aspire: the build of 'api' did not produce" 'target/worker.jar' >&2; echo "Check the jarPath passed to AddJavaApp, or use WithJarArtifact to name the published artifact separately." >&2; exit 1; fi && cp 'target/worker.jar' /build/app.jar + +FROM docker.io/library/eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build --chown=999:999 /build/app.jar /app/app.jar +USER 999:999 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_EmitsABuildContextIgnoreThatExcludesBuildOutputDirectories.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_EmitsABuildContextIgnoreThatExcludesBuildOutputDirectories.verified.txt new file mode 100644 index 00000000000..dd3b54cd4a9 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_EmitsABuildContextIgnoreThatExcludesBuildOutputDirectories.verified.txt @@ -0,0 +1,19 @@ +# Generated by Aspire. Author /.dockerignore to take over these rules. +target +**/target +build +**/build +.gradle +**/.gradle +.git +.gitignore +.DS_Store +.env +.env.* +.aspire +aspire-output +Dockerfile +Dockerfile.* +*.Dockerfile +*.Dockerfile.dockerignore +.dockerignore diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_GeneratesAGradleBuild.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_GeneratesAGradleBuild.verified.txt new file mode 100644 index 00000000000..d278ed77dca --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_GeneratesAGradleBuild.verified.txt @@ -0,0 +1,15 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:17-jdk AS build +WORKDIR /app +ENV GRADLE_USER_HOME=/root/.gradle +COPY gradlew ./gradlew +COPY gradle ./gradle +RUN sed -i 's/\r$//' ./gradlew && sh ./gradlew --no-daemon --version +COPY . . +RUN --mount=type=cache,id=aspire-java-gradle-api,target=/root/.gradle/caches,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./gradlew && sh ./gradlew --no-daemon -x test build && jars=$(ls build/libs/*.jar 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\.jar$' || true) && count=$(printf '%s\n' "$jars" | grep -c . || true) && if [ "$count" != "1" ]; then echo "Aspire: expected exactly one application JAR from the build of 'api' matching build/libs/*.jar, found $count:" >&2; echo "$jars" >&2; echo "Use WithJarArtifact(\"\") to select one." >&2; exit 1; fi && cp "$jars" /build/app.jar + +FROM docker.io/library/eclipse-temurin:17-jre +WORKDIR /app +COPY --from=build --chown=999:999 /build/app.jar /app/app.jar +USER 999:999 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_GeneratesAMavenBuildAndJreRuntimePair.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_GeneratesAMavenBuildAndJreRuntimePair.verified.txt new file mode 100644 index 00000000000..e2e6ed721ee --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_GeneratesAMavenBuildAndJreRuntimePair.verified.txt @@ -0,0 +1,14 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build +WORKDIR /app +COPY mvnw ./mvnw +COPY .mvn ./.mvn +RUN sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp --version +COPY . . +RUN --mount=type=cache,id=aspire-java-maven-api,target=/root/.m2/repository,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp -DskipTests package && jars=$(ls target/*.jar 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\.jar$' || true) && count=$(printf '%s\n' "$jars" | grep -c . || true) && if [ "$count" != "1" ]; then echo "Aspire: expected exactly one application JAR from the build of 'api' matching target/*.jar, found $count:" >&2; echo "$jars" >&2; echo "Use WithJarArtifact(\"\") to select one." >&2; exit 1; fi && cp "$jars" /build/app.jar + +FROM docker.io/library/eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build --chown=999:999 /build/app.jar /app/app.jar +USER 999:999 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_HonoursWithDockerfileBaseImage.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_HonoursWithDockerfileBaseImage.verified.txt new file mode 100644 index 00000000000..819f3872f9d --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_HonoursWithDockerfileBaseImage.verified.txt @@ -0,0 +1,14 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/amazoncorretto:21 AS build +WORKDIR /app +COPY mvnw ./mvnw +COPY .mvn ./.mvn +RUN sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp --version +COPY . . +RUN --mount=type=cache,id=aspire-java-maven-api,target=/root/.m2/repository,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp -DskipTests package && jars=$(ls target/*.jar 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\.jar$' || true) && count=$(printf '%s\n' "$jars" | grep -c . || true) && if [ "$count" != "1" ]; then echo "Aspire: expected exactly one application JAR from the build of 'api' matching target/*.jar, found $count:" >&2; echo "$jars" >&2; echo "Use WithJarArtifact(\"\") to select one." >&2; exit 1; fi && cp "$jars" /build/app.jar + +FROM docker.io/library/amazoncorretto:21-alpine +WORKDIR /app +COPY --from=build --chown=999:999 /build/app.jar /app/app.jar +USER 999:999 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_ProducesAContainerManifestEntry.verified.json b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_ProducesAContainerManifestEntry.verified.json new file mode 100644 index 00000000000..6461ef8de6f --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_ProducesAContainerManifestEntry.verified.json @@ -0,0 +1,22 @@ +{ + "resources": { + "api": { + "type": "container.v1", + "build": { + "context": "{sourceDirectory}", + "dockerfile": "api.Dockerfile" + }, + "env": { + "SERVER_PORT": "{api.bindings.http.targetPort}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "targetPort": 8080 + } + } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_Quarkus_Gradle_StagesFromTheGradleOutputDirectory.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_Quarkus_Gradle_StagesFromTheGradleOutputDirectory.verified.txt new file mode 100644 index 00000000000..1575d697e57 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_Quarkus_Gradle_StagesFromTheGradleOutputDirectory.verified.txt @@ -0,0 +1,15 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build +WORKDIR /app +ENV GRADLE_USER_HOME=/root/.gradle +COPY gradlew ./gradlew +COPY gradle ./gradle +RUN sed -i 's/\r$//' ./gradlew && sh ./gradlew --no-daemon --version +COPY . . +RUN --mount=type=cache,id=aspire-java-gradle-api,target=/root/.gradle/caches,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./gradlew && sh ./gradlew --no-daemon build -x test && mkdir -p /build/app && quarkus_artifact=$(sed -n 's/^path=//p' build/quarkus-artifact.properties 2>/dev/null | head -1) && case "$quarkus_artifact" in '') if [ -d build/quarkus-app ]; then cp -r build/quarkus-app/. /build/app/; else jars=$(ls build/libs/*.jar 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\.jar$' || true) && count=$(printf '%s\n' "$jars" | grep -c . || true) && if [ "$count" != "1" ]; then echo "Aspire: expected exactly one application JAR from the build of 'api' matching build/libs/*.jar, found $count:" >&2; echo "$jars" >&2; echo "Use WithJarArtifact(\"\") to select one." >&2; exit 1; fi && cp "$jars" /build/app/quarkus-run.jar; fi ;; */*) cp -r "build/${quarkus_artifact%/*}/." /build/app/ ;; *) cp "build/$quarkus_artifact" /build/app/quarkus-run.jar && if [ -d build/lib ]; then cp -r build/lib /build/app/lib; fi ;; esac + +FROM docker.io/library/eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build --chown=999:999 /build/app /app +USER 999:999 +ENTRYPOINT ["java","-jar","/app/quarkus-run.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_Quarkus_StagesTheFastJarDirectoryAndRunsQuarkusRunJar.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_Quarkus_StagesTheFastJarDirectoryAndRunsQuarkusRunJar.verified.txt new file mode 100644 index 00000000000..a1249919436 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_Quarkus_StagesTheFastJarDirectoryAndRunsQuarkusRunJar.verified.txt @@ -0,0 +1,14 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build +WORKDIR /app +COPY mvnw ./mvnw +COPY .mvn ./.mvn +RUN sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp --version +COPY . . +RUN --mount=type=cache,id=aspire-java-maven-api,target=/root/.m2/repository,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp -DskipTests package && mkdir -p /build/app && quarkus_artifact=$(sed -n 's/^path=//p' target/quarkus-artifact.properties 2>/dev/null | head -1) && case "$quarkus_artifact" in '') if [ -d target/quarkus-app ]; then cp -r target/quarkus-app/. /build/app/; else jars=$(ls target/*.jar 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\.jar$' || true) && count=$(printf '%s\n' "$jars" | grep -c . || true) && if [ "$count" != "1" ]; then echo "Aspire: expected exactly one application JAR from the build of 'api' matching target/*.jar, found $count:" >&2; echo "$jars" >&2; echo "Use WithJarArtifact(\"\") to select one." >&2; exit 1; fi && cp "$jars" /build/app/quarkus-run.jar; fi ;; */*) cp -r "target/${quarkus_artifact%/*}/." /build/app/ ;; *) cp "target/$quarkus_artifact" /build/app/quarkus-run.jar && if [ -d target/lib ]; then cp -r target/lib /build/app/lib; fi ;; esac + +FROM docker.io/library/eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build --chown=999:999 /build/app /app +USER 999:999 +ENTRYPOINT ["java","-jar","/app/quarkus-run.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_ReusesTheArgumentsConfiguredForTheHostBuildStep.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_ReusesTheArgumentsConfiguredForTheHostBuildStep.verified.txt new file mode 100644 index 00000000000..ea8120ecb87 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_ReusesTheArgumentsConfiguredForTheHostBuildStep.verified.txt @@ -0,0 +1,14 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build +WORKDIR /app +COPY mvnw ./mvnw +COPY .mvn ./.mvn +RUN sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp --version +COPY . . +RUN --mount=type=cache,id=aspire-java-maven-api,target=/root/.m2/repository,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./mvnw && sh ./mvnw -Pprod package && jars=$(ls target/*.jar 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\.jar$' || true) && count=$(printf '%s\n' "$jars" | grep -c . || true) && if [ "$count" != "1" ]; then echo "Aspire: expected exactly one application JAR from the build of 'api' matching target/*.jar, found $count:" >&2; echo "$jars" >&2; echo "Use WithJarArtifact(\"\") to select one." >&2; exit 1; fi && cp "$jars" /build/app.jar + +FROM docker.io/library/eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build --chown=999:999 /build/app.jar /app/app.jar +USER 999:999 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_UsesTheWrapperWhenTheProjectShipsOne.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_UsesTheWrapperWhenTheProjectShipsOne.verified.txt new file mode 100644 index 00000000000..e2e6ed721ee --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_UsesTheWrapperWhenTheProjectShipsOne.verified.txt @@ -0,0 +1,14 @@ +FROM --platform=$BUILDPLATFORM docker.io/library/eclipse-temurin:21-jdk AS build +WORKDIR /app +COPY mvnw ./mvnw +COPY .mvn ./.mvn +RUN sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp --version +COPY . . +RUN --mount=type=cache,id=aspire-java-maven-api,target=/root/.m2/repository,sharing=locked \ + mkdir -p /build && sed -i 's/\r$//' ./mvnw && sh ./mvnw -B -ntp -DskipTests package && jars=$(ls target/*.jar 2>/dev/null | grep -Ev '(-plain|-sources|-javadoc)\.jar$' || true) && count=$(printf '%s\n' "$jars" | grep -c . || true) && if [ "$count" != "1" ]; then echo "Aspire: expected exactly one application JAR from the build of 'api' matching target/*.jar, found $count:" >&2; echo "$jars" >&2; echo "Use WithJarArtifact(\"\") to select one." >&2; exit 1; fi && cp "$jars" /build/app.jar + +FROM docker.io/library/eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build --chown=999:999 /build/app.jar /app/app.jar +USER 999:999 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_WithAPrebuiltJarAndNoBuildTool_CopiesTheJarWithoutABuildStage.verified.txt b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_WithAPrebuiltJarAndNoBuildTool_CopiesTheJarWithoutABuildStage.verified.txt new file mode 100644 index 00000000000..848c4cdf86d --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/Snapshots/AddJavaAppPublishTests.VerifyPublish_WithAPrebuiltJarAndNoBuildTool_CopiesTheJarWithoutABuildStage.verified.txt @@ -0,0 +1,5 @@ +FROM docker.io/library/eclipse-temurin:21-jre +WORKDIR /app +COPY --chown=999:999 target/worker.jar /app/app.jar +USER 999:999 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/tests/Aspire.Hosting.Java.Tests/TempJavaAppDirectory.cs b/tests/Aspire.Hosting.Java.Tests/TempJavaAppDirectory.cs new file mode 100644 index 00000000000..b704feb62fa --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/TempJavaAppDirectory.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Java.Tests; + +/// +/// Creates a securely-created temporary directory that stands in for a Java application directory. +/// +internal sealed class TempJavaAppDirectory : IDisposable +{ + private readonly DirectoryInfo _root = Directory.CreateTempSubdirectory("aspire-java-tests"); + private readonly DirectoryInfo _directory; + + /// + /// Whether to seed both wrapper scripts. Aspire requires a wrapper, so a project that ships one is the + /// normal case; pass to exercise the rejection. + /// + /// + /// Name to give the application's own directory. The default random name is fine for most tests, but + /// the name a project is imported under in the IDE is only trusted when it matches the directory, so + /// tests covering that have to control it. + /// + public TempJavaAppDirectory(bool withWrappers = true, string? directoryName = null) + { + _directory = directoryName is null + ? _root + : Directory.CreateDirectory(System.IO.Path.Combine(_root.FullName, directoryName)); + + if (withWrappers) + { + WriteWrapper(OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"); + WriteWrapper(OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew"); + } + } + + public string Path => _directory.FullName; + + /// + /// Writes a file into the directory, creating any intermediate directories. + /// + public string Write(string fileName, string content = "") + { + var fullPath = System.IO.Path.Combine(Path, fileName); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content); + + return fullPath; + } + + /// + /// Writes a build tool wrapper script into the directory and marks it executable on Unix, matching + /// what a real mvnw/gradlew checkout looks like. + /// + public string WriteWrapper(string fileName) + { + var fullPath = Write(fileName, "#!/bin/sh\nexit 0\n"); + + // A real wrapper is never just the script: the properties file next to it is what pins the tool + // version, and publishing requires it so the distribution can be unpacked in its own image layer. + var gradle = fileName.Contains("gradle", StringComparison.OrdinalIgnoreCase); + var supportDirectory = gradle ? "gradle" : ".mvn"; + var propertiesName = gradle ? "gradle-wrapper.properties" : "maven-wrapper.properties"; + var wrapperDirectory = System.IO.Path.GetDirectoryName(fullPath)!; + + Directory.CreateDirectory(System.IO.Path.Combine(wrapperDirectory, supportDirectory, "wrapper")); + File.WriteAllText( + System.IO.Path.Combine(wrapperDirectory, supportDirectory, "wrapper", propertiesName), + gradle + ? "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.14-bin.zip\n" + : "distributionUrl=https\\://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip\n"); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + fullPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + + return fullPath; + } + + public void Dispose() + { + try + { + _root.Delete(recursive: true); + } + catch (IOException) + { + // Best effort: a virus scanner or indexer can briefly hold a handle on Windows, and failing + // to clean up a temp directory must not fail an otherwise passing test. + } + } +} diff --git a/tests/Aspire.Hosting.Java.Tests/TestEndpointAllocator.cs b/tests/Aspire.Hosting.Java.Tests/TestEndpointAllocator.cs new file mode 100644 index 00000000000..ddc7c052103 --- /dev/null +++ b/tests/Aspire.Hosting.Java.Tests/TestEndpointAllocator.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Java.Tests; + +internal static class TestEndpointAllocator +{ + /// + /// Endpoints are allocated by the orchestrator at run time. Environment variable evaluation waits on + /// that allocation, so a test that never starts the application has to supply it or the evaluation + /// never returns. + /// + public static void AllocateEndpoints(IResource resource) + { + foreach (var endpoint in resource.Annotations.OfType()) + { + endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", 8080, targetPortExpression: "8080"); + } + } +} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs index fc3f58afd6a..d3dda526576 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs @@ -196,6 +196,49 @@ public async Task MultipleComputeEnvironmentsOnlyProcessTargetedResources() Assert.Null(projectDockerResource.GetDeploymentTargetAnnotation(kubernetes.Resource)); } + [Fact] + public async Task RunningTheDeploymentTargetHooksTwiceLeavesOneTargetPerEnvironment() + { + // The prepare-deployment-target step declares RequiredBySteps = [BeforeStart], so it runs once in + // the BeforeStart pipeline and again in the publish DAG. Without a guard the second pass adds a + // second DeploymentTargetAnnotation, and GetDeploymentTargetAnnotation then throws on the + // ambiguity, which fails every publish that goes through both paths. + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var kubernetes = builder.AddKubernetesEnvironment("kubernetes"); + var dockerCompose = builder.AddDockerComposeEnvironment("docker-compose"); + + builder.AddContainer("containerk8s", "nginx") + .WithHttpEndpoint(port: 8080, targetPort: 80, name: "http") + .WithComputeEnvironment(kubernetes); + + builder.AddContainer("containerdocker", "nginx") + .WithHttpEndpoint(port: 9090, targetPort: 80, name: "http") + .WithComputeEnvironment(dockerCompose); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var containerK8sResource = model.Resources.First(r => r.Name == "containerk8s"); + var containerDockerResource = model.Resources.First(r => r.Name == "containerdocker"); + + Assert.Single(containerK8sResource.Annotations.OfType()); + Assert.Single(containerDockerResource.Annotations.OfType()); + + Assert.Same(kubernetes.Resource, containerK8sResource.GetDeploymentTargetAnnotation()!.ComputeEnvironment); + Assert.Same(dockerCompose.Resource, containerDockerResource.GetDeploymentTargetAnnotation()!.ComputeEnvironment); + + // The second pass must not have produced a fresh, unconfigured target either: the Kubernetes + // service still has to carry the endpoint that was mapped on the first pass. + var service = Assert.IsType(containerK8sResource.GetDeploymentTargetAnnotation()!.DeploymentTarget); + var mapping = Assert.Contains("http", service.EndpointMappings); + Assert.Equal("http", mapping.Scheme); + } + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs index ff0121b418c..dbbd3f97898 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs @@ -447,4 +447,42 @@ public async Task AddGateway_WithRoute_ExternalEndpoint_Succeeds() var content = await File.ReadAllTextAsync(gatewayFile); Assert.Contains("Gateway", content); } + + /// + /// The deployment-target step is reachable from two pipeline executions: it is RequiredBy + /// "before-start" and it is also part of the publish DAG. The step guards against adding a second + /// DeploymentTargetAnnotation, but gateway route generation runs downstream of that guard, so a + /// second pass appended every route again. The rendered chart hid it, because duplicate routes + /// share a name and overwrite each other's file — the list itself grew on every pass. + /// + [Fact] + public async Task AddGateway_WhenDeploymentTargetsArePreparedTwice_DoesNotDuplicateHttpRoutes() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path); + + var k8s = builder.AddKubernetesEnvironment("env"); + var gateway = k8s.AddGateway("public").WithGatewayClass("nginx"); + + var api = builder.AddContainer("myapi", "nginx") + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); + + gateway.WithRoute("/api", api.GetEndpoint("http")); + + var app = builder.Build(); + + var steps = await CreateStepsAsync(app.Services, k8s.Resource); + var prepareStep = Assert.Single(steps, step => step.Name == "prepare-deployment-targets-env"); + + await RunStepAsync(app.Services, prepareStep); + await RunStepAsync(app.Services, prepareStep); + + var gatewayResource = Assert.IsType(gateway.Resource); + var route = Assert.Single(gatewayResource.GeneratedHttpRoutes); + var rule = Assert.Single(route.Spec.Rules); + var match = Assert.Single(rule.Matches); + + Assert.Equal("/api", match.Path?.Value); + } } \ No newline at end of file diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/PipelineStepTestHelpers.cs b/tests/Aspire.Hosting.Kubernetes.Tests/PipelineStepTestHelpers.cs index d7f87bcd68e..14b5561b0fa 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/PipelineStepTestHelpers.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/PipelineStepTestHelpers.cs @@ -22,12 +22,7 @@ internal static class PipelineStepTestHelpers /// public static async Task> CreateStepsAsync(IServiceProvider services, IResource resource) { - var pipelineContext = new PipelineContext( - services.GetRequiredService(), - new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish), - services, - NullLogger.Instance, - CancellationToken.None); + var pipelineContext = CreateContext(services); var results = new List(); foreach (var annotation in resource.Annotations.OfType()) @@ -42,6 +37,29 @@ public static async Task> CreateStepsAsync(IServiceProvider s return results; } + /// + /// Runs a step's action directly. Several steps are reachable from more than one pipeline execution, + /// so tests use this to run a step twice and assert it stayed idempotent. + /// + public static async Task RunStepAsync(IServiceProvider services, PipelineStep step) + { + await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync(step.Name); + + await step.Action!(new PipelineStepContext + { + PipelineContext = CreateContext(services), + ReportingStep = reportingStep + }); + } + + private static PipelineContext CreateContext(IServiceProvider services) + => new( + services.GetRequiredService(), + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish), + services, + NullLogger.Instance, + CancellationToken.None); + /// /// Returns the sorted names of gateway- and TLS-related steps. Tests assert on this whole set /// rather than probing individual step names, so a newly added gateway/TLS step that skips the diff --git a/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs b/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs index c5de2fa5365..f05fcc7b376 100644 --- a/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs +++ b/tests/Aspire.Hosting.Qdrant.Tests/QdrantFunctionalTests.cs @@ -59,7 +59,7 @@ await pipeline.ExecuteAsync(async token => await CreateTestDataAsync(qdrantClient, token); - var results = await qdrantClient.SearchAsync(CollectionName, s_testVector, limit: 1, cancellationToken: token); + var results = await qdrantClient.QueryAsync(CollectionName, s_testVector, limit: 1, cancellationToken: token); Assert.Collection(results, r => Assert.Equal("Test", r.Payload["title"].StringValue)); }, cts.Token); @@ -189,7 +189,7 @@ await pipeline.ExecuteAsync(async token => { var qdrantClient = host.Services.GetRequiredService(); - var results = await qdrantClient.SearchAsync(CollectionName, s_testVector, limit: 1, cancellationToken: token); + var results = await qdrantClient.QueryAsync(CollectionName, s_testVector, limit: 1, cancellationToken: token); Assert.Collection(results, r => Assert.Equal("Test", r.Payload["title"].StringValue)); }, cts.Token); diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs index 0e7630ef106..b14a5966e91 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/CodeGenerationDiagnosticBuilderTests.cs @@ -128,4 +128,40 @@ public void BuildDiagnostic_RuntimeAspireHostingVersion_DoesNotFallBackToRemoteH // Aspire.Hosting.RemoteHost - a sibling, not the runtime that backed the failing codegen. Assert.Equal(aspireHostingVersion, diagnostic.RuntimeAspireHostingVersion); } + + [Fact] + public void TryDescribeAssemblySkew_ExplainsReflectionLoadFailures() + { + var description = CodeGenerationDiagnosticBuilder.TryDescribeAssemblySkew(new TypeLoadException("Could not load type 'X'.")); + + Assert.NotNull(description); + Assert.Contains("aspire update --self", description); + Assert.Contains("sdkVersion", description); + } + + [Fact] + public void TryDescribeAssemblySkew_FindsLoadFailuresNestedInTheExceptionChain() + { + var exception = new InvalidOperationException("outer", new MissingMethodException("inner")); + + Assert.NotNull(CodeGenerationDiagnosticBuilder.TryDescribeAssemblySkew(exception)); + } + + [Fact] + public void TryDescribeAssemblySkew_FindsLoadFailuresInLaterAggregateChildren() + { + // AggregateException.InnerException only exposes the first child, so a load failure raised + // by any later parallel operation is only reachable by fanning out InnerExceptions. + var exception = new AggregateException( + new InvalidOperationException("unrelated"), + new TypeLoadException("Could not load type 'X'.")); + + Assert.NotNull(CodeGenerationDiagnosticBuilder.TryDescribeAssemblySkew(exception)); + } + + [Fact] + public void TryDescribeAssemblySkew_IsNullForUnrelatedFailures() + { + Assert.Null(CodeGenerationDiagnosticBuilder.TryDescribeAssemblySkew(new InvalidOperationException("boom"))); + } } diff --git a/tests/Aspire.Hosting.Sdk.Tests.FakeCommand/Program.cs b/tests/Aspire.Hosting.Sdk.Tests.FakeCommand/Program.cs index 525747e083b..b58631c3e68 100644 --- a/tests/Aspire.Hosting.Sdk.Tests.FakeCommand/Program.cs +++ b/tests/Aspire.Hosting.Sdk.Tests.FakeCommand/Program.cs @@ -38,8 +38,10 @@ { var dcpDirectory = Directory.CreateDirectory(Path.Combine(setupInstallPath, "bundle", "dcp")); var managedDirectory = Directory.CreateDirectory(Path.Combine(setupInstallPath, "bundle", "managed")); + var dashboardDirectory = Directory.CreateDirectory(Path.Combine(setupInstallPath, "bundle", "dashboard")); File.WriteAllText(Path.Combine(dcpDirectory.FullName, OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"), ""); File.WriteAllText(Path.Combine(managedDirectory.FullName, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"), ""); + File.WriteAllText(Path.Combine(dashboardDirectory.FullName, OperatingSystem.IsWindows() ? "Aspire.Dashboard.exe" : "Aspire.Dashboard"), ""); return; } diff --git a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs index 9fa9c90cfde..a4100c2bd59 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs +++ b/tests/Aspire.Hosting.Sdk.Tests/AppHostSdkTargetsTests.cs @@ -7,6 +7,7 @@ using System.Security; using System.Text.Json; using System.Text.RegularExpressions; +using Aspire.Hosting.Tasks; using Xunit; namespace Aspire.Hosting.Sdk.Tests; @@ -660,8 +661,8 @@ public async Task ResolveAspireCliBundlePathsUsesDnxBundleWhenDnxModeIsSelected( Assert.Equal("Dnx", properties["_AspireResolvedCliInvocationMode"]); Assert.Equal(dnxBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); - Assert.Equal(dnxBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); - Assert.Equal(dnxBundle.ManagedPath, properties["AspireDashboardPath"]); + Assert.Equal(dnxBundle.DashboardDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); + Assert.Equal(dnxBundle.DashboardPath, properties["AspireDashboardPath"]); Assert.NotEqual(staleBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); AssertCliBundleExists(aspireHome, JsonSerializer.Serialize(properties)); } @@ -688,8 +689,8 @@ public async Task ResolveAspireCliBundlePathsPreservesUnixShellMetacharactersInA Assert.Equal(aspireCliPath, properties["_AspireResolvedCliPath"]); Assert.Equal(selectedBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); - Assert.Equal(selectedBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); - Assert.Equal(selectedBundle.ManagedPath, properties["AspireDashboardPath"]); + Assert.Equal(selectedBundle.DashboardDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); + Assert.Equal(selectedBundle.DashboardPath, properties["AspireDashboardPath"]); AssertCliBundleExists(fakeCliDirectory.FullName, JsonSerializer.Serialize(properties)); } @@ -716,8 +717,8 @@ public async Task ResolveAspireCliBundlePathsUsesSelectedAspireCliWhenLaterPathC Assert.Equal("Aspire", properties["_AspireResolvedCliInvocationMode"]); Assert.Equal(selectedCliPath, properties["_AspireResolvedCliPath"]); Assert.Equal(selectedBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); - Assert.Equal(selectedBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); - Assert.Equal(selectedBundle.ManagedPath, properties["AspireDashboardPath"]); + Assert.Equal(selectedBundle.DashboardDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); + Assert.Equal(selectedBundle.DashboardPath, properties["AspireDashboardPath"]); Assert.NotEqual(staleBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); AssertCliBundleExists(selectedCliDirectory.FullName, JsonSerializer.Serialize(properties)); } @@ -743,8 +744,8 @@ public async Task ResolveAspireCliBundlePathsEscapesPercentSignsInNativeAspireCl var selectedBundle = GetFakeCliBundlePaths(fakeCliDirectory.FullName); Assert.Equal(selectedBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"])); - Assert.Equal(selectedBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); - Assert.Equal(selectedBundle.ManagedPath, properties["AspireDashboardPath"]); + Assert.Equal(selectedBundle.DashboardDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"])); + Assert.Equal(selectedBundle.DashboardPath, properties["AspireDashboardPath"]); AssertCliBundleExists(fakeCliDirectory.FullName, JsonSerializer.Serialize(properties)); Assert.False(Directory.Exists(Path.Combine(workspace.Path, "nativeexpanded"))); } @@ -766,7 +767,7 @@ public async Task BuildFallsBackToDnxWhenPathAspireCliSetupTimesOut() var buildResult = await RunDotNetWithArgumentsAsync( project.ProjectDirectory, - ["build", "-nologo", project.ProjectFile, "-p:_AspireCliBundleSetupTimeout=100", "-warnaserror"], + ["build", "-nologo", project.ProjectFile, "-p:_AspireCliBundleSetupTimeout=1000", "-warnaserror"], environment); Assert.True(buildResult.ExitCode == 0, buildResult.Output); @@ -805,6 +806,7 @@ await File.WriteAllTextAsync(projectPath, $$""" + """); @@ -824,6 +826,7 @@ await File.WriteAllTextAsync(projectPath, $$""" Assert.True(result.ExitCode == 0, result.Output); Assert.Contains("CommandTimedOut=true", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("CommandFailureMessage=The command timed out", result.Output, StringComparison.OrdinalIgnoreCase); Assert.True(File.Exists(childPidPath), $"The child process did not record its PID. MSBuild output:{Environment.NewLine}{result.Output}"); childProcessId = int.Parse(await File.ReadAllTextAsync(childPidPath), CultureInfo.InvariantCulture); @@ -844,6 +847,74 @@ await File.WriteAllTextAsync(projectPath, $$""" } } + [Fact] + public async Task RunAspireCliCommandReportsWhenProcessDoesNotExitAfterTermination() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var commandPath = Path.Combine(workspace.Path, OperatingSystem.IsWindows() ? "hang.cmd" : "hang"); + await File.WriteAllTextAsync( + commandPath, + OperatingSystem.IsWindows() + ? "@echo off\r\nping -n 3601 127.0.0.1 > nul\r\n" + : "#!/bin/sh\nsleep 3600\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(commandPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var terminationRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var task = new RunAspireCliCommand + { + FileName = commandPath, + TimeoutMilliseconds = 1, + TestTerminateProcess = process => + { + terminationRequested.TrySetResult(process.Id); + return true; + } + }; + + int? processId = null; + Task? executionTask = null; + try + { + executionTask = Task.Run(task.Execute); + processId = await terminationRequested.Task.WaitAsync( + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); + var result = await executionTask.WaitAsync( + TimeSpan.FromSeconds(10), + TestContext.Current.CancellationToken); + + using var survivingProcess = TryGetProcessById(processId.Value); + Assert.NotNull(survivingProcess); + Assert.False(survivingProcess.HasExited); + Assert.True(result); + Assert.True(task.TimedOut); + Assert.NotNull(task.FailureMessage); + Assert.Contains($"The command '{commandPath}' timed out", task.FailureMessage, StringComparison.Ordinal); + Assert.Contains("did not exit within", task.FailureMessage, StringComparison.Ordinal); + Assert.Contains("after termination was requested", task.FailureMessage, StringComparison.Ordinal); + } + finally + { + if (processId is { } startedProcessId) + { + using var process = TryGetProcessById(startedProcessId); + if (process is not null && !process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } + } + + if (executionTask is not null) + { + _ = await executionTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + } + } + [Fact] public async Task BuildUsesEnvironmentAspireHomeWhenMsBuildPropertyDiffers() { @@ -1173,8 +1244,10 @@ @echo off if not "%~1"=="setup" exit /b 2 mkdir "%~dp0bundle\dcp" mkdir "%~dp0bundle\managed" + mkdir "%~dp0bundle\dashboard" type nul > "%~dp0bundle\dcp\dcp.exe" type nul > "%~dp0bundle\managed\aspire-managed.exe" + type nul > "%~dp0bundle\dashboard\Aspire.Dashboard.exe" """ : """ #!/bin/sh @@ -1182,9 +1255,10 @@ type nul > "%~dp0bundle\managed\aspire-managed.exe" exit 2 fi install_path="$(dirname "$0")" - mkdir -p "$install_path/bundle/dcp" "$install_path/bundle/managed" + mkdir -p "$install_path/bundle/dcp" "$install_path/bundle/managed" "$install_path/bundle/dashboard" : > "$install_path/bundle/dcp/dcp" : > "$install_path/bundle/managed/aspire-managed" + : > "$install_path/bundle/dashboard/Aspire.Dashboard" """; await File.WriteAllTextAsync(aspirePath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n")); @@ -1196,23 +1270,27 @@ exit 2 return aspirePath; } - private static (string DcpDirectory, string ManagedDirectory, string ManagedPath) CreateFakeCliBundle(string layoutRoot) + private static (string DcpDirectory, string DashboardDirectory, string DashboardPath) CreateFakeCliBundle(string layoutRoot) { var bundle = GetFakeCliBundlePaths(layoutRoot); Directory.CreateDirectory(bundle.DcpDirectory); - Directory.CreateDirectory(bundle.ManagedDirectory); + Directory.CreateDirectory(Path.Combine(layoutRoot, "bundle", "managed")); + Directory.CreateDirectory(bundle.DashboardDirectory); File.WriteAllText(Path.Combine(bundle.DcpDirectory, OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"), ""); - File.WriteAllText(bundle.ManagedPath, ""); + File.WriteAllText( + Path.Combine(layoutRoot, "bundle", "managed", OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"), + ""); + File.WriteAllText(bundle.DashboardPath, ""); return bundle; } - private static (string DcpDirectory, string ManagedDirectory, string ManagedPath) GetFakeCliBundlePaths(string layoutRoot) + private static (string DcpDirectory, string DashboardDirectory, string DashboardPath) GetFakeCliBundlePaths(string layoutRoot) { var bundleRoot = Path.Combine(layoutRoot, "bundle"); var dcpDirectory = Path.Combine(bundleRoot, "dcp"); - var managedDirectory = Path.Combine(bundleRoot, "managed"); - var managedPath = Path.Combine(managedDirectory, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"); - return (dcpDirectory, managedDirectory, managedPath); + var dashboardDirectory = Path.Combine(bundleRoot, "dashboard"); + var dashboardPath = Path.Combine(dashboardDirectory, OperatingSystem.IsWindows() ? "Aspire.Dashboard.exe" : "Aspire.Dashboard"); + return (dcpDirectory, dashboardDirectory, dashboardPath); } private static async Task CreateFakeAspireCommandShimAsync(string fakeCliDirectory, string extension = ".cmd") @@ -1286,9 +1364,10 @@ goto loop exit 0 fi if [ "$1" = "--yes" ] && { [ "$2" = "aspire.cli@13.5.0" ] || [ "$2" = "aspire.cli" ]; } && [ "$3" = "--" ] && [ "$4" = "setup" ] && [ "$5" = "--install-path" ]; then - mkdir -p "$6/bundle/dcp" "$6/bundle/managed" + mkdir -p "$6/bundle/dcp" "$6/bundle/managed" "$6/bundle/dashboard" : > "$6/bundle/dcp/dcp" : > "$6/bundle/managed/aspire-managed" + : > "$6/bundle/dashboard/Aspire.Dashboard" exit 0 fi printf '%s\n' "$@" > "$ASPIRE_TEST_CAPTURE_PATH" diff --git a/tests/Aspire.Hosting.Sdk.Tests/Aspire.Hosting.Sdk.Tests.csproj b/tests/Aspire.Hosting.Sdk.Tests/Aspire.Hosting.Sdk.Tests.csproj index 67525bfb3d3..f662b0deaf2 100644 --- a/tests/Aspire.Hosting.Sdk.Tests/Aspire.Hosting.Sdk.Tests.csproj +++ b/tests/Aspire.Hosting.Sdk.Tests/Aspire.Hosting.Sdk.Tests.csproj @@ -20,11 +20,13 @@ SkipGetTargetFrameworkProperties="true" Private="false" ExcludeAssets="all" /> + + + + diff --git a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj index 3a71b518c12..9c974ebff16 100644 --- a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj +++ b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj @@ -6,6 +6,10 @@ enable false + true + true + true + false false @@ -36,10 +40,22 @@ + + + + + diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs new file mode 100644 index 00000000000..a1a4a8284df --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -0,0 +1,316 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREINTERACTION001 + +using Aspire.Hosting.Dashboard; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; +using TestingResources = Aspire.Hosting.Testing.Properties.Resources; + +namespace Aspire.Hosting.Testing.Tests; + +public class DashboardTestingBuilderTests +{ + private const string AspNetCoreUrls = "ASPNETCORE_URLS"; + private const string DashboardOtlpGrpcEndpointUrl = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"; + private const string DashboardOtlpHttpEndpointUrl = "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"; + private const string DashboardUnsecuredAllowAnonymous = "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"; + private const string InteractivityEnabled = "ASPIRE_INTERACTIVITY_ENABLED"; + private const string ResourceServiceEndpointUrl = "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"; + private const string DashboardFrontendBrowserToken = "ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN"; + private const string AppHostBrowserToken = "AppHost:BrowserToken"; + private const string DashboardResourceServiceApiKey = "ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY"; + private const string AppHostResourceServiceAuthMode = "AppHost:ResourceService:AuthMode"; + private const string AppHostResourceServiceApiKey = "AppHost:ResourceService:ApiKey"; + + [Fact] + public void DashboardIsDisabledByDefault() + { + var options = new DistributedApplicationTestingBuilderOptions(); + + Assert.False(options.EnableDashboard); + + using var builder = DistributedApplicationTestingBuilder.Create(); + Assert.Null(builder.Services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DashboardServiceHost))); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardCanBeEnabledAtBuilderCreation(CreationSurface creationSurface) + { + await using var builder = await CreateDashboardBuilderAsync(creationSurface, []); + + Assert.Single(builder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); + AssertDashboardTestingDefaults(builder); + + await using var app = await builder.BuildAsync(); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardTestingDefaultsOverrideCreationConfiguration(CreationSurface creationSurface) + { + string[] args = + [ + "--DcpPublisher:RandomizePorts=false", + $"--{AspNetCoreUrls}=http://127.0.0.1:12345", + $"--{DashboardOtlpGrpcEndpointUrl}=http://127.0.0.1:12346", + $"--{DashboardOtlpHttpEndpointUrl}=http://127.0.0.1:12347", + $"--{ResourceServiceEndpointUrl}=http://127.0.0.1:12348", + $"--{DashboardUnsecuredAllowAnonymous}=true", + $"--{InteractivityEnabled}=true" + ]; + + await using var builder = await CreateDashboardBuilderAsync(creationSurface, args); + + AssertDashboardTestingDefaults(builder); + Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration[AppHostResourceServiceAuthMode]); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardTestingGeneratesAFreshBrowserTokenPerApplication(CreationSurface creationSurface) + { + const string SharedToken = "shared-browser-token"; + string[] args = [$"--{DashboardFrontendBrowserToken}={SharedToken}"]; + + await using var first = await CreateDashboardBuilderAsync(creationSurface, args); + await using var second = await CreateDashboardBuilderAsync(creationSurface, args); + + var firstToken = first.Configuration[AppHostBrowserToken]; + var secondToken = second.Configuration[AppHostBrowserToken]; + + Assert.NotEmpty(firstToken!); + Assert.NotEmpty(secondToken!); + Assert.NotEqual(SharedToken, firstToken); + Assert.NotEqual(SharedToken, secondToken); + Assert.NotEqual(firstToken, secondToken); + } + + [Fact] + public async Task DashboardTestingDefaultsAreNonInteractiveAndFailFast() + { + var builder = DistributedApplicationTestingBuilder.Create(CreateDashboardOptions(), []); + await using var app = await builder.BuildAsync(); + + Assert.False(app.Services.GetRequiredService().IsAvailable); + Assert.Equal( + WaitBehavior.StopOnResourceUnavailable, + app.Services.GetRequiredService>().Value.DefaultWaitBehavior); + } + + [Fact] + public async Task DashboardTestingOptionsCannotBeNull() + { + await Assert.ThrowsAsync( + () => DistributedApplicationTestingBuilder.CreateAsync(null!, [])); + await Assert.ThrowsAsync( + () => DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), null!, [])); + Assert.Throws(() => DistributedApplicationTestingBuilder.Create(null!, [])); + } + + [Fact] + public async Task ExistingDefaultCallsRemainUnambiguous() + { + Assert.Throws(() => DistributedApplicationTestingBuilder.Create(default!)); + + await using var genericBuilder = + await DistributedApplicationTestingBuilder.CreateAsync(default); + await using var typeBuilder = + await DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), default); + + Assert.Null(genericBuilder.Services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DashboardServiceHost))); + Assert.Null(typeBuilder.Services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DashboardServiceHost))); + + await using var genericOptionsBuilder = + await DistributedApplicationTestingBuilder.CreateAsync( + CreateDashboardOptions(), + [], + default); + await using var typeOptionsBuilder = + await DistributedApplicationTestingBuilder.CreateAsync( + typeof(Projects.TestingAppHost1_AppHost), + CreateDashboardOptions(), + [], + default); + + Assert.Single(genericOptionsBuilder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); + Assert.Single(typeOptionsBuilder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardTestingIsRejectedInPublishMode(CreationSurface creationSurface) + { + var exception = await Assert.ThrowsAsync( + () => CreatePublishBuilderAsync(creationSurface)); + + Assert.Equal(TestingResources.DashboardTestingPublishModeExceptionMessage, exception.Message); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + public async Task DashboardTestingPublishModeFailureStopsAppHostEntryPoint(CreationSurface creationSurface) + { + using var probe = TestingAppHostEntryPointProbe.Create(); + + var exception = await Assert.ThrowsAsync( + () => CreatePublishBuilderAsync(creationSurface, $"--entry-point-exit-probe={probe.Id}")); + + Assert.Equal(TestingResources.DashboardTestingPublishModeExceptionMessage, exception.Message); + await probe.Exited.WaitAsync(TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task DashboardEnabledThroughConfigureBuilderKeepsCallerConfiguration() + { + await using var builder = await DistributedApplicationTestingBuilder.CreateAsync( + [$"--{DashboardUnsecuredAllowAnonymous}=true", $"--{AspNetCoreUrls}=http://127.0.0.1:12345"], + (options, _) => options.DisableDashboard = false); + + Assert.Equal("true", builder.Configuration[DashboardUnsecuredAllowAnonymous]); + Assert.Equal("http://127.0.0.1:12345", builder.Configuration[AspNetCoreUrls]); + } + + [Theory] + [InlineData(CreationSurface.Generic, "--clear-apphost-browser-token")] + [InlineData(CreationSurface.Generic, "--null-apphost-browser-token")] + [InlineData(CreationSurface.Type, "--clear-apphost-browser-token")] + [InlineData(CreationSurface.Type, "--null-apphost-browser-token")] + public async Task DashboardTestingRestoresTheBrowserTokenAnAppHostCleared( + CreationSurface creationSurface, + string appHostFlag) + { + await using var builder = await CreateDashboardBuilderAsync(creationSurface, [appHostFlag]); + await using var app = await builder.BuildAsync(); + + var dashboardOptions = app.Services.GetRequiredService>().Value; + Assert.False(string.IsNullOrEmpty(dashboardOptions.DashboardToken)); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + public async Task DashboardTestingLeavesTheBrowserTokenToTheCallersBuilder(CreationSurface creationSurface) + { + await using var builder = await CreateDashboardBuilderAsync(creationSurface, []); + builder.Configuration[AppHostBrowserToken] = ""; + await using var app = await builder.BuildAsync(); + + var dashboardOptions = app.Services.GetRequiredService>().Value; + Assert.True(string.IsNullOrEmpty(dashboardOptions.DashboardToken)); + } + + [Theory] + [InlineData(CreationSurface.Generic, "--unsecure-apphost-resource-service")] + [InlineData(CreationSurface.Generic, "--clear-apphost-resource-service-key")] + [InlineData(CreationSurface.Type, "--unsecure-apphost-resource-service")] + [InlineData(CreationSurface.Type, "--clear-apphost-resource-service-key")] + public async Task DashboardTestingRestoresResourceServiceAuthenticationAnAppHostDowngraded( + CreationSurface creationSurface, + string appHostFlag) + { + await using var builder = await CreateDashboardBuilderAsync(creationSurface, [appHostFlag]); + + Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration[AppHostResourceServiceAuthMode]); + Assert.False(string.IsNullOrEmpty(builder.Configuration[AppHostResourceServiceApiKey])); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + public async Task DashboardTestingGeneratesAFreshResourceServiceApiKeyPerApplication(CreationSurface creationSurface) + { + const string SharedKey = "shared-resource-service-key"; + string[] args = [$"--{DashboardResourceServiceApiKey}={SharedKey}"]; + + await using var first = await CreateDashboardBuilderAsync(creationSurface, args); + await using var second = await CreateDashboardBuilderAsync(creationSurface, args); + + var firstKey = first.Configuration[AppHostResourceServiceApiKey]; + var secondKey = second.Configuration[AppHostResourceServiceApiKey]; + + Assert.False(string.IsNullOrEmpty(firstKey)); + Assert.False(string.IsNullOrEmpty(secondKey)); + Assert.NotEqual(SharedKey, firstKey); + Assert.NotEqual(SharedKey, secondKey); + Assert.NotEqual(firstKey, secondKey); + } + + private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() + { + return new() + { + EnableDashboard = true + }; + } + + private static void AssertDashboardTestingDefaults(IDistributedApplicationTestingBuilder builder) + { + Assert.Equal("true", builder.Configuration["DcpPublisher:RandomizePorts"]); + Assert.Equal(string.Empty, builder.Configuration[AspNetCoreUrls]); + Assert.Equal(string.Empty, builder.Configuration[DashboardOtlpGrpcEndpointUrl]); + Assert.Equal(string.Empty, builder.Configuration[DashboardOtlpHttpEndpointUrl]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[ResourceServiceEndpointUrl]); + Assert.Equal("false", builder.Configuration[DashboardUnsecuredAllowAnonymous]); + Assert.Equal("false", builder.Configuration[InteractivityEnabled]); + + var browserToken = builder.Configuration[DashboardFrontendBrowserToken]; + Assert.NotEmpty(browserToken!); + Assert.Equal(browserToken, builder.Configuration[AppHostBrowserToken]); + } + + private static async Task CreateDashboardBuilderAsync( + CreationSurface creationSurface, + string[] args) + { + var options = CreateDashboardOptions(); + + return creationSurface switch + { + CreationSurface.Generic => await DistributedApplicationTestingBuilder.CreateAsync(options, args), + CreationSurface.Type => await DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), options, args), + CreationSurface.AdHoc => DistributedApplicationTestingBuilder.Create(options, args), + _ => throw new ArgumentOutOfRangeException(nameof(creationSurface)) + }; + } + + private static async Task CreatePublishBuilderAsync(CreationSurface creationSurface, params string[] additionalArgs) + { + var options = CreateDashboardOptions(); + string[] args = [.. additionalArgs, "--publisher", "manifest"]; + + var builder = creationSurface switch + { + CreationSurface.Generic => await DistributedApplicationTestingBuilder.CreateAsync( + options, + args), + CreationSurface.Type => await DistributedApplicationTestingBuilder.CreateAsync( + typeof(Projects.TestingAppHost1_AppHost), + options, + args), + CreationSurface.AdHoc => DistributedApplicationTestingBuilder.Create(options, args), + _ => throw new ArgumentOutOfRangeException(nameof(creationSurface)) + }; + + await builder.DisposeAsync(); + } + + public enum CreationSurface + { + Generic, + Type, + AdHoc + } +} diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs new file mode 100644 index 00000000000..4793a87831b --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs @@ -0,0 +1,375 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Aspire.TestUtilities; +using Aspire.Templates.Tests; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Playwright; +using Xunit; +using TestingResources = Aspire.Hosting.Testing.Properties.Resources; + +namespace Aspire.Hosting.Testing.Tests; + +public class DashboardUrlTests +{ + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncAuthenticatesDashboardBrowser() + { + await using var builder = await CreateDashboardBuilderAsync(); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.True(dashboardUri.IsAbsoluteUri); + Assert.Equal(Uri.UriSchemeHttp, dashboardUri.Scheme); + Assert.True(dashboardUri.IsLoopback); + Assert.InRange(dashboardUri.Port, 1, 65535); + Assert.Equal("/login", dashboardUri.AbsolutePath); + Assert.StartsWith("?t=", dashboardUri.Query, StringComparison.Ordinal); + Assert.True(dashboardUri.Query.Length > 3); + + using var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = true + }; + using var httpClient = new HttpClient(handler) + { + Timeout = TestConstants.LongTimeoutTimeSpan + }; + using var loginResponse = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); + + Assert.Equal(HttpStatusCode.Redirect, loginResponse.StatusCode); + Assert.Equal("/", loginResponse.Headers.Location?.OriginalString); + Assert.Single( + loginResponse.Headers.GetValues("Set-Cookie"), + cookie => cookie.StartsWith(".Aspire.Dashboard.Auth", StringComparison.Ordinal)); + + using var protectedResponse = await httpClient.GetAsync( + new Uri(dashboardUri.GetLeftPart(UriPartial.Authority) + "/structuredlogs"), + cancellationTokenSource.Token); + Assert.Equal(HttpStatusCode.OK, protectedResponse.StatusCode); + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncUsesConfiguredLocalhostTld() + { + const string DashboardHost = "dashboard.testing.localhost"; + + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["ASPNETCORE_URLS"] = $"http://{DashboardHost}:0"; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.Equal(DashboardHost, dashboardUri.Host); + Assert.Equal("/login", dashboardUri.AbsolutePath); + Assert.StartsWith("?t=", dashboardUri.Query, StringComparison.Ordinal); + } + + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + [RequiresFeature(TestFeature.ContainerRuntime | TestFeature.Playwright)] + public async Task DashboardDisplaysResourceUpdatesWhileAppHostIsPausedAtBreakpoint() + { + const string resourceName = "dashboard-test-resource"; + const string initialState = "Dashboard test initial"; + const string updatedState = "Dashboard test updated"; + + using var breakpoint = TestingAppHostEntryPointProbe.CreateBreakpoint(); + await using var builder = await CreateDashboardBuilderAsync($"--entry-point-breakpoint-probe={breakpoint.Id}"); + builder.AddExternalService(resourceName, "https://example.com/"); + await using var app = await builder.BuildAsync(); + + try + { + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var cancellationToken = cancellationTokenSource.Token; + + // The AppHost signals this immediately after app.StartAsync() and then waits for Continue(). This models a + // debugger stopped on the next AppHost statement while hosted services, child resources, and the dashboard run. + await breakpoint.Reached.WaitAsync(cancellationToken); + + var runningResourceEvent = await app.ResourceNotifications.WaitForResourceAsync( + resourceName, + resourceEvent => resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running, + cancellationToken); + + await app.ResourceNotifications.PublishUpdateAsync( + runningResourceEvent.Resource, + snapshot => snapshot with { State = initialState }); + + // GetDashboardUrlAsync is a dashboard-health barrier, not a resource-propagation barrier. The resource service + // caches notifications for dashboard clients, so a browser that connects now receives this state as initial data. + var dashboardUri = await app.GetDashboardUrlAsync(cancellationToken); + + PlaywrightProvider.DetectAndSetInstalledPlaywrightDependenciesPath(); + Assertions.SetDefaultExpectTimeout(TestConstants.LongTimeoutDuration); + var browser = await PlaywrightProvider.CreateBrowserAsync(); + try + { + await using var context = await browser.NewContextAsync(); + var page = await context.NewPageAsync(); + await page.GotoAsync(dashboardUri.AbsoluteUri); + + var resourceRow = page.Locator("tr.resource-row").Filter(new() { HasText = resourceName }); + var stateCell = resourceRow.Locator(".state-column-cell"); + + await Assertions.Expect(resourceRow).ToBeVisibleAsync(); + await Assertions.Expect(stateCell).ToContainTextAsync(initialState); + + var updatedResourceTask = app.ResourceNotifications.WaitForResourceAsync( + resourceName, + resourceEvent => resourceEvent.Snapshot.State?.Text == updatedState, + cancellationToken); + + await app.ResourceNotifications.PublishUpdateAsync( + runningResourceEvent.Resource, + snapshot => snapshot with { State = updatedState }); + await updatedResourceTask; + + // PublishUpdateAsync updates the application notification stream while the AppHost breakpoint remains held. + // Dashboard consumption and browser rendering are asynchronous, so wait separately for the rendered change. + await Assertions.Expect(stateCell).ToContainTextAsync(updatedState); + } + finally + { + await browser.CloseAsync(); + } + } + finally + { + // Release AppHost execution before the application is disposed; otherwise its entry point cannot finish cleanup. + breakpoint.Continue(); + } + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncEncodesCustomBrowserToken() + { + const string BrowserToken = "browser&token#with+reserved"; + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["AppHost:BrowserToken"] = BrowserToken; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.Equal($"?t={Uri.EscapeDataString(BrowserToken)}", dashboardUri.Query); + + using var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = true + }; + using var httpClient = new HttpClient(handler) + { + Timeout = TestConstants.LongTimeoutTimeSpan + }; + using var loginResponse = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); + + Assert.Equal(HttpStatusCode.Redirect, loginResponse.StatusCode); + Assert.Equal("/", loginResponse.Headers.Location?.OriginalString); + Assert.Single( + loginResponse.Headers.GetValues("Set-Cookie"), + cookie => cookie.StartsWith(".Aspire.Dashboard.Auth", StringComparison.Ordinal)); + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task DashboardRejectsWrongCrossApplicationAndBogusLoginTokens() + { + await using var firstBuilder = await CreateDashboardBuilderAsync(); + await using var secondBuilder = await CreateDashboardBuilderAsync(); + await using var firstApp = await firstBuilder.BuildAsync(); + await using var secondApp = await secondBuilder.BuildAsync(); + + await Task.WhenAll(firstApp.StartAsync(), secondApp.StartAsync()).WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var firstUrl = await firstApp.GetDashboardUrlAsync(cancellationTokenSource.Token); + var secondUrl = await secondApp.GetDashboardUrlAsync(cancellationTokenSource.Token); + var firstBaseUrl = firstUrl.GetLeftPart(UriPartial.Authority); + Uri[] invalidLoginUrls = + [ + new($"{firstBaseUrl}/login?t=wrong-token"), + new(firstBaseUrl + secondUrl.PathAndQuery), + new($"{firstBaseUrl}/login?t=%25") + ]; + + Assert.NotEqual(firstUrl.Port, secondUrl.Port); + Assert.NotEqual(firstUrl.Query, secondUrl.Query); + + foreach (var invalidLoginUrl in invalidLoginUrls) + { + using var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = true + }; + using var httpClient = new HttpClient(handler) + { + Timeout = TestConstants.LongTimeoutTimeSpan + }; + using var response = await httpClient.GetAsync(invalidLoginUrl, cancellationTokenSource.Token); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/login", response.Headers.Location?.OriginalString); + Assert.False(response.Headers.TryGetValues("Set-Cookie", out _)); + } + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncThrowsWhenDashboardIsDisabled() + { + var builder = DistributedApplicationTestingBuilder.Create(); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(default)); + + Assert.Equal(TestingResources.DashboardDisabledExceptionMessage, exception.Message); + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncReturnsBaseUrlWhenDashboardAllowsAnonymousAccess() + { + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["AppHost:BrowserToken"] = string.Empty; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.True(dashboardUri.IsAbsoluteUri); + Assert.Equal(Uri.UriSchemeHttp, dashboardUri.Scheme); + Assert.True(dashboardUri.IsLoopback); + Assert.InRange(dashboardUri.Port, 1, 65535); + Assert.Equal("/", dashboardUri.AbsolutePath); + Assert.Equal(string.Empty, dashboardUri.Query); + + using var httpClient = new HttpClient { Timeout = TestConstants.LongTimeoutTimeSpan }; + using var response = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task GetDashboardUrlAsyncThrowsBeforeApplicationStarts() + { + await using var builder = await CreateDashboardBuilderAsync(); + await using var app = await builder.BuildAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(default)); + + Assert.Equal(TestingResources.DashboardUrlApplicationNotStartedExceptionMessage, exception.Message); + } + + [Fact] + public async Task GetDashboardUrlAsyncThrowsInPublishMode() + { + var builder = DistributedApplicationTestingBuilder.Create(["--publisher", "manifest"]); + await using var app = await builder.BuildAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(default)); + + Assert.Equal(TestingResources.DashboardUrlPublishModeExceptionMessage, exception.Message); + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncPreservesTerminalDashboardFailure() + { + var missingDashboardPath = Path.Combine( + AppContext.BaseDirectory, + "missing-dashboard", + Guid.NewGuid().ToString("N")); + var builder = DistributedApplicationTestingBuilder.Create( + CreateDashboardOptions(), + [$"DcpPublisher:DashboardPath={missingDashboardPath}"]); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(cancellationTokenSource.Token)); + + Assert.Contains(KnownResourceNames.AspireDashboard, exception.Message, StringComparison.Ordinal); + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task DashboardStartupSummaryDoesNotWriteTheBrowserTokenToAppHostLogs() + { + await using var builder = await CreateDashboardBuilderAsync(); + var logCollector = new FakeLogCollector(); + builder.Services.AddLogging(logging => logging.AddProvider(new FakeLoggerProvider(logCollector))); + + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + var token = dashboardUri.Query["?t=".Length..]; + Assert.NotEmpty(token); + + // Resource log forwarding is asynchronous. Wait for the child dashboard's summary, which is written after + // its separate login URL line, so the assertion covers both the AppHost and dashboard-process output. + while (!logCollector.GetSnapshot().Any(record => + record.Category?.EndsWith(".Resources.aspire-dashboard", StringComparison.Ordinal) == true && + GetLogText(record).Contains("Aspire Dashboard", StringComparison.Ordinal))) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationTokenSource.Token); + } + + var written = string.Join( + Environment.NewLine, + logCollector.GetSnapshot().Select(GetLogText)); + + Assert.False( + written.Contains(token, StringComparison.Ordinal), + "The dashboard browser token was written to AppHost logs."); + Assert.Contains("Aspire Dashboard", written, StringComparison.Ordinal); + } + + private static string GetLogText(FakeLogRecord record) + { + return record.Message + " " + record.StructuredState?.Aggregate( + string.Empty, + (accumulated, pair) => accumulated + " " + pair.Value); + } + + private static Task CreateDashboardBuilderAsync(params string[] args) + { + return DistributedApplicationTestingBuilder.CreateAsync( + CreateDashboardOptions(), + args); + } + + private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() + { + return new() + { + EnableDashboard = true + }; + } +} diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs index 44f16084281..6c1683160c9 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; + namespace Aspire.Hosting.Tests.ApplicationModel; [Trait("Partition", "4")] @@ -202,4 +205,69 @@ public void IsLocalhostOrLocalhostTld_VariousUriInputs_ReturnsExpectedResult(str // Assert Assert.Equal(expected, result); } + + [Theory] + [InlineData("dashboard.testing.localhost", "http://dashboard.testing.localhost:17454")] + [InlineData("app.dev.localhost", "http://app.dev.localhost:17454")] + [InlineData("localhost", "http://localhost:17454")] + [InlineData("example.com", "http://localhost:17454")] + public async Task GetUrlWithTargetHostAsync_UsesTargetHostOnlyForLocalhostTld(string targetHost, string expectedUrl) + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var container = builder.AddContainer("container1", "image") + .WithHttpEndpoint(name: "primary", targetPort: 10005) + .WithEndpoint("primary", ep => + { + ep.TargetHost = targetHost; + + // DCP allocates "localhost" even when a localhost TLD is configured, because + // "localhost" is the address the service actually binds to. The TLD hostname + // only ever exists in the URL presented back to the user. + ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 17454); + }); + + var url = await EndpointHostHelpers.GetUrlWithTargetHostAsync(container.GetEndpoint("primary")) + .AsTask() + .DefaultTimeout(); + + Assert.Equal(expectedUrl, url); + } + + [Fact] + public async Task GetUrlWithTargetHostAsync_KeepsAddressResolvedForANonLocalNetworkContext() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var container = builder.AddContainer("container1", "image") + .WithHttpEndpoint(name: "primary", targetPort: 10005) + .WithEndpoint("primary", ep => + { + ep.TargetHost = "app.dev.localhost"; + ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 17454); + ep.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint( + KnownNetworkIdentifiers.DefaultAspireContainerNetwork, + new AllocatedEndpoint(ep, "container1.dev.internal", 10005, EndpointBindingMode.SingleAddress, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + }); + + var containerNetworkEndpoint = new EndpointReference( + container.Resource, + container.GetEndpoint("primary").EndpointAnnotation, + KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + + var url = await EndpointHostHelpers.GetUrlWithTargetHostAsync(containerNetworkEndpoint) + .AsTask() + .DefaultTimeout(); + + // A localhost TLD only ever resolves on the host loopback, so it must not be substituted into an + // address that was resolved for the container network - the container would be sent to its own loopback. + Assert.Equal("http://container1.dev.internal:10005", url); + } + + [Fact] + public async Task GetUrlWithTargetHostAsync_ThrowsForNullEndpoint() + { + await Assert.ThrowsAsync( + async () => await EndpointHostHelpers.GetUrlWithTargetHostAsync(null!)); + } } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs index 0901bfed7d7..da44b0f6961 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs @@ -457,7 +457,7 @@ public async Task ResourceReadyEvent_LogsConfiguredOtlpUrlsWhenConfigured() } [Fact] - public async Task AddDashboardResource_CreatesExecutableResourceWithCustomRuntimeConfig() + public async Task AddDashboardResource_UsesDashboardRuntimeConfigWithoutReplacingFrameworkVersions() { // Arrange var resourceLoggerService = new ResourceLoggerService(); @@ -482,12 +482,11 @@ public async Task AddDashboardResource_CreatesExecutableResourceWithCustomRuntim { runtimeOptions = new { - tfm = "net8.0", - rollForward = "Major", + tfm = "net11.0", frameworks = new[] { - new { name = "Microsoft.NETCore.App", version = "8.0.0" }, - new { name = "Microsoft.AspNetCore.App", version = "8.0.0" } + new { name = "Microsoft.NETCore.App", version = "11.0.0" }, + new { name = "Microsoft.AspNetCore.App", version = "11.0.0" } }, configProperties = new { @@ -523,19 +522,20 @@ public async Task AddDashboardResource_CreatesExecutableResourceWithCustomRuntim Assert.Equal(4, args.Count); Assert.Equal("exec", args[0]); Assert.Equal("--runtimeconfig", args[1]); - Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); + Assert.Equal(runtimeConfig, args[2]); Assert.Equal(dashboardDll, args[3]); - // Verify that the custom runtime config has been updated with current framework versions - var customConfigContent = File.ReadAllText((string)args[2]); - var customConfig = JsonSerializer.Deserialize(customConfigContent); + // Verify that the Dashboard's framework versions were preserved rather than replaced + // with the lower-targeted AppHost's versions. + var dashboardConfigContent = File.ReadAllText((string)args[2]); + var dashboardConfig = JsonSerializer.Deserialize(dashboardConfigContent); - var frameworks = customConfig.GetProperty("runtimeOptions").GetProperty("frameworks").EnumerateArray().ToArray(); + var frameworks = dashboardConfig.GetProperty("runtimeOptions").GetProperty("frameworks").EnumerateArray().ToArray(); var netCoreFramework = frameworks.First(f => f.GetProperty("name").GetString() == "Microsoft.NETCore.App"); var aspNetCoreFramework = frameworks.First(f => f.GetProperty("name").GetString() == "Microsoft.AspNetCore.App"); - Assert.Equal("8.0.0", netCoreFramework.GetProperty("version").GetString()); - Assert.Equal("8.0.0", aspNetCoreFramework.GetProperty("version").GetString()); + Assert.Equal("11.0.0", netCoreFramework.GetProperty("version").GetString()); + Assert.Equal("11.0.0", aspNetCoreFramework.GetProperty("version").GetString()); } finally { @@ -603,7 +603,7 @@ public async Task AddDashboardResource_WithExecutablePath_CreatesCorrectArgument Assert.Equal(4, args.Count); Assert.Equal("exec", args[0]); Assert.Equal("--runtimeconfig", args[1]); - Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); + Assert.Equal(runtimeConfig, args[2]); Assert.Equal(dashboardDll, args[3]); } finally @@ -672,7 +672,7 @@ public async Task AddDashboardResource_WithUnixExecutablePath_CreatesCorrectArgu Assert.Equal(4, args.Count); Assert.Equal("exec", args[0]); Assert.Equal("--runtimeconfig", args[1]); - Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); + Assert.Equal(runtimeConfig, args[2]); Assert.Equal(dashboardDll, args[3]); } finally @@ -739,7 +739,7 @@ public async Task AddDashboardResource_WithDirectDllPath_CreatesCorrectArguments Assert.Equal(4, args.Count); Assert.Equal("exec", args[0]); Assert.Equal("--runtimeconfig", args[1]); - Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); + Assert.Equal(runtimeConfig, args[2]); Assert.Equal(dashboardDll, args[3]); } finally @@ -751,6 +751,38 @@ public async Task AddDashboardResource_WithDirectDllPath_CreatesCorrectArguments } } + [Theory] + [InlineData("Aspire.Dashboard.exe")] + [InlineData("Aspire.Dashboard")] + public async Task AddDashboardResource_WithNativeExecutable_RunsDirectly(string executableName) + { + var resourceLoggerService = new ResourceLoggerService(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + var configuration = new ConfigurationBuilder().Build(); + var tempDir = Directory.CreateTempSubdirectory(); + + try + { + var dashboardExecutable = Path.Combine(tempDir.FullName, executableName); + File.WriteAllText(dashboardExecutable, "mock native executable"); + + var dashboardOptions = Options.Create(new DashboardOptions { DashboardPath = dashboardExecutable }); + var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); + var model = new DistributedApplicationModel(new ResourceCollection()); + + await hook.OnBeforeStartAsync(new BeforeStartEvent(new TestServiceProvider(), model), CancellationToken.None); + + var dashboardResource = Assert.Single(model.Resources); + var executableResource = Assert.IsType(dashboardResource); + Assert.Equal(dashboardExecutable, executableResource.Command); + Assert.Empty(executableResource.Annotations.OfType()); + } + finally + { + tempDir.Delete(recursive: true); + } + } + private static DashboardEventHandlers CreateHook( ResourceLoggerService resourceLoggerService, ResourceNotificationService resourceNotificationService, diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index fd223896f0e..4ea2dba9851 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -5249,6 +5249,64 @@ public async Task PersistentPlainExecutable_UsesStableCertificateOutputPath() Assert.Equal(Path.Join(expectedCertificatesRoot, "cert.pem"), sslCertFile); } + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Unix file modes do not apply on Windows, where the directory inherits its ACL from the parent.")] + public async Task PersistentPlainExecutable_WritesCustomBundleDirectoryOwnerOnly() + { + var builder = DistributedApplication.CreateBuilder(); + using var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build()); + using var aspireStoreDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-store"); + + using var certificate = CreateTestCertificate(); + var certificateAuthorities = builder.AddCertificateAuthorityCollection("certificates") + .WithCertificate(certificate); + + var executable = new TestExecutableResource("test-working-directory"); + builder.AddResource(executable) + .WithCertificateAuthorityCollection(certificateAuthorities) + .WithCertificateTrustScope(CertificateTrustScope.Override) + // A custom bundle is what Aspire.Hosting.Java writes for JAVAX_NET_SSL_TRUSTSTORE, and it is + // the only thing that causes the bundles/ directory to be created. + .WithCertificateTrustConfiguration(static ctx => + { + ctx.EnvironmentVariables["TEST_BUNDLE"] = ctx.CreateCustomBundle(static (_, _) => Task.FromResult(new byte[] { 1, 2, 3 })); + return Task.CompletedTask; + }) + // Persistent lifetime is the case that matters: the bundle lands in the stable Aspire store + // path rather than inside the session-scoped temp directory, which is already owner-only. + .WithPersistentLifetime(); + + var configDict = new Dictionary + { + [AspireStore.AspireStorePathKeyName] = aspireStoreDirectory.Path, + ["AppHost:Sha256"] = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }; + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration); + + await appExecutor.RunApplicationAsync(); + + var certificatesRoot = Path.Join(aspireStoreDirectory.Path, ".aspire", "dcp", "executables", "TestExecutable-12345678", "certificates"); + var bundlesDirectory = Path.Join(certificatesRoot, "bundles"); + Assert.True(Directory.Exists(bundlesDirectory), $"Expected the custom bundle directory to exist at {bundlesDirectory}."); + + var mode = GetUnixFileModeForTest(bundlesDirectory); + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, mode); + } + + private static UnixFileMode GetUnixFileModeForTest(string path) + { + // The caller guards on platform, but the analyzer cannot see through [SkipOnPlatform]. +#pragma warning disable CA1416 + return File.GetUnixFileMode(path); +#pragma warning restore CA1416 + } + [Fact] public void PlainExecutableCertificateDirectoriesPath_IncludesExistingWellKnownDirectoriesForAppendWhenSslCertDirIsUnsetOnLinux() { @@ -6502,16 +6560,44 @@ public async Task ProjectResource_CustomIdeLaunch_OwnedDotnetToolRunArgsPreserve } [Theory] - [InlineData("run", false)] - [InlineData("run", true)] - [InlineData("watch", false)] - [InlineData("watch", true)] - public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedProjectPreservesLaunchProfileArgs(string launchVerb, bool useFullDotnetPath) + [InlineData("run", false, null, null, null)] + [InlineData("run", true, null, null, null)] + [InlineData("watch", false, null, null, null)] + [InlineData("watch", true, null, null, null)] + [InlineData("run", false, "-d", null, null)] + [InlineData("watch", false, "--diagnostics", null, null)] + [InlineData("run", false, null, new string[] { "[env:ASPIRE_PREFIX_PROBE=1]" }, null)] + [InlineData("run", false, "--diagnostics", new string[] { "[env:ASPIRE_PREFIX_PROBE=1]" }, null)] + [InlineData("run", false, "--diagnostics", new string[] { "[env:ASPIRE_PREFIX_PROBE_A=1]", "[env:ASPIRE_PREFIX_PROBE_B=2]" }, null)] + [InlineData("run", false, "--diagnostics", new string[] { "[env:ASPIRE_PREFIX_PROBE=1]" }, "app-arg")] + public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedProjectPreservesLaunchProfileArgs( + string launchVerb, + bool useFullDotnetPath, + string? sdkOption, + string[]? environmentVariableDirectives, + string? applicationArgument) { var builder = DistributedApplication.CreateBuilder(); var dotnetCommand = useFullDotnetPath ? Path.GetFullPath(Path.Combine("test-dotnet-root", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet")) : "dotnet"; + var resourceArgs = new List(); + if (environmentVariableDirectives is not null) + { + resourceArgs.AddRange(environmentVariableDirectives); + } + + if (sdkOption is not null) + { + resourceArgs.Add(sdkOption); + } + + resourceArgs.AddRange([launchVerb, "-f", "net10.0-ios"]); + if (applicationArgument is not null) + { + resourceArgs.AddRange(["--", applicationArgument]); + } + var projectBuilder = builder.AddProject("proj", launchProfileName: "http"); var defaultAnnotation = projectBuilder.Resource.Annotations.OfType().FirstOrDefault(); if (defaultAnnotation is not null) @@ -6535,7 +6621,7 @@ public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedProjectPres TargetKind = "simulator" }, "maui") - .WithArgs(launchVerb, "-f", "net10.0-ios"); + .WithArgs([.. resourceArgs]); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -6555,28 +6641,69 @@ public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedProjectPres var exe = GetCreatedExecutableForResource(kubernetesService, "proj"); Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType); Assert.Equal(dotnetCommand, exe.Spec.ExecutablePath); - var expectedArgs = new List { launchVerb }; + var expectedArgs = new List(); + if (environmentVariableDirectives is not null) + { + expectedArgs.AddRange(environmentVariableDirectives); + } + + if (sdkOption is not null) + { + expectedArgs.Add(sdkOption); + } + + expectedArgs.Add(launchVerb); if (GetTestAssemblyConfiguration() is { } configurationName) { expectedArgs.AddRange(["--configuration", configurationName]); } + expectedArgs.AddRange(["--no-launch-profile", "-f", "net10.0-ios", "--", "--profile-arg", "profile value"]); + if (applicationArgument is not null) + { + expectedArgs.Add(applicationArgument); + } + Assert.Equal(expectedArgs, exe.Spec.Args); Assert.Equal(ExecutionType.Process, Assert.Single(exe.Spec.FallbackExecutionTypes!)); Assert.True(exe.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var displayArgs)); - Assert.Equal( - [launchVerb, "-f", "net10.0-ios", "--", "--profile-arg", "profile value"], - displayArgs.Select(a => a.Argument)); + var expectedDisplayArgs = new List(); + if (environmentVariableDirectives is not null) + { + expectedDisplayArgs.AddRange(environmentVariableDirectives); + } + + if (sdkOption is not null) + { + expectedDisplayArgs.Add(sdkOption); + } + + expectedDisplayArgs.AddRange([launchVerb, "-f", "net10.0-ios", "--", "--profile-arg", "profile value"]); + if (applicationArgument is not null) + { + expectedDisplayArgs.Add(applicationArgument); + } + + Assert.Equal(expectedDisplayArgs, displayArgs.Select(a => a.Argument)); AssertEffectiveArgumentIndexesMatchSpecArgs(displayArgs, exe.Spec.Args); } [Theory] - [InlineData("run", true)] - [InlineData("watch", true)] - [InlineData("run", false)] - [InlineData("watch", false)] - public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedDotnetApplicationDoesNotOfferInvalidProcessFallback(string nonLeadingLaunchVerb, bool useExec) + [InlineData("run", true, false, null)] + [InlineData("watch", true, false, null)] + [InlineData("run", false, false, null)] + [InlineData("watch", false, false, null)] + [InlineData("run", false, true, null)] + [InlineData("watch", false, true, null)] + [InlineData("watch", false, false, "[env:ASPIRE_PREFIX_PROBE=1]")] + [InlineData("run", false, false, "@options.rsp")] + [InlineData("watch", false, false, "@options.rsp")] + public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedDotnetApplicationDoesNotOfferInvalidProcessFallback( + string nonLeadingLaunchVerb, + bool useExec, + bool useRuntimeOptions, + string? commandPrefix) { var builder = DistributedApplication.CreateBuilder(); var projectBuilder = builder.AddProject("proj", launchProfileName: "http"); @@ -6586,9 +6713,13 @@ public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedDotnetAppli projectBuilder.Resource.Annotations.Remove(defaultAnnotation); } - string[] resourceArgs = useExec - ? ["exec", "app.dll", nonLeadingLaunchVerb] - : ["app.dll", nonLeadingLaunchVerb]; + string[] resourceArgs = commandPrefix is not null + ? [commandPrefix, nonLeadingLaunchVerb] + : useRuntimeOptions + ? ["--roll-forward", "LatestMajor", "app.dll", nonLeadingLaunchVerb] + : useExec + ? ["exec", "app.dll", nonLeadingLaunchVerb] + : ["app.dll", nonLeadingLaunchVerb]; projectBuilder .WithAnnotation(new ExecutableAnnotation { @@ -6634,9 +6765,12 @@ public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedDotnetAppli } [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedDotnetApplicationWithoutLaunchProfileArgsOffersProcessFallback(bool useExec) + [InlineData(new string[] { "exec", "app.dll", "run" }, true)] + [InlineData(new string[] { "app.dll", "run" }, true)] + [InlineData(new string[] { "[env:ASPIRE_PREFIX_PROBE=1]", "watch" }, false)] + public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedDotnetApplicationWithoutLaunchProfileArgsSetsExpectedProcessFallback( + string[] resourceArgs, + bool offersProcessFallback) { var builder = DistributedApplication.CreateBuilder(); var projectBuilder = builder.AddProject("proj", launchProfileName: null); @@ -6646,9 +6780,6 @@ public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedDotnetAppli projectBuilder.Resource.Annotations.Remove(defaultAnnotation); } - string[] resourceArgs = useExec - ? ["exec", "app.dll", "run"] - : ["app.dll", "run"]; projectBuilder .WithAnnotation(new ExecutableAnnotation { @@ -6678,7 +6809,14 @@ public async Task ProjectResource_CustomIdeLaunch_ExecutableAnnotatedDotnetAppli var exe = GetCreatedExecutableForResource(kubernetesService, "proj"); Assert.Equal(ExecutionType.IDE, exe.Spec.ExecutionType); Assert.Equal(resourceArgs, exe.Spec.Args); - Assert.Equal(ExecutionType.Process, Assert.Single(exe.Spec.FallbackExecutionTypes!)); + if (offersProcessFallback) + { + Assert.Equal(ExecutionType.Process, Assert.Single(exe.Spec.FallbackExecutionTypes!)); + } + else + { + Assert.Null(exe.Spec.FallbackExecutionTypes); + } } [Fact] diff --git a/tests/Aspire.Hosting.Tests/MSBuildTests.cs b/tests/Aspire.Hosting.Tests/MSBuildTests.cs index ebb9508ef8e..8a40fd8a337 100644 --- a/tests/Aspire.Hosting.Tests/MSBuildTests.cs +++ b/tests/Aspire.Hosting.Tests/MSBuildTests.cs @@ -367,8 +367,8 @@ public async Task CliBundleOptInResolvesExplicitBundlePath() var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt")); Assert.Equal(bundle.DcpDir, resolvedPaths[0]); - Assert.Equal(bundle.ManagedDir, resolvedPaths[1]); - Assert.Equal(bundle.ManagedPath, resolvedPaths[2]); + Assert.Equal(bundle.DashboardDir, resolvedPaths[1]); + Assert.Equal(bundle.DashboardPath, resolvedPaths[2]); } [Fact] @@ -391,8 +391,8 @@ public async Task CliBundleOptInResolvesNewestVersionedBundlePath() var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt")); Assert.Equal(newestBundle.DcpDir, resolvedPaths[0]); - Assert.Equal(newestBundle.ManagedDir, resolvedPaths[1]); - Assert.Equal(newestBundle.ManagedPath, resolvedPaths[2]); + Assert.Equal(newestBundle.DashboardDir, resolvedPaths[1]); + Assert.Equal(newestBundle.DashboardPath, resolvedPaths[2]); } [Fact] @@ -415,8 +415,8 @@ public async Task CliBundleOptInIgnoresTemporaryVersionedBundleDirectories() var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt")); Assert.Equal(newestBundle.DcpDir, resolvedPaths[0]); - Assert.Equal(newestBundle.ManagedDir, resolvedPaths[1]); - Assert.Equal(newestBundle.ManagedPath, resolvedPaths[2]); + Assert.Equal(newestBundle.DashboardDir, resolvedPaths[1]); + Assert.Equal(newestBundle.DashboardPath, resolvedPaths[2]); } [Fact] @@ -461,8 +461,8 @@ public async Task CliBundleOptInResolvesAspireHomeBundleForSidecarlessCli() var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt")); Assert.Equal(bundle.DcpDir, resolvedPaths[0]); - Assert.Equal(bundle.ManagedDir, resolvedPaths[1]); - Assert.Equal(bundle.ManagedPath, resolvedPaths[2]); + Assert.Equal(bundle.DashboardDir, resolvedPaths[1]); + Assert.Equal(bundle.DashboardPath, resolvedPaths[2]); } [Fact] @@ -487,10 +487,10 @@ public async Task CliBundleOptInPreparesMissingBundleBesidePathCli() var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt")); var bundleRoot = Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName); var expectedDcpDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "dcp")); - var expectedManagedDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "managed")); + var expectedDashboardDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.DashboardDirectoryName)); Assert.Equal(expectedDcpDir, resolvedPaths[0]); - Assert.Equal(expectedManagedDir, resolvedPaths[1]); - Assert.Equal(Path.Combine(expectedManagedDir, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"), resolvedPaths[2]); + Assert.Equal(expectedDashboardDir, resolvedPaths[1]); + Assert.Equal(Path.Combine(expectedDashboardDir, OperatingSystem.IsWindows() ? "Aspire.Dashboard.exe" : "Aspire.Dashboard"), resolvedPaths[2]); Assert.False(Directory.Exists(Path.Combine(aspireHome, BundleDiscovery.BundleDirectoryName))); } @@ -578,7 +578,7 @@ public void CliBundleSetupTimeoutReportsAspire009() Assert.Contains("ASPIRE009", output); Assert.Contains("Automatic Aspire CLI bundle setup did not produce a usable DCP and dashboard layout.", output); - Assert.Contains("The command timed out after 100 milliseconds.", output); + Assert.Contains("The command timed out", output, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -611,7 +611,7 @@ public async Task CliBundleOptInPreservesLiteralPercentCharactersInWindowsComman var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt")); var bundleRoot = Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName); Assert.Equal(EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.DcpDirectoryName)), resolvedPaths[0]); - Assert.Equal(EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.ManagedDirectoryName)), resolvedPaths[1]); + Assert.Equal(EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.DashboardDirectoryName)), resolvedPaths[1]); } [Fact] @@ -664,8 +664,8 @@ public async Task CliBundleOptInResolvesAspireHomeBundleWithoutCliOnPath() var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt")); Assert.Equal(bundle.DcpDir, resolvedPaths[0]); - Assert.Equal(bundle.ManagedDir, resolvedPaths[1]); - Assert.Equal(bundle.ManagedPath, resolvedPaths[2]); + Assert.Equal(bundle.DashboardDir, resolvedPaths[1]); + Assert.Equal(bundle.DashboardPath, resolvedPaths[2]); } [Fact] @@ -700,8 +700,8 @@ public async Task CliBundleOptInResolvesDotNetToolStoreBundleFromShimPath() var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt")); Assert.Equal(bundle.DcpDir, resolvedPaths[0]); - Assert.Equal(bundle.ManagedDir, resolvedPaths[1]); - Assert.Equal(bundle.ManagedPath, resolvedPaths[2]); + Assert.Equal(bundle.DashboardDir, resolvedPaths[1]); + Assert.Equal(bundle.DashboardPath, resolvedPaths[2]); } [Fact] @@ -977,33 +977,37 @@ private static void CreateAppHostPackageDirectoryBuildFiles(string basePath, str """); } - private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundle(string basePath) + private static (string LayoutRoot, string DcpDir, string DashboardDir, string DashboardPath) CreateFakeCliBundle(string basePath) { return CreateFakeCliBundleAtLayoutRoot(Path.Combine(basePath, "layout")); } - private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundleAtLayoutRoot(string layoutRoot) + private static (string LayoutRoot, string DcpDir, string DashboardDir, string DashboardPath) CreateFakeCliBundleAtLayoutRoot(string layoutRoot) { return CreateFakeCliBundleRoot(layoutRoot, Path.Combine(layoutRoot, "bundle")); } - private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundleAtVersionedLayoutRoot(string layoutRoot, string versionId) + private static (string LayoutRoot, string DcpDir, string DashboardDir, string DashboardPath) CreateFakeCliBundleAtVersionedLayoutRoot(string layoutRoot, string versionId) { return CreateFakeCliBundleRoot(layoutRoot, Path.Combine(layoutRoot, "versions", versionId)); } - private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundleRoot(string layoutRoot, string bundleRoot) + private static (string LayoutRoot, string DcpDir, string DashboardDir, string DashboardPath) CreateFakeCliBundleRoot(string layoutRoot, string bundleRoot) { var dcpDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "dcp")); var managedDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "managed")); + var dashboardDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.DashboardDirectoryName)); Directory.CreateDirectory(dcpDir); Directory.CreateDirectory(managedDir); + Directory.CreateDirectory(dashboardDir); File.WriteAllText(Path.Combine(dcpDir, OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"), ""); var managedPath = Path.Combine(managedDir, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"); File.WriteAllText(managedPath, ""); + var dashboardPath = Path.Combine(dashboardDir, OperatingSystem.IsWindows() ? "Aspire.Dashboard.exe" : "Aspire.Dashboard"); + File.WriteAllText(dashboardPath, ""); - return (layoutRoot, dcpDir, managedDir, managedPath); + return (layoutRoot, dcpDir, dashboardDir, dashboardPath); } private static string CreateFakeAspireCli(string directory) @@ -1018,14 +1022,17 @@ private static string CreateFakeAspireCliThatSetsUpBundle(string directory) var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire"); var dcpExecutable = OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"; var managedExecutable = OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"; + var dashboardExecutable = OperatingSystem.IsWindows() ? "Aspire.Dashboard.exe" : "Aspire.Dashboard"; var contents = OperatingSystem.IsWindows() ? $$""" @echo off if not "%~1"=="setup" exit /b 2 mkdir "%~dp0..\bundle\dcp" mkdir "%~dp0..\bundle\managed" + mkdir "%~dp0..\bundle\dashboard" type nul > "%~dp0..\bundle\dcp\{{dcpExecutable}}" type nul > "%~dp0..\bundle\managed\{{managedExecutable}}" + type nul > "%~dp0..\bundle\dashboard\{{dashboardExecutable}}" """ : $$""" #!/bin/sh @@ -1033,9 +1040,10 @@ type nul > "%~dp0..\bundle\managed\{{managedExecutable}}" exit 2 fi install_path="$(dirname "$0")/.." - mkdir -p "$install_path/bundle/dcp" "$install_path/bundle/managed" + mkdir -p "$install_path/bundle/dcp" "$install_path/bundle/managed" "$install_path/bundle/dashboard" : > "$install_path/bundle/dcp/{{dcpExecutable}}" : > "$install_path/bundle/managed/{{managedExecutable}}" + : > "$install_path/bundle/dashboard/{{dashboardExecutable}}" """; File.WriteAllText(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n")); diff --git a/tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs b/tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs index 20805aa64cb..3ca91dc509e 100644 --- a/tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs +++ b/tests/Aspire.Hosting.Tests/VersionChecking/PackageUpdateHelpersTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Aspire.Shared; using Microsoft.Extensions.Logging.Abstractions; using Semver; @@ -215,4 +216,90 @@ public void GetLatestVersion_MixedPackageIds_OnlyConsidersAppHostPackages() // Assert Assert.Equal(new SemVersion(9, 0, 0), latestVersion); } + + [Fact] + public void ParsePackageSearchResults_WithCredentialProviderOutputAroundPayload_ParsesExpectedPayload() + { + // Credential-provider diagnostics use an inherited stdout handle, so braced text or JSON can arrive before + // the package-search payload and additional output can arrive after it. The diagnostic JSON deliberately + // contains nested package-search property names to ensure only complete root payload objects are considered. + // The accented characters ensure the scanner handles UTF-8 byte offsets correctly. + var pollutedStdout = + " [CredentialProvider]R\u00e9ponse re\u00e7ue for request {request-42}\n" + + """{"error":{"searchResult":[{"packages":[]}]}}""" + "\n" + + """ + { + "version": 2, + "problems": [], + "searchResult": [ + { + "sourceName": "azure-default", + "packages": [ + { "id": "Aspire.Hosting.AppHost", "latestVersion": "9.3.1" } + ] + } + ] + } + """ + + "\n [CredentialProvider]VstsCredentialProvider - Acquired bearer token using 'MSAL Silent'"; + + var packages = PackageUpdateHelpers.ParsePackageSearchResults(pollutedStdout, "Aspire.Hosting.AppHost"); + + var package = Assert.Single(packages); + Assert.Equal("Aspire.Hosting.AppHost", package.Id); + Assert.Equal("9.3.1", package.Version); + Assert.Equal("azure-default", package.Source); + } + + [Fact] + public void ParsePackageSearchResults_EmptyPayloadWithOutputAroundIt_ReturnsEmpty() + { + var pollutedStdout = + " [CredentialProvider]Acquiring token\n" + + """{"version":2,"problems":[],"searchResult":[]}""" + + "\n [CredentialProvider]Token acquired"; + + var packages = PackageUpdateHelpers.ParsePackageSearchResults(pollutedStdout); + + Assert.Empty(packages); + } + + [Theory] + [InlineData("""{"searchResult":[{"sourceName":"feed","packages":[]}]}""")] + [InlineData("""{"version":2,"searchResult":[{"packages":[]}]}""")] + [InlineData("""{"version":2,"searchResult":[{"sourceName":"feed","packages":{}}]}""")] + public void ParsePackageSearchResults_RootNearMatchBeforePayload_ParsesExpectedPayload(string nearMatch) + { + var pollutedStdout = + nearMatch + "\n" + + """ + { + "version": 2, + "problems": [], + "searchResult": [ + { + "sourceName": "azure-default", + "packages": [ + { "id": "Aspire.Hosting.AppHost", "latestVersion": "9.3.1" } + ] + } + ] + } + """; + + var packages = PackageUpdateHelpers.ParsePackageSearchResults(pollutedStdout); + + var package = Assert.Single(packages); + Assert.Equal("Aspire.Hosting.AppHost", package.Id); + Assert.Equal("9.3.1", package.Version); + Assert.Equal("azure-default", package.Source); + } + + [Fact] + public void ParsePackageSearchResults_WithoutExpectedPayload_PreservesParseFailure() + { + var pollutedStdout = " [CredentialProvider]Acquiring token for request {request-42}"; + + Assert.ThrowsAny(() => PackageUpdateHelpers.ParsePackageSearchResults(pollutedStdout)); + } } diff --git a/tests/Aspire.Microsoft.Azure.Cosmos.Tests/AspireMicrosoftAzureCosmosExtensionsTests.cs b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/AspireMicrosoftAzureCosmosExtensionsTests.cs index 8b09c8a0fc9..b93ad8b8a18 100644 --- a/tests/Aspire.Microsoft.Azure.Cosmos.Tests/AspireMicrosoftAzureCosmosExtensionsTests.cs +++ b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/AspireMicrosoftAzureCosmosExtensionsTests.cs @@ -4,7 +4,9 @@ using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Xunit; namespace Aspire.Microsoft.Azure.Cosmos.Tests; @@ -630,6 +632,52 @@ public void AddAzureCosmosDatabase_CalledMultipleTimes_CreatesIndependentBuilder Assert.NotSame(usersContainer.Database.Client, productsContainer.Database.Client); } + [Fact] + public void AddAzureCosmosClient_AddsHealthCheckByDefault() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + PopulateConfiguration(builder.Configuration, "AccountEndpoint=https://localhost:8081;AccountKey=fake;"); + + builder.AddAzureCosmosClient("cosmos"); + + using var host = builder.Build(); + + var registrations = host.Services.GetRequiredService>().Value.Registrations; + Assert.Collection(registrations, registration => Assert.Equal("Microsoft.Azure.Cosmos", registration.Name)); + } + + [Fact] + public void AddAzureCosmosClient_DoesNotAddHealthCheck_WhenDisabled() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + PopulateConfiguration(builder.Configuration, "AccountEndpoint=https://localhost:8081;AccountKey=fake;"); + + builder.AddAzureCosmosClient("cosmos", configureSettings: settings => settings.DisableHealthChecks = true); + + using var host = builder.Build(); + + var registrations = host.Services.GetRequiredService>().Value.Registrations; + Assert.Empty(registrations); + } + + [Fact] + public void AddKeyedAzureCosmosClient_AddsHealthCheckWithKeyedName() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + var serviceKey = "cosmos-key"; + + PopulateConfiguration(builder.Configuration, "AccountEndpoint=https://localhost:8081;AccountKey=fake;", serviceKey); + + builder.AddKeyedAzureCosmosClient(serviceKey); + + using var host = builder.Build(); + + var registrations = host.Services.GetRequiredService>().Value.Registrations; + Assert.Collection(registrations, registration => Assert.Equal($"Microsoft.Azure.Cosmos_{serviceKey}", registration.Name)); + } + private static void PopulateConfiguration(ConfigurationManager configuration, string connectionString, string? key = null) => configuration.AddInMemoryCollection([ new KeyValuePair($"ConnectionStrings:{key ?? "cosmos"}", connectionString) diff --git a/tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConformanceTests.cs b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConformanceTests.cs index 979f032d00c..fe870562783 100644 --- a/tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConformanceTests.cs +++ b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConformanceTests.cs @@ -38,7 +38,7 @@ protected override void RegisterComponent(HostApplicationBuilder builder, Action } protected override void SetHealthCheck(MicrosoftAzureCosmosSettings options, bool enabled) - => throw new NotImplementedException(); + => options.DisableHealthChecks = !enabled; protected override void SetTracing(MicrosoftAzureCosmosSettings options, bool enabled) => options.DisableTracing = !enabled; diff --git a/tests/Infrastructure.Tests/Infrastructure.Tests.csproj b/tests/Infrastructure.Tests/Infrastructure.Tests.csproj index 7918ec1575f..e0a5ea2c38a 100644 --- a/tests/Infrastructure.Tests/Infrastructure.Tests.csproj +++ b/tests/Infrastructure.Tests/Infrastructure.Tests.csproj @@ -38,6 +38,8 @@ + + diff --git a/tests/Infrastructure.Tests/Pipelines/ExtensionE2eRecorderStepTests.cs b/tests/Infrastructure.Tests/Pipelines/ExtensionE2eRecorderStepTests.cs new file mode 100644 index 00000000000..7d376f2d5c2 --- /dev/null +++ b/tests/Infrastructure.Tests/Pipelines/ExtensionE2eRecorderStepTests.cs @@ -0,0 +1,216 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests.Pipelines; + +/// +/// Runs the Install E2E recorder step of .github/workflows/extension-e2e-tests.yml +/// inside the same wrapper GitHub Actions builds around a pwsh step, pinning the contract +/// that the step can never fail a shard. +/// +/// ffmpeg only powers the diagnostic screen recordings, and run-e2e.js already skips recording with +/// a warning when the binary is missing, so a failed install has to stay a warning. That was not +/// true once: bounding the apt-get calls with timeout stopped them hanging, but the failed +/// exit code then leaked out and failed six shards outright, because GitHub appends +/// if ((Test-Path -LiteralPath variable:/LASTEXITCODE)) { exit $LASTEXITCODE } to every pwsh +/// step. See +/// https://docs.github.com/actions/reference/workflows-and-actions/workflow-syntax#exit-codes-and-error-action-preference. +/// +/// Running the step body on its own cannot catch that regression, because the appended epilogue is +/// the thing that turns a leftover $LASTEXITCODE into a step failure. So these tests wrap the +/// body the way GitHub does, and the always-fails case is covered twice: once as authored, and once +/// with the explicit exit 0 stripped, which must reproduce the original failure. Without that +/// control the harness could pass while proving nothing. +/// +public sealed class ExtensionE2eRecorderStepTests(ITestOutputHelper testOutput) +{ + private const string RecorderStepName = "Install E2E recorder"; + + /// Exit code the apt-get stub uses for a failed install, distinct from the 124 that a real timeout returns. + private const int StubAptFailureExitCode = 100; + + /// What GitHub wraps around the run: body of a pwsh step. + private const string GitHubPwshPrologue = "$ErrorActionPreference = 'stop'"; + private const string GitHubPwshEpilogue = "if ((Test-Path -LiteralPath variable:/LASTEXITCODE)) { exit $LASTEXITCODE }"; + + private const string ExplicitSuccessExit = "exit 0"; + private const string RetryBackoff = "Start-Sleep -Seconds 15"; + + private const string RetryWarning = "::warning::Attempt 1 to install ffmpeg failed; retrying."; + private const string GaveUpWarning = "::warning::ffmpeg could not be installed; E2E screen recordings are disabled for this shard."; + + [Theory] + // As authored: every apt-get outcome has to leave the shard alive. + [InlineData("always-fails", true, 0, 2, new[] { RetryWarning, GaveUpWarning })] + [InlineData("fails-once", true, 0, 2, new[] { RetryWarning })] + [InlineData("succeeds", true, 0, 1, new string[0])] + // Control: the same body without its explicit exit must reproduce the shard-wide failure, + // otherwise the three cases above would pass even if the epilogue were not being applied. + [InlineData("always-fails", false, StubAptFailureExitCode, 2, new[] { RetryWarning, GaveUpWarning })] + [RequiresTools(["pwsh"])] + public async Task RecorderStepSurvivesEveryAptOutcome( + string aptBehavior, + bool keepExplicitExit, + int expectedExitCode, + int expectedInstallAttempts, + string[] expectedWarnings) + { + // The stubs below are shell scripts, and this step only ever runs on the Linux shards + // (`if: ${{ matrix.useXvfb }}`), so there is nothing to pin on Windows. + Assert.SkipWhen(OperatingSystem.IsWindows(), "The recorder step and its stubs are Unix-only."); + + using var workspace = TemporaryWorkspace.Create(testOutput); + + var stepScript = ExtensionE2eWorkflow.StepScript(RecorderStepName); + var stubDirectory = CreateCommandStubs(workspace, aptBehavior, out var invocationLog, out var pwshPath); + var scriptPath = Path.Combine(workspace.Path, "step.ps1"); + File.WriteAllText(scriptPath, BuildStepScript(stepScript, keepExplicitExit)); + + using var command = new PowerShellCommand(scriptPath, testOutput, label: aptBehavior) + .WithEnvironmentVariable("PATH", stubDirectory) + .WithTimeout(TimeSpan.FromMinutes(2)); + var result = await command.ExecuteAsync(); + + var invocations = File.Exists(invocationLog) ? File.ReadAllLines(invocationLog) : []; + var installAttempts = invocations.Count(line => line.StartsWith("apt-get install", StringComparison.Ordinal)); + + Assert.Equal(expectedExitCode, result.ExitCode); + Assert.Equal(expectedInstallAttempts, installAttempts); + + // Distinguishes a genuine retry from a run that merely failed twice: both make two install + // attempts, and only the warnings say whether the second one succeeded. Without this, a + // stub that could not track attempts would quietly degrade fails-once into always-fails. + var warnings = result.Output + .Split('\n') + .Select(line => line.Trim('\r')) + .Where(line => line.StartsWith("::warning::", StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(expectedWarnings, warnings); + + // Plain `timeout` only sends SIGTERM, and apt/dpkg defer signals around critical sections, + // so without the escalation a wedged child can outlive its own timeout - the hang this step + // was bounded to prevent. + var timeoutInvocations = invocations + .Where(line => line.StartsWith("timeout ", StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(expectedInstallAttempts * 2, timeoutInvocations.Length); + for (var attempt = 0; attempt < expectedInstallAttempts; attempt++) + { + Assert.EndsWith("apt-get update", timeoutInvocations[attempt * 2], StringComparison.Ordinal); + Assert.Contains("apt-get install", timeoutInvocations[(attempt * 2) + 1], StringComparison.Ordinal); + } + + Assert.All( + timeoutInvocations, + line => Assert.Contains("--kill-after=", line, StringComparison.Ordinal)); + + Assert.NotEmpty(pwshPath); + } + + /// + /// Wraps the step body the way the runner does before executing it. + /// + /// The authored cases deliberately run whatever the workflow says without first checking it for + /// an exit 0. Deleting that line then surfaces as the failure it actually causes - the + /// step exiting non-zero and taking the shard with it - rather than as a structural complaint + /// about the script's last line. + /// + private static string BuildStepScript(string stepScript, bool keepExplicitExit) + { + var body = stepScript.TrimEnd(); + if (!keepExplicitExit) + { + // Only the control needs the line to exist, because it is built by removing it. Deriving + // the control this way rather than pasting a copy of the old body keeps it from drifting + // away from what the workflow actually runs. + Assert.EndsWith(ExplicitSuccessExit, body, StringComparison.Ordinal); + body = body[..body.LastIndexOf(ExplicitSuccessExit, StringComparison.Ordinal)].TrimEnd(); + } + + // Asserted before it is replaced, so shortening the wait here cannot quietly stop matching + // the workflow. The delay itself is not what these tests pin; the exit code contract is. + Assert.Contains(RetryBackoff, body, StringComparison.Ordinal); + body = body.Replace(RetryBackoff, "Start-Sleep -Milliseconds 50", StringComparison.Ordinal); + + return string.Join(Environment.NewLine, [GitHubPwshPrologue, body, GitHubPwshEpilogue]); + } + + /// + /// Builds a directory of stub executables that becomes the whole PATH for the step. + /// + /// Replacing PATH rather than prepending is deliberate: the step's first branch is + /// Get-Command ffmpeg, so a machine that happens to have ffmpeg installed would otherwise + /// silently skip the install path these tests exist to cover. pwsh is linked in because it has + /// to stay resolvable once PATH is replaced. + /// + private static string CreateCommandStubs(TemporaryWorkspace workspace, string aptBehavior, out string invocationLog, out string pwshPath) + { + var stubDirectory = workspace.CreateDirectory("stubs").FullName; + invocationLog = Path.Combine(workspace.Path, "invocations.log"); + var attemptCounter = Path.Combine(workspace.Path, "install-attempts"); + + // `sudo timeout --kill-after=30 180 apt-get ...`, so both wrappers have to hand off to the + // rest of the command line for the apt-get stub to ever run. + WriteStub(stubDirectory, "sudo", $""" + #!/bin/sh + echo "sudo $*" >> "{invocationLog}" + exec "$@" + """); + + WriteStub(stubDirectory, "timeout", $""" + #!/bin/sh + echo "timeout $*" >> "{invocationLog}" + while [ $# -gt 0 ]; do + case "$1" in + --kill-after=*) shift ;; + -k) shift 2 ;; + *) break ;; + esac + done + shift + exec "$@" + """); + + // Only the install decides success; `apt-get update` is allowed to fail because the install + // can still succeed from the indices already on the runner image. + // + // The attempt count is kept with shell builtins alone. PATH is replaced by this directory, + // so external tools are deliberately unreachable, and reaching for one here would silently + // read zero attempts every time and turn the fails-once case into a second always-fails run. + WriteStub(stubDirectory, "apt-get", $""" + #!/bin/sh + echo "apt-get $*" >> "{invocationLog}" + if [ "$1" != "install" ]; then exit 0; fi + echo attempt >> "{attemptCounter}" + attempts=0 + while read -r _line; do attempts=$((attempts + 1)); done < "{attemptCounter}" + case "{aptBehavior}" in + succeeds) exit 0 ;; + fails-once) [ "$attempts" -ge 2 ] && exit 0; exit {StubAptFailureExitCode} ;; + *) exit {StubAptFailureExitCode} ;; + esac + """); + + pwshPath = PathLookupHelper.FindFullPathFromPath("pwsh") ?? string.Empty; + Assert.SkipWhen(pwshPath.Length == 0, "pwsh is not on PATH."); + File.CreateSymbolicLink(Path.Combine(stubDirectory, "pwsh"), pwshPath); + + return stubDirectory; + } + + private static void WriteStub(string directory, string name, string contents) + { + var stubPath = Path.Combine(directory, name); + File.WriteAllText(stubPath, contents); + + // The test skips on Windows before reaching this, but Assert.SkipWhen is invisible to the + // platform-compatibility analyzer, so the guard has to be one it can see. + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(stubPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } +} diff --git a/tests/Infrastructure.Tests/Pipelines/ExtensionE2eWorkflow.cs b/tests/Infrastructure.Tests/Pipelines/ExtensionE2eWorkflow.cs new file mode 100644 index 00000000000..3695863af80 --- /dev/null +++ b/tests/Infrastructure.Tests/Pipelines/ExtensionE2eWorkflow.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using YamlDotNet.RepresentationModel; + +namespace Infrastructure.Tests.Pipelines; + +/// +/// Reads .github/workflows/extension-e2e-tests.yml so the tests that pin its contracts all +/// resolve the same job and steps. +/// +internal static class ExtensionE2eWorkflow +{ + private const string WorkflowRelativePath = ".github/workflows/extension-e2e-tests.yml"; + + public static YamlMappingNode Job() + { + var yaml = new YamlStream(); + using var reader = new StringReader(File.ReadAllText(Path.Combine(RepoRoot.Path, WorkflowRelativePath))); + yaml.Load(reader); + + var root = (YamlMappingNode)yaml.Documents[0].RootNode; + var jobs = (YamlMappingNode)root.Children[new YamlScalarNode("jobs")]; + + return (YamlMappingNode)jobs.Children[new YamlScalarNode("extension_e2e")]; + } + + public static IEnumerable Steps(YamlMappingNode job) + => ((YamlSequenceNode)job.Children[new YamlScalarNode("steps")]).Cast(); + + /// + /// Returns the run: body of the named step, so a test can execute the real script rather + /// than a copy that drifts from it. + /// + public static string StepScript(string stepName) + { + var step = Steps(Job()).Single(candidate => Scalar(candidate, "name") == stepName); + + return Scalar(step, "run") ?? throw new InvalidOperationException($"Step '{stepName}' has no run script."); + } + + public static string? Scalar(YamlMappingNode node, string key) + => node.Children.TryGetValue(new YamlScalarNode(key), out var value) && value is YamlScalarNode scalar + ? scalar.Value + : null; +} diff --git a/tests/Infrastructure.Tests/Pipelines/ExtensionE2eWorkflowTests.cs b/tests/Infrastructure.Tests/Pipelines/ExtensionE2eWorkflowTests.cs index 5213e5f84af..c41f6c0b3bb 100644 --- a/tests/Infrastructure.Tests/Pipelines/ExtensionE2eWorkflowTests.cs +++ b/tests/Infrastructure.Tests/Pipelines/ExtensionE2eWorkflowTests.cs @@ -14,9 +14,7 @@ namespace Infrastructure.Tests.Pipelines; /// public sealed class ExtensionE2eWorkflowTests { - private const string WorkflowRelativePath = ".github/workflows/extension-e2e-tests.yml"; private const string CallerWorkflowRelativePath = ".github/workflows/tests.yml"; - [Fact] public void AdvisoryShardRowsRemainIssueTrackedWithoutWeakeningRunnerStep() { @@ -40,6 +38,13 @@ public void AdvisoryShardRowsRemainIssueTrackedWithoutWeakeningRunnerStep() var environment = (YamlMappingNode)runSuiteStep.Children[new YamlScalarNode("env")]; Assert.False(environment.Children.ContainsKey(new YamlScalarNode("ASPIRE_EXTENSION_E2E_ALLOW_TEST_FAILURE"))); Assert.Equal("${{ matrix.advisoryIssue }}", Scalar(environment, "ASPIRE_EXTENSION_E2E_ADVISORY_ISSUE")); + + var prepareCliStep = Assert.Single(steps, step => Scalar(step, "name") == "Prepare Aspire CLI and package hive"); + var prepareCliScript = Scalar(prepareCliStep, "run") ?? string.Empty; + Assert.Contains( + "\"ASPIRE_DCP_PATH=$($dcp.Directory.FullName)\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append", + prepareCliScript, + StringComparison.Ordinal); } [Fact] @@ -52,7 +57,35 @@ public void SelectedExtensionE2eWorkflowMustRun() var root = (YamlMappingNode)yaml.Documents[0].RootNode; var jobs = (YamlMappingNode)root.Children[new YamlScalarNode("jobs")]; var extensionE2eJob = (YamlMappingNode)jobs.Children[new YamlScalarNode("extension_e2e_tests")]; - Assert.Equal("${{ needs.setup_for_tests.outputs.run_extension_e2e == 'true' }}", Scalar(extensionE2eJob, "if")); + + // The condition is a folded block, so compare on collapsed whitespace rather than the layout. + var e2eCondition = string.Join(' ', (Scalar(extensionE2eJob, "if") ?? string.Empty).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + // Selection still decides whether the shards run at all. + Assert.Contains("needs.setup_for_tests.outputs.run_extension_e2e == 'true'", e2eCondition, StringComparison.Ordinal); + + // !cancelled() rather than success(): extension_tests_win is in `needs` only because it + // uploads the VSIX the shards install, and its packaging steps carry the same condition, so a + // unit-test failure must not take the E2E signal with it. Assert the job's result is not + // consulted at all -- reintroducing it is the regression this pins. + Assert.StartsWith("${{ !cancelled() &&", e2eCondition, StringComparison.Ordinal); + Assert.DoesNotContain("needs.extension_tests_win.result", e2eCondition, StringComparison.Ordinal); + + // The artifact producers stay required by result: without them every shard fails on a + // download, which reports nothing useful about the extension. + foreach (var producer in new[] { "build_packages", "build_cli_archive_linux", "build_cli_archive_windows", "extension_bootstrap_linux" }) + { + Assert.Contains($"needs.{producer}.result == 'success'", e2eCondition, StringComparison.Ordinal); + } + + // The VSIX the shards install is published even when the unit tests fail; without this the + // decoupling above buys nothing because the artifact would never exist. + var extensionUnitJob = (YamlMappingNode)jobs.Children[new YamlScalarNode("extension_tests_win")]; + var unitSteps = ((YamlSequenceNode)extensionUnitJob.Children[new YamlScalarNode("steps")]).Cast().ToList(); + var uploadStep = Assert.Single(unitSteps, step => Scalar(step, "name") == "Upload VSIX"); + Assert.Contains("!cancelled()", Scalar(uploadStep, "if") ?? string.Empty, StringComparison.Ordinal); + var packageStep = Assert.Single(unitSteps, step => Scalar(step, "name") == "Package VSIX"); + Assert.Contains("!cancelled()", Scalar(packageStep, "if") ?? string.Empty, StringComparison.Ordinal); var resultsJob = (YamlMappingNode)jobs.Children[new YamlScalarNode("results")]; var steps = (YamlSequenceNode)resultsJob.Children[new YamlScalarNode("steps")]; @@ -62,17 +95,7 @@ public void SelectedExtensionE2eWorkflowMustRun() Assert.Equal(2, condition?.Split(unexpectedSkipCheck, StringSplitOptions.None).Length - 1); } - private static YamlMappingNode LoadExtensionE2eJob() - { - var yaml = new YamlStream(); - using var reader = new StringReader(File.ReadAllText(Path.Combine(RepoRoot.Path, WorkflowRelativePath))); - yaml.Load(reader); - - var root = (YamlMappingNode)yaml.Documents[0].RootNode; - var jobs = (YamlMappingNode)root.Children[new YamlScalarNode("jobs")]; - - return (YamlMappingNode)jobs.Children[new YamlScalarNode("extension_e2e")]; - } + private static YamlMappingNode LoadExtensionE2eJob() => ExtensionE2eWorkflow.Job(); private static IEnumerable MatrixIncludeRows(YamlMappingNode job) { diff --git a/tests/Infrastructure.Tests/Pipelines/NixCliPackageTests.cs b/tests/Infrastructure.Tests/Pipelines/NixCliPackageTests.cs index feb5aae182a..158ed272014 100644 --- a/tests/Infrastructure.Tests/Pipelines/NixCliPackageTests.cs +++ b/tests/Infrastructure.Tests/Pipelines/NixCliPackageTests.cs @@ -111,6 +111,89 @@ await File.WriteAllTextAsync(curlPath, """ } } + [Fact] + [RequiresTools(["bash", "base64", "xxd"])] + public async Task UpdateVersionsUsesSuppliedSha512OverridesWithoutReadingRelease() + { + var outputPath = Path.Combine(_workspace.Path, "versions.json"); + var fakeBinPath = Path.Combine(_workspace.Path, "bin"); + Directory.CreateDirectory(fakeBinPath); + + // A curl stub that always fails. Offline mode (--sha512 supplied) must + // never invoke curl, so its presence here proves no network read happens. + var curlPath = Path.Combine(fakeBinPath, "curl"); + await File.WriteAllTextAsync(curlPath, """ + #!/usr/bin/env bash + echo 'curl should not be called in offline mode' >&2 + exit 1 + """); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(curlPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + + // Distinct digest per RID so a mismapped override would fail the assert. + var hexByRid = new Dictionary(StringComparer.Ordinal) + { + ["osx-arm64"] = new string('a', 128), + ["osx-x64"] = new string('b', 128), + ["linux-arm64"] = new string('c', 128), + ["linux-x64"] = new string('d', 128), + }; + + var arguments = new List { "--version", "13.4.0", "--output-path", outputPath }; + foreach (var (rid, hex) in hexByRid) + { + arguments.Add("--sha512"); + arguments.Add($"{rid}={hex}"); + } + + var result = await RunBashAsync( + Path.Combine(RepoRoot.Path, "eng", "nix", "update-versions.sh"), + arguments.ToArray(), + new Dictionary + { + ["PATH"] = fakeBinPath + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH") + }); + + Assert.True(result.ExitCode == 0, $"Expected update-versions.sh to succeed in offline mode.{Environment.NewLine}{result.Output}"); + + var manifest = await ReadJsonObjectAsync(outputPath); + var systems = GetRequiredObject(manifest, "systems"); + + foreach (var (system, rid) in s_expectedSystems) + { + var entry = GetRequiredObject(systems, system); + var expectedHash = "sha512-" + Convert.ToBase64String(Convert.FromHexString(hexByRid[rid])); + Assert.Equal(expectedHash, GetRequiredString(entry, "hash")); + } + } + + [Fact] + [RequiresTools(["bash", "base64", "xxd"])] + public async Task UpdateVersionsRejectsPartialSha512Overrides() + { + var outputPath = Path.Combine(_workspace.Path, "versions.json"); + + // Supply only one platform's digest. Offline mode requires all four, so + // the script must fail rather than silently curl the missing three. + var result = await RunBashAsync( + Path.Combine(RepoRoot.Path, "eng", "nix", "update-versions.sh"), + ["--version", "13.4.0", "--output-path", outputPath, "--sha512", $"osx-arm64={new string('a', 128)}"], + new Dictionary + { + ["PATH"] = Environment.GetEnvironmentVariable("PATH") + }); + + Assert.False(result.ExitCode == 0, $"Expected update-versions.sh to fail with a partial override set.{Environment.NewLine}{result.Output}"); + Assert.Contains("no --sha512 override supplied for rid", result.Output, StringComparison.Ordinal); + Assert.False(File.Exists(outputPath), "versions.json must not be written when the override set is incomplete."); + } + [Fact] public async Task FlakeLockPinsNixpkgsInput() { diff --git a/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs b/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs index 6f5421106ee..11fe97572f4 100644 --- a/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs +++ b/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs @@ -80,7 +80,7 @@ public async Task RoutesMicroBuildPublishAuthPluginToDncengFeedOrDisablesIt() // 2) templateContext.mb.publish.feedSource: (for the publishing job) // Both must be present in this pipeline: // - non-publishing jobs (PrepareJob, WinGetJob, DispatchGitHubTasksJob, - // PublishReleaseAssetsJob, UpdateNixPackageJob, HomebrewValidateJob) -> enabled: false + // PublishReleaseAssetsJob, UpdateNixPackageJob) -> enabled: false // - ReleaseJob (the only job that actually publishes) -> feedSource = dnceng mirror Assert.Contains("enabled: false", pipeline); Assert.Contains( @@ -581,7 +581,6 @@ public async Task ExtensionReleaseInstructionsSkipNonExtensionReleaseLegs() Assert.Contains("| \\`SkipNpmPointerPublish\\` | \\`true\\` |", instructions); Assert.Contains("| \\`SkipChannelPromotion\\` | \\`true\\` |", instructions); Assert.Contains("| \\`SkipWinGetPublish\\` | \\`true\\` |", instructions); - Assert.Contains("| \\`SkipHomebrewValidation\\` | \\`true\\` |", instructions); Assert.Contains("| \\`SkipGitHubTasks\\` | \\`true\\` |", instructions); Assert.Contains("| \\`SkipReleaseAssets\\` | \\`true\\` |", instructions); Assert.Contains("| \\`SkipNixPackageUpdate\\` | \\`true\\` |", instructions); diff --git a/tests/Infrastructure.Tests/PowerShellScripts/DownloadNativeArchivesTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/DownloadNativeArchivesTests.cs index 06d62473102..1684587b5e4 100644 --- a/tests/Infrastructure.Tests/PowerShellScripts/DownloadNativeArchivesTests.cs +++ b/tests/Infrastructure.Tests/PowerShellScripts/DownloadNativeArchivesTests.cs @@ -108,7 +108,9 @@ public async Task DownloadsAndExtractsMatchingEntries_PreservingArtifactPathComp { ("Release/Shipping/aspire-cli-linux-x64-13.4.0.tar.gz", "tar-content"), ("Release/Shipping/Aspire.Cli.linux-x64.13.4.0.nupkg", "nupkg-content"), + ("Release/Shipping/Aspire.Dashboard.Sdk.linux-x64.13.4.0.nupkg", "dashboard-nupkg-content"), ("Release/Shipping/microsoft-aspire-cli-linux-x64-13.4.0.tgz", "tgz-content"), + ("Release/DashboardArtifacts/linux-x64/aspire-dashboard-linux-x64.zip", "dashboard-zip-content"), ("Release/Shipping/aspire-cli-linux-x64-13.4.0.tar.gz.sha512", "ignored"), ("Release/Shipping/SomeRandom.txt", "ignored"), }); @@ -135,7 +137,7 @@ public async Task DownloadsAndExtractsMatchingEntries_PreservingArtifactPathComp // NupkgsTargetDir for stage-native-cli-tool-packages.ps1 to consume). Assert.Contains("native_archives_linux_x64", result.Output); Assert.Contains("native_archives_win_x64", result.Output); - Assert.Contains("archives=1 nupkgs=2", result.Output); + Assert.Contains("archives=1 nupkgs=3 dashboardArchives=1", result.Output); var extractedArchives = Directory.GetFiles(archivesDir, "*", SearchOption.AllDirectories) .Select(p => Path.GetRelativePath(archivesDir, p).Replace(Path.DirectorySeparatorChar, '/')) @@ -157,11 +159,20 @@ public async Task DownloadsAndExtractsMatchingEntries_PreservingArtifactPathComp new[] { "native_archives_linux_x64/Release/Shipping/Aspire.Cli.linux-x64.13.4.0.nupkg", + "native_archives_linux_x64/Release/Shipping/Aspire.Dashboard.Sdk.linux-x64.13.4.0.nupkg", "native_archives_linux_x64/Release/Shipping/microsoft-aspire-cli-linux-x64-13.4.0.tgz", "native_archives_win_x64/Release/Shipping/Aspire.Cli.win-x64.13.4.0.nupkg", "native_archives_win_x64/Release/Shipping/microsoft-aspire-cli-win-x64-13.4.0.tgz", }, extractedNupkgs); + + var dashboardArtifactsDir = Path.Combine(Path.GetDirectoryName(archivesDir)!, "dashboard-artifacts"); + var extractedDashboardArtifacts = Directory.GetFiles(dashboardArtifactsDir, "*", SearchOption.AllDirectories) + .Select(p => Path.GetRelativePath(dashboardArtifactsDir, p).Replace(Path.DirectorySeparatorChar, '/')) + .ToArray(); + Assert.Equal( + ["native_archives_linux_x64/Release/DashboardArtifacts/linux-x64/aspire-dashboard-linux-x64.zip"], + extractedDashboardArtifacts); } [Fact] @@ -401,6 +412,7 @@ private async Task RunScript( "-BuildId", $"\"{buildId}\"", "-ArchivesTargetDir", $"\"{archivesTargetDir}\"", "-NupkgsTargetDir", $"\"{nupkgsTargetDir}\"", + "-DashboardArtifactsTargetDir", $"\"{Path.Combine(Path.GetDirectoryName(archivesTargetDir)!, "dashboard-artifacts")}\"", // Keep tests fast: never sleep between retries. The mock server // returns its failures instantly, so backoff time is pure overhead. "-RetryBaseDelaySeconds", retryBaseDelaySeconds.ToString(), diff --git a/tests/Infrastructure.Tests/TestTriggerMap/SelectTestsWorkflowTests.cs b/tests/Infrastructure.Tests/TestTriggerMap/SelectTestsWorkflowTests.cs index 7888b058f89..1dd537a87e2 100644 --- a/tests/Infrastructure.Tests/TestTriggerMap/SelectTestsWorkflowTests.cs +++ b/tests/Infrastructure.Tests/TestTriggerMap/SelectTestsWorkflowTests.cs @@ -32,6 +32,22 @@ public void SelectTestsActionGatesForceAllOnBooleanInputNotPrBody() Assert.DoesNotContain("full ci", action); } + // The repo SDK can move ahead of the target framework used by SelectTests. The minimal bootstrap + // must install that runtime through Arcade's wrapper using the wrapper's single-dash switches; + // unsupported raw dotnet-install switches make the setup job exit before test selection runs. + [Fact] + public void SelectTestsActionInstallsPinnedRuntimeWithArcadeWrapperArguments() + { + var action = File.ReadAllText(SelectTestsActionPath); + + Assert.Contains("", File.ReadAllText(VersionsPropsPath)); + Assert.Contains("./eng/common/dotnet-install.sh", action); + Assert.Contains("-runtime dotnet", action); + Assert.Contains("-version \"$runtime_version\"", action); + Assert.DoesNotContain("--install-dir", action); + Assert.DoesNotContain("--skip-non-versioned-files", action); + } + // tests.yml must compute forceAll from the presence of the 'run-full-ci' label on the PR, read from // the event-payload snapshot. If the label name or the contains() expression drifts, the kill // switch silently stops working (it would just never force-all), so pin the exact wiring. @@ -45,6 +61,23 @@ public void TestsWorkflowComputesForceAllFromFullCiLabel() testsYml); } + // The native ARM64 MSVC linker in the VS 2022 image fails on the Native AOT Dashboard object + // with LNK1322. Keep win-arm64 on the newer VS 2026 ARM image in both the PR override and the + // reusable workflow default so scheduled/direct callers do not regress to windows-11-arm. + [Fact] + public void WindowsArm64NativeArchiveUsesVs2026ArmRunner() + { + var testsYml = File.ReadAllText(TestsWorkflowPath); + var nativeArchivesYml = File.ReadAllText(BuildCliNativeArchivesWorkflowPath); + const string expectedTarget = "{\"os\": \"windows-latest\", \"runner\": \"windows-11-vs2026-arm\", \"rids\": \"win-arm64\"}"; + + Assert.Contains(expectedTarget, testsYml); + Assert.Contains(expectedTarget, nativeArchivesYml); + Assert.DoesNotContain("\"runner\": \"windows-11-arm\", \"rids\": \"win-arm64\"", testsYml); + Assert.DoesNotContain("\"runner\": \"windows-11-arm\", \"rids\": \"win-arm64\"", nativeArchivesYml); + Assert.Contains("if: ${{ matrix.targets.rids != 'osx-x64' }}", nativeArchivesYml); + } + // The comment_selection job posts one comment per pushed commit (createComment for a new commit, // updateComment for a re-run of the same commit) and collapses superseded comments with // minimizeComment -- it must never delete. This guard fails if deletion is introduced or the @@ -138,6 +171,12 @@ private static string SelectTestsActionPath private static string TestsWorkflowPath => Path.Combine(RepoRoot.Path, ".github", "workflows", "tests.yml"); + private static string VersionsPropsPath + => Path.Combine(RepoRoot.Path, "eng", "Versions.props"); + + private static string BuildCliNativeArchivesWorkflowPath + => Path.Combine(RepoRoot.Path, ".github", "workflows", "build-cli-native-archives.yml"); + private static string ExtractCommentSelectionJob() { var testsYml = File.ReadAllText(TestsWorkflowPath); diff --git a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs index 46d49c8d449..ef417bb0d5e 100644 --- a/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs +++ b/tests/Infrastructure.Tests/TestTriggerMap/TestTriggerMapTests.cs @@ -369,7 +369,8 @@ public void EachGatedJobConditionUsesItsOwnSelectionBoolean() // This list is hand-maintained intent (like EveryJobTargetResolvesToExistingWorkflowOrJob's // job-id mapping): a new gated job must be added here too. The check is substring containment, // so it catches a job NOT referencing its own var; it intentionally tolerates extra vars -- the - // extension jobs additionally OR-in run_extension_e2e for need-propagation. + // extension jobs additionally OR-in run_extension_e2e because extension_e2e_tests needs the + // VSIX and the corepack bootstrap they produce. var testsYml = File.ReadAllText(Path.Combine(RepoRoot.Path, ".github", "workflows", "tests.yml")); var bindings = new[] diff --git a/tests/Infrastructure.Tests/WorkflowScripts/ExtensionReleaseWorkflowTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/ExtensionReleaseWorkflowTests.cs index 255cef04df5..e2673aaab39 100644 --- a/tests/Infrastructure.Tests/WorkflowScripts/ExtensionReleaseWorkflowTests.cs +++ b/tests/Infrastructure.Tests/WorkflowScripts/ExtensionReleaseWorkflowTests.cs @@ -62,31 +62,27 @@ public void ExtensionChangelogPromptRequiresLocalFullRangeEnumerationAndRejectsP prompt, StringComparison.Ordinal); Assert.Contains( - "Use local git as the authoritative source for the exact candidate set:", + "`${RUNNER_TEMP}/gh-aw/extension-changelog-candidates.tsv`", prompt, StringComparison.Ordinal); Assert.Contains( - "A deterministic pre-agent step already preloaded the authoritative marker range", + "A deterministic pre-agent step already validated and materialized the", prompt, StringComparison.Ordinal); Assert.Contains( - "and history into this checkout.", + "Do not run `git` or", prompt, StringComparison.Ordinal); Assert.Contains( - "Do not perform any network fetch in the agent", + "perform any network fetch in the agent step.", prompt, StringComparison.Ordinal); Assert.Contains( - "step.", + "Each line contains the 40-character commit SHA", prompt, StringComparison.Ordinal); Assert.Contains( - "`git log --format='%H%x09%s' --no-merges .. -- extension/`", - prompt, - StringComparison.Ordinal); - Assert.Contains( - "Count the candidates produced by that command", + "Count every line and keep that exact candidate count", prompt, StringComparison.Ordinal); Assert.Contains( @@ -114,11 +110,11 @@ public void ExtensionChangelogPromptRequiresLocalFullRangeEnumerationAndRejectsP prompt, StringComparison.Ordinal); Assert.Contains( - "The local git range still cannot be enumerated after the pre-agent history", + "The authoritative candidate file is missing or unreadable after pre-agent", prompt, StringComparison.Ordinal); Assert.Contains( - "preload, or required enrichment searches fail outright", + "materialization, or required enrichment searches fail outright", prompt, StringComparison.Ordinal); Assert.Contains( @@ -142,6 +138,26 @@ public void ExtensionChangelogPromptRequiresLocalFullRangeEnumerationAndRejectsP Assert.False( prompt.Contains("after explicit fetch", StringComparison.Ordinal), "The prompt must not describe failure handling in terms of an agent-side fetch that is no longer allowed."); + Assert.False( + prompt.Contains("Use local git as the authoritative source", StringComparison.Ordinal), + "The prompt must consume the pre-agent candidate file instead of invoking Git."); + Assert.False( + prompt.Contains("`git log", StringComparison.Ordinal), + "The prompt must not expose broad Git execution to the agent."); + Assert.DoesNotContain( + "/tmp/gh-aw/agent/extension-changelog-candidates.tsv", + prompt, + StringComparison.Ordinal); + var tools = GetSection(prompt, "^tools:", "^pre-agent-steps:"); + Assert.DoesNotContain("\"git\"", tools, StringComparison.Ordinal); + Assert.Contains( + "--mount \"${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro\"", + compiledWorkflow, + StringComparison.Ordinal); + Assert.DoesNotContain( + "/tmp/gh-aw/agent/extension-changelog-candidates.tsv", + compiledWorkflow, + StringComparison.Ordinal); Assert.Contains("{{#runtime-import .github/workflows/extension-changelog.md}}", compiledWorkflow, StringComparison.Ordinal); } @@ -216,10 +232,7 @@ public void ExtensionReleaseWorkflowChecksOutHelpersFromWorkflowDefinitionCommit public void CompiledWorkflowPreloadsAuthoritativeRangeBeforeCleaningCredentials() { var workflow = File.ReadAllText(s_changelogWorkflowLockPath); - var preloadStep = GetSection( - workflow, - "^ - name: Preload authoritative marker range for local changelog enumeration", - "^ - name: Download container images"); + var preloadScript = GetCompiledWorkflowRunScript("Preload authoritative marker range for local changelog enumeration"); var configureGitCredentialsIndex = FindRequiredText(workflow, "Configure Git credentials"); var checkoutPrBranchIndex = FindRequiredText(workflow, "Checkout PR branch"); @@ -235,12 +248,17 @@ public void CompiledWorkflowPreloadsAuthoritativeRangeBeforeCleaningCredentials( Assert.True( preloadIndex < cleanCredentialsIndex, "The authoritative range preload must finish before gh-aw removes Git credentials."); - Assert.Contains("extension/CHANGELOG.md", preloadStep, StringComparison.Ordinal); - Assert.Contains("aspire-ext-changelog", preloadStep, StringComparison.Ordinal); - Assert.Contains("git fetch --no-tags", preloadStep, StringComparison.Ordinal); - Assert.Contains("git log --format='%H%x09%s' --no-merges", preloadStep, StringComparison.Ordinal); - Assert.Contains("${FROM_SHA}..${TO_SHA}", preloadStep, StringComparison.Ordinal); - Assert.Contains("-- extension/ >/dev/null 2>&1", preloadStep, StringComparison.Ordinal); + Assert.Contains("extension/CHANGELOG.md", preloadScript, StringComparison.Ordinal); + Assert.Contains("aspire-ext-changelog", preloadScript, StringComparison.Ordinal); + Assert.Contains("git fetch --no-tags", preloadScript, StringComparison.Ordinal); + Assert.Contains( + "CANDIDATES_FILE=\"${RUNNER_TEMP}/gh-aw/extension-changelog-candidates.tsv\"", + preloadScript, + StringComparison.Ordinal); + Assert.Contains("git log --format='%H%x09%s' --no-merges", preloadScript, StringComparison.Ordinal); + Assert.Contains("${FROM_SHA}..${TO_SHA}", preloadScript, StringComparison.Ordinal); + Assert.Contains("-- extension/ > \"${CANDIDATES_FILE}\"", preloadScript, StringComparison.Ordinal); + Assert.Contains("candidate_count=\"$(wc -l < \"${CANDIDATES_FILE}\")\"", preloadScript, StringComparison.Ordinal); } [Fact] diff --git a/tests/Infrastructure.Tests/WorkflowScripts/PrDocsCheckWorkflowTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/PrDocsCheckWorkflowTests.cs index d7296019c56..edd5d038b62 100644 --- a/tests/Infrastructure.Tests/WorkflowScripts/PrDocsCheckWorkflowTests.cs +++ b/tests/Infrastructure.Tests/WorkflowScripts/PrDocsCheckWorkflowTests.cs @@ -11,7 +11,7 @@ namespace Infrastructure.Tests; public sealed class PrDocsCheckWorkflowTests(ITestOutputHelper testOutput) { [Fact] - public void SourceWorkflowBridgesCanonicalBaseIntoSafeOutputs() + public void SourceWorkflowBridgesCurrentAndLegacyCanonicalBaseIntoSafeOutputs() { var workflow = ReadWorkflow("pr-docs-check.md"); var safeOutputs = GetSection(workflow, "^safe-outputs:", "^pre-agent-steps:"); @@ -24,12 +24,17 @@ public void SourceWorkflowBridgesCanonicalBaseIntoSafeOutputs() StringComparison.Ordinal); Assert.Contains("/tmp/gh-aw/agent_output.json", customSteps, StringComparison.Ordinal); Assert.Contains("len(create_items) != 1", customSteps, StringComparison.Ordinal); - Assert.Contains("create_items[0].get(\"base_branch\")", customSteps, StringComparison.Ordinal); + Assert.Contains("has_base = \"base\" in create_item", customSteps, StringComparison.Ordinal); + Assert.Contains("has_base_branch = \"base_branch\" in create_item", customSteps, StringComparison.Ordinal); + Assert.Contains("base = create_item.get(\"base\")", customSteps, StringComparison.Ordinal); + Assert.Contains("base_branch = create_item.get(\"base_branch\")", customSteps, StringComparison.Ordinal); + Assert.Contains("if has_base and has_base_branch and base != base_branch:", customSteps, StringComparison.Ordinal); + Assert.Contains("target_branch = base if has_base else base_branch", customSteps, StringComparison.Ordinal); Assert.Contains( - "re.fullmatch(r\"main|release/[0-9]+\\.[0-9]+(?:\\.[0-9]+)?\", base_branch)", + "re.fullmatch(r\"main|release/[0-9]+\\.[0-9]+(?:\\.[0-9]+)?\", target_branch)", customSteps, StringComparison.Ordinal); - Assert.Contains("github_output.write(f\"branch={base_branch}\\n\")", customSteps, StringComparison.Ordinal); + Assert.Contains("github_output.write(f\"branch={target_branch}\\n\")", customSteps, StringComparison.Ordinal); Assert.Empty(Regex.Matches(customSteps, "actions/checkout@", RegexOptions.CultureInvariant).Cast()); Assert.Contains( "base-branch: ${{ steps.resolve-target.outputs.branch || 'main' }}", @@ -57,6 +62,9 @@ public void CompiledWorkflowBridgesCanonicalBaseBeforeSafeOutputApplication() safeOutputs, StringComparison.Ordinal); Assert.Contains("/tmp/gh-aw/agent_output.json", safeOutputs, StringComparison.Ordinal); + Assert.Contains("base = create_item.get(\"base\")", safeOutputs, StringComparison.Ordinal); + Assert.Contains("base_branch = create_item.get(\"base_branch\")", safeOutputs, StringComparison.Ordinal); + Assert.Contains("if has_base and has_base_branch and base != base_branch:", safeOutputs, StringComparison.Ordinal); Assert.Contains( "\\\"base_branch\\\":\\\"${{ steps.resolve-target.outputs.branch || 'main' }}\\\"", safeOutputs, @@ -67,6 +75,18 @@ public void CompiledWorkflowBridgesCanonicalBaseBeforeSafeOutputApplication() _ => { }); } + [Fact] + public void OutcomeValidatorReadsCurrentAndLegacyCanonicalBase() + { + var validator = File.ReadAllText( + Path.Combine(RepoRoot.Path, ".github", "workflows", "pr-docs-check", "validate_outcome.py")); + + Assert.Contains("create_pull_request.get(\"base\")", validator, StringComparison.Ordinal); + Assert.Contains("create_pull_request.get(\"base_branch\")", validator, StringComparison.Ordinal); + Assert.Contains("if base != base_branch:", validator, StringComparison.Ordinal); + Assert.Contains("_get_create_pull_request_target(payload)", validator, StringComparison.Ordinal); + } + [Fact] public void SourceAndCompiledWorkflowGuardDraftedPrBase() { diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/Go/apphost.go b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Go/apphost.go new file mode 100644 index 00000000000..44c575ae246 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Go/apphost.go @@ -0,0 +1,57 @@ +package main + +import ( + "log" + + "apphost/modules/aspire" +) + +// A resource's launch mode is exclusive — a prebuilt JAR, a Maven goal, or a Gradle task — and +// the two build steps exclude each other, so the exported surface is spread across four apps. +func main() { + builder, err := aspire.CreateBuilder() + if err != nil { + log.Fatal(aspire.FormatError(err)) + } + + // Maven-launched app with an explicit main class and JVM tuning + catalog := builder.AddJavaApp("catalog", "../java-catalog"). + WithMavenGoal("spring-boot:run", []string{"-Dspring-boot.run.profiles=dev"}). + WithMainClass("com.example.catalog.CatalogApplication"). + WithJvmArgs([]string{"-Xmx512m", "-XX:+UseZGC"}) + if err = catalog.Err(); err != nil { + log.Fatal(aspire.FormatError(err)) + } + + // Prebuilt JAR produced by a Maven build, instrumented with the OpenTelemetry Java agent + worker := builder.AddJavaAppWithJar("worker", "../java-worker", "target/worker.jar", []string{"--spring.profiles.active=ci"}). + WithMavenBuild([]string{"clean", "package", "-DskipTests"}). + WithJarArtifact("target/worker.jar"). + WithOtelAgent("../agents/opentelemetry-javaagent.jar") + if err = worker.Err(); err != nil { + log.Fatal(aspire.FormatError(err)) + } + + // Gradle-launched app whose wrapper lives above the app directory + gateway := builder.AddJavaApp("gateway", "../java-gateway"). + WithGradleTask("bootRun", []string{"--args=--server.port=0"}). + WithWrapperPath("../gradlew") + if err = gateway.Err(); err != nil { + log.Fatal(aspire.FormatError(err)) + } + + // Prebuilt JAR produced by a Gradle build, so the task only has to assemble it + reports := builder.AddJavaAppWithJar("reports", "../java-reports", "build/libs/reports.jar", []string{}). + WithGradleBuild([]string{"clean", "bootJar"}) + if err = reports.Err(); err != nil { + log.Fatal(aspire.FormatError(err)) + } + + app, err := builder.Build() + if err != nil { + log.Fatal(aspire.FormatError(err)) + } + if err := app.Run(); err != nil { + log.Fatal(aspire.FormatError(err)) + } +} diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/Go/aspire.config.json b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Go/aspire.config.json new file mode 100644 index 00000000000..40b7976ef87 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Go/aspire.config.json @@ -0,0 +1,12 @@ +{ + "appHost": { + "path": "apphost.go", + "language": "go" + }, + "features": { + "experimentalPolyglot:go": true + }, + "packages": { + "Aspire.Hosting.Java": "" + } +} diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/Go/go.mod b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Go/go.mod new file mode 100644 index 00000000000..b152cea7778 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Go/go.mod @@ -0,0 +1,7 @@ +module apphost + +go 1.26 + +require apphost/modules/aspire v0.0.0 + +replace apphost/modules/aspire => ./.aspire/modules diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/Java/AppHost.java b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Java/AppHost.java new file mode 100644 index 00000000000..6839bd7f1f9 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Java/AppHost.java @@ -0,0 +1,31 @@ +import aspire.*; + +// A resource's launch mode is exclusive — a prebuilt JAR, a Maven goal, or a Gradle task — and +// the two build steps exclude each other, so the exported surface is spread across four apps. +void main() throws Exception { + var builder = DistributedApplication.CreateBuilder(); + + // Maven-launched app with an explicit main class and JVM tuning + var catalog = builder.addJavaApp("catalog", "../java-catalog") + .withMavenGoal("spring-boot:run", new String[] { "-Dspring-boot.run.profiles=dev" }) + .withMainClass("com.example.catalog.CatalogApplication") + .withJvmArgs(new String[] { "-Xmx512m", "-XX:+UseZGC" }); + + // Prebuilt JAR produced by a Maven build, instrumented with the OpenTelemetry Java agent + var worker = builder.addJavaAppWithJar("worker", "../java-worker", "target/worker.jar", + new String[] { "--spring.profiles.active=ci" }) + .withMavenBuild(new String[] { "clean", "package", "-DskipTests" }) + .withJarArtifact("target/worker.jar") + .withOtelAgent("../agents/opentelemetry-javaagent.jar"); + + // Gradle-launched app whose wrapper lives above the app directory + var gateway = builder.addJavaApp("gateway", "../java-gateway") + .withGradleTask("bootRun", new String[] { "--args=--server.port=0" }) + .withWrapperPath("../gradlew"); + + // Prebuilt JAR produced by a Gradle build, so the task only has to assemble it + var reports = builder.addJavaAppWithJar("reports", "../java-reports", "build/libs/reports.jar", new String[0]) + .withGradleBuild(new String[] { "clean", "bootJar" }); + + builder.build().run(); + } diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/Java/aspire.config.json b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Java/aspire.config.json new file mode 100644 index 00000000000..be0dbe86ad7 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Java/aspire.config.json @@ -0,0 +1,12 @@ +{ + "appHost": { + "path": "AppHost.java", + "language": "java" + }, + "features": { + "experimentalPolyglot:java": true + }, + "packages": { + "Aspire.Hosting.Java": "" + } +} diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/apphost.py b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/apphost.py new file mode 100644 index 00000000000..962f97b2e4f --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/apphost.py @@ -0,0 +1,29 @@ +from aspire_app import create_builder + + +# A resource's launch mode is exclusive - a prebuilt JAR, a Maven goal, or a Gradle task - and +# the two build steps exclude each other, so the exported surface is spread across four apps. +with create_builder() as builder: + # Maven-launched app with an explicit main class and JVM tuning + catalog = builder.add_java_app("catalog", "../java-catalog") + catalog.with_maven_goal("spring-boot:run", ["-Dspring-boot.run.profiles=dev"]) + catalog.with_main_class("com.example.catalog.CatalogApplication") + catalog.with_jvm_args(["-Xmx512m", "-XX:+UseZGC"]) + + # Prebuilt JAR produced by a Maven build, instrumented with the OpenTelemetry Java agent + worker = builder.add_java_app_with_jar("worker", "../java-worker", "target/worker.jar", + args=["--spring.profiles.active=ci"]) + worker.with_maven_build(["clean", "package", "-DskipTests"]) + worker.with_jar_artifact("target/worker.jar") + worker.with_otel_agent("../agents/opentelemetry-javaagent.jar") + + # Gradle-launched app whose wrapper lives above the app directory + gateway = builder.add_java_app("gateway", "../java-gateway") + gateway.with_gradle_task("bootRun", ["--args=--server.port=0"]) + gateway.with_wrapper_path("../gradlew") + + # Prebuilt JAR produced by a Gradle build, so the task only has to assemble it + reports = builder.add_java_app_with_jar("reports", "../java-reports", "build/libs/reports.jar") + reports.with_gradle_build(["clean", "bootJar"]) + + builder.run() diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/apphost_requirements.txt b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/apphost_requirements.txt new file mode 100644 index 00000000000..f4a483a0459 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/apphost_requirements.txt @@ -0,0 +1,3 @@ +# Aspire Python AppHost requirements +# This file is used when uv is not available and pip is used instead. +-e .aspire/modules diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/aspire.config.json b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/aspire.config.json new file mode 100644 index 00000000000..573892b9767 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/aspire.config.json @@ -0,0 +1,12 @@ +{ + "appHost": { + "path": "apphost.py", + "language": "python" + }, + "features": { + "experimentalPolyglot:python": true + }, + "packages": { + "Aspire.Hosting.Java": "" + } +} diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/pylock.apphost.toml b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/pylock.apphost.toml new file mode 100644 index 00000000000..9917069ad97 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/Python/pylock.apphost.toml @@ -0,0 +1,10 @@ +created-by = 'Aspire' +lock-version = '1.0' +requires-python = '>=3.11' + +[[packages]] +name = "aspire_app" +editable = true + +[packages.directory] +path = ".aspire/modules" diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/apphost.mts b/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/apphost.mts new file mode 100644 index 00000000000..31d3a61ab5c --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/apphost.mts @@ -0,0 +1,29 @@ +import { createBuilder } from './.aspire/modules/aspire.mjs'; + +const builder = await createBuilder(); + +// A resource's launch mode is exclusive - a prebuilt JAR, a Maven goal, or a Gradle task - and +// the two build steps exclude each other, so the exported surface is spread across four apps. + +// Maven-launched app with an explicit main class and JVM tuning +const catalog = await builder.addJavaApp('catalog', '../java-catalog'); +await catalog.withMavenGoal('spring-boot:run', ['-Dspring-boot.run.profiles=dev']); +await catalog.withMainClass('com.example.catalog.CatalogApplication'); +await catalog.withJvmArgs(['-Xmx512m', '-XX:+UseZGC']); + +// Prebuilt JAR produced by a Maven build, instrumented with the OpenTelemetry Java agent +const worker = await builder.addJavaAppWithJar('worker', '../java-worker', 'target/worker.jar', ['--spring.profiles.active=ci']); +await worker.withMavenBuild(['clean', 'package', '-DskipTests']); +await worker.withJarArtifact('target/worker.jar'); +await worker.withOtelAgent('../agents/opentelemetry-javaagent.jar'); + +// Gradle-launched app whose wrapper lives above the app directory +const gateway = await builder.addJavaApp('gateway', '../java-gateway'); +await gateway.withGradleTask('bootRun', ['--args=--server.port=0']); +await gateway.withWrapperPath('../gradlew'); + +// Prebuilt JAR produced by a Gradle build, so the task only has to assemble it +const reports = await builder.addJavaAppWithJar('reports', '../java-reports', 'build/libs/reports.jar', []); +await reports.withGradleBuild(['clean', 'bootJar']); + +await builder.build().run(); diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/aspire.config.json b/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/aspire.config.json new file mode 100644 index 00000000000..061dd8cab93 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/aspire.config.json @@ -0,0 +1,9 @@ +{ + "appHost": { + "path": "apphost.mts", + "language": "typescript/nodejs" + }, + "packages": { + "Aspire.Hosting.Java": "" + } +} diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/package-lock.json b/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/package-lock.json new file mode 100644 index 00000000000..6ebc301f7d8 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/package-lock.json @@ -0,0 +1,938 @@ +{ + "name": "polyglot-typescript-validation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polyglot-typescript-validation", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.22.3", + "typescript": "^5.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha1-egGo0uwvuy2seK2tCbD6eB5Agr4=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha1-cEvSl95tdi3lTqu+r79V9nVqvi8=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha1-tUCifRTkr9BYSWpNvsTT9BTbEQo=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha1-0csWbTSw+/D+irRgpVlPJKN4cB4=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha1-EDSyZFf8iGNo/mG70J9lP2r6jlQ=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha1-ZVVqQyoeTXIDLYIYwZMvzKGkl3I=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha1-LmHgWS+QMNfj2uGO4l68U1kYrvY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha1-yV7CiZWe+AecTcqBeh4sS+Zrm9M=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha1-wJoPZ5F1kqwN6JKpvk04FN69Kmw=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha1-QLIhdd2gYYLz7oFBGGxf8wTEpxc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha1-pYD5xnZ5eDOJHlGfx6EzfIr9jbM=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha1-RkUs8yHcf56Rwvp4Cla7Vuec1os=", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha1-QhGzGE3WYI9T3LIuOfXTTuCIUsg=", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha1-aXhXwqYcubC2u2ZS5AwdxeHKjl0=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha1-0ZKUPrFGpArExkl9DPe+NbmGvwg=", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha1-rOoDVtoODrwI+Xz3ucLkAeHmSNw=", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha1-bww84MtkxTS3DExF7LLBbTTjXf0=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha1-i813B3oNzjN4tXT+2ybSolO3PTY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha1-5/sqAemcgwyU5mI82f77TI+1g0c=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha1-xSkJNy24uG4sVeBaiUADO1Zgo7I=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha1-xCe5vlpkwmL/mn63C1+7qt9EbGw=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha1-3JsUe6yi5sSzyFVxdB70hgpIkJc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha1-zoZtEt8TwV5MmfBzo9Rm9uBkmzo=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha1-dGjjaS0B1inVlB5dg4F7uA+eObQ=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha1-pbwAY/sryrbQ7WPyoVN5WLwmnsY=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha1-EAZO5E9DR7kMmgK0Rrv4CpFjKxI=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha1-70W0Y0ycnZeilq6kEUpfmED5VXg=", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha1-fKfLNAKOPiR/H60wDBV+QqkKH1A=", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/package.json b/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/package.json new file mode 100644 index 00000000000..63290525727 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/package.json @@ -0,0 +1,21 @@ +{ + "name": "polyglot-typescript-validation", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit", + "dev": "tsc --noEmit --watch" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.22.3", + "typescript": "^5.3.0" + }, + "overrides": { + "brace-expansion": "5.0.8" + } +} diff --git a/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/tsconfig.json b/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/tsconfig.json new file mode 100644 index 00000000000..19805ab4872 --- /dev/null +++ b/tests/PolyglotAppHosts/Aspire.Hosting.Java/TypeScript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "apphost.mts", + ".aspire/modules/**/*.mts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index de5634460ce..f76aefae555 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -7,8 +7,41 @@ #pragma warning disable ASPIRECERTIFICATES001 +using var entryPointProbe = TestingAppHostEntryPointProbe.Track(args); + var builder = DistributedApplication.CreateBuilder(args); +if (args.Contains("--clear-apphost-browser-token")) +{ + // The frozen token is the one dashboard setting an AppHost could still change after the testing builder had + // hardened things, because DashboardOptions does not read this key until the application starts. It is kept + // separate from --override-dashboard-testing-defaults so that flag keeps asserting on a token. + builder.Configuration["AppHost:BrowserToken"] = ""; +} + +if (args.Contains("--null-apphost-browser-token")) +{ + // Null and empty are distinct configuration values, but DashboardEventHandlers collapses both to Unsecured + // frontend authentication through string.IsNullOrEmpty, so both need coverage. + builder.Configuration["AppHost:BrowserToken"] = null; +} + +if (args.Contains("--unsecure-apphost-resource-service")) +{ + // The resource service has the same post-construction window as the browser token: DashboardServiceHost does + // not bind this section until the application starts. Downgrading the mode alone is enough, because + // ResourceServiceApiKeyAuthenticationHandler only inspects the API key header while the mode is ApiKey. + builder.Configuration["AppHost:ResourceService:AuthMode"] = "Unsecured"; + builder.Configuration["AppHost:ResourceService:ApiKey"] = ""; +} + +if (args.Contains("--clear-apphost-resource-service-key")) +{ + // Clearing only the key is the other half. ValidateResourceServiceOptions would fail the start in this state, + // so without the restore the application does not come up at all rather than coming up unauthenticated. + builder.Configuration["AppHost:ResourceService:ApiKey"] = ""; +} + builder.Configuration["ConnectionStrings:cs"] = "testconnection"; builder.AddConnectionString("cs"); @@ -58,6 +91,10 @@ await app.StartAsync(); +// Tests use this wait to model a debugger stopped on the next AppHost statement. The application has already +// started, so its hosted services, resources, and dashboard continue running while top-level execution is paused. +await TestingAppHostEntryPointProbe.WaitAtBreakpointAsync(args); + if (args.Contains("--wait-for-healthy")) { // Wait indefinitely until redis becomes healthy. diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs new file mode 100644 index 00000000000..d78baaefc7e --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; + +public static class TestingAppHostEntryPointProbe +{ + private const string ArgumentPrefix = "--entry-point-exit-probe="; + private const string BreakpointArgumentPrefix = "--entry-point-breakpoint-probe="; + private static readonly ConcurrentDictionary s_probes = new(); + private static readonly ConcurrentDictionary s_breakpoints = new(); + + public static Probe Create() + { + var id = Guid.NewGuid().ToString("N"); + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!s_probes.TryAdd(id, exited)) + { + throw new InvalidOperationException($"Could not create entry-point probe '{id}'."); + } + + return new Probe(id, exited.Task); + } + + public static IDisposable Track(string[] args) + { + // The test passes the probe as: + // --entry-point-exit-probe=<32-character GUID> + var argument = args.FirstOrDefault(arg => arg.StartsWith(ArgumentPrefix, StringComparison.Ordinal)); + if (argument is null) + { + return EmptyDisposable.Instance; + } + + var id = argument[ArgumentPrefix.Length..]; + if (!s_probes.TryGetValue(id, out var exited)) + { + throw new InvalidOperationException($"Entry-point probe '{id}' was not registered."); + } + + return new ExitSignal(exited); + } + + public static BreakpointProbe CreateBreakpoint() + { + var id = Guid.NewGuid().ToString("N"); + var signal = new BreakpointSignal( + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + if (!s_breakpoints.TryAdd(id, signal)) + { + throw new InvalidOperationException($"Could not create entry-point breakpoint probe '{id}'."); + } + + return new BreakpointProbe(id, signal.Reached, signal.Continue); + } + + public static async Task WaitAtBreakpointAsync(string[] args) + { + // The test passes the probe as: + // --entry-point-breakpoint-probe=<32-character GUID> + var argument = args.FirstOrDefault(arg => arg.StartsWith(BreakpointArgumentPrefix, StringComparison.Ordinal)); + if (argument is null) + { + return; + } + + var id = argument[BreakpointArgumentPrefix.Length..]; + if (!s_breakpoints.TryGetValue(id, out var signal)) + { + throw new InvalidOperationException($"Entry-point breakpoint probe '{id}' was not registered."); + } + + signal.Reached.TrySetResult(); + try + { + await signal.Continue.Task.ConfigureAwait(false); + } + finally + { + s_breakpoints.TryRemove(id, out _); + } + } + + public sealed class Probe(string id, Task exited) : IDisposable + { + public string Id { get; } = id; + + public Task Exited { get; } = exited; + + public void Dispose() + { + s_probes.TryRemove(Id, out _); + } + } + + public sealed class BreakpointProbe : IDisposable + { + private readonly TaskCompletionSource _continue; + + internal BreakpointProbe(string id, TaskCompletionSource reached, TaskCompletionSource @continue) + { + Id = id; + _continue = @continue; + Reached = reached.Task; + } + + public string Id { get; } + + public Task Reached { get; } + + public void Continue() + { + _continue.TrySetResult(); + } + + public void Dispose() + { + Continue(); + if (Reached.IsCompleted) + { + s_breakpoints.TryRemove(Id, out _); + } + } + } + + private sealed class ExitSignal(TaskCompletionSource exited) : IDisposable + { + public void Dispose() + { + exited.TrySetResult(); + } + } + + private sealed record BreakpointSignal(TaskCompletionSource Reached, TaskCompletionSource Continue); + + private sealed class EmptyDisposable : IDisposable + { + public static EmptyDisposable Instance { get; } = new(); + + public void Dispose() + { + } + } +} diff --git a/tools/CreateLayout/Program.cs b/tools/CreateLayout/Program.cs index 6dd1a5cacfa..a38ce4ffc4c 100644 --- a/tools/CreateLayout/Program.cs +++ b/tools/CreateLayout/Program.cs @@ -139,6 +139,7 @@ public async Task BuildAsync() // Copy components CopyManaged(); + CopyDashboard(); await CopyDcpAsync().ConfigureAwait(false); Log("Layout build complete!"); @@ -148,7 +149,7 @@ private void CopyManaged() { Log("Copying aspire-managed..."); - var managedPublishPath = FindPublishPath("Aspire.Managed"); + var managedPublishPath = FindPublishPath("Aspire.Managed", "net10.0"); if (managedPublishPath is null) { throw new InvalidOperationException("Aspire.Managed publish output not found."); @@ -157,7 +158,7 @@ private void CopyManaged() var managedDir = Path.Combine(_outputPath, "managed"); Directory.CreateDirectory(managedDir); - // Copy only the aspire-managed executable and required assets (wwwroot for Dashboard). + // Copy only the aspire-managed executable. // Skip other .exe files — they are native host stubs from referenced Exe projects // that leak into the publish output but are not needed (everything is in aspire-managed.exe). var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); @@ -171,14 +172,41 @@ private void CopyManaged() File.Copy(managedExePath, Path.Combine(managedDir, managedExeName), overwrite: true); - // Copy wwwroot (required for Dashboard static web assets) - var wwwrootPath = Path.Combine(managedPublishPath, "wwwroot"); + Log($" Copied aspire-managed to managed/"); + } + + private void CopyDashboard() + { + Log("Copying Native AOT Dashboard..."); + + // A non-RID publish contains a framework-dependent apphost whose DLL and runtime config are + // intentionally not copied into the bundle. Only RID-specific output can be used here. + var dashboardPublishPath = FindPublishPath("Aspire.Dashboard", "net11.0", requireRidSpecific: true); + if (dashboardPublishPath is null) + { + throw new InvalidOperationException("Aspire.Dashboard publish output not found."); + } + + var dashboardDir = Path.Combine(_outputPath, "dashboard"); + Directory.CreateDirectory(dashboardDir); + + var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); + var dashboardExeName = isWindows ? "Aspire.Dashboard.exe" : "Aspire.Dashboard"; + var dashboardExePath = Path.Combine(dashboardPublishPath, dashboardExeName); + if (!File.Exists(dashboardExePath)) + { + throw new InvalidOperationException($"Native AOT Dashboard executable not found at {dashboardExePath}"); + } + + File.Copy(dashboardExePath, Path.Combine(dashboardDir, dashboardExeName), overwrite: true); + + var wwwrootPath = Path.Combine(dashboardPublishPath, "wwwroot"); if (Directory.Exists(wwwrootPath)) { - CopyDirectory(wwwrootPath, Path.Combine(managedDir, "wwwroot")); + CopyDirectory(wwwrootPath, Path.Combine(dashboardDir, "wwwroot")); } - Log($" Copied aspire-managed to managed/"); + Log(" Copied Native AOT Dashboard to dashboard/"); } private Task CopyDcpAsync() @@ -271,23 +299,33 @@ public async Task CreateArchiveAsync() return archivePath; } - private string? FindPublishPath(string projectName) + private string? FindPublishPath(string projectName, string targetFramework, bool requireRidSpecific = false) { // Look for publish output in standard locations // Order: RID-specific publish paths first (Release then Debug) - var searchPaths = new[] + var ridSpecificSearchPaths = new[] { + // PlatformName-scoped output used by RID packaging and signing. + Path.Combine(_artifactsPath, "bin", projectName, _rid, "Release", targetFramework, _rid, "publish"), + Path.Combine(_artifactsPath, "bin", projectName, _rid, "Debug", targetFramework, _rid, "publish"), // RID-specific self-contained publish output (preferred for Aspire.Managed) - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", _rid, "publish"), - Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", _rid, "publish"), + Path.Combine(_artifactsPath, "bin", projectName, "Release", targetFramework, _rid, "publish"), + Path.Combine(_artifactsPath, "bin", projectName, "Debug", targetFramework, _rid, "publish"), // Native AOT output - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", _rid, "native"), - Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", _rid, "native"), - // Non-RID publish output - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", "publish"), - Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", "publish"), + Path.Combine(_artifactsPath, "bin", projectName, "Release", targetFramework, _rid, "native"), + Path.Combine(_artifactsPath, "bin", projectName, "Debug", targetFramework, _rid, "native"), }; + var searchPaths = requireRidSpecific + ? ridSpecificSearchPaths + : + [ + .. ridSpecificSearchPaths, + // Non-RID publish output + Path.Combine(_artifactsPath, "bin", projectName, "Release", targetFramework, "publish"), + Path.Combine(_artifactsPath, "bin", projectName, "Debug", targetFramework, "publish"), + ]; + foreach (var path in searchPaths) { if (Directory.Exists(path)) diff --git a/tools/CreateLayout/README.md b/tools/CreateLayout/README.md index 012061fe33c..4e3e8b81de2 100644 --- a/tools/CreateLayout/README.md +++ b/tools/CreateLayout/README.md @@ -78,30 +78,20 @@ The tool creates the following layout: ``` {output}/ -├── aspire[.exe] # Native AOT CLI executable -├── runtime/ # .NET shared runtime -│ ├── dotnet[.exe] -│ └── shared/ -│ ├── Microsoft.NETCore.App/{version}/ -│ └── Microsoft.AspNetCore.App/{version}/ -├── dashboard/ # Aspire Dashboard (framework-dependent) -├── dcp/ # DCP binaries -├── aspire-server/ # Pre-built AppHost server (framework-dependent) -└── tools/ - ├── aspire-nuget/ # NuGet helper tool - └── dev-certs/ # Certificate management +├── managed/ +│ └── aspire-managed[.exe] # AppHost server, NuGet helper, and terminal host +├── dashboard/ +│ ├── Aspire.Dashboard[.exe] +│ └── wwwroot/ # Dashboard static assets +└── dcp/ # DCP binaries ``` ## How It Works -1. **Copies CLI** - Finds the native AOT compiled CLI from artifacts and copies to root -2. **Downloads/Copies Runtime** - Either downloads from Microsoft or copies from specified path -3. **Copies Dashboard** - Copies the published Dashboard output -4. **Copies DCP** - Finds DCP binaries from NuGet package restore output -5. **Copies AppHost Server** - Copies the published RemoteHost (server) output -6. **Copies NuGet Helper** - Copies the published NuGet helper tool -7. **Copies Dev-certs** - Copies the dev-certs tool from SDK -8. **Creates Archive** - Optionally creates .zip (Windows) or .tar.gz (Linux/macOS) +1. **Copies aspire-managed** - Copies the self-contained AppHost server, NuGet helper, and terminal host executable +2. **Copies Dashboard** - Copies the Native AOT Dashboard executable and `wwwroot` static assets +3. **Copies DCP** - Finds DCP binaries from NuGet package restore output +4. **Creates Archive** - Optionally creates .zip (Windows) or .tar.gz (Linux/macOS) ## Runtime Download