From 2c4332fd5c544c8ae38cad0121ddc9aa81bb1263 Mon Sep 17 00:00:00 2001 From: Arnab Ghosh Date: Thu, 1 Oct 2026 16:03:31 +0530 Subject: [PATCH] feat: customer adoption and CI tooling (configure, doctor, Docker, CI templates, docs) - scripts: configure-sdk (SDK_* env -> sdk-config.json, validated, tested), doctor - Playwright: env-driven base URL, CI reporters (JUnit/HTML/GitHub), optional webServer - Dockerfile + nginx + entrypoint rendering sdk-config at start; CI smoke job - ci-templates for GitHub Actions, Azure DevOps, GitLab, Jenkins - docs: getting started, configuration, ci-cd, customizing, troubleshooting; README quick start - .nvmrc, .editorconfig, .env.example, devcontainer --- .devcontainer/devcontainer.json | 12 ++ .dockerignore | 9 ++ .editorconfig | 12 ++ .env.example | 19 +++ .github/workflows/quality.yml | 21 ++++ .gitignore | 4 + .nvmrc | 1 + AGENTS.md | 2 + Dockerfile | 23 ++++ README.md | 21 ++++ ci-templates/Jenkinsfile | 39 ++++++ ci-templates/azure-pipelines.yml | 66 +++++++++++ ci-templates/github-actions.yml | 76 ++++++++++++ ci-templates/gitlab-ci.yml | 39 ++++++ docker/entrypoint.sh | 8 ++ docker/nginx.conf | 33 ++++++ docs/ci-cd.md | 71 +++++++++++ docs/configuration.md | 56 +++++++++ docs/customizing.md | 57 +++++++++ docs/getting-started.md | 74 ++++++++++++ docs/troubleshooting.md | 40 +++++++ package.json | 6 +- playwright.config.js | 35 ++++-- projects/angular-test-app/tests/config.js | 8 +- .../SelfService/SelfServicePortal.spec.js | 2 +- scripts/__tests__/sdk-config.test.js | 65 ++++++++++ scripts/configure-sdk.js | 60 ++++++++++ scripts/doctor.js | 101 ++++++++++++++++ scripts/lib/sdk-config.js | 112 ++++++++++++++++++ 29 files changed, 1059 insertions(+), 13 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .nvmrc create mode 100644 Dockerfile create mode 100644 ci-templates/Jenkinsfile create mode 100644 ci-templates/azure-pipelines.yml create mode 100644 ci-templates/github-actions.yml create mode 100644 ci-templates/gitlab-ci.yml create mode 100644 docker/entrypoint.sh create mode 100644 docker/nginx.conf create mode 100644 docs/ci-cd.md create mode 100644 docs/configuration.md create mode 100644 docs/customizing.md create mode 100644 docs/getting-started.md create mode 100644 docs/troubleshooting.md create mode 100644 scripts/__tests__/sdk-config.test.js create mode 100644 scripts/configure-sdk.js create mode 100644 scripts/doctor.js create mode 100644 scripts/lib/sdk-config.js diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..87f57e732 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,12 @@ +{ + "name": "Angular SDK Components", + "image": "mcr.microsoft.com/devcontainers/typescript-node:24", + "postCreateCommand": "npm ci --ignore-scripts && npx playwright install --with-deps chromium", + "forwardPorts": [3500], + "portsAttributes": { "3500": { "label": "SDK dev server" } }, + "customizations": { + "vscode": { + "extensions": ["angular.ng-template", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "ms-playwright.playwright"] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..a567d2bde --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.angular +.git +**/*.tgz +test-results +tests/playwright-report +packages/angular-sdk-overrides/lib +keys diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..8c52ff937 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..48a43c47f --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Copy to .env (git-ignored) and `source` it, or set these in your CI system. +# `npm run configure` applies them to sdk-config.json. Empty values are ignored. +# Full reference: docs/configuration.md + +SDK_INFINITY_REST_SERVER_URL=https://my-pega.example.com/prweb +SDK_APP_ALIAS=MediaCo +SDK_PORTAL_CLIENT_ID= +SDK_MASHUP_CLIENT_ID= +SDK_MASHUP_USER_IDENTIFIER= +# Plain text here; the script Base64-encodes it into sdk-config.json +SDK_MASHUP_PASSWORD= +SDK_CONTENT_SERVER_URL= +SDK_APP_PORTAL= +SDK_APP_MASHUP_CASE_TYPE= +SDK_SHOW_MODALS_IN_EMBEDDED_MODE=false +SDK_THEME=dark + +# End-to-end tests +SDK_E2E_BASE_URL=http://localhost:3500 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 0acc0c81f..e83a28ff3 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -28,6 +28,8 @@ jobs: run: npm run build-angular-sdk-components - name: Public API report is up to date run: npm run api:check + - name: Tooling script tests + run: npm run test:scripts - name: Component catalogue is up to date run: npm run docs:components:check - name: noImplicitAny ratchet @@ -49,3 +51,22 @@ jobs: cache: npm - run: npm ci --ignore-scripts - run: npm run test:unit + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build image + run: docker build -t sdk-ci . + - name: Smoke test (config is rendered from SDK_* variables at start) + run: | + docker run -d --name sdk -p 8080:8080 \ + -e SDK_INFINITY_REST_SERVER_URL=https://pega.example.com/prweb \ + -e SDK_APP_ALIAS=SmokeApp sdk-ci + for i in $(seq 1 30); do curl -fsS http://localhost:8080/healthz && break; sleep 1; done + curl -fsS http://localhost:8080/sdk-config.json | grep -q '"appAlias": "SmokeApp"' + curl -fsS -o /dev/null http://localhost:8080/portal + curl -fsS -o /dev/null http://localhost:8080/embedded + - name: Container logs + if: failure() + run: docker logs sdk diff --git a/.gitignore b/.gitignore index 9d9a0fd25..cd36d4e93 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ packages/angular-sdk-overrides/SECURITY.md **/tsconfig.*.tsbuildinfo temp/ + +# local environment files +.env +.env.local diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/AGENTS.md b/AGENTS.md index 95ace5258..d1fb9ac07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,8 @@ angular-sdk-components/ | `npm run check:any` / `check:any:update` | `noImplicitAny` per-file ratchet (do not add new implicit-any errors) | | `npm run api:check` / `api:update` | Public API report guard (`etc/angular-sdk-components.api.md`) | | `npm run check:overrides` | Type-check the generated overrides package against the built library | +| `npm run doctor` / `npm run configure` | Pre-flight environment check / render `sdk-config.json` from `SDK_*` env vars (see docs/configuration.md) | +| `npm run test:scripts` | Unit tests for the tooling scripts (`scripts/__tests__`) | | `npm run docs:components` | Regenerate `docs/components.md` from the component map | ### Prerequisites diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..08cd8b279 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 + +# ---- build: compile the Angular app with the production configuration ---- +FROM node:24-alpine AS build +WORKDIR /app +COPY package.json package-lock.json .npmrc ./ +RUN npm ci --ignore-scripts +COPY . . +# Optional build-time sdk-config values; prefer runtime SDK_* variables (see docs/configuration.md) +RUN npm run prod-build-angularsdk + +# ---- runtime: static files behind nginx, sdk-config.json generated from SDK_* env vars at start ---- +FROM nginxinc/nginx-unprivileged:alpine AS runtime +USER root +RUN apk add --no-cache nodejs +COPY --from=build --chown=101:101 /app/dist /usr/share/nginx/html +COPY --chown=101:101 scripts/configure-sdk.js /opt/sdk/scripts/configure-sdk.js +COPY --chown=101:101 scripts/lib /opt/sdk/scripts/lib +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --chmod=755 docker/entrypoint.sh /docker-entrypoint.d/40-sdk-config.sh +USER 101 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 diff --git a/README.md b/README.md index 8a8630278..06b3f1710 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,27 @@ with the Angular SDK using the information (including pointers to online documen [**Pega Community**](https://community.pega.com/marketplace/component/angular-sdk) and the Angular SDK code on [**GitHub**](https://community.pega.com/marketplace/component/angular-sdk). +## Quick start for developers + +Clone this repository to build and customize your own Constellation Angular app, then: + +```bash +npm ci +cp .env.example .env && $EDITOR .env # Infinity URL, OAuth client ID, ... +set -a; source .env; set +a +npm run configure # writes sdk-config.json from the environment +npm run doctor # pre-flight check of your setup +npm run start-dev # http://localhost:3500 +``` + +| | | +| --- | --- | +| Setup walkthrough | [docs/getting-started.md](docs/getting-started.md) | +| Configuration reference (`sdk-config.json`, environment variables) | [docs/configuration.md](docs/configuration.md) | +| CI/CD (GitHub Actions, Azure DevOps, GitLab, Jenkins), Docker, E2E | [docs/ci-cd.md](docs/ci-cd.md) | +| Customize, override or add components | [docs/customizing.md](docs/customizing.md) | +| Troubleshooting | [docs/troubleshooting.md](docs/troubleshooting.md) | + ## Packages in this repo * [**angular-sdk-components**](https://www.npmjs.com/package/@pega/angular-sdk-components)
diff --git a/ci-templates/Jenkinsfile b/ci-templates/Jenkinsfile new file mode 100644 index 000000000..ea6e04582 --- /dev/null +++ b/ci-templates/Jenkinsfile @@ -0,0 +1,39 @@ +// Required credentials/env: SDK_INFINITY_REST_SERVER_URL, SDK_APP_ALIAS, SDK_PORTAL_CLIENT_ID (job env or folder properties), +// 'sdk-mashup-password' (secret text) and optionally SDK_E2E_BASE_URL. +pipeline { + agent { docker { image 'node:24' } } + options { timestamps() } + stages { + stage('Install') { steps { sh 'npm ci --ignore-scripts' } } + stage('Pre-flight') { steps { sh 'npm run doctor -- --offline' } } + stage('Lint') { steps { sh 'npm run lint' } } + stage('Configure') { + steps { + withCredentials([string(credentialsId: 'sdk-mashup-password', variable: 'SDK_MASHUP_PASSWORD')]) { + sh 'npm run configure' + } + } + } + stage('Build') { + steps { + sh 'npm run prod-build-angularsdk' + archiveArtifacts artifacts: 'dist/**', fingerprint: true + } + } + stage('E2E') { + when { expression { return env.SDK_E2E_BASE_URL?.trim() } } + agent { docker { image 'mcr.microsoft.com/playwright:v1.63.0-jammy' } } + environment { CI = 'true'; PW_SLOW_MO = '0' } + steps { + sh 'npm ci --ignore-scripts' + sh 'npm test' + } + post { + always { + junit 'test-results/junit.xml' + archiveArtifacts artifacts: 'tests/playwright-report/**', allowEmptyArchive: true + } + } + } + } +} diff --git a/ci-templates/azure-pipelines.yml b/ci-templates/azure-pipelines.yml new file mode 100644 index 000000000..24c61ffba --- /dev/null +++ b/ci-templates/azure-pipelines.yml @@ -0,0 +1,66 @@ +# Copy to azure-pipelines.yml +# Define these variables on the pipeline (mark SDK_MASHUP_PASSWORD as secret): +# SDK_INFINITY_REST_SERVER_URL, SDK_APP_ALIAS, SDK_PORTAL_CLIENT_ID, SDK_MASHUP_PASSWORD, SDK_E2E_BASE_URL (optional) +trigger: + branches: + include: [main, master] +pr: + branches: + include: ['*'] + +pool: + vmImage: ubuntu-latest + +stages: + - stage: Build + jobs: + - job: build + steps: + - task: NodeTool@0 + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + - script: npm ci --ignore-scripts + displayName: Install + - script: npm run doctor -- --offline + displayName: Pre-flight + - script: npm run lint + displayName: Lint + - script: npm run configure + displayName: Render sdk-config.json + env: + SDK_INFINITY_REST_SERVER_URL: $(SDK_INFINITY_REST_SERVER_URL) + SDK_APP_ALIAS: $(SDK_APP_ALIAS) + SDK_PORTAL_CLIENT_ID: $(SDK_PORTAL_CLIENT_ID) + SDK_MASHUP_PASSWORD: $(SDK_MASHUP_PASSWORD) + - script: npm run prod-build-angularsdk + displayName: Production build + - publish: dist + artifact: sdk-dist + + - stage: E2E + dependsOn: Build + condition: and(succeeded(), ne(variables['SDK_E2E_BASE_URL'], '')) + jobs: + - job: playwright + steps: + - task: NodeTool@0 + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + - script: npm ci --ignore-scripts && npx playwright install --with-deps chromium + displayName: Install + - script: npm test + displayName: Playwright + env: + CI: 'true' + PW_SLOW_MO: '0' + SDK_E2E_BASE_URL: $(SDK_E2E_BASE_URL) + - task: PublishTestResults@2 + condition: always() + inputs: + testResultsFormat: JUnit + testResultsFiles: test-results/junit.xml + - publish: tests/playwright-report + artifact: playwright-report + condition: always() diff --git a/ci-templates/github-actions.yml b/ci-templates/github-actions.yml new file mode 100644 index 000000000..7b8cd9791 --- /dev/null +++ b/ci-templates/github-actions.yml @@ -0,0 +1,76 @@ +# Copy to .github/workflows/sdk-ci.yml +# +# Required repository settings +# Variables (Settings > Secrets and variables > Actions > Variables): +# SDK_INFINITY_REST_SERVER_URL, SDK_APP_ALIAS, SDK_PORTAL_CLIENT_ID, SDK_E2E_BASE_URL (optional, deployed env for E2E) +# Secrets: SDK_MASHUP_PASSWORD (only if you use the embedded/mashup flow) +name: sdk-ci + +on: + pull_request: + push: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sdk-ci-${{ github.ref }} + cancel-in-progress: true + +env: + SDK_INFINITY_REST_SERVER_URL: ${{ vars.SDK_INFINITY_REST_SERVER_URL }} + SDK_APP_ALIAS: ${{ vars.SDK_APP_ALIAS }} + SDK_PORTAL_CLIENT_ID: ${{ vars.SDK_PORTAL_CLIENT_ID }} + SDK_MASHUP_PASSWORD: ${{ secrets.SDK_MASHUP_PASSWORD }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci --ignore-scripts + - name: Pre-flight (no network) + run: npm run doctor -- --offline + - name: Lint + run: npm run lint + - name: Render sdk-config.json from environment + run: npm run configure + - name: Production build + run: npm run prod-build-angularsdk + - uses: actions/upload-artifact@v4 + with: + name: sdk-dist + path: dist + retention-days: 7 + + e2e: + # Runs against an already deployed environment; remove this job if you have no E2E environment. + if: ${{ vars.SDK_E2E_BASE_URL != '' }} + needs: build + runs-on: ubuntu-latest + env: + CI: 'true' + SDK_E2E_BASE_URL: ${{ vars.SDK_E2E_BASE_URL }} + PW_SLOW_MO: '0' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci --ignore-scripts + - run: npx playwright install --with-deps chromium + - run: npm test + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + tests/playwright-report + test-results diff --git a/ci-templates/gitlab-ci.yml b/ci-templates/gitlab-ci.yml new file mode 100644 index 000000000..b8efb4e35 --- /dev/null +++ b/ci-templates/gitlab-ci.yml @@ -0,0 +1,39 @@ +# Copy to .gitlab-ci.yml +# CI/CD variables: SDK_INFINITY_REST_SERVER_URL, SDK_APP_ALIAS, SDK_PORTAL_CLIENT_ID, SDK_MASHUP_PASSWORD (masked), SDK_E2E_BASE_URL (optional) +image: node:24 + +stages: [build, e2e] + +cache: + key: + files: [package-lock.json] + paths: [.npm/] + +build: + stage: build + script: + - npm ci --ignore-scripts --cache .npm --prefer-offline + - npm run doctor -- --offline + - npm run lint + - npm run configure + - npm run prod-build-angularsdk + artifacts: + paths: [dist] + expire_in: 1 week + +e2e: + stage: e2e + image: mcr.microsoft.com/playwright:v1.63.0-jammy + rules: + - if: $SDK_E2E_BASE_URL + variables: + CI: 'true' + PW_SLOW_MO: '0' + script: + - npm ci --ignore-scripts --cache .npm --prefer-offline + - npm test + artifacts: + when: always + paths: [tests/playwright-report, test-results] + reports: + junit: test-results/junit.xml diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 000000000..607e1e110 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Runs before nginx starts (nginx image executes /docker-entrypoint.d/*.sh): renders sdk-config.json from SDK_* variables. +set -eu + +WEB_ROOT="${SDK_WEB_ROOT:-/usr/share/nginx/html}" +SCRIPT_DIR="${SDK_SCRIPT_DIR:-/opt/sdk/scripts}" + +node "$SCRIPT_DIR/configure-sdk.js" --file "$WEB_ROOT/sdk-config.json" --out "$WEB_ROOT/sdk-config.json" diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 000000000..59684a3f7 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip_static on; + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # Runtime configuration and entry pages must never be cached, otherwise config changes are not picked up. + location = /sdk-config.json { + add_header Cache-Control "no-store"; + } + + # Fingerprinted bundles are safe to cache for a long time. + location ~* \.(?:js|css|woff2?|svg|png|ico)$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # portal.html, embedded.html, fullportal.html, simpleportal.html and mashup.html are generated by the build. + location / { + add_header Cache-Control "no-cache"; + try_files $uri $uri.html /index.html; + } +} diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 000000000..6e5eb5f9b --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,71 @@ +# CI/CD + +Everything a pipeline needs is exposed as an npm script and is configured through environment variables, so the same repository builds for every environment and no secret is committed. + +## The pipeline in five steps + +| Step | Command | Notes | +| --- | --- | --- | +| Install | `npm ci --ignore-scripts` | Deterministic; `--ignore-scripts` skips the git-hook installer, which is not needed in CI. | +| Pre-flight | `npm run doctor -- --offline` | Fails fast on wrong Node version, missing dependencies or an invalid `sdk-config.json`. | +| Quality | `npm run lint` and `npm run test:unit` | Unit tests need no Pega server. | +| Configure | `npm run configure` | Applies `SDK_*` variables ([configuration.md](configuration.md)). | +| Build | `npm run prod-build-angularsdk` | Output in `dist/` (brotli/gzip compressed). | + +Optional: `npm test` (Playwright) against a deployed environment, and `docker build .` for a container image. + +## Ready-made templates + +Copy the one for your platform from [`ci-templates/`](../ci-templates): + +| Platform | File | +| --- | --- | +| GitHub Actions | `github-actions.yml` | +| Azure DevOps | `azure-pipelines.yml` | +| GitLab CI | `gitlab-ci.yml` | +| Jenkins | `Jenkinsfile` | + +Each template: caches dependencies, builds, publishes `dist/` as an artifact, and runs the E2E stage only when `SDK_E2E_BASE_URL` is defined, publishing JUnit results and the HTML report. Define `SDK_INFINITY_REST_SERVER_URL`, `SDK_APP_ALIAS` and `SDK_PORTAL_CLIENT_ID` as variables and `SDK_MASHUP_PASSWORD` as a secret. + +## Build once, deploy many + +```bash +npm run prod-build-angularsdk # once +SDK_INFINITY_REST_SERVER_URL=https://test.example.com/prweb \ + npm run configure -- --out dist/sdk-config.json # per environment +``` + +Because `sdk-config.json` is read by the browser at runtime, you can promote the same `dist/` through test, staging and production. + +## Docker + +```bash +docker build -t my-sdk . +docker run -p 8080:8080 \ + -e SDK_INFINITY_REST_SERVER_URL=https://my-pega.example.com/prweb \ + -e SDK_PORTAL_CLIENT_ID=... -e SDK_APP_ALIAS=MediaCo my-sdk +``` + +- Multi-stage build; the runtime image is non-root nginx on port 8080. +- `sdk-config.json` is rendered from `SDK_*` variables every time the container starts; no rebuild is needed to change environments. +- `GET /healthz` is the liveness/readiness endpoint. +- Entry pages: `/portal`, `/embedded`, `/fullportal`, `/simpleportal`. +- Register `https:///` as an allowed redirect URI on the Pega OAuth client. + +## End-to-end tests in CI + +```bash +CI=true SDK_E2E_BASE_URL=https://test.example.com PW_SLOW_MO=0 npm test +``` + +- Needs a Pega Infinity server with the MediaCo sample application (see [testing.md](testing.md)). To test your own application, adapt the specs under `projects/angular-test-app/tests`. +- With `CI` set you get retries, a JUnit report (`test-results/junit.xml`), the HTML report (`tests/playwright-report`), and traces/videos for failures. +- Install browsers on the agent first: `npx playwright install --with-deps chromium`. + +## Keeping your fork current + +This repository publishes `@pega/angular-sdk-components` and `@pega/angular-sdk-overrides`. When you track upstream, watch the public API report (`etc/angular-sdk-components.api.md`) and `CHANGELOG.md` for changes that affect your customizations; the `quality` workflow in this repository shows the checks worth keeping in your own pipeline (`api:check`, `check:overrides`, `check:any`). + +## Troubleshooting pipelines + +See [troubleshooting.md](troubleshooting.md). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 000000000..4c8802a98 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,56 @@ +# Configuration + +Runtime settings live in `sdk-config.json` in the repository root. It is copied to the root of the build output (`dist/sdk-config.json`) and fetched by the browser at startup, so **it can be changed after the build without recompiling**. + +## Environment variables + +`npm run configure` (`node scripts/configure-sdk.js`) applies these variables to `sdk-config.json`. Unset or empty variables leave the file value untouched, so you can mix a committed base file with per-environment overrides. + +| Variable | `sdk-config.json` setting | Notes | +| --- | --- | --- | +| `SDK_INFINITY_REST_SERVER_URL` | `serverConfig.infinityRestServerUrl` | Required. Full URL of the Infinity REST server, for example `https://host/prweb` (no trailing slash). | +| `SDK_PORTAL_CLIENT_ID` | `authConfig.portalClientId` | Required. OAuth 2.0 client ID for the portal use case. | +| `SDK_APP_ALIAS` | `serverConfig.appAlias` | Application alias operators will use. | +| `SDK_CONTENT_SERVER_URL` | `serverConfig.sdkContentServerUrl` | Blank means `window.location.origin`. | +| `SDK_APP_PORTAL` | `serverConfig.appPortal` | Blank means the operator's default portal. | +| `SDK_APP_MASHUP_CASE_TYPE` | `serverConfig.appMashupCaseType` | Case type for embedded/mashup. | +| `SDK_SHOW_MODALS_IN_EMBEDDED_MODE` | `serverConfig.showModalsInEmbeddedMode` | `true` or `false`. | +| `SDK_MASHUP_CLIENT_ID` | `authConfig.mashupClientId` | Mashup OAuth client ID. | +| `SDK_MASHUP_USER_IDENTIFIER` | `authConfig.mashupUserIdentifier` | | +| `SDK_MASHUP_PASSWORD` | `authConfig.mashupPassword` | **Provide plain text; it is Base64 encoded into the file.** Store it as a CI secret. | +| `SDK_AUTH_SERVICE` | `authConfig.authService` | | +| `SDK_THEME` | `theme` | For example `dark`. | + +Other settings in the file (for example `excludePortals`) are documented in the [official guide](https://docs.pega.com/bundle/constellation-sdk/page/constellation-sdks/sdks/configuring-sdk-config-json.html). + +## Commands + +```bash +npm run configure # apply env vars in place +npm run configure -- --out dist/sdk-config.json # write the result to another file (the source is untouched) +npm run configure -- --print # also print the result (secrets masked) +npm run configure:check # validate only; exit 1 on errors, nothing written +``` + +## Choosing where to apply configuration + +| Approach | When | +| --- | --- | +| `npm run configure` before the build | One build per environment (simple pipelines). | +| `npm run configure -- --out dist/sdk-config.json` after the build | Build once, deploy many: promote the same `dist/` and render the config per environment. | +| Container with `SDK_*` variables | Kubernetes/ECS: the image renders `sdk-config.json` at container start. See [ci-cd.md](ci-cd.md#docker). | + +## Secrets + +Never commit `mashupPassword` or any credential. `sdk-config.json` is a public file served to browsers: only put values there that you would be comfortable sending to every user. Use CI secrets for `SDK_MASHUP_PASSWORD` and rotate it if it was ever committed. + +## End-to-end test settings + +| Variable | Purpose | +| --- | --- | +| `SDK_E2E_BASE_URL` | Deployed app the Playwright suite targets (default `http://localhost:3500`). | +| `PW_START_SERVER=1` | Let Playwright start `npm run start-prod` itself. `PW_SERVER_COMMAND` overrides the command. | +| `PW_SLOW_MO` | Milliseconds between actions (default 200 locally; use `0` in CI). | +| `PW_WORKERS` | Parallel workers (default 1 in CI). | +| `PW_JUNIT_OUTPUT` | JUnit file path in CI (default `test-results/junit.xml`). | +| `CI` | When set: JUnit + HTML + list reporters, retries, video on failure. | diff --git a/docs/customizing.md b/docs/customizing.md new file mode 100644 index 000000000..c82bc9504 --- /dev/null +++ b/docs/customizing.md @@ -0,0 +1,57 @@ +# Customizing + +Three levels, from least to most invasive. Pick the lowest one that does the job; it keeps upgrades easy. + +## 1. Configuration and theming + +- Settings: [configuration.md](configuration.md). +- Look and feel: [theming.md](theming.md) (Material 3 tokens, dark theme). + +## Overriding a component + +Replace one Pega-provided component with your own implementation without editing the original. + +1. Create your component (copy the original from `packages/angular-sdk-components/src/lib/_components/` as a starting point, or scaffold one, see below). +2. Register it in `packages/angular-sdk-components/src/sdk-local-component-map.ts`: + + ```ts + import { MyTextComponent } from './lib/_components/field/my-text/my-text.component'; + + const localSdkComponentMap = { + Text: MyTextComponent + /* map end - DO NOT REMOVE */ + }; + ``` + + Entries in the local map win over the Pega-provided map, so `Text` now renders `MyTextComponent` everywhere. +3. Rules for the replacement: field components extend `FieldBase`, receive `pConn$`/`formGroup$` inputs, and render children through `` (see [architecture.md](architecture.md)). + +The `@pega/angular-sdk-overrides` npm package contains ready-to-edit copies of every component for projects that consume the published packages instead of this repository. + +## Adding a component + +```bash +npm run new:component -- +# example +npm run new:component -- field star-rating StarRating +``` + +It creates the component (`.ts`, `.html`, `.scss`, `.spec.ts`) following the repository conventions and registers it in `public-api.ts` and `sdk-pega-component-map.ts`. Then: + +```bash +npm run fix # format +npm run build-angular-sdk-components && npm run api:update # refresh the public API report +npm run docs:components # refresh the component catalogue +``` + +## Editing a component in place + +Allowed and supported. Keep these in mind: + +- Field value propagation, display-mode delegation and `` usage are described in `AGENTS.md` and `.github/instructions/components.instructions.md`. +- Run `npm run test:unit` and the relevant parts of the Playwright suite. +- `npm run check:any` guards against new implicit-`any` errors; `npm run api:check` flags public API changes. + +## Catalogue + +Every component name that Pega can render and the Angular class that implements it: [components.md](components.md). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 000000000..a7ab3243c --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,74 @@ +# Getting started + +Goal: from a fresh checkout to a running SDK app against your Pega Infinity server. + +## 1. Prerequisites + +- Node.js 24+ (`nvm use` reads `.nvmrc`) and npm. +- A Pega Infinity server with an OAuth 2.0 client registration for the SDK (see the [Constellation SDK docs](https://docs.pega.com/bundle/constellation-sdk/page/constellation-sdks/sdks/constellation-sdks.html)) and the application you want to render. +- Prefer zero setup? Open the repo in GitHub Codespaces or a Dev Container (`.devcontainer/`); dependencies and Playwright browsers are installed for you. + +## 2. Install + +```bash +git clone https://github.com/pegasystems/angular-sdk-components.git +cd angular-sdk-components +npm ci +``` + +## 3. Configure + +Edit `sdk-config.json`, **or** keep the file untouched and use environment variables (recommended for teams and CI; nothing environment-specific or secret gets committed): + +```bash +cp .env.example .env # fill in the values +set -a; source .env; set +a +npm run configure # writes the values into sdk-config.json +``` + +At minimum set `SDK_INFINITY_REST_SERVER_URL` and `SDK_PORTAL_CLIENT_ID`. All settings are described in [configuration.md](configuration.md). + +## 4. Check your environment + +```bash +npm run doctor +``` + +It verifies the Node version, installed dependencies, `sdk-config.json`, HTTPS keys, port 3500 and that your Infinity server is reachable, and tells you how to fix anything that is wrong. Use `npm run doctor -- --offline` to skip the network probe. + +## 5. Run + +```bash +npm run start-dev # http://localhost:3500 +npm run start-dev-https # with the bundled dev certificate in keys/ +``` + +Entry pages: `/portal` (full portal), `/embedded` (mashup / embedded), `/fullportal`, `/simpleportal`. + +## 6. Make it yours + +- Change a component: edit it under `packages/angular-sdk-components/src/lib/_components/`. +- Add a component: `npm run new:component -- field star-rating StarRating` (see [customizing.md](customizing.md)). +- Override a Pega-provided component without editing the originals: [customizing.md](customizing.md#overriding-a-component). + +## 7. Verify and ship + +```bash +npm run lint +npm run test:unit # no Pega server needed +npm run prod-build-angularsdk +``` + +Automate this with the pipeline templates in [ci-cd.md](ci-cd.md), or build a container image (`docker build -t my-sdk .`). + +## Where to go next + +| I want to... | Read | +| --- | --- | +| understand how it works | [architecture.md](architecture.md) | +| configure for dev/test/prod | [configuration.md](configuration.md) | +| set up CI/CD, Docker, E2E | [ci-cd.md](ci-cd.md) | +| customize or add components | [customizing.md](customizing.md) | +| write tests | [testing.md](testing.md) | +| theme the app | [theming.md](theming.md) | +| fix a problem | [troubleshooting.md](troubleshooting.md) | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 000000000..d6914a6a4 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,40 @@ +# Troubleshooting + +Start with `npm run doctor`; it detects most setup problems and prints how to fix them. Pega's [Troubleshooting Constellation SDKs](https://docs.pega.com/bundle/constellation-sdk/page/constellation-sdks/sdks/troubleshooting-constellation-sdks.html) page covers platform-side issues. + +## Setup + +| Symptom | Likely cause and fix | +| --- | --- | +| `engines` / syntax errors on install or build | Node older than 24. `nvm use` (reads `.nvmrc`). | +| `doctor`: "node_modules is missing" | Run `npm ci`. | +| `npm ci` cannot reach packages | `.npmrc` points at the public npm registry. Behind a corporate proxy/registry, override it in your user-level `.npmrc` or CI environment, and prefer `npm ci` over `npm install`. | +| Port 3500 already in use | Stop the other process or run `npx ng serve --port 3501`. Update `SDK_E2E_BASE_URL` accordingly. | +| Browser warns about the HTTPS certificate | Expected with the bundled dev certificate in `keys/`; trust it locally or use `start-dev` over HTTP. | + +## Configuration and login + +| Symptom | Likely cause and fix | +| --- | --- | +| `npm run configure` fails validation | The message names the setting and the environment variable that sets it. | +| Blank page or network error on load | `serverConfig.infinityRestServerUrl` is wrong or unreachable (VPN, trailing slash, certificate). `npm run doctor` probes it. | +| Login redirect loop / `redirect_uri` error | The URL you open (including port and path) must be registered as a redirect URI on the Pega OAuth 2.0 client. | +| CORS errors | Add your app origin to the Infinity CORS configuration. | +| Embedded/mashup login fails | `mashupClientId`, `mashupUserIdentifier` and the Base64 `mashupPassword` must be set (`SDK_MASHUP_PASSWORD` is encoded for you). | +| Config changes have no effect in a container/CDN | `sdk-config.json` is cached. Ensure it is served with `Cache-Control: no-store` (the bundled nginx config does this). | + +## Build and CI + +| Symptom | Likely cause and fix | +| --- | --- | +| `check:overrides` fails with "Cannot find module '@pega/angular-sdk-components'" | The library has not been built (`npm run build-angular-sdk-components`), or `npm run build` replaced `dist/`. | +| `api:check` fails | The public API changed. If intended: `npm run build-angular-sdk-components && npm run api:update` and commit `etc/angular-sdk-components.api.md`. | +| `check:any` fails | A file got more implicit-`any` errors than its baseline. Add types; if you fixed errors, `npm run check:any:update`. | +| `docs:components:check` fails | `npm run docs:components` and commit. | +| Production build exceeds a style budget | See the budgets in `angular.json`; keep component styles small. | +| Playwright can't find browsers on the agent | `npx playwright install --with-deps chromium`, or use the Playwright Docker image (see the templates). | +| E2E tests time out in CI | Check `SDK_E2E_BASE_URL` is reachable from the agent and the test users exist in the target app. | + +## Still stuck? + +Open an issue with the output of `npm run doctor`, your Node/npm versions and the failing command's log (remove secrets first). diff --git a/package.json b/package.json index 154597a16..e121059bc 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,11 @@ "new:component": "node scripts/new-component.js", "check:overrides": "ngc -p tsconfig.overrides-check.json", "docs:components": "node scripts/generate-component-catalog.js && prettier -w docs/components.md", - "docs:components:check": "node scripts/generate-component-catalog.js --check" + "docs:components:check": "node scripts/generate-component-catalog.js --check", + "configure": "node scripts/configure-sdk.js", + "configure:check": "node scripts/configure-sdk.js --check", + "doctor": "node scripts/doctor.js", + "test:scripts": "node --test scripts/__tests__" }, "dependencies": { "@angular/animations": "^21.2.4", diff --git a/playwright.config.js b/playwright.config.js index 97a484764..df7635ce9 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -27,21 +27,32 @@ const config = { /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: process.env.PW_WORKERS ? Number(process.env.PW_WORKERS) : process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [['html', { outputFolder: 'tests/playwright-report' }]], + // CI gets machine-readable output (JUnit for Azure DevOps/Jenkins/GitLab, annotations on GitHub) next to the HTML report. + reporter: process.env.CI + ? [ + ['list'], + ['html', { outputFolder: 'tests/playwright-report', open: 'never' }], + ['junit', { outputFile: process.env.PW_JUNIT_OUTPUT || 'test-results/junit.xml' }], + ...(process.env.GITHUB_ACTIONS ? [['github']] : []) + ] + : [['html', { outputFolder: 'tests/playwright-report' }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ actionTimeout: 50000, /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://localhost:3000', + baseURL: process.env.SDK_E2E_BASE_URL || 'http://localhost:3500', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: process.env.CI ? 'retain-on-failure' : 'off', ignoreHTTPSErrors: true, launchOptions: { - slowMo: 200 + // Override with PW_SLOW_MO=0 for faster CI runs + slowMo: Number(process.env.PW_SLOW_MO ?? 200) } }, testIgnore: ['e2e/DigV2/ComplexFields/ManyToMany.spec.js', 'e2e/DigV2/Localization/Localization.spec.js'], @@ -95,16 +106,22 @@ const config = { // channel: 'chrome', // }, // }, - ] + ], /* Folder for test artifacts such as screenshots, videos, traces, etc. */ // outputDir: 'test-results/', /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // port: 3000, - // }, + // Set PW_START_SERVER=1 to have Playwright build-serve the app itself (needs sdk-config.json to be configured). + webServer: process.env.PW_START_SERVER + ? { + command: process.env.PW_SERVER_COMMAND || 'npm run start-prod', + url: process.env.SDK_E2E_BASE_URL || 'http://localhost:3500', + reuseExistingServer: !process.env.CI, + timeout: 10 * 60 * 1000, + ignoreHTTPSErrors: true + } + : undefined }; module.exports = config; diff --git a/projects/angular-test-app/tests/config.js b/projects/angular-test-app/tests/config.js index 84a4eface..e0100ec68 100644 --- a/projects/angular-test-app/tests/config.js +++ b/projects/angular-test-app/tests/config.js @@ -1,6 +1,10 @@ +// Point the suite at any deployment: SDK_E2E_BASE_URL=https://my-env.example.com npx playwright test +const origin = (process.env.SDK_E2E_BASE_URL || 'http://localhost:3500').replace(/\/$/, ''); + const config = { - baseUrl: 'http://localhost:3500/portal', - baseEmbedUrl: 'http://localhost:3500/embedded', + origin, + baseUrl: `${origin}/portal`, + baseEmbedUrl: `${origin}/embedded`, apps: { mediaCo: { rep: { diff --git a/projects/angular-test-app/tests/e2e/DigV2/SelfService/SelfServicePortal.spec.js b/projects/angular-test-app/tests/e2e/DigV2/SelfService/SelfServicePortal.spec.js index 294f0203e..13b102a0e 100644 --- a/projects/angular-test-app/tests/e2e/DigV2/SelfService/SelfServicePortal.spec.js +++ b/projects/angular-test-app/tests/e2e/DigV2/SelfService/SelfServicePortal.spec.js @@ -4,7 +4,7 @@ const common = require('../../../common'); test.beforeEach(async ({ page }) => { await page.setViewportSize({ width: 1920, height: 1080 }); - await page.goto('http://localhost:3500/portal?portal=DigV2SelfService'); + await page.goto(`${config.config.baseUrl}?portal=DigV2SelfService`); }); test.describe('E2E test', () => { diff --git a/scripts/__tests__/sdk-config.test.js b/scripts/__tests__/sdk-config.test.js new file mode 100644 index 000000000..a08fd98cb --- /dev/null +++ b/scripts/__tests__/sdk-config.test.js @@ -0,0 +1,65 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { applyEnv, validate, redact } = require('../lib/sdk-config'); + +const base = () => ({ + theme: 'dark', + authConfig: { portalClientId: 'abc', mashupClientId: '', mashupPassword: '' }, + serverConfig: { infinityRestServerUrl: 'https://pega.example.com/prweb', appAlias: '', showModalsInEmbeddedMode: false } +}); + +test('applyEnv overrides only the variables that are set', () => { + const { config, applied } = applyEnv(base(), { SDK_APP_ALIAS: 'MediaCo', SDK_THEME: '' }); + assert.equal(config.serverConfig.appAlias, 'MediaCo'); + assert.equal(config.theme, 'dark'); + assert.deepEqual(applied, ['SDK_APP_ALIAS']); +}); + +test('applyEnv does not mutate its input', () => { + const input = base(); + applyEnv(input, { SDK_APP_ALIAS: 'X' }); + assert.equal(input.serverConfig.appAlias, ''); +}); + +test('applyEnv Base64 encodes the mashup password', () => { + const { config } = applyEnv(base(), { SDK_MASHUP_PASSWORD: 'p@ss' }); + assert.equal(config.authConfig.mashupPassword, Buffer.from('p@ss').toString('base64')); +}); + +test('applyEnv parses booleans and rejects invalid values', () => { + assert.equal(applyEnv(base(), { SDK_SHOW_MODALS_IN_EMBEDDED_MODE: 'TRUE' }).config.serverConfig.showModalsInEmbeddedMode, true); + assert.throws(() => applyEnv(base(), { SDK_SHOW_MODALS_IN_EMBEDDED_MODE: 'yes' }), /must be "true" or "false"/); +}); + +test('validate accepts a complete config', () => { + assert.deepEqual(validate(base()).errors, []); +}); + +test('validate reports missing and malformed values', () => { + const cfg = base(); + cfg.serverConfig.infinityRestServerUrl = 'not a url'; + cfg.authConfig.portalClientId = ''; + const { errors } = validate(cfg); + assert.equal(errors.length, 2); + + cfg.serverConfig.infinityRestServerUrl = ''; + assert.match(validate(cfg).errors[0], /empty/); +}); + +test('validate warns about trailing slash and mashup without password', () => { + const cfg = base(); + cfg.serverConfig.infinityRestServerUrl = 'https://pega.example.com/prweb/'; + cfg.authConfig.mashupClientId = 'm1'; + const { warnings } = validate(cfg); + assert.ok(warnings.some(w => /slash/.test(w))); + assert.ok(warnings.some(w => /mashupPassword/.test(w))); +}); + +test('redact masks secrets without touching the original', () => { + const cfg = base(); + cfg.authConfig.mashupPassword = 'c2VjcmV0'; + assert.equal(redact(cfg).authConfig.mashupPassword, '********'); + assert.equal(cfg.authConfig.mashupPassword, 'c2VjcmV0'); +}); diff --git a/scripts/configure-sdk.js b/scripts/configure-sdk.js new file mode 100644 index 000000000..e5e20a3f6 --- /dev/null +++ b/scripts/configure-sdk.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +'use strict'; + +/* + * Configures sdk-config.json from environment variables so the same checkout can be built for any environment in CI + * without committing environment-specific values or secrets. + * + * node scripts/configure-sdk.js apply SDK_* env vars to ./sdk-config.json in place + * node scripts/configure-sdk.js --out dist/sdk-config.json + * node scripts/configure-sdk.js --check validate only (exit 1 on errors), nothing is written + * node scripts/configure-sdk.js --print print the resulting config with secrets masked + * + * See docs/configuration.md for the variable list. + */ +const fs = require('node:fs'); +const path = require('node:path'); +const { applyEnv, validate, redact } = require('./lib/sdk-config'); + +function parseArgs(argv) { + const args = { file: 'sdk-config.json', out: undefined, check: false, print: false }; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === '--check') args.check = true; + else if (a === '--print') args.print = true; + else if (a === '--file') args.file = argv[++i]; + else if (a === '--out') args.out = argv[++i]; + else throw new Error(`Unknown argument: ${a}`); + } + return args; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const file = path.resolve(args.file); + const original = JSON.parse(fs.readFileSync(file, 'utf8')); + const { config, applied } = applyEnv(original, process.env); + const { errors, warnings } = validate(config); + + warnings.forEach(w => console.warn(`warning: ${w}`)); + errors.forEach(e => console.error(`error: ${e}`)); + + if (args.print) console.log(JSON.stringify(redact(config), null, 2)); + if (errors.length) process.exit(1); + if (args.check) { + console.log(`${path.relative(process.cwd(), file)} is valid${applied.length ? ` (with overrides: ${applied.join(', ')})` : ''}`); + return; + } + + const target = path.resolve(args.out || args.file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, `${JSON.stringify(config, null, 2)}\n`); + console.log(`Wrote ${path.relative(process.cwd(), target)}${applied.length ? ` (applied: ${applied.join(', ')})` : ' (no SDK_* variables set)'}`); +} + +try { + main(); +} catch (e) { + console.error(`error: ${e.message}`); + process.exit(1); +} diff --git a/scripts/doctor.js b/scripts/doctor.js new file mode 100644 index 000000000..4206e2cf5 --- /dev/null +++ b/scripts/doctor.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +'use strict'; + +/* + * Pre-flight checks for a new checkout or a CI agent: `npm run doctor` (add `-- --offline` to skip the network probe). + * Exits 1 when a blocking problem is found so it can gate a pipeline. + */ +const fs = require('node:fs'); +const net = require('node:net'); +const path = require('node:path'); +const { applyEnv, validate } = require('./lib/sdk-config'); + +const root = path.resolve(__dirname, '..'); +const offline = process.argv.includes('--offline'); +const results = []; +const add = (level, name, detail) => results.push({ level, name, detail }); + +function checkNode() { + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const wanted = (pkg.engines && pkg.engines.node) || ''; + const min = Number((/(\d+)/.exec(wanted) || [])[1] || 0); + const major = Number(process.versions.node.split('.')[0]); + if (major >= min) add('ok', 'Node.js', `v${process.versions.node} (requires ${wanted || 'any'})`); + else add('error', 'Node.js', `v${process.versions.node} is too old; requires ${wanted}. Use "nvm use" (see .nvmrc).`); +} + +function checkDependencies() { + const ok = ['@angular/core', '@pega/auth', '@pega/constellationjs'].every(p => fs.existsSync(path.join(root, 'node_modules', p))); + if (ok) add('ok', 'Dependencies', 'node_modules present'); + else add('error', 'Dependencies', 'node_modules is missing or incomplete; run "npm ci" (or "npm install")'); +} + +function loadConfig() { + const file = path.join(root, 'sdk-config.json'); + if (!fs.existsSync(file)) { + add('error', 'sdk-config.json', 'not found in the repository root'); + return undefined; + } + try { + const { config, applied } = applyEnv(JSON.parse(fs.readFileSync(file, 'utf8')), process.env); + const { errors, warnings } = validate(config); + errors.forEach(e => add('error', 'sdk-config.json', e)); + warnings.forEach(w => add('warn', 'sdk-config.json', w)); + if (!errors.length) add('ok', 'sdk-config.json', applied.length ? `valid (env overrides: ${applied.join(', ')})` : 'valid'); + return config; + } catch (e) { + add('error', 'sdk-config.json', `cannot be parsed: ${e.message}`); + return undefined; + } +} + +function checkHttpsKeys() { + const ok = ['sdk-a.key', 'sdk-a.crt'].every(f => fs.existsSync(path.join(root, 'keys', f))); + add( + ok ? 'ok' : 'warn', + 'HTTPS keys', + ok ? 'keys/sdk-a.key and keys/sdk-a.crt found (npm run start-dev-https)' : 'keys/ not found; only needed for start-dev-https' + ); +} + +function portFree(port) { + return new Promise(resolve => { + const srv = net.createServer(); + srv.once('error', () => resolve(false)); + srv.once('listening', () => srv.close(() => resolve(true))); + srv.listen(port, '127.0.0.1'); + }); +} + +async function checkPort() { + const free = await portFree(3500); + add(free ? 'ok' : 'warn', 'Port 3500', free ? 'available for the dev server' : 'in use; stop the other process or pass --port to ng serve'); +} + +async function checkInfinity(config) { + if (offline || !config) return; + const url = config.serverConfig && config.serverConfig.infinityRestServerUrl; + if (!url) return; + try { + const res = await fetch(url, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(8000) }); + add('ok', 'Pega Infinity', `${url} responded (HTTP ${res.status})`); + } catch (e) { + const cause = (e.cause && e.cause.code) || e.name; + add('warn', 'Pega Infinity', `${url} not reachable (${cause}). Check VPN/URL, CORS and certificates. Use --offline to skip.`); + } +} + +(async () => { + checkNode(); + checkDependencies(); + const config = loadConfig(); + checkHttpsKeys(); + await checkPort(); + await checkInfinity(config); + + const icon = { ok: 'PASS', warn: 'WARN', error: 'FAIL' }; + results.forEach(r => console.log(`${icon[r.level]} ${r.name}: ${r.detail}`)); + const failed = results.filter(r => r.level === 'error').length; + console.log(failed ? `\n${failed} blocking problem(s) found.` : '\nEnvironment looks good.'); + process.exit(failed ? 1 : 0); +})(); diff --git a/scripts/lib/sdk-config.js b/scripts/lib/sdk-config.js new file mode 100644 index 000000000..865a31c00 --- /dev/null +++ b/scripts/lib/sdk-config.js @@ -0,0 +1,112 @@ +'use strict'; + +/** Environment variable -> sdk-config.json path (dot notation). */ +const ENV_MAP = { + SDK_THEME: 'theme', + SDK_AUTH_SERVICE: 'authConfig.authService', + SDK_PORTAL_CLIENT_ID: 'authConfig.portalClientId', + SDK_MASHUP_CLIENT_ID: 'authConfig.mashupClientId', + SDK_MASHUP_USER_IDENTIFIER: 'authConfig.mashupUserIdentifier', + SDK_INFINITY_REST_SERVER_URL: 'serverConfig.infinityRestServerUrl', + SDK_APP_ALIAS: 'serverConfig.appAlias', + SDK_CONTENT_SERVER_URL: 'serverConfig.sdkContentServerUrl', + SDK_APP_PORTAL: 'serverConfig.appPortal', + SDK_APP_MASHUP_CASE_TYPE: 'serverConfig.appMashupCaseType' +}; + +const BOOLEAN_ENV_MAP = { + SDK_SHOW_MODALS_IN_EMBEDDED_MODE: 'serverConfig.showModalsInEmbeddedMode' +}; + +/** Secrets that must be Base64 encoded in sdk-config.json; supplied in plain text through the environment. */ +const BASE64_ENV_MAP = { + SDK_MASHUP_PASSWORD: 'authConfig.mashupPassword' +}; + +const SECRET_PATHS = ['authConfig.mashupPassword']; + +function setPath(obj, dotted, value) { + const keys = dotted.split('.'); + let cur = obj; + for (const k of keys.slice(0, -1)) { + if (typeof cur[k] !== 'object' || cur[k] === null) cur[k] = {}; + cur = cur[k]; + } + cur[keys[keys.length - 1]] = value; +} + +function getPath(obj, dotted) { + return dotted.split('.').reduce((cur, k) => (cur == null ? undefined : cur[k]), obj); +} + +/** + * Returns a copy of `config` with values from `env` applied. Unset or empty variables leave the file value untouched. + * @returns {{ config: object, applied: string[] }} the new config and the env variable names that were applied + */ +function applyEnv(config, env) { + const next = JSON.parse(JSON.stringify(config)); + const applied = []; + const present = name => env[name] !== undefined && env[name] !== ''; + + for (const [name, target] of Object.entries(ENV_MAP)) { + if (present(name)) { + setPath(next, target, env[name]); + applied.push(name); + } + } + for (const [name, target] of Object.entries(BOOLEAN_ENV_MAP)) { + if (present(name)) { + const v = String(env[name]).toLowerCase(); + if (!['true', 'false'].includes(v)) throw new Error(`${name} must be "true" or "false" (got "${env[name]}")`); + setPath(next, target, v === 'true'); + applied.push(name); + } + } + for (const [name, target] of Object.entries(BASE64_ENV_MAP)) { + if (present(name)) { + setPath(next, target, Buffer.from(env[name], 'utf8').toString('base64')); + applied.push(name); + } + } + return { config: next, applied }; +} + +/** + * Validates the settings every deployment needs. + * @returns {{ errors: string[], warnings: string[] }} + */ +function validate(config) { + const errors = []; + const warnings = []; + + const url = getPath(config, 'serverConfig.infinityRestServerUrl'); + if (!url) { + errors.push('serverConfig.infinityRestServerUrl is empty (env: SDK_INFINITY_REST_SERVER_URL)'); + } else { + try { + const u = new URL(url); + if (!['http:', 'https:'].includes(u.protocol)) errors.push(`serverConfig.infinityRestServerUrl must be http(s): ${url}`); + if (/\/$/.test(url)) warnings.push('serverConfig.infinityRestServerUrl should not end with a slash'); + } catch { + errors.push(`serverConfig.infinityRestServerUrl is not a valid URL: ${url}`); + } + } + + if (!getPath(config, 'authConfig.portalClientId')) errors.push('authConfig.portalClientId is empty (env: SDK_PORTAL_CLIENT_ID)'); + if (!getPath(config, 'serverConfig.appAlias')) + warnings.push("serverConfig.appAlias is empty; the operator's default application is used (env: SDK_APP_ALIAS)"); + + if (getPath(config, 'authConfig.mashupClientId') && !getPath(config, 'authConfig.mashupPassword')) { + warnings.push('authConfig.mashupClientId is set but mashupPassword is empty; embedded/mashup login will fail unless auth is handled elsewhere'); + } + return { errors, warnings }; +} + +/** Copy of config that is safe to print. */ +function redact(config) { + const copy = JSON.parse(JSON.stringify(config)); + for (const p of SECRET_PATHS) if (getPath(copy, p)) setPath(copy, p, '********'); + return copy; +} + +module.exports = { ENV_MAP, BOOLEAN_ENV_MAP, BASE64_ENV_MAP, applyEnv, validate, redact, getPath };