From 923a3cee7cb46c299d52009592802ae4f799b4e9 Mon Sep 17 00:00:00 2001 From: DanaBenGigi Date: Mon, 27 Jul 2026 12:27:32 +0300 Subject: [PATCH 1/2] add validate pages script and report generation --- validate-pages/.env.example | 6 + validate-pages/.gitignore | 4 + validate-pages/README.md | 78 +++++++ validate-pages/index.js | 75 +++++++ validate-pages/package-lock.json | 360 +++++++++++++++++++++++++++++++ validate-pages/package.json | 18 ++ validate-pages/portClient.js | 110 ++++++++++ validate-pages/report.js | 42 ++++ validate-pages/reportUtils.js | 359 ++++++++++++++++++++++++++++++ 9 files changed, 1052 insertions(+) create mode 100644 validate-pages/.env.example create mode 100644 validate-pages/.gitignore create mode 100644 validate-pages/README.md create mode 100644 validate-pages/index.js create mode 100644 validate-pages/package-lock.json create mode 100644 validate-pages/package.json create mode 100644 validate-pages/portClient.js create mode 100644 validate-pages/report.js create mode 100644 validate-pages/reportUtils.js diff --git a/validate-pages/.env.example b/validate-pages/.env.example new file mode 100644 index 0000000..c1bec3d --- /dev/null +++ b/validate-pages/.env.example @@ -0,0 +1,6 @@ +# Base URL of the Port API. Use https://api.us.getport.io for the US region. +PORT_API_URL=https://api.getport.io + +# JSON array of organizations to validate. Each entry requires CLIENT_ID and +# CLIENT_SECRET; NAME is optional and only used to label the output. +ORGANIZATIONS=[{"NAME":"my-org","CLIENT_ID":"your_client_id_here","CLIENT_SECRET":"your_client_secret_here"}] diff --git a/validate-pages/.gitignore b/validate-pages/.gitignore new file mode 100644 index 0000000..5430b2b --- /dev/null +++ b/validate-pages/.gitignore @@ -0,0 +1,4 @@ +.env +node_modules +output +**/.DS_Store diff --git a/validate-pages/README.md b/validate-pages/README.md new file mode 100644 index 0000000..c63fc80 --- /dev/null +++ b/validate-pages/README.md @@ -0,0 +1,78 @@ +# Validate Pages Script + +This script iterates over one or more Port organizations, lists every page in each organization, and validates them via the Port API. Any page that fails validation is printed along with its validation errors. + +The script will: +1. Authenticate against each organization using its Port API credentials +2. List all pages in the organization (in compact form) +3. Validate each page via the Port API +4. Print any page that is invalid, together with its validation errors + +It can output results in two ways: +- **Console** (`npm start`) — prints invalid pages to the terminal +- **HTML report** (`npm run report`) — generates a styled report at `output/index.html` + +## Prerequisites + +- Node.js (v16 or higher) +- npm +- Port API credentials (Client ID and Client Secret) for each organization you want to check + +## Installation + +1. Clone/Download this repository +2. Run `cd validate-pages` +3. Run `npm install` to install the dependencies +4. Copy `.env.example` to `.env` and fill in the values: + +```bash +cp .env.example .env +``` + +## Configuration + +The script is configured entirely through environment variables (loaded from `.env`): + +| Variable | Required | Description | +| --------------- | -------- | ------------------------------------------------------------------------------------------------------- | +| `ORGANIZATIONS` | Yes | JSON array of organizations to validate. Each entry needs `CLIENT_ID` and `CLIENT_SECRET`; `NAME` is optional. | +| `PORT_API_URL` | No | Base URL of the Port API. Defaults to `https://api.getport.io`. Use `https://api.us.getport.io` for the US region. | + +Example `ORGANIZATIONS` value (single line in `.env`): + +```json +[{ "NAME": "my-org", "CLIENT_ID": "your_client_id_here", "CLIENT_SECRET": "your_client_secret_here" }] +``` + +## Running the Script + +### Console output + +Run the script with the following command: + +```bash +npm start +``` + +For each organization the script prints the organization name and the number of pages found, then lists any invalid pages: + +``` +[my-org] 42 pages + INVALID some-broken-page: [{"message":"..."}] +``` + +If no invalid pages are found for an organization, only the summary line is printed. + +### HTML report + +To generate a shareable HTML report instead, run: + +```bash +npm run report +``` + +This creates `output/index.html` with: +- Summary statistics (organizations, total pages, invalid pages) +- A per-organization section listing each invalid page with its identifier, title, and validation errors + +Open the file in any browser to view it. The `output/` directory is git-ignored. diff --git a/validate-pages/index.js b/validate-pages/index.js new file mode 100644 index 0000000..ba9748a --- /dev/null +++ b/validate-pages/index.js @@ -0,0 +1,75 @@ +/** + * Script: Validate Port Pages + * + * This script iterates over one or more Port organizations, lists every page in + * each organization, and validates them via the Port API. Any page that fails + * validation is printed along with its validation errors. + * + * Required environment variables (see .env.example): + * - ORGANIZATIONS: A JSON array of organizations to validate. Each entry must + * contain CLIENT_ID and CLIENT_SECRET, and may optionally contain NAME. + * + * Optional environment variables: + * - PORT_API_URL: Base URL of the Port API (defaults to https://api.getport.io). + * + * To generate an HTML report instead of console output, run `npm run report`. + */ + +require("dotenv").config(); + +const { KeyError, getApiUrl, parseOrgs, collectFindings } = require("./portClient"); + +async function main() { + const apiUrl = getApiUrl(); + const orgs = parseOrgs(); + + const results = []; + for (const org of orgs) { + results.push(await collectFindings(apiUrl, org)); + } + + printSummary(results); +} + +/** + * Prints a consolidated summary of invalid pages across all organizations, + * grouped by organization, after all validation has finished. + * + * @param {Array<{name: string, totalPages: number, findings: Array<{identifier: string, errors: any[]}>}>} results + */ +function printSummary(results) { + const totalInvalid = results.reduce( + (sum, org) => sum + org.findings.length, + 0 + ); + + console.log("\n==================== SUMMARY ===================="); + + for (const org of results) { + console.log(`\n[${org.name}] ${org.findings.length} invalid / ${org.totalPages} pages`); + + if (org.findings.length === 0) { + console.log(" All pages are valid"); + continue; + } + + for (const finding of org.findings) { + console.log( + ` INVALID ${finding.identifier}: ${JSON.stringify(finding.errors)}` + ); + } + } + + console.log( + `\nTotal: ${totalInvalid} invalid page(s) across ${results.length} organization(s)` + ); +} + +main().catch((error) => { + if (error instanceof KeyError) { + console.error(`Missing env/config key: ${error.message}`); + } else { + console.error(error.message); + } + process.exit(1); +}); diff --git a/validate-pages/package-lock.json b/validate-pages/package-lock.json new file mode 100644 index 0000000..69a727e --- /dev/null +++ b/validate-pages/package-lock.json @@ -0,0 +1,360 @@ +{ + "name": "validate-pages-script", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "validate-pages-script", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.7.9", + "dotenv": "^16.4.5" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + } + } +} diff --git a/validate-pages/package.json b/validate-pages/package.json new file mode 100644 index 0000000..7eb9f15 --- /dev/null +++ b/validate-pages/package.json @@ -0,0 +1,18 @@ +{ + "name": "validate-pages-script", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node index.js", + "report": "node report.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "Validate all pages across one or more Port organizations.", + "dependencies": { + "axios": "^1.7.9", + "dotenv": "^16.4.5" + } +} diff --git a/validate-pages/portClient.js b/validate-pages/portClient.js new file mode 100644 index 0000000..ca14e6f --- /dev/null +++ b/validate-pages/portClient.js @@ -0,0 +1,110 @@ +const axios = require("axios"); + +/** + * Mirrors Python's KeyError so we can report a missing env/config key the same + * way the original script did. + */ +class KeyError extends Error { + constructor(key) { + super(`'${key}'`); + this.name = "KeyError"; + } +} + +/** + * Reads the Port API base URL from the environment, stripping any trailing + * slashes. Defaults to https://api.getport.io. + * + * @returns {string} + */ +function getApiUrl() { + return (process.env.PORT_API_URL || "https://api.getport.io").replace( + /\/+$/, + "" + ); +} + +/** + * Parses the ORGANIZATIONS environment variable into an array of org configs. + * + * @returns {Array<{CLIENT_ID: string, CLIENT_SECRET: string, NAME?: string}>} + */ +function parseOrgs() { + if (!process.env.ORGANIZATIONS) { + throw new KeyError("ORGANIZATIONS"); + } + + try { + return JSON.parse(process.env.ORGANIZATIONS); + } catch (error) { + throw new Error(`ORGANIZATIONS is not valid JSON: ${error.message}`); + } +} + +async function getToken(apiUrl, clientId, clientSecret) { + const res = await axios.post(`${apiUrl}/v1/auth/access_token`, { + clientId, + clientSecret, + }); + return res.data.accessToken; +} + +/** + * Authenticates against a single org, lists its pages, validates each one, and + * returns the org name, total page count, and the invalid pages ("findings"). + * + * @param {string} apiUrl + * @param {{CLIENT_ID: string, CLIENT_SECRET: string, NAME?: string}} org + * @returns {Promise<{name: string, totalPages: number, findings: Array<{identifier: string, title?: string, errors: any[]}>}>} + */ +async function collectFindings(apiUrl, org) { + if (!org.CLIENT_ID) throw new KeyError("CLIENT_ID"); + if (!org.CLIENT_SECRET) throw new KeyError("CLIENT_SECRET"); + + const clientId = org.CLIENT_ID; + const name = org.NAME || clientId.slice(0, 8); + + console.log(`\nConnecting to organization: ${name}`); + const token = await getToken(apiUrl, clientId, org.CLIENT_SECRET); + const headers = { Authorization: `Bearer ${token}` }; + + console.log(" Fetching pages..."); + const pagesRes = await axios.get(`${apiUrl}/v1/pages/`, { + params: { compact: "true" }, + headers, + }); + const pages = pagesRes.data.pages; + + console.log(` Found ${pages.length} pages. Validating...`); + const findings = []; + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + const identifier = page.identifier; + console.log(` [${i + 1}/${pages.length}] Validating ${identifier}`); + const validateRes = await axios.get( + `${apiUrl}/v1/pages/${identifier}/validate`, + { headers } + ); + const result = validateRes.data; + if (!(result.valid ?? true)) { + findings.push({ + identifier, + title: page.title, + errors: result.errors || [], + }); + } + } + + console.log( + ` Done with ${name}: ${findings.length} invalid page(s) out of ${pages.length}` + ); + return { name, totalPages: pages.length, findings }; +} + +module.exports = { + KeyError, + getApiUrl, + parseOrgs, + getToken, + collectFindings, +}; diff --git a/validate-pages/report.js b/validate-pages/report.js new file mode 100644 index 0000000..8317ce4 --- /dev/null +++ b/validate-pages/report.js @@ -0,0 +1,42 @@ +/** + * Script: Validate Port Pages (HTML report) + * + * Same validation as `index.js`, but instead of printing to the console it + * generates a styled HTML report at `output/index.html` summarizing the invalid + * pages across all configured organizations. + * + * Usage: `npm run report` + * + * See `.env.example` for the required configuration. + */ + +require("dotenv").config(); + +const { KeyError, getApiUrl, parseOrgs, collectFindings } = require("./portClient"); +const { generateReport } = require("./reportUtils"); + +async function main() { + const apiUrl = getApiUrl(); + const orgs = parseOrgs(); + + const results = []; + for (const org of orgs) { + const result = await collectFindings(apiUrl, org); + console.log( + `[${result.name}] ${result.totalPages} pages, ${result.findings.length} invalid` + ); + results.push(result); + } + + const outputPath = generateReport(results); + console.log(`\nReport written to ${outputPath}`); +} + +main().catch((error) => { + if (error instanceof KeyError) { + console.error(`Missing env/config key: ${error.message}`); + } else { + console.error(error.message); + } + process.exit(1); +}); diff --git a/validate-pages/reportUtils.js b/validate-pages/reportUtils.js new file mode 100644 index 0000000..a04d08d --- /dev/null +++ b/validate-pages/reportUtils.js @@ -0,0 +1,359 @@ +const fs = require("fs"); +const path = require("path"); + +const DOCS_URL = "https://docs.port.io/customize-pages-dashboards-and-plugins/page/"; + +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Renders a single validation error as readable HTML. Errors may be plain + * strings or objects, so we handle both. + * + * @param {any} error + * @returns {string} + */ +function renderError(error) { + if (error && typeof error === "object") { + const message = error.message || error.error || JSON.stringify(error); + return `
  • ${escapeHtml(message)}
  • `; + } + return `
  • ${escapeHtml(error)}
  • `; +} + +function renderOrgSection(org) { + const rows = org.findings.length + ? org.findings + .map( + (finding) => ` + + ${escapeHtml(finding.identifier)} + ${finding.title ? escapeHtml(finding.title) : ''} + + ${ + finding.errors.length + ? `` + : 'No details provided' + } + + ` + ) + .join("") + : 'All pages are valid 🎉'; + + return ` +
    +
    +

    ${escapeHtml(org.name)}

    + + ${org.findings.length} invalid / ${org.totalPages} pages + +
    +
    + + + + + + + + + + ${rows} + +
    Page IdentifierTitleErrors
    +
    +
    `; +} + +/** + * Generates an HTML report of invalid pages across all organizations and writes + * it to output/index.html. + * + * @param {Array<{name: string, totalPages: number, findings: Array<{identifier: string, title?: string, errors: any[]}>}>} results + * @returns {string} The absolute path to the generated report. + */ +function generateReport(results) { + const totalOrgs = results.length; + const totalPages = results.reduce((sum, org) => sum + org.totalPages, 0); + const totalInvalid = results.reduce( + (sum, org) => sum + org.findings.length, + 0 + ); + + const generatedAt = new Date().toLocaleString(); + + const html = ` + + + + + Port Pages Validation Report + + + + +
    +

    +
    + Port Pages Validation Report + Generated ${escapeHtml(generatedAt)} +
    + +

    + +
    +
    +
    ${totalOrgs}
    +
    Organizations
    +
    +
    +
    ${totalPages}
    +
    Total Pages
    +
    +
    +
    ${totalInvalid}
    +
    Invalid Pages
    +
    +
    + + ${results.map(renderOrgSection).join("")} + +

    + Learn more about Port pages in the + documentation. +

    +
    + +`; + + const outputDir = path.join(__dirname, "output"); + fs.mkdirSync(outputDir, { recursive: true }); + const outputPath = path.join(outputDir, "index.html"); + fs.writeFileSync(outputPath, html); + + return outputPath; +} + +module.exports = { generateReport }; From d76c0a5e8dfd3e7e0f02249b6b5d195072cddd30 Mon Sep 17 00:00:00 2001 From: DanaBenGigi Date: Tue, 28 Jul 2026 16:29:01 +0300 Subject: [PATCH 2/2] improve script and extract run page from validation --- validate-pages/README.md | 7 +- validate-pages/index.js | 37 ++++-- validate-pages/portClient.js | 46 +++++--- validate-pages/reportUtils.js | 207 ++++++++++++++++++++++++++++------ 4 files changed, 239 insertions(+), 58 deletions(-) diff --git a/validate-pages/README.md b/validate-pages/README.md index c63fc80..2f82d65 100644 --- a/validate-pages/README.md +++ b/validate-pages/README.md @@ -72,7 +72,10 @@ npm run report ``` This creates `output/index.html` with: -- Summary statistics (organizations, total pages, invalid pages) -- A per-organization section listing each invalid page with its identifier, title, and validation errors +- Summary statistics (organizations, total pages, invalid pages, pages that failed to validate) +- A per-organization section listing each invalid page with its identifier, title, widget type/title, path, and validation error +- A "Failed to validate" table per organization for pages whose validation request errored (e.g. HTTP 500), so they can be checked manually Open the file in any browser to view it. The `output/` directory is git-ignored. + +Note: each run overwrites `output/index.html`, so only the last run's output is saved. Copy or rename the file if you want to keep a previous report. diff --git a/validate-pages/index.js b/validate-pages/index.js index ba9748a..8911721 100644 --- a/validate-pages/index.js +++ b/validate-pages/index.js @@ -33,15 +33,21 @@ async function main() { /** * Prints a consolidated summary of invalid pages across all organizations, - * grouped by organization, after all validation has finished. + * grouped by organization, after all validation has finished. Pages that could + * not be validated (e.g. the validate request errored) are listed separately at + * the end so they can be manually checked in their organization. * - * @param {Array<{name: string, totalPages: number, findings: Array<{identifier: string, errors: any[]}>}>} results + * @param {Array<{name: string, totalPages: number, findings: Array<{identifier: string, errors: any[]}>, failedPages?: Array<{identifier: string, title?: string, reason: string}>}>} results */ function printSummary(results) { const totalInvalid = results.reduce( (sum, org) => sum + org.findings.length, 0 ); + const totalFailed = results.reduce( + (sum, org) => sum + (org.failedPages ? org.failedPages.length : 0), + 0 + ); console.log("\n==================== SUMMARY ===================="); @@ -50,19 +56,32 @@ function printSummary(results) { if (org.findings.length === 0) { console.log(" All pages are valid"); - continue; - } - - for (const finding of org.findings) { - console.log( - ` INVALID ${finding.identifier}: ${JSON.stringify(finding.errors)}` - ); + } else { + for (const finding of org.findings) { + console.log( + ` INVALID ${finding.identifier}: ${JSON.stringify(finding.errors)}` + ); + } } } console.log( `\nTotal: ${totalInvalid} invalid page(s) across ${results.length} organization(s)` ); + + if (totalFailed > 0) { + console.log("\n---- Failed pages to check (validation errored) ----"); + for (const org of results) { + if (!org.failedPages || org.failedPages.length === 0) continue; + console.log(`\n[${org.name}]`); + for (const failed of org.failedPages) { + console.log(` FAILED ${failed.identifier}: ${failed.reason}`); + } + } + console.log( + `\nTotal: ${totalFailed} page(s) failed to validate across ${results.length} organization(s)` + ); + } } main().catch((error) => { diff --git a/validate-pages/portClient.js b/validate-pages/portClient.js index ca14e6f..c48cab4 100644 --- a/validate-pages/portClient.js +++ b/validate-pages/portClient.js @@ -41,6 +41,9 @@ function parseOrgs() { } } +// Page identifiers to skip during validation (e.g. built-in system pages). +const SKIP_PAGE_IDENTIFIERS = new Set(["$run"]); + async function getToken(apiUrl, clientId, clientSecret) { const res = await axios.post(`${apiUrl}/v1/auth/access_token`, { clientId, @@ -73,32 +76,45 @@ async function collectFindings(apiUrl, org) { params: { compact: "true" }, headers, }); - const pages = pagesRes.data.pages; + const pages = pagesRes.data.pages.filter( + (page) => !SKIP_PAGE_IDENTIFIERS.has(page.identifier) + ); - console.log(` Found ${pages.length} pages. Validating...`); + console.log(`Found ${pages.length} pages to validate`); const findings = []; + const failedPages = []; for (let i = 0; i < pages.length; i++) { const page = pages[i]; const identifier = page.identifier; + console.log(` [${i + 1}/${pages.length}] Validating ${identifier}`); - const validateRes = await axios.get( - `${apiUrl}/v1/pages/${identifier}/validate`, - { headers } - ); - const result = validateRes.data; - if (!(result.valid ?? true)) { - findings.push({ - identifier, - title: page.title, - errors: result.errors || [], - }); + try { + const validateRes = await axios.get( + `${apiUrl}/v1/pages/${identifier}/validate`, + { headers } + ); + const result = validateRes.data; + if (!(result.valid ?? true)) { + findings.push({ + identifier, + title: page.title, + errors: result.errors || [], + }); + } + } catch (error) { + const reason = error.response + ? `HTTP ${error.response.status}` + : error.message; + console.warn(` WARNING: failed to validate ${identifier}: ${reason}`); + failedPages.push({ identifier, title: page.title, reason }); } } console.log( - ` Done with ${name}: ${findings.length} invalid page(s) out of ${pages.length}` + ` Done with ${name}: ${findings.length} invalid page(s) out of ${pages.length}` + + (failedPages.length ? `, ${failedPages.length} failed to validate` : "") ); - return { name, totalPages: pages.length, findings }; + return { name, totalPages: pages.length, findings, failedPages }; } module.exports = { diff --git a/validate-pages/reportUtils.js b/validate-pages/reportUtils.js index a04d08d..698bd03 100644 --- a/validate-pages/reportUtils.js +++ b/validate-pages/reportUtils.js @@ -1,7 +1,7 @@ const fs = require("fs"); const path = require("path"); -const DOCS_URL = "https://docs.port.io/customize-pages-dashboards-and-plugins/page/"; +const DOCS_URL = "https://docs.port.io/api-reference/pages/"; function escapeHtml(str) { return String(str) @@ -11,56 +11,157 @@ function escapeHtml(str) { .replace(/"/g, """); } +const MUTED = ''; + /** - * Renders a single validation error as readable HTML. Errors may be plain - * strings or objects, so we handle both. + * Normalizes a validation error (which may be a plain string or an object) into + * a consistent shape, pulling out widget details when they exist. * * @param {any} error - * @returns {string} + * @returns {{widgetType: string, widgetTitle: string, path: string, message: string}} */ -function renderError(error) { +function normalizeError(error) { if (error && typeof error === "object") { - const message = error.message || error.error || JSON.stringify(error); - return `
  • ${escapeHtml(message)}
  • `; + const widget = error.widget || {}; + return { + widgetType: error.widgetType || widget.type || "", + widgetTitle: error.widgetTitle || widget.title || "", + path: formatPath(error.path), + message: error.message || error.error || JSON.stringify(error), + }; } - return `
  • ${escapeHtml(error)}
  • `; + return { widgetType: "", widgetTitle: "", path: "", message: String(error) }; } -function renderOrgSection(org) { - const rows = org.findings.length - ? org.findings - .map( - (finding) => ` +/** + * Formats a validation error path into a readable string. Paths may be provided + * as an array of segments (e.g. ["widgets", 0, "dataset"]) or a plain string. + * + * @param {any} path + * @returns {string} + */ +function formatPath(path) { + if (Array.isArray(path)) { + return path.join("."); + } + if (path === undefined || path === null) { + return ""; + } + return String(path); +} + +/** + * Renders one table row per validation error for a page. When a page has no + * error details, a single placeholder row is rendered instead. + * + * @param {{identifier: string, title?: string, errors: any[]}} finding + * @returns {string} + */ +function renderFindingRows(finding) { + const identifierCell = `${escapeHtml(finding.identifier)}`; + const titleCell = finding.title ? escapeHtml(finding.title) : MUTED; + + if (!finding.errors.length) { + return ` + + ${identifierCell} + ${titleCell} + ${MUTED} + ${MUTED} + ${MUTED} + No details provided + `; + } + + return finding.errors + .map((error) => { + const { widgetType, widgetTitle, path, message } = normalizeError(error); + return ` + + ${identifierCell} + ${titleCell} + ${widgetType ? `${escapeHtml(widgetType)}` : MUTED} + ${widgetTitle ? escapeHtml(widgetTitle) : MUTED} + ${path ? `${escapeHtml(path)}` : MUTED} + ${escapeHtml(message)} + `; + }) + .join(""); +} + +/** + * Renders a table of pages that could not be validated (the validate request + * errored). Returns an empty string when there are no such pages. + * + * @param {{failedPages?: Array<{identifier: string, title?: string, reason: string}>}} org + * @returns {string} + */ +function renderFailedSection(org) { + const failedPages = org.failedPages || []; + if (!failedPages.length) { + return ""; + } + + const rows = failedPages + .map( + (page) => ` - ${escapeHtml(finding.identifier)} - ${finding.title ? escapeHtml(finding.title) : ''} - - ${ - finding.errors.length - ? `` - : 'No details provided' - } - + ${escapeHtml(page.identifier)} + ${page.title ? escapeHtml(page.title) : MUTED} + ${escapeHtml(page.reason)} ` - ) - .join("") - : 'All pages are valid 🎉'; + ) + .join(""); return ` -
    -
    +

    Failed to validate (${failedPages.length})

    +

    These pages could not be validated because the validation request errored. Check them manually in the organization.

    +
    + + + + + + + + + + ${rows} + +
    Page IdentifierTitleReason
    +
    `; +} + +function renderOrgSection(org) { + const failedCount = org.failedPages ? org.failedPages.length : 0; + const hasIssues = org.findings.length || failedCount; + + const emptyMessage = failedCount + ? "No invalid pages found" + : "All pages are valid 🎉"; + + const rows = org.findings.length + ? org.findings.map(renderFindingRows).join("") + : `${emptyMessage}`; + + return ` +
    +

    ${escapeHtml(org.name)}

    - - ${org.findings.length} invalid / ${org.totalPages} pages + + ${org.findings.length} invalid${failedCount ? ` · ${failedCount} failed` : ""} / ${org.totalPages} pages -
    +
    - + + + + @@ -68,7 +169,8 @@ function renderOrgSection(org) {
    Page Identifier TitleErrorsWidget TypeWidget TitlePathError
    -
    `; + ${renderFailedSection(org)} + `; } /** @@ -85,6 +187,10 @@ function generateReport(results) { (sum, org) => sum + org.findings.length, 0 ); + const totalFailed = results.reduce( + (sum, org) => sum + (org.failedPages ? org.failedPages.length : 0), + 0 + ); const generatedAt = new Date().toLocaleString(); @@ -225,6 +331,27 @@ function generateReport(results) { margin-bottom: 16px; } + summary.section-header { + cursor: pointer; + list-style: none; + user-select: none; + } + + summary.section-header::-webkit-details-marker { + display: none; + } + + summary.section-header::before { + content: "▶"; + font-size: 11px; + color: var(--text-secondary); + transition: transform 0.2s ease; + } + + details[open] > summary.section-header::before { + transform: rotate(90deg); + } + .section-badge { font-size: 13px; font-weight: 600; @@ -290,6 +417,18 @@ function generateReport(results) { font-size: 13px; } + .failed-heading { + color: var(--error-text); + margin: 28px 0 4px; + font-size: 1.1em; + } + + .section-note { + color: var(--text-secondary); + font-size: 13px; + margin: 0 0 12px; + } + ul.errors { margin: 0; padding-left: 18px; @@ -336,6 +475,10 @@ function generateReport(results) {
    ${totalInvalid}
    Invalid Pages
    +
    +
    ${totalFailed}
    +
    Failed to Validate
    +
    ${results.map(renderOrgSection).join("")}