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"
}