├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── code-of-conduct.md ├── contributing.md ├── example.js ├── license ├── media ├── logo-bw.ai ├── logo-bw.png ├── logo-bw.svg ├── logo.ai ├── logo.png ├── logo.svg ├── promo.png └── promo.psd ├── package.json ├── readme.md ├── source └── index.ts ├── test ├── _server.ts ├── cookie.ts ├── fixture.html ├── test.ts └── tsconfig.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.psd binary 3 | *.ai binary 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 22 14 | - 20 15 | - 18 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install 22 | - name: Disable AppArmor for unprivileged user namespaces 23 | run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 24 | - run: npm test 25 | - uses: codecov/codecov-action@v3 26 | if: matrix.node-version == 16 27 | with: 28 | fail_ci_if_error: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | .nyc_output 4 | coverage 5 | dist 6 | localhost*.png 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /code-of-conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | sindresorhus@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Pageres 2 | 3 | Please note that this project is released with a [Contributor Code of Conduct](code-of-conduct.md). By participating in this project you agree to abide by its terms. 4 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | import Pageres from './dist/index.js'; 2 | 3 | await new Pageres({delay: 2}) 4 | .source('https://github.com/sindresorhus/pageres', ['480x320', '1024x768'], {crop: true}) 5 | .source('https://sindresorhus.com', ['1280x1024', '1920x1080']) 6 | .source('data:text/html,

Awesome!

', ['1024x768']) 7 | .destination('screenshots') 8 | .run(); 9 | 10 | console.log('Finished generating screenshots!'); 11 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /media/logo-bw.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/pageres/93d75749e897f2cf7f715cb9ab63327ac0376daf/media/logo-bw.ai -------------------------------------------------------------------------------- /media/logo-bw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/pageres/93d75749e897f2cf7f715cb9ab63327ac0376daf/media/logo-bw.png -------------------------------------------------------------------------------- /media/logo-bw.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/pageres/93d75749e897f2cf7f715cb9ab63327ac0376daf/media/logo.ai -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/pageres/93d75749e897f2cf7f715cb9ab63327ac0376daf/media/logo.png -------------------------------------------------------------------------------- /media/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/pageres/93d75749e897f2cf7f715cb9ab63327ac0376daf/media/promo.png -------------------------------------------------------------------------------- /media/promo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/pageres/93d75749e897f2cf7f715cb9ab63327ac0376daf/media/promo.psd -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pageres", 3 | "version": "8.1.0", 4 | "description": "Capture website screenshots", 5 | "license": "MIT", 6 | "repository": "sindresorhus/pageres", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./dist/index.d.ts", 16 | "default": "./dist/index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=18" 21 | }, 22 | "scripts": { 23 | "test": "xo && tsc --noEmit && nyc ava --timeout=2m", 24 | "release": "np", 25 | "build": "del-cli dist && tsc", 26 | "prepare": "npm run build" 27 | }, 28 | "files": [ 29 | "dist" 30 | ], 31 | "keywords": [ 32 | "page", 33 | "website", 34 | "site", 35 | "web", 36 | "url", 37 | "resolution", 38 | "size", 39 | "screenshot", 40 | "screenshots", 41 | "screengrab", 42 | "screen", 43 | "snapshot", 44 | "shot", 45 | "responsive", 46 | "gulpfriendly", 47 | "puppeteer", 48 | "chrome", 49 | "image", 50 | "svg", 51 | "render", 52 | "html", 53 | "headless", 54 | "capture", 55 | "pic", 56 | "picture", 57 | "png", 58 | "jpg", 59 | "jpeg" 60 | ], 61 | "dependencies": { 62 | "array-differ": "^4.0.0", 63 | "array-uniq": "^3.0.0", 64 | "capture-website": "^4.1.0", 65 | "date-fns": "^3.6.0", 66 | "filenamify": "^6.0.0", 67 | "filenamify-url": "^3.1.0", 68 | "get-res": "^3.0.0", 69 | "lodash.template": "^4.5.0", 70 | "log-symbols": "^6.0.0", 71 | "make-dir": "^5.0.0", 72 | "p-map": "^7.0.2", 73 | "p-memoize": "^7.1.1", 74 | "plur": "^5.1.0", 75 | "type-fest": "^4.23.0", 76 | "unused-filename": "^4.0.1", 77 | "viewport-list": "^5.1.1" 78 | }, 79 | "devDependencies": { 80 | "@sindresorhus/tsconfig": "^6.0.0", 81 | "@types/cookie": "^0.6.0", 82 | "@types/get-res": "^3.0.3", 83 | "@types/lodash.template": "^4.5.3", 84 | "@types/node": "^20.14.12", 85 | "@types/png.js": "^0.2.3", 86 | "@types/sinon": "^17.0.3", 87 | "@types/viewport-list": "^5.1.3", 88 | "ava": "^6.1.3", 89 | "cookie": "^0.6.0", 90 | "del-cli": "^5.1.0", 91 | "file-type": "^19.3.0", 92 | "get-port": "^7.1.0", 93 | "image-dimensions": "^2.3.0", 94 | "nyc": "^17.0.0", 95 | "path-exists": "^5.0.0", 96 | "pify": "^6.1.0", 97 | "png.js": "^0.2.1", 98 | "sinon": "^18.0.0", 99 | "ts-node": "^10.9.2", 100 | "typescript": "^5.5.4", 101 | "xo": "^0.59.2" 102 | }, 103 | "ava": { 104 | "workerThreads": false, 105 | "extensions": { 106 | "ts": "module" 107 | }, 108 | "nodeArguments": [ 109 | "--loader=ts-node/esm" 110 | ] 111 | }, 112 | "xo": { 113 | "parserOptions": { 114 | "project": "./test/tsconfig.json" 115 | }, 116 | "rules": { 117 | "no-await-in-loop": "off", 118 | "@typescript-eslint/no-unused-vars": "off", 119 | "@typescript-eslint/no-unsafe-assignment": "off", 120 | "@typescript-eslint/no-unsafe-return": "off", 121 | "@typescript-eslint/no-unsafe-call": "off", 122 | "unicorn/prefer-event-target": "off", 123 | "n/file-extension-in-import": "off", 124 | "@typescript-eslint/no-unsafe-argument": "off" 125 | } 126 | }, 127 | "nyc": { 128 | "reporter": [ 129 | "text", 130 | "lcov" 131 | ], 132 | "extension": [ 133 | ".ts" 134 | ] 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ![pageres](media/promo.png) 2 | 3 | [![Coverage Status](https://codecov.io/gh/sindresorhus/pageres/branch/main/graph/badge.svg)](https://codecov.io/gh/sindresorhus/pageres) 4 | [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) 5 | 6 | Capture screenshots of websites in various resolutions. A good way to make sure your websites are responsive. It's speedy and generates 100 screenshots from 10 different websites in just over a minute. It can also be used to render SVG images. 7 | 8 | *See [pageres-cli](https://github.com/sindresorhus/pageres-cli) for the command-line tool.* 9 | 10 | ## Install 11 | 12 | ```sh 13 | npm install pageres 14 | ``` 15 | 16 | Note to Linux users: If you get a "No usable sandbox!" error, you need to enable [system sandboxing](https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md#setting-up-chrome-linux-sandbox). 17 | 18 | ## Usage 19 | 20 | ```js 21 | import Pageres from 'pageres'; 22 | 23 | await new Pageres({delay: 2}) 24 | .source('https://github.com/sindresorhus/pageres', ['480x320', '1024x768'], {crop: true}) 25 | .source('https://sindresorhus.com', ['1280x1024', '1920x1080']) 26 | .source('data:text/html,

Awesome!

', ['1024x768']) 27 | .destination('screenshots') 28 | .run(); 29 | 30 | console.log('Finished generating screenshots!'); 31 | ``` 32 | 33 | ## API 34 | 35 | ### Pageres(options?) 36 | 37 | #### options 38 | 39 | Type: `object` 40 | 41 | ##### delay 42 | 43 | Type: `number` *(Seconds)*\ 44 | Default: `0` 45 | 46 | Delay capturing the screenshot. 47 | 48 | Useful when the site does things after load that you want to capture. 49 | 50 | ##### timeout 51 | 52 | Type: `number` *(Seconds)*\ 53 | Default: `60` 54 | 55 | Number of seconds after which the request is aborted. 56 | 57 | ##### crop 58 | 59 | Type: `boolean`\ 60 | Default: `false` 61 | 62 | Crop to the set height. 63 | 64 | ##### css 65 | 66 | Type: `string` 67 | 68 | Apply custom CSS to the webpage. Specify some CSS or the path to a CSS file. 69 | 70 | ##### script 71 | 72 | Type: `string` 73 | 74 | Apply custom JavaScript to the webpage. Specify some JavaScript or the path to a file. 75 | 76 | ##### cookies 77 | 78 | Type: `Array` 79 | 80 | A string with the same format as a [browser cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) or [an object](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagesetcookiecookies). 81 | 82 | Tip: Go to the website you want a cookie for and [copy-paste it from DevTools](https://stackoverflow.com/a/24961735/64949). 83 | 84 | ##### filename 85 | 86 | Type: `string`\ 87 | Default: `'<%= url %>-<%= size %><%= crop %>'` 88 | 89 | Define a customized filename using [Lo-Dash templates](https://lodash.com/docs#template).\ 90 | For example: `<%= date %> - <%= url %>-<%= size %><%= crop %>`. 91 | 92 | Available variables: 93 | 94 | - `url`: The URL in [slugified](https://github.com/sindresorhus/filenamify-url) form, eg. `http://yeoman.io/blog/` becomes `yeoman.io!blog` 95 | - `size`: Specified size, eg. `1024x1000` 96 | - `width`: Width of the specified size, eg. `1024` 97 | - `height`: Height of the specified size, eg. `1000` 98 | - `crop`: Outputs `-cropped` when the crop option is true 99 | - `date`: The current date (YYYY-MM-DD), eg. 2015-05-18 100 | - `time`: The current time (HH-mm-ss), eg. 21-15-11 101 | 102 | ##### incrementalName 103 | 104 | Type: `boolean`\ 105 | Default: `false` 106 | 107 | When a file exists, append an incremental number. 108 | 109 | ##### selector 110 | 111 | Type: `string` 112 | 113 | Capture a specific DOM element matching a CSS selector. 114 | 115 | ##### hide 116 | 117 | Type: `string[]` 118 | 119 | Hide an array of DOM elements matching CSS selectors. 120 | 121 | ##### username 122 | 123 | Type: `string` 124 | 125 | Username for authenticating with HTTP auth. 126 | 127 | ##### password 128 | 129 | Type: `string` 130 | 131 | Password for authenticating with HTTP auth. 132 | 133 | ##### scale 134 | 135 | Type: `number`\ 136 | Default: `1` 137 | 138 | Scale webpage `n` times. 139 | 140 | ##### format 141 | 142 | Type: `string`\ 143 | Default: `png`\ 144 | Values: `'png' | 'jpg'` 145 | 146 | Image format. 147 | 148 | ##### userAgent 149 | 150 | Type: `string` 151 | 152 | Custom user agent. 153 | 154 | ##### headers 155 | 156 | Type: `object` 157 | 158 | Custom HTTP request headers. 159 | 160 | ##### transparent 161 | 162 | Type: `boolean`\ 163 | Default: `false` 164 | 165 | Set background color to `transparent` instead of `white` if no background is set. 166 | 167 | ##### darkMode 168 | 169 | Type: `boolean`\ 170 | Default: `false` 171 | 172 | Emulate preference of dark color scheme. 173 | 174 | ##### launchOptions 175 | 176 | Type: `object`\ 177 | Default: `{}` 178 | 179 | Options passed to [`puppeteer.launch()`](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions). 180 | 181 | ##### beforeScreenshot 182 | 183 | Type: `Function` 184 | 185 | The specified function is called right before the screenshot is captured, as well as before any bounding rectangle is calculated as part of `options.element`. It receives the Puppeteer [`Page` instance](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#class-page) as the first argument and the [`browser` instance](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#class-browser) as the second argument. This gives you a lot of power to do custom stuff. The function can be async. 186 | 187 | Note: Make sure to not call `page.close()` or `browser.close()`. 188 | 189 | ```js 190 | import Pageres from 'pageres'; 191 | 192 | await new Pageres({ 193 | delay: 2, 194 | beforeScreenshot: async (page, browser) => { 195 | await checkSomething(); 196 | await page.click('#activate-button'); 197 | await page.waitForSelector('.finished'); 198 | } 199 | }) 200 | .source('https://github.com/sindresorhus/pageres', ['480x320', '1024x768', 'iphone 5s'], {crop: true}) 201 | .destination('screenshots') 202 | .run(); 203 | 204 | console.log('Finished generating screenshots!'); 205 | ``` 206 | 207 | ### pageres.source(url, sizes, options?) 208 | 209 | Add a page to screenshot. 210 | 211 | #### url 212 | 213 | *Required*\ 214 | Type: `string` 215 | 216 | URL or local path to the website you want to screenshot. You can also use a data URI. 217 | 218 | #### sizes 219 | 220 | *Required*\ 221 | Type: `string[]` 222 | 223 | Use a `x` notation or a keyword. 224 | 225 | A keyword is a version of a device from [this list](https://github.com/kevva/viewport-list/blob/master/data.json). 226 | 227 | You can also pass in the `w3counter` keyword to use the ten most popular resolutions from [w3counter](http://www.w3counter.com/globalstats.php). 228 | 229 | #### options 230 | 231 | Type: `object` 232 | 233 | Options set here will take precedence over the ones set in the constructor. 234 | 235 | ### pageres.destination(directory) 236 | 237 | Set the destination directory. 238 | 239 | #### directory 240 | 241 | Type: `string` 242 | 243 | ### pageres.run() 244 | 245 | Run pageres. 246 | 247 | Returns `Promise`. 248 | 249 | ## Task runners 250 | 251 | Check out [grunt-pageres](https://github.com/sindresorhus/grunt-pageres) if you're using Grunt. 252 | 253 | For Gulp and Broccoli, just use the API directly. No need for a wrapper plugin. 254 | 255 | ## Built with Pageres 256 | 257 | - [Break Shot](https://github.com/victorferraz/break-shot) - Desktop app for capturing screenshots of responsive websites. 258 | 259 | ## Related 260 | 261 | - [capture-website](https://github.com/sindresorhus/capture-website) - A different take on screenshotting websites 262 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import path from 'node:path'; 3 | import {pathToFileURL} from 'node:url'; 4 | import fs from 'node:fs'; 5 | import fsPromises from 'node:fs/promises'; 6 | import os from 'node:os'; 7 | import {EventEmitter} from 'node:events'; 8 | import captureWebsite, { 9 | type Options as CaptureWebsiteOptions, 10 | type BeforeScreenshot, 11 | type LaunchOptions, 12 | } from 'capture-website'; 13 | import pMemoize from 'p-memoize'; 14 | import filenamify from 'filenamify'; 15 | import {unusedFilename} from 'unused-filename'; 16 | import arrayUniq from 'array-uniq'; 17 | import arrayDiffer from 'array-differ'; 18 | import {format as formatDate} from 'date-fns'; 19 | import getResolutions from 'get-res'; 20 | import logSymbols from 'log-symbols'; 21 | import {makeDirectory} from 'make-dir'; 22 | import viewportList from 'viewport-list'; 23 | import template from 'lodash.template'; 24 | import plur from 'plur'; 25 | import filenamifyUrl from 'filenamify-url'; 26 | import pMap from 'p-map'; 27 | import type {Writable} from 'type-fest'; 28 | 29 | const cpuCount = os.cpus().length; 30 | 31 | export type Options = { 32 | /** 33 | Delay capturing the screenshot. 34 | 35 | Useful when the site does things after load that you want to capture. 36 | 37 | @default 0 38 | */ 39 | readonly delay?: number; 40 | 41 | /** 42 | Number of seconds after which the request is aborted. 43 | 44 | @default 60 45 | */ 46 | readonly timeout?: number; 47 | 48 | /** 49 | Crop to the set height. 50 | 51 | @default false 52 | */ 53 | readonly crop?: boolean; 54 | 55 | /** 56 | Apply custom CSS to the webpage. Specify some CSS or the path to a CSS file. 57 | */ 58 | readonly css?: string; 59 | 60 | /** 61 | Apply custom JavaScript to the webpage. Specify some JavaScript or the path to a file. 62 | */ 63 | readonly script?: string; 64 | 65 | /** 66 | A string with the same format as a [browser cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) or [an object](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagesetcookiecookies). 67 | 68 | Tip: Go to the website you want a cookie for and [copy-paste it from DevTools](https://stackoverflow.com/a/24961735/64949). 69 | 70 | @default [] 71 | */ 72 | readonly cookies?: ReadonlyArray>; 73 | 74 | /** 75 | Define a customized filename using [Lo-Dash templates](https://lodash.com/docs#template).\ 76 | For example: `<%= date %> - <%= url %>-<%= size %><%= crop %>`. 77 | 78 | Available variables: 79 | 80 | - `url`: The URL in [slugified](https://github.com/sindresorhus/filenamify-url) form, eg. `http://yeoman.io/blog/` becomes `yeoman.io!blog` 81 | - `size`: Specified size, eg. `1024x1000` 82 | - `width`: Width of the specified size, eg. `1024` 83 | - `height`: Height of the specified size, eg. `1000` 84 | - `crop`: Outputs `-cropped` when the crop option is true 85 | - `date`: The current date (YYYY-MM-DD), eg. 2015-05-18 86 | - `time`: The current time (HH-mm-ss), eg. 21-15-11 87 | 88 | @default '<%= url %>-<%= size %><%= crop %>' 89 | */ 90 | readonly filename?: string; 91 | 92 | /** 93 | When a file exists, append an incremental number. 94 | 95 | @default false 96 | */ 97 | readonly incrementalName?: boolean; 98 | 99 | /** 100 | Capture a specific DOM element matching a CSS selector. 101 | */ 102 | readonly selector?: string; 103 | 104 | /** 105 | Hide an array of DOM elements matching CSS selectors. 106 | 107 | @default [] 108 | */ 109 | readonly hide?: string[]; 110 | 111 | /** 112 | Username for authenticating with HTTP auth. 113 | */ 114 | readonly username?: string; 115 | 116 | /** 117 | Password for authenticating with HTTP auth. 118 | */ 119 | readonly password?: string; 120 | 121 | /** 122 | Scale webpage `n` times. 123 | 124 | @default 1 125 | */ 126 | readonly scale?: number; 127 | 128 | /** 129 | Image format. 130 | 131 | @default 'png' 132 | */ 133 | readonly format?: 'png' | 'jpg' | 'jpeg'; 134 | 135 | /** 136 | Custom user agent. 137 | */ 138 | readonly userAgent?: string; 139 | 140 | /** 141 | Custom HTTP request headers. 142 | 143 | @default {} 144 | */ 145 | readonly headers?: Record; 146 | 147 | /** 148 | Set background color to `transparent` instead of `white` if no background is set. 149 | 150 | @default false 151 | */ 152 | readonly transparent?: boolean; 153 | 154 | /** 155 | Emulate preference of dark color scheme. 156 | 157 | @default false 158 | */ 159 | readonly darkMode?: boolean; 160 | 161 | /** 162 | Options passed to [`puppeteer.launch()`](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions). 163 | 164 | @default {} 165 | */ 166 | readonly launchOptions?: LaunchOptions; 167 | 168 | /** 169 | The specified function is called right before the screenshot is captured. It receives the Puppeteer [`Page` instance](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#class-page) as the first argument and the [`browser` instance](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#class-browser) as the second argument. This gives you a lot of power to do custom stuff. The function can be async. 170 | 171 | Note: Make sure to not call `page.close()` or `browser.close()`. 172 | 173 | @example 174 | ``` 175 | import Pageres from 'pageres'; 176 | 177 | await new Pageres({ 178 | delay: 2, 179 | beforeScreenshot: async (page, browser) => { 180 | await checkSomething(); 181 | await page.click('#activate-button'); 182 | await page.waitForSelector('.finished'); 183 | } 184 | }) 185 | .source('https://github.com/sindresorhus/pageres', ['480x320', '1024x768'], {crop: true}) 186 | .destination('screenshots') 187 | .run(); 188 | 189 | console.log('Finished generating screenshots!'); 190 | ``` 191 | */ 192 | readonly beforeScreenshot?: BeforeScreenshot; 193 | }; 194 | 195 | /** 196 | A page to screenshot added in {@link Pageres.source}. 197 | */ 198 | export type Source = { 199 | /** 200 | URL or local path to a website to screenshot. Can also be a data URI. 201 | */ 202 | readonly url: string; 203 | 204 | /** 205 | Size of screenshot. Uses `x` notation or a keyword. 206 | */ 207 | readonly sizes: string[]; 208 | 209 | /** 210 | Options which will take precedence over the ones set in the constructor. 211 | */ 212 | readonly options?: Options; 213 | }; 214 | 215 | /** 216 | A destination directory set in {@link Pageres.destination}. 217 | */ 218 | export type Destination = string; 219 | 220 | export type Viewport = { 221 | readonly url: string; 222 | readonly sizes: string[]; 223 | readonly keywords: string[]; 224 | }; 225 | 226 | type Stats = { 227 | urls: number; 228 | sizes: number; 229 | screenshots: number; 230 | }; 231 | 232 | /** 233 | Data representing a screenshot. Includes the filename from the template in {@link Options.filename}. 234 | */ 235 | export type Screenshot = Uint8Array & {filename: string}; 236 | 237 | const getResolutionsMemoized = pMemoize(getResolutions); 238 | // @ts-expect-error - TS is not very smart. 239 | const viewportListMemoized = pMemoize(viewportList); 240 | 241 | /** 242 | Capture screenshots of websites in various resolutions. A good way to make sure your websites are responsive. It's speedy and generates 100 screenshots from 10 different websites in just over a minute. It can also be used to render SVG images. 243 | */ 244 | export default class Pageres extends EventEmitter { 245 | readonly #options: Writable; 246 | readonly #stats: Stats = {} as Stats; // eslint-disable-line @typescript-eslint/consistent-type-assertions 247 | readonly #items: Screenshot[] = []; 248 | readonly #sizes: string[] = []; 249 | readonly #urls: string[] = []; 250 | readonly #_source: Source[] = []; 251 | #_destination: Destination = ''; 252 | 253 | constructor(options: Options = {}) { 254 | super(); 255 | 256 | // Prevent false-positive `MaxListenersExceededWarning` warnings 257 | this.setMaxListeners(Number.POSITIVE_INFINITY); 258 | 259 | this.#options = {...options}; 260 | this.#options.filename ??= '<%= url %>-<%= size %><%= crop %>'; 261 | this.#options.format ??= 'png'; 262 | this.#options.incrementalName ??= false; 263 | this.#options.launchOptions ??= {}; 264 | } 265 | 266 | /** 267 | Retrieve pages to screenshot. 268 | 269 | @returns List of pages that have been already been added. 270 | */ 271 | source(): Source[]; 272 | 273 | /** 274 | Add a page to screenshot. 275 | @param url - URL or local path to the website you want to screenshot. You can also use a data URI. 276 | @param sizes - Use a `x` notation or a keyword. 277 | 278 | A keyword is a version of a device from [this list](https://github.com/kevva/viewport-list/blob/master/data.json). 279 | 280 | You can also pass in the `w3counter` keyword to use the ten most popular resolutions from [w3counter](http://www.w3counter.com/globalstats.php). 281 | @param options - Options set here will take precedence over the ones set in the constructor. 282 | 283 | @example 284 | ``` 285 | import Pageres from 'pageres'; 286 | 287 | const pageres = new Pageres({delay: 2}) 288 | .source('https://github.com/sindresorhus/pageres', ['480x320', '1024x768'], {crop: true}) 289 | .source('https://sindresorhus.com', ['1280x1024', '1920x1080']) 290 | .source('data:text/html,

Awesome!

', ['1024x768'], {delay: 1}); 291 | ``` 292 | */ 293 | source(url: string, sizes: readonly string[], options?: Options): this; 294 | 295 | source(url?: string, sizes?: readonly string[], options?: Options): this | Source[] { 296 | if (url === undefined) { 297 | return this.#_source; 298 | } 299 | 300 | if (!(typeof url === 'string' && url.length > 0)) { 301 | throw new TypeError('URL required'); 302 | } 303 | 304 | if (!(Array.isArray(sizes) && sizes.length > 0)) { 305 | throw new TypeError('Sizes required'); 306 | } 307 | 308 | this.#_source.push({url, sizes, options}); 309 | 310 | return this; 311 | } 312 | 313 | /** 314 | Get the destination directory. 315 | */ 316 | destination(): Destination; 317 | 318 | /** 319 | Set the destination directory. 320 | 321 | @example 322 | ``` 323 | import Pageres from 'pageres'; 324 | 325 | const pageres = new Pageres() 326 | .source('https://github.com/sindresorhus/pageres', ['480x320']) 327 | .destination('screenshots'); 328 | ``` 329 | */ 330 | destination(directory: Destination): this; 331 | 332 | destination(directory?: Destination): this | Destination { 333 | if (directory === undefined) { 334 | return this.#_destination; 335 | } 336 | 337 | if (!(typeof directory === 'string' && directory.length > 0)) { 338 | throw new TypeError('Directory required'); 339 | } 340 | 341 | this.#_destination = directory; 342 | 343 | return this; 344 | } 345 | 346 | /** 347 | Run pageres. 348 | 349 | @returns List of screenshot data. 350 | 351 | @example 352 | ``` 353 | import Pageres from 'pageres'; 354 | 355 | await new Pageres({delay: 2}) 356 | .source('https://sindresorhus.com', ['1280x1024']) 357 | .destination('screenshots') 358 | .run(); 359 | ``` 360 | */ 361 | async run(): Promise { 362 | await Promise.all(this.source().map(async (source: Source): Promise => { 363 | const options = { 364 | ...this.#options, 365 | ...source.options, 366 | }; 367 | 368 | const sizes = arrayUniq(source.sizes.filter(size => /^\d{2,4}x\d{2,4}$/i.test(size))); 369 | const keywords = arrayDiffer(source.sizes, sizes); 370 | 371 | this.#urls.push(source.url); 372 | 373 | if (sizes.length === 0 && keywords.includes('w3counter')) { 374 | return this.#resolution(source.url, options); 375 | } 376 | 377 | if (keywords.length > 0) { 378 | return this.#viewport({url: source.url, sizes, keywords}, options); 379 | } 380 | 381 | const screenshots = await pMap( 382 | sizes, 383 | async (size: string): Promise => { 384 | this.#sizes.push(size); 385 | return this.#create(source.url, size, options); 386 | }, 387 | {concurrency: cpuCount * 2}, 388 | ); 389 | this.#items.push(...screenshots); 390 | 391 | return undefined; 392 | })); 393 | 394 | this.#stats.urls = arrayUniq(this.#urls).length; 395 | this.#stats.sizes = arrayUniq(this.#sizes).length; 396 | this.#stats.screenshots = this.#items.length; 397 | 398 | if (!this.destination()) { 399 | return this.#items; 400 | } 401 | 402 | await this.#save(this.#items); 403 | 404 | return this.#items; 405 | } 406 | 407 | /** 408 | Print a success message to the console. 409 | 410 | @example 411 | ``` 412 | import Pageres from 'pageres'; 413 | 414 | const pageres = new Pageres({delay: 2}) 415 | .source('https://sindresorhus.com', ['1280x1024', '1920x1080']) 416 | .destination('screenshots'); 417 | 418 | await pageres.run(); 419 | 420 | // prints: Generated 2 screenshots from 1 url and 2 sizes. 421 | pageres.successMessage(); 422 | ``` 423 | */ 424 | successMessage(): void { 425 | const {screenshots, sizes, urls} = this.#stats; 426 | const words = { 427 | screenshots: plur('screenshot', screenshots), 428 | sizes: plur('size', sizes), 429 | urls: plur('url', urls), 430 | }; 431 | 432 | console.log(`\n${logSymbols.success} Generated ${screenshots} ${words.screenshots} from ${urls} ${words.urls} and ${sizes} ${words.sizes}`); 433 | } 434 | 435 | async #resolution(url: string, options: Options): Promise { 436 | for (const item of await getResolutionsMemoized() as Array<{item: string}>) { 437 | this.#sizes.push(item.item); 438 | this.#items.push(await this.#create(url, item.item, options)); 439 | } 440 | } 441 | 442 | async #viewport(viewport: Viewport, options: Options): Promise { 443 | for (const item of await viewportListMemoized(viewport.keywords) as Array<{size: string}>) { 444 | this.#sizes.push(item.size); 445 | viewport.sizes.push(item.size); 446 | } 447 | 448 | for (const size of arrayUniq(viewport.sizes)) { 449 | this.#items.push(await this.#create(viewport.url, size, options)); 450 | } 451 | } 452 | 453 | async #save(screenshots: Screenshot[]): Promise { 454 | await Promise.all(screenshots.map(async screenshot => { 455 | await makeDirectory(this.destination()); 456 | const destination = path.join(this.destination(), screenshot.filename); 457 | await fsPromises.writeFile(destination, screenshot); 458 | })); 459 | } 460 | 461 | async #create(url: string, size: string, options: Options): Promise { 462 | const basename = fs.existsSync(url) ? path.basename(url) : url; 463 | 464 | let hash = new URL(url, pathToFileURL(process.cwd())).hash ?? ''; 465 | // Strip empty hash fragments: `#` `#/` `#!/` 466 | if (/^#!?\/?$/.test(hash)) { 467 | hash = ''; 468 | } 469 | 470 | const [width, height] = size.split('x'); 471 | 472 | const filenameTemplate = template(`${options.filename!}.${options.format!}`); 473 | 474 | const now = Date.now(); 475 | let filename = filenameTemplate({ 476 | crop: options.crop ? '-cropped' : '', 477 | date: formatDate(now, 'yyyy-MM-dd'), 478 | time: formatDate(now, 'HH-mm-ss'), 479 | size, 480 | width, 481 | height, 482 | url: `${filenamifyUrl(basename)}${filenamify(hash)}`, 483 | }); 484 | 485 | if (options.incrementalName) { 486 | filename = await unusedFilename(filename); 487 | } 488 | 489 | const finalOptions: Writable = { 490 | width: Number(width), 491 | height: Number(height), 492 | delay: options.delay, 493 | timeout: options.timeout, 494 | fullPage: !options.crop, 495 | styles: options.css ? [options.css] : undefined, 496 | defaultBackground: !options.transparent, 497 | scripts: options.script ? [options.script] : undefined, 498 | cookies: options.cookies as any, // TODO: Support string cookies in capture-website 499 | element: options.selector, 500 | hideElements: options.hide, 501 | scaleFactor: options.scale ?? 1, 502 | type: options.format === 'jpg' ? 'jpeg' : 'png', 503 | userAgent: options.userAgent, 504 | headers: options.headers, 505 | darkMode: options.darkMode, 506 | launchOptions: options.launchOptions, 507 | beforeScreenshot: options.beforeScreenshot, 508 | }; 509 | 510 | if (options.username && options.password) { 511 | finalOptions.authentication = { 512 | username: options.username, 513 | password: options.password, 514 | }; 515 | } 516 | 517 | const screenshot = new Uint8Array(await captureWebsite.buffer(url, finalOptions)) as Screenshot; 518 | screenshot.filename = filename; 519 | return screenshot; 520 | } 521 | } 522 | -------------------------------------------------------------------------------- /test/_server.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs'; 3 | import http from 'node:http'; 4 | import {fileURLToPath} from 'node:url'; 5 | import cookie from 'cookie'; 6 | import getPort from 'get-port'; 7 | import pify from 'pify'; 8 | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 | 11 | export const host = 'localhost'; 12 | 13 | export type TestServer = { 14 | host: string; 15 | port: number; 16 | url: string; 17 | protocol: string; 18 | } & http.Server; 19 | 20 | const baseCreateServer = (function_: http.RequestListener): (() => Promise) => async (): Promise => { 21 | const port = await getPort(); 22 | const server = http.createServer(function_) as unknown as TestServer; 23 | 24 | server.host = host; 25 | server.port = port; 26 | server.url = `http://${host}:${port}`; 27 | server.protocol = 'http'; 28 | server.listen(port); 29 | // @ts-expect-error - The `pify` types are not strict. 30 | server.close = pify(server.close) as typeof server.close; 31 | 32 | return server; 33 | }; 34 | 35 | export const createServer = baseCreateServer((_request, response) => { 36 | response.writeHead(200, {'content-type': 'text/html'}); 37 | response.end(fs.readFileSync(path.join(__dirname, 'fixture.html'), 'utf8')); 38 | }); 39 | 40 | export const createCookieServer = baseCreateServer((request, response) => { 41 | const color = cookie.parse(String(request.headers.cookie)).pageresColor ?? 'white'; 42 | response.writeHead(200, {'content-type': 'text/html'}); 43 | response.end(`
; 8 | 9 | async function cookieTest(input: string | Cookie, t: ExecutionContext): Promise { 10 | const server = await createCookieServer(); 11 | // Width of the screenshot 12 | const width = 320; 13 | // Height of the screenshot 14 | const height = 480; 15 | // Bits per pixel 16 | const bpp = 24; 17 | 18 | const screenshots = await new Pageres({cookies: [input]}) 19 | .source(server.url, [width + 'x' + height]) 20 | .run(); 21 | 22 | server.close(); 23 | 24 | const png = new PNG(screenshots[0]); 25 | const {pixels} = await pify(png.parse.bind(png))(); 26 | 27 | // Validate image size 28 | t.is(pixels.length, width * height * bpp / 8); 29 | 30 | // Validate pixel color 31 | t.is(pixels[0], 64); 32 | t.is(pixels[1], 128); 33 | t.is(pixels[2], 255); 34 | } 35 | 36 | test('send cookie', cookieTest.bind(null, 'pageresColor=rgb(64 128 255); Path=/; Domain=localhost')); 37 | 38 | test('send cookie using an object', cookieTest.bind(null, { 39 | name: 'pageresColor', 40 | value: 'rgb(64 128 255)', 41 | domain: 'localhost', 42 | })); 43 | -------------------------------------------------------------------------------- /test/fixture.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 |

unicorns

9 | 10 |
    11 |
  • Sindre Sorhus
  • 12 |
  • Kevin Mårtensson
  • 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import fs from 'node:fs'; 3 | import fsPromises from 'node:fs/promises'; 4 | import {fileURLToPath} from 'node:url'; 5 | import path from 'node:path'; 6 | import test from 'ava'; 7 | import {imageDimensionsFromData} from 'image-dimensions'; 8 | import {format as formatDate} from 'date-fns'; 9 | import PNG from 'png.js'; 10 | import pify from 'pify'; 11 | import {pathExists} from 'path-exists'; 12 | import {stub as sinonStub} from 'sinon'; 13 | import {fileTypeFromBuffer} from 'file-type'; 14 | import Pageres, {type Screenshot} from '../source/index.js'; 15 | import {type TestServer, createServer} from './_server.js'; 16 | 17 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 18 | 19 | const hasScreenshotsWithFilenames = (screenshots: readonly Screenshot[], filenames: readonly string[]): boolean => screenshots.some(screenshot => filenames.includes(screenshot.filename)); 20 | 21 | const getPngPixels = async (data: Uint8Array): Promise => { 22 | const png = new PNG(data); 23 | const {pixels} = await pify(png.parse.bind(png))(); 24 | return pixels; 25 | }; 26 | 27 | let server: TestServer; 28 | let serverFileName: string; 29 | test.before(async () => { 30 | server = await createServer(); 31 | serverFileName = server.url 32 | .replace('http://', '') 33 | .replace(':', '!'); 34 | }); 35 | 36 | test.after(() => { 37 | server.close(); 38 | }); 39 | 40 | test('expose a constructor', t => { 41 | t.is(typeof Pageres, 'function'); 42 | }); 43 | 44 | test('`.source()` - error if no correct `url` is specified', t => { 45 | t.throws(() => { 46 | new Pageres().source('', ['1280x1024', '1920x1080']); 47 | }, {message: 'URL required'}); 48 | }); 49 | 50 | test('`.source()` - error if no `sizes` is specified', t => { 51 | t.throws(() => { 52 | new Pageres().source(server.url, []); 53 | }, {message: 'Sizes required'}); 54 | }); 55 | 56 | test('`.destination()` - error if no correct `directory` is specified', t => { 57 | t.throws(() => { 58 | new Pageres().destination(''); 59 | }, {message: 'Directory required'}); 60 | }); 61 | 62 | test('generate screenshots', async t => { 63 | const screenshots = await new Pageres() 64 | .source(server.url, ['480x320', '1024x768', 'iphone 5s']) 65 | .source(server.url, ['1280x1024', '1920x1080']) 66 | .run(); 67 | 68 | t.is(screenshots.length, 5); 69 | t.true(hasScreenshotsWithFilenames(screenshots, [`${serverFileName}-480x320.png`])); 70 | t.true(hasScreenshotsWithFilenames(screenshots, [`${serverFileName}-1920x1080.png`])); 71 | t.true(screenshots[0].length > 1000); 72 | }); 73 | 74 | test('generate screenshots - multiple sizes for one URL', async t => { 75 | const screenshots = await new Pageres() 76 | .source(server.url, ['1280x1024', '1920x1080']) 77 | .run(); 78 | 79 | t.is(screenshots.length, 2); 80 | t.true(hasScreenshotsWithFilenames(screenshots, [`${serverFileName}-1280x1024.png`])); 81 | t.true(hasScreenshotsWithFilenames(screenshots, [`${serverFileName}-1920x1080.png`])); 82 | t.true(screenshots[0].length > 1000); 83 | }); 84 | 85 | test('save filename with hash', async t => { 86 | const screenshots = await new Pageres() 87 | .source('https://example.com#', ['480x320']) 88 | .source('https://example.com/#/', ['480x320']) 89 | .source('https://example.com/#/@user', ['480x320']) 90 | .source('https://example.com/#/product/listing', ['480x320']) 91 | .source('https://example.com/#!/bang', ['480x320']) 92 | .source('https://example.com#readme', ['480x320']) 93 | .run(); 94 | 95 | t.is(screenshots.length, 6); 96 | 97 | t.true(hasScreenshotsWithFilenames(screenshots, [ 98 | 'example.com!#-480x320.png', 99 | 'example.com!#-480x320.png', 100 | 'example.com!#!@user-480x320.png', 101 | 'example.com!#!product!listing-480x320.png', 102 | 'example.com!#!bang-480x320.png', 103 | 'example.com#readme-480x320.png', 104 | ])); 105 | 106 | t.true(screenshots[0].length > 1000); 107 | }); 108 | 109 | test.serial('success message', async t => { 110 | const stub = sinonStub(console, 'log'); 111 | const pageres = new Pageres().source(server.url, ['480x320', '1024x768', 'iphone 5s']); 112 | await pageres.run(); 113 | pageres.successMessage(); 114 | const [message] = stub.firstCall.args; 115 | t.true(message.includes('Generated 3 screenshots from 1 url and 1 size'), message); // eslint-disable-line ava/assertion-arguments 116 | stub.restore(); 117 | }); 118 | 119 | test('remove special characters from the URL to create a valid filename', async t => { 120 | const screenshots = await new Pageres().source(`${server.url}?query=pageres*|<>:"\\`, ['1024x768']).run(); 121 | t.is(screenshots.length, 1); 122 | t.is(screenshots[0].filename, `${server.host}!${server.port}!query=pageres-1024x768.png`); 123 | }); 124 | 125 | test('`delay` option', async t => { 126 | const now = Date.now(); 127 | await new Pageres({delay: 2}).source(server.url, ['1024x768']).run(); 128 | t.true(Date.now() - now > 2000); 129 | }); 130 | 131 | test('`crop` option', async t => { 132 | const screenshots = await new Pageres({crop: true}).source(server.url, ['1024x768']).run(); 133 | t.is(screenshots[0].filename, `${server.host}!${server.port}-1024x768-cropped.png`); 134 | 135 | const size = imageDimensionsFromData(screenshots[0]) as any; 136 | t.is(size.width, 1024); 137 | t.is(size.height, 768); 138 | }); 139 | 140 | test('`css` option', async t => { 141 | const screenshots = await new Pageres({css: 'body { background-color: red !important; }'}).source(server.url, ['1024x768']).run(); 142 | const pixels = await getPngPixels(screenshots[0]); 143 | t.is(pixels[0], 255); 144 | t.is(pixels[1], 0); 145 | t.is(pixels[2], 0); 146 | }); 147 | 148 | test('`script` option', async t => { 149 | const screenshots = await new Pageres({ 150 | script: 'document.body.style.backgroundColor = \'red\';', 151 | }).source(server.url, ['1024x768']).run(); 152 | const pixels = await getPngPixels(screenshots[0]); 153 | t.is(pixels[0], 255); 154 | t.is(pixels[1], 0); 155 | t.is(pixels[2], 0); 156 | }); 157 | 158 | test('`filename` option', async t => { 159 | const screenshots = await new Pageres() 160 | .source(server.url, ['1024x768'], { 161 | filename: '<%= date %> - <%= time %> - <%= url %>', 162 | }) 163 | .run(); 164 | 165 | t.is(screenshots.length, 1); 166 | t.regex(screenshots[0].filename, new RegExp(`${formatDate(Date.now(), 'yyyy-MM-dd')} - \\d{2}-\\d{2}-\\d{2} - ${server.host}!${server.port}.png`)); 167 | }); 168 | 169 | test('`selector` option', async t => { 170 | const screenshots = await new Pageres({selector: '#team'}).source(server.url, ['1024x768']).run(); 171 | t.is(screenshots[0].filename, `${server.host}!${server.port}-1024x768.png`); 172 | 173 | const size = imageDimensionsFromData(screenshots[0]) as any; 174 | t.is(size.width, 1024); 175 | t.is(size.height, 80); 176 | }); 177 | 178 | test.serial('support local relative files', async t => { 179 | const _cwd = process.cwd(); 180 | process.chdir(__dirname); 181 | const screenshots = await new Pageres().source('fixture.html', ['1024x768']).run(); 182 | t.is(screenshots[0].filename, 'fixture.html-1024x768.png'); 183 | t.true(screenshots[0].length > 1000); 184 | process.chdir(_cwd); 185 | }); 186 | 187 | test('support local absolute files', async t => { 188 | const screenshots = await new Pageres().source(path.join(__dirname, 'fixture.html'), ['1024x768']).run(); 189 | t.is(screenshots[0].filename, 'fixture.html-1024x768.png'); 190 | t.true(screenshots[0].length > 1000); 191 | }); 192 | 193 | /// test('fetch resolutions from w3counter', async t => { 194 | // const screenshots = await new Pageres().source(server.url, ['w3counter']).run(); 195 | // t.is(screenshots.length, 10); 196 | // t.true(screenshots[0].length > 1000); 197 | // }); 198 | 199 | test('save image', async t => { 200 | try { 201 | await new Pageres().source(server.url, ['1024x768']).destination(__dirname).run(); 202 | t.true(fs.existsSync(path.join(__dirname, `${server.host}!${server.port}-1024x768.png`))); 203 | } finally { 204 | await fsPromises.unlink(path.join(__dirname, `${server.host}!${server.port}-1024x768.png`)); 205 | } 206 | }); 207 | 208 | test('remove temporary files on error', async t => { 209 | await t.throwsAsync( 210 | new Pageres().source('https://this-is-a-error-site.io', ['1024x768']).destination(__dirname).run(), 211 | { 212 | message: /ERR_NAME_NOT_RESOLVED/, 213 | }, 214 | ); 215 | t.false(await pathExists(path.join(__dirname, 'this-is-a-error-site.io.png'))); 216 | }); 217 | 218 | test('auth using username and password', async t => { 219 | const screenshots = await new Pageres({ 220 | username: 'user', 221 | password: 'passwd', 222 | }).source('https://httpbin.org/basic-auth/user/passwd', ['120x120']).run(); 223 | 224 | t.is(screenshots.length, 1); 225 | t.true(screenshots[0].length > 0); 226 | }); 227 | 228 | test('`scale` option', async t => { 229 | const screenshots = await new Pageres({ 230 | scale: 2, 231 | crop: true, 232 | }).source(server.url, ['120x120']).run(); 233 | 234 | const size = imageDimensionsFromData(screenshots[0]) as any; 235 | t.is(size.width, 240); 236 | t.is(size.height, 240); 237 | }); 238 | 239 | test('support data URL', async t => { 240 | const screenshots = await new Pageres().source('data:text/html;base64,PGgxPkZPTzwvaDE+', ['100x100']).run(); 241 | const fileType = await fileTypeFromBuffer(screenshots[0]); 242 | t.is(fileType?.mime, 'image/png'); 243 | }); 244 | 245 | test('`format` option', async t => { 246 | const screenshots = await new Pageres().source(server.url, ['100x100'], {format: 'jpg'}).run(); 247 | const fileType = await fileTypeFromBuffer(screenshots[0]); 248 | t.is(fileType?.mime, 'image/jpeg'); 249 | }); 250 | 251 | test('when a file exists, append an incrementer', async t => { 252 | const folderPath = process.cwd(); 253 | try { 254 | await new Pageres({delay: 2}).source(server.url, ['1024x768', '480x320'], {incrementalName: true, filename: '<%= url %>'}).destination(folderPath).run(); 255 | t.true(fs.existsSync(path.join(folderPath, `${serverFileName}.png`))); 256 | await new Pageres({delay: 2}).source(server.url, ['1024x768', '480x320'], {incrementalName: true, filename: '<%= url %>'}).destination(folderPath).run(); 257 | t.true(fs.existsSync(path.join(folderPath, `${serverFileName} (1).png`))); 258 | } finally { 259 | await fsPromises.unlink(path.join(folderPath, `${serverFileName}.png`)); 260 | await fsPromises.unlink(path.join(folderPath, `${serverFileName} (1).png`)); 261 | } 262 | }); 263 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig" 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "isolatedModules": true 6 | }, 7 | "include": [ 8 | "source" 9 | ], 10 | "ts-node": { 11 | "transpileOnly": true, 12 | "files": true, 13 | "experimentalResolver": true 14 | } 15 | } 16 | --------------------------------------------------------------------------------