diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json deleted file mode 100644 index 0837817a13..0000000000 --- a/.github/aw/actions-lock.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "entries": { - }, - "containers": { - "ghcr.io/github/gh-aw-firewall/agent:0.25.58": { - "image": "ghcr.io/github/gh-aw-firewall/agent:0.25.58", - "digest": "sha256:a316a2c021accba8a9ea194c75466b0c4a166be6ac783bf8c2d0afd73373dec2", - "pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.25.58@sha256:a316a2c021accba8a9ea194c75466b0c4a166be6ac783bf8c2d0afd73373dec2" - }, - "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58": { - "image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58", - "digest": "sha256:43a5cdbe4e1156920dcdaab26d6c6761777d5c6bdc572d7d07b11739fb34c749", - "pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58@sha256:43a5cdbe4e1156920dcdaab26d6c6761777d5c6bdc572d7d07b11739fb34c749" - }, - "ghcr.io/github/gh-aw-firewall/squid:0.25.58": { - "image": "ghcr.io/github/gh-aw-firewall/squid:0.25.58", - "digest": "sha256:558682b7b6313a5443cbb3d702899823bd732f991c8b52db6b5b8066abefe7a1", - "pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.25.58@sha256:558682b7b6313a5443cbb3d702899823bd732f991c8b52db6b5b8066abefe7a1" - }, - "ghcr.io/github/gh-aw-mcpg:v0.3.22": { - "image": "ghcr.io/github/gh-aw-mcpg:v0.3.22", - "digest": "sha256:ce5c6f5461b077af0d8e8eb1763436e85153f8e9531117d58a7bdb23de71f00a", - "pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.3.22@sha256:ce5c6f5461b077af0d8e8eb1763436e85153f8e9531117d58a7bdb23de71f00a" - }, - "ghcr.io/github/github-mcp-server:v1.1.0": { - "image": "ghcr.io/github/github-mcp-server:v1.1.0", - "digest": "sha256:71b07d9abecb83b4a2595bcd8ccb35f9a0166361a12335f9e16da1ef07172029", - "pinned_image": "ghcr.io/github/github-mcp-server:v1.1.0@sha256:71b07d9abecb83b4a2595bcd8ccb35f9a0166361a12335f9e16da1ef07172029" - } - } -} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2943db1538..a3c522d627 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,14 +47,15 @@ jobs: if: github.event.pull_request.merged == true || github.ref_name == 'master' || github.ref_name == 'master-patch-*' || github.event_name == 'workflow_dispatch' name: "Setup" runs-on: ubuntu-latest + permissions: + contents: read + actions: write steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - uses: ./.github/actions/setup - with: - enable-node-modules-cache: false - name: install run: | pnpm install --frozen-lockfile @@ -73,6 +74,7 @@ jobs: id-token: write # Required for OIDC contents: read packages: write + actions: read steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -80,8 +82,6 @@ jobs: fetch-depth: 0 - id: setup uses: ./.github/actions/setup - with: - enable-node-modules-cache: false - id: set-dryrun uses: ./.github/actions/enable-dryrun with: diff --git a/.github/workflows/supply-chain-review.lock.yml b/.github/workflows/supply-chain-review.lock.yml index 23bd2f8718..f21ead408c 100644 --- a/.github/workflows/supply-chain-review.lock.yml +++ b/.github/workflows/supply-chain-review.lock.yml @@ -1,5 +1,7 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"835aaf34c5688fe6d4315c8eab49dde1ecd53e9bf49be43ccf4fc41bb63f34f4","body_hash":"ccb05611b0ff5785ed4beddd0e21579d44fbd292b76319f10f570c88bdc42ab4","compiler_version":"v0.77.5","strict":true,"agent_id":"copilot","agent_model":"gpt-5.4-nano"} -# 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":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"3ea13c02d765410340d533515cb31a7eef2baaf0","version":"v0.77.5"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.58"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.22"},{"image":"ghcr.io/github/github-mcp-server:v1.1.0"},{"image":"node:lts-alpine","digest":"sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14","pinned_image":"node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14"}]} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"af8aeacbaaee0d9847e280619219cb8fa8d5fea4ec801da91dc96cb23502f347","body_hash":"7633ba3ff031b54b625dd63e8d2afc34c1fb59148ff2970fbaec3c324d3b134f","compiler_version":"v0.81.6","strict":true,"agent_id":"copilot","agent_model":"gpt-5-mini","engine_versions":{"copilot":"1.0.65"}} +# 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":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"ba6380cc6e5be5d21677bebe04d52fb48e3abec7","version":"v0.81.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11","digest":"sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11","digest":"sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11","digest":"sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} +# This file was automatically generated by gh-aw (v0.81.6). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -14,16 +16,15 @@ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.77.5). DO NOT EDIT. # -# To update this file, edit Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545 and run: +# To update this file, edit Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d and run: # gh aw compile # Not all edits will cause changes to this file. # # For more information: https://github.github.com/gh-aw/introduction/overview/ # # -# Source: Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545 +# Source: Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d # # Secrets used: # - COPILOT_GITHUB_TOKEN @@ -32,21 +33,23 @@ # - GITHUB_TOKEN # # Custom actions used: +# - actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 +# - actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 # - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) # - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@9fba95c4e1e3ce39704ccd39b440f6935715bc09 # v0.81.4 +# - github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6 # # Container images used: -# - ghcr.io/github/gh-aw-firewall/agent:0.25.58 -# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58 -# - ghcr.io/github/gh-aw-firewall/squid:0.25.58 -# - ghcr.io/github/gh-aw-mcpg:v0.3.22 -# - ghcr.io/github/github-mcp-server:v1.1.0 -# - node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14 +# - ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7 +# - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d +# - ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d +# - ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be +# - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b +# - ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036 name: "Supply Chain Review" on: @@ -95,11 +98,17 @@ jobs: discussions: write issues: write pull-requests: write + env: + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} outputs: body: ${{ steps.sanitized.outputs.body }} comment_id: ${{ steps.add-comment.outputs.comment-id }} comment_repo: ${{ steps.add-comment.outputs.comment-repo }} comment_url: ${{ steps.add-comment.outputs.comment-url }} + daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }} + daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }} + daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }} engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} model: ${{ steps.generate_aw_info.outputs.model }} @@ -114,17 +123,18 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@9fba95c4e1e3ce39704ccd39b440f6935715bc09 # v0.81.4 + uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }} parent-span-id: ${{ needs.pre_activation.outputs.setup-parent-span-id || needs.pre_activation.outputs.setup-span-id }} + safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} env: GH_AW_SETUP_WORKFLOW_NAME: "Supply Chain Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/supply-chain-review.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.55" - GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_VERSION: "1.0.65" + GH_AW_INFO_AWF_VERSION: "v0.27.11" GH_AW_INFO_BODY_MODIFIED: "false" GH_AW_INFO_ENGINE_ID: "copilot" - name: Generate agentic run info @@ -132,20 +142,20 @@ jobs: env: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: "gpt-5.4-nano" - GH_AW_INFO_VERSION: "1.0.55" - GH_AW_INFO_AGENT_VERSION: "1.0.55" - GH_AW_INFO_CLI_VERSION: "v0.77.5" + GH_AW_INFO_MODEL: "gpt-5-mini" + GH_AW_INFO_VERSION: "1.0.65" + GH_AW_INFO_AGENT_VERSION: "1.0.65" + GH_AW_INFO_CLI_VERSION: "v0.81.6" GH_AW_INFO_WORKFLOW_NAME: "Supply Chain Review" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","node","java","api.osv.dev","api.scorecard.dev","search.maven.org"]' GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_AWF_VERSION: "v0.27.11" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" - GH_AW_INFO_FRONTMATTER_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545" + GH_AW_INFO_FRONTMATTER_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d" GH_AW_INFO_BODY_MODIFIED: "false" GH_AW_COMPILED_STRICT: "true" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -155,6 +165,50 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: agentic-workflow-usage-supplychainreview-${{ github.run_id }} + restore-keys: agentic-workflow-usage-supplychainreview- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Restore daily AIC usage cache (artifact fallback) + id: restore-daily-aic-cache-fallback + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }} + GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + 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/restore_aic_usage_cache_fallback.cjs'); + await main(); + - name: Check daily workflow token guardrail + id: daily-effective-workflow-guardrail + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_NAME: "Supply Chain Review" + GH_AW_WORKFLOW_ID: "supply-chain-review" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }} + GH_AW_HAS_SLASH_COMMAND: "true" + GH_AW_HAS_LABEL_COMMAND: "false" + GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + 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/check_daily_aic_workflow_guardrail.cjs'); + await main(); - name: Add rocket reaction for immediate feedback id: react if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id @@ -210,7 +264,7 @@ jobs: - name: Check compile-agentic version uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.77.5" + GH_AW_COMPILED_VERSION: "v0.81.6" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -240,6 +294,9 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/add_workflow_run_comment.cjs'); await main(); + - name: Log runtime features + 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 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt @@ -257,20 +314,20 @@ jobs: run: | bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_6740ff15455080cb_EOF' + cat << 'GH_AW_PROMPT_c48f5cf53b906b4c_EOF' - GH_AW_PROMPT_6740ff15455080cb_EOF + GH_AW_PROMPT_c48f5cf53b906b4c_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_6740ff15455080cb_EOF' + cat << 'GH_AW_PROMPT_c48f5cf53b906b4c_EOF' - Tools: add_comment, submit_pull_request_review, add_labels, missing_tool, missing_data, noop + Tools: add_comment, submit_pull_request_review, add_labels, remove_labels, missing_tool, missing_data, noop - GH_AW_PROMPT_6740ff15455080cb_EOF + GH_AW_PROMPT_c48f5cf53b906b4c_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_6740ff15455080cb_EOF' + cat << 'GH_AW_PROMPT_c48f5cf53b906b4c_EOF' The following GitHub context information is available for this workflow: {{#if github.actor}} @@ -299,15 +356,15 @@ jobs: {{/if}} - GH_AW_PROMPT_6740ff15455080cb_EOF + GH_AW_PROMPT_c48f5cf53b906b4c_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then cat "${RUNNER_TEMP}/gh-aw/prompts/pr_context_prompt.md" fi - cat << 'GH_AW_PROMPT_6740ff15455080cb_EOF' + cat << 'GH_AW_PROMPT_c48f5cf53b906b4c_EOF' {{#runtime-import .github/workflows/supply-chain-review.md}} - GH_AW_PROMPT_6740ff15455080cb_EOF + GH_AW_PROMPT_c48f5cf53b906b4c_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -379,7 +436,7 @@ jobs: include-hidden-files: true path: | /tmp/gh-aw/aw_info.json - /tmp/gh-aw/model_multipliers.json + /tmp/gh-aw/models.json /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/aw-prompts/prompt-template.txt /tmp/gh-aw/aw-prompts/prompt-import-tree.json @@ -392,6 +449,7 @@ jobs: agent: needs: activation + if: needs.activation.outputs.daily_ai_credits_exceeded != 'true' runs-on: ubuntu-latest permissions: contents: read @@ -402,12 +460,15 @@ jobs: GH_AW_ASSETS_BRANCH: "" GH_AW_ASSETS_MAX_SIZE_KB: 0 GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} GH_AW_WORKFLOW_ID_SANITIZED: supplychainreview outputs: agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} + ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }} + aic: ${{ steps.parse-mcp-gateway.outputs.aic }} + ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }} checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} - effective_tokens_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.effective_tokens_rate_limit_error || 'false' }} has_patch: ${{ steps.collect_output.outputs.has_patch }} inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} @@ -418,10 +479,11 @@ jobs: setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} setup-span-id: ${{ steps.setup.outputs.span-id }} setup-trace-id: ${{ steps.setup.outputs.trace-id }} + unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }} steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@9fba95c4e1e3ce39704ccd39b440f6935715bc09 # v0.81.4 + uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -430,8 +492,8 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Supply Chain Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/supply-chain-review.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.55" - GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_VERSION: "1.0.65" + GH_AW_INFO_AWF_VERSION: "v0.27.11" GH_AW_INFO_BODY_MODIFIED: "false" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths @@ -454,21 +516,14 @@ jobs: GH_TOKEN: ${{ github.token }} - name: Configure Git credentials env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_TOKEN: ${{ github.token }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" - name: Checkout PR branch id: checkout-pr if: | - github.event.pull_request || github.event.issue.pull_request + github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -480,11 +535,11 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.55 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.65 env: GH_HOST: github.com - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.58 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.11 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) @@ -516,15 +571,15 @@ 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.25.58 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58 ghcr.io/github/gh-aw-firewall/squid:0.25.58 ghcr.io/github/gh-aw-mcpg:v0.3.22 ghcr.io/github/github-mcp-server:v1.1.0 node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036 - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_b3e67e93cdb2db2c_EOF' - {"add_comment":{"hide_older_comments":true,"max":1},"add_labels":{"allowed":["security:low","security:medium","security:high"]},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{},"submit_pull_request_review":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_b3e67e93cdb2db2c_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_1ce839dc0f34bf74_EOF' + {"add_comment":{"hide_older_comments":true,"max":1},"add_labels":{"allowed":["security:low","security:medium","security:high"]},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"remove_labels":{"allowed":["security:low","security:medium","security:high"]},"report_incomplete":{},"submit_pull_request_review":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_1ce839dc0f34bf74_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -532,6 +587,7 @@ jobs: "description_suffixes": { "add_comment": " CONSTRAINTS: Maximum 1 comment(s) can be added. Supports reply_to_id for discussion threading.", "add_labels": " CONSTRAINTS: Only these labels are allowed: [\"security:low\" \"security:medium\" \"security:high\"].", + "remove_labels": " CONSTRAINTS: Only these labels can be removed: [security:low security:medium security:high].", "submit_pull_request_review": " CONSTRAINTS: Maximum 1 review(s) can be submitted." }, "repo_params": {}, @@ -569,10 +625,7 @@ jobs: }, "labels": { "required": true, - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 + "type": "array" }, "repo": { "type": "string", @@ -637,6 +690,22 @@ jobs: } } }, + "remove_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueNumberOrTemporaryId": true + }, + "labels": { + "required": true, + "type": "array" + }, + "repo": { + "type": "string", + "maxLength": 256 + } + } + }, "report_incomplete": { "defaultMax": 5, "fields": { @@ -668,6 +737,13 @@ jobs: "REQUEST_CHANGES", "COMMENT" ] + }, + "pull_request_number": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 } } } @@ -679,55 +755,17 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); await main(); - - name: Generate Safe Outputs MCP Server Config - id: safe-outputs-config - run: | - # Generate a secure random API key (360 bits of entropy, 40+ chars) - # Mask immediately to prevent timing vulnerabilities - API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${API_KEY}" - - PORT=3001 - - # Set outputs for next steps - { - echo "safe_outputs_api_key=${API_KEY}" - echo "safe_outputs_port=${PORT}" - } >> "$GITHUB_OUTPUT" - - echo "Safe Outputs MCP server will run on port ${PORT}" - - - name: Start Safe Outputs MCP HTTP Server - id: safe-outputs-start - env: - DEBUG: '*' - GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - run: | - # Environment variables are set above to prevent template injection - export DEBUG - export GH_AW_SAFE_OUTPUTS - export GH_AW_SAFE_OUTPUTS_PORT - export GH_AW_SAFE_OUTPUTS_API_KEY - export GH_AW_SAFE_OUTPUTS_TOOLS_PATH - export GH_AW_SAFE_OUTPUTS_CONFIG_PATH - export GH_AW_MCP_LOG_DIR - - bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" - - name: Start MCP Gateway id: start-mcp-gateway env: + GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }} GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }} GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }} GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -eo pipefail mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" @@ -753,19 +791,19 @@ jobs: * ) DOCKER_SOCK_PATH=/var/run/docker.sock ;; esac DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0') - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --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_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 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 GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -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 ghcr.io/github/gh-aw-mcpg:v0.3.22' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --name awmg-mcpg --add-host host.docker.internal:127.0.0.1 --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 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.3.30' - mkdir -p /home/runner/.copilot + 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_d2d29ce847428284_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_7aa54edca63b5601_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.1.0", + "container": "ghcr.io/github/github-mcp-server:v1.4.0", "env": { - "GITHUB_HOST": "\${GITHUB_SERVER_URL}", - "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_HOST": "${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", "GITHUB_TOOLSETS": "context,pull_requests,repos" }, @@ -777,10 +815,27 @@ jobs: } }, "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", - "headers": { - "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-node", + "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"], + "args": ["-w", "\${GITHUB_WORKSPACE}"], + "entrypoint": "sh", + "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"], + "env": { + "DEBUG": "*", + "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}", + "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", + "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", + "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", + "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", + "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", + "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", + "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", + "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", + "RUNNER_TEMP": "\${RUNNER_TEMP}" }, "guard-policies": { "write-sink": { @@ -798,7 +853,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_d2d29ce847428284_EOF + GH_AW_MCP_CONFIG_7aa54edca63b5601_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -827,41 +882,53 @@ jobs: run: | set -o pipefail printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + trap 'rm -f "$HOME/.copilot/settings.json"' EXIT + mkdir -p "$HOME/.copilot" + printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" + export XDG_CONFIG_HOME="$HOME" + export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" (umask 177 && touch /tmp/gh-aw/agent-stdio.log) - printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.58/awf-config.schema.json","network":{"allowDomains":["*.gradle-enterprise.cloud","adoptium.net","api.adoptium.net","api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.foojay.io","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","api.npms.io","api.osv.dev","api.scorecard.dev","api.snapcraft.io","archive.apache.org","archive.ubuntu.com","azure.archive.ubuntu.com","bun.sh","cdn.azul.com","cdn.jsdelivr.net","central.sonatype.com","crl.geotrust.com","crl.globalsign.com","crl.identrust.com","crl.sectigo.com","crl.thawte.com","crl.usertrust.com","crl.verisign.com","crl3.digicert.com","crl4.digicert.com","crls.ssl.com","deb.nodesource.com","deno.land","develocity.apache.org","dl.google.com","dlcdn.apache.org","download.eclipse.org","download.java.net","download.oracle.com","downloads.gradle-dn.com","esm.sh","ge.spockframework.org","get.pnpm.io","github.com","googleapis.deno.dev","googlechromelabs.github.io","gradle.org","host.docker.internal","jcenter.bintray.com","jdk.java.net","json-schema.org","json.schemastore.org","jsr.io","keyserver.ubuntu.com","maven-central.storage-download.googleapis.com","maven.apache.org","maven.google.com","maven.oracle.com","maven.pkg.github.com","nodejs.org","npm.pkg.github.com","npmjs.com","npmjs.org","ocsp.digicert.com","ocsp.geotrust.com","ocsp.globalsign.com","ocsp.identrust.com","ocsp.sectigo.com","ocsp.ssl.com","ocsp.thawte.com","ocsp.usertrust.com","ocsp.verisign.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","plugins-artifacts.gradle.org","plugins.gradle.org","ppa.launchpad.net","raw.githubusercontent.com","registry.bower.io","registry.npmjs.com","registry.npmjs.org","registry.yarnpkg.com","repo.gradle.org","repo.grails.org","repo.maven.apache.org","repo.spring.io","repo.yarnpkg.com","repo1.maven.org","repository.apache.org","s.symcb.com","s.symcd.com","scans-in.gradle.com","search.maven.org","security.ubuntu.com","services.gradle.org","skimdb.npmjs.com","storage.googleapis.com","telemetry.enterprise.githubcopilot.com","telemetry.vercel.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com","www.java.com","www.npmjs.com","www.npmjs.org","yarnpkg.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000,"models":{"agent":["sonnet-6x","gpt-5.4","gpt-5.3","gemini-pro","any"],"antigravity":["copilot/antigravity*","google/antigravity*","gemini/antigravity*"],"any":["copilot/*","anthropic/*","openai/*","google/*","gemini/*"],"claude":["agent"],"codex":["agent"],"coding":["copilot/gpt-5*codex*","openai/gpt-5*codex*","gpt-5-codex"],"computer-use":["copilot/*computer-use*","google/*computer-use*","gemini/*computer-use*","openai/*computer-use*"],"copilot":["agent"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","gemini/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"gemini":["agent"],"gemini-3-flash":["copilot/gemini-3*flash*","google/gemini-3*flash*","gemini/gemini-3*flash*"],"gemini-3-pro":["copilot/gemini-3*pro*","google/gemini-3*pro*","gemini/gemini-3*pro*"],"gemini-3.1-flash":["copilot/gemini-3.1*flash*","google/gemini-3.1*flash*","gemini/gemini-3.1*flash*"],"gemini-3.1-pro":["copilot/gemini-3.1*pro*","google/gemini-3.1*pro*","gemini/gemini-3.1*pro*"],"gemini-3.5-flash":["copilot/gemini-3.5*flash*","google/gemini-3.5*flash*","gemini/gemini-3.5*flash*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"],"gemini-flash-lite":["copilot/gemini-*flash*lite*","google/gemini-*flash*lite*","gemini/gemini-*flash*lite*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*","gemini/gemini-*pro*"],"gemma":["copilot/gemma*","google/gemma*","gemini/gemma*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"gpt-5.2":["copilot/gpt-5.2*","openai/gpt-5.2*"],"gpt-5.3":["copilot/gpt-5.3*","openai/gpt-5.3*"],"gpt-5.4":["copilot/gpt-5.4*","openai/gpt-5.4*"],"gpt-5.5":["copilot/gpt-5.5*","openai/gpt-5.5*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash-lite"],"opus":["copilot/*opus*","anthropic/*opus*"],"opusplan":["opus?effort=high"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"robotics":["copilot/*robotics*","google/*robotics*","gemini/*robotics*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"],"sonnet-6x":["copilot/*sonnet-4-5-*","anthropic/*sonnet-4-5-*","copilot/*sonnet-4-6*","anthropic/*sonnet-4-6*"],"summarization":["haiku","gpt-5-mini","gemini-flash-lite","mini"],"vision":["copilot/gemini-*image*","gemini/gemini-*image*","copilot/gemini-*flash*","gemini/gemini-*flash*"]}},"container":{"imageTag":"0.25.58"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" - GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs" + GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.11/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.gradle-enterprise.cloud\",\"adoptium.net\",\"api.adoptium.net\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.foojay.io\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.npms.io\",\"api.osv.dev\",\"api.scorecard.dev\",\"api.snapcraft.io\",\"archive.apache.org\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"bun.sh\",\"cdn.azul.com\",\"cdn.jsdelivr.net\",\"central.sonatype.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"deb.nodesource.com\",\"deno.land\",\"develocity.apache.org\",\"dl.google.com\",\"dlcdn.apache.org\",\"download.eclipse.org\",\"download.java.net\",\"download.oracle.com\",\"downloads.gradle-dn.com\",\"esm.sh\",\"ge.spockframework.org\",\"get.pnpm.io\",\"github.com\",\"googleapis.deno.dev\",\"googlechromelabs.github.io\",\"gradle.org\",\"host.docker.internal\",\"jcenter.bintray.com\",\"jdk.java.net\",\"json-schema.org\",\"json.schemastore.org\",\"jsr.io\",\"keyserver.ubuntu.com\",\"maven-central.storage-download.googleapis.com\",\"maven.apache.org\",\"maven.google.com\",\"maven.oracle.com\",\"maven.pkg.github.com\",\"nodejs.org\",\"npm.pkg.github.com\",\"npmjs.com\",\"npmjs.org\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"plugins-artifacts.gradle.org\",\"plugins.gradle.org\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.bower.io\",\"registry.npmjs.com\",\"registry.npmjs.org\",\"registry.yarnpkg.com\",\"repo.gradle.org\",\"repo.grails.org\",\"repo.maven.apache.org\",\"repo.spring.io\",\"repo.yarnpkg.com\",\"repo1.maven.org\",\"repository.apache.org\",\"s.symcb.com\",\"s.symcd.com\",\"scans-in.gradle.com\",\"search.maven.org\",\"security.ubuntu.com\",\"services.gradle.org\",\"skimdb.npmjs.com\",\"storage.googleapis.com\",\"telemetry.enterprise.githubcopilot.com\",\"telemetry.vercel.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\",\"www.java.com\",\"www.npmjs.com\",\"www.npmjs.org\",\"yarnpkg.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.5\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.11,squid=sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d,agent=sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7,api-proxy=sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" + GH_AW_DOCKER_HOST="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST="${DOCKER_HOST}" + fi GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + GH_AW_CHROOT_BINARIES_SOURCE_PATH=/tmp/gh-aw GH_AW_CHROOT_IDENTITY_HOME=/tmp/gh-aw/home node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs" fi GH_AW_TOOL_CACHE_MOUNT="" - GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" if [ -d "$GH_AW_TOOL_CACHE" ]; then if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" fi - elif [ -d "/home/runner/work/_tool" ]; then - GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro" fi - # shellcheck disable=SC1003 - sudo -E 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_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/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_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 + # shellcheck disable=SC1003,SC2086 + sudo -E 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"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --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 && 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 env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: gpt-5.4-nano - GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + COPILOT_MODEL: gpt-5-mini + GH_AW_LLM_PROVIDER: github + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} GH_AW_PHASE: agent 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_VERSION: v0.77.5 + GH_AW_TIMEOUT_MINUTES: 20 + GH_AW_VERSION: v0.81.6 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -876,7 +943,7 @@ jobs: GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com GIT_COMMITTER_NAME: github-actions[bot] RUNNER_TEMP: ${{ runner.temp }} - XDG_CONFIG_HOME: /home/runner + 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: Detect agent errors if: always() id: detect-agent-errors @@ -884,17 +951,10 @@ jobs: run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs" - name: Configure Git credentials env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_TOKEN: ${{ github.token }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" - name: Copy Copilot session state files to logs if: always() continue-on-error: true @@ -1045,17 +1105,18 @@ jobs: - safe_outputs if: > always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || - needs.activation.outputs.stale_lock_file_failed == 'true') + needs.activation.outputs.stale_lock_file_failed == 'true' || needs.activation.outputs.daily_ai_credits_exceeded == 'true') runs-on: ubuntu-slim permissions: contents: read - discussions: write issues: write pull-requests: write concurrency: group: "gh-aw-conclusion-supply-chain-review" cancel-in-progress: false queue: max + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} outputs: incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} noop_message: ${{ steps.noop.outputs.noop_message }} @@ -1064,7 +1125,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@9fba95c4e1e3ce39704ccd39b440f6935715bc09 # v0.81.4 + uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1073,8 +1134,8 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Supply Chain Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/supply-chain-review.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.55" - GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_VERSION: "1.0.65" + GH_AW_INFO_AWF_VERSION: "v0.27.11" GH_AW_INFO_BODY_MODIFIED: "false" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1091,6 +1152,88 @@ jobs: mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Collect usage artifact files + if: always() + continue-on-error: true + run: | + mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection + echo "Usage artifact source file status:" + for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do + [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file" + done + [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true + [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true + [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true + [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true + [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true + [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl + [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl + mkdir -p /tmp/gh-aw/usage/activity + node ${{ runner.temp }}/gh-aw/actions/generate_usage_activity_summary.cjs + find /tmp/gh-aw/usage -type f -print | sort + - name: Upload usage artifact + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: usage + path: | + /tmp/gh-aw/usage/aw_info.json + /tmp/gh-aw/usage/aw-info.jsonl + /tmp/gh-aw/usage/agent_usage.json + /tmp/gh-aw/usage/agent_usage.jsonl + /tmp/gh-aw/usage/detection_usage.jsonl + /tmp/gh-aw/usage/github_rate_limits.jsonl + /tmp/gh-aw/usage/agent/token_usage.jsonl + /tmp/gh-aw/usage/detection/token_usage.jsonl + /tmp/gh-aw/usage/activity/summary.json + if-no-files-found: ignore + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache-conclusion + if: always() + continue-on-error: true + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: agentic-workflow-usage-supplychainreview-${{ github.run_id }} + restore-keys: agentic-workflow-usage-supplychainreview- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Write daily AIC usage cache entry + id: write-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context); + const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs'); + await main(); + - name: Save daily AIC usage cache + id: save-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: agentic-workflow-usage-supplychainreview-${{ github.run_id }} + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Upload daily AIC usage cache artifact + id: upload-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: aic-usage-cache + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + if-no-files-found: ignore + retention-days: 7 - name: Process no-op messages id: noop uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1098,11 +1241,15 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_NOOP_MAX: "1" GH_AW_WORKFLOW_NAME: "Supply Chain Review" - GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/52467f0241079de71fe14591f97bdec7555ab545/.github/workflows/supply-chain-review.md" + GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/7479d99410acb8b63f78a5b17abb3b8ebec3a66d/.github/workflows/supply-chain-review.md" GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_NOOP_REPORT_AS_ISSUE: "true" + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_WORKFLOW_ID: "supply-chain-review" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -1116,8 +1263,8 @@ jobs: env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Supply Chain Review" - GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/52467f0241079de71fe14591f97bdec7555ab545/.github/workflows/supply-chain-review.md" + GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/7479d99410acb8b63f78a5b17abb3b8ebec3a66d/.github/workflows/supply-chain-review.md" GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} @@ -1135,8 +1282,8 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" GH_AW_WORKFLOW_NAME: "Supply Chain Review" - GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/52467f0241079de71fe14591f97bdec7555ab545/.github/workflows/supply-chain-review.md" + GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/7479d99410acb8b63f78a5b17abb3b8ebec3a66d/.github/workflows/supply-chain-review.md" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -1151,8 +1298,8 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" GH_AW_WORKFLOW_NAME: "Supply Chain Review" - GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/52467f0241079de71fe14591f97bdec7555ab545/.github/workflows/supply-chain-review.md" + GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/7479d99410acb8b63f78a5b17abb3b8ebec3a66d/.github/workflows/supply-chain-review.md" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -1167,8 +1314,8 @@ jobs: env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Supply Chain Review" - GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/52467f0241079de71fe14591f97bdec7555ab545/.github/workflows/supply-chain-review.md" + GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/7479d99410acb8b63f78a5b17abb3b8ebec3a66d/.github/workflows/supply-chain-review.md" GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_WORKFLOW_ID: "supply-chain-review" @@ -1177,7 +1324,11 @@ jobs: GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} - GH_AW_EFFECTIVE_TOKENS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.effective_tokens_rate_limit_error || 'false' }} + GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }} + GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }} + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} @@ -1185,12 +1336,14 @@ jobs: GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }} + GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }} + GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }} GH_AW_GROUP_REPORTS: "false" GH_AW_FAILURE_REPORT_AS_ISSUE: "true" GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" GH_AW_TIMEOUT_MINUTES: "20" - GH_AW_MAX_EFFECTIVE_TOKENS: "25000000" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -1223,19 +1376,21 @@ jobs: needs: - activation - agent - if: > - always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + if: always() && needs.agent.result != 'skipped' runs-on: ubuntu-latest permissions: contents: read + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} outputs: + aic: ${{ steps.parse_detection_token_usage.outputs.aic }} detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} detection_reason: ${{ steps.detection_conclusion.outputs.reason }} detection_success: ${{ steps.detection_conclusion.outputs.success }} steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@9fba95c4e1e3ce39704ccd39b440f6935715bc09 # v0.81.4 + uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1244,8 +1399,8 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Supply Chain Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/supply-chain-review.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.55" - GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_VERSION: "1.0.65" + GH_AW_INFO_AWF_VERSION: "v0.27.11" GH_AW_INFO_BODY_MODIFIED: "false" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1273,7 +1428,7 @@ jobs: rm -rf /tmp/gh-aw/sandbox/firewall/logs rm -rf /tmp/gh-aw/sandbox/firewall/audit - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.58 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58 ghcr.io/github/gh-aw-firewall/squid:0.25.58 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d - name: Check if detection needed id: detection_guard if: always() @@ -1292,12 +1447,13 @@ jobs: if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | rm -f "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json" - rm -f /home/runner/.copilot/mcp-config.json + rm -f "$HOME/.copilot/mcp-config.json" rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" - 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." @@ -1335,11 +1491,11 @@ jobs: node-version: '24' package-manager-cache: false - name: Install GitHub Copilot CLI - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.55 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.65 env: GH_HOST: github.com - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.58 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.11 - name: Execute GitHub Copilot CLI if: always() && steps.detection_guard.outputs.run_detection == 'true' continue-on-error: true @@ -1349,39 +1505,53 @@ jobs: run: | set -o pipefail printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + trap 'rm -f "$HOME/.copilot/settings.json"' EXIT + mkdir -p "$HOME/.copilot" + printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" + export XDG_CONFIG_HOME="$HOME" touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) - printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.58/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","registry.npmjs.org","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000},"container":{"imageTag":"0.25.58"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" - GH_AW_MODEL_MULTIPLIERS_PATH="/tmp/gh-aw/model_multipliers.json" node "${RUNNER_TEMP}/gh-aw/actions/merge_awf_model_multipliers.cjs" + GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-400}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.11/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5},\"container\":{\"imageTag\":\"0.27.11,squid=sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d,agent=sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7,api-proxy=sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" + GH_AW_DOCKER_HOST="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST="${DOCKER_HOST}" + fi GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + _GH_AW_CHROOT_JSON=$(jq -c --arg src /tmp/gh-aw --arg user "$(id -un)" --argjson uid "$(id -u)" --argjson gid "$(id -g)" --arg home /tmp/gh-aw/home '.chroot={"binariesSourcePath":$src,"identity":{"user":$user,"uid":$uid,"gid":$gid,"home":$home}}' "${RUNNER_TEMP}/gh-aw/awf-config.json") || { echo "chroot config patch failed" >&2; exit 1; } + printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + printf '%s\n' "$_GH_AW_CHROOT_JSON" > "/tmp/gh-aw/awf-config.json" fi GH_AW_TOOL_CACHE_MOUNT="" - GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" if [ -d "$GH_AW_TOOL_CACHE" ]; then if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" fi - elif [ -d "/home/runner/work/_tool" ]; then - GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro" fi - # shellcheck disable=SC1003 - sudo -E 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_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'set +o histexpand; GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/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_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 + # shellcheck disable=SC1003,SC2086 + sudo -E 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"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --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 && 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 env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: gpt-5.4-nano + COPILOT_MODEL: gpt-5-mini + GH_AW_LLM_PROVIDER: github + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }} + GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_VERSION: v0.77.5 + GH_AW_TIMEOUT_MINUTES: 20 + GH_AW_VERSION: v0.81.6 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows @@ -1395,7 +1565,20 @@ jobs: GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com GIT_COMMITTER_NAME: github-actions[bot] RUNNER_TEMP: ${{ runner.temp }} - XDG_CONFIG_HOME: /home/runner + 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: Parse threat detection token usage for step summary + id: parse_detection_token_usage + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_TOKEN_USAGE_SUMMARY_TITLE: Threat Detection Token Usage + 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/parse_token_usage.cjs'); + await main(); - name: Upload threat detection log if: always() && steps.detection_guard.outputs.run_detection == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -1439,6 +1622,8 @@ jobs: pre_activation: if: "(github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment' || contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)) && (github.event_name == 'issues' && (startsWith(github.event.issue.body, '/supply-chain-review ') || startsWith(github.event.issue.body, '/supply-chain-review\n') || github.event.issue.body == '/supply-chain-review') || github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/supply-chain-review ') || startsWith(github.event.comment.body, '/supply-chain-review\n') || github.event.comment.body == '/supply-chain-review') && github.event.issue.pull_request == null || github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/supply-chain-review ') || startsWith(github.event.comment.body, '/supply-chain-review\n') || github.event.comment.body == '/supply-chain-review') && github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' && (startsWith(github.event.comment.body, '/supply-chain-review ') || startsWith(github.event.comment.body, '/supply-chain-review\n') || github.event.comment.body == '/supply-chain-review') || github.event_name == 'pull_request' && (startsWith(github.event.pull_request.body, '/supply-chain-review ') || startsWith(github.event.pull_request.body, '/supply-chain-review\n') || github.event.pull_request.body == '/supply-chain-review') || github.event_name == 'discussion' && (startsWith(github.event.discussion.body, '/supply-chain-review ') || startsWith(github.event.discussion.body, '/supply-chain-review\n') || github.event.discussion.body == '/supply-chain-review') || github.event_name == 'discussion_comment' && (startsWith(github.event.comment.body, '/supply-chain-review ') || startsWith(github.event.comment.body, '/supply-chain-review\n') || github.event.comment.body == '/supply-chain-review'))" runs-on: ubuntu-slim + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} outputs: activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_command_position.outputs.command_position_ok == 'true' }} matched_command: ${{ steps.check_command_position.outputs.matched_command }} @@ -1448,15 +1633,15 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@9fba95c4e1e3ce39704ccd39b440f6935715bc09 # v0.81.4 + uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} env: GH_AW_SETUP_WORKFLOW_NAME: "Supply Chain Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/supply-chain-review.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.55" - GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_VERSION: "1.0.65" + GH_AW_INFO_AWF_VERSION: "v0.27.11" GH_AW_INFO_BODY_MODIFIED: "false" GH_AW_INFO_ENGINE_ID: "copilot" - name: Check team membership for command workflow @@ -1492,23 +1677,27 @@ jobs: runs-on: ubuntu-slim permissions: contents: read - discussions: write issues: write pull-requests: write - timeout-minutes: 15 + timeout-minutes: 45 env: + GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/supply-chain-review" GH_AW_COMMANDS: "[\"supply-chain-review\"]" GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5.4-nano" - GH_AW_ENGINE_VERSION: "1.0.55" + GH_AW_ENGINE_MODEL: "gpt-5-mini" + GH_AW_ENGINE_VERSION: "1.0.65" + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }} GH_AW_WORKFLOW_ID: "supply-chain-review" GH_AW_WORKFLOW_NAME: "Supply Chain Review" - GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/52467f0241079de71fe14591f97bdec7555ab545/.github/workflows/supply-chain-review.md" + GH_AW_WORKFLOW_SOURCE: "Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/Alfresco/alfresco-build-tools/blob/7479d99410acb8b63f78a5b17abb3b8ebec3a66d/.github/workflows/supply-chain-review.md" outputs: code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} @@ -1521,7 +1710,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@9fba95c4e1e3ce39704ccd39b440f6935715bc09 # v0.81.4 + uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1530,8 +1719,8 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Supply Chain Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/supply-chain-review.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.55" - GH_AW_INFO_AWF_VERSION: "v0.25.58" + GH_AW_INFO_VERSION: "1.0.65" + GH_AW_INFO_AWF_VERSION: "v0.27.11" GH_AW_INFO_BODY_MODIFIED: "false" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1551,7 +1740,7 @@ jobs: - name: Configure GH_HOST for enterprise compatibility id: ghes-host-config shell: bash - run: | + run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. GH_HOST="${GITHUB_SERVER_URL#https://}" @@ -1566,7 +1755,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.gradle-enterprise.cloud,adoptium.net,api.adoptium.net,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.foojay.io,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.osv.dev,api.scorecard.dev,api.snapcraft.io,archive.apache.org,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.azul.com,cdn.jsdelivr.net,central.sonatype.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,develocity.apache.org,dl.google.com,dlcdn.apache.org,download.eclipse.org,download.java.net,download.oracle.com,downloads.gradle-dn.com,esm.sh,ge.spockframework.org,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,gradle.org,host.docker.internal,jcenter.bintray.com,jdk.java.net,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,maven-central.storage-download.googleapis.com,maven.apache.org,maven.google.com,maven.oracle.com,maven.pkg.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,plugins-artifacts.gradle.org,plugins.gradle.org,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.gradle.org,repo.grails.org,repo.maven.apache.org,repo.spring.io,repo.yarnpkg.com,repo1.maven.org,repository.apache.org,s.symcb.com,s.symcd.com,scans-in.gradle.com,search.maven.org,security.ubuntu.com,services.gradle.org,skimdb.npmjs.com,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.java.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":1},\"add_labels\":{\"allowed\":[\"security:low\",\"security:medium\",\"security:high\"]},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{},\"submit_pull_request_review\":{\"max\":1}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":1},\"add_labels\":{\"allowed\":[\"security:low\",\"security:medium\",\"security:high\"]},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"remove_labels\":{\"allowed\":[\"security:low\",\"security:medium\",\"security:high\"]},\"report_incomplete\":{},\"submit_pull_request_review\":{\"max\":1}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/supply-chain-review.md b/.github/workflows/supply-chain-review.md index 4806d21555..86922f4e71 100644 --- a/.github/workflows/supply-chain-review.md +++ b/.github/workflows/supply-chain-review.md @@ -10,7 +10,7 @@ permissions: engine: id: copilot - model: gpt-5.4-nano + model: gpt-5-mini tools: github: @@ -30,9 +30,11 @@ safe-outputs: hide-older-comments: true add-labels: allowed: [security:low, security:medium, security:high] + remove-labels: + allowed: [security:low, security:medium, security:high] submit-pull-request-review: -source: Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545 +source: Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d --- # Supply Chain Review @@ -336,7 +338,8 @@ No suspicious patterns detected. Routine upgrade. ## Step 6 — Apply Label and Review Status -- Apply a label to the PR based on the highest risk level found: +- First, remove any `security:low`, `security:medium`, or `security:high` labels already present on the PR from a previous review — this PR may have been reviewed before (e.g., after a new commit), and stale risk labels must not remain alongside the new one. +- Then apply a label to the PR based on the highest risk level found: - `security:low` for LOW risk - `security:medium` for MEDIUM risk - `security:high` for HIGH or CRITICAL risk diff --git a/docs/content-services/components/search-text.component.md b/docs/content-services/components/search-text.component.md index 52531deb43..21eac80b27 100644 --- a/docs/content-services/components/search-text.component.md +++ b/docs/content-services/components/search-text.component.md @@ -46,9 +46,9 @@ Implements a [search widget](../../../lib/content-services/src/lib/search/models | field | string | Field to apply the query fragment to. Required value | | pattern | string | Regular expression pattern to restrict the format of the input text | | placeholder | string | Text displayed in the widget when the input string is empty | -| searchSuffix | string | Text to append always in the search of a string | -| searchPrefix | string | Text to prepend always in the search of a string | -| allowUpdateOnChange | `boolean` | Enable/Disable the update fire event when text has been changed. By default is true. | +| searchSuffix | string | Text to append in the search of a string. Only applied when wildcard matching is enabled (the `search-wildcards-enabled` app config flag, default `true`). | +| searchPrefix | string | Text to prepend in the search of a string. Only applied when wildcard matching is enabled (the `search-wildcards-enabled` app config flag, default `true`). | +| allowUpdateOnChange | `boolean` | Enable/Disable firing the search update when the text changes. Defaults to `false`; when disabled the search runs only when the user submits the value. | | hideDefaultAction | boolean | Show/hide the widget actions. By default is false. | ## Details diff --git a/docs/content-services/interfaces/search-widget.interface.md b/docs/content-services/interfaces/search-widget.interface.md index 8ce60c716d..3adab3e76c 100644 --- a/docs/content-services/interfaces/search-widget.interface.md +++ b/docs/content-services/interfaces/search-widget.interface.md @@ -115,8 +115,8 @@ that will be used when performing the actual query. Every query fragment is stored and retrieved using its widget `id`. It is your responsibility to format the query correctly. -Once your change to the query is finished, update the context and call the `update` method -to inform other components about the change: +Once your change to the query is finished, update the context and call the `execute` method +to rebuild and run the query so the results reflect your change: ```ts @Component({...}) @@ -126,12 +126,15 @@ export class MyComponent implements SearchWidget, OnInit { onUIChanged() { this.context.queryFragments[this.id] = `some query`; - this.context.update(); + void this.context.execute(); } } ``` +> **Note:** Earlier versions called `this.context.update()` here. The `update()` method and the +> `updated` stream have been removed; call `this.context.execute()` directly instead. + When executed, your fragment will be injected into the resulting query based on the category order in the application configuration file. ```text diff --git a/docs/content-services/services/search-query-builder.service.md b/docs/content-services/services/search-query-builder.service.md index a6d2c8b335..e1e583ddb2 100644 --- a/docs/content-services/services/search-query-builder.service.md +++ b/docs/content-services/services/search-query-builder.service.md @@ -2,7 +2,7 @@ Title: Search Query Builder service Added: v2.3.0 Status: Active -Last reviewed: 2019-03-19 +Last reviewed: 2026-06-29 --- # [Search Query Builder service](../../../lib/content-services/src/lib/search/services/search-query-builder.service.ts "Defined in search-query-builder.service.ts") @@ -11,6 +11,17 @@ Stores information from all the custom search and faceted search widgets, compil ## Class members +### Properties + +| Name | Type | Description | +| ----------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| userQuery | `string` | The raw query string typed by the user. Setting it stores the value, records it in `filterRawParams` and recomputes `parsedQuery` according to the current `searchMode`. | +| parsedQuery | `string` (read-only) | The query derived from `userQuery`. In `regular` mode the user terms are expanded against the configured fields (see `app:fields`) and optionally wildcarded; in `formula` mode it is identical to `userQuery`. | +| searchMode | `'regular' \| 'formula'` | Controls how `userQuery` is turned into `parsedQuery`. `regular` (default) parses the user input into a field query; `formula` uses the user input verbatim as an AFTS expression. | +| selectedConfigurationId | `string` | Id of the currently active search configuration. Setting it also records the value in `filterRawParams`. | +| encodedQuery | `string` (read-only) | The Base64-encoded `filterRawParams`, produced by `encodeQuery()` and written to the `q` route query parameter. | +| wildcardsEnabled | `boolean` (read-only) | Reads the `search-wildcards-enabled` app config flag (default `true`). When enabled, query terms are suffixed with `*` so partial matches are returned. | + ### Methods - **addFilterQuery**(query: `string`)
@@ -25,9 +36,10 @@ Stores information from all the custom search and faceted search widgets, compil - **Returns** `SearchRequest` - The finished query - **encodeQuery**()
Encodes query shards stored in `filterRawParams` property. -- **execute**(queryBody?: `SearchRequest`)
- Builds and executes the current query. - - _queryBody:_ `SearchRequest` - (Optional) +- **execute**(updateQueryParams: `boolean` = `true`, queryBody?: `SearchRequest`)
+ Builds and executes the current query, then emits the result on the `executed` stream. + - _updateQueryParams:_ `boolean` - (Optional) When `true` (default) the encoded query is written to the `q` route query parameter. Pass `false` to execute without updating the URL. + - _queryBody:_ `SearchRequest` - (Optional) Pre-built query to execute instead of building one from the current state. - **getDefaultConfiguration**(): [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)`|undefined`
- **Returns** [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)`|undefined` - @@ -70,6 +82,11 @@ Stores information from all the custom search and faceted search widgets, compil - **Returns** `boolean` - +- **isOperator**(input: `string`): `boolean`
+ Checks whether the supplied string is a logical `AND` or `OR` operator. Used when parsing a multi-word user query in `regular` search mode. + - _input:_ `string` - String to check + - **Returns** `boolean` - `true` if the trimmed string is `AND` or `OR`, otherwise `false` + - **loadConfiguration**(): [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)
- **Returns** [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts) - @@ -86,7 +103,10 @@ Stores information from all the custom search and faceted search widgets, compil Removes an existing bucket from a field. - _field:_ [`FacetField`](../../../lib/content-services/src/lib/search/models/facet-field.interface.ts) - The target field - _bucket:_ [`FacetFieldBucket`](../../../lib/content-services/src/lib/search/models/facet-field-bucket.interface.ts) - Bucket to remove -- **resetToDefaults**()
+- **resetToDefaults**(withNavigate: `boolean` = `false`, resetUserQuery: `boolean` = `true`)
+ Resets the query builder back to the default search configuration. + - _withNavigate:_ `boolean` - (Optional) When `true`, clears the `q` route query parameter while resetting. Defaults to `false`. + - _resetUserQuery:_ `boolean` - (Optional) When `true` (default), the `userQuery` and its parsed form are cleared. Pass `false` to keep the current user query while resetting the rest of the options. - **search**(queryBody: `SearchRequest`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>`
@@ -97,14 +117,13 @@ Stores information from all the custom search and faceted search widgets, compil - _scope:_ `RequestScope` - -- **update**(queryBody?: `SearchRequest`)
- Builds the current query and triggers the `updated` event. - - _queryBody:_ `SearchRequest` - (Optional) - **updateSearchQueryParams**()
Encodes the query and navigates to existing search route adding encoded query as a search param. -- **updateSelectedConfiguration**(index: `number`)
- - - _index:_ `number` - +- **updateSelectedConfiguration**(id: `string`, resetFilters: `boolean` = `true`, shouldExecute: `boolean` = `true`)
+ Switches the active search configuration to the one matching the supplied id (only relevant when multiple configurations are provided). + - _id:_ `string` - Id of the configuration to select + - _resetFilters:_ `boolean` - (Optional) When `true` (default), the current search options are reset before applying the new configuration. Pass `false` to keep them. + - _shouldExecute:_ `boolean` - (Optional) When `true` (default), the query is executed immediately after switching configuration. ## Details @@ -127,10 +146,6 @@ You can use custom widgets to populate and edit the following parts of the resul ```ts constructor(queryBuilder: SearchQueryBuilderService) { - queryBuilder.updated.subscribe(query => { - this.queryBuilder.execute(); - }); - queryBuilder.executed.subscribe(data => { this.onDataLoaded(data); }); @@ -138,6 +153,29 @@ constructor(queryBuilder: SearchQueryBuilderService) { } ``` +To run a search, build the query state (for example by setting `userQuery` or by letting a +search widget update `queryFragments`) and then call `execute()`. The result is delivered +through the `executed` stream. + +```ts +this.queryBuilder.userQuery = 'invoice'; +void this.queryBuilder.execute(); +``` + +> **Note:** Earlier versions exposed an `updated` stream and an `update()` method that built the +> query and emitted it so that a subscriber could call `execute()`. Both have been removed; build +> the query state and call `execute()` directly instead. + +### Search modes + +The builder supports two search modes, selected through the `searchMode` property: + +- **regular** (default) - the text in `userQuery` is treated as user input and parsed into a + field query. The query is split into terms, each term is matched against the fields listed in + the `app:fields` search configuration entry (falling back to `cm:name`), and a `*` wildcard is + appended when wildcards are enabled. Bare `AND`/`OR` words are preserved as operators. +- **formula** - the text in `userQuery` is used verbatim as an [AFTS](https://docs.alfresco.com/content-services/latest/develop/search-api/) expression, allowing callers that already build their own query syntax to bypass parsing. + > **Note:** From ADF 3.0.0, the query contains the `"facetFormat": "V2"` parameter so that all the responses have the same structure whether they come from search queries containing facetFields, facetQueries, grouped facetQueries or facetIntervals. ## Runtime Configuration diff --git a/docs/user-guide/authentication.md b/docs/user-guide/authentication.md index 53c69c408e..4126d1df7c 100644 --- a/docs/user-guide/authentication.md +++ b/docs/user-guide/authentication.md @@ -13,6 +13,50 @@ The authType parameter specifies the authentication method, with BASIC and OAUTH "authType": "OAUTH" } ``` + +## Session Timeout + +ADF can track user activity and show a countdown dialog before logging out an idle authenticated session. Register the feature with `provideSessionTimeout` in your application providers: + +```ts +import { ApplicationConfig } from '@angular/core'; +import { provideSessionTimeout } from '@alfresco/adf-core'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideSessionTimeout({ + enabled: true, + idleTimeoutMs: 30 * 60 * 1000, + dialogTimeoutMs: 60 * 1000 + }) + ] +}; +``` + +The same values can be configured in `app.config.json` with the `sessionTimeout` key: + +```json +{ + "sessionTimeout": { + "enabled": true, + "idleTimeoutMs": 1800000, + "dialogTimeoutMs": 60000 + } +} +``` + +Registering `provideSessionTimeout` enables the feature: `enabled` defaults to `true`, so the countdown is active unless you set `enabled` to `false` in `app.config.json` or in the options passed to `provideSessionTimeout`. The default idle timeout is 30 minutes, and the default dialog timeout is 60 seconds. Values passed to `provideSessionTimeout` take precedence over values from `app.config.json`. + +Applications can defer startup until another async condition is enabled: + +```ts +provideSessionTimeout({ + startWhen: () => featureService.isOn$('session-timeout-feature') +}); +``` + +Whether the user clicks **Log out** in the countdown dialog or leaves it unanswered until the countdown elapses, the normal logout flow runs and the user is redirected to the configured IdP/login page. The same logout is broadcast to other tabs so every session ends together. + # OAuth2 Configuration OAuth2 is a protocol that allows the application to authorize operations without exposing user credentials. The configuration includes several parameters essential for setting up OAuth2 authentication. diff --git a/docs/user-guide/search-building.md b/docs/user-guide/search-building.md index 344b4897cf..7b44f64e73 100644 --- a/docs/user-guide/search-building.md +++ b/docs/user-guide/search-building.md @@ -90,7 +90,6 @@ export class YourSearchComponent implements OnInit { ngOnInit() { this.queryBuilder.resetToDefaults(); - this.queryBuilder.updated.subscribe(() => void this.queryBuilder.execute()); this.queryBuilder.executed.subscribe((data) => { this.queryBuilder.paging.skipCount = 0; this.onSearchResultLoaded(data); @@ -103,7 +102,7 @@ export class YourSearchComponent implements OnInit { onSearchQueryChanged(string: string) { this.queryBuilder.userQuery = decodeURIComponent(string); - this.queryBuilder.update(); + void this.queryBuilder.execute(); } async onPaginationChanged(pagination: Pagination) { @@ -111,7 +110,7 @@ export class YourSearchComponent implements OnInit { maxItems: pagination.maxItems, skipCount: pagination.skipCount }; - this.queryBuilder.update(); + void this.queryBuilder.execute(); } } ``` diff --git a/docs/user-guide/search-configuration-guide.md b/docs/user-guide/search-configuration-guide.md index ff431bd378..946c319a97 100644 --- a/docs/user-guide/search-configuration-guide.md +++ b/docs/user-guide/search-configuration-guide.md @@ -16,6 +16,7 @@ This page describes how you can configure the search configuration. - [Steps Involved In Search Configuration](#steps-involved-in-search-configuration) - [Configuration](#configuration) - [Extra fields and filter queries](#extra-fields-and-filter-queries) +- [Search modes and wildcards](#search-modes-and-wildcards) - [Sorting](#sorting) - [Categories and widgets](#categories-and-widgets) - [Facet Fields](#facet-fields) @@ -267,6 +268,44 @@ settings: Note that the entries of the `filterQueries` array are joined using the `AND` operator. +### Search modes and wildcards + +When a user types free text into a search input, the [Search Query Builder Service](../../content-services/services/search-query-builder.service.md) turns that text (its `userQuery`) into the final query according to the configured *search mode*: + +- **regular** (default) - the user input is parsed into a field query. Each term is matched + against the fields listed in the `app:fields` array (falling back to `cm:name` when it is not + set), and a `*` wildcard is appended to each term when wildcards are enabled. Words that are + exactly `AND` or `OR` are preserved as logical operators. +- **formula** - the user input is passed through verbatim as an + [AFTS](https://docs.alfresco.com/content-services/latest/develop/search-api/) expression. Use + this mode when the caller already builds valid query syntax. + +The `app:fields` entry lists the fields used to expand a `regular` user query: + +```json +{ + "search": { + ... + "app:fields": ["cm:name", "cm:title", "cm:description"] + ... + } +} +``` + +For example, with the configuration above and wildcards enabled, the user query `report` is +expanded to `(cm:name:"report*" OR cm:title:"report*" OR cm:description:"report*")`. + +Wildcard matching is controlled by the top-level `search-wildcards-enabled` flag in +`app.config.json` (default `true`). When set to `false`, terms are matched exactly and the +trailing `*` is not added (this also disables the `searchPrefix`/`searchSuffix` of the +[Search text component](../content-services/components/search-text.component.md)): + +```json +{ + "search-wildcards-enabled": false +} +``` + ### Sorting The Sorting configuration section consists of two blocks: diff --git a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component-search.spec.ts b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component-search.spec.ts index c2ea758720..f63894769d 100644 --- a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component-search.spec.ts +++ b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component-search.spec.ts @@ -26,7 +26,6 @@ import { DocumentListComponent } from '../../document-list/components/document-l import { CustomResourcesService } from '../../document-list/services/custom-resources.service'; import { NodeEntryEvent, ShareDataRow } from '../../document-list'; import { SearchQueryBuilderService } from '../../search'; -import { mockSearchRequest } from '../../mock/search-query.mock'; import { SitesService } from '../../common/services/sites.service'; import { NodesApiService } from '../../common/services/nodes-api.service'; import { UnitTestingUtils } from '../../../../../core/src/lib/testing/unit-testing-utils'; @@ -161,43 +160,70 @@ describe('ContentNodeSelectorPanelComponent', () => { }); it('should the user query get updated when the user types in the search input', fakeAsync(() => { - const updateSpy = spyOn(searchQueryBuilderService, 'update'); typeToSearchBox('search-term'); tick(debounceSearch); fixture.detectChanges(); - expect(updateSpy).toHaveBeenCalled(); + expect(searchSpy).toHaveBeenCalled(); expect(searchQueryBuilderService.userQuery).toEqual('(search-term*)'); expect(component.searchTerm).toEqual('search-term'); })); + it('should set the query builder search mode to formula when the user types in the search input', fakeAsync(() => { + typeToSearchBox('search-term'); + + tick(debounceSearch); + fixture.detectChanges(); + + expect(searchQueryBuilderService.searchMode).toEqual('formula'); + })); + + it('should add the wildcard suffix to the user query when wildcards are enabled', fakeAsync(() => { + spyOnProperty(searchQueryBuilderService, 'wildcardsEnabled', 'get').and.returnValue(true); + typeToSearchBox('search-term'); + + tick(debounceSearch); + fixture.detectChanges(); + + expect(searchQueryBuilderService.userQuery).toEqual('(search-term*)'); + })); + + it('should NOT add the wildcard suffix to the user query when wildcards are disabled', fakeAsync(() => { + spyOnProperty(searchQueryBuilderService, 'wildcardsEnabled', 'get').and.returnValue(false); + typeToSearchBox('search-term'); + + tick(debounceSearch); + fixture.detectChanges(); + + expect(searchQueryBuilderService.userQuery).toEqual('(search-term)'); + })); + it('should perform a search when the search request gets updated and it is defined', fakeAsync(() => { typeToSearchBox('search-term'); tick(debounceSearch); fixture.detectChanges(); - expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); + expect(searchSpy).toHaveBeenCalledWith(false); })); - it('should NOT perform a search and clear the results when the search request gets updated and it is NOT defined', async () => { + it('should NOT perform a search and clear the results when the search input is empty', fakeAsync(() => { spyOn(component, 'clearSearch'); - searchQueryBuilderService.userQuery = ''; - searchQueryBuilderService.update(); + typeToSearchBox(''); + tick(debounceSearch); fixture.detectChanges(); - await fixture.whenStable(); expect(searchSpy).not.toHaveBeenCalled(); expect(component.clearSearch).toHaveBeenCalled(); - }); + })); it('should reset the search term when clicking the clear icon', async () => { component.searchTerm = 'search-term'; searchQueryBuilderService.userQuery = 'search-term'; - spyOn(component, 'clearSearch'); + spyOn(component, 'clear').and.callThrough(); fixture.detectChanges(); const clearIcon = getSearchIcon('clear'); @@ -207,7 +233,7 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(searchQueryBuilderService.userQuery).toEqual(''); expect(component.searchTerm).toEqual(''); - expect(component.clearSearch).toHaveBeenCalled(); + expect(component.clear).toHaveBeenCalled(); }); it('should load the results by calling the search api on search change', fakeAsync(() => { @@ -216,22 +242,21 @@ describe('ContentNodeSelectorPanelComponent', () => { tick(debounceSearch); fixture.detectChanges(); - expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); + expect(searchSpy).toHaveBeenCalledWith(false); })); it('should the query include the show files filterQuery', fakeAsync(() => { + spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough(); component.showFilesInResult = true; typeToSearchBox('search-term'); - const expectedRequest = mockSearchRequest; - expectedRequest.filterQueries.push({ - query: `TYPE:'cm:folder' OR TYPE:'cm:content'` - }); + const expectedRequest = `TYPE:'cm:folder' OR TYPE:'cm:content'`; tick(debounceSearch); fixture.detectChanges(); - expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false); + expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest); })); it('should reset the currently chosen node in case of starting a new search', fakeAsync(() => { @@ -253,6 +278,7 @@ describe('ContentNodeSelectorPanelComponent', () => { }); it('should perform a search when selecting a site with the correct query', fakeAsync(() => { + spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough(); typeToSearchBox('search-term'); tick(debounceSearch); @@ -261,14 +287,15 @@ describe('ContentNodeSelectorPanelComponent', () => { component.siteChanged({ entry: { guid: 'namek' } } as SiteEntry); - const expectedRequest = mockSearchRequest; - expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/namek'` }]; + const expectedRequest = `ANCESTOR:'workspace://SpacesStore/namek'`; expect(searchSpy.calls.count()).toBe(2); - expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false); + expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest); })); it('should create the query with the right parameters on changing the site selectBox value from a custom dropdown menu', fakeAsync(() => { + spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough(); component.dropdownSiteList = { list: { entries: [{ entry: { guid: '-sites-' } }, { entry: { guid: 'namek' } }] } } as SitePaging; component.documentList.folderNode = { id: 'fakeNodeId', isFolder: true, path: {} } as Node; fixture.detectChanges(); @@ -281,17 +308,12 @@ describe('ContentNodeSelectorPanelComponent', () => { component.siteChanged({ entry: { guid: '-sites-' } } as SiteEntry); - const expectedRequest = mockSearchRequest; - expectedRequest.filterQueries = [ - { - query: `ANCESTOR:'workspace://SpacesStore/-sites-' OR ANCESTOR:'workspace://SpacesStore/123456testId' OR ANCESTOR:'workspace://SpacesStore/09876543testId'` - } - ]; + const expectedRequest = `ANCESTOR:'workspace://SpacesStore/-sites-' OR ANCESTOR:'workspace://SpacesStore/123456testId' OR ANCESTOR:'workspace://SpacesStore/09876543testId'`; expect(searchSpy).toHaveBeenCalled(); expect(searchSpy.calls.count()).toBe(2); - expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); - expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false); + expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest); })); it('should get the corresponding node ids on search when a known alias is selected from dropdown', fakeAsync(() => { @@ -395,7 +417,7 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(component.clear).toHaveBeenCalled(); })); - it('should clear the search field, nodes and chosenNode when clicking on the X (clear) icon', async () => { + it('should reset the search term and user query when clearing the search', async () => { component.chosenNode = [entry]; component.nodePaging = { @@ -404,32 +426,31 @@ describe('ContentNodeSelectorPanelComponent', () => { } }; component.searchTerm = 'piccolo'; + searchQueryBuilderService.userQuery = 'piccolo'; component.showingSearchResults = true; component.clear(); expect(component.searchTerm).toBe(''); - expect(component.nodePaging).toEqual(null); - expect(component.chosenNode).toBeNull(); - expect(component.showingSearchResults).toBeFalsy(); + expect(searchQueryBuilderService.userQuery).toBe(''); + expect(searchSpy).toHaveBeenCalledWith(false); }); it('should the query restrict the search to the currentFolderId in case is defined', fakeAsync(() => { + spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough(); component.currentFolderId = 'my-root-id'; component.restrictRootToCurrentFolderId = true; component.ngOnInit(); typeToSearchBox('search-term'); tick(debounceSearch); - const expectedRequest = mockSearchRequest; - expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/my-root-id'` }]; + const expectedRequest = `ANCESTOR:'workspace://SpacesStore/my-root-id'`; - expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false); + expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest); })); it('should emit showingSearch event with true while searching', async () => { - searchQueryBuilderService.userQuery = 'mock-search-term'; - searchQueryBuilderService.update(); spyOn(customResourcesService, 'hasCorrespondingNodeIds').and.returnValue(true); const showingSearchSpy = spyOn(component.showingSearch, 'emit'); @@ -454,8 +475,7 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(showingSearchSpy).toHaveBeenCalledWith(false); })); - it('should emit showingResults event with false when clicking on the X (clear) icon', async () => { - const showingSearchSpy = spyOn(component.showingSearch, 'emit'); + it('should re-run the query with an empty user query when clicking on the X (clear) icon', async () => { component.chosenNode = [entry]; component.nodePaging = { @@ -464,17 +484,16 @@ describe('ContentNodeSelectorPanelComponent', () => { } }; component.searchTerm = 'piccolo'; - component.showingSearchResults = true; + searchQueryBuilderService.userQuery = 'piccolo'; component.clear(); - expect(component.showingSearchResults).toBe(false); - expect(showingSearchSpy).toHaveBeenCalledWith(false); + expect(component.searchTerm).toBe(''); + expect(searchQueryBuilderService.userQuery).toBe(''); + expect(searchSpy).toHaveBeenCalledWith(false); }); it('should emit showingResults event with false if search api fails', async () => { - searchQueryBuilderService.userQuery = 'mock-search-term'; - searchQueryBuilderService.update(); getCorrespondingNodeIdsSpy.and.throwError('Failed'); const showingSearchSpy = spyOn(component.showingSearch, 'emit'); await searchQueryBuilderService.execute(true, { query: { query: 'search' } }); @@ -488,15 +507,16 @@ describe('ContentNodeSelectorPanelComponent', () => { }); it('should the query restrict the search to the site and not to the currentFolderId in case is changed', async () => { + spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough(); searchQueryBuilderService.userQuery = 'search-term*'; component.currentFolderId = 'my-root-id'; component.restrictRootToCurrentFolderId = true; component.siteChanged({ entry: { guid: 'my-site-id' } } as SiteEntry); - const expectedRequest = mockSearchRequest; - expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/my-site-id'` }]; + const expectedRequest = `ANCESTOR:'workspace://SpacesStore/my-site-id'`; - expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); + expect(searchSpy).toHaveBeenCalledWith(false); + expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest); }); it('should restrict the breadcrumb to the currentFolderId in case restrictedRoot is true', async () => { @@ -563,10 +583,12 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(searchSpy.calls.count()).toBe(2); - component.clear(); + typeToSearchBox(''); + tick(debounceSearch); expect(component.searchTerm).toBe(''); expect(component.folderIdToShow).toBe('namek'); + flush(); })); it('should show the current folder content instead of search results if search was not performed', async () => { @@ -638,25 +660,23 @@ describe('ContentNodeSelectorPanelComponent', () => { fixture.detectChanges(); const documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); expect(documentList).not.toBeNull(); - expect(component.hasValidQuery).toEqual(true); expect(documentList.componentInstance.currentFolderId).toBeNull(); done(); }, 300); }); - it('should not show the result list when results are returned but there is no search term typed', (done) => { - searchQueryBuilderService.userQuery = ''; - searchQueryBuilderService.update(); + it('should not run a search nor show the result list when there is no search term typed', fakeAsync(() => { + spyOn(component, 'clearSearch').and.callThrough(); - setTimeout(() => { - triggerSearchResults(fakeResultSetPaging); - fixture.detectChanges(); + typeToSearchBox(''); + tick(debounceSearch); + fixture.detectChanges(); - expect(component.hasValidQuery).toEqual(false); - expect(component.showingSearchResults).toEqual(false); - done(); - }, 300); - }); + expect(searchSpy).not.toHaveBeenCalled(); + expect(component.clearSearch).toHaveBeenCalled(); + expect(component.showingSearchResults).toEqual(false); + flush(); + })); it('should highlight the results when search was performed in the next timeframe', (done) => { typeToSearchBox('My'); @@ -673,7 +693,7 @@ describe('ContentNodeSelectorPanelComponent', () => { }, 300); }); - it('should show the default text instead of result list if search was cleared', (done) => { + it('should reset the search term and re-run an empty query when the clear button is clicked', (done) => { typeToSearchBox(); setTimeout(() => { @@ -683,12 +703,13 @@ describe('ContentNodeSelectorPanelComponent', () => { fixture.whenStable().then(() => { const clearButton = getSearchIcon('clear'); expect(clearButton).not.toBeNull(); + searchSpy.calls.reset(); clearButton.triggerEventHandler('click', {}); fixture.detectChanges(); - const documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); - expect(documentList).not.toBeNull(); - expect(documentList.componentInstance.currentFolderId).toBe('cat-girl-nuku-nuku'); + expect(component.searchTerm).toBe(''); + expect(searchQueryBuilderService.userQuery).toBe(''); + expect(searchSpy).toHaveBeenCalledWith(false); done(); }); }, 300); @@ -709,20 +730,20 @@ describe('ContentNodeSelectorPanelComponent', () => { flush(); })); - it('should set the folderIdToShow to the default "currentFolderId" if siteId is undefined', (done) => { + it('should reset the folder to the selected site and fall back to currentFolderId when site is undefined', () => { component.siteChanged({ entry: { guid: 'Kame-Sennin Muten Roshi' } } as SiteEntry); + component.resetFolderToShow(); fixture.detectChanges(); let documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); expect(documentList.componentInstance.currentFolderId).toBe('Kame-Sennin Muten Roshi'); component.siteChanged({ entry: { guid: undefined } } as SiteEntry); + component.resetFolderToShow(); fixture.detectChanges(); documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); expect(documentList.componentInstance.currentFolderId).toBe('cat-girl-nuku-nuku'); - - done(); }); describe('Pagination "Load more" button', () => { @@ -752,7 +773,7 @@ describe('ContentNodeSelectorPanelComponent', () => { }); it('should set its loading state to true to perform a new search', async () => { - component.prepareDialogForNewSearch(mockSearchRequest); + component.prepareDialogForNewSearch(); fixture.detectChanges(); await fixture.whenStable(); @@ -764,13 +785,14 @@ describe('ContentNodeSelectorPanelComponent', () => { expect(paginationLoading).not.toBeNull(); }); - it('Should infinite pagination target be null when we use it for search ', fakeAsync(() => { + it('Should keep the document list as infinite pagination target while searching', fakeAsync(() => { component.showingSearchResults = true; typeToSearchBox('shenron'); tick(debounceSearch); fixture.detectChanges(); - expect(component.target).toBeNull(); + expect(component.target).toEqual(component.documentList); + flush(); })); it('Should infinite pagination target be present when search finish', () => { diff --git a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.spec.ts b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.spec.ts index e43ceb0da0..be8db30ea9 100644 --- a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.spec.ts +++ b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.spec.ts @@ -270,7 +270,6 @@ describe('ContentNodeSelectorPanelComponent', () => { it('should not show the breadcrumb if search was performed as last action', async () => { searchQueryBuilderService.userQuery = 'mock-search-term'; - searchQueryBuilderService.update(); triggerSearchResults(fakeResultSetPaging); fixture.detectChanges(); @@ -291,7 +290,6 @@ describe('ContentNodeSelectorPanelComponent', () => { it('should show the breadcrumb in search results for a valid node selection', async () => { searchQueryBuilderService.userQuery = 'mock-search-term'; - searchQueryBuilderService.update(); triggerSearchResults(fakeResultSetPaging); const chosenNode = new Node({ path: { elements: [{ name: 'one' }] } }); @@ -306,7 +304,6 @@ describe('ContentNodeSelectorPanelComponent', () => { it('should show the breadcrumb in search results even for an invalid node selection', async () => { component.isSelectionValid = (node: Node) => node.isFile; searchQueryBuilderService.userQuery = 'mock-search-term'; - searchQueryBuilderService.update(); triggerSearchResults(fakeResultSetPaging); const chosenNode = new Node({ path: { elements: [{ name: 'fake-path' }] }, isFile: false, isFolder: true }); diff --git a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.ts b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.ts index b9e27e3015..007e06f7f2 100644 --- a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.ts +++ b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel/content-node-selector-panel.component.ts @@ -33,7 +33,7 @@ import { } from '@alfresco/adf-core'; import { FileUploadCompleteEvent, FileUploadDeleteEvent, NodesApiService, SitesService, UploadService } from '../../common'; import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; -import { Node, NodeEntry, NodePaging, Pagination, RequestScope, SearchRequest, SiteEntry, SitePaging } from '@alfresco/js-api'; +import { Node, NodeEntry, NodePaging, Pagination, RequestScope, SiteEntry, SitePaging } from '@alfresco/js-api'; import { DocumentListComponent } from '../../document-list/components/document-list.component'; import { RowFilter } from '../../document-list/data/row-filter.model'; import { ImageResolver } from '../../document-list/data/image-resolver.model'; @@ -292,7 +292,6 @@ export class ContentNodeSelectorPanelComponent implements OnInit { folderIdToShow: string | null = null; breadcrumbFolderTitle: string | null = null; startSiteGuid: string | null = null; - hasValidQuery: boolean = false; showHeader = ShowHeaderMode.Never; @ViewChild(InfinitePaginationComponent, { static: true }) @@ -334,26 +333,16 @@ export class ContentNodeSelectorPanelComponent implements OnInit { .pipe(debounceTime(this.debounceSearch), takeUntilDestroyed(this.destroyRef)) .subscribe((searchValue: string) => { this.searchTerm = searchValue; - this.queryBuilderService.userQuery = searchValue.length > 0 ? `${searchValue}*` : searchValue; - this.queryBuilderService.update(); + if (this.searchTerm) { + this.executeSearch(searchValue); + } else { + this.resetFolderToShow(); + this.clearSearch(); + } }); - this.queryBuilderService.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((searchRequest) => { - if (searchRequest) { - this.hasValidQuery = true; - this.prepareDialogForNewSearch(searchRequest); - this.queryBuilderService.execute(false, searchRequest); - } else { - this.hasValidQuery = false; - this.resetFolderToShow(); - this.clearSearch(); - } - }); - this.queryBuilderService.executed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((results: NodePaging) => { - if (this.hasValidQuery) { - this.showSearchResults(results); - } + this.showSearchResults(results); }); this.userPreferencesService @@ -451,7 +440,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit { this.siteId = chosenSite.entry.guid; this.setTitleIfCustomSite(chosenSite); this.siteChange.emit(chosenSite.entry.title); - this.queryBuilderService.update(); + this.executeSearch(this.searchTerm); } /** @@ -473,14 +462,8 @@ export class ContentNodeSelectorPanelComponent implements OnInit { /** * Prepares the dialog for a new search - * - * @param searchRequest request options */ - prepareDialogForNewSearch(searchRequest: SearchRequest): void { - this.target = searchRequest ? null : this.documentList; - if (this.target) { - this.infinitePaginationComponent.reset(); - } + prepareDialogForNewSearch(): void { this.folderIdToShow = null; this.preselectedNodes = []; this.loadingSearchResults = true; @@ -494,7 +477,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit { clear(): void { this.searchTerm = ''; this.queryBuilderService.userQuery = ''; - this.queryBuilderService.update(); + this.executeSearch(this.searchTerm); } /** @@ -619,7 +602,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit { this.queryBuilderService.paging.skipCount = pagination.skipCount; if (this.searchTerm.length > 0) { - this.queryBuilderService.update(); + this.executeSearch(this.searchTerm); } } @@ -696,4 +679,12 @@ export class ContentNodeSelectorPanelComponent implements OnInit { skipCount: this.DEFAULT_PAGINATION.skipCount }; } + + private executeSearch(searchValue: string): void { + this.prepareDialogForNewSearch(); + this.queryBuilderService.searchMode = 'formula'; + const wildcardSuffix = this.queryBuilderService.wildcardsEnabled ? '*' : ''; + this.queryBuilderService.userQuery = searchValue.length > 0 ? `(${searchValue}${wildcardSuffix})` : searchValue; + this.queryBuilderService.execute(false); + } } diff --git a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts index 6e13882fad..008e5af98c 100644 --- a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts +++ b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts @@ -23,6 +23,8 @@ import { SimpleChange } from '@angular/core'; import { SearchHeaderQueryBuilderService } from './../../../search/services/search-header-query-builder.service'; import { FilterHeaderComponent } from './filter-header.component'; import { provideRouter } from '@angular/router'; +import { SearchCategory } from '@alfresco/adf-content-services'; +import { NodePaging } from '@alfresco/js-api'; describe('FilterHeaderComponent', () => { let fixture: ComponentFixture; @@ -150,7 +152,7 @@ describe('FilterHeaderComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - expect(Object.keys(queryBuilder.filterRawParams).length).toBe(0); + expect(queryBuilder.filterRawParams['name']).toBeUndefined(); component.value = { name: 'pinocchio' }; const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); @@ -158,11 +160,41 @@ describe('FilterHeaderComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - expect(Object.keys(queryBuilder.filterRawParams).length).toBe(1); expect(queryBuilder.filterRawParams['name']).toBe('pinocchio'); expect(queryBuilder.queryFragments['name']).toBe('pinocchio'); }); + it('should build a wildcard field query fragment for the queryName filter when wildcards are enabled', async () => { + spyOn(queryBuilder, 'setCurrentRootFolderId'); + spyOn(queryBuilder, 'isCustomSourceNode').and.returnValue(false); + spyOnProperty(queryBuilder, 'wildcardsEnabled', 'get').and.returnValue(true); + queryBuilder.categories = [{ id: 'queryName', component: { settings: { field: 'cm:name' } } } as SearchCategory]; + + component.value = { queryName: 'pinocchio' }; + const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); + component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(queryBuilder.filterRawParams['queryName']).toBe('pinocchio'); + expect(queryBuilder.queryFragments['queryName']).toBe(`cm:name:'*pinocchio*'`); + }); + + it('should build a non-wildcard field query fragment for the queryName filter when wildcards are disabled', async () => { + spyOn(queryBuilder, 'setCurrentRootFolderId'); + spyOn(queryBuilder, 'isCustomSourceNode').and.returnValue(false); + spyOnProperty(queryBuilder, 'wildcardsEnabled', 'get').and.returnValue(false); + queryBuilder.categories = [{ id: 'queryName', component: { settings: { field: 'cm:name' } } } as SearchCategory]; + + component.value = { queryName: 'pinocchio' }; + const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); + component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(queryBuilder.queryFragments['queryName']).toBe(`cm:name:'pinocchio'`); + }); + it('should emit filterSelection when a filter is changed', (done) => { spyOn(queryBuilder, 'getActiveFilters').and.returnValue([{ key: 'name', value: 'pinocchio' }]); @@ -193,7 +225,7 @@ describe('FilterHeaderComponent', () => { it('should emit searchResultsReady when search query builder executes', (done) => { fixture.detectChanges(); // Initialize component (triggers ngOnInit) - const mockNodePaging: any = { list: { entries: [] } }; + const mockNodePaging: NodePaging = { list: { entries: [] } }; component.searchResultsReady.subscribe((nodePaging) => { expect(nodePaging).toBe(mockNodePaging); diff --git a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts index d1ede34a2d..7515018fc6 100644 --- a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts +++ b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts @@ -115,9 +115,16 @@ export class FilterHeaderComponent implements OnInit, OnChanges { const operator = this.searchFilterQueryBuilder.getOperatorForFilterId(key) || 'OR'; this.searchFilterQueryBuilder.filterRawParams[key] = this.value[key]; - this.searchFilterQueryBuilder.queryFragments[key] = Array.isArray(this.value[key]) - ? this.value[key].join(` ${operator} `) - : this.value[key]; + if (key === 'queryName') { + const filterConfig = this.searchFilterQueryBuilder.categories.find((category) => category.id === key); + const wildcardSuffix = this.searchFilterQueryBuilder.wildcardsEnabled ? '*' : ''; + this.searchFilterQueryBuilder.queryFragments[key] = + `${filterConfig.component.settings.field}:'${wildcardSuffix}${this.value[key]}${wildcardSuffix}'`; + } else { + this.searchFilterQueryBuilder.queryFragments[key] = Array.isArray(this.value[key]) + ? this.value[key].join(` ${operator} `) + : this.value[key]; + } }); } this.searchFilterQueryBuilder.setCurrentRootFolderId(currentFolderId); diff --git a/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.spec.ts b/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.spec.ts index 9330c57f48..09c0f8926b 100644 --- a/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.spec.ts @@ -44,7 +44,7 @@ describe('SearchCheckListComponent', () => { queryFragments: {}, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy() + execute: jasmine.createSpy('execute') } as any; }); @@ -134,7 +134,7 @@ describe('SearchCheckListComponent', () => { component.reset(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.filterRawParams[component.id]).toBeUndefined(); }); @@ -226,7 +226,7 @@ describe('SearchCheckListComponent', () => { fixture.detectChanges(); expect(component.context.queryFragments[component.id]).toBe(''); - expect(component.context.update).not.toHaveBeenCalled(); + expect(component.context.execute).not.toHaveBeenCalled(); }); it('should populate filter state when populate filters event has been observed', () => { diff --git a/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.ts b/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.ts index 5551aa2078..d8bd4ca28b 100644 --- a/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.ts +++ b/lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.ts @@ -107,7 +107,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit { this.clearOptions(); if (this.id && this.context && this.enableChangeUpdate) { this.updateDisplayValue(); - this.context.update(); + this.context.execute(); } } @@ -128,7 +128,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit { if (this.id && this.context) { this.updateDisplayValue(); if (updateContext) { - this.context.update(); + this.context.execute(); } } } @@ -178,7 +178,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit { this.context.queryFragments[this.id] = query; this.updateDisplayValue(); if (updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts index bc2af8a734..e13c2230ca 100644 --- a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts @@ -309,6 +309,13 @@ describe('SearchChipAutocompleteInputComponent', () => { expect(inputChangedSpy).toHaveBeenCalledOnceWith('test-value'); }); + it('should not emit input value when input is empty', async () => { + const inputChangedSpy = spyOn(component.inputChanged, 'emit'); + enterNewInputValue(''); + await fixture.whenStable(); + expect(inputChangedSpy).not.toHaveBeenCalled(); + }); + describe('isOptionSelected', () => { beforeEach(() => { component.autocompleteOptions = [{ value: 'option1' }, { value: 'option2' }]; diff --git a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts index cb811d0655..ea26560a50 100644 --- a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts @@ -108,8 +108,10 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnChanges { takeUntilDestroyed(this.destroyRef) ) .subscribe((value: string) => { - this.filteredOptions = this.filter(this.autocompleteOptions, value); - this.inputChanged.emit(value); + if (value) { + this.filteredOptions = this.filter(this.autocompleteOptions, value); + this.inputChanged.emit(value); + } }); this.onReset$?.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.reset()); this.selectedOptions = this.preselectedOptions ?? []; diff --git a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.spec.ts b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.spec.ts index 4110fbf818..c1878bd662 100644 --- a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.spec.ts @@ -79,7 +79,7 @@ describe('SearchDateRangeTabbedComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { hideDefaultAction: false, @@ -210,7 +210,7 @@ describe('SearchDateRangeTabbedComponent', () => { expect(component.context.filterRawParams[component.id].modifiedDate).toEqual(anyMockDate); }); - it('should trigger context.update() when values are submitted', () => { + it('should trigger context.execute() when values are submitted', () => { component.onDateRangedValueChanged(betweenMockData, 'createdDate'); component.onDateRangedValueChanged(inLastMockData, 'modifiedDate'); fixture.detectChanges(); @@ -221,7 +221,7 @@ describe('SearchDateRangeTabbedComponent', () => { `createdDate:['${formatISO(startOfDay(betweenMockData.betweenStartDate))}' TO '${formatISO(endOfDay(betweenMockData.betweenEndDate))}']` + ` AND modifiedDate:['${formatISO(startOfDay(inLastStartDate))}' TO '${formatISO(endOfToday())}']`; expect(component.context.queryFragments['dateRange']).toEqual(query); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should clear values and search filter when widget is reset', (done) => { @@ -231,7 +231,7 @@ describe('SearchDateRangeTabbedComponent', () => { expect(component.combinedDisplayValue).toBe(''); expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.context.queryFragments['dateRange']).toEqual(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); component.fields.forEach((field) => expect(component.context.filterRawParams[field]).toBeUndefined()); done(); }); diff --git a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.ts b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.ts index e4c6ab2200..e40f3071e1 100644 --- a/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.ts +++ b/lib/content-services/src/lib/search/components/search-date-range-tabbed/search-date-range-tabbed.component.ts @@ -138,7 +138,7 @@ export class SearchDateRangeTabbedComponent implements SearchWidget, OnInit { this.context.queryFragments[this.id] = this.combinedQuery; this.displayValue$.next(this.combinedDisplayValue); if (this.id && this.context && updateContext) { - this.context.update(); + this.context.execute(); } } onDateRangedValueChanged(value: Partial, field: string) { diff --git a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts index 75ef62ec92..b495fba6f2 100644 --- a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts @@ -46,7 +46,7 @@ describe('SearchDatetimeRangeComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { field: 'cm:created' }; }); @@ -128,7 +128,7 @@ describe('SearchDatetimeRangeComponent', () => { component.reset(); expect(component.context.queryFragments.createdDatetimeRange).toEqual(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should update the query in UTC format when values change', async () => { @@ -150,7 +150,7 @@ describe('SearchDatetimeRangeComponent', () => { expect(component.context.queryFragments[component.id]).toEqual(expectedQuery); expect(component.context.filterRawParams[component.id]).toEqual({ start: expectedFromDate, end: expectedToDate }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should be able to update the query in UTC format from a GMT format', async () => { @@ -175,7 +175,7 @@ describe('SearchDatetimeRangeComponent', () => { expect(startDate).toContain('2021-02-24'); expect(endDate).toContain('2021-02-28'); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); // Verify the query structure is correct without hardcoding exact timezone values const query = component.context.queryFragments[component.id]; diff --git a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts index d9110b2325..e3a0c9a53a 100644 --- a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts +++ b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts @@ -171,7 +171,7 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit { filterParam.end = end; this.updateDisplayValue(); if (updateContext) { - this.context.update(); + this.context.execute(); } } } @@ -242,7 +242,7 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit { private updateQuery() { if (this.id && this.context) { this.updateDisplayValue(); - this.context.update(); + this.context.execute(); } } diff --git a/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.spec.ts b/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.spec.ts index ea9d11d69f..9271bac47c 100644 --- a/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.spec.ts @@ -44,7 +44,7 @@ describe('SearchFacetFieldComponent', () => { }); it('should update bucket model and query builder on facet toggle', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'addUserFacetBucket').and.callThrough(); const event: any = { checked: true }; @@ -57,12 +57,12 @@ describe('SearchFacetFieldComponent', () => { expect(bucket.checked).toBeTruthy(); expect(queryBuilder.addUserFacetBucket).toHaveBeenCalledWith(facetField.field, bucket); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled(); }); it('should update bucket model and query builder on facet un-toggle', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); const event: any = { checked: false }; @@ -75,12 +75,12 @@ describe('SearchFacetFieldComponent', () => { component.onToggleBucket(event, facetField, bucket); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(facetField.field, bucket); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled(); }); it('should unselect facet query and update builder', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); const event: any = { checked: false }; @@ -94,17 +94,17 @@ describe('SearchFacetFieldComponent', () => { expect(query.checked).toEqual(false); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(facetField.field, query); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled(); }); it('should update query builder only when has bucket to unselect', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); const field: FacetField = { field: 'f1', label: 'f1' }; component.onToggleBucket({ checked: true } as any, field, null); - expect(queryBuilder.update).not.toHaveBeenCalled(); + expect(queryBuilder.execute).not.toHaveBeenCalled(); }); it('should allow to to reset selected buckets', () => { @@ -166,7 +166,7 @@ describe('SearchFacetFieldComponent', () => { }); it('should update query builder upon resetting buckets', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); const buckets: FacetFieldBucket[] = [ { label: 'bucket1', checked: false, count: 1, filterQuery: 'q1' }, @@ -183,6 +183,6 @@ describe('SearchFacetFieldComponent', () => { fixture.detectChanges(); component.resetSelectedBuckets(field); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); }); }); diff --git a/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.ts b/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.ts index 20f1accdd3..95d90b0c1b 100644 --- a/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.ts +++ b/lib/content-services/src/lib/search/components/search-facet-field/search-facet-field.component.ts @@ -69,7 +69,7 @@ export class SearchFacetFieldComponent implements FacetWidget { this.searchFacetFiltersService.updateSelectedBuckets(); if (this.canUpdateOnChange) { this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } } } @@ -81,7 +81,7 @@ export class SearchFacetFieldComponent implements FacetWidget { this.searchFacetFiltersService.updateSelectedBuckets(); if (this.canUpdateOnChange) { this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } } } @@ -101,7 +101,7 @@ export class SearchFacetFieldComponent implements FacetWidget { } this.searchFacetFiltersService.updateSelectedBuckets(); if (this.canUpdateOnChange) { - this.queryBuilder.update(); + this.queryBuilder.execute(); } } } @@ -125,11 +125,11 @@ export class SearchFacetFieldComponent implements FacetWidget { reset(): void { this.resetSelectedBuckets(this.field); this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } submitValues(): void { this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } } diff --git a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts index 749ad5fa1d..082f5ab90a 100644 --- a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts @@ -47,7 +47,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { field: 'test', @@ -102,7 +102,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => { clearBtn.click(); expect(component.context.queryFragments[component.id]).toBe(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.selectedOptions).toEqual([]); expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.context.filterRawParams[component.id]).toBeUndefined(); @@ -116,7 +116,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => { applyBtn.click(); fixture.detectChanges(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe('test:"option2" OR test:"option1"'); expect(component.context.filterRawParams[component.id]).toEqual([{ value: 'option2' }, { value: 'option1' }]); diff --git a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts index ce02115a9a..e97c291ad9 100644 --- a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts @@ -111,7 +111,6 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI this.selectedOptions = selectedOptions; if (this.enableChangeUpdate) { this.updateQuery(); - this.context.update(); } } @@ -160,7 +159,7 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI } this.context.queryFragments[this.id] = queryFragments.join(' OR '); if (updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.spec.ts index 5c800df6d1..995a0bc130 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.spec.ts @@ -33,7 +33,7 @@ describe('SearchFacetTabbedContentComponent', () => { let queryBuilder: SearchQueryBuilderService; let searchFacetService: SearchFacetFiltersService; let loader: HarnessLoader; - let queryBuilderUpdateSpy: jasmine.Spy; + let queryBuilderExecuteSpy: jasmine.Spy; beforeEach(() => { TestBed.configureTestingModule({ @@ -44,7 +44,7 @@ describe('SearchFacetTabbedContentComponent', () => { component = fixture.componentInstance; queryBuilder = TestBed.inject(SearchQueryBuilderService); searchFacetService = TestBed.inject(SearchFacetFiltersService); - queryBuilderUpdateSpy = spyOn(queryBuilder, 'update').and.stub(); + queryBuilderExecuteSpy = spyOn(queryBuilder, 'execute').and.stub(); const facet1: FacetField = { type: 'field', label: 'field', field: 'field', buckets: new SearchFilterList() }; const facet2: FacetField = { type: 'field', label: 'field2', field: 'field2', buckets: new SearchFilterList() }; @@ -185,7 +185,7 @@ describe('SearchFacetTabbedContentComponent', () => { spyOn(searchFacetService, 'updateSelectedBuckets').and.callThrough(); component.submitValues(); expect(component.submitValues).toHaveBeenCalled(); - expect(queryBuilderUpdateSpy).toHaveBeenCalled(); + expect(queryBuilderExecuteSpy).toHaveBeenCalled(); expect(component.updateDisplayValue).toHaveBeenCalled(); expect(searchFacetService.updateSelectedBuckets).toHaveBeenCalled(); }); @@ -193,12 +193,12 @@ describe('SearchFacetTabbedContentComponent', () => { it('should update search query and display value on reset', () => { spyOn(component, 'updateDisplayValue').and.callThrough(); component.reset(); - expect(queryBuilderUpdateSpy).toHaveBeenCalled(); + expect(queryBuilderExecuteSpy).toHaveBeenCalled(); expect(component.updateDisplayValue).toHaveBeenCalled(); }); - it('should not call queryBuilder.update on options change', () => { + it('should not call queryBuilder.execute on options change', () => { component.onOptionsChange([{ value: 'test' }], 'field'); - expect(queryBuilderUpdateSpy).not.toHaveBeenCalled(); + expect(queryBuilderExecuteSpy).not.toHaveBeenCalled(); }); }); diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.ts index 6e92e01226..3e9d0bd528 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip-tabbed/search-facet-tabbed-content.component.ts @@ -111,14 +111,14 @@ export class SearchFacetTabbedContentComponent implements OnInit, OnChanges, Fac this.resetSubject$.next(); this.updateUserFacetBuckets(); this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } submitValues() { this.updateUserFacetBuckets(); this.searchFacetFiltersService.updateSelectedBuckets(); this.updateDisplayValue(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } optionComparator(option1: AutocompleteOption, option2: AutocompleteOption): boolean { diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip/search-facet-chip.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip/search-facet-chip.component.spec.ts index 20a8f80f91..127ba658f0 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip/search-facet-chip.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-facet-chip/search-facet-chip.component.spec.ts @@ -41,7 +41,7 @@ describe('SearchFacetChipComponent', () => { fixture = TestBed.createComponent(SearchFacetChipComponent); component = fixture.componentInstance; queryBuilder = TestBed.inject(SearchQueryBuilderService); - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); component.field = { type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList() }; fixture.detectChanges(); @@ -55,7 +55,7 @@ describe('SearchFacetChipComponent', () => { const applyButton = await menu.getHarness(MatButtonHarness.with({ selector: '#apply-filter-button' })); await applyButton.click(); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); }); it('should update search query on cancel click', async () => { @@ -65,7 +65,7 @@ describe('SearchFacetChipComponent', () => { const cancelButton = await menu.getHarness(MatButtonHarness.with({ selector: '#cancel-filter-button' })); await cancelButton.click(); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); }); it('should display arrow down icon and not disable the chip when items are loaded', async () => { diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-chips.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-chips.component.spec.ts index c11e2fe14a..a8366eed8f 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-chips.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-chips.component.spec.ts @@ -223,7 +223,7 @@ describe('SearchFilterChipsComponent', () => { }); it('should update query builder upon resetting selected queries', async () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); const queryResponse = { @@ -247,7 +247,7 @@ describe('SearchFilterChipsComponent', () => { facetField.resetSelectedBuckets(queryResponse); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledTimes(3); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); for (const entry of searchFacetFiltersService.responseFacets[0].buckets.items) { expect(entry.checked).toEqual(false); @@ -276,7 +276,7 @@ describe('SearchFilterChipsComponent', () => { }); it('should be update the search query when name changed', async () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); appConfigService.config.search = searchFilter; queryBuilder.resetToDefaults(); @@ -290,7 +290,7 @@ describe('SearchFilterChipsComponent', () => { const inputElement = fixture.debugElement.query(By.css('[data-automation-id="search-field-Name"] input')); inputElement.triggerEventHandler('change', { target: { value: '*' } }); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); queryBuilder.executed.next(mockSearchResult); @@ -404,7 +404,7 @@ describe('SearchFilterChipsComponent', () => { queryBuilder.executed.next(mockSearchResult); fixture.detectChanges(); - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); const searchChip = fixture.debugElement.query(By.css(`[data-automation-id="search-filter-chip-Size facet queries"]`)); searchChip.triggerEventHandler('click', { stopPropagation: () => null }); @@ -441,7 +441,7 @@ describe('SearchFilterChipsComponent', () => { await filteredMenu[0].check(); expect(await filteredMenu[0].getLabelText()).toEqual('Extra Small (10239)'); - expect(queryBuilder.update).toHaveBeenCalledTimes(1); + expect(queryBuilder.execute).toHaveBeenCalledTimes(1); }); }); }); diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.spec.ts index b50258f57e..7e84809ccc 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-widget-chip/search-widget-chip.component.spec.ts @@ -45,7 +45,7 @@ describe('SearchWidgetChipComponent', () => { queryBuilder = TestBed.inject(SearchQueryBuilderService); fixture = TestBed.createComponent(SearchWidgetChipComponent); component = fixture.componentInstance; - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); component.category = simpleCategories[1]; fixture.detectChanges(); @@ -60,7 +60,7 @@ describe('SearchWidgetChipComponent', () => { const applyButton = fixture.debugElement.query(By.css('#apply-filter-button')); applyButton.triggerEventHandler('click', {}); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); }); it('should update search query on cancel click', async () => { @@ -69,7 +69,7 @@ describe('SearchWidgetChipComponent', () => { const applyButton = fixture.debugElement.query(By.css('#cancel-filter-button')); applyButton.triggerEventHandler('click', {}); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); }); it('should display arrow down icon', async () => { diff --git a/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts index ccbb0de7e1..93115480a2 100644 --- a/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts @@ -240,7 +240,7 @@ describe('SearchFilterComponent', () => { }); it('should update query builder upon resetting selected queries', () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); const queryResponse = { @@ -258,7 +258,7 @@ describe('SearchFilterComponent', () => { facetField.resetSelectedBuckets(queryResponse); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledTimes(3); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); for (const entry of searchFacetFiltersService.responseFacets[0].buckets.items) { expect(entry.checked).toEqual(false); @@ -320,7 +320,7 @@ describe('SearchFilterComponent', () => { }); it('should be update the search query when name changed', async () => { - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); appConfigService.config.search = searchFilter; queryBuilder.resetToDefaults(); @@ -333,7 +333,7 @@ describe('SearchFilterComponent', () => { const inputElement = fixture.debugElement.query(By.css('[data-automation-id="expansion-panel-Name"] input')); inputElement.triggerEventHandler('change', { target: { value: '*' } }); - expect(queryBuilder.update).toHaveBeenCalled(); + expect(queryBuilder.execute).toHaveBeenCalled(); queryBuilder.executed.next(mockSearchResult); fixture.detectChanges(); @@ -441,7 +441,7 @@ describe('SearchFilterComponent', () => { queryBuilder.executed.next(mockSearchResult); fixture.detectChanges(); - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); const panel = await loader.getHarness( MatExpansionPanelHarness.with({ @@ -470,7 +470,7 @@ describe('SearchFilterComponent', () => { expect(labels).toEqual(stepOne); await checkboxes[0].check(); - expect(queryBuilder.update).toHaveBeenCalledTimes(1); + expect(queryBuilder.execute).toHaveBeenCalledTimes(1); }); it('should preserve the filter state if other fields edited', async () => { @@ -481,7 +481,7 @@ describe('SearchFilterComponent', () => { fixture.detectChanges(); queryBuilder.executed.next(mockSearchResult); fixture.detectChanges(); - spyOn(queryBuilder, 'update').and.stub(); + spyOn(queryBuilder, 'execute').and.stub(); const inputElement = fixture.debugElement.query(By.css(`${panel1Selector} input`)); inputElement.nativeElement.value = 'my'; @@ -511,7 +511,7 @@ describe('SearchFilterComponent', () => { const checkedOption = await panel1.getHarness(MatCheckboxHarness.with({ checked: true })); expect(await checkedOption.getLabelText()).toBe('my1 (806)'); - expect(queryBuilder.update).toHaveBeenCalledTimes(2); + expect(queryBuilder.execute).toHaveBeenCalledTimes(2); }); it('should reset the query fragments when reset All is clicked', () => { diff --git a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts index 8e1eb62382..3b5e835bbb 100644 --- a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts @@ -38,7 +38,7 @@ describe('SearchLogicalFilterComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { field: 'field1,field2', allowUpdateOnChange: true, hideDefaultAction: false }; fixture.detectChanges(); @@ -136,7 +136,7 @@ describe('SearchLogicalFilterComponent', () => { spyOn(component.displayValue$, 'next'); component.reset(); expect(component.context.queryFragments[component.id]).toBe(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.getCurrentValue()).toEqual({ matchAll: '', matchAny: '', exclude: '', matchExact: '' }); expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); @@ -145,7 +145,7 @@ describe('SearchLogicalFilterComponent', () => { it('should form correct query from match all field', () => { enterNewPhrase(' test1 test2 ', 0); component.submitValues(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe('((field1:"test1" AND field1:"test2") OR (field2:"test1" AND field2:"test2"))'); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); @@ -153,7 +153,7 @@ describe('SearchLogicalFilterComponent', () => { it('should form correct query from match any field', () => { enterNewPhrase(' test3 test4', 1); component.submitValues(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe('((field1:"test3" OR field1:"test4") OR (field2:"test3" OR field2:"test4"))'); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); @@ -161,7 +161,7 @@ describe('SearchLogicalFilterComponent', () => { it('should form correct query from exclude field', () => { enterNewPhrase('test5 test6 ', 2); component.submitValues(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe( '((NOT field1:"test5" AND NOT field1:"test6") AND (NOT field2:"test5" AND NOT field2:"test6"))' ); @@ -171,7 +171,7 @@ describe('SearchLogicalFilterComponent', () => { it('should form correct query from match exact field and trim it', () => { enterNewPhrase(' test7 test8 ', 3); component.submitValues(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe('((field1:"test7 test8") OR (field2:"test7 test8"))'); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); @@ -186,7 +186,7 @@ describe('SearchLogicalFilterComponent', () => { const subQuery2 = '((field1:"test2") OR (field2:"test2"))'; const subQuery3 = '((NOT field1:"test3") AND (NOT field2:"test3"))'; const subQuery4 = '((field1:"test4") OR (field2:"test4"))'; - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.queryFragments[component.id]).toBe(`${subQuery1} AND ${subQuery2} AND ${subQuery4} AND ${subQuery3}`); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); }); diff --git a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts index 4ebc1a5327..1784cfd8ea 100644 --- a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts +++ b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts @@ -128,7 +128,7 @@ export class SearchLogicalFilterComponent implements SearchWidget, OnInit { }); this.context.queryFragments[this.id] = query; if (updateContext) { - this.context.update(); + this.context.execute(); } } else { this.reset(updateContext); @@ -154,7 +154,7 @@ export class SearchLogicalFilterComponent implements SearchWidget, OnInit { this.clearSearchInputs(); this.context.filterRawParams[this.id] = this.searchCondition; if (updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.spec.ts b/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.spec.ts index e8f5a7118d..87b66e9e79 100644 --- a/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.spec.ts @@ -37,7 +37,7 @@ describe('SearchNumberRangeComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; }); @@ -64,7 +64,7 @@ describe('SearchNumberRangeComponent', () => { component.reset(); expect(component.context.queryFragments.contentSize).toEqual(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.filterRawParams[component.id]).toBeUndefined(); }); @@ -82,7 +82,7 @@ describe('SearchNumberRangeComponent', () => { const expectedQuery = 'cm:content.size:[10 TO 20]'; expect(component.context.queryFragments[component.id]).toEqual(expectedQuery); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); expect(component.context.filterRawParams[component.id].from).toEqual('10'); expect(component.context.filterRawParams[component.id].to).toEqual('20'); }); diff --git a/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.ts b/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.ts index 10fbbada01..d89d9fada2 100644 --- a/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.ts +++ b/lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.ts @@ -123,7 +123,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit { filterParam.from = model.from; filterParam.to = model.to; if (updateContext) { - this.context.update(); + this.context.execute(); } } } @@ -178,7 +178,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit { this.context.filterRawParams[this.id] = undefined; this.updateDisplayValue(); if (this.enableChangeUpdate && updateContext) { - this.context.update(); + this.context.execute(); } } } @@ -186,7 +186,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit { reset(updateContext = true) { this.clear(); if (this.id && this.context && updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.spec.ts b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.spec.ts index 6fb303e06f..a5dfcbb9c3 100644 --- a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.spec.ts @@ -72,7 +72,7 @@ describe('SearchPropertiesComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; }); @@ -208,7 +208,7 @@ describe('SearchPropertiesComponent', () => { component.submitValues(); expect(component.displayValue$.next).not.toHaveBeenCalled(); - expect(component.context.update).not.toHaveBeenCalled(); + expect(component.context.execute).not.toHaveBeenCalled(); }); it('should not search when context is not set', () => { @@ -227,7 +227,7 @@ describe('SearchPropertiesComponent', () => { fileExtensions: undefined, fileSizeCondition: { fileSize: null, fileSizeOperator: FileSizeOperator.AT_LEAST, fileSizeUnit: FileSizeUnit.KB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by at least KB by default when any size is typed', () => { @@ -246,7 +246,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.KB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by at most MB after selecting proper options', () => { @@ -271,7 +271,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.MB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by exactly GB after selecting proper options', () => { @@ -296,7 +296,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.GB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by single file type', () => { @@ -314,7 +314,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.KB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by multiple file types', () => { @@ -331,7 +331,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.KB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should search by file size and type', () => { @@ -351,7 +351,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.KB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); }); @@ -439,7 +439,7 @@ describe('SearchPropertiesComponent', () => { expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.filterRawParams[component.id]).toBeUndefined(); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); }); @@ -484,7 +484,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.MB } }); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); }); diff --git a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts index f76455b37c..88c520809f 100644 --- a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts +++ b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts @@ -206,7 +206,7 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear this.context.queryFragments[this.id] = ''; this.context.filterRawParams[this.id] = undefined; if (updateContext) { - this.context.update(); + this.context.execute(); } } this.reset$.next(); @@ -272,7 +272,7 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear this.displayValue$.next(displayedValue); this.context.queryFragments[this.id] = query; if (updateContext) { - this.context.update(); + this.context.execute(); } } diff --git a/lib/content-services/src/lib/search/components/search-radio/search-radio.component.spec.ts b/lib/content-services/src/lib/search/components/search-radio/search-radio.component.spec.ts index 283afbd9a9..fcd26dc7ae 100644 --- a/lib/content-services/src/lib/search/components/search-radio/search-radio.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-radio/search-radio.component.spec.ts @@ -43,7 +43,7 @@ describe('SearchRadioComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { options: sizeOptions } as any; }); @@ -93,6 +93,15 @@ describe('SearchRadioComponent', () => { expect(component.context.filterRawParams[component.id]).toBe(sizeOptions[0].value); }); + it('should call context.execute when reset is called', async () => { + const group = await loader.getHarness(MatRadioGroupHarness); + await group.checkRadioButton({ selector: `[data-automation-id="search-radio-${sizeOptions[2].name}"]` }); + + component.reset(); + + expect(component.context.execute).toHaveBeenCalled(); + }); + it('should populate filter state when populate filters event has been observed', async () => { component.context.filterLoaded = new ReplaySubject(1); spyOn(component.context.filterLoaded, 'next').and.stub(); diff --git a/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts b/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts index 6802fc8863..ce9d103e75 100644 --- a/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts +++ b/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts @@ -114,7 +114,7 @@ export class SearchRadioComponent implements SearchWidget, OnInit { this.setValue(this.value); this.updateDisplayValue(); if (updateContext) { - this.context.update(); + this.context.execute(); } } @@ -129,7 +129,6 @@ export class SearchRadioComponent implements SearchWidget, OnInit { this.context.filterRawParams[this.id] = newValue; if (this.enableChangeUpdate) { this.updateDisplayValue(); - this.context.update(); } } @@ -164,7 +163,7 @@ export class SearchRadioComponent implements SearchWidget, OnInit { this.setValue(initialValue); this.updateDisplayValue(); if (updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-slider/search-slider.component.spec.ts b/lib/content-services/src/lib/search/components/search-slider/search-slider.component.spec.ts index 52c37a67ba..50a9df1ca9 100644 --- a/lib/content-services/src/lib/search/components/search-slider/search-slider.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-slider/search-slider.component.spec.ts @@ -36,7 +36,7 @@ describe('SearchSliderComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute') } as any; component.settings = { field: 'field1', @@ -64,7 +64,7 @@ describe('SearchSliderComponent', () => { component.onChangedHandler(); expect(component.context.queryFragments[component.id]).toEqual('cm:content.size:[0 TO 10]'); expect(component.context.filterRawParams[component.id]).toEqual(10); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); component.value = 20; component.onChangedHandler(); @@ -81,7 +81,7 @@ describe('SearchSliderComponent', () => { expect(component.value).toBe(10); expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.filterRawParams[component.id]).toBe(null); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should reset to 0 if min not provided', () => { @@ -93,7 +93,7 @@ describe('SearchSliderComponent', () => { expect(component.value).toBe(0); expect(component.context.queryFragments['slider']).toBe(''); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); it('should populate filter state when populate filters event has been observed', async () => { diff --git a/lib/content-services/src/lib/search/components/search-slider/search-slider.component.ts b/lib/content-services/src/lib/search/components/search-slider/search-slider.component.ts index f826fdb7c4..fc5e43117b 100644 --- a/lib/content-services/src/lib/search/components/search-slider/search-slider.component.ts +++ b/lib/content-services/src/lib/search/components/search-slider/search-slider.component.ts @@ -135,7 +135,7 @@ export class SearchSliderComponent implements SearchWidget, OnInit { this.context.queryFragments[this.id] = `${this.settings.field}:[0 TO ${value}]`; } if (updateContext) { - this.context.update(); + this.context.execute(); } } } diff --git a/lib/content-services/src/lib/search/components/search-text/search-text.component.spec.ts b/lib/content-services/src/lib/search/components/search-text/search-text.component.spec.ts index 9bb277f9d8..a2a88c40c6 100644 --- a/lib/content-services/src/lib/search/components/search-text/search-text.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-text/search-text.component.spec.ts @@ -22,11 +22,13 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatInputHarness } from '@angular/material/input/testing'; import { MatButtonHarness } from '@angular/material/button/testing'; import { ReplaySubject } from 'rxjs'; +import { AppConfigService } from '@alfresco/adf-core'; describe('SearchTextComponent', () => { let loader: HarnessLoader; let fixture: ComponentFixture; let component: SearchTextComponent; + let appConfig: AppConfigService; beforeEach(() => { TestBed.configureTestingModule({ @@ -34,6 +36,7 @@ describe('SearchTextComponent', () => { }); fixture = TestBed.createComponent(SearchTextComponent); component = fixture.componentInstance; + appConfig = TestBed.inject(AppConfigService); component.id = 'text'; component.settings = { pattern: `cm:name:'(.*?)'`, @@ -46,7 +49,10 @@ describe('SearchTextComponent', () => { }, filterRawParams: {}, populateFilters: new ReplaySubject(1), - update: jasmine.createSpy('update') + execute: jasmine.createSpy('execute'), + get wildcardsEnabled(): boolean { + return appConfig.get('search-wildcards-enabled', true); + } } as any; loader = TestbedHarnessEnvironment.loader(fixture); @@ -67,7 +73,10 @@ describe('SearchTextComponent', () => { expect(component.value).toEqual(''); }); - it('should update query builder on change', () => { + it('should update query builder on change when change updates are enabled', () => { + component.settings.allowUpdateOnChange = true; + fixture.detectChanges(); + component.onChangedHandler({ target: { value: 'top-secret.doc' @@ -77,10 +86,27 @@ describe('SearchTextComponent', () => { expect(component.value).toBe('top-secret.doc'); expect(component.context.queryFragments[component.id]).toBe(`cm:name:'top-secret.doc'`); expect(component.context.filterRawParams[component.id]).toBe('top-secret.doc'); - expect(component.context.update).toHaveBeenCalled(); + expect(component.context.execute).toHaveBeenCalled(); }); - it('should reset query builder', () => { + it('should not update query builder on change when change updates are disabled', () => { + fixture.detectChanges(); + + component.onChangedHandler({ + target: { + value: 'top-secret.doc' + } + }); + + expect(component.value).toBe('top-secret.doc'); + expect(component.context.queryFragments[component.id]).toBe(''); + expect(component.context.execute).not.toHaveBeenCalled(); + }); + + it('should reset query builder when change updates are enabled', () => { + component.settings.allowUpdateOnChange = true; + fixture.detectChanges(); + component.onChangedHandler({ target: { value: 'top-secret.doc' @@ -125,13 +151,21 @@ describe('SearchTextComponent', () => { expect(component.context.filterRawParams[component.id]).toBeUndefined(); }); - it('should update query with startValue on init, if provided', () => { + it('should set value from startValue on init, if provided', () => { component.startValue = 'mock-start-value'; fixture.detectChanges(); - expect(component.context.queryFragments[component.id]).toBe(`cm:name:'mock-start-value'`); expect(component.value).toBe('mock-start-value'); - expect(component.context.update).toHaveBeenCalled(); + }); + + it('should build the query fragment from the current value when submitted', () => { + component.startValue = 'mock-start-value'; + fixture.detectChanges(); + + component.submitValues(); + + expect(component.context.queryFragments[component.id]).toBe(`cm:name:'mock-start-value'`); + expect(component.context.execute).toHaveBeenCalled(); }); it('should parse value and set query context as blank, and not call query update, if no start value was provided', () => { @@ -141,7 +175,7 @@ describe('SearchTextComponent', () => { expect(component.context.queryFragments[component.id]).toBe(''); expect(component.value).toBe('secret.pdf'); - expect(component.context.update).not.toHaveBeenCalled(); + expect(component.context.execute).not.toHaveBeenCalled(); }); it('should populate filter state when populate filters event has been observed', async () => { @@ -157,4 +191,30 @@ describe('SearchTextComponent', () => { expect(component.context.filterRawParams[component.id]).toBe('secret.pdf'); expect(component.context.filterLoaded.next).toHaveBeenCalled(); }); + + it('should add the search prefix and suffix to the query fragment when wildcards are enabled', () => { + spyOn(appConfig, 'get').and.callFake((key: string, defaultValue?: any) => (key === 'search-wildcards-enabled' ? true : defaultValue)); + component.settings.searchPrefix = '*'; + component.settings.searchSuffix = '*'; + fixture.detectChanges(); + + component.onChangedHandler({ target: { value: 'secret' } }); + component.submitValues(); + + expect(component.context.wildcardsEnabled).toBeTrue(); + expect(component.context.queryFragments[component.id]).toBe(`cm:name:'*secret*'`); + }); + + it('should NOT add the search prefix and suffix to the query fragment when wildcards are disabled', () => { + spyOn(appConfig, 'get').and.callFake((key: string, defaultValue?: any) => (key === 'search-wildcards-enabled' ? false : defaultValue)); + component.settings.searchPrefix = '*'; + component.settings.searchSuffix = '*'; + fixture.detectChanges(); + + component.onChangedHandler({ target: { value: 'secret' } }); + component.submitValues(); + + expect(component.context.wildcardsEnabled).toBeFalse(); + expect(component.context.queryFragments[component.id]).toBe(`cm:name:'secret'`); + }); }); diff --git a/lib/content-services/src/lib/search/components/search-text/search-text.component.ts b/lib/content-services/src/lib/search/components/search-text/search-text.component.ts index af71f55034..f565196bbd 100644 --- a/lib/content-services/src/lib/search/components/search-text/search-text.component.ts +++ b/lib/content-services/src/lib/search/components/search-text/search-text.component.ts @@ -48,7 +48,7 @@ export class SearchTextComponent implements SearchWidget, OnInit { context: SearchQueryBuilderService; startValue: string; isActive = false; - enableChangeUpdate = true; + enableChangeUpdate = false; displayValue$ = new ReplaySubject(1); private readonly destroyRef = inject(DestroyRef); @@ -121,7 +121,7 @@ export class SearchTextComponent implements SearchWidget, OnInit { if (this.context?.queryFragments && this.settings?.field) { this.context.queryFragments[this.id] = value ? `${this.settings.field}:'${this.getSearchPrefix()}${value}${this.getSearchSuffix()}'` : ''; if (updateContext) { - this.context.update(); + this.context.execute(); } } } @@ -141,14 +141,13 @@ export class SearchTextComponent implements SearchWidget, OnInit { setValue(value: string) { this.value = value; this.displayValue$.next(this.value); - this.submitValues(); } private getSearchPrefix(): string { - return this.settings.searchPrefix ? this.settings.searchPrefix : ''; + return this.settings.searchPrefix && this.context.wildcardsEnabled ? this.settings.searchPrefix : ''; } private getSearchSuffix(): string { - return this.settings.searchSuffix ? this.settings.searchSuffix : ''; + return this.settings.searchSuffix && this.context.wildcardsEnabled ? this.settings.searchSuffix : ''; } } diff --git a/lib/content-services/src/lib/search/services/base-query-builder.service.spec.ts b/lib/content-services/src/lib/search/services/base-query-builder.service.spec.ts index 550a7ea599..b01c31e736 100644 --- a/lib/content-services/src/lib/search/services/base-query-builder.service.spec.ts +++ b/lib/content-services/src/lib/search/services/base-query-builder.service.spec.ts @@ -69,6 +69,12 @@ describe('BaseQueryBuilderService', () => { { ...mockSearchConfig, id: 'config-3', name: 'Config 3', default: false } ]; + const configureAppConfig = (wildcards?: boolean): void => { + (appConfig.get as jasmine.Spy).and.callFake((key: string, defaultValue?: any) => + key === 'search-wildcards-enabled' ? (wildcards ?? defaultValue) : false + ); + }; + beforeEach(() => { TestBed.configureTestingModule({ providers: [ @@ -89,19 +95,218 @@ describe('BaseQueryBuilderService', () => { }); describe('userQuery', () => { - it('should set userQuery with parentheses', () => { + it('should set userQuery to raw value without wrapping', () => { service.userQuery = 'test query'; - expect(service.userQuery).toBe('(test query)'); + expect(service.userQuery).toBe('test query'); }); - it('should trim userQuery', () => { - service.userQuery = ' test query '; - expect(service.userQuery).toBe('(test query)'); + it('should store userQuery in filterRawParams', () => { + service.userQuery = 'test'; + expect(service.filterRawParams['userQuery']).toBe('test'); }); - it('should return empty string for null input', () => { - service.userQuery = null; + it('should set parsedQuery when userQuery is set in regular mode', () => { + service.searchMode = 'regular'; + service.userQuery = 'hello'; + expect(service.parsedQuery).toBe('((cm:name:"hello*"))'); + }); + + it('should set parsedQuery equal to userQuery in formula mode', () => { + service.searchMode = 'formula'; + service.userQuery = '(cm:name:"test*")'; + expect(service.parsedQuery).toBe('(cm:name:"test*")'); + }); + + it('should clear userQuery and its raw param when set to empty', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'something'; + service.userQuery = ''; + expect(service.userQuery).toBe(''); + expect(service.filterRawParams['userQuery']).toBe(''); + }); + }); + + describe('parsedQuery', () => { + it('should wrap a single term with field query and parentheses in regular mode', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello'; + expect(service.parsedQuery).toContain('cm:name:"hello*"'); + }); + + it('should join multiple terms with AND in regular mode when no operator present', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello world'; + expect(service.parsedQuery).toContain(' AND '); + }); + + it('should preserve AND/OR operators in regular mode when present in query', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello AND world'; + expect(service.parsedQuery).toBe('((cm:name:"hello*") AND (cm:name:"world*"))'); + }); + + it('should store parsedQuery in filterRawParams', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello'; + expect(service.filterRawParams['parsedQuery']).toBe(service.parsedQuery); + }); + + it('should not add wildcard suffix when wildcards are disabled', () => { + configureAppConfig(false); + service.searchMode = 'regular'; + service.userQuery = 'hello'; + expect(service.parsedQuery).toBe('((cm:name:"hello"))'); + }); + + it('should build the exact single-term parsed query against the default cm:name field', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello'; + expect(service.parsedQuery).toBe('((cm:name:"hello*"))'); + }); + + it('should join each multi-term word with AND wrapped in parentheses', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello world'; + expect(service.parsedQuery).toBe('((cm:name:"hello*") AND (cm:name:"world*"))'); + }); + + it('should keep explicit operators as separators between terms', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello OR world'; + expect(service.parsedQuery).toBe('((cm:name:"hello*") OR (cm:name:"world*"))'); + }); + + it('should parse each term against every configured app:fields entry', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.config = { id: 'test-config', categories: [], 'app:fields': ['cm:name', 'cm:title'] } as any; + service.userQuery = 'hello'; + expect(service.parsedQuery).toBe('((cm:name:"hello*" OR cm:title:"hello*"))'); + }); + + it('should escape double quotes in the user query term', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'he"llo'; + expect(service.parsedQuery).toBe('((cm:name:"he\\"llo*"))'); + }); + + it('should escape backslashes in the user query term', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'he\\llo'; + expect(service.parsedQuery).toBe('((cm:name:"he\\\\llo*"))'); + }); + + it('should clear parsedQuery and its raw param when userQuery is set to empty', () => { + configureAppConfig(true); + service.searchMode = 'regular'; + service.userQuery = 'hello'; + service.userQuery = ''; + + expect(service.parsedQuery).toBe(''); + expect(service.filterRawParams['parsedQuery']).toBe(''); + }); + }); + + describe('searchMode', () => { + it('should default to regular mode', () => { + expect(service.searchMode).toBe('regular'); + }); + + it('should store searchMode in filterRawParams', () => { + service.searchMode = 'formula'; + expect(service.filterRawParams['searchMode']).toBe('formula'); + }); + + it('should update filterRawParams when changed back to regular', () => { + service.searchMode = 'formula'; + service.searchMode = 'regular'; + expect(service.filterRawParams['searchMode']).toBe('regular'); + }); + + it('should recompute parsedQuery when switching search mode', () => { + configureAppConfig(true); + service.searchMode = 'formula'; + service.userQuery = 'hello'; + expect(service.parsedQuery).toBe('hello'); + + service.searchMode = 'regular'; + expect(service.parsedQuery).toBe('((cm:name:"hello*"))'); + }); + }); + + describe('wildcardsEnabled', () => { + it('should return true by default', () => { + configureAppConfig(); + expect(service.wildcardsEnabled).toBe(true); + }); + + it('should return false when config disables wildcards', () => { + configureAppConfig(false); + expect(service.wildcardsEnabled).toBe(false); + }); + }); + + describe('isOperator', () => { + it('should return true for AND', () => { + expect(service.isOperator('AND')).toBeTrue(); + }); + + it('should return true for OR', () => { + expect(service.isOperator('OR')).toBeTrue(); + }); + + it('should return true for AND with surrounding spaces', () => { + expect(service.isOperator(' AND ')).toBeTrue(); + }); + + it('should return false for regular word', () => { + expect(service.isOperator('hello')).toBeFalse(); + }); + + it('should return false for empty string', () => { + expect(service.isOperator('')).toBeFalse(); + }); + + it('should return false for null/undefined', () => { + expect(service.isOperator(null)).toBeFalse(); + expect(service.isOperator(undefined)).toBeFalse(); + }); + }); + + describe('selectedConfigurationId', () => { + it('should store selectedConfigurationId in filterRawParams when set', () => { + (appConfig.get as jasmine.Spy).and.returnValue(mockMultipleConfigs); + service.resetToDefaults(); + + service.selectedConfigurationId = 'config-2'; + expect(service.filterRawParams['selectedConfigurationId']).toBe('config-2'); + }); + }); + + describe('encodedQuery', () => { + it('should return encoded query after encodeQuery is called', () => { + service.userQuery = 'test'; + service.encodeQuery(); + expect(service.encodedQuery).toBeTruthy(); + }); + + it('should return a base64 encoded string of filterRawParams', () => { + service.userQuery = 'test'; + service.encodeQuery(); + const decoded = new TextDecoder().decode(Uint8Array.from(atob(service.encodedQuery), (char) => char.charCodeAt(0))); + const parsed = JSON.parse(decoded); + expect(parsed['userQuery']).toBe('test'); }); }); @@ -215,15 +420,27 @@ describe('BaseQueryBuilderService', () => { expect(service.buildQuery()).toBeNull(); }); - it('should build query with userQuery', () => { + it('should build query with parsedQuery in regular mode', () => { + configureAppConfig(true); + service.searchMode = 'regular'; service.userQuery = 'test'; const query = service.buildQuery(); expect(query).toBeTruthy(); - expect(query.query.query).toBe('(test)'); + expect(query.query.query).toContain('cm:name:"test*"'); + }); + + it('should build query using userQuery directly in formula mode', () => { + service.searchMode = 'formula'; + service.userQuery = '(cm:name:"test*")'; + const query = service.buildQuery(); + + expect(query).toBeTruthy(); + expect(query.query.query).toBe('(cm:name:"test*")'); }); it('should include scope in query when set', () => { + service.searchMode = 'formula'; service.userQuery = 'test'; service.setScope({ locations: 'nodes' }); const query = service.buildQuery(); @@ -233,6 +450,7 @@ describe('BaseQueryBuilderService', () => { it('should include default includes when none configured', () => { service.config = { id: 'test-config', categories: [] }; + service.searchMode = 'formula'; service.userQuery = 'test'; const query = service.buildQuery(); @@ -241,29 +459,25 @@ describe('BaseQueryBuilderService', () => { }); }); - describe('update', () => { - it('should emit updated event with built query', (done) => { + describe('getFinalQuery', () => { + it('should skip query fragments that are empty match-all objects', () => { + service.searchMode = 'formula'; service.userQuery = 'test'; + service.categories = [{ id: 'cat1', name: 'Cat1', enabled: true, expanded: false, component: { selector: 'test', settings: undefined } }]; + service.queryFragments['cat1'] = { matchAll: '', matchAny: '', matchExact: '', exclude: '' }; - service.updated.subscribe((query) => { - expect(query.query.query).toBe('(test)'); - done(); - }); - - service.update(); + const query = service.buildQuery(); + expect(query.query.query).toBe('test'); }); - it('should emit updated event with provided query body', (done) => { - const customQuery = { - query: { query: 'custom query', language: 'afts' } - }; + it('should include non-empty query fragments', () => { + service.searchMode = 'formula'; + service.userQuery = 'test'; + service.categories = [{ id: 'cat1', name: 'Cat1', enabled: true, expanded: false, component: { selector: 'test', settings: undefined } }]; + service.queryFragments['cat1'] = 'cm:name:"hello"'; - service.updated.subscribe((query) => { - expect(query.query.query).toBe('custom query'); - done(); - }); - - service.update(customQuery); + const query = service.buildQuery(); + expect(query.query.query).toContain('cm:name:"hello"'); }); }); @@ -450,79 +664,24 @@ describe('BaseQueryBuilderService', () => { expect(service.execute).toHaveBeenCalled(); }); - }); - describe('populateFilters and selectedConfiguration restoration', () => { - beforeEach(() => { - (appConfig.get as jasmine.Spy<(key: string, defaultValue?: T) => T>).and.returnValue(mockMultipleConfigs); - service.resetToDefaults(); - }); - - it('should restore selectedConfiguration from populateFilters', (done) => { + it('should not call execute when shouldExecute is false', async () => { spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + spyOn(service, 'execute'); - service.configUpdated.subscribe((config) => { - expect(config.name).toBe('Config 3'); - done(); - }); + service.updateSelectedConfiguration('config-2', true, false); - service.populateFilters.next({ selectedConfigurationId: 'config-3', someOtherFilter: 'value' }); + expect(service.execute).not.toHaveBeenCalled(); }); - it('should reset to default configuration when populateFilters has no selectedConfigurationId', (done) => { + it('should not reset search options when resetFilters is false', () => { spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve({ list: { entries: [] } } as ResultSetPaging)); - service.updateSelectedConfiguration('config-3'); + service.queryFragments['someFilter'] = 'some value'; + service.updateSelectedConfiguration('config-2', false, false); - setTimeout(() => { - service.configUpdated.subscribe((config) => { - expect(config.name).toBe('Config 1'); - done(); - }); - - service.populateFilters.next({ someOtherFilter: 'value' }); - }, 0); - }); - - it('should not change configuration when populateFilters is empty', () => { - spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); - const configUpdatedSpy = jasmine.createSpy('configUpdatedSpy'); - - service.configUpdated.subscribe(configUpdatedSpy); - configUpdatedSpy.calls.reset(); - - service.populateFilters.next({}); - - expect(configUpdatedSpy).not.toHaveBeenCalled(); - }); - - it('should not change configuration when same configuration is already selected', (done) => { - spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); - spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve({ list: { entries: [] } } as ResultSetPaging)); - - service.updateSelectedConfiguration('config-2'); - - setTimeout(() => { - const configUpdatedSpy = jasmine.createSpy('configUpdatedSpy'); - service.configUpdated.subscribe(configUpdatedSpy); - - service.populateFilters.next({ selectedConfigurationId: 'config-2' }); - - setTimeout(() => { - expect(configUpdatedSpy).not.toHaveBeenCalled(); - done(); - }, 0); - }, 0); - }); - - it('should update filterRawParams when restoring configuration from populateFilters', (done) => { - service.configUpdated.subscribe(() => { - expect(service.filterRawParams['selectedConfigurationId']).toBe('config-2'); - done(); - }); - - service.populateFilters.next({ selectedConfigurationId: 'config-2' }); + expect(service.queryFragments['someFilter']).toBe('some value'); }); }); @@ -577,5 +736,17 @@ describe('BaseQueryBuilderService', () => { expect(service.categories.length).toBe(1); expect(service.categories[0].id).toBe('cat1'); }); + + it('should reset userQuery when resetUserQuery is true (default)', () => { + service.userQuery = 'some query'; + service.resetToDefaults(false, true); + expect(service.userQuery).toBe(''); + }); + + it('should preserve userQuery when resetUserQuery is false', () => { + service.userQuery = 'some query'; + service.resetToDefaults(false, false); + expect(service.userQuery).toBe('some query'); + }); }); }); diff --git a/lib/content-services/src/lib/search/services/base-query-builder.service.ts b/lib/content-services/src/lib/search/services/base-query-builder.service.ts index 16ca722fd4..670839fe20 100644 --- a/lib/content-services/src/lib/search/services/base-query-builder.service.ts +++ b/lib/content-services/src/lib/search/services/base-query-builder.service.ts @@ -54,9 +54,6 @@ export abstract class BaseQueryBuilderService { /* Stream that emits the event each time when search filter finishes loading initial value */ filterLoaded = new Subject(); - /* Stream that emits the query before search whenever user search */ - updated = new Subject(); - /* Stream that emits the results whenever user search */ executed = new Subject(); @@ -85,11 +82,13 @@ export abstract class BaseQueryBuilderService { sorting: SearchSortingDefinition[] = []; sortingOptions: SearchSortingDefinition[] = []; - private encodedQuery: string; + private _encodedQuery: string; private scope: RequestScope; - private selectedConfigurationId: string; - private _userQuery = ''; + private _selectedConfigurationId: string; private _queryFragments: { [id: string]: string } = {}; + private _parsedQuery: string; + private _userQuery: string; + private _searchMode: 'regular' | 'formula'; private readonly selectedConfigurationKey = 'selectedConfigurationId'; private readonly queryFragmentsHandler: ProxyHandler<{ [key: string]: any }> = { @@ -111,13 +110,45 @@ export abstract class BaseQueryBuilderService { this.queryFragmentsUpdate.next(this._queryFragments); } + get encodedQuery(): string { + return this._encodedQuery; + } + + get wildcardsEnabled(): boolean { + return this.appConfig.get('search-wildcards-enabled', true); + } + get userQuery(): string { return this._userQuery; } set userQuery(value: string) { - value = (value || '').trim(); - this._userQuery = value ? `(${value})` : ''; + this._userQuery = value; + this.filterRawParams['userQuery'] = value; + this.setParsedQuery(); + } + + get parsedQuery(): string { + return this._parsedQuery; + } + + get searchMode(): 'regular' | 'formula' { + return this._searchMode; + } + + set searchMode(value: 'regular' | 'formula') { + this._searchMode = value; + this.filterRawParams['searchMode'] = value; + this.setParsedQuery(); + } + + get selectedConfigurationId(): string { + return this._selectedConfigurationId; + } + + set selectedConfigurationId(value: string) { + this._selectedConfigurationId = value; + this.filterRawParams[this.selectedConfigurationKey] = value; } config: SearchConfiguration = { @@ -132,17 +163,17 @@ export abstract class BaseQueryBuilderService { protected readonly appConfig: AppConfigService, protected readonly alfrescoApiService: AlfrescoApiService ) { + this.searchMode = 'regular'; + this.userQuery = ''; this.resetToDefaults(); this._queryFragments = this.createQueryFragmentsProxy({}); - - this.populateFilters.subscribe((filters) => this.handleSelectedConfigurationChange(filters)); } public abstract loadConfiguration(): SearchConfiguration | SearchConfiguration[]; public abstract isFilterServiceActive(): boolean; - public resetToDefaults(withNavigate = false) { + public resetToDefaults(withNavigate = false, resetUserQuery = true) { if (withNavigate) { this.router.navigate([], { queryParams: { q: null }, @@ -151,7 +182,7 @@ export abstract class BaseQueryBuilderService { }); } const currentConfig = this.getDefaultConfiguration(); - this.resetSearchOptions(); + this.resetSearchOptions(resetUserQuery); this.configUpdated.next(currentConfig); this.searchForms.next(this.getSearchFormDetails()); this.setUpSearchConfiguration(currentConfig); @@ -170,23 +201,26 @@ export abstract class BaseQueryBuilderService { return configurations; } - public updateSelectedConfiguration(id: string): void { + public updateSelectedConfiguration(id: string, resetFilters = true, shouldExecute = true): void { const currentConfig = this.loadConfiguration(); if (Array.isArray(currentConfig)) { const selectedConfig = currentConfig.find((config) => config.id === id); if (selectedConfig) { + if (resetFilters) { + this.resetSearchOptions(false); + } this.selectedConfigurationId = id; this.searchForms.next(this.getSearchFormDetails()); - this.resetSearchOptions(); this.setUpSearchConfiguration(selectedConfig); - this.filterRawParams[this.selectedConfigurationKey] = id; this.configUpdated.next(selectedConfig); - this.execute(); + if (shouldExecute) { + this.execute(true); + } } } } - private resetSearchOptions(): void { + private resetSearchOptions(resetUserQuery = true): void { this.categories = []; this.queryFragments = {}; this.filterQueries = []; @@ -194,8 +228,10 @@ export abstract class BaseQueryBuilderService { this.sortingOptions = []; this.resetUserFacetBucket(); this.scope = null; - this.filterRawParams = {}; - this._userQuery = ''; + if (resetUserQuery) { + this.userQuery = ''; + } + this.resetFilterRawParams(resetUserQuery); this.populateFilters.next({}); } @@ -229,6 +265,7 @@ export abstract class BaseQueryBuilderService { this.categories = (this.config.categories || []).filter((category) => category.enabled); this.filterQueries = this.config.filterQueries || []; this.userFacetBuckets = {}; + this.userQuery = this.filterRawParams['userQuery'] || ''; if (this.config.sorting) { this.sorting = this.config.sorting.defaults || []; this.sortingOptions = this.config.sorting.options || []; @@ -355,16 +392,6 @@ export abstract class BaseQueryBuilderService { return this.scope; } - /** - * Builds the current query and triggers the `updated` event. - * - * @param queryBody query settings - */ - update(queryBody?: SearchRequest): void { - const query = queryBody ? queryBody : this.buildQuery(); - this.updated.next(query); - } - /** * Builds and executes the current query. * @@ -552,12 +579,9 @@ export abstract class BaseQueryBuilderService { protected getFinalQuery(): string { let query = ''; - if (this.userQuery) { - this.filterRawParams['userQuery'] = this.userQuery; - } this.categories.forEach((facet) => { const customQuery = this.queryFragments[facet.id]; - if (customQuery) { + if (customQuery && JSON.stringify(customQuery) !== JSON.stringify({ matchAll: '', matchAny: '', matchExact: '', exclude: '' })) { if (query.length > 0) { query += ' AND '; } @@ -565,7 +589,8 @@ export abstract class BaseQueryBuilderService { } }); - let result = [this.userQuery, query].filter((entry) => entry).join(' AND '); + const parsedQuery = this.searchMode === 'regular' ? this.parsedQuery : this.userQuery; + let result = [parsedQuery, query].filter((entry) => entry).join(' AND '); if (this.userFacetBuckets) { Object.keys(this.userFacetBuckets).forEach((key) => { @@ -626,10 +651,10 @@ export abstract class BaseQueryBuilderService { */ encodeQuery() { try { - this.encodedQuery = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(this.filterRawParams)))); + this._encodedQuery = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(this.filterRawParams)))); } catch (error) { console.error('Failed to encode query parameters:', error); - this.encodedQuery = ''; + this._encodedQuery = ''; } } @@ -640,7 +665,7 @@ export abstract class BaseQueryBuilderService { this.encodeQuery(); this.router.navigate([], { relativeTo: this.activatedRoute, - queryParams: { q: this.encodedQuery }, + queryParams: { q: this._encodedQuery }, queryParamsHandling: 'merge' }); } @@ -653,50 +678,71 @@ export abstract class BaseQueryBuilderService { */ async navigateToSearch(query: string, searchUrl: string) { this.userQuery = query; + this.encodeQuery(); + await this.execute(); await this.router.navigate([searchUrl], { - queryParams: { q: this.encodedQuery }, + queryParams: { q: this._encodedQuery }, queryParamsHandling: 'merge' }); } + /** + * Checks if string is an AND or OR operator + * + * @param input string to check if it is an operator + * @returns boolean + */ + isOperator(input: string): boolean { + if (input) { + const operators = ['AND', 'OR']; + return operators.includes(input.trim()); + } + return false; + } + private createQueryFragmentsProxy(target: { [key: string]: any }): { [key: string]: any } { return new Proxy(target, this.queryFragmentsHandler); } - private setSelectedConfiguration(id: string): void { - const currentConfig = this.loadConfiguration(); - if (Array.isArray(currentConfig)) { - const selectedConfig = currentConfig.find((config) => config.id === id); - if (selectedConfig) { - this.selectedConfigurationId = id; - this.searchForms.next(this.getSearchFormDetails()); - this.setUpSearchConfiguration(selectedConfig); - this.filterRawParams[this.selectedConfigurationKey] = id; - this.configUpdated.next(selectedConfig); - } - } - } - - private handleSelectedConfigurationChange(filters: { [key: string]: string }): void { - if (Object.keys(filters ?? {}).length === 0) { + private setParsedQuery() { + if (!this.userQuery) { + this._parsedQuery = ''; + this.filterRawParams['parsedQuery'] = ''; return; } - - const newSelectedConfig = filters?.[this.selectedConfigurationKey]; - - if (newSelectedConfig) { - if (newSelectedConfig !== this.selectedConfigurationId) { - this.setSelectedConfiguration(newSelectedConfig); - } + if (this.searchMode === 'formula') { + this._parsedQuery = this.userQuery; } else { - const configurations = this.loadConfiguration(); - if (Array.isArray(configurations)) { - const defaultConfig = configurations.find((config) => config.default); - if (defaultConfig && this.selectedConfigurationId !== defaultConfig.id) { - this.setSelectedConfiguration(defaultConfig.id); - } + const words = this.userQuery.split(/\s+/); + if (words.length > 1) { + const separator = words.some(this.isOperator) ? ' ' : ' AND '; + this._parsedQuery = '(' + words.map((term) => (this.isOperator(term) ? term : this.parseTermByFields(term))).join(separator) + ')'; + } else { + this._parsedQuery = '(' + this.parseTermByFields(this.userQuery) + ')'; } } + this.filterRawParams['parsedQuery'] = this._parsedQuery; + } + + private parseTermByFields(term: string): string { + const suffix = this.wildcardsEnabled ? '*' : ''; + const fields = this.config['app:fields'] || ['cm:name']; + const escapedTerm = this.escapeQueryTerm(term); + return '(' + fields.map((field) => `${field}:"${escapedTerm}${suffix}"`).join(' OR ') + ')'; + } + + private escapeQueryTerm(term: string): string { + return term.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + } + + private resetFilterRawParams(resetUserQuery = true) { + this.filterRawParams = { + userQuery: resetUserQuery ? '' : this.userQuery, + parsedQuery: resetUserQuery ? '' : this.parsedQuery, + searchMode: this.searchMode, + [this.selectedConfigurationKey]: this.selectedConfigurationId, + logic: { matchAll: '', matchAny: '', matchExact: '', exclude: '' } + }; } } diff --git a/lib/content-services/src/lib/search/services/search-facet-filters.service.spec.ts b/lib/content-services/src/lib/search/services/search-facet-filters.service.spec.ts index 178b697b19..499b55866f 100644 --- a/lib/content-services/src/lib/search/services/search-facet-filters.service.spec.ts +++ b/lib/content-services/src/lib/search/services/search-facet-filters.service.spec.ts @@ -679,7 +679,7 @@ describe('SearchFacetFiltersService', () => { it('should reset filters and call resetToDefaults', () => { const resetToDefaultsSpy = spyOn(queryBuilder, 'resetToDefaults'); - const updateSpy = spyOn(queryBuilder, 'update'); + const executeSpy = spyOn(queryBuilder, 'execute'); const responseFacets = [{ field: 'field1', label: null }]; const selectedBuckets = [{ field: { field: 'field1', label: null }, bucket: { label: 'bucket1', count: 1, filterQuery: 'q1' } }]; @@ -695,8 +695,8 @@ describe('SearchFacetFiltersService', () => { searchFacetFiltersService.reset(); - expect(resetToDefaultsSpy).toHaveBeenCalled(); - expect(updateSpy).toHaveBeenCalled(); + expect(resetToDefaultsSpy).toHaveBeenCalledWith(true, false); + expect(executeSpy).toHaveBeenCalled(); expect(searchFacetFiltersService.responseFacets).toEqual([]); expect(searchFacetFiltersService.selectedBuckets).toEqual([]); diff --git a/lib/content-services/src/lib/search/services/search-facet-filters.service.ts b/lib/content-services/src/lib/search/services/search-facet-filters.service.ts index ff30d27e8e..0de729bd57 100644 --- a/lib/content-services/src/lib/search/services/search-facet-filters.service.ts +++ b/lib/content-services/src/lib/search/services/search-facet-filters.service.ts @@ -70,8 +70,6 @@ export class SearchFacetFiltersService { this.responseFacets = null; }); - this.queryBuilder.updated.pipe(takeUntilDestroyed()).subscribe((query) => this.queryBuilder.execute(true, query)); - this.queryBuilder.executed.pipe(takeUntilDestroyed()).subscribe((resultSetPaging: ResultSetPaging) => { this.onDataLoaded(resultSetPaging); this.searchService.dataLoaded.next(resultSetPaging); @@ -397,7 +395,7 @@ export class SearchFacetFiltersService { bucket.checked = false; this.queryBuilder.removeUserFacetBucket(facetField?.field, bucket); this.updateSelectedBuckets(); - this.queryBuilder.update(); + this.queryBuilder.execute(); } } @@ -432,7 +430,7 @@ export class SearchFacetFiltersService { this.updateSelectedBuckets(); } }); - this.queryBuilder.update(); + this.queryBuilder.execute(); } resetQueryFragments() { @@ -444,7 +442,7 @@ export class SearchFacetFiltersService { this.responseFacets = []; this.selectedBuckets = []; this.tabbedFacet = null; - this.queryBuilder.resetToDefaults(true); - this.queryBuilder.update(); + this.queryBuilder.resetToDefaults(true, false); + this.queryBuilder.execute(); } } diff --git a/lib/content-services/src/lib/search/services/search-header-query-builder.service.spec.ts b/lib/content-services/src/lib/search/services/search-header-query-builder.service.spec.ts index 8b2032679e..ef26a560aa 100644 --- a/lib/content-services/src/lib/search/services/search-header-query-builder.service.spec.ts +++ b/lib/content-services/src/lib/search/services/search-header-query-builder.service.spec.ts @@ -231,19 +231,17 @@ describe('SearchHeaderQueryBuilderService', () => { spyOn(router, 'navigate'); spyOn(console, 'error'); const searchUrl = 'search'; - builderService.filterRawParams = { - userQuery: '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))' - }; - builderService.encodeQuery(); + const nonLatinQuery = '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))'; + builderService.searchMode = 'formula'; + + await builderService.navigateToSearch(nonLatinQuery, searchUrl); - await builderService.navigateToSearch('', searchUrl); expect(console.error).not.toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith([searchUrl], { - queryParams: { - q: 'eyJ1c2VyUXVlcnkiOiIoKGNtOm5hbWU6XCJ3xIXFvCpcIiBPUiBjbTp0aXRsZTpcInfEhcW8KlwiIE9SIGNtOmRlc2NyaXB0aW9uOlwid8SFxbwqXCIgT1IgVEVYVDpcInfEhcW8KlwiIE9SIFRBRzpcInfEhcW8KlwiKSkifQ==' - }, - queryParamsHandling: 'merge' - }); + const navigateArgs = (router.navigate as jasmine.Spy).calls.mostRecent().args; + expect(navigateArgs[0]).toEqual([searchUrl]); + expect(navigateArgs[1].queryParamsHandling).toBe('merge'); + const decoded = JSON.parse(decodeURIComponent(escape(atob(navigateArgs[1].queryParams.q)))); + expect(decoded.userQuery).toBe(nonLatinQuery); }); }); diff --git a/lib/content-services/src/lib/search/services/search-header-query-builder.service.ts b/lib/content-services/src/lib/search/services/search-header-query-builder.service.ts index 7ba51c6a4a..5dd9f9f9d8 100644 --- a/lib/content-services/src/lib/search/services/search-header-query-builder.service.ts +++ b/lib/content-services/src/lib/search/services/search-header-query-builder.service.ts @@ -21,7 +21,6 @@ import { SearchConfiguration } from '../models/search-configuration.interface'; import { BaseQueryBuilderService } from './base-query-builder.service'; import { SearchCategory } from '../models/search-category.interface'; import { Node } from '@alfresco/js-api'; -import { filter } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { SearchSortingDefinition } from '../models/search-sorting-definition.interface'; import { FilterSearch } from '../models/filter-search.interface'; @@ -43,10 +42,6 @@ export class SearchHeaderQueryBuilderService extends BaseQueryBuilderService { const alfrescoApiService = inject(AlfrescoApiService); super(appConfig, alfrescoApiService); - - this.updated.pipe(filter((query) => !!query)).subscribe(() => { - this.execute(); - }); } public isFilterServiceActive(): boolean { diff --git a/lib/content-services/src/lib/search/services/search-query-builder.service.spec.ts b/lib/content-services/src/lib/search/services/search-query-builder.service.spec.ts index b0bb87d341..4661fd0749 100644 --- a/lib/content-services/src/lib/search/services/search-query-builder.service.spec.ts +++ b/lib/content-services/src/lib/search/services/search-query-builder.service.spec.ts @@ -126,16 +126,16 @@ describe('SearchQueryBuilder', () => { expect(builder.userQuery).toBe(''); }); - it('should wrap user query with brackets', () => { + it('should store the user query as the raw value', () => { const builder = createQueryBuilder(); builder.userQuery = 'my query'; - expect(builder.userQuery).toEqual('(my query)'); + expect(builder.userQuery).toEqual('my query'); }); - it('should trim user query value', () => { + it('should expose the user query through filterRawParams', () => { const builder = createQueryBuilder(); - builder.userQuery = ' something '; - expect(builder.userQuery).toEqual('(something)'); + builder.userQuery = 'something'; + expect(builder.filterRawParams['userQuery']).toEqual('something'); }); it('should use only enabled categories', () => { @@ -579,7 +579,8 @@ describe('SearchQueryBuilder', () => { categories: [{ id: 'cat1', enabled: true } as any] }; const builder = createQueryBuilder(config); - builder.userQuery = 'my query'; + builder.searchMode = 'formula'; + builder.userQuery = '(my query)'; builder.queryFragments['cat1'] = 'cm:name:test'; @@ -587,6 +588,21 @@ describe('SearchQueryBuilder', () => { expect(compiled.query.query).toBe('(my query) AND (cm:name:test)'); }); + it('should build final request from the parsed query in regular mode', () => { + const config: SearchConfiguration = { + id: 'test-config', + categories: [{ id: 'cat1', enabled: true } as any] + }; + const builder = createQueryBuilder(config); + builder.searchMode = 'regular'; + builder.userQuery = 'my query'; + + builder.queryFragments['cat1'] = 'cm:name:test'; + + const compiled = builder.buildQuery(); + expect(compiled.query.query).toBe(`${builder.parsedQuery} AND (cm:name:test)`); + }); + it('should group facet buckets by field', () => { const field1: FacetField = { field: 'f1', @@ -729,12 +745,13 @@ describe('SearchQueryBuilder', () => { }); }); - it('should add user query to filter raw params when query is built', () => { + it('should add user query and parsed query to filter raw params when user query is set', () => { const builder = createQueryBuilder(); builder.userQuery = 'nuka cola quantum'; builder.buildQuery(); - expect(builder.filterRawParams).toEqual({ userQuery: '(nuka cola quantum)' }); + expect(builder.filterRawParams['userQuery']).toBe('nuka cola quantum'); + expect(builder.filterRawParams['parsedQuery']).toBe(builder.parsedQuery); }); it('should encode query from filter raw params and update query params on executing query', (done) => { @@ -754,13 +771,14 @@ describe('SearchQueryBuilder', () => { it('should encode query from filter raw params and update query params on navigating to search', async () => { spyOn(router, 'navigate'); - service.filterRawParams = { userQuery: '(test query)' }; + service.searchMode = 'formula'; await service.navigateToSearch('test query', '/search'); - expect(router.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { q: 'eyJ1c2VyUXVlcnkiOiIodGVzdCBxdWVyeSkifQ==' }, - queryParamsHandling: 'merge' - }); + const navigateArgs = (router.navigate as jasmine.Spy).calls.mostRecent().args; + expect(navigateArgs[0]).toEqual(['/search']); + expect(navigateArgs[1].queryParamsHandling).toBe('merge'); + const decoded = JSON.parse(decodeURIComponent(escape(atob(navigateArgs[1].queryParams.q)))); + expect(decoded.userQuery).toBe('test query'); }); describe('Multiple search configuration', () => { @@ -859,17 +877,17 @@ describe('SearchQueryBuilder', () => { spyOn(router, 'navigate'); spyOn(console, 'error'); const searchUrl = 'search'; - service.filterRawParams = { userQuery: '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))' }; - service.encodeQuery(); + const nonLatinQuery = '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))'; + service.searchMode = 'formula'; + + await service.navigateToSearch(nonLatinQuery, searchUrl); - await service.navigateToSearch('', searchUrl); expect(console.error).not.toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith([searchUrl], { - queryParams: { - q: 'eyJ1c2VyUXVlcnkiOiIoKGNtOm5hbWU6XCJ3xIXFvCpcIiBPUiBjbTp0aXRsZTpcInfEhcW8KlwiIE9SIGNtOmRlc2NyaXB0aW9uOlwid8SFxbwqXCIgT1IgVEVYVDpcInfEhcW8KlwiIE9SIFRBRzpcInfEhcW8KlwiKSkifQ==' - }, - queryParamsHandling: 'merge' - }); + const navigateArgs = (router.navigate as jasmine.Spy).calls.mostRecent().args; + expect(navigateArgs[0]).toEqual([searchUrl]); + expect(navigateArgs[1].queryParamsHandling).toBe('merge'); + const decoded = JSON.parse(decodeURIComponent(escape(atob(navigateArgs[1].queryParams.q)))); + expect(decoded.userQuery).toBe(nonLatinQuery); }); }); diff --git a/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts b/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts index 67d5ab38a0..5f857b5f29 100644 --- a/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts +++ b/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts @@ -80,6 +80,7 @@ describe('OidcAuthenticationService', () => { }); service = TestBed.inject(OidcAuthenticationService); oauthService = TestBed.inject(OAuthService); + mockOAuthService.logOut.calls.reset(); }); it('should be created', () => { @@ -165,6 +166,14 @@ describe('OidcAuthenticationService', () => { }); }); + describe('reset', () => { + it('should clear local OAuth state without redirecting to the IdP', () => { + service.reset(); + + expect(oauthService.logOut as jasmine.Spy).toHaveBeenCalledOnceWith(true); + }); + }); + describe('loggedIn', () => { it('should return true if has valid tokens', () => { mockOAuthService.hasValidAccessToken.and.returnValue(true); diff --git a/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts b/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts index dc7b2e3d8a..4b5176c8c9 100644 --- a/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts +++ b/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts @@ -131,8 +131,7 @@ export class OidcAuthenticationService extends BaseAuthenticationService { } reset(): void { - const config = this.authConfig.loadAppConfig(); - this.auth.updateIDPConfiguration(config); + this.oauthService.logOut(true); } isPublicUrl(): boolean { diff --git a/lib/core/src/lib/auth/public-api.ts b/lib/core/src/lib/auth/public-api.ts index c8ab3a3e2e..fac8688ead 100644 --- a/lib/core/src/lib/auth/public-api.ts +++ b/lib/core/src/lib/auth/public-api.ts @@ -49,4 +49,5 @@ export * from './models/identity-role.model'; export * from './models/user-access.model'; export * from './models/application-access.model'; +export * from './session-timeout/public-api'; export * from './oidc/public-api'; diff --git a/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.spec.ts b/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.spec.ts new file mode 100644 index 0000000000..305777b022 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.spec.ts @@ -0,0 +1,112 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { DOCUMENT } from '@angular/common'; +import { NgZone } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ACTIVITY_THROTTLE_MS, IdleActivityTracker } from './idle-activity-tracker'; + +describe('IdleActivityTracker', () => { + let tracker: IdleActivityTracker; + let doc: Document; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [IdleActivityTracker] }); + tracker = TestBed.inject(IdleActivityTracker); + doc = TestBed.inject(DOCUMENT); + }); + + afterEach(() => tracker.stop()); + + it('emits on activity$ when a registered DOM event fires after start()', () => { + const spy = jasmine.createSpy('activity'); + tracker.activity$.subscribe(spy); + tracker.start(); + + doc.dispatchEvent(new Event('click')); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('throttles a burst of activity events within the throttle window', fakeAsync(() => { + const spy = jasmine.createSpy('activity'); + tracker.activity$.subscribe(spy); + tracker.start(); + + // Leading edge emits immediately, the rest of the burst is throttled. + for (let i = 0; i < 10; i++) { + doc.dispatchEvent(new Event('mousemove')); + } + expect(spy).toHaveBeenCalledTimes(1); + + // After the throttle window, activity emits again. + tick(ACTIVITY_THROTTLE_MS); + doc.dispatchEvent(new Event('mousemove')); + expect(spy.calls.count()).toBeGreaterThan(1); + + tick(ACTIVITY_THROTTLE_MS); + })); + + it('does not emit after stop()', () => { + const spy = jasmine.createSpy('activity'); + tracker.activity$.subscribe(spy); + tracker.start(); + tracker.stop(); + + doc.dispatchEvent(new Event('click')); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('registers listeners only once across repeated start() calls', () => { + const spy = jasmine.createSpy('activity'); + tracker.activity$.subscribe(spy); + tracker.start(); + tracker.start(); + + doc.dispatchEvent(new Event('click')); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when stop() is called without a prior start()', () => { + const spy = jasmine.createSpy('activity'); + tracker.activity$.subscribe(spy); + + expect(() => tracker.stop()).not.toThrow(); + + doc.dispatchEvent(new Event('click')); + expect(spy).not.toHaveBeenCalled(); + }); + + it('emits the current visibility state on visibilitychange', () => { + const spy = jasmine.createSpy('visibility'); + tracker.visibilityChange$.subscribe(spy); + tracker.start(); + + doc.dispatchEvent(new Event('visibilitychange')); + + expect(spy).toHaveBeenCalledWith(doc.visibilityState); + }); + + it('registers listeners outside the Angular zone', () => { + const ngZone = TestBed.inject(NgZone); + const runOutside = spyOn(ngZone, 'runOutsideAngular').and.callThrough(); + tracker.start(); + expect(runOutside).toHaveBeenCalled(); + }); +}); diff --git a/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.ts b/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.ts new file mode 100644 index 0000000000..d8ef34e265 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.ts @@ -0,0 +1,74 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { DOCUMENT } from '@angular/common'; +import { Injectable, NgZone, OnDestroy, inject } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { throttleTime } from 'rxjs/operators'; + +export const ACTIVITY_EVENTS = ['click', 'keydown', 'mousedown', 'mousemove', 'pointerdown', 'scroll', 'touchstart', 'wheel'] as const; + +/** High-frequency activity events (e.g. mousemove, scroll) are throttled to avoid rescheduling the idle timer on every DOM event. */ +export const ACTIVITY_THROTTLE_MS = 1000; + +@Injectable() +export class IdleActivityTracker implements OnDestroy { + private readonly document = inject(DOCUMENT); + private readonly ngZone = inject(NgZone); + private readonly activitySubject = new Subject(); + private readonly visibilitySubject = new Subject(); + private isRegistered = false; + + readonly activity$: Observable = this.activitySubject + .asObservable() + .pipe(throttleTime(ACTIVITY_THROTTLE_MS, undefined, { leading: true, trailing: true })); + readonly visibilityChange$: Observable = this.visibilitySubject.asObservable(); + + start(): void { + if (this.isRegistered) { + return; + } + + this.ngZone.runOutsideAngular(() => { + ACTIVITY_EVENTS.forEach((eventName) => this.document.addEventListener(eventName, this.handleActivity, { passive: true })); + this.document.addEventListener('visibilitychange', this.handleVisibilityChange); + }); + this.isRegistered = true; + } + + stop(): void { + if (!this.isRegistered) { + return; + } + + ACTIVITY_EVENTS.forEach((eventName) => this.document.removeEventListener(eventName, this.handleActivity)); + this.document.removeEventListener('visibilitychange', this.handleVisibilityChange); + this.isRegistered = false; + } + + ngOnDestroy(): void { + this.stop(); + } + + private readonly handleActivity = (): void => { + this.activitySubject.next(); + }; + + private readonly handleVisibilityChange = (): void => { + this.visibilitySubject.next(this.document.visibilityState); + }; +} diff --git a/lib/core/src/lib/auth/session-timeout/provide-session-timeout.spec.ts b/lib/core/src/lib/auth/session-timeout/provide-session-timeout.spec.ts new file mode 100644 index 0000000000..f650d206c0 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/provide-session-timeout.spec.ts @@ -0,0 +1,70 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { ApplicationInitStatus } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; +import { provideSessionTimeout } from './provide-session-timeout'; +import { SessionTimeoutService } from './session-timeout.service'; +import { AppConfigService } from '../../app-config/app-config.service'; +import { AuthenticationService } from '../services/authentication.service'; + +describe('provideSessionTimeout', () => { + it('registers SessionTimeoutService and runs its start() during app init', async () => { + const startSpy = spyOn(SessionTimeoutService.prototype, 'start'); + TestBed.configureTestingModule({ + providers: [ + provideSessionTimeout(), + { provide: AppConfigService, useValue: { get: () => ({}), isLoaded: true, onLoad: new Subject() } }, + { + provide: AuthenticationService, + useValue: { isLoggedIn: () => false, logout: () => {}, onLogin: new Subject(), onLogout: new Subject() } + } + ] + }); + await TestBed.inject(ApplicationInitStatus).donePromise; + expect(startSpy).toHaveBeenCalled(); + }); + + it('waits for startWhen to emit true before starting SessionTimeoutService', async () => { + const startWhen$ = new Subject(); + const startSpy = spyOn(SessionTimeoutService.prototype, 'start'); + + TestBed.configureTestingModule({ + providers: [ + provideSessionTimeout({ startWhen: () => startWhen$ }), + { provide: AppConfigService, useValue: { get: () => ({}), isLoaded: true, onLoad: new Subject() } }, + { + provide: AuthenticationService, + useValue: { isLoggedIn: () => false, logout: () => {}, onLogin: new Subject(), onLogout: new Subject() } + } + ] + }); + await TestBed.inject(ApplicationInitStatus).donePromise; + + expect(startSpy).not.toHaveBeenCalled(); + + startWhen$.next(false); + expect(startSpy).not.toHaveBeenCalled(); + + startWhen$.next(true); + expect(startSpy).toHaveBeenCalledTimes(1); + + startWhen$.next(true); + expect(startSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/core/src/lib/auth/session-timeout/provide-session-timeout.ts b/lib/core/src/lib/auth/session-timeout/provide-session-timeout.ts new file mode 100644 index 0000000000..12ff4be362 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/provide-session-timeout.ts @@ -0,0 +1,53 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { EnvironmentProviders, inject, makeEnvironmentProviders, provideAppInitializer } from '@angular/core'; +import { SESSION_TIMEOUT_OPTIONS, SessionTimeoutOptions } from './session-timeout.config'; +import { IdleActivityTracker } from './idle-activity-tracker'; +import { SessionTimeoutSyncChannel } from './session-timeout-sync-channel'; +import { SessionTimeoutService } from './session-timeout.service'; +import { filter, take } from 'rxjs/operators'; + +/** + * Provides the session timeout feature: idle tracking, the countdown dialog and cross-tab sync. + * + * When the countdown dialog is not answered (or the user clicks "Log out"), the normal logout flow + * runs and the user is redirected to the configured IdP/login page. + * + * @param options - Optional overrides that take precedence over the `sessionTimeout` app config block + * @returns Environment providers that register the service and start it during app initialization + */ +export function provideSessionTimeout(options?: SessionTimeoutOptions): EnvironmentProviders { + return makeEnvironmentProviders([ + { provide: SESSION_TIMEOUT_OPTIONS, useValue: options ?? {} }, + IdleActivityTracker, + SessionTimeoutSyncChannel, + SessionTimeoutService, + provideAppInitializer(() => { + const sessionTimeoutService = inject(SessionTimeoutService); + const sessionTimeoutOptions = inject(SESSION_TIMEOUT_OPTIONS); + const startWhen$ = sessionTimeoutOptions.startWhen?.(); + + if (!startWhen$) { + sessionTimeoutService.start(); + return; + } + + startWhen$.pipe(filter(Boolean), take(1)).subscribe(() => sessionTimeoutService.start()); + }) + ]); +} diff --git a/lib/core/src/lib/auth/session-timeout/public-api.ts b/lib/core/src/lib/auth/session-timeout/public-api.ts new file mode 100644 index 0000000000..5dfb0f7c09 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/public-api.ts @@ -0,0 +1,23 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +export * from './session-timeout.config'; +export * from './idle-activity-tracker'; +export * from './session-timeout-sync-channel'; +export * from './session-timeout-dialog.component'; +export * from './session-timeout.service'; +export * from './provide-session-timeout'; diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.html b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.html new file mode 100644 index 0000000000..3c3c332c92 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.html @@ -0,0 +1,14 @@ +

{{ 'SESSION_TIMEOUT.TITLE' | translate }}

+ + + {{ 'SESSION_TIMEOUT.MESSAGE' | translate: { seconds: remainingSeconds } }} + + + + + + diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.scss b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.scss new file mode 100644 index 0000000000..fb4a6f6209 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.scss @@ -0,0 +1,4 @@ +.adf-session-timeout-backdrop { + background-color: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(16px); +} diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.spec.ts b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.spec.ts new file mode 100644 index 0000000000..1280cd7f93 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.spec.ts @@ -0,0 +1,64 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { SessionTimeoutDialogComponent } from './session-timeout-dialog.component'; + +describe('SessionTimeoutDialogComponent', () => { + const dialogRef = { close: jasmine.createSpy('close') }; + + beforeEach(() => { + dialogRef.close.calls.reset(); + TestBed.configureTestingModule({ + imports: [SessionTimeoutDialogComponent, NoopAnimationsModule, TranslateModule.forRoot()], + providers: [ + { provide: MatDialogRef, useValue: dialogRef }, + { provide: MAT_DIALOG_DATA, useValue: { dialogTimeoutMs: 3000 } } + ] + }); + }); + + it('initializes remainingSeconds from dialogTimeoutMs', () => { + const fixture = TestBed.createComponent(SessionTimeoutDialogComponent); + expect(fixture.componentInstance.remainingSeconds).toBe(3); + }); + + it('counts down each second', fakeAsync(() => { + const fixture = TestBed.createComponent(SessionTimeoutDialogComponent); + fixture.detectChanges(); + tick(1000); + expect(fixture.componentInstance.remainingSeconds).toBe(2); + tick(1000); + expect(fixture.componentInstance.remainingSeconds).toBe(1); + fixture.destroy(); + })); + + it('closes with true on continueWorking()', () => { + const fixture = TestBed.createComponent(SessionTimeoutDialogComponent); + fixture.componentInstance.continueWorking(); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('closes with false on logout()', () => { + const fixture = TestBed.createComponent(SessionTimeoutDialogComponent); + fixture.componentInstance.logout(); + expect(dialogRef.close).toHaveBeenCalledWith(false); + }); +}); diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.ts b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.ts new file mode 100644 index 0000000000..d463afcd2f --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.ts @@ -0,0 +1,67 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, ViewEncapsulation, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { TranslatePipe } from '@ngx-translate/core'; + +export const SESSION_TIMEOUT_BACKDROP_CLASS = 'adf-session-timeout-backdrop'; + +export interface SessionTimeoutDialogData { + dialogTimeoutMs: number; +} + +@Component({ + selector: 'adf-session-timeout-dialog', + standalone: true, + imports: [MatButtonModule, MatDialogModule, TranslatePipe], + templateUrl: './session-timeout-dialog.component.html', + styleUrl: './session-timeout-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +export class SessionTimeoutDialogComponent implements OnDestroy { + private readonly dialogRef = inject>(MatDialogRef); + private readonly data = inject(MAT_DIALOG_DATA); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly timeoutEndTime = Date.now() + this.data.dialogTimeoutMs; + private readonly countdownIntervalId = setInterval(() => this.updateRemainingSeconds(), 1000); + + remainingSeconds = this.getRemainingSeconds(); + + ngOnDestroy(): void { + clearInterval(this.countdownIntervalId); + } + + continueWorking(): void { + this.dialogRef.close(true); + } + + logout(): void { + this.dialogRef.close(false); + } + + private updateRemainingSeconds(): void { + this.remainingSeconds = this.getRemainingSeconds(); + this.changeDetectorRef.markForCheck(); + } + + private getRemainingSeconds(): number { + return Math.max(Math.ceil((this.timeoutEndTime - Date.now()) / 1000), 0); + } +} diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.spec.ts b/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.spec.ts new file mode 100644 index 0000000000..72f710b00e --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.spec.ts @@ -0,0 +1,119 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { DOCUMENT } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; +import { SessionTimeoutSyncChannel } from './session-timeout-sync-channel'; + +class MockBroadcastChannel { + static instances: MockBroadcastChannel[] = []; + postMessage = jasmine.createSpy('postMessage').and.callFake((message: unknown) => { + MockBroadcastChannel.instances + .filter((instance) => instance !== this && instance.name === this.name) + .forEach((instance) => instance.dispatch(message)); + }); + close = jasmine.createSpy('close'); + private readonly listeners = new Set<(event: MessageEvent) => void>(); + constructor(public name: string) { + MockBroadcastChannel.instances.push(this); + } + addEventListener(type: string, listener: (event: MessageEvent) => void): void { + if (type === 'message') { + this.listeners.add(listener); + } + } + removeEventListener(_type: string, listener: (event: MessageEvent) => void): void { + this.listeners.delete(listener); + } + dispatch(data: unknown): void { + this.listeners.forEach((listener) => listener({ data } as MessageEvent)); + } +} + +describe('SessionTimeoutSyncChannel', () => { + let doc: Document; + let original: typeof BroadcastChannel | undefined; + + beforeEach(() => { + MockBroadcastChannel.instances = []; + TestBed.configureTestingModule({ providers: [SessionTimeoutSyncChannel] }); + doc = TestBed.inject(DOCUMENT); + original = (doc.defaultView as any).BroadcastChannel; + (doc.defaultView as any).BroadcastChannel = MockBroadcastChannel; + }); + + afterEach(() => { + (doc.defaultView as any).BroadcastChannel = original; + }); + + it('posts a well-formed event with type, sourceTabId and createdAt', () => { + const channel = TestBed.inject(SessionTimeoutSyncChannel); + channel.open(); + channel.post('activity'); + + const instance = MockBroadcastChannel.instances[0]; + expect(instance.postMessage).toHaveBeenCalledTimes(1); + const payload = instance.postMessage.calls.mostRecent().args[0]; + expect(payload.type).toBe('activity'); + expect(typeof payload.sourceTabId).toBe('string'); + expect(typeof payload.createdAt).toBe('number'); + }); + + it('ignores messages originating from its own tab', () => { + const channel = TestBed.inject(SessionTimeoutSyncChannel); + const spy = jasmine.createSpy('messages'); + channel.messages$.subscribe(spy); + channel.open(); + const instance = MockBroadcastChannel.instances[0]; + channel.post('timeout'); + instance.dispatch(instance.postMessage.calls.mostRecent().args[0]); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('emits validated messages from other tabs', () => { + const channel = TestBed.inject(SessionTimeoutSyncChannel); + const spy = jasmine.createSpy('messages'); + channel.messages$.subscribe(spy); + channel.open(); + const instance = MockBroadcastChannel.instances[0]; + + instance.dispatch({ type: 'logout', sourceTabId: 'other-tab', createdAt: 123 }); + + expect(spy).toHaveBeenCalledWith({ type: 'logout', sourceTabId: 'other-tab', createdAt: 123 }); + }); + + it('drops malformed messages', () => { + const channel = TestBed.inject(SessionTimeoutSyncChannel); + const spy = jasmine.createSpy('messages'); + channel.messages$.subscribe(spy); + channel.open(); + const instance = MockBroadcastChannel.instances[0]; + + instance.dispatch({ type: 'nope' }); + instance.dispatch(null); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('post() does not throw when channel is unavailable', () => { + (doc.defaultView as any).BroadcastChannel = undefined; + const channel = TestBed.inject(SessionTimeoutSyncChannel); + channel.open(); + expect(() => channel.post('activity')).not.toThrow(); + }); +}); diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.ts b/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.ts new file mode 100644 index 0000000000..cd242d35ea --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.ts @@ -0,0 +1,120 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { DOCUMENT } from '@angular/common'; +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; + +const SESSION_TIMEOUT_CHANNEL_NAME = 'adf-session-timeout'; + +export const SESSION_TIMEOUT_SYNC_EVENT_TYPES = ['activity', 'timeout', 'continue', 'logout'] as const; + +export type SessionTimeoutSyncEventType = (typeof SESSION_TIMEOUT_SYNC_EVENT_TYPES)[number]; + +export interface SessionTimeoutSyncEvent { + type: SessionTimeoutSyncEventType; + sourceTabId: string; + createdAt: number; +} + +@Injectable() +export class SessionTimeoutSyncChannel implements OnDestroy { + private readonly document = inject(DOCUMENT); + private readonly window = this.document.defaultView; + private readonly tabId = this.createTabId(); + private readonly messageSubject = new Subject(); + private channel: BroadcastChannel | undefined; + + readonly messages$: Observable = this.messageSubject.asObservable(); + + open(): void { + if (this.channel || !this.window?.BroadcastChannel) { + return; + } + + this.channel = new this.window.BroadcastChannel(SESSION_TIMEOUT_CHANNEL_NAME); + this.channel.addEventListener('message', this.handleMessage); + } + + close(): void { + if (!this.channel) { + return; + } + + this.channel.removeEventListener('message', this.handleMessage); + this.channel.close(); + this.channel = undefined; + } + + post(type: SessionTimeoutSyncEventType): void { + try { + this.channel?.postMessage({ + type, + sourceTabId: this.tabId, + createdAt: Date.now() + } satisfies SessionTimeoutSyncEvent); + } catch { + /* empty */ + } + } + + ngOnDestroy(): void { + this.close(); + } + + private readonly handleMessage = (event: MessageEvent): void => { + const syncEvent = this.parse(event.data); + if (!syncEvent || syncEvent.sourceTabId === this.tabId) { + return; + } + + this.messageSubject.next(syncEvent); + }; + + private parse(value: unknown): SessionTimeoutSyncEvent | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + + const syncEvent = value as Partial; + if ( + typeof syncEvent.type === 'string' && + (SESSION_TIMEOUT_SYNC_EVENT_TYPES as readonly string[]).includes(syncEvent.type) && + typeof syncEvent.sourceTabId === 'string' && + typeof syncEvent.createdAt === 'number' + ) { + return syncEvent as SessionTimeoutSyncEvent; + } + + return undefined; + } + + private createTabId(): string { + const crypto = this.window?.crypto; + if (crypto?.randomUUID) { + return crypto.randomUUID(); + } + + if (crypto?.getRandomValues) { + const buffer = new Uint32Array(4); + crypto.getRandomValues(buffer); + return Array.from(buffer, (value) => value.toString(16)).join('-'); + } + + return `${Date.now()}-${this.window?.performance?.now?.() ?? 0}`; + } +} diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout.config.spec.ts b/lib/core/src/lib/auth/session-timeout/session-timeout.config.spec.ts new file mode 100644 index 0000000000..04a993d8bb --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout.config.spec.ts @@ -0,0 +1,47 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { normalizeSessionTimeoutOptions, DEFAULT_SESSION_TIMEOUT_OPTIONS } from './session-timeout.config'; + +describe('normalizeSessionTimeoutOptions', () => { + it('coerces string "true" to boolean enabled', () => { + expect(normalizeSessionTimeoutOptions({ enabled: 'true' }).enabled).toBe(true); + }); + + it('preserves boolean enabled values', () => { + expect(normalizeSessionTimeoutOptions({ enabled: true }).enabled).toBe(true); + expect(normalizeSessionTimeoutOptions({ enabled: false }).enabled).toBe(false); + }); + + it('treats non-"true" strings and missing as disabled', () => { + expect(normalizeSessionTimeoutOptions({ enabled: 'false' }).enabled).toBe(false); + expect(normalizeSessionTimeoutOptions({}).enabled).toBe(false); + }); + + it('parses numeric strings for timeouts', () => { + const result = normalizeSessionTimeoutOptions({ idleTimeoutMs: '1000', dialogTimeoutMs: '2000' }); + expect(result.idleTimeoutMs).toBe(1000); + expect(result.dialogTimeoutMs).toBe(2000); + }); + + it('falls back to defaults for non-positive or invalid numbers', () => { + const result = normalizeSessionTimeoutOptions({ idleTimeoutMs: 0, dialogTimeoutMs: 'abc' }); + expect(result.enabled).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.enabled); + expect(result.idleTimeoutMs).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs); + expect(result.dialogTimeoutMs).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs); + }); +}); diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout.config.ts b/lib/core/src/lib/auth/session-timeout/session-timeout.config.ts new file mode 100644 index 0000000000..b3be545c72 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout.config.ts @@ -0,0 +1,63 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { InjectionToken } from '@angular/core'; +import { Observable } from 'rxjs'; + +export const SESSION_TIMEOUT_CONFIG_KEY = 'sessionTimeout'; + +export interface SessionTimeoutOptions { + enabled?: boolean | string; + idleTimeoutMs?: number | string; + dialogTimeoutMs?: number | string; + startWhen?: () => Observable; +} + +export interface NormalizedSessionTimeoutOptions { + enabled: boolean; + idleTimeoutMs: number; + dialogTimeoutMs: number; +} + +export const DEFAULT_SESSION_TIMEOUT_OPTIONS: NormalizedSessionTimeoutOptions = { + enabled: false, + idleTimeoutMs: 30 * 60 * 1000, + dialogTimeoutMs: 60 * 1000 +}; + +export const SESSION_TIMEOUT_OPTIONS = new InjectionToken('SESSION_TIMEOUT_OPTIONS'); + +const toBoolean = (value: boolean | string | undefined): boolean => value === true || value === 'true'; + +const toPositiveNumber = (value: number | string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +/** + * Coerces raw session timeout options (which may arrive as strings from app config) into validated values. + * + * @param merged - Raw options merged from defaults, app config and provider input + * @returns Normalized options with a boolean `enabled` flag and positive numeric timeouts + */ +export function normalizeSessionTimeoutOptions(merged: SessionTimeoutOptions): NormalizedSessionTimeoutOptions { + return { + enabled: merged.enabled === undefined ? DEFAULT_SESSION_TIMEOUT_OPTIONS.enabled : toBoolean(merged.enabled), + idleTimeoutMs: toPositiveNumber(merged.idleTimeoutMs, DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs), + dialogTimeoutMs: toPositiveNumber(merged.dialogTimeoutMs, DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs) + }; +} diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout.service.spec.ts b/lib/core/src/lib/auth/session-timeout/session-timeout.service.spec.ts new file mode 100644 index 0000000000..304555c84d --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout.service.spec.ts @@ -0,0 +1,537 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { TestBed, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing'; +import { Observable, Subject, defer } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { AppConfigService } from '../../app-config/app-config.service'; +import { AuthenticationService } from '../services/authentication.service'; +import { SessionTimeoutService } from './session-timeout.service'; +import { SessionTimeoutOptions, SESSION_TIMEOUT_OPTIONS } from './session-timeout.config'; +import { IdleActivityTracker } from './idle-activity-tracker'; +import { SessionTimeoutSyncChannel, SessionTimeoutSyncEvent } from './session-timeout-sync-channel'; +import { SESSION_TIMEOUT_BACKDROP_CLASS } from './session-timeout-dialog.component'; + +describe('SessionTimeoutService', () => { + let authService: { + isLoggedIn: jasmine.Spy<() => boolean>; + logout: jasmine.Spy<() => Observable>; + reset: jasmine.Spy<() => void>; + onLogin: Subject; + onLogout: Subject; + }; + let logoutSideEffect: jasmine.Spy; + let appConfigService: { + get: jasmine.Spy<(key: string, fallback: SessionTimeoutOptions) => SessionTimeoutOptions>; + isLoaded: boolean; + onLoad: Subject; + }; + let dialog: { + open: jasmine.Spy; + }; + let tracker: { + start: jasmine.Spy; + stop: jasmine.Spy; + activity$: Subject; + visibilityChange$: Subject; + }; + let syncChannel: { + open: jasmine.Spy; + close: jasmine.Spy; + post: jasmine.Spy; + messages$: Subject; + }; + let dialogClosed$: Subject; + let isLoggedIn: boolean; + + const configureTestingModule = (sessionTimeoutOptions?: SessionTimeoutOptions, providerOptions?: SessionTimeoutOptions) => { + isLoggedIn = true; + logoutSideEffect = jasmine.createSpy('logoutSideEffect'); + authService = { + isLoggedIn: jasmine.createSpy('isLoggedIn').and.callFake(() => isLoggedIn), + logout: jasmine.createSpy('logout').and.callFake(() => + defer(() => { + logoutSideEffect(); + return new Observable((subscriber) => subscriber.complete()); + }) + ), + reset: jasmine.createSpy('reset'), + onLogin: new Subject(), + onLogout: new Subject() + }; + appConfigService = { + get: jasmine.createSpy('get').and.callFake((_key: string, fallback: SessionTimeoutOptions) => sessionTimeoutOptions ?? fallback), + isLoaded: true, + onLoad: new Subject() + }; + dialogClosed$ = new Subject(); + dialog = { + open: jasmine.createSpy('open').and.returnValue({ + afterClosed: () => dialogClosed$, + close: (value?: boolean) => { + dialogClosed$.next(value); + dialogClosed$.complete(); + } + }) + }; + tracker = { + start: jasmine.createSpy('start'), + stop: jasmine.createSpy('stop'), + activity$: new Subject(), + visibilityChange$: new Subject() + }; + syncChannel = { + open: jasmine.createSpy('open'), + close: jasmine.createSpy('close'), + post: jasmine.createSpy('post'), + messages$: new Subject() + }; + TestBed.configureTestingModule({ + providers: [ + SessionTimeoutService, + { provide: SESSION_TIMEOUT_OPTIONS, useValue: providerOptions ?? {} }, + { provide: AuthenticationService, useValue: authService }, + { provide: AppConfigService, useValue: appConfigService }, + { provide: MatDialog, useValue: dialog }, + { provide: IdleActivityTracker, useValue: tracker }, + { provide: SessionTimeoutSyncChannel, useValue: syncChannel } + ] + }); + }; + + const startService = ({ emitLoginEvent = true } = {}) => { + const service = TestBed.inject(SessionTimeoutService); + service.start(); + if (emitLoginEvent) { + authService.onLogin.next(undefined); + } + + return service; + }; + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should not activate when session timeout is disabled', fakeAsync(() => { + configureTestingModule({ enabled: false, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(tracker.start).not.toHaveBeenCalled(); + expect(syncChannel.open).not.toHaveBeenCalled(); + expect(dialog.open).not.toHaveBeenCalled(); + expect(authService.logout).not.toHaveBeenCalled(); + })); + + it('should not activate by default when enabled is not configured', fakeAsync(() => { + configureTestingModule({ idleTimeoutMs: 1000 }); + + startService(); + + expect(tracker.start).not.toHaveBeenCalled(); + expect(syncChannel.open).not.toHaveBeenCalled(); + })); + + it('should wait for app config to load before starting', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + appConfigService.isLoaded = false; + + startService({ emitLoginEvent: false }); + tick(1000); + + expect(tracker.start).not.toHaveBeenCalled(); + + appConfigService.isLoaded = true; + appConfigService.onLoad.next({}); + flushMicrotasks(); + authService.onLogin.next(undefined); + tick(1000); + + expect(tracker.start).toHaveBeenCalledTimes(1); + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should start tracker and open sync channel on login when logged in', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + + expect(tracker.start).toHaveBeenCalledTimes(1); + expect(syncChannel.open).toHaveBeenCalledTimes(1); + })); + + it('should clear session state when login event fires but user is not logged in', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + isLoggedIn = false; + + startService(); + tick(1000); + + expect(tracker.start).not.toHaveBeenCalled(); + expect(dialog.open).not.toHaveBeenCalled(); + })); + + it('should arm immediately on start when a session is already authenticated', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + // No onLogin event is emitted: the session predates this service. + startService({ emitLoginEvent: false }); + + expect(tracker.start).toHaveBeenCalledTimes(1); + expect(syncChannel.open).toHaveBeenCalledTimes(1); + + tick(1000); + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should not leak handler subscriptions across logout and login cycles', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + expect(tracker.activity$.observers.length).toBe(1); + expect(syncChannel.messages$.observers.length).toBe(1); + + authService.onLogout.next({}); + flushMicrotasks(); + + // Logout must tear down the session-scoped subscriptions. + expect(tracker.activity$.observers.length).toBe(0); + expect(syncChannel.messages$.observers.length).toBe(0); + + authService.onLogin.next(undefined); + + // Re-login arms exactly one fresh set, never two. + expect(tracker.activity$.observers.length).toBe(1); + expect(syncChannel.messages$.observers.length).toBe(1); + })); + + it('should not re-arm or duplicate subscriptions when redundant login events fire', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + authService.onLogin.next(undefined); + authService.onLogin.next(undefined); + + expect(tracker.start).toHaveBeenCalledTimes(1); + expect(syncChannel.open).toHaveBeenCalledTimes(1); + expect(tracker.activity$.observers.length).toBe(1); + expect(syncChannel.messages$.observers.length).toBe(1); + })); + + it('should open dialog after idle timeout with no activity', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + expect(dialog.open).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.objectContaining({ + data: { dialogTimeoutMs: 60000 }, + backdropClass: SESSION_TIMEOUT_BACKDROP_CLASS, + disableClose: true, + width: '420px' + }) + ); + expect(authService.logout).not.toHaveBeenCalled(); + })); + + it('should reschedule timeout when activity occurs before idle timeout', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(750); + tracker.activity$.next(); + tick(750); + + expect(dialog.open).not.toHaveBeenCalled(); + + tick(250); + + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should ignore activity while dialog is open', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + + tracker.activity$.next(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should continue working when dialog is confirmed', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + dialogClosed$.next(true); + flushMicrotasks(); + + expect(authService.logout).not.toHaveBeenCalled(); + expect(syncChannel.post).toHaveBeenCalledWith('continue'); + + tick(999); + expect(dialog.open).toHaveBeenCalledTimes(1); + + tick(1); + expect(dialog.open).toHaveBeenCalledTimes(2); + })); + + it('should run the full logout flow when the dialog auto-closes on timeout', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000, dialogTimeoutMs: 500 }); + + startService(); + tick(1000); + tick(500); + flushMicrotasks(); + + // An unanswered (timed-out) dialog logs the user out and redirects, same as the explicit "Log out" button. + expect(authService.logout).toHaveBeenCalledTimes(1); + expect(logoutSideEffect).toHaveBeenCalledTimes(1); + expect(authService.reset).not.toHaveBeenCalled(); + // Other tabs are told to log out too. + expect(syncChannel.post).toHaveBeenCalledWith('logout'); + })); + + it('should logout (not redirect) when the user explicitly chooses to log out', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + dialogClosed$.next(false); + flushMicrotasks(); + + expect(authService.logout).toHaveBeenCalledTimes(1); + expect(logoutSideEffect).toHaveBeenCalledTimes(1); + })); + + it('should logout once when dialog is rejected (idempotent)', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + dialogClosed$.next(false); + flushMicrotasks(); + + expect(authService.logout).toHaveBeenCalledTimes(1); + expect(syncChannel.post).toHaveBeenCalledWith('logout'); + + // Try to logout again + dialogClosed$.next(false); + flushMicrotasks(); + + expect(authService.logout).toHaveBeenCalledTimes(1); + })); + + it('should throttle activity sync posts to one per 1000ms', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 5000 }); + + startService(); + + tracker.activity$.next(); + tracker.activity$.next(); + tracker.activity$.next(); + + expect(syncChannel.post).toHaveBeenCalledWith('activity'); + expect(syncChannel.post).toHaveBeenCalledTimes(1); + + tick(999); + tracker.activity$.next(); + expect(syncChannel.post).toHaveBeenCalledTimes(1); + + tick(1); + tracker.activity$.next(); + expect(syncChannel.post).toHaveBeenCalledTimes(2); + })); + + it('should continue session when inbound activity message received', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(750); + syncChannel.messages$.next({ + type: 'activity', + sourceTabId: 'other-tab', + createdAt: Date.now() + }); + tick(750); + + expect(dialog.open).not.toHaveBeenCalled(); + + tick(250); + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should continue session when inbound continue message received', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + + // When continue is received, the dialog closes without a value + // We need to complete the dialog observable to avoid the EmptyError + const dialogCloseSpy = jasmine.createSpy('dialogClose'); + const mockDialogRef = dialog.open.calls.mostRecent().returnValue; + mockDialogRef.close = dialogCloseSpy; + + syncChannel.messages$.next({ + type: 'continue', + sourceTabId: 'other-tab', + createdAt: Date.now() + }); + + expect(dialogCloseSpy).toHaveBeenCalled(); + + // Session should continue, new timeout scheduled + tick(999); + expect(dialog.open).toHaveBeenCalledTimes(1); + + tick(1); + expect(dialog.open).toHaveBeenCalledTimes(2); + })); + + it('should open dialog when inbound timeout message received without re-broadcasting', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + const service = startService(); + (service as any).lastActivityAt = Date.now() - 500; + + syncChannel.messages$.next({ + type: 'timeout', + sourceTabId: 'other-tab', + createdAt: Date.now() + }); + + expect(dialog.open).toHaveBeenCalledTimes(1); + expect(syncChannel.post).not.toHaveBeenCalledWith('timeout'); + })); + + it('should not open dialog when inbound timeout message is stale', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + const service = startService(); + (service as any).lastActivityAt = Date.now(); + + syncChannel.messages$.next({ + type: 'timeout', + sourceTabId: 'other-tab', + createdAt: Date.now() - 1000 + }); + + expect(dialog.open).not.toHaveBeenCalled(); + })); + + it('should logout when inbound logout message received without re-broadcasting', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + syncChannel.messages$.next({ + type: 'logout', + sourceTabId: 'other-tab', + createdAt: Date.now() + }); + + expect(authService.logout).toHaveBeenCalledTimes(1); + expect(syncChannel.post).not.toHaveBeenCalledWith('logout'); + })); + + it('should re-evaluate timeout when visibility changes to visible', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(500); + + // Simulate elapsed time during hidden state + const service = TestBed.inject(SessionTimeoutService); + (service as any).lastActivityAt = Date.now() - 1001; + + tracker.visibilityChange$.next('visible'); + flushMicrotasks(); + + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should clear state and stop tracker on logout event', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + + authService.onLogout.next({}); + flushMicrotasks(); + + expect(tracker.stop).toHaveBeenCalledTimes(1); + expect(syncChannel.close).toHaveBeenCalledTimes(1); + })); + + it('should tear down resources on ngOnDestroy', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + const service = startService(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + + service.ngOnDestroy(); + + expect(tracker.stop).toHaveBeenCalledTimes(1); + expect(syncChannel.close).toHaveBeenCalledTimes(1); + })); + + it('should not logout when dialog is closed without explicit choice', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + dialogClosed$.next(undefined); + flushMicrotasks(); + + expect(authService.logout).not.toHaveBeenCalled(); + })); + + it('should use provider options over app config options', fakeAsync(() => { + configureTestingModule({ enabled: false, idleTimeoutMs: 1000 }, { enabled: true, idleTimeoutMs: 500 }); + + startService(); + tick(500); + + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should post timeout message when dialog opens', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(syncChannel.post).toHaveBeenCalledWith('timeout'); + })); +}); diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout.service.ts b/lib/core/src/lib/auth/session-timeout/session-timeout.service.ts new file mode 100644 index 0000000000..25705e3f52 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout.service.ts @@ -0,0 +1,370 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 + * + * 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. + */ + +import { Injectable, NgZone, OnDestroy, inject } from '@angular/core'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Subscription } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { AppConfigService } from '../../app-config/app-config.service'; +import { AuthenticationService } from '../services/authentication.service'; +import { + SESSION_TIMEOUT_CONFIG_KEY, + SESSION_TIMEOUT_OPTIONS, + SessionTimeoutOptions, + DEFAULT_SESSION_TIMEOUT_OPTIONS, + normalizeSessionTimeoutOptions +} from './session-timeout.config'; +import { IdleActivityTracker } from './idle-activity-tracker'; +import { SessionTimeoutSyncChannel, SessionTimeoutSyncEvent } from './session-timeout-sync-channel'; +import { SessionTimeoutDialogComponent, SessionTimeoutDialogData, SESSION_TIMEOUT_BACKDROP_CLASS } from './session-timeout-dialog.component'; + +const ACTIVITY_SYNC_THROTTLE_MS = 1000; + +@Injectable() +export class SessionTimeoutService implements OnDestroy { + private readonly appConfigService = inject(AppConfigService); + private readonly authService = inject(AuthenticationService); + private readonly dialog = inject(MatDialog); + private readonly ngZone = inject(NgZone); + private readonly providerOptions = inject(SESSION_TIMEOUT_OPTIONS); + private readonly tracker = inject(IdleActivityTracker); + private readonly syncChannel = inject(SessionTimeoutSyncChannel); + private readonly subscription = new Subscription(); + private sessionSubscription: Subscription | undefined; + private timeoutId: ReturnType | undefined; + private dialogTimeoutId: ReturnType | undefined; + private dialogRef: MatDialogRef | undefined; + private idleTimeoutMs = DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs; + private dialogTimeoutMs = DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs; + private lastActivityAt = Date.now(); + private isStarted = false; + private isSessionActive = false; + private isLoggingOut = false; + private lastActivitySyncAt = 0; + + start(): void { + if (this.isStarted) { + return; + } + + if (this.appConfigService.isLoaded === false) { + this.appConfigService.onLoad.pipe(take(1)).subscribe(() => { + this.startFromLoadedConfig(); + }); + return; + } + + this.startFromLoadedConfig(); + } + + ngOnDestroy(): void { + this.clearTimeout(); + this.clearDialogTimeout(); + this.dialogRef?.close(); + this.tracker.stop(); + this.syncChannel.close(); + this.sessionSubscription?.unsubscribe(); + this.subscription.unsubscribe(); + } + + private startFromLoadedConfig(): void { + if (this.isStarted) { + return; + } + + const appConfigOptions = this.appConfigService.get(SESSION_TIMEOUT_CONFIG_KEY, {}) as SessionTimeoutOptions; + const mergedOptions = { + ...DEFAULT_SESSION_TIMEOUT_OPTIONS, + ...appConfigOptions, + ...this.providerOptions + }; + const options = normalizeSessionTimeoutOptions(mergedOptions); + + if (!options.enabled) { + return; + } + + this.idleTimeoutMs = options.idleTimeoutMs; + this.dialogTimeoutMs = options.dialogTimeoutMs; + this.isStarted = true; + this.subscribeToAuthEvents(); + + if (this.authService.isLoggedIn()) { + this.isSessionActive = true; + this.isLoggingOut = false; + this.activateSessionTimeout(); + } + } + + private subscribeToAuthEvents(): void { + this.subscription.add( + this.authService.onLogin.subscribe(() => { + const isLoggedIn = this.authService.isLoggedIn(); + + if (!isLoggedIn) { + this.clearSessionState(); + return; + } + + this.isSessionActive = true; + this.isLoggingOut = false; + this.activateSessionTimeout(); + }) + ); + + this.subscription.add( + this.authService.onLogout.subscribe(() => { + this.clearSessionState(); + }) + ); + } + + private activateSessionTimeout(): void { + this.ngZone.runOutsideAngular(() => { + if (!this.canArmSessionTimeout()) { + return; + } + + if (this.sessionSubscription) { + this.refreshSession(); + return; + } + + this.sessionSubscription = new Subscription(); + this.tracker.start(); + this.syncChannel.open(); + this.subscribeToTrackerEvents(); + this.subscribeToSyncEvents(); + this.refreshSession(); + }); + } + + private subscribeToTrackerEvents(): void { + this.sessionSubscription?.add( + this.tracker.activity$.subscribe(() => { + this.handleActivity(); + }) + ); + + this.sessionSubscription?.add( + this.tracker.visibilityChange$.subscribe((state) => { + this.handleVisibilityChange(state); + }) + ); + } + + private subscribeToSyncEvents(): void { + this.sessionSubscription?.add( + this.syncChannel.messages$.subscribe((syncEvent) => { + this.handleSyncEvent(syncEvent); + }) + ); + } + + private handleActivity(): void { + if (this.dialogRef) { + return; + } + + this.refreshSession({ shouldNotifyActivity: true }); + } + + private handleVisibilityChange(state: DocumentVisibilityState): void { + if (state === 'visible') { + this.handleTimeout(); + } + } + + private handleSyncEvent(syncEvent: SessionTimeoutSyncEvent): void { + if (!this.canArmSessionTimeout() && syncEvent.type !== 'logout') { + return; + } + + if (syncEvent.type === 'activity' || syncEvent.type === 'continue') { + this.continueSession(); + return; + } + + if (syncEvent.type === 'timeout') { + if (syncEvent.createdAt >= this.lastActivityAt) { + this.openContinueWorkingDialog(false); + } + return; + } + + this.logout(false); + } + + private refreshSession(options?: { shouldNotifyActivity?: boolean }): void { + if (!this.canArmSessionTimeout()) { + return; + } + + this.lastActivityAt = Date.now(); + if (options?.shouldNotifyActivity === true) { + this.notifyActivity(); + } + this.scheduleTimeout(); + } + + private scheduleTimeout(): void { + this.clearTimeout(); + + if (!this.canArmSessionTimeout()) { + return; + } + + const elapsedMs = Date.now() - this.lastActivityAt; + const remainingMs = Math.max(this.idleTimeoutMs - elapsedMs, 0); + this.timeoutId = setTimeout(() => this.handleTimeout(), remainingMs); + } + + private handleTimeout(): void { + if (!this.canArmSessionTimeout()) { + this.clearTimeout(); + return; + } + + const elapsedMs = Date.now() - this.lastActivityAt; + if (elapsedMs < this.idleTimeoutMs) { + this.scheduleTimeout(); + return; + } + + this.openContinueWorkingDialog(); + } + + private openContinueWorkingDialog(shouldNotifyTabs = true): void { + if (this.dialogRef) { + return; + } + + if (!this.canArmSessionTimeout()) { + return; + } + + this.clearTimeout(); + this.dialogRef = this.ngZone.run(() => + this.dialog.open(SessionTimeoutDialogComponent, { + data: { + dialogTimeoutMs: this.dialogTimeoutMs + }, + backdropClass: SESSION_TIMEOUT_BACKDROP_CLASS, + disableClose: true, + width: '420px' + }) + ); + this.dialogTimeoutId = setTimeout(() => { + this.dialogRef?.close(false); + }, this.dialogTimeoutMs); + if (shouldNotifyTabs) { + this.syncChannel.post('timeout'); + } + + this.dialogRef + .afterClosed() + .pipe(take(1)) + .subscribe((shouldContinueWorking) => { + this.clearDialogTimeout(); + this.dialogRef = undefined; + + if (shouldContinueWorking === true) { + this.continueSession(); + this.syncChannel.post('continue'); + return; + } + + if (shouldContinueWorking === false) { + this.logout(); + } + }); + } + + private continueSession(): void { + this.closeDialogWithoutAction(); + this.refreshSession(); + } + + private logout(shouldNotifyTabs = true): void { + if (this.isLoggingOut) { + return; + } + + this.isSessionActive = false; + this.isLoggingOut = true; + this.clearTimeout(); + this.clearDialogTimeout(); + if (shouldNotifyTabs) { + this.syncChannel.post('logout'); + } + this.ngZone.run(() => this.authService.logout().pipe(take(1)).subscribe()); + } + + private clearSessionState(): void { + this.isSessionActive = false; + this.dialogRef?.close(); + this.clearTimeout(); + this.clearDialogTimeout(); + this.sessionSubscription?.unsubscribe(); + this.sessionSubscription = undefined; + this.tracker.stop(); + this.syncChannel.close(); + } + + private closeDialogWithoutAction(): void { + const dialogRef = this.dialogRef; + if (!dialogRef) { + return; + } + + this.dialogRef = undefined; + this.clearDialogTimeout(); + dialogRef.close(); + } + + private canArmSessionTimeout(): boolean { + return this.isSessionActive && !this.isLoggingOut && this.authService.isLoggedIn(); + } + + private notifyActivity(): void { + const now = Date.now(); + if (now - this.lastActivitySyncAt < ACTIVITY_SYNC_THROTTLE_MS) { + return; + } + + this.lastActivitySyncAt = now; + this.syncChannel.post('activity'); + } + + private clearTimeout(): void { + if (this.timeoutId === undefined) { + return; + } + + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + + private clearDialogTimeout(): void { + if (this.dialogTimeoutId === undefined) { + return; + } + + clearTimeout(this.dialogTimeoutId); + this.dialogTimeoutId = undefined; + } +} diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json index 3cb79a1603..1d497c1064 100644 --- a/lib/core/src/lib/i18n/en.json +++ b/lib/core/src/lib/i18n/en.json @@ -667,5 +667,11 @@ "ACTION": "Do you want to proceed?", "YES_LABEL": "Yes", "NO_LABEL": "No" + }, + "SESSION_TIMEOUT": { + "TITLE": "Are you still working?", + "MESSAGE": "Your session will end in {{seconds}} seconds because there has been no activity.", + "LOG_OUT": "Log out", + "CONTINUE_WORKING": "Continue working" } } diff --git a/lib/process-services/src/lib/form/widgets/people/people.widget.spec.ts b/lib/process-services/src/lib/form/widgets/people/people.widget.spec.ts index cc5c7bf7ba..f4bb920809 100644 --- a/lib/process-services/src/lib/form/widgets/people/people.widget.spec.ts +++ b/lib/process-services/src/lib/form/widgets/people/people.widget.spec.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { FormFieldTypes, FormFieldModel, FormModel } from '@alfresco/adf-core'; import { Observable, of } from 'rxjs'; @@ -250,6 +250,22 @@ describe('PeopleWidgetComponent', () => { expect(widget.searchTerm.value).toBe(''); }); + it('should call the users API only once when the user types several characters quickly', fakeAsync(() => { + const getWorkflowUsersSpy = spyOn(peopleProcessService, 'getWorkflowUsers').and.returnValue(of([])); + + widget.searchTerm.setValue('T'); + tick(100); + widget.searchTerm.setValue('Te'); + tick(100); + widget.searchTerm.setValue('Tes'); + tick(100); + widget.searchTerm.setValue('Test'); + tick(300); + + expect(getWorkflowUsersSpy).toHaveBeenCalledTimes(1); + expect(getWorkflowUsersSpy).toHaveBeenCalledWith(undefined, 'Test', widget.groupId); + })); + it('should remove user from selectedUsers if user exists', () => { const users: LightUserRepresentation[] = [ { id: 1, firstName: 'John', lastName: 'Doe' }, @@ -351,21 +367,21 @@ describe('PeopleWidgetComponent', () => { expect(element.querySelector('#people-widget-content')).not.toBeNull(); }); - it('should show an error message if the user is invalid', async () => { + it('should show an error message if the user is invalid', fakeAsync(() => { const peopleHTMLElement = element.querySelector('input'); peopleHTMLElement.focus(); peopleHTMLElement.value = 'K'; peopleHTMLElement.dispatchEvent(new Event('keyup')); peopleHTMLElement.dispatchEvent(new Event('input')); + tick(300); fixture.detectChanges(); - await fixture.whenStable(); expect(element.querySelector('.adf-error-text')).not.toBeNull(); expect(element.querySelector('.adf-error-text').textContent).toContain('FORM.FIELD.VALIDATOR.INVALID_VALUE'); - }); + })); - it('should show the people if the typed result match', async () => { + it('should show the people if the typed result match', fakeAsync(() => { const peopleHTMLElement = element.querySelector('input'); peopleHTMLElement.focus(); peopleHTMLElement.value = 'T'; @@ -373,13 +389,14 @@ describe('PeopleWidgetComponent', () => { peopleHTMLElement.dispatchEvent(new Event('input')); fixture.detectChanges(); - await fixture.whenStable(); + tick(300); + fixture.detectChanges(); expect(fixture.debugElement.query(By.css('#adf-people-widget-user-0'))).not.toBeNull(); expect(fixture.debugElement.query(By.css('#adf-people-widget-user-1'))).not.toBeNull(); - }); + })); - it('should hide result list if input is empty', async () => { + it('should hide result list if input is empty', fakeAsync(() => { const peopleHTMLElement = element.querySelector('input'); peopleHTMLElement.focus(); peopleHTMLElement.value = ''; @@ -388,14 +405,14 @@ describe('PeopleWidgetComponent', () => { peopleHTMLElement.dispatchEvent(new Event('input')); fixture.detectChanges(); - await fixture.whenStable(); + tick(300); expect(fixture.debugElement.query(By.css('#adf-people-widget-user-0'))).toBeNull(); - }); + })); - it('should display two options if we tap one letter', async () => { + it('should display two options if we tap one letter', fakeAsync(() => { fixture.detectChanges(); - await fixture.whenStable(); + tick(300); const peopleHTMLElement = element.querySelector('input'); peopleHTMLElement.focus(); @@ -404,11 +421,12 @@ describe('PeopleWidgetComponent', () => { peopleHTMLElement.dispatchEvent(new Event('input')); fixture.detectChanges(); - await fixture.whenStable(); + tick(300); + fixture.detectChanges(); expect(fixture.debugElement.query(By.css('#adf-people-widget-user-0'))).not.toBeNull(); expect(fixture.debugElement.query(By.css('#adf-people-widget-user-1'))).not.toBeNull(); - }); + })); it('should emit peopleSelected if option is valid', async () => { const selectEmitSpy = spyOn(widget.peopleSelected, 'emit'); diff --git a/lib/process-services/src/lib/form/widgets/people/people.widget.ts b/lib/process-services/src/lib/form/widgets/people/people.widget.ts index 79ad583911..f45b4bab2a 100644 --- a/lib/process-services/src/lib/form/widgets/people/people.widget.ts +++ b/lib/process-services/src/lib/form/widgets/people/people.widget.ts @@ -23,7 +23,7 @@ import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; import { MatChipsModule } from '@angular/material/chips'; import { MatIconModule } from '@angular/material/icon'; import { Observable, of } from 'rxjs'; -import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; +import { catchError, debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { PeopleProcessService } from '../../../services/people-process.service'; import { LightUserRepresentation } from '@alfresco/js-api'; import { CommonModule } from '@angular/common'; @@ -77,6 +77,7 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit { searchTerms$ = this.searchTerm.valueChanges; users$: Observable = this.searchTerms$.pipe( + debounceTime(300), distinctUntilChanged(), switchMap((searchTerm) => { if (!searchTerm) { diff --git a/nx.json b/nx.json index 8edad9a277..1b41c180d8 100644 --- a/nx.json +++ b/nx.json @@ -47,7 +47,7 @@ }, "namedInputs": { "default": ["{projectRoot}/**/*", "sharedGlobals"], - "sharedGlobals": ["{workspaceRoot}/angular.json", "{workspaceRoot}/e2e/protractor.excludes.json", "{workspaceRoot}/e2e/protractor.conf.js"], + "sharedGlobals": ["{workspaceRoot}/package.json", "{workspaceRoot}/angular.json", "{workspaceRoot}/e2e/protractor.excludes.json", "{workspaceRoot}/e2e/protractor.conf.js"], "production": [ "default", "!{projectRoot}/.storybook/**/*", diff --git a/package.json b/package.json index 74f7b89e43..a1ffb6b611 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "engines": { "node": ">=24.14.0" }, - "packageManager": "pnpm@11.8.0", + "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", "module": "./index.js", "typings": "./index.d.ts" }