diff --git a/.github/workflows/enforce-branch-flow.yml b/.github/workflows/enforce-branch-flow.yml index 1f81da24e..068f9c93a 100644 --- a/.github/workflows/enforce-branch-flow.yml +++ b/.github/workflows/enforce-branch-flow.yml @@ -1,4 +1,4 @@ -name: Enforce Branch Protection Flow (Development → Staging → Main) +name: Enforce Branch Protection Flow (Development -> Staging -> Main) on: pull_request: @@ -11,20 +11,20 @@ jobs: enforce-branch-flow: runs-on: ubuntu-latest steps: - - name: Fail if PR→staging doesn't come from development + - name: Fail if PR into Staging does not come from Development if: > - github.event.pull_request.base.ref == 'staging' && - github.event.pull_request.head.ref != 'development' + github.event.pull_request.base.ref == 'Staging' && + github.event.pull_request.head.ref != 'Development' run: | - echo "::error ::Pull requests into 'staging' must originate from branch 'development'." + echo "::error ::Pull requests into 'Staging' must originate from branch 'Development'." exit 1 - - name: Fail if PR→main doesn't come from staging + - name: Fail if PR into main does not come from Staging if: > github.event.pull_request.base.ref == 'main' && - github.event.pull_request.head.ref != 'staging' + github.event.pull_request.head.ref != 'Staging' run: | - echo "::error ::Pull requests into 'main' must originate from branch 'staging'." + echo "::error ::Pull requests into 'main' must originate from branch 'Staging'." exit 1 - name: Branch flow validated diff --git a/.github/workflows/staging-azd-ui-tests.yml b/.github/workflows/staging-azd-ui-tests.yml new file mode 100644 index 000000000..a02fd0c15 --- /dev/null +++ b/.github/workflows/staging-azd-ui-tests.yml @@ -0,0 +1,290 @@ +name: Staging AZD Deploy and UI Smoke Tests + +on: + push: + branches: + - Staging + workflow_dispatch: + inputs: + azd_command: + description: "azd command to run before UI tests" + required: true + default: "up" + type: choice + options: + - up + - deploy + pytest_target: + description: "pytest target for staging UI validation" + required: true + default: "ui_tests/test_staging_chat_smoke.py" + type: string + +permissions: + contents: read + id-token: write + +concurrency: + group: simplechat-staging-azd-ui-tests + cancel-in-progress: false + +jobs: + deploy-and-ui-smoke: + name: Deploy staging and run UI smoke tests + runs-on: ubuntu-latest + environment: + name: ${{ vars.STAGING_GITHUB_ENVIRONMENT || 'Staging' }} + timeout-minutes: 180 + env: + AZD_COMMAND: ${{ github.event.inputs.azd_command || 'up' }} + PYTEST_TARGET: ${{ github.event.inputs.pytest_target || 'ui_tests/test_staging_chat_smoke.py' }} + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID || secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID || secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID || secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_LOCATION: ${{ vars.AZURE_LOCATION || 'eastus' }} + AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME || 'staging' }} + DEPLOYMENT_APPNAME: ${{ vars.DEPLOYMENT_APPNAME || 'simplechat' }} + SIMPLECHAT_UI_BASE_URL: ${{ vars.SIMPLECHAT_UI_BASE_URL }} + SIMPLECHAT_UI_AUTH_RESOURCE: ${{ vars.SIMPLECHAT_UI_AUTH_RESOURCE }} + PLAYWRIGHT_SERVICE_URL: ${{ vars.PLAYWRIGHT_SERVICE_URL }} + AZD_ENV_FILE_B64: ${{ secrets.AZD_ENV_FILE_B64 }} + SIMPLECHAT_UI_STORAGE_STATE_B64: ${{ secrets.SIMPLECHAT_UI_STORAGE_STATE_B64 }} + SIMPLECHAT_UI_ADMIN_STORAGE_STATE_B64: ${{ secrets.SIMPLECHAT_UI_ADMIN_STORAGE_STATE_B64 }} + SIMPLECHAT_UI_ARTIFACT_DIR: ui_tests/artifacts + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Validate required environment values + shell: bash + run: | + set -euo pipefail + + missing=() + for name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID AZURE_LOCATION AZURE_ENV_NAME DEPLOYMENT_APPNAME PLAYWRIGHT_SERVICE_URL; do + if [[ -z "${!name:-}" ]]; then + missing+=("$name") + fi + done + + if [[ ${#missing[@]} -gt 0 ]]; then + printf 'Missing required GitHub Environment variables/secrets:\n' + printf ' - %s\n' "${missing[@]}" + exit 1 + fi + + if [[ -z "${SIMPLECHAT_UI_STORAGE_STATE_B64:-}" && -z "${SIMPLECHAT_UI_ADMIN_STORAGE_STATE_B64:-}" && -z "${SIMPLECHAT_UI_AUTH_RESOURCE:-}" ]]; then + echo "Missing UI authentication. Set SIMPLECHAT_UI_AUTH_RESOURCE for CI bearer auth or SIMPLECHAT_UI_STORAGE_STATE_B64/SIMPLECHAT_UI_ADMIN_STORAGE_STATE_B64 for browser session auth." + exit 1 + fi + + case "$AZD_COMMAND" in + up|deploy) ;; + *) echo "Unsupported azd command: $AZD_COMMAND"; exit 1 ;; + esac + + - name: Azure login + uses: azure/login@v2 + with: + client-id: ${{ env.AZURE_CLIENT_ID }} + tenant-id: ${{ env.AZURE_TENANT_ID }} + subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} + + - name: Install Azure Developer CLI + uses: Azure/setup-azd@v2 + + - name: Authenticate Azure Developer CLI + shell: bash + run: | + set -euo pipefail + azd auth login \ + --client-id "$AZURE_CLIENT_ID" \ + --federated-credential-provider github \ + --tenant-id "$AZURE_TENANT_ID" + + - name: Restore azd environment + shell: bash + working-directory: deployers + run: | + set -euo pipefail + + mkdir -p ".azure/$AZURE_ENV_NAME" + + if [[ -n "${AZD_ENV_FILE_B64:-}" ]]; then + printf '%s' "$AZD_ENV_FILE_B64" | base64 -d > ".azure/$AZURE_ENV_NAME/.env" + fi + + cat > .azure/config.json <> "$GITHUB_ENV" + echo "Resolved staging URL: $base_url" + + - name: Acquire SimpleChat UI access token + if: ${{ env.SIMPLECHAT_UI_AUTH_RESOURCE != '' }} + shell: bash + run: | + set -euo pipefail + access_token="$(az account get-access-token --resource "$SIMPLECHAT_UI_AUTH_RESOURCE" --query accessToken -o tsv)" + if [[ -z "$access_token" ]]; then + echo "Failed to acquire SimpleChat UI access token." + exit 1 + fi + echo "::add-mask::$access_token" + echo "SIMPLECHAT_UI_ACCESS_TOKEN=$access_token" >> "$GITHUB_ENV" + + - name: Wait for staging health check + shell: bash + run: | + set -euo pipefail + + no_auth_health_url="$SIMPLECHAT_UI_BASE_URL/external/healthcheckz" + auth_health_url="$SIMPLECHAT_UI_BASE_URL/external/healthcheck" + chat_url="$SIMPLECHAT_UI_BASE_URL/chats" + + echo "Waiting up to 15 minutes for staging to finish App Service warm-up." + for attempt in {1..90}; do + no_auth_status="$(curl -k -sS --connect-timeout 10 --max-time 20 -o /tmp/simplechat-healthz.txt -w '%{http_code}' "$no_auth_health_url" || true)" + auth_status="$(curl -k -sS --connect-timeout 10 --max-time 20 -o /tmp/simplechat-health.txt -w '%{http_code}' "$auth_health_url" || true)" + chat_status="$(curl -k -sS --connect-timeout 10 --max-time 20 -o /tmp/simplechat-chat.txt -w '%{http_code}' "$chat_url" || true)" + + if [[ "$no_auth_status" == "200" || "$auth_status" == "200" || "$chat_status" == "302" || "$chat_status" == "401" || "$chat_status" == "403" ]]; then + echo "Staging health check passed." + exit 0 + fi + + echo "Health check attempt $attempt failed. healthcheckz=$no_auth_status healthcheck=$auth_status chats=$chat_status" + if (( attempt % 6 == 0 )); then + echo "Recent health response sample:" + head -c 1000 /tmp/simplechat-healthz.txt || true + echo "" + fi + sleep 10 + done + + echo "Staging health check did not pass within the expected 15-minute warm-up window." + exit 1 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ui_tests/playwright-workspaces/package-lock.json + + - name: Install UI test dependencies + shell: bash + run: | + set -euo pipefail + python -m pip install --upgrade pip + python -m pip install -r ui_tests/requirements.txt + python -m playwright install --with-deps chromium + + - name: Install Playwright Workspaces dependencies + shell: bash + working-directory: ui_tests/playwright-workspaces + run: | + set -euo pipefail + npm ci + + - name: Restore Playwright storage state + shell: bash + run: | + set -euo pipefail + + mkdir -p ui_tests/artifacts/auth + + if [[ -n "${SIMPLECHAT_UI_STORAGE_STATE_B64:-}" ]]; then + printf '%s' "$SIMPLECHAT_UI_STORAGE_STATE_B64" | base64 -d > ui_tests/artifacts/auth/storage_state.json + echo "SIMPLECHAT_UI_STORAGE_STATE=$PWD/ui_tests/artifacts/auth/storage_state.json" >> "$GITHUB_ENV" + fi + + if [[ -n "${SIMPLECHAT_UI_ADMIN_STORAGE_STATE_B64:-}" ]]; then + printf '%s' "$SIMPLECHAT_UI_ADMIN_STORAGE_STATE_B64" | base64 -d > ui_tests/artifacts/auth/admin_storage_state.json + echo "SIMPLECHAT_UI_ADMIN_STORAGE_STATE=$PWD/ui_tests/artifacts/auth/admin_storage_state.json" >> "$GITHUB_ENV" + if [[ -z "${SIMPLECHAT_UI_STORAGE_STATE_B64:-}" ]]; then + echo "SIMPLECHAT_UI_STORAGE_STATE=$PWD/ui_tests/artifacts/auth/admin_storage_state.json" >> "$GITHUB_ENV" + fi + fi + + - name: Run staging UI smoke test in Playwright Workspaces + shell: bash + working-directory: ui_tests/playwright-workspaces + run: | + set -euo pipefail + npm run test:staging:azure + + - name: Run staging UI smoke tests + shell: bash + run: | + set -euo pipefail + python -m pytest "$PYTEST_TARGET" -m ui -ra + + - name: Upload UI test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: staging-ui-test-artifacts + path: | + ui_tests/artifacts + ui_tests/playwright-workspaces/playwright-report + ui_tests/playwright-workspaces/test-results + if-no-files-found: ignore diff --git a/.github/workflows/swagger-route-check.yml b/.github/workflows/swagger-route-check.yml index 33ef74cf1..ca95e6d58 100644 --- a/.github/workflows/swagger-route-check.yml +++ b/.github/workflows/swagger-route-check.yml @@ -5,7 +5,7 @@ on: branches: - main - Development - - staging + - Staging paths: - 'application/single_app/**/*.py' - 'scripts/check_swagger_routes.py' diff --git a/README.md b/README.md index 37aa452ae..898946543 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,12 @@ Install these tools before starting the deployment flow: Download: https://learn.microsoft.com/cli/azure/install-azure-cli 2. Azure Developer CLI (`azd`) Download: https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd -3. PowerShell 7 +3. Python 3.12 + Download: https://www.python.org/downloads/ + The AZD preprovision and postprovision hooks in [deployers/azure.yaml](./deployers/azure.yaml) run Python scripts for prerequisite validation, dependency installation, and post-provision configuration. Make sure `python` is available on Windows and `python3` is available on Linux/macOS before running `azd up`. +4. PowerShell 7 Download: https://learn.microsoft.com/powershell/scripting/install/installing-powershell -4. Visual Studio Code +5. Visual Studio Code Download: https://code.visualstudio.com/download Shell guidance: @@ -83,6 +86,14 @@ The following script will create an Entra Enterprise Application, with an App Re .\Initialize-EntraApplication.ps1 -AppName $appName -Environment $environment -AppRolesJsonPath "./azurecli/appRegistrationRoles.json" ``` +By default, the script saves the app registration values that `azd up` needs into the resolved AZD environment: + +- `ENTERPRISE_APP_CLIENT_ID` +- `ENTERPRISE_APP_SERVICE_PRINCIPAL_ID` +- `ENTERPRISE_APP_CLIENT_SECRET` + +Use `-AzdEnvironmentName ` to target a specific AZD environment, or `-SkipAzdEnvironmentUpdate` when running the registration as a standalone/manual workflow. + Linux and macOS example: ```bash @@ -91,7 +102,7 @@ pwsh ./Initialize-EntraApplication.ps1 -AppName simplechat -Environment dev -App > [!NOTE] > -> Be sure to save this information as it will not be available after the window is closed.* +> If the script cannot update the AZD environment, save the displayed values manually and set them later with `azd env set`. ```======================================== App Registration Created Successfully! diff --git a/application/external_apps/bulkloader/requirements.txt b/application/external_apps/bulkloader/requirements.txt index f8a4a0b0f..06a389592 100644 --- a/application/external_apps/bulkloader/requirements.txt +++ b/application/external_apps/bulkloader/requirements.txt @@ -1,3 +1,3 @@ requests==2.33.0 msal==1.31.0 -python-dotenv==0.21.0 \ No newline at end of file +python-dotenv==1.2.2 \ No newline at end of file diff --git a/application/external_apps/databaseseeder/requirements.txt b/application/external_apps/databaseseeder/requirements.txt index b0d23371b..db528381b 100644 --- a/application/external_apps/databaseseeder/requirements.txt +++ b/application/external_apps/databaseseeder/requirements.txt @@ -1,3 +1,3 @@ requests==2.33.0 msal==1.31.0 -python-dotenv==0.21.0 +python-dotenv==1.2.2 diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index 6f04b41aa..79e1c0fa7 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -18,6 +18,29 @@ RUN update-ca-trust enable \ ENV PYTHONUNBUFFERED=1 +ENV ACCEPT_EULA=Y + +RUN set -eux; \ + printf '%s\n' \ + '[packages-microsoft-com-prod]' \ + 'name=packages-microsoft-com-prod' \ + 'baseurl=https://packages.microsoft.com/azurelinux/3.0/prod/ms-non-oss/x86_64' \ + 'enabled=1' \ + 'gpgcheck=1' \ + 'gpgkey=https://packages.microsoft.com/keys/microsoft.asc' \ + > /etc/yum.repos.d/packages-microsoft-com-prod.repo; \ + tdnf install -y msodbcsql18 unixODBC; \ + driver_lib="$(find /opt/microsoft/msodbcsql18/lib64 -name 'libmsodbcsql-*.so*' | sort | tail -n 1)"; \ + test -n "${driver_lib}"; \ + printf '[ODBC Driver 18 for SQL Server]\nDescription=Microsoft ODBC Driver 18 for SQL Server\nDriver=%s\nUsageCount=1\n' "${driver_lib}" > /etc/odbcinst.ini; \ + mkdir -p /odbc-runtime/usr/lib64; \ + for lib in /usr/lib64/libodbc* /usr/lib/libodbc* /usr/lib64/libltdl* /usr/lib/libltdl*; do \ + if [ -e "${lib}" ]; then cp -a "${lib}" /odbc-runtime/usr/lib64/; fi; \ + done; \ + find /odbc-runtime/usr/lib64 -maxdepth 1 -name 'libodbc*' | grep -q .; \ + find /odbc-runtime/usr/lib64 -maxdepth 1 -name 'libltdl*' | grep -q .; \ + tdnf clean all + RUN set -eux; \ echo "nonroot:x:${GID}:" >> /etc/group; \ echo "nonroot:x:${UID}:${GID}:nonroot:/home/nonroot:/bin/bash" >> /etc/passwd; \ @@ -47,6 +70,9 @@ COPY --from=builder /home/nonroot /home/nonroot COPY --from=builder /etc/passwd /etc/passwd COPY --from=builder /etc/group /etc/group COPY --from=builder /usr/lib/python3.12 /usr/lib/python3.12 +COPY --from=builder /opt/microsoft /opt/microsoft +COPY --from=builder /etc/odbcinst.ini /etc/odbcinst.ini +COPY --from=builder /odbc-runtime/usr/lib64/ /usr/lib64/ USER ${UID}:${GID} @@ -59,6 +85,7 @@ ENV HOME=/home/nonroot \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ PYTHONUNBUFFERED=1 \ + LD_LIBRARY_PATH=/usr/lib64:/opt/microsoft/msodbcsql18/lib64 \ SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt \ SSL_CERT_DIR=/etc/ssl/certs \ REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-bundle.crt diff --git a/application/single_app/config.py b/application/single_app/config.py index 30cb1b95f..8e0295a3a 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.007" +VERSION = "0.241.008" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -193,6 +193,7 @@ def get_allowed_extensions(enable_video=False, enable_audio=False): '/logout/local', '/getAToken', '/getATokenApi', + '/ci-auth/session', '/robots933456.txt', '/favicon.ico' } @@ -210,6 +211,13 @@ def get_allowed_extensions(enable_video=False, enable_audio=False): TENANT_ID = os.getenv("TENANT_ID") SCOPE = ["User.Read", "User.ReadBasic.All", "People.Read.All", "Group.Read.All"] # Adjust scope according to your needs MICROSOFT_PROVIDER_AUTHENTICATION_SECRET = os.getenv("MICROSOFT_PROVIDER_AUTHENTICATION_SECRET") +ENABLE_CI_BEARER_SESSION_AUTH = os.getenv("ENABLE_CI_BEARER_SESSION_AUTH", "false").lower() == "true" +CI_BEARER_SESSION_ALLOWED_APP_IDS = [ + app_id.strip().lower() + for app_id in os.getenv("CI_BEARER_SESSION_ALLOWED_APP_IDS", "").split(",") + if app_id.strip() +] +CI_BEARER_SESSION_REQUIRED_ROLE = os.getenv("CI_BEARER_SESSION_REQUIRED_ROLE", "Admin") LOGIN_REDIRECT_URL = os.getenv("LOGIN_REDIRECT_URL") HOME_REDIRECT_URL = os.getenv("HOME_REDIRECT_URL") # Front Door URL for home page AZURE_ENVIRONMENT = os.getenv("AZURE_ENVIRONMENT", "public") # public, usgovernment, custom diff --git a/application/single_app/functions_authentication.py b/application/single_app/functions_authentication.py index a0ecde0ab..ba824b880 100644 --- a/application/single_app/functions_authentication.py +++ b/application/single_app/functions_authentication.py @@ -1,6 +1,10 @@ # functions_authentication.py +import base64 +import json + from config import * +from functions_appinsights import log_event from functions_settings import * from functions_debug import debug_print @@ -525,6 +529,107 @@ def validate_bearer_token(token): except Exception as e: return False, f"An unexpected error occurred during token validation: {e}" +def _extract_easy_auth_claims(): + """Extract claims injected by App Service Authentication after token validation.""" + encoded_principal = request.headers.get('X-MS-CLIENT-PRINCIPAL') + if not encoded_principal: + return None + + try: + principal = json.loads(base64.b64decode(encoded_principal).decode('utf-8')) + except (ValueError, TypeError, json.JSONDecodeError) as ex: + log_event( + "[CIAuth] Failed to parse App Service Authentication principal header.", + extra={"error": str(ex)}, + debug_only=True, + category="CIAuth", + ) + return None + + data = {} + roles = [] + for claim in principal.get('claims', []): + claim_type = claim.get('typ') or claim.get('type') + claim_value = claim.get('val') or claim.get('value') + if not claim_type or claim_value is None: + continue + + normalized_claim_type = claim_type.rsplit('/', 1)[-1] + if normalized_claim_type in {'role', 'roles'}: + roles.append(claim_value) + elif normalized_claim_type in {'appid', 'azp', 'oid', 'sub', 'tid', 'name', 'aud', 'iss'}: + data[normalized_claim_type] = claim_value + elif claim_type.endswith('/objectidentifier'): + data['oid'] = claim_value + elif claim_type.endswith('/tenantid'): + data['tid'] = claim_value + + if roles: + data['roles'] = roles + data.setdefault('name', principal.get('userDetails')) + data.setdefault('oid', request.headers.get('X-MS-CLIENT-PRINCIPAL-ID')) + return data + +def create_ci_bearer_session(): + """Create a Flask session from a validated CI app-only bearer token.""" + if not ENABLE_CI_BEARER_SESSION_AUTH: + log_event( + "[CIAuth] CI bearer session auth requested while disabled.", + debug_only=True, + category="CIAuth", + ) + return jsonify({"error": "disabled", "message": "CI bearer session authentication is disabled."}), 404 + + auth_header = request.headers.get('Authorization') + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ", 1)[1] + is_valid, data = validate_bearer_token(token) + if not is_valid: + log_event( + "[CIAuth] CI bearer session token validation failed.", + extra={"reason": data}, + debug_only=True, + category="CIAuth", + ) + return jsonify({"error": "unauthorized", "message": "Bearer token is invalid."}), 401 + else: + data = _extract_easy_auth_claims() + if not data: + return jsonify({"error": "unauthorized", "message": "Bearer token is required."}), 401 + + roles = data.get("roles") if isinstance(data, dict) else None + required_role = CI_BEARER_SESSION_REQUIRED_ROLE or "Admin" + if not roles or required_role not in roles: + return jsonify({"error": "forbidden", "message": f"{required_role} app role is required."}), 403 + + caller_app_id = (data.get("appid") or data.get("azp") or "").lower() + if CI_BEARER_SESSION_ALLOWED_APP_IDS and caller_app_id not in CI_BEARER_SESSION_ALLOWED_APP_IDS: + log_event( + "[CIAuth] CI bearer session caller app id was not allowed.", + extra={"caller_app_id": caller_app_id}, + debug_only=True, + category="CIAuth", + ) + return jsonify({"error": "forbidden", "message": "Caller application is not allowed."}), 403 + + session_user = dict(data) + session_user.setdefault("name", data.get("name") or data.get("appidacr") or "SimpleChat CI") + session_user.setdefault("preferred_username", f"ci:{caller_app_id or 'unknown'}") + session_user.setdefault("oid", data.get("oid") or data.get("sub") or caller_app_id) + session_user["roles"] = roles + session_user["ci_bearer_session"] = True + + session["user"] = session_user + session["last_activity_epoch"] = int(time.time()) + + log_event( + "[CIAuth] CI bearer session established.", + extra={"caller_app_id": caller_app_id, "required_role": required_role}, + debug_only=True, + category="CIAuth", + ) + return jsonify({"authenticated": True, "roles": roles}), 200 + def accesstoken_required(f): @wraps(f) def decorated_function(*args, **kwargs): diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index ec6817567..af804dafa 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -21,7 +21,7 @@ SciPy==1.15.1 joblib==1.4.2 threadpoolctl==3.5.0 azure-search-documents==11.5.3 -python-dotenv==0.21.0 +python-dotenv==1.2.2 azure-ai-formrecognizer==3.3.3 azure-ai-projects==1.0.0 azure-ai-agents==1.2.0b6 @@ -33,15 +33,15 @@ azure-ai-contentsafety==1.0.0 azure-storage-blob==12.24.1 azure-storage-queue==12.12.0 azure-keyvault-secrets==4.10.0 -pypdf==6.9.2 +pypdf==6.10.0 python-docx==1.1.2 flask-executor==1.0.0 PyMuPDF==1.25.3 -langchain-text-splitters==0.3.9 +langchain-text-splitters==1.1.2 beautifulsoup4==4.13.3 openpyxl==3.1.5 xlrd==2.0.1 -pillow==12.1.1 +pillow==12.2.0 ffmpeg-binaries-compat==1.0.1 ffmpeg-python==0.2.0 semantic-kernel==1.39.4 diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index b60f81894..11fef6ddf 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -6,6 +6,11 @@ from flask import Blueprint, jsonify, request, current_app from semantic_kernel_plugins.plugin_loader import get_all_plugin_metadata from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker, PluginErrorRecovery +from semantic_kernel_plugins.sql_odbc_utils import ( + DEFAULT_SQL_SERVER_ODBC_DRIVER, + build_sql_server_odbc_connection_string, + connect_with_sql_server_odbc_fallback, +) from functions_settings import get_settings, is_tabular_processing_enabled, update_settings from functions_authentication import * from functions_appinsights import log_event @@ -1138,21 +1143,30 @@ def test_sql_connection(): if database_type == 'sqlserver': import pyodbc if connection_method == 'connection_string' and connection_string: - conn = pyodbc.connect(connection_string, timeout=timeout) + conn = connect_with_sql_server_odbc_fallback( + pyodbc.connect, + connection_string, + connect_kwargs={"timeout": timeout}, + log_source="PluginSqlConnectionTest", + ) else: if not server or not database: return jsonify({'success': False, 'error': 'Server and database are required for individual parameters connection.'}), 400 - drv = driver or 'ODBC Driver 17 for SQL Server' - conn_str = f"DRIVER={{{drv}}};SERVER={server};DATABASE={database}" - if port: - conn_str += f",{port}" - if auth_type == 'username_password' and username and password: - conn_str += f";UID={username};PWD={password}" - elif auth_type == 'managed_identity': - conn_str += ";Authentication=ActiveDirectoryMsi" - elif auth_type == 'integrated': - conn_str += ";Trusted_Connection=yes" - conn = pyodbc.connect(conn_str, timeout=timeout) + conn_str = build_sql_server_odbc_connection_string( + server=server, + database=database, + driver=driver or DEFAULT_SQL_SERVER_ODBC_DRIVER, + port=port, + username=username if auth_type == 'username_password' else None, + password=password if auth_type == 'username_password' else None, + auth_type=auth_type, + ) + conn = connect_with_sql_server_odbc_fallback( + pyodbc.connect, + conn_str, + connect_kwargs={"timeout": timeout}, + log_source="PluginSqlConnectionTest", + ) cursor = conn.cursor() cursor.execute("SELECT 1") cursor.close() diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index eac97c37e..07a9a030f 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -160,6 +160,7 @@ def user_settings(): 'publicDirectorySavedLists', 'publicDirectorySettings', 'activePublicWorkspaceOid', # Chat UI settings 'navbar_layout', 'chatLayout', 'showChatTitle', 'chatSplitSizes', + 'sidebarToggleStyle', # Microphone permission settings 'microphonePermissionState', # Text-to-speech settings @@ -182,6 +183,13 @@ def user_settings(): settings_to_update = dict(settings_to_update) + + if "sidebarToggleStyle" in settings_to_update: + sidebar_toggle_style = str(settings_to_update.get("sidebarToggleStyle") or "large").strip().lower() + if sidebar_toggle_style not in {"large", "compact"}: + return jsonify({"error": "Invalid sidebar toggle style"}), 400 + settings_to_update["sidebarToggleStyle"] = sidebar_toggle_style + active_group_updated = False active_public_workspace_updated = False diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py index 95de17e3d..12c736374 100644 --- a/application/single_app/route_frontend_authentication.py +++ b/application/single_app/route_frontend_authentication.py @@ -3,7 +3,7 @@ from unittest import result from config import * from functions_activity_logging import log_user_login, record_user_login_session_activity -from functions_authentication import _build_msal_app, _load_cache, _save_cache, clear_requested_oauth_scopes, get_requested_oauth_scopes +from functions_authentication import _build_msal_app, _load_cache, _save_cache, clear_requested_oauth_scopes, create_ci_bearer_session, get_requested_oauth_scopes from functions_debug import debug_print from swagger_wrapper import swagger_route, get_auth_security @@ -71,6 +71,11 @@ def login(): #auth_url= auth_url.replace('https://', 'http://') # Ensure HTTPS for security return redirect(auth_url) + @app.route('/ci-auth/session', methods=['POST']) + @swagger_route(security=get_auth_security()) + def ci_auth_session(): + return create_ci_bearer_session() + @app.route('/getAToken') # This is your redirect URI path @swagger_route(security=get_auth_security()) def authorized(): diff --git a/application/single_app/semantic_kernel_plugins/SQL_Plugins_Configuration_Guide.md b/application/single_app/semantic_kernel_plugins/SQL_Plugins_Configuration_Guide.md index 08558ba91..2f782e7ae 100644 --- a/application/single_app/semantic_kernel_plugins/SQL_Plugins_Configuration_Guide.md +++ b/application/single_app/semantic_kernel_plugins/SQL_Plugins_Configuration_Guide.md @@ -77,7 +77,7 @@ Choose authentication method: { "name": "azure_sql_schema", "database_type": "azure_sql", - "connection_string": "DRIVER={ODBC Driver 17 for SQL Server};SERVER=myserver.database.windows.net;DATABASE=mydatabase;Authentication=ActiveDirectoryMsi", + "connection_string": "DRIVER={ODBC Driver 18 for SQL Server};SERVER=myserver.database.windows.net;DATABASE=mydatabase;Authentication=ActiveDirectoryMsi", "metadata": { "description": "Extract schema from Azure SQL database using Managed Identity" } @@ -89,7 +89,7 @@ Choose authentication method: { "name": "azure_sql_query", "database_type": "azure_sql", - "connection_string": "DRIVER={ODBC Driver 17 for SQL Server};SERVER=myserver.database.windows.net;DATABASE=mydatabase;Authentication=ActiveDirectoryMsi", + "connection_string": "DRIVER={ODBC Driver 18 for SQL Server};SERVER=myserver.database.windows.net;DATABASE=mydatabase;Authentication=ActiveDirectoryMsi", "read_only": true, "max_rows": 500, "timeout": 30, diff --git a/application/single_app/semantic_kernel_plugins/sql_odbc_utils.py b/application/single_app/semantic_kernel_plugins/sql_odbc_utils.py new file mode 100644 index 000000000..44688dbc3 --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/sql_odbc_utils.py @@ -0,0 +1,92 @@ +# sql_odbc_utils.py +""" +Shared SQL Server ODBC driver utilities. +""" + +from typing import Any, Callable, Dict, Optional + +from functions_appinsights import log_event + + +DEFAULT_SQL_SERVER_ODBC_DRIVER = "ODBC Driver 18 for SQL Server" +LEGACY_SQL_SERVER_ODBC_DRIVER = "ODBC Driver 17 for SQL Server" + +_MISSING_DRIVER_ERROR_MARKERS = ( + "can't open lib", + "data source name not found", + "specified driver could not be loaded", + "file not found", + "im002", +) + + +def build_sql_server_odbc_connection_string( + server: str, + database: str, + driver: Optional[str] = None, + port: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + auth_type: Optional[str] = None, +) -> str: + """Build a SQL Server ODBC connection string with the current default driver.""" + selected_driver = (driver or DEFAULT_SQL_SERVER_ODBC_DRIVER).strip() + conn_str = f"DRIVER={{{selected_driver}}};SERVER={server}" + if port: + conn_str += f",{port}" + conn_str += f";DATABASE={database}" + + if username and password: + conn_str += f";UID={username};PWD={password}" + elif auth_type == 'managed_identity': + conn_str += ";Authentication=ActiveDirectoryMsi" + elif auth_type == 'integrated' or not auth_type: + conn_str += ";Trusted_Connection=yes" + + return conn_str + + +def replace_legacy_sql_server_odbc_driver(connection_string: str) -> str: + """Return a Driver 18 connection string when the saved value uses Driver 17.""" + if not isinstance(connection_string, str): + return connection_string + return connection_string.replace( + LEGACY_SQL_SERVER_ODBC_DRIVER, + DEFAULT_SQL_SERVER_ODBC_DRIVER, + ) + + +def should_retry_sql_server_odbc_driver_18(connection_string: str, error: Exception) -> bool: + """Determine whether a failed Driver 17 connection should be retried with Driver 18.""" + if not isinstance(connection_string, str) or LEGACY_SQL_SERVER_ODBC_DRIVER not in connection_string: + return False + + error_text = str(error).lower() + return any(marker in error_text for marker in _MISSING_DRIVER_ERROR_MARKERS) + + +def connect_with_sql_server_odbc_fallback( + connect_callable: Callable[..., Any], + connection_string: str, + connect_kwargs: Optional[Dict[str, Any]] = None, + log_source: str = "SQLODBC", +) -> Any: + """Connect to SQL Server and retry legacy Driver 17 strings with Driver 18 if needed.""" + kwargs = connect_kwargs or {} + try: + return connect_callable(connection_string, **kwargs) + except Exception as ex: + if not should_retry_sql_server_odbc_driver_18(connection_string, ex): + raise + + fallback_connection_string = replace_legacy_sql_server_odbc_driver(connection_string) + log_event( + f"[{log_source}] Retrying SQL Server ODBC connection with Driver 18 after Driver 17 was unavailable.", + extra={ + "legacy_driver": LEGACY_SQL_SERVER_ODBC_DRIVER, + "fallback_driver": DEFAULT_SQL_SERVER_ODBC_DRIVER, + "error_type": type(ex).__name__, + }, + debug_only=True, + ) + return connect_callable(fallback_connection_string, **kwargs) \ No newline at end of file diff --git a/application/single_app/semantic_kernel_plugins/sql_plugin_factory.py b/application/single_app/semantic_kernel_plugins/sql_plugin_factory.py index 5a7929d26..9ee6e1ca4 100644 --- a/application/single_app/semantic_kernel_plugins/sql_plugin_factory.py +++ b/application/single_app/semantic_kernel_plugins/sql_plugin_factory.py @@ -8,6 +8,7 @@ import json from .sql_schema_plugin import SQLSchemaPlugin from .sql_query_plugin import SQLQueryPlugin +from .sql_odbc_utils import DEFAULT_SQL_SERVER_ODBC_DRIVER class SQLPluginFactory: """Factory class for creating SQL plugins with common configurations""" @@ -40,7 +41,7 @@ def create_sql_server_plugins( base_config.update({ "username": username, "password": password, - "driver": "ODBC Driver 17 for SQL Server" + "driver": DEFAULT_SQL_SERVER_ODBC_DRIVER }) # Schema plugin config @@ -227,9 +228,9 @@ def create_azure_sql_plugins( tuple: (schema_plugin, query_plugin) """ if use_managed_identity: - connection_string = f"DRIVER={{ODBC Driver 17 for SQL Server}};SERVER={server};DATABASE={database};Authentication=ActiveDirectoryMsi" + connection_string = f"DRIVER={{{DEFAULT_SQL_SERVER_ODBC_DRIVER}}};SERVER={server};DATABASE={database};Authentication=ActiveDirectoryMsi" else: - connection_string = f"DRIVER={{ODBC Driver 17 for SQL Server}};SERVER={server};DATABASE={database};UID={username};PWD={password}" + connection_string = f"DRIVER={{{DEFAULT_SQL_SERVER_ODBC_DRIVER}}};SERVER={server};DATABASE={database};UID={username};PWD={password}" base_config = { "database_type": "sqlserver", diff --git a/application/single_app/semantic_kernel_plugins/sql_query_plugin.py b/application/single_app/semantic_kernel_plugins/sql_query_plugin.py index 6eba26f82..b30ee642c 100644 --- a/application/single_app/semantic_kernel_plugins/sql_query_plugin.py +++ b/application/single_app/semantic_kernel_plugins/sql_query_plugin.py @@ -13,6 +13,11 @@ from semantic_kernel.functions import kernel_function from functions_appinsights import log_event from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger +from semantic_kernel_plugins.sql_odbc_utils import ( + DEFAULT_SQL_SERVER_ODBC_DRIVER, + build_sql_server_odbc_connection_string, + connect_with_sql_server_odbc_fallback, +) # Helper class to wrap results with metadata class ResultWithMetadata: @@ -81,7 +86,7 @@ def _setup_database_config(self): self.supported_databases = { 'sqlserver': { 'module': 'pyodbc', - 'default_driver': 'ODBC Driver 17 for SQL Server', + 'default_driver': DEFAULT_SQL_SERVER_ODBC_DRIVER, 'default_port': 1433 }, 'postgresql': { @@ -116,15 +121,26 @@ def _create_connection(self): if self.database_type == 'sqlserver': import pyodbc if self.connection_string: - return pyodbc.connect(self.connection_string, timeout=self.timeout) + return connect_with_sql_server_odbc_fallback( + pyodbc.connect, + self.connection_string, + connect_kwargs={"timeout": self.timeout}, + log_source="SQLQueryPlugin", + ) else: - driver = self.driver or self.supported_databases['sqlserver']['default_driver'] - conn_str = f"DRIVER={{{driver}}};SERVER={self.server};DATABASE={self.database}" - if self.username and self.password: - conn_str += f";UID={self.username};PWD={self.password}" - else: - conn_str += ";Trusted_Connection=yes" - return pyodbc.connect(conn_str, timeout=self.timeout) + conn_str = build_sql_server_odbc_connection_string( + server=self.server, + database=self.database, + driver=self.driver or self.supported_databases['sqlserver']['default_driver'], + username=self.username, + password=self.password, + ) + return connect_with_sql_server_odbc_fallback( + pyodbc.connect, + conn_str, + connect_kwargs={"timeout": self.timeout}, + log_source="SQLQueryPlugin", + ) elif self.database_type == 'postgresql': import psycopg2 diff --git a/application/single_app/semantic_kernel_plugins/sql_schema_plugin.py b/application/single_app/semantic_kernel_plugins/sql_schema_plugin.py index 0b2f2898a..b40748966 100644 --- a/application/single_app/semantic_kernel_plugins/sql_schema_plugin.py +++ b/application/single_app/semantic_kernel_plugins/sql_schema_plugin.py @@ -12,6 +12,11 @@ from functions_appinsights import log_event from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger from functions_debug import debug_print +from semantic_kernel_plugins.sql_odbc_utils import ( + DEFAULT_SQL_SERVER_ODBC_DRIVER, + build_sql_server_odbc_connection_string, + connect_with_sql_server_odbc_fallback, +) # Helper class to wrap results with metadata class ResultWithMetadata: @@ -74,7 +79,7 @@ def _setup_database_config(self): self.supported_databases = { 'sqlserver': { 'module': 'pyodbc', - 'default_driver': 'ODBC Driver 17 for SQL Server', + 'default_driver': DEFAULT_SQL_SERVER_ODBC_DRIVER, 'default_port': 1433 }, 'postgresql': { @@ -109,15 +114,24 @@ def _create_connection(self): if self.database_type == 'sqlserver': import pyodbc if self.connection_string: - return pyodbc.connect(self.connection_string) + return connect_with_sql_server_odbc_fallback( + pyodbc.connect, + self.connection_string, + log_source="SQLSchemaPlugin", + ) else: - driver = self.driver or self.supported_databases['sqlserver']['default_driver'] - conn_str = f"DRIVER={{{driver}}};SERVER={self.server};DATABASE={self.database}" - if self.username and self.password: - conn_str += f";UID={self.username};PWD={self.password}" - else: - conn_str += ";Trusted_Connection=yes" - return pyodbc.connect(conn_str) + conn_str = build_sql_server_odbc_connection_string( + server=self.server, + database=self.database, + driver=self.driver or self.supported_databases['sqlserver']['default_driver'], + username=self.username, + password=self.password, + ) + return connect_with_sql_server_odbc_fallback( + pyodbc.connect, + conn_str, + log_source="SQLSchemaPlugin", + ) elif self.database_type == 'postgresql': import psycopg2 diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 6474185b0..d39936091 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -64,18 +64,6 @@ min-width: 0; } -#chat-sidebar-inline-toggle { - border-radius: 999px; - flex: 0 0 auto; - height: 2.25rem; - padding: 0; - width: 2.25rem; -} - -body.sidebar-collapsed #chat-sidebar-inline-toggle { - color: var(--bs-primary); -} - .chat-searchable-select .dropdown-menu.show { display: block !important; opacity: 1 !important; diff --git a/application/single_app/static/css/sidebar.css b/application/single_app/static/css/sidebar.css index ab7e652ce..652910633 100644 --- a/application/single_app/static/css/sidebar.css +++ b/application/single_app/static/css/sidebar.css @@ -170,9 +170,10 @@ body.has-classification-banner #floating-expand-btn { justify-content: center; gap: 0.375rem; background-color: var(--bs-body-bg, #fff) !important; - border: 1px solid var(--bs-border-color, #dee2e6) !important; + border: 1px solid rgba(var(--bs-secondary-rgb), 0.22) !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important; color: var(--bs-body-color, #212529) !important; + max-width: min(18rem, calc(100vw - 1rem)); } #floating-expand-btn.sidebar-floating-expand-visible { @@ -181,7 +182,7 @@ body.has-classification-banner #floating-expand-btn { [data-bs-theme="dark"] #floating-expand-btn { background-color: var(--bs-dark, #212529) !important; - border: 1px solid var(--bs-border-color-translucent, rgba(255, 255, 255, 0.15)) !important; + border: 1px solid rgba(255, 255, 255, 0.16) !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !important; color: var(--bs-body-color, #fff) !important; } @@ -201,6 +202,25 @@ body.has-classification-banner #floating-expand-btn { font-weight: 600; } +.sidebar-floating-logo { + flex: 0 0 auto; + max-width: none; +} + +.floating-expand-title { + display: inline-block; + font-weight: 600; + max-width: min(12rem, 42vw); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#floating-expand-btn i { + font-size: 1.1rem; +} + /* When the banner is present, add padding to the top of the main content */ body.sidebar-nav-enabled.has-classification-banner .main-content, body.sidebar-nav-enabled.has-classification-banner .container, @@ -320,6 +340,21 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid { gap: 0.75rem; } +.sidebar-brand-row { + align-items: center; + display: flex; + gap: 0.5rem; + min-width: 0; +} + +.sidebar-brand-row .sidebar-brand-link { + flex: 1 1 auto; +} + +.sidebar-brand-row-icon-only { + justify-content: flex-start; +} + .sidebar-brand-link { display: flex !important; align-items: center; @@ -357,12 +392,65 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid { gap: 0.5rem; min-height: 38px; border-radius: 0.5rem; + border-color: rgba(var(--bs-secondary-rgb), 0.22) !important; + color: var(--bs-secondary-color, rgba(33, 37, 41, 0.75)); +} + +.sidebar-toggle-control:hover, +.sidebar-toggle-control:focus-visible { + background-color: rgba(var(--bs-primary-rgb), 0.08) !important; + border-color: rgba(var(--bs-primary-rgb), 0.32) !important; + color: var(--bs-primary); +} + +[data-bs-theme="dark"] .sidebar-toggle-control { + border-color: rgba(255, 255, 255, 0.16) !important; + color: rgba(255, 255, 255, 0.76); +} + +[data-bs-theme="dark"] .sidebar-toggle-control:hover, +[data-bs-theme="dark"] .sidebar-toggle-control:focus-visible { + background-color: rgba(255, 255, 255, 0.08) !important; + border-color: rgba(255, 255, 255, 0.28) !important; + color: rgba(255, 255, 255, 0.92); } .sidebar-toggle-control i { font-size: 0.95rem; } +.sidebar-toggle-compact { + background: transparent !important; + border: 0 !important; + border-radius: 999px; + box-shadow: none !important; + flex: 0 0 auto; + height: 2.25rem; + min-height: 2.25rem; + padding: 0; + width: 2.25rem; +} + +.sidebar-toggle-compact:hover, +.sidebar-toggle-compact:focus { + background: transparent !important; + border: 0 !important; + box-shadow: none !important; +} + +.sidebar-toggle-compact:focus-visible { + outline: 2px solid rgba(var(--bs-primary-rgb), 0.45); + outline-offset: 2px; +} + +.sidebar-toggle-compact i { + font-size: 1rem; +} + +.sidebar-short-header-compact-toggle { + align-items: flex-start; +} + .sidebar-conversation-item { cursor: pointer; padding: 8px 12px; diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js index 40be40e6c..2ab0a0759 100644 --- a/application/single_app/static/js/chat/chat-documents.js +++ b/application/single_app/static/js/chat/chat-documents.js @@ -194,26 +194,89 @@ function refreshDocumentsAndTags({ source = null, showLoading = true } = {}) { }); } -function getSearchDocumentsDropdownConfig() { +const SEARCH_FILTER_DROPDOWN_VIEWPORT_PADDING = 10; +const SEARCH_FILTER_DROPDOWN_FLIP_THRESHOLD = 180; + +function getSearchFilterDropdownViewportSpace(buttonEl) { + if (!buttonEl) { + return { + above: 0, + below: 0, + }; + } + + const buttonRect = buttonEl.getBoundingClientRect(); + + return { + above: Math.max(0, buttonRect.top - SEARCH_FILTER_DROPDOWN_VIEWPORT_PADDING), + below: Math.max(0, window.innerHeight - buttonRect.bottom - SEARCH_FILTER_DROPDOWN_VIEWPORT_PADDING), + }; +} + +function getSearchFilterDropdownPlacement(buttonEl) { + const viewportSpace = getSearchFilterDropdownViewportSpace(buttonEl); + + if ( + viewportSpace.below < SEARCH_FILTER_DROPDOWN_FLIP_THRESHOLD + && viewportSpace.above > viewportSpace.below + ) { + return 'top-start'; + } + + return 'bottom-start'; +} + +function getSearchDocumentsDropdownConfig(buttonEl) { return { boundary: 'viewport', reference: 'toggle', autoClose: 'outside', - popperConfig: { - strategy: 'fixed', - modifiers: [ - { - name: 'preventOverflow', - options: { - boundary: 'viewport', - padding: 10, + popperConfig: (defaultConfig) => { + const placement = getSearchFilterDropdownPlacement(buttonEl); + const baseModifiers = Array.isArray(defaultConfig.modifiers) + ? defaultConfig.modifiers.filter(modifier => !['flip', 'preventOverflow'].includes(modifier.name)) + : []; + + return { + ...defaultConfig, + placement, + strategy: 'fixed', + modifiers: [ + ...baseModifiers, + { + name: 'flip', + options: { + boundary: 'viewport', + fallbackPlacements: placement.startsWith('top') ? ['bottom-start'] : ['top-start'], + padding: SEARCH_FILTER_DROPDOWN_VIEWPORT_PADDING, + rootBoundary: 'viewport', + }, + }, + { + name: 'preventOverflow', + options: { + boundary: 'viewport', + padding: SEARCH_FILTER_DROPDOWN_VIEWPORT_PADDING, + rootBoundary: 'viewport', + }, }, - }, - ], + ], + }; }, }; } +function getSearchFilterDropdownAvailableHeight(buttonEl, menuEl) { + const placement = menuEl.getAttribute('data-popper-placement') || getSearchFilterDropdownPlacement(buttonEl); + const viewportSpace = getSearchFilterDropdownViewportSpace(buttonEl); + + if (placement.startsWith('top')) { + return viewportSpace.above; + } + + return viewportSpace.below; +} + function sizeSearchFilterDropdown(buttonEl, menuEl, itemsContainerEl) { if (!buttonEl || !menuEl) { return; @@ -223,22 +286,24 @@ function sizeSearchFilterDropdown(buttonEl, menuEl, itemsContainerEl) { const containerWidth = fieldContainer ? fieldContainer.offsetWidth : buttonEl.offsetWidth || 280; menuEl.style.width = `${containerWidth}px`; + menuEl.style.minWidth = `${containerWidth}px`; menuEl.style.maxWidth = `${containerWidth}px`; + menuEl.style.maxHeight = `${Math.floor(getSearchFilterDropdownAvailableHeight(buttonEl, menuEl))}px`; + menuEl.style.overflowY = 'hidden'; menuEl.style.zIndex = '1060'; - const menuRect = menuEl.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const maxPossibleHeight = Math.max(180, viewportHeight - menuRect.top - 10); - - menuEl.style.maxHeight = `${maxPossibleHeight}px`; - if (!itemsContainerEl) { return; } + const maxMenuHeight = Number.parseFloat(menuEl.style.maxHeight) || 0; const searchContainer = menuEl.querySelector('.chat-dropdown-search, .document-search-container'); - const searchHeight = searchContainer ? searchContainer.offsetHeight : 40; - itemsContainerEl.style.maxHeight = `${Math.max(120, maxPossibleHeight - searchHeight - 16)}px`; + const searchHeight = searchContainer && !searchContainer.classList.contains('d-none') + ? searchContainer.getBoundingClientRect().height + : 0; + const menuVerticalChrome = searchHeight + 16; + + itemsContainerEl.style.maxHeight = `${Math.max(0, Math.floor(maxMenuHeight - menuVerticalChrome))}px`; itemsContainerEl.style.overflowY = 'auto'; } @@ -246,6 +311,8 @@ function resetSearchFilterDropdownStyles(menuEl, itemsContainerEl) { if (menuEl) { menuEl.style.maxHeight = ''; menuEl.style.maxWidth = ''; + menuEl.style.minWidth = ''; + menuEl.style.overflowY = ''; menuEl.style.width = ''; menuEl.style.zIndex = ''; } @@ -269,7 +336,7 @@ function initializeSearchFilterDropdown({ return; } - new bootstrap.Dropdown(buttonEl, getSearchDocumentsDropdownConfig()); + new bootstrap.Dropdown(buttonEl, getSearchDocumentsDropdownConfig(buttonEl)); dropdownEl.addEventListener('show.bs.dropdown', function() { if (searchInputEl) { @@ -277,12 +344,19 @@ function initializeSearchFilterDropdown({ } searchController?.applyFilter(''); + sizeSearchFilterDropdown(buttonEl, menuEl, itemsContainerEl); }); dropdownEl.addEventListener('shown.bs.dropdown', function() { sizeSearchFilterDropdown(buttonEl, menuEl, itemsContainerEl); onShown?.(); + const dropdownInstance = bootstrap.Dropdown.getInstance(buttonEl); + if (dropdownInstance) { + dropdownInstance.update(); + sizeSearchFilterDropdown(buttonEl, menuEl, itemsContainerEl); + } + if (searchInputEl) { setTimeout(() => searchInputEl.focus(), 50); } @@ -1298,7 +1372,7 @@ export function loadAllDocs() { // Function to adjust dropdown sizing when shown function initializeDocumentDropdown() { - if (!docDropdownMenu) return; + if (!docDropdownMenu || !docDropdownButton || !docDropdownItems) return; // Clear any leftover search-filter state on visible items docDropdownItems.querySelectorAll('.dropdown-item').forEach(item => { @@ -1308,28 +1382,7 @@ function initializeDocumentDropdown() { // Re-apply tag filter (DOM removal approach — no CSS issues) filterDocumentsBySelectedTags(); documentSearchController?.applyFilter(docSearchInput ? docSearchInput.value : ''); - - // Size the dropdown to fill its parent container - const parentContainer = docDropdownButton.closest('.flex-grow-1'); - const maxWidth = parentContainer ? parentContainer.offsetWidth : 400; - - docDropdownMenu.style.maxWidth = `${maxWidth}px`; - docDropdownMenu.style.width = `${maxWidth}px`; - - // Ensure dropdown stays within viewport bounds - const menuRect = docDropdownMenu.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - - if (menuRect.bottom > viewportHeight) { - const maxPossibleHeight = viewportHeight - menuRect.top - 10; - docDropdownMenu.style.maxHeight = `${maxPossibleHeight}px`; - - if (docDropdownItems) { - const searchContainer = docDropdownMenu.querySelector('.document-search-container'); - const searchHeight = searchContainer ? searchContainer.offsetHeight : 40; - docDropdownItems.style.maxHeight = `${maxPossibleHeight - searchHeight}px`; - } - } + sizeSearchFilterDropdown(docDropdownButton, docDropdownMenu, docDropdownItems); } /* --------------------------------------------------------------------------- Load Tags for Selected Scope diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 2e619fcf7..c1cb7c498 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -1373,12 +1373,12 @@ export class PluginModalStepper { getSqlConnectionExamples(dbType) { const examples = { sqlserver: ` -
SQL Server: DRIVER={ODBC Driver 17 for SQL Server};SERVER=server.com;DATABASE=mydb;UID=user;PWD=pass
-
Integrated Auth: DRIVER={ODBC Driver 17 for SQL Server};SERVER=server.com;DATABASE=mydb;Trusted_Connection=yes
+
SQL Server: DRIVER={ODBC Driver 18 for SQL Server};SERVER=server.com;DATABASE=mydb;UID=user;PWD=pass
+
Integrated Auth: DRIVER={ODBC Driver 18 for SQL Server};SERVER=server.com;DATABASE=mydb;Trusted_Connection=yes
`, azure_sql: ` -
Managed Identity: DRIVER={ODBC Driver 17 for SQL Server};SERVER=server.database.windows.net;DATABASE=mydb;Authentication=ActiveDirectoryMsi
-
Username/Password: DRIVER={ODBC Driver 17 for SQL Server};SERVER=server.database.windows.net;DATABASE=mydb;UID=user;PWD=pass
+
Managed Identity: DRIVER={ODBC Driver 18 for SQL Server};SERVER=server.database.windows.net;DATABASE=mydb;Authentication=ActiveDirectoryMsi
+
Username/Password: DRIVER={ODBC Driver 18 for SQL Server};SERVER=server.database.windows.net;DATABASE=mydb;UID=user;PWD=pass
`, postgresql: `
PostgreSQL: host=localhost dbname=mydb user=username password=password port=5432
@@ -1588,7 +1588,7 @@ export class PluginModalStepper { document.getElementById('sql-server').value = additionalFields.server || ''; document.getElementById('sql-database').value = additionalFields.database || ''; document.getElementById('sql-port').value = additionalFields.port || ''; - document.getElementById('sql-driver').value = additionalFields.driver || 'ODBC Driver 17 for SQL Server'; + document.getElementById('sql-driver').value = additionalFields.driver || 'ODBC Driver 18 for SQL Server'; let sqlAuthType = hasConnectionString ? 'connection_string_only' : 'username_password'; diff --git a/application/single_app/static/js/public/manage_public_workspace.js b/application/single_app/static/js/public/manage_public_workspace.js index 7fc920018..f39245bde 100644 --- a/application/single_app/static/js/public/manage_public_workspace.js +++ b/application/single_app/static/js/public/manage_public_workspace.js @@ -219,30 +219,13 @@ $(document).ready(function () { }); // Approve / Reject requests (Admin/Owner) - $("#searchUsersBtn").on("click", function () { - `; - }).join(""); - }); - $("#userSearchTerm").on("keydown", function (e) { - if (e.key === "Enter") { - e.preventDefault(); - searchUsers(); - } - }); - - // Approve / Reject requests (Admin/Owner) - const safeUserId = escapeHtml(u.id || ""); - const safeDisplayName = escapeHtml(u.displayName || "(no name)"); - const safeEmail = escapeHtml(u.email || ""); $("#pendingRequestsTable").on("click", ".approve-request-btn", function () { approveRequest($(this).data("id")); - ${safeDisplayName} - ${safeEmail} + }); + $("#pendingRequestsTable").on("click", ".reject-request-btn", function () { rejectRequest($(this).data("id")); - {% endif %} - {% if not app_settings.hide_app_title %} - - {{ app_settings.app_title }} - + {% if app_settings.show_logo %} + + {% if app_settings.custom_logo_base64 or app_settings.custom_logo_dark_base64 %} + + {% if app_settings.custom_logo_base64 %} + + {% else %} + + {% endif %} + + {% if app_settings.custom_logo_dark_base64 %} + Logo + {% elif app_settings.custom_logo_base64 %} + Logo + {% else %} + Logo + {% endif %} + {% else %} + + Logo + {% endif %} + {% if not app_settings.hide_app_title %} + + {{ app_settings.app_title }} + + {% endif %} + + {% elif not app_settings.hide_app_title %} + + + {{ app_settings.app_title }} + + {% endif %} - - {% elif not app_settings.hide_app_title %} - - - {{ app_settings.app_title }} - - + {% endif %} + {% if not sidebar_toggle_compact %} + {% endif %} - - - - + - - \ No newline at end of file + \ No newline at end of file diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 11bc3e09b..2d17d0002 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -2,243 +2,168 @@ - - - {% if page.title %}{{ page.title }} | {% endif %}{{ site.title | default: site.github.repository_name }} - - - - - - - - - - - - - - - - - - - - - - - - {% if jekyll.environment == 'production' and site.google_analytics %} - + {% include sidebar_nav.html %} + + +
+ {{ content }} +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + - {% endif %} - - - {% if site.classification_banner.enabled and site.classification_banner.text %} -
- {{ site.classification_banner.text }} -
- {% endif %} - - - {% assign left_sidebar_enabled = true %} - {% include sidebar_nav.html %} - - -
- {{ content }} -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% if page.custom_js %} - {% for js_file in page.custom_js %} - - {% endfor %} - {% endif %} + + + + + + + + + {% if page.custom_js %} + {% for js_file in page.custom_js %} + + {% endfor %} + {% endif %} \ No newline at end of file diff --git a/docs/_layouts/page.html b/docs/_layouts/page.html index 51576678e..9825b4f1b 100644 --- a/docs/_layouts/page.html +++ b/docs/_layouts/page.html @@ -2,49 +2,74 @@ layout: default --- -
- {% if page.title %} - - {% endif %} -
- {{ content }} -
+
+ {{ content }} +
+ + {% if page.show_nav and page.nav_links %} + + {% endif %} +
+ - {% if page.show_nav and page.nav_links %} - - {% endif %} - \ No newline at end of file + {% unless page.hide_toc %} + + {% endunless %} + \ No newline at end of file diff --git a/docs/_layouts/showcase-page.html b/docs/_layouts/showcase-page.html index f96eb4ae0..8eeb98af7 100644 --- a/docs/_layouts/showcase-page.html +++ b/docs/_layouts/showcase-page.html @@ -4,119 +4,151 @@ {% assign accent = page.accent | default: 'blue' %} -
-
-
- {% if page.breadcrumb and page.breadcrumb.url and page.breadcrumb.label %} - - {% endif %} +
+
+
+
+
+ {% if page.breadcrumb and page.breadcrumb.url and page.breadcrumb.label %} + + {% elsif page.url != '/' %} + + {% endif %} - {% if page.eyebrow or page.hero_version %} -
- {% if page.eyebrow %} - {{ page.eyebrow }} - {% endif %} - {% if page.hero_version %} - {{ page.hero_version }} - {% endif %} -
- {% endif %} + {% if page.eyebrow or page.hero_version %} +
+ {% if page.eyebrow %} + {{ page.eyebrow }} + {% endif %} + {% if page.hero_version %} + {{ page.hero_version }} + {% endif %} +
+ {% endif %} -

{{ page.title }}

+

{{ page.title }}

- {% if page.description %} -

{{ page.description }}

- {% endif %} + {% if page.description %} +

{{ page.description }}

+ {% endif %} - {% if page.hero_pills %} -
- {% for pill in page.hero_pills %} - {{ pill }} - {% endfor %} -
- {% endif %} + {% if page.home_search %} + + {% endif %} - {% if page.hero_links %} -
- {% for link in page.hero_links %} - {% assign button_class = 'btn-outline-secondary' %} - {% if link.style == 'primary' %} - {% assign button_class = 'btn-primary' %} - {% elsif link.style == 'secondary' %} - {% assign button_class = 'btn-secondary' %} - {% endif %} - {{ link.label }} - {% endfor %} -
- {% endif %} -
+ {% if page.hero_pills %} +
+ {% for pill in page.hero_pills %} + {{ pill }} + {% endfor %} +
+ {% endif %} - -
+ {% if page.hero_links %} +
+ {% for link in page.hero_links %} + {% assign button_class = 'btn-outline-secondary' %} + {% if link.style == 'primary' %} + {% assign button_class = 'btn-primary' %} + {% elsif link.style == 'secondary' %} + {% assign button_class = 'btn-secondary' %} + {% endif %} + {{ link.label }} + {% endfor %} +
+ {% endif %} +
-
-
- {{ content }} -
+ +
- {% if page.show_nav and page.nav_links %} - - {% endif %} - -
- - + + {% unless page.hide_toc %} + + {% endunless %} + \ No newline at end of file diff --git a/docs/assets/css/main.scss b/docs/assets/css/main.scss index 740f311cf..83300e60a 100644 --- a/docs/assets/css/main.scss +++ b/docs/assets/css/main.scss @@ -1030,4 +1030,918 @@ pre[class*="language-"] { .latest-release-rich-content > h2 + ol { padding-right: 1rem; } +} + +/* GitHub Pages redesign shell */ +:root { + --docs-bg: #f8f9fa; + --docs-surface: #ffffff; + --docs-surface-low: #f3f4f5; + --docs-surface-high: #e7e8e9; + --docs-text: #191c1d; + --docs-muted: #404752; + --docs-border: #dee2e6; + --docs-primary: #005faa; + --docs-primary-bright: #0078d4; + --docs-primary-soft: #d3e3ff; + --docs-emerald: #198754; + --docs-orange: #fd7e14; + --docs-violet: #6f42c1; + --docs-shadow: 0 0.75rem 1.8rem rgba(15, 23, 42, 0.08); + --docs-radius-sm: 0.25rem; + --docs-radius-md: 0.5rem; + --docs-radius-lg: 0.75rem; +} + +[data-bs-theme="dark"] { + --docs-bg: #15191d; + --docs-surface: #212529; + --docs-surface-low: #2b3035; + --docs-surface-high: #343a40; + --docs-text: #f8f9fa; + --docs-muted: #c0c7d4; + --docs-border: rgba(255, 255, 255, 0.16); + --docs-primary: #a3c9ff; + --docs-primary-bright: #6ea8fe; + --docs-primary-soft: rgba(110, 168, 254, 0.16); + --docs-shadow: none; +} + +body.docs-shell { + background: var(--docs-bg); + color: var(--docs-text); + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + padding-top: var(--navbar-height); +} + +body.docs-nav-open { + overflow: hidden; +} + +.docs-shell h1, +.docs-shell h2, +.docs-shell h3, +.docs-shell h4, +.docs-shell h5, +.docs-shell h6, +.latest-release-hero-title, +.latest-release-section-header h2, +.latest-release-card-title { + font-family: "Work Sans", "Inter", system-ui, sans-serif; +} + +.docs-shell code, +.docs-shell pre, +.docs-shell kbd, +.docs-shell samp { + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.docs-topbar { + align-items: center; + background: rgba(248, 249, 250, 0.92); + border-bottom: 1px solid var(--docs-border); + display: flex; + height: var(--navbar-height); + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 1050; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +[data-bs-theme="dark"] .docs-topbar { + background: rgba(33, 37, 41, 0.92); +} + +body.has-classification-banner .docs-topbar { + top: var(--classification-banner-height); +} + +.docs-topbar-inner { + align-items: center; + display: flex; + gap: 1.25rem; + height: 100%; + justify-content: space-between; + padding: 0 1rem; + width: 100%; +} + +.docs-topbar-start, +.docs-topbar-actions, +.docs-topbar-nav { + align-items: center; + display: flex; +} + +.docs-topbar-start { + gap: 0.8rem; + min-width: 0; +} + +.docs-topbar-actions { + gap: 0.35rem; +} + +.docs-brand { + align-items: center; + color: var(--docs-primary); + display: inline-flex; + flex: 0 0 auto; + font-family: "Work Sans", "Inter", system-ui, sans-serif; + font-size: 1.12rem; + font-weight: 700; + gap: 0.55rem; + min-width: 0; + text-decoration: none; +} + +.docs-brand:hover, +.docs-brand:focus-visible { + color: var(--docs-primary-bright); +} + +.docs-brand-logo { + display: block; + height: 30px; + width: auto; +} + +.docs-icon-button { + align-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--docs-radius-md); + color: var(--docs-muted); + display: inline-flex; + flex: 0 0 auto; + height: 2.25rem; + justify-content: center; + padding: 0; + text-decoration: none; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; + width: 2.25rem; +} + +.docs-icon-button:hover, +.docs-icon-button:focus-visible { + background: var(--docs-surface-low); + border-color: var(--docs-border); + color: var(--docs-primary); +} + +.docs-icon-button .d-light-mode-only, +.docs-icon-button .d-dark-mode-only { + align-items: center; + justify-content: center; +} + +.docs-search { + position: relative; +} + +.docs-search > .bi-search { + color: var(--docs-muted); + font-size: 0.95rem; + left: 0.85rem; + pointer-events: none; + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 1; +} + +.docs-search-input { + background: var(--docs-surface-low); + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-md); + color: var(--docs-text); + font-size: 0.92rem; + line-height: 1.25rem; + min-height: 2.25rem; + padding: 0.45rem 0.8rem 0.45rem 2.35rem; + transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; + width: 100%; +} + +.docs-search-input:focus { + background: var(--docs-surface); + border-color: var(--docs-primary-bright); + box-shadow: 0 0 0 0.2rem rgba(0, 120, 212, 0.18); + outline: 0; +} + +.docs-topbar-search { + display: none; + width: min(32vw, 340px); +} + +.docs-search-results { + background: var(--docs-surface); + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-md); + box-shadow: var(--docs-shadow); + left: 0; + margin-top: 0.45rem; + max-height: min(420px, calc(100vh - 7rem)); + min-width: min(90vw, 360px); + overflow-y: auto; + padding: 0.4rem; + position: absolute; + right: 0; + top: 100%; + z-index: 1100; +} + +.docs-search-result { + border-radius: var(--docs-radius-sm); + color: var(--docs-text); + display: grid; + gap: 0.12rem; + padding: 0.75rem; + text-decoration: none; +} + +.docs-search-result:hover, +.docs-search-result:focus-visible { + background: var(--docs-surface-low); + color: var(--docs-text); +} + +.docs-search-result-title { + font-weight: 700; +} + +.docs-search-result-meta, +.docs-search-result-description, +.docs-search-empty { + color: var(--docs-muted); + font-size: 0.86rem; + line-height: 1.35; +} + +.docs-search-empty { + padding: 0.75rem; +} + +.docs-topbar-nav { + display: none; + gap: 1.4rem; + height: 100%; +} + +.docs-topbar-link { + align-items: center; + border-bottom: 2px solid transparent; + color: var(--docs-muted); + display: inline-flex; + font-size: 0.94rem; + font-weight: 600; + height: 100%; + text-decoration: none; +} + +.docs-topbar-link:hover, +.docs-topbar-link:focus-visible, +.docs-topbar-link.active { + border-bottom-color: var(--docs-primary-bright); + color: var(--docs-primary); +} + +#sidebar-nav.docs-sidebar { + background: var(--docs-surface); + border-right: 1px solid var(--docs-border); + bottom: 0; + color: var(--docs-text); + display: flex; + flex-direction: column; + height: auto; + left: 0; + min-width: var(--sidebar-width); + overflow-y: auto; + padding: 1.25rem 0.65rem; + position: fixed; + top: var(--navbar-height); + transform: translateX(-100%); + transition: transform 0.2s ease; + width: var(--sidebar-width); + z-index: 1045; +} + +body.has-classification-banner #sidebar-nav.docs-sidebar { + top: calc(var(--navbar-height) + var(--classification-banner-height)); +} + +#sidebar-nav.docs-sidebar.is-open { + transform: translateX(0); +} + +.docs-sidebar-backdrop { + background: rgba(15, 23, 42, 0.42); + border: 0; + bottom: 0; + left: 0; + position: fixed; + right: 0; + top: var(--navbar-height); + z-index: 1040; +} + +body.has-classification-banner .docs-sidebar-backdrop { + top: calc(var(--navbar-height) + var(--classification-banner-height)); +} + +.docs-sidebar-header { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; + padding: 0 0.6rem 1rem; +} + +.docs-sidebar-kicker { + color: var(--docs-muted); + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.08em; + margin: 0 0 0.25rem; + text-transform: uppercase; +} + +.docs-sidebar-version { + color: var(--docs-muted); + font-size: 0.86rem; + margin: 0; +} + +.docs-sidebar-close { + display: inline-flex; +} + +.docs-sidebar-sections { + display: grid; + gap: 0.4rem; +} + +.docs-sidebar-section { + display: grid; + gap: 0.15rem; +} + +.docs-sidebar-section-toggle { + align-items: center; + background: transparent; + border: 0; + border-radius: var(--docs-radius-md); + color: var(--docs-muted); + display: flex; + font-size: 0.78rem; + font-weight: 800; + justify-content: space-between; + letter-spacing: 0.08em; + padding: 0.65rem 0.7rem; + text-align: left; + text-transform: uppercase; + width: 100%; +} + +.docs-sidebar-section-toggle:hover, +.docs-sidebar-section-toggle:focus-visible { + background: var(--docs-surface-low); + color: var(--docs-text); +} + +.docs-sidebar-section-toggle i { + transition: transform 0.15s ease; +} + +.docs-sidebar-section-toggle.is-collapsed i { + transform: rotate(-90deg); +} + +.docs-sidebar-list { + display: grid; + gap: 0.15rem; + list-style: none; + margin: 0; + padding: 0; +} + +.docs-sidebar-link { + align-items: center; + border-left: 4px solid transparent; + border-radius: 0 var(--docs-radius-md) var(--docs-radius-md) 0; + color: var(--docs-muted); + display: grid; + font-size: 0.9rem; + gap: 0.65rem; + grid-template-columns: 1.15rem minmax(0, 1fr); + line-height: 1.35; + padding: 0.55rem 0.65rem; + text-decoration: none; +} + +.docs-sidebar-link:hover, +.docs-sidebar-link:focus-visible { + background: var(--docs-surface-low); + color: var(--docs-primary); +} + +.docs-sidebar-link.active { + background: var(--docs-primary-soft); + border-left-color: var(--docs-primary-bright); + color: var(--docs-primary); + font-weight: 700; +} + +.docs-sidebar-link span { + min-width: 0; + overflow-wrap: anywhere; +} + +.docs-sidebar-footer { + border-top: 1px solid var(--docs-border); + display: grid; + gap: 0.55rem; + margin-top: auto; + padding: 1rem 0.6rem 0; +} + +.docs-sidebar-cta, +.docs-sidebar-secondary-link { + align-items: center; + border-radius: var(--docs-radius-md); + display: inline-flex; + gap: 0.55rem; + justify-content: center; + min-height: 2.35rem; + padding: 0.55rem 0.7rem; + text-decoration: none; +} + +.docs-sidebar-cta { + background: var(--docs-primary); + color: #ffffff; + font-weight: 700; +} + +.docs-sidebar-cta:hover, +.docs-sidebar-cta:focus-visible { + background: var(--docs-primary-bright); + color: #ffffff; +} + +.docs-sidebar-secondary-link { + border: 1px solid var(--docs-border); + color: var(--docs-muted); +} + +.docs-sidebar-secondary-link:hover, +.docs-sidebar-secondary-link:focus-visible { + background: var(--docs-surface-low); + color: var(--docs-primary); +} + +.docs-main-content { + margin: 0; + min-height: calc(100vh - var(--navbar-height)); + padding: 1.25rem 1rem 2.5rem; + width: 100%; +} + +.docs-page-shell { + display: grid; + gap: 2rem; + margin: 0 auto; + max-width: 1180px; + width: 100%; +} + +.docs-page-primary { + min-width: 0; +} + +.docs-article, +.docs-showcase-page, +.docs-main-content > .latest-release-page, +.docs-main-content > .page-content { + margin: 0 auto; + max-width: 1180px; + width: 100%; +} + +.docs-breadcrumb { + align-items: center; + color: var(--docs-muted); + display: flex; + flex-wrap: wrap; + font-size: 0.9rem; + gap: 0.5rem; + margin-bottom: 1.25rem; +} + +.docs-breadcrumb a { + color: inherit; + font-weight: 600; + text-decoration: none; +} + +.docs-breadcrumb a:hover, +.docs-breadcrumb a:focus-visible { + color: var(--docs-primary); + text-decoration: underline; +} + +.docs-breadcrumb i { + font-size: 0.75rem; +} + +.docs-breadcrumb--hero { + margin-bottom: 0; +} + +.docs-content-header { + border-bottom: 1px solid var(--docs-border); + margin-bottom: 1.5rem; + padding-bottom: 1.2rem; +} + +.docs-content-header .page-title { + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1.08; + margin-bottom: 0.75rem; +} + +.docs-content-header .page-description { + color: var(--docs-muted); + font-size: 1.08rem; + line-height: 1.7; + margin: 0; + max-width: 52rem; +} + +.docs-prose { + color: var(--docs-text); + font-size: 1rem; + line-height: 1.72; +} + +.docs-prose > h2, +.docs-prose > h3, +.docs-prose > h4 { + scroll-margin-top: calc(var(--navbar-height) + 1.5rem); +} + +.docs-prose h2 { + border-bottom: 1px solid var(--docs-border); + margin-top: 2.5rem; + padding-bottom: 0.5rem; +} + +.docs-prose h3 { + margin-top: 2rem; +} + +.docs-prose p, +.docs-prose li, +.docs-prose td, +.docs-prose th { + color: inherit; +} + +.docs-prose a { + text-underline-offset: 0.16rem; +} + +.docs-prose table { + background: var(--docs-surface); + border: 1px solid var(--docs-border); + border-collapse: separate; + border-radius: var(--docs-radius-md); + border-spacing: 0; + display: block; + overflow-x: auto; + width: 100%; +} + +.docs-prose th, +.docs-prose td { + border-bottom: 1px solid var(--docs-border); + padding: 0.8rem 0.9rem; + vertical-align: top; +} + +.docs-prose tr:last-child td { + border-bottom: 0; +} + +.docs-prose th { + background: var(--docs-surface-low); + color: var(--docs-text); + font-weight: 700; +} + +.docs-prose blockquote, +.latest-release-note-panel { + border-left: 4px solid var(--latest-release-accent-solid, var(--docs-primary-bright)); +} + +.heading-anchor { + color: var(--docs-muted); + display: inline-flex; + font-size: 0.85em; + margin-left: 0.45rem; + opacity: 0; + text-decoration: none; + transition: opacity 0.15s ease, color 0.15s ease; +} + +h2:hover .heading-anchor, +h3:hover .heading-anchor, +h4:hover .heading-anchor, +.heading-anchor:focus-visible { + opacity: 1; +} + +.heading-anchor:hover, +.heading-anchor:focus-visible { + color: var(--docs-primary); +} + +.docs-code-block { + position: relative; +} + +.docs-copy-button { + position: absolute; + right: 0.55rem; + top: 0.55rem; + z-index: 2; +} + +.docs-visually-hidden-copy-field { + height: 1px; + left: -10000px; + position: fixed; + top: auto; + width: 1px; +} + +.docs-on-this-page { + display: none; +} + +.docs-on-this-page-inner { + border-left: 1px solid var(--docs-border); + padding-left: 1rem; + position: sticky; + top: calc(var(--navbar-height) + 2rem); +} + +.docs-on-this-page h2 { + color: var(--docs-muted); + font-family: "Inter", system-ui, sans-serif; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.08em; + margin: 0 0 0.75rem; + text-transform: uppercase; +} + +.docs-on-this-page nav { + display: grid; + gap: 0.45rem; +} + +.docs-on-this-page a { + color: var(--docs-muted); + font-size: 0.9rem; + line-height: 1.35; + text-decoration: none; +} + +.docs-on-this-page a:hover, +.docs-on-this-page a:focus-visible { + color: var(--docs-primary); +} + +.docs-on-this-page a.is-subheading { + padding-left: 0.85rem; +} + +.docs-footer { + background: var(--docs-surface); + border-top: 1px solid var(--docs-border); + color: var(--docs-muted); + padding: 1.2rem 1rem; +} + +.docs-footer-inner { + align-items: center; + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 0 auto; + max-width: 1180px; +} + +.docs-footer nav { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: center; +} + +.docs-footer a { + color: var(--docs-muted); + text-decoration: underline; + text-underline-offset: 0.15rem; +} + +.docs-footer a:hover, +.docs-footer a:focus-visible { + color: var(--docs-primary); +} + +#toast-container { + z-index: 1110; +} + +.latest-release-page, +.latest-release-rich-content { + max-width: none; +} + +.latest-release-hero { + background: linear-gradient(135deg, rgba(var(--latest-release-accent-rgb), 0.12), rgba(255, 255, 255, 0.96) 48%, var(--docs-surface) 100%); + border-color: rgba(var(--latest-release-accent-rgb), 0.18); + border-radius: var(--docs-radius-lg); + box-shadow: none; +} + +[data-bs-theme="dark"] .latest-release-hero { + background: linear-gradient(135deg, rgba(var(--latest-release-accent-rgb), 0.16), rgba(33, 37, 41, 0.96) 50%, var(--docs-surface) 100%); + box-shadow: none; +} + +.latest-release-hero-title { + color: var(--docs-text); + font-size: clamp(2.15rem, 5vw, 3.35rem); + letter-spacing: 0; +} + +.latest-release-hero-description, +.latest-release-section-header p, +.latest-release-card-summary, +.latest-release-note-panel p, +.latest-release-note-panel ul { + color: var(--docs-muted); +} + +.latest-release-hero-stack span, +.latest-release-icon-orb, +.latest-release-card-shell, +.latest-release-card-icon, +.latest-release-thumbnail-media, +.latest-release-archive-panel, +.latest-release-rich-content > h2 + p, +.latest-release-rich-content > h2 + ul, +.latest-release-rich-content > h2 + ol { + border-radius: var(--docs-radius-lg); + box-shadow: none; +} + +.latest-release-icon-orb { + height: min(34vw, 168px); + width: min(34vw, 168px); +} + +.latest-release-hero-stack span:nth-child(2) { + transform: none; +} + +.latest-release-card-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); +} + +.latest-release-card-grid > .latest-release-card { + background: var(--docs-surface); + border: 1px solid var(--docs-border); + border-radius: var(--docs-radius-lg); + box-shadow: none; + padding: 1.15rem; +} + +.latest-release-card-grid > .latest-release-card:has(.latest-release-card-shell) { + background: transparent; + border: 0; + padding: 0; +} + +.latest-release-card-shell { + background: linear-gradient(180deg, rgba(var(--latest-release-accent-rgb), 0.07), var(--docs-surface) 40%); + border-color: rgba(var(--latest-release-accent-rgb), 0.16); +} + +[data-bs-theme="dark"] .latest-release-card-shell, +[data-bs-theme="dark"] .latest-release-card-grid > .latest-release-card { + background: linear-gradient(180deg, rgba(var(--latest-release-accent-rgb), 0.12), var(--docs-surface) 42%); +} + +.latest-release-card-grid > .latest-release-card > h2, +.latest-release-card-grid > .latest-release-card > h3 { + font-size: 1.15rem; + margin-top: 0.85rem; +} + +.latest-release-card-grid > .latest-release-card > p:last-child { + margin-bottom: 0; +} + +.docs-hero-search { + max-width: 680px; + width: 100%; +} + +.docs-hero-search .docs-search-input { + font-size: 1rem; + min-height: 3rem; + padding-left: 2.65rem; +} + +.docs-hero-search > .bi-search { + left: 1rem; +} + +@media (min-width: 768px) { + .docs-footer-inner { + flex-direction: row; + justify-content: space-between; + } +} + +@media (min-width: 992px) { + .docs-mobile-nav-toggle, + .docs-sidebar-close { + display: none; + } + + .docs-topbar-inner { + padding: 0 1.5rem; + } + + .docs-topbar-search, + .docs-topbar-nav { + display: flex; + } + + #sidebar-nav.docs-sidebar { + transform: translateX(0); + } + + .docs-main-content { + margin-left: var(--sidebar-width); + padding: 2rem clamp(2rem, 7vw, 9.375rem) 3rem; + } + + .docs-footer { + margin-left: var(--sidebar-width); + padding-left: clamp(2rem, 7vw, 9.375rem); + padding-right: clamp(2rem, 7vw, 9.375rem); + } +} + +@media (min-width: 1200px) { + .docs-page-shell:not(.docs-page-shell--home) { + grid-template-columns: minmax(0, 1fr) 230px; + max-width: 1320px; + } + + .docs-on-this-page:not(.d-none) { + display: block; + } +} + +@media (max-width: 991.98px) { + .docs-brand span { + display: none; + } + + .latest-release-hero { + grid-template-columns: 1fr; + } +} + +@media (max-width: 575.98px) { + .docs-main-content { + padding-left: 0.85rem; + padding-right: 0.85rem; + } + + .latest-release-hero { + padding: 1.25rem; + } } \ No newline at end of file diff --git a/docs/assets/js/dark-mode.js b/docs/assets/js/dark-mode.js index 962f72052..7ec1200ab 100644 --- a/docs/assets/js/dark-mode.js +++ b/docs/assets/js/dark-mode.js @@ -1,102 +1,79 @@ +// dark-mode.js /** - * Dark Mode Toggle Functionality - * Handles theme switching between light and dark modes + * Light and dark theme switching for the documentation site. */ (function() { - 'use strict'; + "use strict"; - // Theme constants - const THEME_KEY = 'simplechat-theme'; - const THEMES = { - LIGHT: 'light', - DARK: 'dark' - }; + const THEME_KEY = "simplechat-theme"; + const THEMES = { + LIGHT: "light", + DARK: "dark" + }; - // Get current theme - function getCurrentTheme() { - return localStorage.getItem(THEME_KEY) || THEMES.LIGHT; - } + function getCurrentTheme() { + return localStorage.getItem(THEME_KEY) || THEMES.LIGHT; + } - // Set theme - function setTheme(theme) { - document.documentElement.setAttribute('data-bs-theme', theme); - localStorage.setItem(THEME_KEY, theme); - - // Update toggle buttons text/icons - updateToggleButtons(theme); - - // Trigger custom event for other components - document.dispatchEvent(new CustomEvent('themeChanged', { detail: { theme } })); - } + function updateToggleButtons(theme) { + const toggles = document.querySelectorAll(".dark-mode-toggle"); + const isDark = theme === THEMES.DARK; - // Update toggle button states - function updateToggleButtons(theme) { - const toggles = document.querySelectorAll('.dark-mode-toggle'); - toggles.forEach(toggle => { - const lightElements = toggle.querySelectorAll('.d-light-mode-only'); - const darkElements = toggle.querySelectorAll('.d-dark-mode-only'); - - if (theme === THEMES.DARK) { - lightElements.forEach(el => el.style.display = 'none'); - darkElements.forEach(el => el.style.display = 'inline'); - } else { - lightElements.forEach(el => el.style.display = 'inline'); - darkElements.forEach(el => el.style.display = 'none'); - } - }); - } + toggles.forEach(function(toggle) { + toggle.classList.toggle("is-dark", isDark); + toggle.setAttribute("aria-label", isDark ? "Switch to light mode" : "Switch to dark mode"); + }); + } - // Toggle theme - function toggleTheme() { - const currentTheme = getCurrentTheme(); - const newTheme = currentTheme === THEMES.LIGHT ? THEMES.DARK : THEMES.LIGHT; - setTheme(newTheme); - } + function setTheme(theme) { + document.documentElement.setAttribute("data-bs-theme", theme); + localStorage.setItem(THEME_KEY, theme); + updateToggleButtons(theme); + document.dispatchEvent(new CustomEvent("themeChanged", { detail: { theme } })); + } - // Initialize theme on page load - function initTheme() { - const savedTheme = getCurrentTheme(); - setTheme(savedTheme); - } + function toggleTheme() { + const currentTheme = getCurrentTheme(); + const newTheme = currentTheme === THEMES.LIGHT ? THEMES.DARK : THEMES.LIGHT; + setTheme(newTheme); + } - // Set up event listeners - function setupEventListeners() { - // Handle toggle button clicks - document.addEventListener('click', function(e) { - if (e.target.closest('.dark-mode-toggle')) { - e.preventDefault(); - toggleTheme(); - } - }); + function initTheme() { + setTheme(getCurrentTheme()); + } - // Handle keyboard shortcuts (Ctrl/Cmd + Shift + L) - document.addEventListener('keydown', function(e) { - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'L') { - e.preventDefault(); - toggleTheme(); - } - }); - } + function setupEventListeners() { + document.addEventListener("click", function(event) { + if (event.target.closest(".dark-mode-toggle")) { + event.preventDefault(); + toggleTheme(); + } + }); - // Initialize when DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', function() { - initTheme(); - setupEventListeners(); - }); - } else { - initTheme(); - setupEventListeners(); - } + document.addEventListener("keydown", function(event) { + if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "L") { + event.preventDefault(); + toggleTheme(); + } + }); + } - // Expose utilities globally - window.SimpleChat = window.SimpleChat || {}; - window.SimpleChat.Theme = { - getCurrentTheme, - setTheme, - toggleTheme, - THEMES - }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function() { + initTheme(); + setupEventListeners(); + }); + } else { + initTheme(); + setupEventListeners(); + } + window.SimpleChat = window.SimpleChat || {}; + window.SimpleChat.Theme = { + getCurrentTheme, + setTheme, + toggleTheme, + THEMES + }; })(); \ No newline at end of file diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 672b10944..acbe61d00 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -1,234 +1,411 @@ +// main.js /** - * Main JavaScript for SimpleChat Jekyll Theme - * Handles general functionality and utilities + * Main JavaScript for the Simple Chat documentation site. */ (function() { - 'use strict'; - - // Toast notification system - function showToast(message, type = 'info', duration = 5000) { - const toastContainer = document.getElementById('toast-container'); - if (!toastContainer) return; - - // Create toast element - const toastId = 'toast-' + Date.now(); - const toastHtml = ` - - `; - - toastContainer.insertAdjacentHTML('beforeend', toastHtml); - - const toastElement = document.getElementById(toastId); - const toast = new bootstrap.Toast(toastElement, { delay: duration }); - - toast.show(); - - // Clean up after toast is hidden - toastElement.addEventListener('hidden.bs.toast', function() { - toastElement.remove(); - }); - } - - // Copy to clipboard functionality - function copyToClipboard(text, successMessage = 'Copied to clipboard!') { - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(text).then(function() { - showToast(successMessage, 'success', 2000); - }).catch(function() { - fallbackCopy(text, successMessage); - }); - } else { - fallbackCopy(text, successMessage); - } - } - - // Fallback clipboard copy for older browsers - function fallbackCopy(text, successMessage) { - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.style.position = 'fixed'; - textArea.style.left = '-999999px'; - textArea.style.top = '-999999px'; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - try { - document.execCommand('copy'); - showToast(successMessage, 'success', 2000); - } catch (err) { - showToast('Failed to copy to clipboard', 'error', 3000); - } - - document.body.removeChild(textArea); - } - - // Add copy buttons to code blocks - function addCopyButtonsToCodeBlocks() { - const codeBlocks = document.querySelectorAll('pre[class*="language-"]'); - - codeBlocks.forEach(function(codeBlock) { - // Skip if copy button already exists - if (codeBlock.querySelector('.copy-button')) return; - - const button = document.createElement('button'); - button.className = 'btn btn-sm btn-outline-secondary copy-button'; - button.innerHTML = ''; - button.title = 'Copy code'; - button.style.cssText = 'position: absolute; top: 0.5rem; right: 0.5rem; z-index: 10;'; - - // Make the code block relative positioned - codeBlock.style.position = 'relative'; - - button.addEventListener('click', function() { - const code = codeBlock.querySelector('code'); - if (code) { - copyToClipboard(code.textContent, 'Code copied!'); - - // Visual feedback - button.innerHTML = ''; - setTimeout(function() { - button.innerHTML = ''; - }, 2000); + "use strict"; + + let cachedSearchIndex = null; + + function createIcon(iconClass) { + const icon = document.createElement("i"); + icon.className = iconClass; + icon.setAttribute("aria-hidden", "true"); + return icon; + } + + function showToast(message, type = "info", duration = 5000) { + const toastContainer = document.getElementById("toast-container"); + if (!toastContainer || !window.bootstrap) { + return; + } + + const toast = document.createElement("div"); + toast.className = `toast align-items-center text-bg-${type} border-0`; + toast.setAttribute("role", "alert"); + toast.setAttribute("aria-live", "assertive"); + toast.setAttribute("aria-atomic", "true"); + + const row = document.createElement("div"); + row.className = "d-flex"; + + const body = document.createElement("div"); + body.className = "toast-body"; + body.textContent = message; + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.className = "btn-close btn-close-white me-2 m-auto"; + closeButton.setAttribute("data-bs-dismiss", "toast"); + closeButton.setAttribute("aria-label", "Close"); + + row.appendChild(body); + row.appendChild(closeButton); + toast.appendChild(row); + toastContainer.appendChild(toast); + + const bootstrapToast = new bootstrap.Toast(toast, { delay: duration }); + toast.addEventListener("hidden.bs.toast", function() { + toast.remove(); + }); + bootstrapToast.show(); + } + + function fallbackCopy(text, successMessage) { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.className = "docs-visually-hidden-copy-field"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand("copy"); + showToast(successMessage, "success", 2000); + } catch (error) { + showToast("Failed to copy to clipboard", "danger", 3000); + } + + textArea.remove(); + } + + function copyToClipboard(text, successMessage = "Copied to clipboard") { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(function() { + showToast(successMessage, "success", 2000); + }).catch(function() { + fallbackCopy(text, successMessage); + }); + } else { + fallbackCopy(text, successMessage); + } + } + + function setButtonIcon(button, iconClass) { + button.replaceChildren(createIcon(iconClass)); + } + + function addCopyButtonsToCodeBlocks() { + const codeBlocks = document.querySelectorAll("pre[class*='language-'], .docs-prose pre"); + + codeBlocks.forEach(function(codeBlock) { + if (codeBlock.querySelector(".copy-button")) { + return; + } + + const code = codeBlock.querySelector("code"); + if (!code) { + return; + } + + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn btn-sm btn-outline-secondary copy-button docs-copy-button"; + button.title = "Copy code"; + button.setAttribute("aria-label", "Copy code"); + setButtonIcon(button, "bi bi-clipboard"); + + codeBlock.classList.add("docs-code-block"); + button.addEventListener("click", function() { + copyToClipboard(code.textContent, "Code copied"); + setButtonIcon(button, "bi bi-clipboard-check"); + setTimeout(function() { + setButtonIcon(button, "bi bi-clipboard"); + }, 2000); + }); + + codeBlock.appendChild(button); + }); + } + + function initTooltips() { + if (!window.bootstrap) { + return; + } + + const tooltipTriggerList = Array.from(document.querySelectorAll("[data-bs-toggle='tooltip']")); + tooltipTriggerList.forEach(function(tooltipTriggerElement) { + new bootstrap.Tooltip(tooltipTriggerElement); + }); + } + + function initPopovers() { + if (!window.bootstrap) { + return; + } + + const popoverTriggerList = Array.from(document.querySelectorAll("[data-bs-toggle='popover']")); + popoverTriggerList.forEach(function(popoverTriggerElement) { + new bootstrap.Popover(popoverTriggerElement); + }); + } + + function initSmoothScrolling() { + document.querySelectorAll("a[href^='#']").forEach(function(anchor) { + anchor.addEventListener("click", function(event) { + const targetId = anchor.getAttribute("href"); + if (!targetId || targetId === "#") { + return; + } + + const targetElement = document.querySelector(targetId); + if (!targetElement) { + return; + } + + event.preventDefault(); + const headerHeight = document.querySelector(".docs-topbar")?.offsetHeight || 0; + const targetPosition = targetElement.offsetTop - headerHeight - 20; + + window.scrollTo({ + top: targetPosition, + behavior: "smooth" + }); + + history.pushState(null, "", targetId); + }); + }); + } + + function addHeadingAnchors() { + const headings = document.querySelectorAll(".docs-prose h2[id], .docs-prose h3[id], .docs-prose h4[id]"); + + headings.forEach(function(heading) { + if (heading.querySelector(".heading-anchor")) { + return; + } + + const anchor = document.createElement("a"); + anchor.href = `#${heading.id}`; + anchor.className = "heading-anchor"; + anchor.title = "Link to this heading"; + anchor.setAttribute("aria-label", "Copy link to this heading"); + anchor.appendChild(createIcon("bi bi-link-45deg")); + + anchor.addEventListener("click", function(event) { + event.preventDefault(); + const url = `${window.location.origin}${window.location.pathname}${anchor.getAttribute("href")}`; + copyToClipboard(url, "Link copied"); + }); + + heading.appendChild(anchor); + }); + } + + function getSearchIndex() { + if (cachedSearchIndex) { + return cachedSearchIndex; } - }); - - codeBlock.appendChild(button); - }); - } - - // Initialize Bootstrap tooltips - function initTooltips() { - const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); - tooltipTriggerList.map(function(tooltipTriggerEl) { - return new bootstrap.Tooltip(tooltipTriggerEl); - }); - } - - // Initialize Bootstrap popovers - function initPopovers() { - const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')); - popoverTriggerList.map(function(popoverTriggerEl) { - return new bootstrap.Popover(popoverTriggerEl); - }); - } - - // Smooth scrolling for anchor links - function initSmoothScrolling() { - document.querySelectorAll('a[href^="#"]').forEach(function(anchor) { - anchor.addEventListener('click', function(e) { - const targetId = this.getAttribute('href'); - if (targetId === '#') return; - - const targetElement = document.querySelector(targetId); - if (targetElement) { - e.preventDefault(); - - const headerHeight = document.querySelector('.navbar')?.offsetHeight || 0; - const targetPosition = targetElement.offsetTop - headerHeight - 20; - - window.scrollTo({ - top: targetPosition, - behavior: 'smooth' - }); - - // Update URL hash - history.pushState(null, null, targetId); + + const searchDataElement = document.getElementById("docs-search-data"); + if (searchDataElement) { + try { + cachedSearchIndex = JSON.parse(searchDataElement.textContent).filter(function(item) { + return item && item.title && item.url; + }); + return cachedSearchIndex; + } catch (error) { + cachedSearchIndex = []; + } } - }); - }); - } - - // Add heading anchors - function addHeadingAnchors() { - const headings = document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'); - - headings.forEach(function(heading) { - const anchor = document.createElement('a'); - anchor.href = '#' + heading.id; - anchor.className = 'heading-anchor'; - anchor.innerHTML = ''; - anchor.style.cssText = 'margin-left: 0.5rem; opacity: 0; transition: opacity 0.2s; text-decoration: none; color: var(--bs-secondary);'; - anchor.title = 'Link to this heading'; - - heading.appendChild(anchor); - - // Show anchor on hover - heading.addEventListener('mouseenter', function() { - anchor.style.opacity = '1'; - }); - - heading.addEventListener('mouseleave', function() { - anchor.style.opacity = '0'; - }); - - // Copy link on click - anchor.addEventListener('click', function(e) { - e.preventDefault(); - const url = window.location.origin + window.location.pathname + this.getAttribute('href'); - copyToClipboard(url, 'Link copied!'); - }); - }); - } - - // Initialize search functionality (if search input exists) - function initSearch() { - const searchInput = document.querySelector('#search-input, .search-input'); - if (!searchInput) return; - - let searchTimeout; - searchInput.addEventListener('input', function() { - clearTimeout(searchTimeout); - searchTimeout = setTimeout(function() { - // Implement search functionality based on your needs - console.log('Search query:', searchInput.value); - }, 300); - }); - } - - // Initialize all functionality - function init() { - initTooltips(); - initPopovers(); - initSmoothScrolling(); - addHeadingAnchors(); - addCopyButtonsToCodeBlocks(); - initSearch(); - - // Re-initialize after theme changes (for syntax highlighting) - document.addEventListener('themeChanged', function() { - setTimeout(function() { - if (window.Prism) { - Prism.highlightAll(); + + cachedSearchIndex = Array.from(document.querySelectorAll(".docs-sidebar-link, .docs-topbar-link")).map(function(link) { + return { + title: link.textContent.trim(), + description: "", + section: "Navigation", + url: link.href + }; + }); + return cachedSearchIndex; + } + + function createSearchResult(item) { + const result = document.createElement("a"); + result.className = "docs-search-result"; + result.href = item.url; + result.setAttribute("role", "option"); + + const title = document.createElement("span"); + title.className = "docs-search-result-title"; + title.textContent = item.title; + + const meta = document.createElement("span"); + meta.className = "docs-search-result-meta"; + meta.textContent = item.section || "Docs"; + + const description = document.createElement("span"); + description.className = "docs-search-result-description"; + description.textContent = item.description || "Open this documentation page."; + + result.appendChild(title); + result.appendChild(meta); + result.appendChild(description); + return result; + } + + function renderSearchResults(searchInput, resultsContainer) { + const query = searchInput.value.trim().toLowerCase(); + resultsContainer.replaceChildren(); + + if (query.length < 2) { + resultsContainer.classList.add("d-none"); + return; + } + + const matches = getSearchIndex().map(function(item) { + const title = item.title.toLowerCase(); + const description = (item.description || "").toLowerCase(); + const section = (item.section || "").toLowerCase(); + const searchableText = `${title} ${description} ${section}`; + let score = 10; + + if (title === query) { + score = 0; + } else if (title.startsWith(query)) { + score = 1; + } else if (title.includes(query)) { + score = 2; + } else if (section.includes(query)) { + score = 3; + } else if (description.includes(query)) { + score = 4; + } + + return { item, score, searchableText }; + }).filter(function(result) { + return result.searchableText.includes(query); + }).sort(function(firstResult, secondResult) { + if (firstResult.score !== secondResult.score) { + return firstResult.score - secondResult.score; + } + return firstResult.item.title.localeCompare(secondResult.item.title); + }).slice(0, 8).map(function(result) { + return result.item; + }); + + if (matches.length === 0) { + const emptyState = document.createElement("div"); + emptyState.className = "docs-search-empty"; + emptyState.textContent = "No matching docs found."; + resultsContainer.appendChild(emptyState); + } else { + matches.forEach(function(item) { + resultsContainer.appendChild(createSearchResult(item)); + }); + } + + resultsContainer.classList.remove("d-none"); + } + + function initSearch() { + const searchInputs = document.querySelectorAll("[data-docs-search='true']"); + + searchInputs.forEach(function(searchInput) { + const searchRoot = searchInput.closest(".docs-search"); + const resultsContainer = searchRoot ? searchRoot.querySelector("[data-docs-search-results='true']") : null; + + if (!resultsContainer) { + return; + } + + let searchTimeout; + + searchInput.addEventListener("input", function() { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(function() { + renderSearchResults(searchInput, resultsContainer); + }, 120); + }); + + searchInput.addEventListener("keydown", function(event) { + if (event.key === "Escape") { + searchInput.value = ""; + resultsContainer.classList.add("d-none"); + } + }); + }); + + document.addEventListener("click", function(event) { + if (!event.target.closest(".docs-search")) { + document.querySelectorAll("[data-docs-search-results='true']").forEach(function(resultsContainer) { + resultsContainer.classList.add("d-none"); + }); + } + }); + } + + function buildOnThisPage() { + const tocContainers = document.querySelectorAll("[data-docs-toc='true']"); + if (tocContainers.length === 0) { + return; } - addCopyButtonsToCodeBlocks(); // Re-add copy buttons if code blocks are re-rendered - }, 100); - }); - } - - // Initialize when DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } - - // Expose utilities globally - window.SimpleChat = window.SimpleChat || {}; - window.SimpleChat.Utils = { - showToast, - copyToClipboard, - addCopyButtonsToCodeBlocks, - initTooltips, - initPopovers - }; + const headings = Array.from(document.querySelectorAll(".docs-prose h2[id], .docs-prose h3[id]")).filter(function(heading) { + return heading.textContent.trim().length > 0; + }); + + tocContainers.forEach(function(tocContainer) { + const linksContainer = tocContainer.querySelector("[data-docs-toc-links='true']"); + if (!linksContainer) { + return; + } + + linksContainer.replaceChildren(); + + if (headings.length === 0) { + tocContainer.classList.add("d-none"); + return; + } + + headings.slice(0, 12).forEach(function(heading) { + const link = document.createElement("a"); + link.href = `#${heading.id}`; + link.textContent = heading.textContent.replace("#", "").trim(); + if (heading.tagName.toLowerCase() === "h3") { + link.classList.add("is-subheading"); + } + linksContainer.appendChild(link); + }); + + tocContainer.classList.remove("d-none"); + }); + } + + function init() { + initTooltips(); + initPopovers(); + initSmoothScrolling(); + addHeadingAnchors(); + addCopyButtonsToCodeBlocks(); + initSearch(); + buildOnThisPage(); + + document.addEventListener("themeChanged", function() { + setTimeout(function() { + if (window.Prism) { + Prism.highlightAll(); + } + addCopyButtonsToCodeBlocks(); + }, 100); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + + window.SimpleChat = window.SimpleChat || {}; + window.SimpleChat.Utils = { + showToast, + copyToClipboard, + addCopyButtonsToCodeBlocks, + initTooltips, + initPopovers, + initSearch, + buildOnThisPage + }; })(); \ No newline at end of file diff --git a/docs/assets/js/sidebar.js b/docs/assets/js/sidebar.js index 88a5d000d..ad4ed9fbb 100644 --- a/docs/assets/js/sidebar.js +++ b/docs/assets/js/sidebar.js @@ -1,157 +1,140 @@ +// sidebar.js /** - * Sidebar Functionality - * Handles sidebar interactions, collapsing, and responsive behavior + * Documentation sidebar behavior for the GitHub Pages site. */ (function() { - 'use strict'; - - // Sidebar state management - let sidebarState = { - expanded: true, - mobile: false - }; - - // Check if we're on mobile - function isMobile() { - return window.innerWidth <= 768; - } - - // Update sidebar state - function updateSidebarState() { - sidebarState.mobile = isMobile(); - - if (sidebarState.mobile) { - // On mobile, sidebar should be collapsed by default - const sidebar = document.getElementById('sidebar-nav'); - if (sidebar && !sidebar.classList.contains('sidebar-collapsed')) { - sidebarState.expanded = false; - } + "use strict"; + + const DESKTOP_BREAKPOINT = 992; + + function isDesktop() { + return window.innerWidth >= DESKTOP_BREAKPOINT; + } + + function getElements() { + return { + sidebar: document.getElementById("sidebar-nav"), + openButton: document.getElementById("docs-mobile-menu-toggle"), + closeButton: document.getElementById("docs-sidebar-close"), + backdrop: document.getElementById("docs-sidebar-backdrop") + }; + } + + function setSidebarOpen(isOpen) { + const { sidebar, openButton, backdrop } = getElements(); + + if (!sidebar || !openButton || !backdrop) { + return; + } + + sidebar.classList.toggle("is-open", isOpen); + openButton.setAttribute("aria-expanded", isOpen ? "true" : "false"); + backdrop.classList.toggle("d-none", !isOpen || isDesktop()); + document.body.classList.toggle("docs-nav-open", isOpen && !isDesktop()); } - } - - // Toggle collapsible sections - function initCollapsibleSections() { - // Sections toggle - const sectionsToggle = document.getElementById('sections-toggle'); - const sectionsList = document.getElementById('sections-list'); - const sectionsCaret = document.getElementById('sections-caret'); - - if (sectionsToggle && sectionsList && sectionsCaret) { - let sectionsCollapsed = false; - sectionsToggle.addEventListener('click', function() { - sectionsCollapsed = !sectionsCollapsed; - sectionsList.style.display = sectionsCollapsed ? 'none' : ''; - sectionsCaret.style.transform = sectionsCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)'; - }); + + function closeSidebar() { + setSidebarOpen(false); } - // External links toggle - const externalLinksToggle = document.getElementById('external-links-toggle'); - const externalLinksSection = document.getElementById('external-links-section'); - const externalLinksCaret = document.getElementById('external-links-caret'); - - if (externalLinksToggle && externalLinksSection && externalLinksCaret) { - let externalLinksCollapsed = false; - externalLinksToggle.addEventListener('click', function() { - externalLinksCollapsed = !externalLinksCollapsed; - externalLinksSection.style.display = externalLinksCollapsed ? 'none' : ''; - externalLinksCaret.style.transform = externalLinksCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)'; - }); + function openSidebar() { + setSidebarOpen(true); } - // Section submenus - const sectionToggles = document.querySelectorAll('.section-nav-toggle'); - sectionToggles.forEach(function(toggle) { - toggle.addEventListener('click', function(e) { - e.preventDefault(); - const targetId = toggle.getAttribute('data-target'); - const submenu = document.getElementById(targetId); - const caret = toggle.querySelector('.section-caret'); - - if (submenu && caret) { - const isHidden = submenu.style.display === 'none' || submenu.style.display === ''; - submenu.style.display = isHidden ? 'block' : 'none'; - caret.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)'; + function syncSidebarForViewport() { + const { backdrop } = getElements(); + + if (isDesktop()) { + document.body.classList.remove("docs-nav-open"); + if (backdrop) { + backdrop.classList.add("d-none"); + } } - }); - }); - } - - // Handle responsive behavior - function handleResize() { - updateSidebarState(); - - const sidebar = document.getElementById('sidebar-nav'); - const mainContent = document.getElementById('main-content'); - - if (!sidebar || !mainContent) return; - - if (isMobile()) { - // On mobile, always collapse sidebar and remove padding - sidebar.classList.add('sidebar-collapsed'); - sidebar.classList.remove('sidebar-expanded'); - mainContent.classList.remove('sidebar-padding'); - document.body.classList.add('sidebar-collapsed'); - } else if (sidebarState.expanded) { - // On desktop, restore expanded state if it was expanded - sidebar.classList.remove('sidebar-collapsed'); - sidebar.classList.add('sidebar-expanded'); - mainContent.classList.add('sidebar-padding'); - document.body.classList.remove('sidebar-collapsed'); } - } - - // Auto-expand current section - function autoExpandCurrentSection() { - // Find the current page in the sidebar and expand its parent section - const currentLinks = document.querySelectorAll('#sidebar-nav .nav-link.active, #sidebar-nav .dropdown-item.active'); - - currentLinks.forEach(function(link) { - let parentSubmenu = link.closest('ul[id$="-submenu"]'); - if (parentSubmenu) { - const submenuId = parentSubmenu.id; - const toggleElement = document.querySelector(`[data-target="${submenuId}"]`); - const caret = toggleElement ? toggleElement.querySelector('.section-caret') : null; - - if (toggleElement && caret) { - parentSubmenu.style.display = 'block'; - caret.style.transform = 'rotate(90deg)'; + + function initSectionToggles() { + const toggles = document.querySelectorAll(".docs-sidebar-section-toggle"); + + toggles.forEach(function(toggle) { + const targetId = toggle.getAttribute("aria-controls"); + const target = targetId ? document.getElementById(targetId) : null; + + if (!target) { + return; + } + + toggle.addEventListener("click", function() { + const isExpanded = toggle.getAttribute("aria-expanded") === "true"; + toggle.setAttribute("aria-expanded", isExpanded ? "false" : "true"); + toggle.classList.toggle("is-collapsed", isExpanded); + target.classList.toggle("d-none", isExpanded); + }); + }); + } + + function expandActiveSection() { + const activeLink = document.querySelector(".docs-sidebar-link.active"); + + if (!activeLink) { + return; } - } - }); - } - - // Initialize sidebar functionality - function initSidebar() { - updateSidebarState(); - initCollapsibleSections(); - autoExpandCurrentSection(); - - // Handle window resize - let resizeTimer; - window.addEventListener('resize', function() { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(handleResize, 250); - }); - - // Initial responsive setup - handleResize(); - } - - // Initialize when DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initSidebar); - } else { - initSidebar(); - } - - // Expose utilities globally - window.SimpleChat = window.SimpleChat || {}; - window.SimpleChat.Sidebar = { - getSidebarState: () => ({ ...sidebarState }), - updateSidebarState, - isMobile - }; + const list = activeLink.closest(".docs-sidebar-list"); + if (!list) { + return; + } + + const toggle = document.querySelector(`[aria-controls="${list.id}"]`); + list.classList.remove("d-none"); + + if (toggle) { + toggle.setAttribute("aria-expanded", "true"); + toggle.classList.remove("is-collapsed"); + } + } + + function initSidebar() { + const { openButton, closeButton, backdrop } = getElements(); + + if (openButton) { + openButton.addEventListener("click", function() { + openSidebar(); + }); + } + + if (closeButton) { + closeButton.addEventListener("click", closeSidebar); + } + + if (backdrop) { + backdrop.addEventListener("click", closeSidebar); + } + + document.addEventListener("keydown", function(event) { + if (event.key === "Escape") { + closeSidebar(); + } + }); + + window.addEventListener("resize", syncSidebarForViewport); + + initSectionToggles(); + expandActiveSection(); + syncSidebarForViewport(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initSidebar); + } else { + initSidebar(); + } + + window.SimpleChat = window.SimpleChat || {}; + window.SimpleChat.Sidebar = { + closeSidebar, + openSidebar, + setSidebarOpen, + isDesktop + }; })(); \ No newline at end of file diff --git a/docs/explanation/features/v0.241.014/STAGING_UI_CICD.md b/docs/explanation/features/v0.241.014/STAGING_UI_CICD.md new file mode 100644 index 000000000..087d99d89 --- /dev/null +++ b/docs/explanation/features/v0.241.014/STAGING_UI_CICD.md @@ -0,0 +1,200 @@ +# Staging UI CI/CD with GitHub Actions (v0.241.014) + +## Overview + +SimpleChat now includes a repeatable staging CI/CD pattern for repository administrators who want to validate the full Azure deployment path before promoting changes to `main`. + +Implemented in version: **0.241.014** + +Updated in version: **0.241.016** for the App Service warm-up wait behavior. +Updated in version: **0.241.017** for Microsoft Playwright Workspaces provisioning and Azure-hosted browser execution. +Updated in version: **0.241.018** for CI bearer session authentication with the GitHub Actions OIDC service principal. + +This feature adds: + +- A GitHub Actions workflow that runs on the `Staging` branch. +- Azure OIDC authentication for `azd up` or `azd deploy` without storing an Azure client secret in GitHub. +- A reusable PowerShell bootstrap script for creating the CI app registration, federated credential, Azure role assignments, and GitHub Environment values. +- An authenticated Playwright smoke test that starts a chat conversation, waits for an assistant response, and cleans up the created conversation. +- A Microsoft Playwright Workspaces resource for running the staging smoke path on Azure-hosted browsers. +- A disabled-by-default `/ci-auth/session` endpoint that lets staging CI exchange a freshly minted app-only bearer token for a Flask browser session. + +The related version update is tracked in `application/single_app/config.py`. + +## Dependencies + +- Azure CLI authenticated as an administrator for bootstrap setup. +- Azure Developer CLI (`azd`). +- GitHub CLI (`gh`) authenticated with permission to manage repository environments, variables, and secrets. +- A protected GitHub Environment named `Staging`. +- A staging `azd` environment, normally named `staging`. +- A SimpleChat Enterprise App registration with the `Admin` app role assignable to the GitHub Actions service principal, or a Playwright storage state file for a dedicated staging test user/admin. + +## Architecture + +The account model uses the same GitHub OIDC service principal for deployment and staging UI automation, but keeps the browser login path tightly scoped: + +- The GitHub workflow uses an Entra app registration and service principal with a GitHub OIDC federated credential. This identity deploys Azure resources and runs `azd`. +- For CI bearer auth, the workflow mints a short-lived token for the SimpleChat Enterprise App resource (`api://`), calls `/ci-auth/session`, and receives a normal Flask session cookie for the test run. +- Browser storage state remains supported as a fallback for workflows that must test a real user session. + +The workflow file is located at: + +```text +.github/workflows/staging-azd-ui-tests.yml +``` + +The bootstrap script is located at: + +```text +deployers/Initialize-GitHubActionsStaging.ps1 +``` + +The staging smoke test is located at: + +```text +ui_tests/test_staging_chat_smoke.py +``` + +## GitHub Environment Values + +The bootstrap script writes these GitHub Environment variables: + +```text +AZURE_CLIENT_ID +AZURE_TENANT_ID +AZURE_SUBSCRIPTION_ID +AZURE_LOCATION +AZURE_ENV_NAME +DEPLOYMENT_APPNAME +SIMPLECHAT_UI_BASE_URL +SIMPLECHAT_UI_AUTH_RESOURCE +PLAYWRIGHT_SERVICE_URL +PLAYWRIGHT_WORKSPACE_NAME +PLAYWRIGHT_WORKSPACE_RESOURCE_ID +``` + +The bootstrap script writes these GitHub Environment secrets when values are available: + +```text +AZD_ENV_FILE_B64 +SIMPLECHAT_UI_STORAGE_STATE_B64 +SIMPLECHAT_UI_ADMIN_STORAGE_STATE_B64 +``` + +`AZD_ENV_FILE_B64` contains the base64-encoded `azd env get-values` output for the target environment. This keeps the workflow reusable without requiring every `azd env set` value to be represented as a separate GitHub secret. + +## Bootstrap Usage + +From the repository root or the `deployers` folder, sign in first: + +```powershell +az login +azd auth login +gh auth login +``` + +Create or refresh the staging CI/CD identity and GitHub Environment values: + +```powershell +cd deployers +.\Initialize-GitHubActionsStaging.ps1 ` + -GitHubRepository "microsoft/simplechat" ` + -GitHubEnvironment "Staging" ` + -AzdEnvironmentName "staging" ` + -AppName "simplechat" ` + -AzureLocation "eastus" ` + -UiStorageStatePath "..\ui_tests\artifacts\auth\storage_state.json" +``` + +For an admin-only smoke test session, use `-AdminUiStorageStatePath` instead of `-UiStorageStatePath`. + +By default, the bootstrap script also configures CI bearer auth for staging when it can resolve `ENTERPRISE_APP_CLIENT_ID` from the azd environment. It allows the SimpleChat `Admin` app role to be assigned to applications, assigns that role to the GitHub Actions service principal, sets `ENABLE_CI_BEARER_SESSION_AUTH=true` and `CI_BEARER_SESSION_ALLOWED_APP_IDS=` on the staging App Service, and writes `SIMPLECHAT_UI_AUTH_RESOURCE=api://` to the GitHub Environment. + +Use `-SkipCiBearerSessionAuth` if you want the workflow to require browser storage state instead. + +To assign a dedicated test user or group to the SimpleChat Enterprise App during bootstrap, provide the Enterprise App client ID and principal object ID: + +```powershell +.\Initialize-GitHubActionsStaging.ps1 ` + -GitHubRepository "microsoft/simplechat" ` + -EnterpriseAppClientId "" ` + -UiTestPrincipalObjectId "" ` + -UiTestAppRoleValue "User" +``` + +The script performs that assignment with your current Azure/Graph permissions during bootstrap. The GitHub Actions deployment identity does not need ongoing Graph write permissions for normal staging deployments. + +## UI Authentication Options + +The preferred staging CI path is CI bearer auth. The workflow acquires a fresh app-only token during each run with: + +```bash +az account get-access-token --resource "$SIMPLECHAT_UI_AUTH_RESOURCE" +``` + +That token is masked in the job logs, exported only for the current job, and used by both the Azure Playwright Workspaces smoke test and the Python smoke test to establish a browser session through `/ci-auth/session`. + +### Capturing Playwright Storage State + +A staging browser session can be captured locally with Playwright: + +```powershell +cd ui_tests +..\.venv\Scripts\python.exe -m playwright codegen "$env:SIMPLECHAT_UI_BASE_URL/chats" --save-storage artifacts\auth\storage_state.json +``` + +Sign in as the dedicated staging test user, then close the browser. Rerun the bootstrap script with `-UiStorageStatePath` to update the GitHub Environment secret. This is only needed when you want to test a real user session instead of the OIDC-backed CI service principal session. + +GitHub secrets have a size limit. If the base64-encoded storage state becomes too large, store the state in Azure Key Vault and extend the workflow to retrieve it with the OIDC identity. + +## Workflow Behavior + +On a push to `Staging`, the workflow: + +1. Authenticates to Azure using GitHub OIDC. +2. Restores the `azd` environment from `AZD_ENV_FILE_B64`. +3. Runs `azd up --no-prompt` by default. +4. Resolves or uses `SIMPLECHAT_UI_BASE_URL`. +5. Acquires a short-lived SimpleChat UI access token when `SIMPLECHAT_UI_AUTH_RESOURCE` is configured. +6. Waits up to 15 minutes for App Service warm-up. It checks `/external/healthcheckz`, `/external/healthcheck`, and the authenticated `/chats` route so normal post-deploy 503 responses during cold start do not fail the job too early. +7. Installs Playwright dependencies. +8. Restores the authenticated browser storage state when storage-state secrets are present. +9. Runs `npm run test:staging:azure` from `ui_tests/playwright-workspaces` against Microsoft Playwright Workspaces. +10. Runs `python -m pytest ui_tests/test_staging_chat_smoke.py -m ui -ra` as the existing Python smoke validation path. +11. Uploads UI test artifacts, screenshots, and traces if present. + +Manual dispatch can choose `azd deploy` for code-only validation: + +```text +azd_command: deploy +``` + +## Azure Permissions + +Because `deployers/bicep/main.bicep` is subscription-scoped and creates role assignments, a full `azd up` workflow generally requires the CI service principal to have: + +- `Contributor` +- `User Access Administrator` + +The bootstrap script assigns these at subscription scope by default so `azd up` can fully validate staging infrastructure before `main`. + +The same script also creates or updates a Microsoft Playwright Workspaces resource named `--pw` in the staging resource group, assigns the GitHub Actions service principal `Playwright Workspace Contributor` at that workspace scope, and writes `PLAYWRIGHT_SERVICE_URL` to the GitHub Environment. Playwright Workspaces are not available in every Azure region, so the script defaults the workspace region to `eastus` while keeping the resource in the staging resource group. + +For UI authentication, the service principal is assigned the SimpleChat Enterprise App `Admin` app role. The app only accepts that token through `/ci-auth/session` when `ENABLE_CI_BEARER_SESSION_AUTH=true` and the caller app ID is explicitly listed in `CI_BEARER_SESSION_ALLOWED_APP_IDS`. + +For a narrower deploy-only pattern against existing staging infrastructure, pass a resource group scope with `-RoleAssignmentScope` and run the workflow with `azd_command: deploy`. + +## Testing and Validation + +Coverage added in this version: + +- `functional_tests/test_staging_ui_cicd_workflow.py` validates that the workflow, bootstrap script, and smoke test are present and connected to OIDC, `azd`, GitHub Environment secrets, and Playwright. +- `ui_tests/test_staging_chat_smoke.py` validates a real browser chat loop against the deployed staging environment. +- `ui_tests/playwright-workspaces/staging-chat-smoke.spec.js` validates the same path through Microsoft Playwright Workspaces. + +Known limitations: + +- `gh` is required to write GitHub Environment secrets automatically. Without it, the script can still create Azure OIDC assets when run with `-SkipGitHubConfiguration`. +- Storage state expires and must be refreshed periodically when storage-state authentication is used. +- Private-network-only staging environments require a runner and browser execution path with network access to the App Service. diff --git a/docs/explanation/fixes/v0.241.009/CHAT_DOCUMENT_DROPDOWN_VIEWPORT_FIX.md b/docs/explanation/fixes/v0.241.009/CHAT_DOCUMENT_DROPDOWN_VIEWPORT_FIX.md new file mode 100644 index 000000000..6ef1aa79f --- /dev/null +++ b/docs/explanation/fixes/v0.241.009/CHAT_DOCUMENT_DROPDOWN_VIEWPORT_FIX.md @@ -0,0 +1,45 @@ +# Chat Document Dropdown Viewport Fit Fix + +Fixed in version: **0.241.009** + +## Issue Description + +The chat document selector could open downward from the grounded-search controls when the controls were close to the bottom of the browser viewport. On short desktop windows and mobile-influenced layouts, the dropdown extended below the visible screen and made document selection difficult. + +## Root Cause Analysis + +The shared chat search-filter dropdown sizing helper enforced a minimum dropdown height even when less space was available below the trigger. The document dropdown also had its own viewport adjustment after the dropdown was already shown, so placement was not chosen before Bootstrap/Popper calculated the menu position. + +## Version Implemented + +Implemented in version: **0.241.009** + +The application version was updated in `application/single_app/config.py` from `0.241.008` to `0.241.009`. + +## Technical Details + +### Files Modified + +- `application/single_app/static/js/chat/chat-documents.js` +- `application/single_app/config.py` +- `ui_tests/test_chat_document_dropdown_viewport_fit.py` + +### Code Changes Summary + +- Added viewport-aware dropdown placement for grounded-search dropdowns. +- Configured Bootstrap/Popper to prefer upward placement when there is not enough room below the trigger. +- Replaced fixed minimum dropdown heights with available-space clamping. +- Routed the document selector through the shared dropdown sizing helper instead of maintaining separate post-show sizing logic. + +### Testing Approach + +- Added a Playwright UI regression test that opens the chat document selector in a short desktop viewport and verifies the dropdown stays inside the visible browser area. +- The test also verifies that the document list remains internally scrollable when the dropdown is constrained. + +## Impact Analysis + +The fix is scoped to the chat grounded-search dropdowns for scope, tags, and documents. It does not change document selection state, backend search behavior, or chat message payloads. + +## Validation + +Before the fix, the document dropdown could extend below the browser viewport when opened near the bottom of the screen. After the fix, the dropdown selects the best available vertical direction and clamps its menu/list height to the viewport. diff --git a/docs/explanation/fixes/v0.241.009/PUBLIC_WORKSPACE_MANAGE_SCRIPT_SYNTAX_FIX.md b/docs/explanation/fixes/v0.241.009/PUBLIC_WORKSPACE_MANAGE_SCRIPT_SYNTAX_FIX.md new file mode 100644 index 000000000..d90edf6a8 --- /dev/null +++ b/docs/explanation/fixes/v0.241.009/PUBLIC_WORKSPACE_MANAGE_SCRIPT_SYNTAX_FIX.md @@ -0,0 +1,59 @@ +# Public Workspace Manage Script Syntax Fix + +Fixed/Implemented in version: **0.241.009** + +## Issue Description + +The public workspace management page could fail to load with a browser syntax error: + +```text +Uncaught SyntaxError: Unexpected token '&' (at manage_public_workspace.js:310:27) +``` + +The reported `•` line was valid JavaScript, but the parser reached it after an earlier malformed template-literal fragment in the document-ready event binding block. + +## Root Cause Analysis + +The `$(document).ready(...)` block in `application/single_app/static/js/public/manage_public_workspace.js` contained duplicated user-search event bindings and spliced fragments from the user-search result template. + +Those fragments left raw template markup and HTML table cells inside executable JavaScript, so Chromium and Node reported a syntax error before the rest of the public workspace page scripts could initialize. + +## Technical Details + +Files modified: + +- `application/single_app/static/js/public/manage_public_workspace.js` +- `application/single_app/config.py` +- `functional_tests/test_public_workspace_manage_script_syntax_fix.py` +- `ui_tests/test_public_workspace_manage_script_parse.py` +- `docs/explanation/fixes/v0.241.009/PUBLIC_WORKSPACE_MANAGE_SCRIPT_SYNTAX_FIX.md` +- `docs/explanation/release_notes.md` + +Code changes summary: + +- Removed the stray template-literal and HTML fragments from the document-ready handler block. +- Preserved the delegated `.select-user-btn` click handler added for safe member search selection. +- Restored the delegated approve and reject request handlers for `#pendingRequestsTable`. +- Kept the CSV bulk upload handlers immediately after the restored request handlers. +- Aligned regression artifacts with `VERSION = "0.241.009"` in `application/single_app/config.py`. + +Testing approach: + +- Added a functional regression that runs `node --check` when Node.js is available and verifies the document-ready block no longer contains the known spliced fragments. +- Added a Playwright regression that compiles the script with Chromium's JavaScript parser. + +Impact analysis: + +- Public workspace management pages can initialize again instead of stopping at the first syntax error. +- Member search selection, pending request approval/rejection, and CSV bulk member upload event wiring remain available. + +## Validation + +Before the fix, `manage_public_workspace.js` failed to parse and the public workspace page could not load its management scripts. After the fix, both Node.js and Chromium parser checks validate the script, and the restored event handlers are covered by targeted regression tests. + +Related config.py version update: `VERSION = "0.241.009"` + +Related tests: + +- `functional_tests/test_public_workspace_manage_script_syntax_fix.py` +- `ui_tests/test_public_workspace_manage_script_parse.py` \ No newline at end of file diff --git a/docs/explanation/fixes/v0.241.009/SQL_ODBC_DRIVER_18_CONTAINER_FIX.md b/docs/explanation/fixes/v0.241.009/SQL_ODBC_DRIVER_18_CONTAINER_FIX.md new file mode 100644 index 000000000..6cb464715 --- /dev/null +++ b/docs/explanation/fixes/v0.241.009/SQL_ODBC_DRIVER_18_CONTAINER_FIX.md @@ -0,0 +1,59 @@ +# SQL ODBC Driver 18 Container Fix - v0.241.009 + +## Issue Description + +Users creating or testing SQL actions against SQL Server or Azure SQL received errors indicating that `ODBC Driver 17 for SQL Server` could not be found. The Python application included `pyodbc`, but the Azure Linux distroless application image did not include the native Microsoft ODBC SQL Server driver and unixODBC runtime files required by `pyodbc`. + +## Root Cause Analysis + +The SQL action code imported `pyodbc` and built SQL Server connection strings, but the container only installed Python packages from `requirements.txt`. The final distroless runtime image did not copy forward Microsoft ODBC driver files, unixODBC shared libraries, or `/etc/odbcinst.ini`, so `pyodbc.connect()` could fail at runtime with a missing Driver 17 library error. + +Existing SQL action defaults also preferred `ODBC Driver 17 for SQL Server`, which meant new actions continued to select the driver users were missing. + +## Fixed in version: **0.241.009** + +The version was updated in `application/single_app/config.py` from `0.241.008` to `0.241.009` for this fix. + +## Technical Details + +### Files Modified + +| File | Change | +|------|--------| +| `application/single_app/Dockerfile` | Registers the Microsoft Azure Linux `ms-non-oss` package feed, installs `msodbcsql18` and `unixODBC` in the builder stage, then copies `/opt/microsoft`, `/etc/odbcinst.ini`, unixODBC shared libraries, and ODBC library paths into the distroless runtime. | +| `application/single_app/semantic_kernel_plugins/sql_odbc_utils.py` | Adds shared Driver 18 constants, SQL Server connection string construction, and Driver 17-to-18 retry logic for missing-driver errors. | +| `application/single_app/semantic_kernel_plugins/sql_schema_plugin.py` | Uses Driver 18 as the default SQL Server ODBC driver and applies legacy Driver 17 fallback. | +| `application/single_app/semantic_kernel_plugins/sql_query_plugin.py` | Uses Driver 18 as the default SQL Server ODBC driver and applies legacy Driver 17 fallback. | +| `application/single_app/semantic_kernel_plugins/sql_plugin_factory.py` | Generates SQL Server and Azure SQL plugin configuration with Driver 18 defaults. | +| `application/single_app/route_backend_plugins.py` | Uses Driver 18 defaults and fallback logic in the SQL action Test Connection endpoint. | +| `application/single_app/templates/_plugin_modal.html` | Presents Driver 18 before Driver 17 in the SQL action driver selector. | +| `application/single_app/static/js/plugin_modal_stepper.js` | Defaults new SQL actions and examples to Driver 18. | +| `application/single_app/semantic_kernel_plugins/SQL_Plugins_Configuration_Guide.md` | Updates SQL Server connection string examples to Driver 18. | +| `functional_tests/test_sql_odbc_driver_18_support.py` | Adds regression coverage for container ODBC runtime installation, Driver 18 defaults, legacy Driver 17 fallback, and version consistency. | + +### Code Changes Summary + +- The application image now registers the Microsoft Azure Linux `ms-non-oss` package feed, installs the Microsoft ODBC Driver 18 package using `ACCEPT_EULA=Y`, and includes the native ODBC files needed by the final distroless runtime. +- New SQL Server and Azure SQL action defaults use `ODBC Driver 18 for SQL Server`. +- Saved actions or connection strings that still reference `ODBC Driver 17 for SQL Server` are retried with Driver 18 when the failure is specifically a missing-driver error. +- Non-driver errors, such as login failures, are not retried or masked by the fallback logic. + +## Testing Approach + +- Functional test: `functional_tests/test_sql_odbc_driver_18_support.py` +- Validates Dockerfile ODBC runtime installation and copy-forward instructions. +- Validates generated SQL Server connection strings default to Driver 18. +- Validates saved Driver 17 connection strings retry with Driver 18 when Driver 17 is unavailable. +- Validates non-driver errors are not hidden by fallback behavior. +- Validates `config.py` version consistency for v0.241.009. + +## Impact Analysis + +- **SQL Server and Azure SQL actions**: New actions now target Driver 18 by default, and existing Driver 17 actions can recover automatically when the missing-driver error occurs. +- **Container deployments**: Azure Linux distroless runtime images include the native ODBC driver and registration files needed by `pyodbc`. +- **Other database actions**: PostgreSQL, MySQL, and SQLite paths are unchanged. +- **Security**: The fallback logs driver names and exception type only; it does not log connection strings, usernames, or passwords. + +## Validation + +Before the fix, SQL actions could fail with missing native ODBC Driver 17 errors despite `pyodbc` being installed. After the fix, the runtime image includes Driver 18, new action defaults point to Driver 18, and legacy Driver 17 references retry with Driver 18 only when the legacy driver is unavailable. \ No newline at end of file diff --git a/docs/explanation/fixes/v0.241.010/ENTRA_APPLICATION_GRAPH_MFA_AUTH_FIX.md b/docs/explanation/fixes/v0.241.010/ENTRA_APPLICATION_GRAPH_MFA_AUTH_FIX.md new file mode 100644 index 000000000..c86eaed97 --- /dev/null +++ b/docs/explanation/fixes/v0.241.010/ENTRA_APPLICATION_GRAPH_MFA_AUTH_FIX.md @@ -0,0 +1,34 @@ +# Entra Application Graph MFA Auth Fix + +Fixed/Implemented in version: **0.241.010** + +Related config.py update: `VERSION = "0.241.010"` + +## Issue Description + +`Initialize-EntraApplication.ps1` could fail when Azure CLI was signed in for normal Azure Resource Manager operations but the cached account still needed a Microsoft Graph MFA or conditional-access challenge before `az ad` commands could run. + +## Root Cause Analysis + +The script only verified `az account show`, which proves the Azure CLI account is present but does not prove the account can acquire a Microsoft Graph token. When `az ad app list` hit `AADSTS50076` or another interaction-required Graph challenge, the failed output was piped into `ConvertFrom-Json`; the script then continued as if no app registration existed and failed later with a generic create error. + +## Technical Details + +### Files Modified + +- `deployers/Initialize-EntraApplication.ps1` +- `application/single_app/config.py` +- `functional_tests/test_entra_application_graph_mfa_auth.py` + +### Code Changes Summary + +- Added a Microsoft Graph token preflight with `az account get-access-token` after tenant and cloud detection. +- Added interaction-required detection for MFA, invalid grant, and claims-challenge Azure CLI responses. +- Added interactive recovery that runs `az login --tenant ... --scope ...` only when Graph access requires it, preserving existing non-MFA cached-token runs. +- Wrapped app registration and service principal JSON reads in explicit Azure CLI exit-code checks so command failures are not treated as empty JSON. + +## Validation + +- Functional test: `functional_tests/test_entra_application_graph_mfa_auth.py` +- PowerShell parser validation for `deployers/Initialize-EntraApplication.ps1` +- Expected outcome: MFA-gated tenants get a Graph-scoped interactive login prompt before app registration work begins, while non-MFA tenants continue through the existing authenticated Azure CLI workflow without an extra login. \ No newline at end of file diff --git a/docs/explanation/fixes/v0.241.011/ENTRA_APPLICATION_AZD_ENV_PERSISTENCE_FIX.md b/docs/explanation/fixes/v0.241.011/ENTRA_APPLICATION_AZD_ENV_PERSISTENCE_FIX.md new file mode 100644 index 000000000..c7d039446 --- /dev/null +++ b/docs/explanation/fixes/v0.241.011/ENTRA_APPLICATION_AZD_ENV_PERSISTENCE_FIX.md @@ -0,0 +1,39 @@ +# Entra Application AZD Environment Persistence Fix + +Fixed/Implemented in version: **0.241.011** + +Related config.py update: `VERSION = "0.241.011"` + +## Issue Description + +After running `Initialize-EntraApplication.ps1`, users had to copy the app registration client ID, client secret, and service principal ID into a separate text file and paste them back into `azd up` prompts later. + +## Root Cause Analysis + +The Entra registration script created the values needed by `deployers/bicep/main.parameters.json`, but it only printed them to the terminal. It did not persist those values through `azd env set`, so the AZD environment could not supply `ENTERPRISE_APP_CLIENT_ID`, `ENTERPRISE_APP_CLIENT_SECRET`, or `ENTERPRISE_APP_SERVICE_PRINCIPAL_ID` automatically during provisioning. + +## Technical Details + +### Files Modified + +- `deployers/Initialize-EntraApplication.ps1` +- `application/single_app/config.py` +- `README.md` +- `deployers/bicep/README.md` +- `functional_tests/test_entra_application_graph_mfa_auth.py` +- `functional_tests/test_entra_application_azd_env_persistence.py` + +### Code Changes Summary + +- Added `-AzdEnvironmentName` to explicitly choose the target AZD environment. +- Added `-SkipAzdEnvironmentUpdate` for standalone/manual app registration workflows. +- Added AZD environment resolution using `AZURE_ENV_NAME`, `.azure/config.json` `defaultEnvironment`, and then the deployment `Environment` value. +- Persisted app registration outputs with `azd env set --environment ...` after the client secret is generated. +- Kept persistence failures non-fatal so successful app registration creation still completes and prints manual recovery guidance. + +## Validation + +- Functional test: `functional_tests/test_entra_application_azd_env_persistence.py` +- Functional test: `functional_tests/test_entra_application_graph_mfa_auth.py` +- PowerShell parser validation for `deployers/Initialize-EntraApplication.ps1` +- Expected outcome: once the registration script completes, `azd up` can read the saved app registration values from the selected AZD environment and no longer prompts for those fields. \ No newline at end of file diff --git a/docs/explanation/fixes/v0.241.012/SQL_ODBC_DRIVER_REGISTRATION_FILE_FIX.md b/docs/explanation/fixes/v0.241.012/SQL_ODBC_DRIVER_REGISTRATION_FILE_FIX.md new file mode 100644 index 000000000..cb95dbd28 --- /dev/null +++ b/docs/explanation/fixes/v0.241.012/SQL_ODBC_DRIVER_REGISTRATION_FILE_FIX.md @@ -0,0 +1,46 @@ +# SQL ODBC Driver Registration File Fix + +Fixed/Implemented in version: **0.241.012** + +Related config.py update: `VERSION = "0.241.012"` + +## Issue Description + +`azd up` could fail during the Windows `predeploy` ACR build after installing SQL Server ODBC Driver 18. The Docker build reached the distroless runtime stage and then failed with missing ODBC runtime copy sources, including: + +```text +COPY failed: stat etc/odbcinst.ini: file does not exist +COPY failed: no source files were specified +``` + +## Root Cause Analysis + +The Dockerfile assumed the Azure Linux `msodbcsql18` and `unixODBC` package installation would always create `/etc/odbcinst.ini` in the builder stage and place unixODBC libraries under `/usr/lib64`. In the ACR remote build, `/etc/odbcinst.ini` was absent and the unixODBC libraries were not available through the hardcoded `/usr/lib64/libodbc*` copy pattern, so Docker failed before the image could be published. + +## Technical Details + +### Files Modified + +- `application/single_app/Dockerfile` +- `application/single_app/config.py` +- `functional_tests/test_sql_odbc_driver_18_support.py` +- `functional_tests/test_entra_application_graph_mfa_auth.py` +- `functional_tests/test_entra_application_azd_env_persistence.py` + +### Code Changes Summary + +- After installing `msodbcsql18`, the Docker builder stage now resolves the installed `libmsodbcsql-*.so*` path under `/opt/microsoft/msodbcsql18/lib64`. +- The builder writes a deterministic `/etc/odbcinst.ini` registration for `ODBC Driver 18 for SQL Server`. +- The builder stages unixODBC and libltdl shared libraries from either `/usr/lib64` or `/usr/lib` into `/odbc-runtime/usr/lib64`. +- The distroless runtime can now copy the registration file and staged unixODBC libraries reliably while preserving the existing Driver 18 runtime support. + +## Validation + +- ACR log review for failed run `cp1` confirmed the missing `/etc/odbcinst.ini` source file. +- ACR log review for failed run `cp2` confirmed the hardcoded `/usr/lib64/libodbc*` copy pattern had no sources. +- ACR verification run `cp3` succeeded with the staged ODBC runtime files. +- `azd deploy` completed successfully after the Dockerfile fix. +- Functional test: `functional_tests/test_sql_odbc_driver_18_support.py` +- Functional test: `functional_tests/test_entra_application_graph_mfa_auth.py` +- Functional test: `functional_tests/test_entra_application_azd_env_persistence.py` +- Expected outcome: the ACR Docker build no longer fails while copying ODBC Driver 18 registration or unixODBC runtime files into the distroless image. \ No newline at end of file diff --git a/docs/explanation/fixes/v0.241.016/CHAT_CONVERSATION_INFO_ICON_BUTTON_FIX.md b/docs/explanation/fixes/v0.241.016/CHAT_CONVERSATION_INFO_ICON_BUTTON_FIX.md new file mode 100644 index 000000000..d4bd386b8 --- /dev/null +++ b/docs/explanation/fixes/v0.241.016/CHAT_CONVERSATION_INFO_ICON_BUTTON_FIX.md @@ -0,0 +1,43 @@ +# Chat Conversation Info Icon Button Fix - Version 0.241.016 + +Fixed in version: **0.241.016** + +## Issue Description + +The conversation details button in the chat header displayed as a dark outlined circular button. This made the info affordance stand out more than intended. + +## Root Cause Analysis + +The button used Bootstrap's `btn-outline-secondary` class together with the chat icon button shape, creating a visible bordered circle around the `bi-info-circle` icon. + +## Technical Details + +### Files Modified + +- `application/single_app/templates/chats.html` +- `ui_tests/test_chat_sidebar_toggle_controls.py` +- `application/single_app/config.py` + +### Code Changes Summary + +- Removed `btn-outline-secondary` from `#conversation-info-btn`. +- Added transparent background, no-border, and no-shadow states for the info button. +- Preserved hover color and keyboard focus visibility. +- Updated UI test coverage to prevent the outline class from returning. +- Updated `config.py` version to `0.241.016`. + +## Testing Approach + +- Python syntax checks for edited Python files. +- Jinja template parsing for the edited chat template. +- Focused UI test collection for chat sidebar/header coverage. + +## Impact Analysis + +The chat header now shows only the info icon for conversation details while retaining tooltip, click behavior, and accessible keyboard focus. + +## Validation + +Before: the info button showed a visually prominent outlined circular border. + +After: the info button renders as a quiet icon-only action with no persistent border or background. diff --git a/docs/explanation/fixes/v0.241.017/COMPACT_SIDEBAR_TOGGLE_ICON_ONLY_FIX.md b/docs/explanation/fixes/v0.241.017/COMPACT_SIDEBAR_TOGGLE_ICON_ONLY_FIX.md new file mode 100644 index 000000000..06859f23d --- /dev/null +++ b/docs/explanation/fixes/v0.241.017/COMPACT_SIDEBAR_TOGGLE_ICON_ONLY_FIX.md @@ -0,0 +1,46 @@ +# Compact Sidebar Toggle Icon-Only Fix - Version 0.241.017 + +Fixed in version: **0.241.017** + +## Issue Description + +The compact sidebar collapse control still showed button edging, making it look like a small outlined button instead of a quiet icon-only affordance. + +## Root Cause Analysis + +The compact control reused Bootstrap's `btn-outline-secondary` class and inherited shared sidebar toggle border styling intended for the large button variant. + +## Technical Details + +### Files Modified + +- `application/single_app/templates/_sidebar_nav.html` +- `application/single_app/templates/_sidebar_short_nav.html` +- `application/single_app/static/css/sidebar.css` +- `ui_tests/test_chat_sidebar_toggle_controls.py` +- `application/single_app/config.py` + +### Code Changes Summary + +- Removed `btn-outline-secondary` from compact sidebar toggle buttons. +- Added compact-specific transparent background, no-border, and no-shadow styles. +- Preserved accessible keyboard focus using an outline only on focus-visible. +- Kept the large sidebar toggle border styling unchanged. +- Updated UI coverage to assert the compact toggle does not use the outline button class. +- Updated `config.py` version to `0.241.017`. + +## Testing Approach + +- Python syntax checks for edited Python files. +- Jinja parsing for edited sidebar templates. +- Focused UI test collection for chat sidebar toggle coverage. + +## Impact Analysis + +Users who choose the compact sidebar toggle now see only the layout-sidebar icon. Users who prefer the large toggle keep the bordered full-width button. + +## Validation + +Before: compact sidebar toggle rendered with a visible outline/button edge. + +After: compact sidebar toggle renders as an icon-only control with no persistent border, background, or shadow. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index db17633db..e37698de9 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -1,9 +1,88 @@ -This page tracks notable Simple Chat releases and organizes the detailed change log by version. The timeline below provides a quick visual overview of the current release progression through v0.240.002, and the per-version entries continue immediately after it. +This page tracks notable Simple Chat releases and organizes the detailed change log by version. The timeline below provides a quick visual overview of the current release progression through v0.241.008, and the per-version entries continue immediately after it. For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.241.008)** + +#### New Features + +* **Staging Branch UI Test CI/CD** + * Added a protected GitHub Actions workflow for the `Staging` branch that deploys the Azure Developer CLI staging environment, waits for slow App Service warm-up, and runs live UI smoke coverage before the environment is considered healthy. + * The workflow authenticates to Azure through GitHub OIDC and a staging environment-scoped service principal, avoiding stored Azure client secrets while keeping staging deployment and test configuration admin-controlled. + * Added a reusable staging bootstrap script that creates or updates the GitHub OIDC app, federated credential, Azure role assignments, GitHub Environment variables, App Service CI authentication settings, and Microsoft Playwright Workspace wiring. + * (Ref: `.github/workflows/staging-azd-ui-tests.yml`, `deployers/Initialize-GitHubActionsStaging.ps1`, `docs/explanation/features/v0.241.014/STAGING_UI_CICD.md`, `test_staging_ui_cicd_workflow.py`) + +* **Microsoft Playwright Workspaces Staging Runner** + * Added Azure-hosted Playwright Workspace support for the staging smoke test flow, including a Node-based Playwright runner that uses the Microsoft Playwright service URL and Azure identity credentials in CI. + * Added matching Python and Node smoke tests that open the staging chat experience, create a conversation, submit a prompt, wait for an assistant response, and preserve screenshots or traces when the run fails. + * (Ref: `ui_tests/playwright-workspaces/`, `ui_tests/test_staging_chat_smoke.py`, `PLAYWRIGHT_SERVICE_URL`, staging UI smoke tests) + +* **Service Principal Authentication for CI UI Tests** + * Added a disabled-by-default `/ci-auth/session` endpoint that lets staging UI tests create a Flask session from a fresh Entra access token minted by the GitHub OIDC service principal. + * The CI session path validates token audience, allowed client IDs, and required app roles before setting session state, so normal staging UI test runs no longer need recurring browser storage-state secrets. + * Updated the staging test runners to prefer fresh bearer-session authentication while keeping storage-state input as an optional fallback for manual or transitional runs. + * (Ref: `functions_authentication.py`, `route_frontend_authentication.py`, `config.py`, `appRegistrationRoles.json`, `SIMPLECHAT_UI_AUTH_RESOURCE`, `ENABLE_CI_BEARER_SESSION_AUTH`) + +* **Profile Sidebar Toggle Style Preference** + * Added a profile navigation preference so users can choose between the large sidebar hide control and a compact icon-only control that sits next to the sidebar logo or title. + * Saved the preference in user settings and applied it across full and compact sidebar templates on subsequent page loads. + * Added UI coverage for saving the compact preference and verifying the chat sidebar renders the selected control style. + * (Ref: `profile.html`, `_sidebar_nav.html`, `_sidebar_short_nav.html`, `sidebar.css`, `route_backend_users.py`, `test_profile_sidebar_toggle_style_preference.py`) + +#### User Interface Enhancements + +* **GitHub Pages Documentation Redesign** + * Redesigned the GitHub Pages documentation shell with a fixed top navigation, curated sidebar sections, responsive mobile drawer, documentation search, and right-side "On this page" rail. + * Updated the docs landing page to surface guide, API, examples, changelog, feature, and deployment entry points more directly. + * Replaced legacy inline sidebar behavior with shared JavaScript modules that use DOM APIs, ARIA state, and safer search/result rendering. + * (Ref: `docs/_layouts/default.html`, `docs/_includes/sidebar_nav.html`, `docs/_layouts/page.html`, `docs/_layouts/showcase-page.html`, `docs/assets/css/main.scss`, `docs/assets/js/main.js`, `docs/assets/js/sidebar.js`, `docs/index.md`, `ui_tests/test_docs_showcase_pages.py`) + +* **Chat and Sidebar Icon-Only Controls** + * Refined the chat conversation info button and compact sidebar toggle so they render as quiet icon-only actions without persistent Bootstrap outline button edging. + * Preserved accessible focus states and kept the existing large sidebar toggle styling intact for users who prefer the larger control. + * Added UI coverage to prevent the outlined button class from returning on the compact and conversation-info controls. + * (Ref: `chats.html`, `_sidebar_nav.html`, `_sidebar_short_nav.html`, `sidebar.css`, `test_chat_sidebar_toggle_controls.py`, `CHAT_CONVERSATION_INFO_ICON_BUTTON_FIX.md`, `COMPACT_SIDEBAR_TOGGLE_ICON_ONLY_FIX.md`) + +#### Bug Fixes + +* **SQL ODBC Driver 18 Container Support** + * Fixed SQL Server and Azure SQL actions failing in container deployments with missing `ODBC Driver 17 for SQL Server` errors even though `pyodbc` was installed. + * The Azure Linux application image now installs Microsoft ODBC Driver 18 from the Microsoft `ms-non-oss` feed and copies the native driver registration and unixODBC libraries into the distroless runtime image. + * New SQL actions now default to `ODBC Driver 18 for SQL Server`, and saved Driver 17 connection strings retry with Driver 18 only when the failure is a missing-driver error. + * (Ref: `Dockerfile`, `sql_odbc_utils.py`, `sql_schema_plugin.py`, `sql_query_plugin.py`, `route_backend_plugins.py`, `test_sql_odbc_driver_18_support.py`, `SQL_ODBC_DRIVER_18_CONTAINER_FIX.md`) + +* **SQL ODBC Driver Registration File Fix** + * Fixed ACR remote builds failing when the ODBC builder stage did not produce `/etc/odbcinst.ini` or unixODBC libraries at the hardcoded copy paths. + * The Docker build now writes a deterministic Driver 18 registration file and stages unixODBC/libltdl runtime libraries from the available Azure Linux library paths before copying them into the distroless runtime image. + * Added focused regression coverage for the Dockerfile registration and runtime-copy behavior. + * (Ref: `Dockerfile`, `test_sql_odbc_driver_18_support.py`, `SQL_ODBC_DRIVER_REGISTRATION_FILE_FIX.md`) + +* **Entra Application Graph MFA Auth Fix** + * Fixed `Initialize-EntraApplication.ps1` failing in tenants where Azure CLI could read ARM account state but still needed a Microsoft Graph MFA or conditional-access challenge before running `az ad` commands. + * The script now preflights Microsoft Graph token access, detects interaction-required Graph failures, and launches a scoped interactive login only when the cached session needs it. + * Added explicit Azure CLI exit-code checks so failed Graph responses are not treated as empty app-registration JSON. + * (Ref: `Initialize-EntraApplication.ps1`, `test_entra_application_graph_mfa_auth.py`, `ENTRA_APPLICATION_GRAPH_MFA_AUTH_FIX.md`) + +* **Entra Application AZD Environment Persistence Fix** + * Fixed the Entra registration setup flow so app registration outputs are saved into the selected AZD environment instead of requiring users to manually copy client IDs, service principal IDs, and client secrets after script execution. + * Added `-AzdEnvironmentName` and `-SkipAzdEnvironmentUpdate` controls, automatic AZD environment resolution, and non-fatal manual recovery guidance when persistence cannot be completed. + * Updated setup documentation to call out Python 3.12 as an AZD hook prerequisite and explain where the script saves the app registration values. + * (Ref: `Initialize-EntraApplication.ps1`, `README.md`, `deployers/bicep/README.md`, `test_entra_application_azd_env_persistence.py`, `ENTRA_APPLICATION_AZD_ENV_PERSISTENCE_FIX.md`) + +* **Public Workspace Manage Script Syntax Fix** + * Fixed a public workspace management page load failure caused by stray user-search template fragments being spliced into the document-ready event binding block. + * Restored the pending request approve/reject handlers while preserving the safe delegated member-search selection handler, so the script parses and the manage page can initialize again. + * Added focused Node.js and Chromium parser regression coverage plus versioned fix documentation for the repaired script block. + * (Ref: `manage_public_workspace.js`, `test_public_workspace_manage_script_syntax_fix.py`, `test_public_workspace_manage_script_parse.py`, `PUBLIC_WORKSPACE_MANAGE_SCRIPT_SYNTAX_FIX.md`) + +* **Chat Document Dropdown Viewport Fit Fix** + * Fixed the chat document selector so it no longer opens downward off-screen when grounded-search controls sit near the bottom of a short or mobile-influenced viewport. + * The grounded-search dropdowns now choose viewport-aware placement, clamp their menu height to visible space, and keep long document lists scrollable inside the menu. + * Added focused Playwright regression coverage plus versioned fix documentation for the dropdown positioning behavior. + * (Ref: `chat-documents.js`, `test_chat_document_dropdown_viewport_fit.py`, `CHAT_DOCUMENT_DROPDOWN_VIEWPORT_FIX.md`) + ### **(v0.241.007)** ## New Feature diff --git a/docs/index.md b/docs/index.md index 5994021db..b73a4ab59 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,6 +5,7 @@ description: "Azure-native documentation for deploying, operating, and extending section: "Overview" accent: blue eyebrow: "Docs Overview" +home_search: true hero_icons: - bi-house-fill - bi-stars @@ -14,11 +15,11 @@ hero_pills: - Retrieval-Augmented Generation - Enterprise controls hero_links: - - label: "Start with deployment" - url: /setup_instructions/ + - label: "Start the guide" + url: /tutorials/ style: primary - - label: "Explore features" - url: /features/ + - label: "Open API reference" + url: /reference/api_reference/ style: outline --- @@ -28,10 +29,10 @@ Simple Chat gives teams an Azure-native way to deploy, ground, govern, and exten
Start here
-

Four pages that cover the full path

-

Use these entry points when you want to get from deployment decisions to daily usage without hunting through the entire docs tree.

+

The shortest paths through the docs

+

Use these entry points when you want guidance, API details, examples, and release history without hunting through the entire docs tree.

- Core docs + Top routes
@@ -49,6 +50,20 @@ Simple Chat gives teams an Azure-native way to deploy, ground, govern, and exten
+
+
+
+ + Guide +
+

Tutorials

+

Follow guided product paths for first deployment, document upload, agents, and classification.

+ +
+
+
@@ -66,13 +81,13 @@ Simple Chat gives teams an Azure-native way to deploy, ground, govern, and exten
- - Troubleshooting + + Reference
-

FAQ

-

Jump straight to the issues teams hit most often around networking, auth, uploads, search, and model configuration.

+

API Reference

+

Find live Swagger endpoints, OpenAPI helper routes, and the right source of truth for route review.

@@ -80,13 +95,27 @@ Simple Chat gives teams an Azure-native way to deploy, ground, govern, and exten
- - Current release + + Examples +
+

Scenario Library

+

Browse workspace and agent scenarios that show how Simple Chat can be shaped for practical team workflows.

+ +
+
+ + diff --git a/docs/reference/deploy/azd-cli_deploy.md b/docs/reference/deploy/azd-cli_deploy.md index b6021a4ad..f7438527b 100644 --- a/docs/reference/deploy/azd-cli_deploy.md +++ b/docs/reference/deploy/azd-cli_deploy.md @@ -75,6 +75,7 @@ This is the primary recommended deployment path for the repo. ### Required Software - **Azure Developer CLI** ([install guide](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd)) +- **Python 3.12** ([download](https://www.python.org/downloads/)) with `python` available on Windows and `python3` available on Linux/macOS. The `deployers/azure.yaml` `preprovision` and `postprovision` hooks call Python for prerequisite validation, dependency installation, and post-provision configuration. - **Git** for repository cloning - **Azure CLI** (usually installed with azd) diff --git a/docs/setup_instructions.md b/docs/setup_instructions.md index 397390a47..c115c5911 100644 --- a/docs/setup_instructions.md +++ b/docs/setup_instructions.md @@ -187,7 +187,7 @@ If you want the most current, least ambiguous deployment path, start with Azure Tooling

Install the local toolchain

-

At minimum, line up Azure CLI, Azure Developer CLI, PowerShell 7, and Visual Studio Code before starting the primary flow.

+

At minimum, line up Azure CLI, Azure Developer CLI, Python 3.12, PowerShell 7, and Visual Studio Code before starting the primary flow.

@@ -204,6 +204,11 @@ If you want the most current, least ambiguous deployment path, start with Azure +
+

Python is part of the AZD toolchain

+

The repo's AZD workflow runs Python during the preprovision and postprovision hooks defined in deployers/azure.yaml. Install Python 3.12 and confirm python works on Windows or python3 works on Linux/macOS before running azd up.

+
+
diff --git a/functional_tests/test_entra_application_azd_env_persistence.py b/functional_tests/test_entra_application_azd_env_persistence.py new file mode 100644 index 000000000..65dcda9d7 --- /dev/null +++ b/functional_tests/test_entra_application_azd_env_persistence.py @@ -0,0 +1,88 @@ +# test_entra_application_azd_env_persistence.py +#!/usr/bin/env python3 +""" +Functional test for Entra application azd environment persistence. +Version: 0.241.018 +Implemented in: 0.241.011 + +This test ensures that Initialize-EntraApplication.ps1 can persist app +registration values into the active azd environment so azd up does not prompt +for values that were already created by the registration workflow. +""" + +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = REPO_ROOT / "deployers" / "Initialize-EntraApplication.ps1" +PARAMETERS_PATH = REPO_ROOT / "deployers" / "bicep" / "main.parameters.json" +CONFIG_PATH = REPO_ROOT / "application" / "single_app" / "config.py" +FIX_DOC_PATH = ( + REPO_ROOT + / "docs" + / "explanation" + / "fixes" + / "v0.241.011" + / "ENTRA_APPLICATION_AZD_ENV_PERSISTENCE_FIX.md" +) + + +def require_contains(content: str, expected: str, description: str) -> None: + if expected not in content: + raise AssertionError(f"Missing {description}: {expected}") + + +def test_entra_application_azd_env_persistence() -> bool: + print("Testing Entra application azd environment persistence") + print("=" * 70) + + script_content = SCRIPT_PATH.read_text(encoding="utf-8") + parameters_content = PARAMETERS_PATH.read_text(encoding="utf-8") + config_content = CONFIG_PATH.read_text(encoding="utf-8") + fix_doc_content = FIX_DOC_PATH.read_text(encoding="utf-8") + + require_contains(script_content, "[string]$AzdEnvironmentName", "azd environment override parameter") + require_contains(script_content, "[switch]$SkipAzdEnvironmentUpdate", "azd persistence skip switch") + require_contains(script_content, "function Resolve-AzdEnvironmentName", "azd environment resolver") + require_contains(script_content, "AZURE_ENV_NAME", "AZURE_ENV_NAME environment fallback") + require_contains(script_content, ".azure\\config.json", "azd default environment fallback") + require_contains(script_content, "function Save-EntraRegistrationToAzdEnvironment", "azd persistence helper") + require_contains(script_content, "azd env set --environment $EnvironmentName", "azd env set command") + require_contains(script_content, "ENTERPRISE_APP_CLIENT_ID", "client id azd variable") + require_contains(script_content, "ENTERPRISE_APP_SERVICE_PRINCIPAL_ID", "service principal id azd variable") + require_contains(script_content, "ENTERPRISE_APP_CLIENT_SECRET", "client secret azd variable") + require_contains(script_content, "Saving app registration values to azd environment", "save progress message") + + require_contains(parameters_content, '"value": "${ENTERPRISE_APP_CLIENT_ID}"', "client id parameter mapping") + require_contains( + parameters_content, + '"value": "${ENTERPRISE_APP_SERVICE_PRINCIPAL_ID}"', + "service principal parameter mapping", + ) + require_contains( + parameters_content, + '"value": "${ENTERPRISE_APP_CLIENT_SECRET}"', + "client secret parameter mapping", + ) + require_contains(config_content, 'VERSION = "0.241.018"', "config version bump") + require_contains( + fix_doc_content, + "Fixed/Implemented in version: **0.241.011**", + "versioned fix documentation", + ) + + print("azd environment override and skip controls are present") + print("Entra outputs are saved using azd env set") + print("Bicep parameters already consume the saved azd values") + return True + + +if __name__ == "__main__": + try: + success = test_entra_application_azd_env_persistence() + except Exception as exc: + print(f"Test failed: {exc}") + raise + + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/functional_tests/test_entra_application_graph_mfa_auth.py b/functional_tests/test_entra_application_graph_mfa_auth.py new file mode 100644 index 000000000..e90dd539f --- /dev/null +++ b/functional_tests/test_entra_application_graph_mfa_auth.py @@ -0,0 +1,112 @@ +# test_entra_application_graph_mfa_auth.py +#!/usr/bin/env python3 +""" +Functional test for Entra application Microsoft Graph MFA authentication handling. +Version: 0.241.018 +Implemented in: 0.241.010 + +This test ensures that Initialize-EntraApplication.ps1 validates Microsoft Graph +Azure CLI access before app registration operations, recovers from interactive +MFA challenges, and preserves the existing non-MFA cached-token workflow. +""" + +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = REPO_ROOT / "deployers" / "Initialize-EntraApplication.ps1" +CONFIG_PATH = REPO_ROOT / "application" / "single_app" / "config.py" +FIX_DOC_PATH = ( + REPO_ROOT + / "docs" + / "explanation" + / "fixes" + / "v0.241.010" + / "ENTRA_APPLICATION_GRAPH_MFA_AUTH_FIX.md" +) + + +def require_contains(content: str, expected: str, description: str) -> None: + if expected not in content: + raise AssertionError(f"Missing {description}: {expected}") + + +def require_not_contains(content: str, unexpected: str, description: str) -> None: + if unexpected in content: + raise AssertionError(f"Unexpected {description}: {unexpected}") + + +def test_entra_application_graph_mfa_auth_flow() -> bool: + print("Testing Entra application Microsoft Graph MFA authentication handling") + print("=" * 80) + + script_content = SCRIPT_PATH.read_text(encoding="utf-8") + config_content = CONFIG_PATH.read_text(encoding="utf-8") + fix_doc_content = FIX_DOC_PATH.read_text(encoding="utf-8") + + require_contains( + script_content, + "function Test-AzureCliInteractionRequiredMessage", + "interaction-required detector", + ) + require_contains(script_content, "AADSTS50076", "MFA challenge detection") + require_contains(script_content, "invalid_grant", "invalid grant detection") + require_contains( + script_content, + "function Ensure-AzureCliGraphAuthenticated", + "Microsoft Graph Azure CLI preflight helper", + ) + require_contains( + script_content, + "az account get-access-token `\n --tenant $TenantId `\n --resource $GraphResource", + "non-interactive Graph token preflight", + ) + require_contains( + script_content, + "az login --tenant $TenantId --scope $GraphScope", + "interactive Graph-scoped login recovery", + ) + require_contains( + script_content, + "Ensure-AzureCliGraphAuthenticated -TenantId $tenantId -GraphUrl $graphUrl", + "preflight invocation before Graph app operations", + ) + require_contains( + script_content, + "function Invoke-AzureCliJson", + "checked Azure CLI JSON wrapper", + ) + require_contains( + script_content, + "Check app registration '$appRegistrationName'", + "checked app registration lookup", + ) + require_not_contains( + script_content, + "az ad app list --display-name $appRegistrationName --output json | ConvertFrom-Json", + "unchecked app registration lookup pipeline", + ) + + require_contains(config_content, 'VERSION = "0.241.018"', "config version bump") + require_contains( + fix_doc_content, + "Fixed/Implemented in version: **0.241.010**", + "versioned fix documentation", + ) + + print("Graph token preflight is present") + print("Interactive MFA recovery uses a Graph-scoped Azure CLI login") + print("Existing non-MFA cached-token runs can continue without re-login") + print("App registration lookup no longer treats Azure CLI errors as empty JSON") + return True + + +if __name__ == "__main__": + try: + success = test_entra_application_graph_mfa_auth_flow() + except Exception as exc: + print(f"Test failed: {exc}") + raise + + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/functional_tests/test_public_workspace_manage_script_syntax_fix.py b/functional_tests/test_public_workspace_manage_script_syntax_fix.py new file mode 100644 index 000000000..cca67285b --- /dev/null +++ b/functional_tests/test_public_workspace_manage_script_syntax_fix.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# test_public_workspace_manage_script_syntax_fix.py +""" +Functional test for public workspace manage script syntax recovery. +Version: 0.241.009 +Implemented in: 0.241.009 + +This test ensures the public workspace management script parses cleanly and +that the document-ready handler block no longer contains spliced template +fragments that prevent the page from loading. +""" + +import shutil +import subprocess +import sys +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] +MANAGE_PUBLIC_WORKSPACE_JS = ( + ROOT_DIR + / "application" + / "single_app" + / "static" + / "js" + / "public" + / "manage_public_workspace.js" +) +CONFIG_FILE = ROOT_DIR / "application" / "single_app" / "config.py" +FIX_DOC = ( + ROOT_DIR + / "docs" + / "explanation" + / "fixes" + / "v0.241.009" + / "PUBLIC_WORKSPACE_MANAGE_SCRIPT_SYNTAX_FIX.md" +) +UI_TEST = ROOT_DIR / "ui_tests" / "test_public_workspace_manage_script_parse.py" + + +def read_file_text(file_path): + """Read a UTF-8 text file.""" + return file_path.read_text(encoding="utf-8") + + +def read_config_version(): + """Read the application version from config.py.""" + for line in read_file_text(CONFIG_FILE).splitlines(): + if line.startswith("VERSION = "): + return line.split("=", 1)[1].strip().strip('"') + raise AssertionError("VERSION assignment not found in config.py") + + +def extract_document_ready_source(source): + """Extract the top-level jQuery document-ready block from the script.""" + start_marker = "$(document).ready(function () {" + end_marker = "// --- API & Rendering Functions ---" + start_index = source.index(start_marker) + end_index = source.index(end_marker) + return source[start_index:end_index] + + +def test_public_workspace_manage_script_parses_with_node(): + """Verify Node can parse the public workspace management script.""" + print("Testing public workspace manage script syntax with Node.js...") + + node_path = shutil.which("node") + if not node_path: + print("Node.js was not found; structural syntax regression checks will still run.") + return + + result = subprocess.run( + [node_path, "--check", str(MANAGE_PUBLIC_WORKSPACE_JS)], + capture_output=True, + check=False, + text=True, + ) + if result.returncode != 0: + raise AssertionError( + "Expected manage_public_workspace.js to parse cleanly. " + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + print("Public workspace manage script parsed cleanly with Node.js.") + + +def test_document_ready_handlers_are_restored(): + """Verify the repaired event handlers are present without stray template fragments.""" + print("Testing public workspace document-ready handler recovery...") + + source = read_file_text(MANAGE_PUBLIC_WORKSPACE_JS) + document_ready_source = extract_document_ready_source(source) + + required_snippets = [ + '$(document).on("click", ".select-user-btn", function () {', + '$("#pendingRequestsTable").on("click", ".approve-request-btn", function () {', + '$("#pendingRequestsTable").on("click", ".reject-request-btn", function () {', + '$("#addBulkMemberBtn").on("click", function () {', + ] + missing = [snippet for snippet in required_snippets if snippet not in document_ready_source] + assert not missing, f"Missing repaired handler snippets: {missing}" + + assert document_ready_source.count('$("#searchUsersBtn").on("click", function () {') == 1 + assert document_ready_source.count('$("#userSearchTerm").on("keydown", function (event)') == 0 + assert document_ready_source.count('$("#userSearchTerm").on("keydown", function (e)') == 1 + assert document_ready_source.count('$("#pendingRequestsTable").on("click", ".approve-request-btn"') == 1 + assert document_ready_source.count('$("#pendingRequestsTable").on("click", ".reject-request-btn"') == 1 + + forbidden_fragments = [ + ' `;', + ' }).join("");', + 'const safeUserId = escapeHtml(u.id || "");', + '${safeDisplayName}', + '${safeEmail}', + 'data-user-email="${safeEmail}">', + ] + present = [fragment for fragment in forbidden_fragments if fragment in document_ready_source] + assert not present, f"Unexpected spliced template fragments remain: {present}" + + print("Public workspace document-ready handlers are restored.") + + +def test_fix_artifacts_and_version_are_in_sync(): + """Verify versioned regression artifacts landed for this fix.""" + print("Testing public workspace manage syntax fix artifact alignment...") + + assert read_config_version() == "0.241.009" + assert FIX_DOC.exists(), f"Expected fix documentation at {FIX_DOC}" + assert UI_TEST.exists(), f"Expected UI regression test at {UI_TEST}" + + fix_doc_source = read_file_text(FIX_DOC) + assert "Fixed/Implemented in version: **0.241.009**" in fix_doc_source + assert "functional_tests/test_public_workspace_manage_script_syntax_fix.py" in fix_doc_source + assert "ui_tests/test_public_workspace_manage_script_parse.py" in fix_doc_source + + print("Public workspace manage syntax fix artifacts are aligned.") + + +if __name__ == "__main__": + tests = [ + test_public_workspace_manage_script_parses_with_node, + test_document_ready_handlers_are_restored, + test_fix_artifacts_and_version_are_in_sync, + ] + + for test in tests: + print(f"\nRunning {test.__name__}...") + test() + + print(f"\nResults: {len(tests)}/{len(tests)} tests passed") + sys.exit(0) \ No newline at end of file diff --git a/functional_tests/test_sql_odbc_driver_18_support.py b/functional_tests/test_sql_odbc_driver_18_support.py new file mode 100644 index 000000000..b5aa56eaf --- /dev/null +++ b/functional_tests/test_sql_odbc_driver_18_support.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +# test_sql_odbc_driver_18_support.py +""" +Functional test for SQL Server ODBC Driver 18 support. +Version: 0.241.018 +Implemented in: 0.241.009 + +This test ensures that the application container installs the Microsoft ODBC +Driver 18 runtime, new SQL Server connection strings default to Driver 18, and +saved Driver 17 SQL actions retry with Driver 18 when the legacy driver is +missing. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app')) + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) +APP_ROOT = os.path.join(REPO_ROOT, 'application', 'single_app') + + +def read_repo_file(*parts): + """Read a repository file as UTF-8 text.""" + with open(os.path.join(REPO_ROOT, *parts), 'r', encoding='utf-8') as file: + return file.read() + + +def test_dockerfile_installs_driver_18_runtime(): + """Test that the application image installs and copies native ODBC runtime files.""" + print("Testing Dockerfile ODBC Driver 18 runtime support...") + + try: + dockerfile = read_repo_file('application', 'single_app', 'Dockerfile') + + assert 'ACCEPT_EULA=Y' in dockerfile, 'Docker build should accept the Microsoft ODBC driver EULA' + assert 'packages.microsoft.com/azurelinux/3.0/prod/ms-non-oss' in dockerfile, \ + 'Docker build should register the Microsoft Azure Linux ms-non-oss feed' + assert 'msodbcsql18' in dockerfile, 'Docker build should install Microsoft ODBC Driver 18' + assert 'unixODBC' in dockerfile, 'Docker build should install unixODBC runtime support' + assert "find /opt/microsoft/msodbcsql18/lib64 -name 'libmsodbcsql-*.so*'" in dockerfile, \ + 'Docker build should resolve the installed Driver 18 shared library path' + assert "UsageCount=1" in dockerfile, 'Docker build should create a deterministic odbcinst.ini registration' + assert '/odbc-runtime/usr/lib64' in dockerfile, \ + 'Docker build should stage unixODBC libraries from Azure Linux library paths before runtime copy' + assert "/usr/lib64/libodbc* /usr/lib/libodbc*" in dockerfile, \ + 'Docker build should support unixODBC libraries installed under /usr/lib64 or /usr/lib' + assert '/opt/microsoft' in dockerfile, 'Distroless runtime should include Microsoft driver files' + assert '/etc/odbcinst.ini' in dockerfile, 'Distroless runtime should include ODBC driver registration' + assert 'libodbc' in dockerfile, 'Distroless runtime should include unixODBC shared libraries' + assert 'LD_LIBRARY_PATH' in dockerfile, 'Runtime should expose ODBC library paths' + + print("Dockerfile installs and copies ODBC Driver 18 runtime support.") + return True + except Exception as ex: + print(f"Test failed: {ex}") + import traceback + traceback.print_exc() + return False + + +def test_new_connection_strings_default_to_driver_18(): + """Test that generated SQL Server connection strings prefer Driver 18.""" + print("Testing SQL Server connection string default driver...") + + try: + from semantic_kernel_plugins.sql_odbc_utils import build_sql_server_odbc_connection_string + + conn_str = build_sql_server_odbc_connection_string( + server='example.database.windows.net', + database='exampledb', + ) + + assert 'DRIVER={ODBC Driver 18 for SQL Server}' in conn_str, 'New SQL Server strings should default to Driver 18' + assert 'ODBC Driver 17 for SQL Server' not in conn_str, 'New SQL Server strings should not default to Driver 17' + + print("New SQL Server connection strings default to Driver 18.") + return True + except Exception as ex: + print(f"Test failed: {ex}") + import traceback + traceback.print_exc() + return False + + +def test_saved_driver_17_connections_retry_driver_18(): + """Test that saved Driver 17 SQL actions retry with Driver 18 on missing-driver errors.""" + print("Testing legacy Driver 17 fallback behavior...") + + try: + from semantic_kernel_plugins import sql_odbc_utils + + sql_odbc_utils.log_event = lambda *args, **kwargs: None + calls = [] + + def fake_connect(connection_string, **kwargs): + calls.append((connection_string, kwargs)) + if len(calls) == 1: + raise RuntimeError("[unixODBC][Driver Manager]Can't open lib 'ODBC Driver 17 for SQL Server' : file not found") + return 'connected' + + original_connection_string = ( + 'DRIVER={ODBC Driver 17 for SQL Server};' + 'SERVER=example.database.windows.net;DATABASE=exampledb' + ) + + result = sql_odbc_utils.connect_with_sql_server_odbc_fallback( + fake_connect, + original_connection_string, + connect_kwargs={'timeout': 5}, + log_source='FunctionalTest', + ) + + assert result == 'connected', 'Fallback connection should return the retry result' + assert len(calls) == 2, 'Missing Driver 17 should trigger exactly one Driver 18 retry' + assert 'ODBC Driver 17 for SQL Server' in calls[0][0], 'First attempt should use saved Driver 17 string' + assert 'ODBC Driver 18 for SQL Server' in calls[1][0], 'Retry should use Driver 18' + assert calls[1][1] == {'timeout': 5}, 'Retry should preserve pyodbc connection kwargs' + + print("Saved Driver 17 strings retry with Driver 18 when the legacy driver is missing.") + return True + except Exception as ex: + print(f"Test failed: {ex}") + import traceback + traceback.print_exc() + return False + + +def test_non_driver_errors_do_not_retry(): + """Test that login/query errors are not retried as driver fallback failures.""" + print("Testing fallback does not hide non-driver errors...") + + try: + from semantic_kernel_plugins import sql_odbc_utils + + calls = [] + + def fake_connect(connection_string, **kwargs): + calls.append(connection_string) + raise RuntimeError("Login failed for user 'example'") + + original_connection_string = ( + 'DRIVER={ODBC Driver 17 for SQL Server};' + 'SERVER=example.database.windows.net;DATABASE=exampledb' + ) + + try: + sql_odbc_utils.connect_with_sql_server_odbc_fallback(fake_connect, original_connection_string) + except RuntimeError as ex: + assert 'Login failed' in str(ex), 'Original non-driver error should be preserved' + else: + raise AssertionError('Expected login failure to be raised') + + assert len(calls) == 1, 'Non-driver errors should not trigger Driver 18 retry' + + print("Non-driver errors are not retried as ODBC fallback failures.") + return True + except Exception as ex: + print(f"Test failed: {ex}") + import traceback + traceback.print_exc() + return False + + +def test_sql_defaults_reference_driver_18(): + """Test that SQL action defaults and examples use Driver 18.""" + print("Testing SQL action defaults reference Driver 18...") + + try: + files_to_check = [ + ('application', 'single_app', 'semantic_kernel_plugins', 'sql_schema_plugin.py'), + ('application', 'single_app', 'semantic_kernel_plugins', 'sql_query_plugin.py'), + ('application', 'single_app', 'semantic_kernel_plugins', 'sql_plugin_factory.py'), + ('application', 'single_app', 'route_backend_plugins.py'), + ('application', 'single_app', 'static', 'js', 'plugin_modal_stepper.js'), + ] + + for parts in files_to_check: + content = read_repo_file(*parts) + assert 'ODBC Driver 18 for SQL Server' in content or 'DEFAULT_SQL_SERVER_ODBC_DRIVER' in content, \ + f"{'/'.join(parts)} should reference Driver 18 default handling" + + route_content = read_repo_file('application', 'single_app', 'route_backend_plugins.py') + assert "driver or 'ODBC Driver 17 for SQL Server'" not in route_content, \ + 'SQL connection test should not default to Driver 17' + + js_content = read_repo_file('application', 'single_app', 'static', 'js', 'plugin_modal_stepper.js') + assert "additionalFields.driver || 'ODBC Driver 18 for SQL Server'" in js_content, \ + 'SQL action wizard should default new actions to Driver 18' + + print("SQL action defaults and examples reference Driver 18.") + return True + except Exception as ex: + print(f"Test failed: {ex}") + import traceback + traceback.print_exc() + return False + + +def test_version_updated(): + """Test that the version has been updated for this fix.""" + print("Testing version update...") + + try: + config_content = read_repo_file('application', 'single_app', 'config.py') + assert 'VERSION = "0.241.018"' in config_content, 'config.py should contain VERSION = "0.241.018"' + + print("Version updated to 0.241.018.") + return True + except Exception as ex: + print(f"Test failed: {ex}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + tests = [ + test_dockerfile_installs_driver_18_runtime, + test_new_connection_strings_default_to_driver_18, + test_saved_driver_17_connections_retry_driver_18, + test_non_driver_errors_do_not_retry, + test_sql_defaults_reference_driver_18, + test_version_updated, + ] + results = [] + + for test in tests: + print(f"\nRunning {test.__name__}...") + results.append(test()) + + success = all(results) + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/functional_tests/test_staging_ui_cicd_workflow.py b/functional_tests/test_staging_ui_cicd_workflow.py new file mode 100644 index 000000000..4dbda21c8 --- /dev/null +++ b/functional_tests/test_staging_ui_cicd_workflow.py @@ -0,0 +1,255 @@ +# test_staging_ui_cicd_workflow.py +#!/usr/bin/env python3 +""" +Functional test for staging UI CI/CD workflow assets. +Version: 0.241.018 +Implemented in: 0.241.014 + +This test ensures that the reusable GitHub Actions staging deployment workflow, +Azure/GitHub bootstrap script, and Playwright smoke test are present and wired +for OIDC-based azd deployment followed by authenticated UI validation. +""" + +import os +import sys + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + + +def repo_root(): + """Return the repository root path.""" + return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) + + +def read_repo_file(*parts): + """Read a repository file for assertions.""" + file_path = os.path.join(repo_root(), *parts) + with open(file_path, 'r', encoding='utf-8') as handle: + return handle.read() + + +def assert_fragments(content, fragments, label): + """Validate that each expected fragment exists in the supplied content.""" + for fragment in fragments: + if fragment not in content: + print(f'Missing {label} fragment: {fragment}') + return False + return True + + +def test_staging_workflow_configuration(): + """Verify the staging workflow runs azd deployment and UI tests.""" + print('Testing staging workflow configuration...') + + try: + content = read_repo_file('.github', 'workflows', 'staging-azd-ui-tests.yml') + required_fragments = [ + 'name: Staging AZD Deploy and UI Smoke Tests', + 'branches:', + '- Staging', + "name: ${{ vars.STAGING_GITHUB_ENVIRONMENT || 'Staging' }}", + 'id-token: write', + 'azure/login@v2', + 'Azure/setup-azd@v2', + '--federated-credential-provider github', + 'azd up --no-prompt', + 'azd deploy --no-prompt', + 'Waiting up to 15 minutes for staging to finish App Service warm-up.', + '/external/healthcheckz', + 'SIMPLECHAT_UI_STORAGE_STATE_B64', + 'SIMPLECHAT_UI_ADMIN_STORAGE_STATE_B64', + 'SIMPLECHAT_UI_AUTH_RESOURCE', + 'SIMPLECHAT_UI_ACCESS_TOKEN', + 'az account get-access-token --resource', + 'PLAYWRIGHT_SERVICE_URL', + 'actions/setup-node@v4', + 'ui_tests/playwright-workspaces/package-lock.json', + 'npm run test:staging:azure', + 'ui_tests/requirements.txt', + 'python -m pytest "$PYTEST_TARGET" -m ui -ra', + 'actions/upload-artifact@v4', + ] + + if not assert_fragments(content, required_fragments, 'workflow'): + return False + + print('Staging workflow is configured for OIDC azd deployment and UI smoke tests.') + return True + except Exception as exc: + print(f'Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_branch_flow_uses_actual_branch_casing(): + """Verify branch-flow guard matches the repository's branch names.""" + print('Testing branch-flow casing configuration...') + + try: + content = read_repo_file('.github', 'workflows', 'enforce-branch-flow.yml') + required_fragments = [ + "github.event.pull_request.base.ref == 'Staging'", + "github.event.pull_request.head.ref != 'Development'", + "github.event.pull_request.head.ref != 'Staging'", + "Pull requests into 'Staging' must originate from branch 'Development'.", + "Pull requests into 'main' must originate from branch 'Staging'.", + ] + + if not assert_fragments(content, required_fragments, 'branch-flow workflow'): + return False + + print('Branch-flow guard uses the actual Development and Staging branch casing.') + return True + except Exception as exc: + print(f'Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_bootstrap_script_configuration(): + """Verify the bootstrap script creates OIDC and GitHub Environment settings.""" + print('Testing staging bootstrap script configuration...') + + try: + content = read_repo_file('deployers', 'Initialize-GitHubActionsStaging.ps1') + required_fragments = [ + 'az ad app create', + 'az ad sp create', + 'az ad app federated-credential create', + 'repo:${Repository}:environment:${EnvironmentName}', + 'Contributor', + 'User Access Administrator', + 'gh api --method PUT', + 'gh variable set', + 'gh secret set', + 'AZD_ENV_FILE_B64', + 'SIMPLECHAT_UI_STORAGE_STATE_B64', + 'SIMPLECHAT_UI_ADMIN_STORAGE_STATE_B64', + 'Microsoft.LoadTestService/playwrightWorkspaces', + 'Playwright Workspace Contributor', + 'PLAYWRIGHT_SERVICE_URL', + 'SIMPLECHAT_UI_AUTH_RESOURCE', + 'ENABLE_CI_BEARER_SESSION_AUTH', + 'CI_BEARER_SESSION_ALLOWED_APP_IDS', + 'AppRoleValue "Admin"', + 'Assigning Enterprise App role', + ] + + if not assert_fragments(content, required_fragments, 'bootstrap script'): + return False + + print('Bootstrap script includes Azure OIDC, RBAC, GitHub secret, and app assignment support.') + return True + except Exception as exc: + print(f'Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_staging_smoke_test_configuration(): + """Verify the live Playwright smoke test targets the deployed chat workflow.""" + print('Testing staging Playwright smoke test configuration...') + + try: + content = read_repo_file('ui_tests', 'test_staging_chat_smoke.py') + required_fragments = [ + 'Version: 0.241.018', + 'SIMPLECHAT_UI_BASE_URL', + 'SIMPLECHAT_UI_STORAGE_STATE', + 'SIMPLECHAT_UI_ADMIN_STORAGE_STATE', + 'SIMPLECHAT_UI_ACCESS_TOKEN', + '/ci-auth/session', + '@pytest.mark.ui', + 'page.goto(f"{BASE_URL}/chats"', + '#new-conversation-btn', + '#user-input', + '#send-btn', + '.ai-message .message-text', + 'context.request.delete', + 'context.tracing.start', + ] + + if not assert_fragments(content, required_fragments, 'smoke test'): + return False + + print('Staging smoke test validates authenticated chat creation and assistant response.') + return True + except Exception as exc: + print(f'Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +def test_playwright_workspaces_runner_configuration(): + """Verify the Azure Playwright Workspaces runner is configured.""" + print('Testing Playwright Workspaces runner configuration...') + + try: + package_content = read_repo_file('ui_tests', 'playwright-workspaces', 'package.json') + service_config = read_repo_file('ui_tests', 'playwright-workspaces', 'playwright.service.config.js') + smoke_test = read_repo_file('ui_tests', 'playwright-workspaces', 'staging-chat-smoke.spec.js') + + required_package_fragments = [ + '@azure/playwright', + '@azure/identity', + '@playwright/test', + 'test:staging:azure', + ] + required_service_fragments = [ + 'createAzurePlaywrightConfig', + 'DefaultAzureCredential', + 'ServiceOS.LINUX', + 'PLAYWRIGHT_SERVICE_URL', + ] + required_smoke_fragments = [ + 'Version: 0.241.018', + 'SIMPLECHAT_UI_BASE_URL', + 'SIMPLECHAT_UI_STORAGE_STATE', + 'SIMPLECHAT_UI_ADMIN_STORAGE_STATE', + 'SIMPLECHAT_UI_ACCESS_TOKEN', + '/ci-auth/session', + 'page.goto(`${baseUrl}/chats`', + '#new-conversation-btn', + '#user-input', + '#send-btn', + '.ai-message .message-text', + 'page.context().request.delete', + ] + + if not assert_fragments(package_content, required_package_fragments, 'Playwright package'): + return False + if not assert_fragments(service_config, required_service_fragments, 'Playwright service config'): + return False + if not assert_fragments(smoke_test, required_smoke_fragments, 'Playwright Workspaces smoke test'): + return False + + print('Playwright Workspaces runner is configured for Azure-hosted browser smoke tests.') + return True + except Exception as exc: + print(f'Test failed: {exc}') + import traceback + traceback.print_exc() + return False + + +if __name__ == '__main__': + tests = [ + test_staging_workflow_configuration, + test_branch_flow_uses_actual_branch_casing, + test_bootstrap_script_configuration, + test_staging_smoke_test_configuration, + test_playwright_workspaces_runner_configuration, + ] + results = [] + + for test in tests: + print(f'Running {test.__name__}...') + results.append(test()) + + success = all(results) + print(f'Results: {sum(results)}/{len(results)} tests passed') + sys.exit(0 if success else 1) diff --git a/functional_tests/test_stored_xss_chat_workspace_rendering_fix.py b/functional_tests/test_stored_xss_chat_workspace_rendering_fix.py index 93aa10cee..0e52e48c3 100644 --- a/functional_tests/test_stored_xss_chat_workspace_rendering_fix.py +++ b/functional_tests/test_stored_xss_chat_workspace_rendering_fix.py @@ -1,7 +1,7 @@ # test_stored_xss_chat_workspace_rendering_fix.py """ Functional test for stored XSS chat and workspace rendering hardening. -Version: 0.241.017 +Version: 0.241.018 Implemented in: 0.241.017 This test ensures chat agent display names, workspace member display names, @@ -192,7 +192,7 @@ def test_fix_documentation_and_version_exist(): """Verify the version bump and fix documentation landed for this change.""" print("🔍 Testing stored XSS rendering fix documentation and version...") - assert read_config_version() == "0.241.017" + assert read_config_version() == "0.241.018" assert os.path.exists(FIX_DOC), f"Expected fix documentation at {FIX_DOC}" print("✅ Stored XSS rendering fix documentation and version passed") diff --git a/functional_tests/test_tabular_all_scope_group_source_context.py b/functional_tests/test_tabular_all_scope_group_source_context.py index be42e9c62..fffb265bb 100644 --- a/functional_tests/test_tabular_all_scope_group_source_context.py +++ b/functional_tests/test_tabular_all_scope_group_source_context.py @@ -2,7 +2,7 @@ # test_tabular_all_scope_group_source_context.py """ Functional test for all-scope tabular group source context handling. -Version: 0.241.017 +Version: 0.241.018 Implemented in: 0.240.032; 0.240.041; 0.240.042; 0.240.043; 0.240.048; 0.240.049; 0.241.016 This test ensures mixed-scope workspace search keeps per-file group/public @@ -209,7 +209,7 @@ def test_route_uses_context_aware_tabular_analysis_and_version_bump(): ] missing = [snippet for snippet in required_snippets if snippet not in source] assert not missing, f'Missing route integration snippets: {missing}' - assert read_config_version() == '0.241.017' + assert read_config_version() == '0.241.018' print('✅ Route integration and version bump passed') return True diff --git a/ui_tests/playwright-workspaces/package-lock.json b/ui_tests/playwright-workspaces/package-lock.json new file mode 100644 index 000000000..c8421a316 --- /dev/null +++ b/ui_tests/playwright-workspaces/package-lock.json @@ -0,0 +1,875 @@ +{ + "name": "simplechat-playwright-workspaces", + "version": "0.241.018", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplechat-playwright-workspaces", + "version": "0.241.018", + "devDependencies": { + "@azure/identity": "4.13.1", + "@azure/playwright": "1.1.5", + "@playwright/test": "1.60.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.4.0.tgz", + "integrity": "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", + "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-xml": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.1.tgz", + "integrity": "sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.5.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.10.1.tgz", + "integrity": "sha512-hTbvOi9Ko2Jvn+G/fSmjzHf9WbNcf/o3epMtbeGx/pMwMrVAbi6OgCJVeCfsAb8IybSRpaCSc4EDRlYAhgngUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.1.tgz", + "integrity": "sha512-VxKdEtUwDuLD0F1hOQP7kye0YadZxFJfv37Em440geEf/w9uggKnHpRrqwZJOdxmPUOdhZ9kyRtKuAJW8wUcRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.1.tgz", + "integrity": "sha512-tmQiQ2HvtzaeLqYGy3BemiPOSGPY4wCy1IW5zDWITKSs/s35WEd7Zij/hCxvUdAOzj6U3qnyaGbYXY91ortFEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/playwright": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@azure/playwright/-/playwright-1.1.5.tgz", + "integrity": "sha512-Zgc12kmrtbXM1JEsmLm5tuhChrpvQ1AtPLWxPXQqpyMI5e9vOgJThXurR1i+x2Mf+a+qSTzfPMHOYtdbwPdroQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.9.0", + "@azure/core-rest-pipeline": "^1.18.0", + "@azure/logger": "^1.1.4", + "@azure/storage-blob": "^12.24.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@playwright/test": "^1.47.0" + } + }, + "node_modules/@azure/storage-blob": { + "version": "12.31.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.31.0.tgz", + "integrity": "sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.3", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/core-xml": "^1.4.5", + "@azure/logger": "^1.1.4", + "@azure/storage-common": "^12.3.0", + "events": "^3.0.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/storage-common": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.3.0.tgz", + "integrity": "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.1.4", + "events": "^3.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", + "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + } + } +} diff --git a/ui_tests/playwright-workspaces/package.json b/ui_tests/playwright-workspaces/package.json new file mode 100644 index 000000000..58215fbea --- /dev/null +++ b/ui_tests/playwright-workspaces/package.json @@ -0,0 +1,14 @@ +{ + "name": "simplechat-playwright-workspaces", + "version": "0.241.018", + "private": true, + "type": "module", + "scripts": { + "test:staging:azure": "playwright test staging-chat-smoke.spec.js --config=playwright.service.config.js --workers=1" + }, + "devDependencies": { + "@azure/identity": "4.13.1", + "@azure/playwright": "1.1.5", + "@playwright/test": "1.60.0" + } +} \ No newline at end of file diff --git a/ui_tests/playwright-workspaces/playwright.config.js b/ui_tests/playwright-workspaces/playwright.config.js new file mode 100644 index 000000000..5f29cf03b --- /dev/null +++ b/ui_tests/playwright-workspaces/playwright.config.js @@ -0,0 +1,23 @@ +// playwright.config.js +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + testMatch: /.*\.spec\.js/, + timeout: Number(process.env.PLAYWRIGHT_TEST_TIMEOUT_MS ?? 180000), + expect: { + timeout: 30000, + }, + fullyParallel: false, + reporter: [ + ['list'], + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ], + use: { + baseURL: process.env.SIMPLECHAT_UI_BASE_URL, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'retain-on-failure', + viewport: { width: 1440, height: 900 }, + }, +}); \ No newline at end of file diff --git a/ui_tests/playwright-workspaces/playwright.service.config.js b/ui_tests/playwright-workspaces/playwright.service.config.js new file mode 100644 index 000000000..a4edf04e2 --- /dev/null +++ b/ui_tests/playwright-workspaces/playwright.service.config.js @@ -0,0 +1,19 @@ +// playwright.service.config.js +import { DefaultAzureCredential } from '@azure/identity'; +import { createAzurePlaywrightConfig, ServiceOS } from '@azure/playwright'; +import { defineConfig } from '@playwright/test'; + +import baseConfig from './playwright.config.js'; + +if (!process.env.PLAYWRIGHT_SERVICE_URL) { + throw new Error('Set PLAYWRIGHT_SERVICE_URL to run tests in Microsoft Playwright Workspaces.'); +} + +export default defineConfig( + baseConfig, + createAzurePlaywrightConfig(baseConfig, { + connectTimeout: 3 * 60 * 1000, + credential: new DefaultAzureCredential(), + os: ServiceOS.LINUX, + }), +); \ No newline at end of file diff --git a/ui_tests/playwright-workspaces/staging-chat-smoke.spec.js b/ui_tests/playwright-workspaces/staging-chat-smoke.spec.js new file mode 100644 index 000000000..53271ae2d --- /dev/null +++ b/ui_tests/playwright-workspaces/staging-chat-smoke.spec.js @@ -0,0 +1,87 @@ +// staging-chat-smoke.spec.js +/* +UI smoke test for staging chat deployment through Microsoft Playwright Workspaces. +Version: 0.241.018 +Implemented in: 0.241.017; 0.241.018 + +This test validates the same authenticated chat path as the Python staging +smoke test, but runs through Azure-hosted browsers in Playwright Workspaces. +*/ + +import { existsSync } from 'node:fs'; + +import { expect, test } from '@playwright/test'; + +const baseUrl = (process.env.SIMPLECHAT_UI_BASE_URL ?? '').replace(/\/$/, ''); +const storageState = process.env.SIMPLECHAT_UI_STORAGE_STATE + || process.env.SIMPLECHAT_UI_ADMIN_STORAGE_STATE + || ''; +const accessToken = process.env.SIMPLECHAT_UI_ACCESS_TOKEN || ''; +const smokePrompt = process.env.SIMPLECHAT_UI_SMOKE_PROMPT + || 'CI smoke test. Reply with one short greeting.'; +const responseTimeoutMs = Number(process.env.SIMPLECHAT_UI_SMOKE_RESPONSE_TIMEOUT_MS ?? 180000); + +test.skip(!baseUrl, 'Set SIMPLECHAT_UI_BASE_URL to run this staging UI smoke test.'); +test.skip(!accessToken && (!storageState || !existsSync(storageState)), 'Set SIMPLECHAT_UI_ACCESS_TOKEN or a valid SIMPLECHAT_UI_STORAGE_STATE/SIMPLECHAT_UI_ADMIN_STORAGE_STATE file.'); + +if (accessToken) { + test.use({ extraHTTPHeaders: { Authorization: `Bearer ${accessToken}` } }); +} +else if (storageState) { + test.use({ storageState }); +} + +test('staging chat can create conversation and receive response', async ({ page }) => { + let conversationId = null; + + try { + if (accessToken) { + const sessionResponse = await page.context().request.post(`${baseUrl}/ci-auth/session`, { + headers: { Authorization: `Bearer ${accessToken}` }, + timeout: 30000, + }); + expect(sessionResponse.ok(), `Expected CI bearer session setup to succeed, got HTTP ${sessionResponse.status()}.`).toBeTruthy(); + } + + const response = await page.goto(`${baseUrl}/chats`, { waitUntil: 'networkidle', timeout: 60000 }); + expect(response, 'Expected a navigation response when loading /chats.').not.toBeNull(); + expect([401, 403], 'Authenticated storage state was rejected by the staging chat page.').not.toContain(response.status()); + expect(response.ok(), `Expected /chats to load successfully, got HTTP ${response.status()}.`).toBeTruthy(); + + await expect(page.locator('#user-input')).toBeVisible({ timeout: 30000 }); + await expect(page.locator('#send-btn')).toBeAttached({ timeout: 30000 }); + + await page.locator('#new-conversation-btn').click(); + await page.locator('#user-input').fill(smokePrompt); + await page.locator('#send-btn').click(); + + await expect(page.locator('.user-message .message-text').filter({ hasText: smokePrompt })).toBeVisible({ timeout: 15000 }); + + await page.waitForFunction(() => Array.from(document.querySelectorAll('.ai-message .message-text')).some((element) => { + const text = (element.textContent || '').trim(); + const messageElement = element.closest('.ai-message'); + return Boolean( + text + && !text.includes('Streaming...') + && !text.includes('Reconnecting') + && messageElement + && !messageElement.querySelector('.streaming-cursor, .spinner-border'), + ); + }), null, { timeout: responseTimeoutMs }); + + const assistantText = ((await page.locator('.ai-message .message-text').last().textContent()) || '').trim(); + expect(assistantText, 'Expected the assistant response to contain text.').not.toHaveLength(0); + + conversationId = await page.evaluate(() => window.chatConversations?.getCurrentConversationId?.() + || window.currentConversationId + || null); + expect(conversationId, 'Expected the staging smoke test to create or select a conversation.').toBeTruthy(); + + await expect(page.locator('.toast.show .text-bg-danger, .toast.show.bg-danger, .toast.show .alert-danger')).toHaveCount(0); + } + finally { + if (conversationId) { + await page.context().request.delete(`${baseUrl}/api/conversations/${conversationId}`, { timeout: 30000 }).catch(() => {}); + } + } +}); \ No newline at end of file diff --git a/ui_tests/requirements.txt b/ui_tests/requirements.txt index 06514a203..86b99f5f9 100644 --- a/ui_tests/requirements.txt +++ b/ui_tests/requirements.txt @@ -1,4 +1,4 @@ -pytest==9.0.2 +pytest==9.0.3 playwright==1.58.0 pytest-playwright==0.7.2 azure-mgmt-playwright==1.0.0 diff --git a/ui_tests/test_chat_document_dropdown_viewport_fit.py b/ui_tests/test_chat_document_dropdown_viewport_fit.py new file mode 100644 index 000000000..10eea8cde --- /dev/null +++ b/ui_tests/test_chat_document_dropdown_viewport_fit.py @@ -0,0 +1,127 @@ +# test_chat_document_dropdown_viewport_fit.py +""" +UI test for chat document dropdown viewport fitting. +Version: 0.241.009 +Implemented in: 0.241.009 + +This test ensures the chat document selector opens within the visible viewport +when the grounded-search controls sit near the bottom of a short browser window. +""" + +import json +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +SHORT_DESKTOP_VIEWPORT = {"width": 1024, "height": 260} + + +def _require_authenticated_chat_env(): + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + if not STORAGE_STATE or not Path(STORAGE_STATE).exists(): + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + +def _fulfill_json(route, payload, status=200): + route.fulfill( + status=status, + content_type="application/json", + body=json.dumps(payload), + ) + + +@pytest.mark.ui +def test_chat_document_dropdown_stays_in_short_viewport(playwright): + """Validate the document dropdown flips or shrinks instead of leaving the viewport.""" + _require_authenticated_chat_env() + + personal_docs_payload = { + "documents": [ + { + "id": f"viewport-doc-{index}", + "title": f"Viewport Test Document {index}", + "file_name": f"viewport-test-document-{index}.md", + "tags": [], + "document_classification": "", + } + for index in range(1, 25) + ] + } + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport=SHORT_DESKTOP_VIEWPORT, + ) + page = context.new_page() + + page.route("**/api/documents?page_size=1000", lambda route: _fulfill_json(route, personal_docs_payload)) + page.route("**/api/group_documents?*", lambda route: _fulfill_json(route, {"documents": []})) + page.route("**/api/public_workspace_documents?page_size=1000", lambda route: _fulfill_json(route, {"documents": []})) + page.route("**/api/documents/tags", lambda route: _fulfill_json(route, {"tags": []})) + page.route("**/api/group_documents/tags?*", lambda route: _fulfill_json(route, {"tags": []})) + page.route("**/api/public_workspace_documents/tags?*", lambda route: _fulfill_json(route, {"tags": []})) + + try: + response = page.goto(f"{BASE_URL}/chats", wait_until="networkidle") + assert response is not None and response.ok, "Expected /chats to load successfully." + + search_button = page.locator("#search-documents-btn") + if search_button.count() == 0: + pytest.skip("Grounded search is not enabled for this environment.") + + expect(search_button).to_be_visible() + search_button.click() + + document_button = page.locator("#document-dropdown-button") + expect(document_button).to_be_visible() + document_button.click() + + menu = page.locator("#document-dropdown-menu.show") + expect(menu).to_be_visible() + page.wait_for_function( + """ + () => { + const menu = document.getElementById('document-dropdown-menu'); + return !!menu && menu.classList.contains('show') && menu.getBoundingClientRect().height > 0; + } + """ + ) + + metrics = page.evaluate( + """ + () => { + const button = document.getElementById('document-dropdown-button').getBoundingClientRect(); + const menu = document.getElementById('document-dropdown-menu').getBoundingClientRect(); + const items = document.getElementById('document-dropdown-items'); + const placement = document.getElementById('document-dropdown-menu').getAttribute('data-popper-placement') || ''; + + return { + buttonTop: button.top, + menuBottom: menu.bottom, + menuTop: menu.top, + placement, + viewportHeight: window.innerHeight, + itemsClientHeight: items.clientHeight, + itemsScrollHeight: items.scrollHeight, + }; + } + """ + ) + + assert metrics["menuTop"] >= -1, f"Document dropdown escaped above viewport: {metrics}" + assert metrics["menuBottom"] <= metrics["viewportHeight"] + 1, ( + f"Document dropdown escaped below viewport: {metrics}" + ) + assert metrics["itemsScrollHeight"] > metrics["itemsClientHeight"], ( + f"Expected document list to scroll within constrained dropdown: {metrics}" + ) + finally: + context.close() + browser.close() diff --git a/ui_tests/test_chat_sidebar_toggle_controls.py b/ui_tests/test_chat_sidebar_toggle_controls.py index a6da4be13..0c81338a7 100644 --- a/ui_tests/test_chat_sidebar_toggle_controls.py +++ b/ui_tests/test_chat_sidebar_toggle_controls.py @@ -1,14 +1,17 @@ # test_chat_sidebar_toggle_controls.py """ UI test for the unified chat navigation shell. -Version: 0.241.023 -Implemented in: 0.241.023 +Version: 0.241.017 +Implemented in: 0.241.017 This test ensures that chats in top-nav mode use the adaptive conversation rail, preserve compact desktop top-nav links, and become the hamburger drawer on mobile without reintroducing the old top-nav drawer or the chat drawer close-button crash while preserving direct workspace navigation in the mobile -drawer. +drawer. It also prevents the duplicate desktop inline sidebar toggle from +returning in the chat header and verifies the user-selected sidebar toggle +style. It also checks that the conversation details icon remains an unoutlined +icon button and that compact sidebar controls render as icon-only buttons. """ import os @@ -61,8 +64,8 @@ def _set_user_settings(page, settings): @pytest.mark.ui -def test_chat_sidebar_desktop_uses_inline_toggle_without_floating_reopen(playwright): - """Validate that desktop chat uses the docked rail, inline toggle, and compact header navigation.""" +def test_chat_sidebar_desktop_uses_sidebar_toggle_without_inline_duplicate(playwright): + """Validate that desktop chat uses the default large docked rail toggle only.""" _require_authenticated_chat_env() browser = playwright.chromium.launch() @@ -78,6 +81,7 @@ def test_chat_sidebar_desktop_uses_inline_toggle_without_floating_reopen(playwri original_settings = _get_user_settings(page) top_nav_settings = dict(original_settings) top_nav_settings["navLayout"] = "top" + top_nav_settings["sidebarToggleStyle"] = "large" assert _set_user_settings(page, top_nav_settings), "Expected nav layout update to succeed." response = page.goto(f"{BASE_URL}/chats", wait_until="domcontentloaded") @@ -85,11 +89,14 @@ def test_chat_sidebar_desktop_uses_inline_toggle_without_floating_reopen(playwri sidebar = page.locator("#sidebar-nav") sidebar_toggle = page.locator("#sidebar-toggle-btn") - inline_toggle = page.locator("#chat-sidebar-inline-toggle") + info_button = page.locator("#conversation-info-btn") expect(sidebar).to_be_visible() expect(sidebar_toggle).to_be_visible() - expect(inline_toggle).to_be_visible() + expect(sidebar_toggle).not_to_have_class(re.compile(r".*sidebar-toggle-compact.*")) + expect(sidebar_toggle.locator(".sidebar-toggle-label")).to_have_text("Hide navigation") + expect(info_button).not_to_have_class(re.compile(r".*btn-outline-secondary.*")) + expect(page.locator("#chat-sidebar-inline-toggle")).to_have_count(0) expect(page.locator("#floating-expand-btn")).to_have_count(0) expect(page.locator("#topNavMobileMenu")).to_have_count(0) expect(page.locator(".top-nav-chat-nav")).to_be_visible() @@ -99,13 +106,54 @@ def test_chat_sidebar_desktop_uses_inline_toggle_without_floating_reopen(playwri page.wait_for_function("document.body.classList.contains('sidebar-collapsed')") expect(sidebar).to_have_class(re.compile(r".*sidebar-collapsed.*")) - expect(inline_toggle).to_have_attribute("aria-expanded", "false") + expect(sidebar_toggle).to_have_attribute("aria-expanded", "false") + finally: + if original_settings is not None: + _set_user_settings(page, original_settings) + context.close() + browser.close() - inline_toggle.click() - page.wait_for_function("!document.body.classList.contains('sidebar-collapsed')") - expect(sidebar_toggle).to_have_attribute("aria-expanded", "true") - expect(inline_toggle).to_have_attribute("aria-expanded", "true") +@pytest.mark.ui +def test_chat_sidebar_desktop_uses_compact_toggle_preference(playwright): + """Validate that desktop chat renders the compact sidebar toggle when selected by the user.""" + _require_authenticated_chat_env() + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=STORAGE_STATE, + viewport=DESKTOP_VIEWPORT, + ) + page = context.new_page() + original_settings = None + + try: + page.goto(f"{BASE_URL}/chats", wait_until="domcontentloaded") + original_settings = _get_user_settings(page) + compact_settings = dict(original_settings) + compact_settings["navLayout"] = "top" + compact_settings["sidebarToggleStyle"] = "compact" + assert _set_user_settings(page, compact_settings), "Expected compact sidebar toggle setting update to succeed." + + response = page.goto(f"{BASE_URL}/chats", wait_until="domcontentloaded") + assert response is not None and response.ok, "Expected /chats to load in top-nav mode." + + sidebar = page.locator("#sidebar-nav") + sidebar_toggle = page.locator("#sidebar-toggle-btn") + + expect(sidebar).to_be_visible() + expect(sidebar_toggle).to_be_visible() + expect(sidebar_toggle).to_have_class(re.compile(r".*sidebar-toggle-compact.*")) + expect(sidebar_toggle).not_to_have_class(re.compile(r".*btn-outline-secondary.*")) + expect(sidebar_toggle.locator("i.bi-layout-sidebar")).to_be_visible() + expect(sidebar_toggle.locator(".sidebar-toggle-label")).to_have_count(0) + expect(page.locator("#chat-sidebar-inline-toggle")).to_have_count(0) + + sidebar_toggle.click() + page.wait_for_function("document.body.classList.contains('sidebar-collapsed')") + + expect(sidebar).to_have_class(re.compile(r".*sidebar-collapsed.*")) + expect(sidebar_toggle).to_have_attribute("aria-expanded", "false") finally: if original_settings is not None: _set_user_settings(page, original_settings) diff --git a/ui_tests/test_docs_showcase_pages.py b/ui_tests/test_docs_showcase_pages.py index ed94beda5..b7c4d27d7 100644 --- a/ui_tests/test_docs_showcase_pages.py +++ b/ui_tests/test_docs_showcase_pages.py @@ -1,14 +1,13 @@ # test_docs_showcase_pages.py """ UI test for docs showcase pages. -Version: 0.241.010 -Implemented in: 0.241.010 +Version: 0.241.012 +Implemented in: 0.241.012 This test ensures that the redesigned docs landing pages, reference guides, -how-to guides, tutorials, and troubleshooting pages render the shared -latest-release-style hero and page-specific content blocks at desktop and -mobile viewport sizes, and that features page preview images open in the -shared popup modal. +how-to guides, tutorials, and troubleshooting pages render the shared GitHub +Pages navigation shell, responsive sidebar, search experience, page-specific +content blocks, and shared preview modal at desktop and mobile viewport sizes. """ import os @@ -101,16 +100,26 @@ def test_docs_showcase_pages(playwright, viewport, path, heading, specific_selec assert response is not None, f"Expected a navigation response when loading {path}." assert response.ok, f"Expected {path} to load successfully, got HTTP {response.status}." - expect(page.locator(".latest-release-hero")).to_be_visible() + expect(page.locator(".docs-topbar")).to_be_visible() expect(page.get_by_role("heading", name=heading, exact=True)).to_be_visible() - expect(page.locator(".latest-release-hero-actions .btn").first).to_be_visible() - expect(page.locator(".latest-release-card-grid").first).to_be_visible() expect(page.locator(specific_selector).first).to_be_visible() + if viewport["width"] >= 992: + expect(page.locator("#sidebar-nav")).to_be_visible() + expect(page.locator(".docs-topbar-search [data-docs-search='true']")).to_be_visible() + else: + page.get_by_role("button", name="Open documentation navigation").click() + expect(page.locator("#sidebar-nav")).to_be_visible() + page.get_by_role("button", name="Close documentation navigation").click() + if path == "/features/": page.locator("[data-latest-feature-image-src]").first.click() expect(page.locator("#latestFeatureImageModal")).to_be_visible() expect(page.locator("#latestFeatureImageModalLabel")).to_have_text("Architecture overview") + + if path == "/": + page.locator("#docs-hero-search-input").fill("features") + expect(page.locator(".docs-search-result-title", has_text="Features").first).to_be_visible() finally: context.close() browser.close() \ No newline at end of file diff --git a/ui_tests/test_profile_sidebar_toggle_style_preference.py b/ui_tests/test_profile_sidebar_toggle_style_preference.py new file mode 100644 index 000000000..8960a447f --- /dev/null +++ b/ui_tests/test_profile_sidebar_toggle_style_preference.py @@ -0,0 +1,113 @@ +# test_profile_sidebar_toggle_style_preference.py +""" +UI test for profile sidebar toggle style preference. +Version: 0.241.015 +Implemented in: 0.241.015 + +This test ensures a signed-in user can choose the compact sidebar hide control +from the profile page and that Chat renders the compact control after the +preference is saved. +""" + +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") +ADMIN_STORAGE_STATE = os.getenv("SIMPLECHAT_UI_ADMIN_STORAGE_STATE", "") + + +def _require_base_url(): + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.") + + +def _get_storage_state_path(): + for candidate in (STORAGE_STATE, ADMIN_STORAGE_STATE): + if candidate and Path(candidate).exists(): + return candidate + pytest.skip("Set SIMPLECHAT_UI_STORAGE_STATE or SIMPLECHAT_UI_ADMIN_STORAGE_STATE to a valid authenticated Playwright storage state file.") + + +def _get_user_settings(page): + return page.evaluate( + """ + async () => { + const response = await fetch('/api/user/settings'); + const data = await response.json(); + return data.settings || {}; + } + """ + ) + + +def _set_user_settings(page, settings): + return page.evaluate( + """ + async (nextSettings) => { + const response = await fetch('/api/user/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: nextSettings }) + }); + return response.ok; + } + """, + settings, + ) + + +@pytest.mark.ui +def test_profile_can_save_compact_sidebar_toggle_style(playwright): + """Validate that the profile preference switches Chat to the compact sidebar toggle.""" + _require_base_url() + storage_state = _get_storage_state_path() + + browser = playwright.chromium.launch() + context = browser.new_context( + storage_state=storage_state, + viewport={"width": 1440, "height": 900}, + ) + page = context.new_page() + original_settings = None + + try: + response = page.goto(f"{BASE_URL}/profile", wait_until="domcontentloaded") + assert response is not None, "Expected a navigation response when loading /profile." + if response.status in {401, 403, 404}: + pytest.skip("Profile page was not available for the configured session.") + + assert response.ok, f"Expected /profile to load successfully, got HTTP {response.status}." + expect(page.get_by_role("heading", name="Navigation Preferences")).to_be_visible() + + original_settings = _get_user_settings(page) + + page.locator("#sidebar-toggle-style-compact").check(force=True) + page.locator("#save-navigation-preferences-btn").click() + expect(page.locator("#navigation-preference-status")).to_contain_text("compact sidebar hide control") + + saved_settings = _get_user_settings(page) + assert saved_settings.get("sidebarToggleStyle") == "compact", "Expected compact sidebar toggle style to be saved." + + assert _set_user_settings(page, {"navLayout": "top"}), "Expected nav layout update to succeed." + + chat_response = page.goto(f"{BASE_URL}/chats", wait_until="domcontentloaded") + assert chat_response is not None, "Expected a navigation response when loading /chats." + if chat_response.status in {401, 403, 404}: + pytest.skip("Chat page was not available for the configured session.") + + assert chat_response.ok, f"Expected /chats to load successfully, got HTTP {chat_response.status}." + expect(page.locator("#sidebar-toggle-btn.sidebar-toggle-compact")).to_be_visible() + expect(page.locator("#sidebar-toggle-btn i.bi-layout-sidebar")).to_be_visible() + finally: + if original_settings is not None: + _set_user_settings(page, { + "navLayout": original_settings.get("navLayout", ""), + "sidebarToggleStyle": original_settings.get("sidebarToggleStyle", "large"), + }) + context.close() + browser.close() \ No newline at end of file diff --git a/ui_tests/test_public_workspace_manage_script_parse.py b/ui_tests/test_public_workspace_manage_script_parse.py new file mode 100644 index 000000000..b5c699f83 --- /dev/null +++ b/ui_tests/test_public_workspace_manage_script_parse.py @@ -0,0 +1,55 @@ +# test_public_workspace_manage_script_parse.py +""" +UI test for public workspace manage script parsing. +Version: 0.241.009 +Implemented in: 0.241.009 + +This test ensures Chromium can parse the public workspace management script +without the syntax error that prevented public workspace pages from loading. +""" + +from pathlib import Path + +import pytest + + +ROOT_DIR = Path(__file__).resolve().parents[1] +MANAGE_PUBLIC_WORKSPACE_JS = ( + ROOT_DIR + / "application" + / "single_app" + / "static" + / "js" + / "public" + / "manage_public_workspace.js" +) + + +@pytest.mark.ui +def test_public_workspace_manage_script_parses_in_chromium(page): + """Validate the public workspace manage script parses in Chromium.""" + source = MANAGE_PUBLIC_WORKSPACE_JS.read_text(encoding="utf-8") + + parse_result = page.evaluate( + """ + (scriptSource) => { + try { + new Function(scriptSource); + return { ok: true }; + } catch (error) { + return { + ok: false, + name: error.name, + message: error.message, + stack: error.stack, + }; + } + } + """, + source, + ) + + assert parse_result["ok"], ( + "Expected manage_public_workspace.js to parse in Chromium. " + f"Observed: {parse_result}" + ) \ No newline at end of file diff --git a/ui_tests/test_staging_chat_smoke.py b/ui_tests/test_staging_chat_smoke.py new file mode 100644 index 000000000..acf094ef5 --- /dev/null +++ b/ui_tests/test_staging_chat_smoke.py @@ -0,0 +1,126 @@ +# test_staging_chat_smoke.py +""" +UI smoke test for staging chat deployment. +Version: 0.241.018 +Implemented in: 0.241.014; 0.241.018 + +This test ensures that a deployed staging SimpleChat environment can load the +chat UI with authenticated browser state, create a conversation, submit a +message, receive an assistant response, and clean up the created conversation. +""" + +import os +from pathlib import Path + +import pytest +from playwright.sync_api import expect + + +BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/") +STORAGE_STATE = ( + os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "") + or os.getenv("SIMPLECHAT_UI_ADMIN_STORAGE_STATE", "") +) +ACCESS_TOKEN = os.getenv("SIMPLECHAT_UI_ACCESS_TOKEN", "") +ARTIFACT_DIR = Path(os.getenv("SIMPLECHAT_UI_ARTIFACT_DIR", Path(__file__).parent / "artifacts")) +SMOKE_PROMPT = os.getenv( + "SIMPLECHAT_UI_SMOKE_PROMPT", + "CI smoke test. Reply with one short greeting.", +) +RESPONSE_TIMEOUT_MS = int(os.getenv("SIMPLECHAT_UI_SMOKE_RESPONSE_TIMEOUT_MS", "180000")) + + +def _require_staging_settings(): + """Skip when the staging URL or authenticated browser state is not configured.""" + if not BASE_URL: + pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this staging UI smoke test.") + if not ACCESS_TOKEN and (not STORAGE_STATE or not Path(STORAGE_STATE).exists()): + pytest.skip("Set SIMPLECHAT_UI_ACCESS_TOKEN or a valid SIMPLECHAT_UI_STORAGE_STATE/SIMPLECHAT_UI_ADMIN_STORAGE_STATE file.") + + +@pytest.mark.ui +def test_staging_chat_can_create_conversation_and_receive_response(playwright): + """Validate the basic staging chat loop against a deployed environment.""" + _require_staging_settings() + ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) + + browser = playwright.chromium.launch() + context_options = {"viewport": {"width": 1440, "height": 900}} + auth_headers = {} + if ACCESS_TOKEN: + auth_headers["Authorization"] = f"Bearer {ACCESS_TOKEN}" + context_options["extra_http_headers"] = auth_headers + else: + context_options["storage_state"] = STORAGE_STATE + + context = browser.new_context(**context_options) + trace_path = ARTIFACT_DIR / "staging_chat_smoke_trace.zip" + screenshot_path = ARTIFACT_DIR / "staging_chat_smoke_failure.png" + context.tracing.start(screenshots=True, snapshots=True, sources=True) + page = context.new_page() + conversation_id = None + + try: + if ACCESS_TOKEN: + session_response = context.request.post(f"{BASE_URL}/ci-auth/session", headers=auth_headers, timeout=30000) + assert session_response.ok, f"Expected CI bearer session setup to succeed, got HTTP {session_response.status}." + + response = page.goto(f"{BASE_URL}/chats", wait_until="networkidle", timeout=60000) + assert response is not None, "Expected a navigation response when loading /chats." + if response.status in (401, 403): + pytest.fail("Authenticated storage state was rejected by the staging chat page.") + assert response.ok, f"Expected /chats to load successfully, got HTTP {response.status}." + + expect(page.locator("#user-input")).to_be_visible(timeout=30000) + expect(page.locator("#send-btn")).to_be_attached(timeout=30000) + + page.locator("#new-conversation-btn").click() + page.locator("#user-input").fill(SMOKE_PROMPT) + page.locator("#send-btn").click() + + expect(page.locator(".user-message .message-text").filter(has_text=SMOKE_PROMPT)).to_be_visible(timeout=15000) + + page.wait_for_function( + """ + () => Array.from(document.querySelectorAll('.ai-message .message-text')).some(element => { + const text = (element.textContent || '').trim(); + const messageElement = element.closest('.ai-message'); + return Boolean( + text + && !text.includes('Streaming...') + && !text.includes('Reconnecting') + && messageElement + && !messageElement.querySelector('.streaming-cursor, .spinner-border') + ); + }) + """, + timeout=RESPONSE_TIMEOUT_MS, + ) + + assistant_message = page.locator(".ai-message .message-text").last + assistant_text = (assistant_message.text_content() or "").strip() + assert assistant_text, "Expected the assistant response to contain text." + + conversation_id = page.evaluate( + """ + () => window.chatConversations?.getCurrentConversationId?.() + || window.currentConversationId + || null + """ + ) + assert conversation_id, "Expected the staging smoke test to create or select a conversation." + + assert page.locator(".toast.show .text-bg-danger, .toast.show.bg-danger, .toast.show .alert-danger").count() == 0 + except Exception: + page.screenshot(path=screenshot_path, full_page=True) + raise + finally: + if conversation_id: + try: + context.request.delete(f"{BASE_URL}/api/conversations/{conversation_id}", timeout=30000) + except Exception: + pass + + context.tracing.stop(path=trace_path) + context.close() + browser.close()