From 574742b9e71838d74269b75037396fe569221637 Mon Sep 17 00:00:00 2001 From: shrinishLT Date: Tue, 14 Oct 2025 12:53:36 +0530 Subject: [PATCH 1/2] add customCSS support --- package.json | 1 + pnpm-lock.yaml | 155 +++++++++++++++++++++- src/lib/ctx.ts | 18 ++- src/lib/processSnapshot.ts | 48 ++++++- src/lib/schemaValidation.ts | 8 ++ src/lib/utils.ts | 256 ++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- 7 files changed, 483 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 50c433e7..6a20d19e 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "json-stringify-safe": "^5.0.1", "listr2": "^7.0.1", "node-cache": "^5.1.2", + "postcss": "^8.5.6", "sharp": "^0.33.4", "tsup": "^7.2.0", "uuid": "^11.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b49e672c..7290aea7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@lambdatest/node-tunnel': + specifier: ^4.0.9 + version: 4.0.9 '@playwright/browser-chromium': specifier: ^1.47.2 version: 1.47.2 @@ -23,6 +26,9 @@ importers: '@types/cross-spawn': specifier: ^6.0.4 version: 6.0.5 + '@types/json-stringify-safe': + specifier: ^5.0.3 + version: 5.0.3 '@types/node': specifier: ^20.8.9 version: 20.9.0 @@ -53,15 +59,27 @@ importers: form-data: specifier: ^4.0.0 version: 4.0.0 + json-stringify-safe: + specifier: ^5.0.1 + version: 5.0.1 listr2: specifier: ^7.0.1 version: 7.0.2 + node-cache: + specifier: ^5.1.2 + version: 5.1.2 + postcss: + specifier: ^8.5.6 + version: 8.5.6 sharp: specifier: ^0.33.4 version: 0.33.4 tsup: specifier: ^7.2.0 - version: 7.2.0(typescript@5.3.2) + version: 7.2.0(postcss@8.5.6)(typescript@5.3.2) + uuid: + specifier: ^11.0.3 + version: 11.1.0 which: specifier: ^4.0.0 version: 4.0.0 @@ -69,6 +87,9 @@ importers: specifier: ^3.10.0 version: 3.10.0 devDependencies: + find-free-port: + specifier: ^2.0.0 + version: 2.0.0 typescript: specifier: ^5.3.2 version: 5.3.2 @@ -364,6 +385,9 @@ packages: '@jridgewell/trace-mapping@0.3.20': resolution: {integrity: sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==} + '@lambdatest/node-tunnel@4.0.9': + resolution: {integrity: sha512-n4s2MpgqVkWZzYwEpoRUsJZJfsE2UCcbfd88zqTqZStWIw7Y4+fZfxP/6QK/yWTRNLK0/ZwwGkP814beQU1mzA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -396,6 +420,9 @@ packages: '@types/cross-spawn@6.0.5': resolution: {integrity: sha512-wsIMP68FvGXk+RaWhraz6Xp4v7sl4qwzHAmtPaJEN2NRTXXI9LtFawUpeTsBNL/pd6QoLStdytCaAyiK7AEd/Q==} + '@types/json-stringify-safe@5.0.3': + resolution: {integrity: sha512-oNOjRxLfPeYbBSQ60maucaFNqbslVOPU4WWs5t/sHvAh6tyo/CThXSG+E24tEzkgh/fzvxyDrYdOJufgeNy1sQ==} + '@types/node@20.9.0': resolution: {integrity: sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==} @@ -412,6 +439,14 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-errors@3.0.0: resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==} peerDependencies: @@ -520,6 +555,10 @@ packages: resolution: {integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -661,6 +700,9 @@ packages: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} + find-free-port@2.0.0: + resolution: {integrity: sha512-J1j8gfEVf5FN4PR5w5wrZZ7NYs2IvqsHcd03cAeQx3Ec/mo+lKceaVNhpsRKoZpZKbId88o8qh+dwUwzBV6WCg==} + find-my-way@7.7.0: resolution: {integrity: sha512-+SrHpvQ52Q6W9f3wJoJBbAQULJuNEEQwBvlvYwACDhBTLOTMiQ0HYWh4+vC3OivGP2ENcTI1oKlFA2OepJNjhQ==} engines: {node: '>=14'} @@ -698,6 +740,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-port@1.0.0: + resolution: {integrity: sha512-vg59F3kcXBOtcIijwtdAyCxFocyv/fVkGQvw1kVGrxFO1U4SSGkGjrbASg5DN3TVekVle/jltwOjYRnZWc1YdA==} + engines: {node: '>=0.10.0'} + hasBin: true + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -717,6 +764,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -782,6 +833,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} @@ -850,6 +904,15 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-cache@5.1.2: + resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} + engines: {node: '>= 8.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -888,6 +951,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -928,6 +994,10 @@ packages: ts-node: optional: true + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + process-warning@2.3.0: resolution: {integrity: sha512-N6mp1+2jpQr3oCFMz6SeHRGbv6Slb20bRhj4v3xR99HqNToAcOe1MFOp4tytyzOfJn+QtN8Rf7U/h2KAn4kC6g==} @@ -1054,6 +1124,10 @@ packages: sonic-boom@3.7.0: resolution: {integrity: sha512-IudtNvSqA/ObjN97tfgNmOKyDOs4dNcg4cUUsHDebqsgb8wGBBwb31LIgShNO8fye0dFI52X1+tFoKKI6Rq1Gg==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} @@ -1062,6 +1136,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + split@1.0.1: + resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} @@ -1102,6 +1179,9 @@ packages: thread-stream@2.4.1: resolution: {integrity: sha512-d/Ex2iWd1whipbT681JmTINKw0ZwOUBZm7+Gjs64DHuX34mmw8vJL2bFAaNacaW72zYiTJxSHi5abUuOi5nsfg==} + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1161,6 +1241,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -1388,6 +1472,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.4.15 + '@lambdatest/node-tunnel@4.0.9': + dependencies: + adm-zip: 0.5.16 + axios: 1.6.2 + get-port: 1.0.0 + https-proxy-agent: 5.0.1 + split: 1.0.1 + transitivePeerDependencies: + - debug + - supports-color + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1420,6 +1515,8 @@ snapshots: dependencies: '@types/node': 20.9.0 + '@types/json-stringify-safe@5.0.3': {} + '@types/node@20.9.0': dependencies: undici-types: 5.26.5 @@ -1434,6 +1531,14 @@ snapshots: abstract-logging@2.0.1: {} + adm-zip@0.5.16: {} + + agent-base@6.0.2: + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + ajv-errors@3.0.0(ajv@8.12.0): dependencies: ajv: 8.12.0 @@ -1547,6 +1652,8 @@ snapshots: slice-ansi: 5.0.0 string-width: 5.1.2 + clone@2.1.2: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -1723,6 +1830,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-free-port@2.0.0: {} + find-my-way@7.7.0: dependencies: fast-deep-equal: 3.1.3 @@ -1749,6 +1858,8 @@ snapshots: fsevents@2.3.3: optional: true + get-port@1.0.0: {} + get-stream@6.0.1: {} glob-parent@5.1.2: @@ -1775,6 +1886,13 @@ snapshots: has-flag@4.0.0: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} ieee754@1.2.1: {} @@ -1820,6 +1938,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-stringify-safe@5.0.1: {} + kuler@2.0.0: {} light-my-request@5.11.0: @@ -1895,6 +2015,12 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.11: {} + + node-cache@5.1.2: + dependencies: + clone: 2.1.2 + normalize-path@3.0.0: {} npm-run-path@4.0.1: @@ -1923,6 +2049,8 @@ snapshots: path-type@4.0.0: {} + picocolors@1.1.1: {} + picomatch@2.3.1: {} pino-abstract-transport@1.1.0: @@ -1956,10 +2084,18 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - postcss-load-config@4.0.1: + postcss-load-config@4.0.1(postcss@8.5.6): dependencies: lilconfig: 2.1.0 yaml: 2.3.4 + optionalDependencies: + postcss: 8.5.6 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 process-warning@2.3.0: {} @@ -2088,12 +2224,18 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + source-map@0.8.0-beta.0: dependencies: whatwg-url: 7.1.0 split2@4.2.0: {} + split@1.0.1: + dependencies: + through: 2.3.8 + stack-trace@0.0.10: {} string-width@5.1.2: @@ -2140,6 +2282,8 @@ snapshots: dependencies: real-require: 0.2.0 + through@2.3.8: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -2159,7 +2303,7 @@ snapshots: tslib@2.6.3: optional: true - tsup@7.2.0(typescript@5.3.2): + tsup@7.2.0(postcss@8.5.6)(typescript@5.3.2): dependencies: bundle-require: 4.0.2(esbuild@0.18.20) cac: 6.7.14 @@ -2169,13 +2313,14 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.1 + postcss-load-config: 4.0.1(postcss@8.5.6) resolve-from: 5.0.0 rollup: 3.29.4 source-map: 0.8.0-beta.0 sucrase: 3.34.0 tree-kill: 1.2.2 optionalDependencies: + postcss: 8.5.6 typescript: 5.3.2 transitivePeerDependencies: - supports-color @@ -2193,6 +2338,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.1.0: {} + webidl-conversions@4.0.2: {} whatwg-url@7.1.0: diff --git a/src/lib/ctx.ts b/src/lib/ctx.ts index ef6e2091..0dc34902 100644 --- a/src/lib/ctx.ts +++ b/src/lib/ctx.ts @@ -6,6 +6,7 @@ import logger from './logger.js' import getEnv from './env.js' import httpClient from './httpClient.js' import fs from 'fs' +import { resolveCustomCSS } from './utils.js' export default (options: Record): Context => { let env: Env = getEnv(); @@ -52,6 +53,20 @@ export default (options: Record): Context => { if (!validateConfigFn(config)) { throw new Error(validateConfigFn.errors[0].message); } + + // Resolve customCSS if provided + if ((config as any).customCSS) { + try { + (config as any).customCSS = resolveCustomCSS( + (config as any).customCSS, + options.config, + logger + ); + logger.debug('Successfully resolved and validated customCSS from config'); + } catch (error: any) { + throw new Error(`customCSS error: ${error.message}`); + } + } } else { logger.info("## No config file provided. Using default config."); } @@ -155,7 +170,8 @@ export default (options: Record): Context => { loadDomContent: loadDomContent, approvalThreshold: config.approvalThreshold, rejectionThreshold: config.rejectionThreshold, - showRenderErrors: config.showRenderErrors ?? false + showRenderErrors: config.showRenderErrors ?? false, + customCSS: (config as any).customCSS }, uploadFilePath: '', webStaticConfig: [], diff --git a/src/lib/processSnapshot.ts b/src/lib/processSnapshot.ts index 263141c2..12d7a476 100644 --- a/src/lib/processSnapshot.ts +++ b/src/lib/processSnapshot.ts @@ -1,9 +1,10 @@ import { Snapshot, Context, DiscoveryErrors } from "../types.js"; -import { scrollToBottomAndBackToTop, getRenderViewports, getRenderViewportsForOptions, validateCoordinates } from "./utils.js" +import { scrollToBottomAndBackToTop, getRenderViewports, getRenderViewportsForOptions, validateCoordinates, resolveCustomCSS, parseCSSFile, validateCSSSelectors, generateCSSInjectionReport } from "./utils.js" import { chromium, Locator } from "@playwright/test" import constants from "./constants.js"; import { updateLogContext } from '../lib/logger.js' import NodeCache from 'node-cache'; +import chalk from "chalk"; const globalCache = new NodeCache({ stdTTL: 3600, checkperiod: 600 }); const MAX_RESOURCE_SIZE = 15 * (1024 ** 2); // 15MB @@ -154,6 +155,20 @@ export async function prepareSnapshot(snapshot: Snapshot, ctx: Context): Promise processedOptions.useExtendedViewport = true; } + try { + if (options?.customCSS) { + const resolvedCSS = resolveCustomCSS(options.customCSS, '', ctx.log); + processedOptions.customCSS = resolvedCSS; + ctx.log.debug('Using per-snapshot customCSS (overriding config)'); + } else if (ctx.config.customCSS) { + processedOptions.customCSS = ctx.config.customCSS; + ctx.log.debug('Using config customCSS'); + } + } catch (error: any) { + ctx.log.warn(`customCSS warning: ${error.message}`); + chalk.yellow(`[SmartUI] warning: ${error.message}`); + } + processedOptions.allowedAssets = ctx.config.allowedAssets; processedOptions.selectors = selectors; @@ -577,6 +592,19 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context): processedOptions.useExtendedViewport = true; } + try { + if (options?.customCSS) { + const resolvedCSS = resolveCustomCSS(options.customCSS, '', ctx.log); + processedOptions.customCSS = resolvedCSS; + } else if (ctx.config.customCSS) { + processedOptions.customCSS = ctx.config.customCSS; + } + } catch (error: any) { + optionWarnings.add(`${error.message}`); + } + + ctx.log.debug(`Processed options: ${JSON.stringify(processedOptions)}`); + // process for every viewport let navigated: boolean = false; let previousDeviceType: string | null = null; @@ -839,6 +867,24 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context): ctx.log.debug(`Processed options: ${JSON.stringify(processedOptions)}`); } + // Validate and report CSS injection after selector processing + if (processedOptions.customCSS) { + try { + const cssRules = parseCSSFile(processedOptions.customCSS); + const validationResult = await validateCSSSelectors(page, cssRules, ctx.log); + const report = generateCSSInjectionReport(validationResult, ctx.log); + + if (validationResult.failedSelectors.length > 0) { + validationResult.failedSelectors.forEach(selector => { + optionWarnings.add(`customCSS selector not found: ${selector}`); + }); + } + } catch (error: any) { + ctx.log.warn(`CSS validation failed: ${error.message}`); + optionWarnings.add(`CSS validation error: ${error.message}`); + } + } + let hasBrowserErrors = false; for (let browser in discoveryErrors.browsers) { diff --git a/src/lib/schemaValidation.ts b/src/lib/schemaValidation.ts index 6b0efe13..3887b3d3 100644 --- a/src/lib/schemaValidation.ts +++ b/src/lib/schemaValidation.ts @@ -284,6 +284,10 @@ const ConfigSchema = { type: "boolean", errorMessage: "Invalid config; loadDomContent must be true/false" }, + customCSS: { + type: "string", + errorMessage: "Invalid config; customCSS must be a string" + }, approvalThreshold: { type: "number", minimum: 0, @@ -594,6 +598,10 @@ const SnapshotSchema: JSONSchemaType = { minProperties: 1, }, errorMessage: "Invalid snapshot options; customCookies must be an array of objects with string properties" + }, + customCSS: { + type: "string", + errorMessage: "Invalid snapshot options; customCSS must be a string" } }, additionalProperties: false diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f10af98f..752f4374 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -7,6 +7,7 @@ import fs from 'fs'; import { globalAgent } from 'http'; import { promisify } from 'util' import { build } from 'tsup'; +import postcss from 'postcss'; const util = require('util'); // Import the util module var lambdaTunnel = require('@lambdatest/node-tunnel'); @@ -888,4 +889,259 @@ export async function startSSEListener(ctx: Context) { } catch (error) { ctx.log.debug('Failed to start SSE listener:', error); } +} + +/** + * Validates if a string contains valid CSS syntax + * @param cssString - The CSS string to validate + * @returns true if valid CSS, false otherwise + */ +export function isValidCSS(cssString: string): boolean { + if (!cssString || typeof cssString !== 'string' || cssString.trim().length === 0) { + return false; + } + + const trimmed = cssString.trim(); + + // Basic CSS validation patterns + // Check for balanced braces + const openBraces = (trimmed.match(/\{/g) || []).length; + const closeBraces = (trimmed.match(/\}/g) || []).length; + + if (openBraces !== closeBraces) { + return false; + } + + // Check for basic CSS structure (selector { property: value; }) + // Allow comments /* */ and media queries + const cssPattern = /^[\s\S]*[\{\}][\s\S]*$/; + + // Must contain at least one CSS rule or be empty + if (trimmed.length > 0 && !cssPattern.test(trimmed)) { + // Allow single-line rules without newlines + const singleRulePattern = /^[^{]+\{[^}]+\}$/; + if (!singleRulePattern.test(trimmed)) { + return false; + } + } + + return true; +} + +/** + * Resolves customCSS from either a file path or inline CSS string + * @param cssValue - The CSS value from config (file path or inline CSS) + * @param configPath - The path to the config file (for resolving relative paths) + * @param logger - Logger instance for debug messages + * @returns Resolved CSS string or throws error if invalid + */ +export function resolveCustomCSS(cssValue: string, configPath: string, logger: any): string { + if (!cssValue || typeof cssValue !== 'string') { + throw new Error('customCSS must be a non-empty string'); + } + + const trimmed = cssValue.trim(); + if (trimmed.length === 0) { + throw new Error('customCSS cannot be empty'); + } + + // Check if it looks like a file path + const path = require('path'); + const isLikelyFilePath = + trimmed.endsWith('.css') || + trimmed.startsWith('./') || + trimmed.startsWith('../') || + trimmed.startsWith('/') || + path.isAbsolute(trimmed); + + if (isLikelyFilePath) { + logger.debug(`customCSS appears to be a file path: ${trimmed}`); + + // Validate file extension + const ext = path.extname(trimmed).toLowerCase(); + if (ext && ext !== '.css') { + throw new Error(`Invalid customCSS file type: ${ext}. Only .css files are supported.`); + } + + // Resolve the file path + const baseDir = path.dirname(configPath); + const resolvedPath = path.isAbsolute(trimmed) + ? trimmed + : path.resolve(baseDir, trimmed); + + logger.debug(`Resolved customCSS file path: ${resolvedPath}`); + + // Check if file exists + if (!fs.existsSync(resolvedPath)) { + throw new Error(`customCSS file not found: ${resolvedPath}`); + } + + // Check if it's a file (not a directory) + const stats = fs.statSync(resolvedPath); + if (!stats.isFile()) { + throw new Error(`customCSS path is not a file: ${resolvedPath}`); + } + + // Read the file + try { + const cssContent = fs.readFileSync(resolvedPath, 'utf-8'); + logger.debug(`Read ${cssContent.length} characters from customCSS file`); + + return cssContent; + } catch (error: any) { + if (error.message.includes('Invalid CSS syntax')) { + throw error; + } + throw new Error(`Failed to read customCSS file: ${error.message}`); + } + } else { + // Treat as inline CSS + logger.debug('customCSS appears to be inline CSS'); + return trimmed; + } +} + + +/** + * Parse CSS content and extract selectors with their rules + * @param cssContent - The CSS content to parse + * @returns Array of parsed CSS rules with selectors + */ +export function parseCSSFile(cssContent: string): Array<{ + selector: string; + declarations: Array<{ property: string; value: string; important: boolean }>; + source?: { start?: any; end?: any }; +}> { + const rules: Array<{ + selector: string; + declarations: Array<{ property: string; value: string; important: boolean }>; + source?: { start?: any; end?: any }; + }> = []; + + try { + const ast = postcss.parse(cssContent); + + ast.walkRules((rule: any) => { + const declarations: Array<{ property: string; value: string; important: boolean }> = []; + + rule.walkDecls((decl: any) => { + declarations.push({ + property: decl.prop, + value: decl.value, + important: decl.important + }); + }); + + rules.push({ + selector: rule.selector, + declarations: declarations, + source: { + start: rule.source?.start, + end: rule.source?.end + } + }); + }); + } catch (error: any) { + throw new Error(`Failed to parse CSS: ${error.message}`); + } + + return rules; +} + +/** + * Validate CSS selectors in the page context + * @param page - Playwright page object + * @param cssRules - Parsed CSS rules + * @param logger - Logger instance + * @returns Validation results with success and failed selectors + */ +export async function validateCSSSelectors( + page: any, + cssRules: Array<{ selector: string; declarations: any[] }>, + logger: any +): Promise<{ + successCount: number; + failedSelectors: string[]; + totalRules: number; +}> { + const failedSelectors: string[] = []; + let successCount = 0; + + for (const rule of cssRules) { + const selector = rule.selector; + + // Skip pseudo-selectors, media queries, and special selectors that can't be validated + if ( + selector.includes(':') || + selector.includes('@') || + selector.includes('::') + ) { + successCount++; // Count as success since they're valid CSS + continue; + } + + try { + // Validate if selector finds at least one element + const elementExists = await page.evaluate(({ selectorValue }: { selectorValue: string }) => { + try { + const elements = document.querySelectorAll(selectorValue); + return elements.length > 0; + } catch (error) { + return false; + } + }, { selectorValue: selector }); + + if (elementExists) { + successCount++; + logger.debug(`CSS selector valid: ${selector}`); + } else { + failedSelectors.push(selector); + logger.debug(`CSS selector found no elements: ${selector}`); + } + } catch (error: any) { + failedSelectors.push(selector); + logger.debug(`CSS selector validation error for "${selector}": ${error.message}`); + } + } + + return { + successCount, + failedSelectors, + totalRules: cssRules.length + }; +} + +/** + * Generate CSS injection report + * @param validationResult - Results from CSS selector validation + * @param logger - Logger instance + * @returns Formatted report string + */ +export function generateCSSInjectionReport( + validationResult: { + successCount: number; + failedSelectors: string[]; + totalRules: number; + }, + logger: any +): string { + const lines: string[] = []; + + lines.push(chalk.cyan('[SmartUI] CSS Injection Report:')); + + if (validationResult.successCount > 0) { + lines.push(chalk.green(`[SmartUI] ✅ Success: ${validationResult.successCount} rules applied.`)); + } + + if (validationResult.failedSelectors.length > 0) { + lines.push(chalk.yellow(`[SmartUI] ⚠️ Warning: ${validationResult.failedSelectors.length} selector(s) failed to find an element:`)); + validationResult.failedSelectors.forEach(selector => { + lines.push(chalk.yellow(`[SmartUI] - ${selector}`)); + }); + } + + const report = lines.join('\n'); + logger.info(report); + + return report; } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 099b1c9e..bc43a756 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,7 +43,8 @@ export interface Context { loadDomContent?: boolean; approvalThreshold?: number; rejectionThreshold?: number; - showRenderErrors?: boolean + showRenderErrors?: boolean; + customCSS?: string; }; uploadFilePath: string; webStaticConfig: WebStaticConfig; @@ -169,6 +170,7 @@ export interface Snapshot { approvalThreshold?: number; rejectionThreshold?: number; customCookies?: CustomCookie[]; + customCSS?: string; } } From 9ad4707b12da538a856c9a8c5a50944acc914f9a Mon Sep 17 00:00:00 2001 From: shrinishLT Date: Tue, 14 Oct 2025 13:44:13 +0530 Subject: [PATCH 2/2] fix report --- src/lib/processSnapshot.ts | 2 +- src/lib/utils.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/processSnapshot.ts b/src/lib/processSnapshot.ts index 12d7a476..aaf43f59 100644 --- a/src/lib/processSnapshot.ts +++ b/src/lib/processSnapshot.ts @@ -872,7 +872,7 @@ export default async function processSnapshot(snapshot: Snapshot, ctx: Context): try { const cssRules = parseCSSFile(processedOptions.customCSS); const validationResult = await validateCSSSelectors(page, cssRules, ctx.log); - const report = generateCSSInjectionReport(validationResult, ctx.log); + const report = generateCSSInjectionReport(validationResult, ctx.log, snapshot.name); if (validationResult.failedSelectors.length > 0) { validationResult.failedSelectors.forEach(selector => { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 752f4374..870edcae 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1123,11 +1123,12 @@ export function generateCSSInjectionReport( failedSelectors: string[]; totalRules: number; }, - logger: any + logger: any, + snapshotName: string ): string { const lines: string[] = []; - lines.push(chalk.cyan('[SmartUI] CSS Injection Report:')); + lines.push(chalk.cyan(`[SmartUI] CSS Injection Report for Snapshot: ${snapshotName}`)); if (validationResult.successCount > 0) { lines.push(chalk.green(`[SmartUI] ✅ Success: ${validationResult.successCount} rules applied.`));