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..2f82d65 --- /dev/null +++ b/validate-pages/README.md @@ -0,0 +1,81 @@ +# 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, 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 new file mode 100644 index 0000000..8911721 --- /dev/null +++ b/validate-pages/index.js @@ -0,0 +1,94 @@ +/** + * 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. 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[]}>, 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 ===================="); + + 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"); + } 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) => { + 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..c48cab4 --- /dev/null +++ b/validate-pages/portClient.js @@ -0,0 +1,126 @@ +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}`); + } +} + +// 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, + 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.filter( + (page) => !SKIP_PAGE_IDENTIFIERS.has(page.identifier) + ); + + 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}`); + 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}` + + (failedPages.length ? `, ${failedPages.length} failed to validate` : "") + ); + return { name, totalPages: pages.length, findings, failedPages }; +} + +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..698bd03 --- /dev/null +++ b/validate-pages/reportUtils.js @@ -0,0 +1,502 @@ +const fs = require("fs"); +const path = require("path"); + +const DOCS_URL = "https://docs.port.io/api-reference/pages/"; + +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +const MUTED = ''; + +/** + * 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 {{widgetType: string, widgetTitle: string, path: string, message: string}} + */ +function normalizeError(error) { + if (error && typeof error === "object") { + 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 { widgetType: "", widgetTitle: "", path: "", message: String(error) }; +} + +/** + * 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(page.identifier)} + ${page.title ? escapeHtml(page.title) : MUTED} + ${escapeHtml(page.reason)} + ` + ) + .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${failedCount ? ` · ${failedCount} failed` : ""} / ${org.totalPages} pages + +
+
+ + + + + + + + + + + + + ${rows} + +
Page IdentifierTitleWidget TypeWidget TitlePathError
+
+ ${renderFailedSection(org)} +
`; +} + +/** + * 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 totalFailed = results.reduce( + (sum, org) => sum + (org.failedPages ? org.failedPages.length : 0), + 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
+
+
+
${totalFailed}
+
Failed to Validate
+
+
+ + ${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 };