diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d6e97f..2c50d4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Syntax check + - name: Type check run: pnpm run check + - name: Build + run: pnpm run build + - name: Show help - run: node capture.mjs --help + run: node dist/capture.js --help diff --git a/.gitignore b/.gitignore index 49a0c68..d1d571e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # dependencies node_modules/ .pnpm-store/ +dist/ # captured screenshots (output) *.png diff --git a/README.md b/README.md index bd4501f..158c905 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Requires **Node 18+** and **[pnpm](https://pnpm.io/)**. git clone https://github.com/eda-labs/screenshotter.git cd screenshotter pnpm install +pnpm run build ``` You also need a **Chrome/Chromium** binary. The tool auto-detects a system @@ -44,7 +45,8 @@ pnpm run install-browser # downloads a Chromium for playwright-core From a local clone, register the command globally: ```bash -pnpm link --global +pnpm run build +pnpm add --global . edascr capture --url https://my-eda.example.ts.net ``` @@ -70,9 +72,10 @@ edascr capture --url https://my-eda --aspect 16:9 \ --page /ui/app/main/interfaces.eda.nokia.com/v1alpha1/interfaces,/ui/app/main/core.eda.nokia.com/v1/toponodes ``` -In a clone you can equivalently run `node capture.mjs ...`. Installed as a -package (`pnpm add -g @eda-labs/screenshotter`, or via `npx`/`pnpm dlx`), it -exposes `edascr` and `eda-screenshotter` commands. +In a clone you can equivalently run `pnpm capture --url ...`, which builds the +TypeScript source before running `node dist/capture.js`. Installed as a package +(`pnpm add -g @eda-labs/screenshotter`, or via `npx`/`pnpm dlx`), it exposes +`edascr` and `eda-screenshotter` commands. The command prints per-phase timings while it runs. Authentication is performed once per invocation and reused for each requested theme. @@ -141,7 +144,7 @@ Two independent things control color: - Tuned for the current EDA UI. In **collapsed** nav mode the rail icons have no accessible labels, so Alarms / Transactions / Topologies are clicked by a - fixed pixel position (the `RAIL` y-coordinates near the top of `capture.mjs`). + fixed pixel position (the `RAIL` y-coordinates near the top of `src/capture.ts`). If a future EDA release changes the rail, update those, or just use `--nav expanded` (label-based, more robust). Very short viewports (height below ~650) can push the lower rail icons off-screen in collapsed mode. diff --git a/package.json b/package.json index 151e50a..6873014 100644 --- a/package.json +++ b/package.json @@ -4,20 +4,23 @@ "description": "Capture themed (dark/light) screenshots of the Nokia EDA (Event Driven Automation) web UI.", "type": "module", "bin": { - "edascr": "./capture.mjs", - "eda-screenshotter": "./capture.mjs" + "edascr": "./dist/capture.js", + "eda-screenshotter": "./dist/capture.js" }, - "main": "capture.mjs", + "main": "dist/capture.js", + "types": "dist/capture.d.ts", "files": [ - "capture.mjs", + "dist", "README.md", "LICENSE" ], "scripts": { - "capture": "node capture.mjs", - "start": "node capture.mjs", - "help": "node capture.mjs --help", - "check": "node --check capture.mjs", + "build": "tsc", + "capture": "pnpm run build && node dist/capture.js", + "start": "pnpm run capture", + "help": "pnpm run build && node dist/capture.js --help", + "check": "tsc --noEmit", + "prepare": "pnpm run build", "install-browser": "playwright-core install chromium" }, "keywords": [ @@ -44,5 +47,9 @@ "packageManager": "pnpm@11.3.0", "dependencies": { "playwright-core": "^1.60.0" + }, + "devDependencies": { + "@types/node": "^26.0.0", + "typescript": "^6.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df321f0..265d273 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,14 +11,40 @@ importers: playwright-core: specifier: ^1.60.0 version: 1.61.0 + devDependencies: + '@types/node': + specifier: ^26.0.0 + version: 26.0.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 packages: + '@types/node@26.0.0': + resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} + playwright-core@1.61.0: resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} engines: {node: '>=18'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + snapshots: + '@types/node@26.0.0': + dependencies: + undici-types: 8.3.0 + playwright-core@1.61.0: {} + + typescript@6.0.3: {} + + undici-types@8.3.0: {} diff --git a/capture.mjs b/src/capture.ts similarity index 70% rename from capture.mjs rename to src/capture.ts index 5bfbbfa..af756ad 100755 --- a/capture.mjs +++ b/src/capture.ts @@ -8,17 +8,26 @@ // edascr capture --url https://my-eda --page /ui/app/main/interfaces.eda.nokia.com/v1alpha1/interfaces // edascr capture --url https://my-eda --nav expanded --resolution 1920x1080 // -// (or `node capture.mjs ...` / `pnpm capture ...`) +// (or `node dist/capture.js ...` / `pnpm capture ...`) // Requires Node 18+, playwright-core, and a Chrome/Chromium binary. import { chromium } from 'playwright-core'; +import type { Browser, BrowserContextOptions, Page } from 'playwright-core'; import { mkdirSync, existsSync } from 'fs'; import { resolve } from 'path'; +type ArgValue = string | boolean | string[] | undefined; +type Args = { + _: string[]; + [key: string]: ArgValue; +}; +type LaunchOptions = NonNullable[0]>; +type StorageState = Exclude; + // --------------------------------------------------------------------------- // args // --------------------------------------------------------------------------- -function parseArgs(argv) { - const out = { _: [] }; +function parseArgs(argv: string[]): Args { + const out: Args = { _: [] }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith('--')) { @@ -31,34 +40,44 @@ function parseArgs(argv) { return out; } -function normalizeArgv(argv) { +function normalizeArgv(argv: string[]): string[] { const [command, ...rest] = argv; if (command === 'capture') return rest; if (command === 'help') return ['--help', ...rest]; return argv; } +function argString(value: ArgValue): string | undefined { + return typeof value === 'string' ? value : undefined; +} + const args = parseArgs(normalizeArgv(process.argv.slice(2))); -const num = (v) => (v === undefined ? undefined : Number(v)); - -const URL = (args.url || args._[0] || process.env.EDA_URL || '').replace(/\/$/, ''); -const USER = args.user || process.env.EDA_USER || 'admin'; -const PASS = args.pass || process.env.EDA_PASS || 'admin'; -const OUT = resolve(args.out || process.env.EDA_OUT || '.'); -const THEMES = (args.themes || 'dark,light').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); -const SCALE = num(args.scale) || 2; -const NAV_EXPANDED = String(args.nav || 'collapsed').toLowerCase().startsWith('exp'); +const num = (v: string | undefined): number | undefined => (v === undefined ? undefined : Number(v)); + +const URL = (argString(args.url) || args._[0] || process.env.EDA_URL || '').replace(/\/$/, ''); +const USER = argString(args.user) || process.env.EDA_USER || 'admin'; +const PASS = argString(args.pass) || process.env.EDA_PASS || 'admin'; +const OUT = resolve(argString(args.out) || process.env.EDA_OUT || '.'); +const THEMES = (argString(args.themes) || 'dark,light').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); +const SCALE = num(argString(args.scale)) || 2; +const NAV_EXPANDED = String(argString(args.nav) || 'collapsed').toLowerCase().startsWith('exp'); // --page / --path: one or more deep links (full URL or path), comma-separated. -const CUSTOM = String(args.page || args.path || '').split(',').map((s) => s.trim()).filter(Boolean); +const CUSTOM = (argString(args.page) || argString(args.path) || '').split(',').map((s) => s.trim()).filter(Boolean); // resolution / aspect ratio -> viewport (CSS px). Final PNG = viewport * scale. -let width = num(args.width), height = num(args.height); -if (args.resolution && typeof args.resolution === 'string') { - const [w, h] = args.resolution.toLowerCase().split('x').map(Number); +let width = num(argString(args.width)), height = num(argString(args.height)); +const resolution = argString(args.resolution); +const aspect = argString(args.aspect); +if (resolution) { + const [w, h] = resolution.toLowerCase().split('x').map(Number); if (w && h) { width = w; height = h; } -} else if (args.aspect && typeof args.aspect === 'string') { - const [aw, ah] = args.aspect.split(':').map(Number); - if (aw && ah) { width = width || 1480; height = Math.round((width * ah) / aw); } +} else if (aspect) { + const [aw, ah] = aspect.split(':').map(Number); + if (aw && ah) { + const aspectWidth = width || 1480; + width = aspectWidth; + height = Math.round((aspectWidth * ah) / aw); + } } const VIEW = { width: width || 1480, height: height || 920 }; const WAIT = { @@ -74,7 +93,7 @@ if (!URL || args.help) { console.log(`Usage: edascr capture --url [options] eda-screenshotter --url [options] - node capture.mjs --url [options] + node dist/capture.js --url [options] --url EDA base URL (also positional, or $EDA_URL) --user login username (default admin) @@ -94,18 +113,18 @@ if (!URL || args.help) { // --------------------------------------------------------------------------- // browser: prefer a system Chrome/Chromium, else Playwright-managed Chromium // --------------------------------------------------------------------------- -function launchOptions() { - const o = { args: ['--no-sandbox'] }; +function launchOptions(): LaunchOptions { + const options: LaunchOptions = { args: ['--no-sandbox'] }; const candidates = [ process.env.CHROME_PATH, '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - ].filter(Boolean); + ].filter((p): p is string => Boolean(p)); const found = candidates.find((p) => existsSync(p)); - if (found) o.executablePath = found; - return o; + if (found) options.executablePath = found; + return options; } // --------------------------------------------------------------------------- @@ -127,17 +146,17 @@ const BUSY_SELECTOR = [ '.v-progress-circular', ].join(','); -const shot = (page, scheme, name) => page.screenshot({ path: `${OUT}/${name}-${scheme}.png` }); +const shot = (page: Page, scheme: string, name: string) => page.screenshot({ path: `${OUT}/${name}-${scheme}.png` }); -function schemeFor(theme) { +function schemeFor(theme: string): 'light' | 'dark' { return theme === 'light' ? 'light' : 'dark'; } -function duration(ms) { +function duration(ms: number): string { return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)}s`; } -async function timed(label, fn, indent = ' ') { +async function timed(label: string, fn: () => Promise, indent = ' '): Promise { const start = performance.now(); try { return await fn(); @@ -146,24 +165,24 @@ async function timed(label, fn, indent = ' ') { } } -async function waitForPaint(page) { - await page.evaluate(() => new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(resolve)); +async function waitForPaint(page: Page): Promise { + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); })).catch(() => {}); } -async function waitForDocumentReady(page, timeout = WAIT.app) { +async function waitForDocumentReady(page: Page, timeout = WAIT.app): Promise { await page.waitForFunction(() => document.readyState !== 'loading', undefined, { timeout }).catch(() => {}); } -async function waitForAssetsReady(page) { +async function waitForAssetsReady(page: Page): Promise { await page.waitForFunction(() => !document.fonts || document.fonts.status === 'loaded', undefined, { timeout: 2000 }).catch(() => {}); await page.waitForFunction(() => Array.from(document.images).every((img) => img.complete), undefined, { timeout: 2000 }).catch(() => {}); } -async function waitForBusyGone(page, timeout = WAIT.busy) { - await page.waitForFunction((selector) => { - const isVisible = (el) => { +async function waitForBusyGone(page: Page, timeout = WAIT.busy): Promise { + await page.waitForFunction((selector: string) => { + const isVisible = (el: Element): boolean => { const style = getComputedStyle(el); const box = el.getBoundingClientRect(); return style.display !== 'none' && @@ -176,23 +195,23 @@ async function waitForBusyGone(page, timeout = WAIT.busy) { }, BUSY_SELECTOR, { timeout }).catch(() => {}); } -async function waitForDomQuiet(page, quietMs = 300, timeout = WAIT.quiet) { - await page.waitForFunction(({ quietMs }) => new Promise((resolve) => { +async function waitForDomQuiet(page: Page, quietMs = 300, timeout = WAIT.quiet): Promise { + await page.waitForFunction(({ quietMs }: { quietMs: number }) => new Promise((resolve) => { const target = document.body; if (!target) { resolve(true); return; } - let timer; - let observer; + let timer: ReturnType | undefined; + let observer: MutationObserver | undefined; const done = () => { - clearTimeout(timer); + if (timer !== undefined) clearTimeout(timer); observer?.disconnect(); resolve(true); }; const reset = () => { - clearTimeout(timer); + if (timer !== undefined) clearTimeout(timer); timer = setTimeout(done, quietMs); }; @@ -202,7 +221,7 @@ async function waitForDomQuiet(page, quietMs = 300, timeout = WAIT.quiet) { }), { quietMs }, { timeout }).catch(() => {}); } -async function settlePage(page, timeout = WAIT.page) { +async function settlePage(page: Page, timeout = WAIT.page): Promise { await waitForDocumentReady(page, timeout); await waitForBusyGone(page, Math.min(timeout, WAIT.busy)); await waitForDomQuiet(page, 300, Math.min(timeout, WAIT.quiet)); @@ -210,25 +229,37 @@ async function settlePage(page, timeout = WAIT.page) { await waitForPaint(page); } -async function waitForAppReady(page) { +async function waitForAppShellReady(page: Page, timeout = WAIT.app): Promise { + await page.waitForFunction(() => { + const text = (document.body?.innerText || '').replace(/\u200b/g, '').replace(/\s+/g, ' ').trim(); + if (!text || text === 'Loading...' || text.endsWith(' Loading...')) return false; + return text.includes('Event Driven Automation') && text.includes('All Namespaces') && text.includes('Home'); + }, undefined, { timeout }).catch((e: unknown) => { + throw new Error(`EDA app shell did not become ready within ${timeout}ms: ${String(e).split('\n')[0]}`); + }); +} + +async function waitForAppReady(page: Page): Promise { await waitForDocumentReady(page, WAIT.app); const loginVisible = await page.locator('#username, #kc-login').first().isVisible().catch(() => false); if (loginVisible) throw new Error('Authenticated session was not accepted; login form is still visible'); + await waitForAppShellReady(page, WAIT.app); await settlePage(page, WAIT.app); } -async function newCaptureContext(browser, theme, storageState) { - return browser.newContext({ +async function newCaptureContext(browser: Browser, theme: string, storageState?: StorageState) { + const options: BrowserContextOptions = { viewport: VIEW, deviceScaleFactor: SCALE, ignoreHTTPSErrors: true, colorScheme: schemeFor(theme), httpCredentials: { username: USER, password: PASS }, - ...(storageState ? { storageState } : {}), - }); + }; + if (storageState) options.storageState = storageState; + return browser.newContext(options); } -function slug(u) { +function slug(u: string): string { try { const segs = new globalThis.URL(u).pathname.split('/').filter(Boolean); const last = segs[segs.length - 1] || 'page'; @@ -236,7 +267,7 @@ function slug(u) { } catch { return 'page'; } } -async function login(page, theme, { captureLogin = false } = {}) { +async function login(page: Page, theme: string, { captureLogin = false }: { captureLogin?: boolean } = {}): Promise { await page.goto(URL, { waitUntil: 'domcontentloaded' }); const username = page.locator('#username'); @@ -252,7 +283,7 @@ async function login(page, theme, { captureLogin = false } = {}) { await waitForAppReady(page); } -async function authenticate(browser, theme) { +async function authenticate(browser: Browser, theme: string): Promise { const ctx = await newCaptureContext(browser, theme); const page = await ctx.newPage(); try { @@ -263,7 +294,7 @@ async function authenticate(browser, theme) { } } -async function captureLoginPage(browser, theme) { +async function captureLoginPage(browser: Browser, theme: string): Promise { const ctx = await newCaptureContext(browser, theme); const page = await ctx.newPage(); try { @@ -277,7 +308,7 @@ async function captureLoginPage(browser, theme) { } // EDA defaults to Dark and does not persist the choice, so set it every run. -async function setAppTheme(page, theme) { +async function setAppTheme(page: Page, theme: string): Promise { await page.mouse.click(VIEW.width - 30, 24); await page.getByText('Appearance Theme', { exact: true }).first().click({ timeout: WAIT.action }); await page.getByText(theme === 'light' ? 'Light' : 'Dark', { exact: true }).first().click({ timeout: WAIT.action }); @@ -286,7 +317,7 @@ async function setAppTheme(page, theme) { } // Pin / unpin the left nav via the top-left hamburger. Default state is collapsed. -async function setNav(page) { +async function setNav(page: Page): Promise { const visible = await page.getByText('Alarms', { exact: true }).first().isVisible().catch(() => false); if (NAV_EXPANDED !== visible) { // toggle only if the current state is wrong await page.mouse.click(42, 25); @@ -295,7 +326,7 @@ async function setNav(page) { } // Navigate to a top-level nav item: by label when expanded, by position when collapsed. -async function nav(page, label, y) { +async function nav(page: Page, label: string, y: number): Promise { if (NAV_EXPANDED) { await page.getByText(label, { exact: true }).first().click({ timeout: WAIT.action }); } else { @@ -305,12 +336,12 @@ async function nav(page, label, y) { await settlePage(page, WAIT.page); } -async function openApp(page) { +async function openApp(page: Page): Promise { await page.goto(URL, { waitUntil: 'domcontentloaded' }); await waitForAppReady(page); } -async function captureBuiltins(page, theme) { +async function captureBuiltins(page: Page, theme: string): Promise { await timed('02-home', async () => { await settlePage(page, WAIT.page); await shot(page, theme, '02-home'); @@ -343,20 +374,21 @@ async function captureBuiltins(page, theme) { }, ' '); } -async function captureCustom(page, theme) { +async function captureCustom(page: Page, theme: string): Promise { for (let i = 0; i < CUSTOM.length; i++) { const p = CUSTOM[i]; const full = p.startsWith('http') ? p : URL + (p.startsWith('/') ? p : '/' + p); await timed(`${String(i + 1).padStart(2, '0')}-${slug(full)}`, async () => { await page.goto(full, { waitUntil: 'domcontentloaded' }); await page.mouse.move(VIEW.width / 2, VIEW.height / 2); + await waitForAppShellReady(page, WAIT.page); await settlePage(page, WAIT.page); await shot(page, theme, `${String(i + 1).padStart(2, '0')}-${slug(full)}`); }, ' '); } } -async function captureTheme(browser, theme, storageState) { +async function captureTheme(browser: Browser, theme: string, storageState: StorageState): Promise { const ctx = await newCaptureContext(browser, theme, storageState); const page = await ctx.newPage(); const start = performance.now(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a9781c9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "sourceMap": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}