@afixt/screenshot-utils 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/LICENSE +13 -0
- package/README.md +132 -0
- package/bin/afixt-screenshot +14 -0
- package/dist/index.js +2797 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2795 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +164 -0
- package/src/adapters/cdp.js +116 -0
- package/src/adapters/index.js +17 -0
- package/src/adapters/playwright.js +150 -0
- package/src/adapters/puppeteer.js +128 -0
- package/src/capture/element.js +257 -0
- package/src/capture/index.js +17 -0
- package/src/capture/long-page.js +267 -0
- package/src/capture/page.js +207 -0
- package/src/capture/responsive.js +94 -0
- package/src/capture/settle.js +182 -0
- package/src/capture/viewport.js +21 -0
- package/src/cli/index.js +266 -0
- package/src/compose/annotate.js +283 -0
- package/src/compose/diff.js +173 -0
- package/src/compose/heatmap.js +159 -0
- package/src/compose/index.js +8 -0
- package/src/compose/theme.js +45 -0
- package/src/errors/adapter.js +65 -0
- package/src/errors/base.js +48 -0
- package/src/errors/capture.js +175 -0
- package/src/errors/compose.js +78 -0
- package/src/errors/index.js +75 -0
- package/src/errors/optional.js +37 -0
- package/src/errors/redaction.js +42 -0
- package/src/errors/tile.js +61 -0
- package/src/errors/transform.js +78 -0
- package/src/index.js +69 -0
- package/src/redact/index.js +6 -0
- package/src/redact/policy.js +67 -0
- package/src/redact/redact.js +268 -0
- package/src/tile/dzi.js +268 -0
- package/src/tile/index.js +5 -0
- package/src/transform/convert.js +54 -0
- package/src/transform/crop.js +251 -0
- package/src/transform/index.js +8 -0
- package/src/transform/normalize.js +83 -0
- package/src/transform/resize.js +99 -0
- package/src/types/index.d.ts +432 -0
package/package.json
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@afixt/screenshot-utils",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Screenshot capture, transform, compose, tile, and redact utilities for the AFixt/Revenant accessibility platform",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.mjs",
|
|
8
|
+
"types": "./dist/types/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/types/index.d.ts",
|
|
12
|
+
"import": "./dist/index.mjs",
|
|
13
|
+
"require": "./dist/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"dist",
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"bin": {
|
|
26
|
+
"afixt-screenshot": "./bin/afixt-screenshot"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "npm-run-all -p build:bundle build:types",
|
|
30
|
+
"build:bundle": "tsup",
|
|
31
|
+
"build:types": "tsc -p tsconfig.json",
|
|
32
|
+
"build:clean": "rm -rf dist",
|
|
33
|
+
"prebuild": "npm run build:clean",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:watch": "vitest",
|
|
36
|
+
"test:coverage": "vitest run --coverage",
|
|
37
|
+
"test:single": "vitest run --testNamePattern",
|
|
38
|
+
"test:perf": "vitest run --config vitest.perf.config.js",
|
|
39
|
+
"lint": "eslint . --max-warnings 0",
|
|
40
|
+
"lint:fix": "eslint . --fix --max-warnings 0",
|
|
41
|
+
"format": "prettier --write .",
|
|
42
|
+
"format:check": "prettier --check .",
|
|
43
|
+
"lint:md": "markdownlint '**/*.md' --ignore node_modules --ignore scratchpads --ignore dist --ignore docs --ignore CHANGELOG.md --ignore spec.md",
|
|
44
|
+
"lint:cpd": "jscpd . --config .jscpd.json",
|
|
45
|
+
"lint:cpd:ci": "jscpd . --config .jscpd.json --threshold 5",
|
|
46
|
+
"security:audit": "npm audit",
|
|
47
|
+
"security:check": "npm audit --audit-level=high",
|
|
48
|
+
"security:check:prod": "npm audit --omit=dev --audit-level=high",
|
|
49
|
+
"security:osv": "osv-scanner --config=.osv-scanner.toml --lockfile=package-lock.json",
|
|
50
|
+
"security:semgrep": "semgrep --config=p/javascript --config=p/nodejsscan --config=p/owasp-top-ten --config=p/secrets --error --metrics=off",
|
|
51
|
+
"security:secrets": "trufflehog git file://. --only-verified --fail --no-update",
|
|
52
|
+
"security:secrets:staged": "trufflehog git file://. --since-commit HEAD --only-verified --fail --no-update",
|
|
53
|
+
"license:check": "license-checker-rseidelsohn --production --onlyAllow 'MIT;ISC;Apache-2.0;BSD-2-Clause;BSD-3-Clause;0BSD;CC0-1.0;Unlicense;Python-2.0;WTFPL;BlueOak-1.0.0;LGPL-3.0-or-later;LGPL-3.0' --excludePackagesStartingWith '@afixt/' --summary",
|
|
54
|
+
"license:report": "license-checker-rseidelsohn --production --csv --out reports/licenses.csv --excludePackagesStartingWith '@afixt/'",
|
|
55
|
+
"links": "lychee --no-progress --exclude 'github\\.com/AFixt/' '**/*.md' --exclude-path 'node_modules/**' --exclude-path 'dist/**' --exclude-path 'docs/**' --exclude-path 'scratchpads/**'",
|
|
56
|
+
"docs": "jsdoc -c jsdoc.json",
|
|
57
|
+
"docs:serve": "npx http-server ./docs",
|
|
58
|
+
"check": "npm-run-all -p lint format:check lint:md lint:cpd:ci",
|
|
59
|
+
"check:all": "npm-run-all -s check test security:check:prod license:check",
|
|
60
|
+
"release": "standard-version",
|
|
61
|
+
"prepare": "husky"
|
|
62
|
+
},
|
|
63
|
+
"lint-staged": {
|
|
64
|
+
"*.{js,mjs,cjs}": [
|
|
65
|
+
"eslint --fix",
|
|
66
|
+
"prettier --write"
|
|
67
|
+
],
|
|
68
|
+
"*.{json,md,yml,yaml}": [
|
|
69
|
+
"prettier --write"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"repository": {
|
|
73
|
+
"type": "git",
|
|
74
|
+
"url": "git+ssh://git@github.com/AFixt/screenshot-utils.git"
|
|
75
|
+
},
|
|
76
|
+
"bugs": {
|
|
77
|
+
"url": "https://github.com/AFixt/screenshot-utils/issues"
|
|
78
|
+
},
|
|
79
|
+
"homepage": "https://github.com/AFixt/screenshot-utils",
|
|
80
|
+
"author": "Karl Groves <karl.groves@afixt.com>",
|
|
81
|
+
"license": "UNLICENSED",
|
|
82
|
+
"publishConfig": {
|
|
83
|
+
"access": "restricted"
|
|
84
|
+
},
|
|
85
|
+
"engines": {
|
|
86
|
+
"node": ">=22.22.1"
|
|
87
|
+
},
|
|
88
|
+
"keywords": [
|
|
89
|
+
"screenshot",
|
|
90
|
+
"accessibility",
|
|
91
|
+
"puppeteer",
|
|
92
|
+
"playwright",
|
|
93
|
+
"sharp",
|
|
94
|
+
"dzi",
|
|
95
|
+
"tile",
|
|
96
|
+
"diff",
|
|
97
|
+
"annotate",
|
|
98
|
+
"redact",
|
|
99
|
+
"a11y",
|
|
100
|
+
"afixt",
|
|
101
|
+
"revenant"
|
|
102
|
+
],
|
|
103
|
+
"dependencies": {
|
|
104
|
+
"sharp": "0.34.5"
|
|
105
|
+
},
|
|
106
|
+
"peerDependencies": {
|
|
107
|
+
"@napi-rs/canvas": "^0.1.0",
|
|
108
|
+
"pixelmatch": "^7.1.0",
|
|
109
|
+
"playwright": ">=1.40.0",
|
|
110
|
+
"pngjs": "^7.0.0",
|
|
111
|
+
"puppeteer": ">=23.0.0"
|
|
112
|
+
},
|
|
113
|
+
"peerDependenciesMeta": {
|
|
114
|
+
"@napi-rs/canvas": {
|
|
115
|
+
"optional": true
|
|
116
|
+
},
|
|
117
|
+
"pixelmatch": {
|
|
118
|
+
"optional": true
|
|
119
|
+
},
|
|
120
|
+
"playwright": {
|
|
121
|
+
"optional": true
|
|
122
|
+
},
|
|
123
|
+
"pngjs": {
|
|
124
|
+
"optional": true
|
|
125
|
+
},
|
|
126
|
+
"puppeteer": {
|
|
127
|
+
"optional": true
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
"devDependencies": {
|
|
131
|
+
"@commitlint/cli": "20.5.3",
|
|
132
|
+
"@commitlint/config-conventional": "20.5.3",
|
|
133
|
+
"@eslint/js": "10.0.1",
|
|
134
|
+
"@types/node": "22.19.19",
|
|
135
|
+
"@vitest/coverage-v8": "4.1.5",
|
|
136
|
+
"clean-jsdoc-theme": "4.3.2",
|
|
137
|
+
"eslint": "10.3.0",
|
|
138
|
+
"eslint-plugin-import-x": "4.16.2",
|
|
139
|
+
"eslint-plugin-jsdoc": "62.9.0",
|
|
140
|
+
"eslint-plugin-n": "18.0.1",
|
|
141
|
+
"eslint-plugin-no-secrets": "2.3.3",
|
|
142
|
+
"eslint-plugin-promise": "7.3.0",
|
|
143
|
+
"eslint-plugin-security": "4.0.0",
|
|
144
|
+
"eslint-plugin-sonarjs": "4.0.3",
|
|
145
|
+
"eslint-plugin-unicorn": "64.0.0",
|
|
146
|
+
"globals": "17.6.0",
|
|
147
|
+
"husky": "9.1.7",
|
|
148
|
+
"jscpd": "4.0.9",
|
|
149
|
+
"jsdoc": "4.0.4",
|
|
150
|
+
"license-checker-rseidelsohn": "4.4.2",
|
|
151
|
+
"lint-staged": "17.0.2",
|
|
152
|
+
"markdownlint-cli": "0.48.0",
|
|
153
|
+
"npm-run-all2": "8.0.4",
|
|
154
|
+
"pixelmatch": "7.1.0",
|
|
155
|
+
"playwright": "1.60.0",
|
|
156
|
+
"pngjs": "7.0.0",
|
|
157
|
+
"prettier": "3.8.3",
|
|
158
|
+
"puppeteer": "25.0.2",
|
|
159
|
+
"standard-version": "9.5.0",
|
|
160
|
+
"tsup": "8.5.1",
|
|
161
|
+
"typescript": "6.0.3",
|
|
162
|
+
"vitest": "4.1.5"
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AdapterContractError, BrowserApiError } = require('../errors/index.js');
|
|
4
|
+
const { fromPuppeteerPage } = require('./puppeteer.js');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wrap a CDP (Chrome DevTools Protocol) session + Puppeteer page in the
|
|
8
|
+
* package's `PageAdapter` interface.
|
|
9
|
+
*
|
|
10
|
+
* Use this adapter when the consumer already holds a CDP session (e.g.
|
|
11
|
+
* the engine's CDP-attached runtime path described in spec § 10). The
|
|
12
|
+
* adapter routes `screenshot()` through CDP's `Page.captureScreenshot`
|
|
13
|
+
* — which avoids the page.screenshot() round-trip overhead — while
|
|
14
|
+
* delegating every other method to the supplied Puppeteer page.
|
|
15
|
+
*
|
|
16
|
+
* The Puppeteer page is required because CDP does not expose
|
|
17
|
+
* `Page.setViewport`, document scrollWidth/Height, or page reloads in a
|
|
18
|
+
* single coherent API; falling back to the Puppeteer page for those
|
|
19
|
+
* keeps the contract identical to `fromPuppeteerPage`.
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
* @param {object} session A CDP session (e.g. from `page.target().createCDPSession()`).
|
|
23
|
+
* @param {object} page The same Puppeteer Page the session is attached to.
|
|
24
|
+
* @param {object} [options]
|
|
25
|
+
* @param {string} [options.label]
|
|
26
|
+
* @param {number} [options.screenshotMaxHeight] Override Chromium's 16384 limit.
|
|
27
|
+
* @returns {import('../types/index.d.ts').PageAdapter}
|
|
28
|
+
*/
|
|
29
|
+
function fromCDPSession(session, page, options = {}) {
|
|
30
|
+
assertSession(session);
|
|
31
|
+
// assertPuppeteerPage validation runs inside fromPuppeteerPage.
|
|
32
|
+
const base = fromPuppeteerPage(page, options);
|
|
33
|
+
const label = options.label;
|
|
34
|
+
const screenshotMaxHeight = Number.isFinite(options.screenshotMaxHeight)
|
|
35
|
+
? options.screenshotMaxHeight
|
|
36
|
+
: 16_384;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
async screenshot(opts) {
|
|
40
|
+
const cdpOpts = translateScreenshotOpts(opts);
|
|
41
|
+
try {
|
|
42
|
+
const response = await session.send('Page.captureScreenshot', cdpOpts);
|
|
43
|
+
return Buffer.from(response.data, 'base64');
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new BrowserApiError('CDP Page.captureScreenshot failed', {
|
|
46
|
+
cause: error,
|
|
47
|
+
context: { operation: 'CDP.Page.captureScreenshot', label },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
// Delegate every non-screenshot method to the wrapped Puppeteer adapter.
|
|
52
|
+
evaluate: base.evaluate.bind(base),
|
|
53
|
+
scrollTo: base.scrollTo.bind(base),
|
|
54
|
+
getMetrics: base.getMetrics.bind(base),
|
|
55
|
+
setViewport: base.setViewport.bind(base),
|
|
56
|
+
reload: base.reload.bind(base),
|
|
57
|
+
get capabilities() {
|
|
58
|
+
return {
|
|
59
|
+
screenshotMaxHeight,
|
|
60
|
+
supportsViewportEmulation: true,
|
|
61
|
+
supportsCDP: true,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
get label() {
|
|
65
|
+
return label;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Translate the adapter's ScreenshotOptions into CDP's
|
|
72
|
+
* `Page.captureScreenshot` parameter shape.
|
|
73
|
+
*
|
|
74
|
+
* @param {import('../types/index.d.ts').ScreenshotOptions} [opts]
|
|
75
|
+
* @returns {object}
|
|
76
|
+
*/
|
|
77
|
+
function translateScreenshotOpts(opts = {}) {
|
|
78
|
+
const out = {
|
|
79
|
+
format: opts.type === 'jpeg' ? 'jpeg' : 'png',
|
|
80
|
+
};
|
|
81
|
+
if (opts.type === 'jpeg' && Number.isFinite(opts.quality)) {
|
|
82
|
+
out.quality = opts.quality;
|
|
83
|
+
}
|
|
84
|
+
if (opts.clip) {
|
|
85
|
+
out.clip = {
|
|
86
|
+
x: opts.clip.x,
|
|
87
|
+
y: opts.clip.y,
|
|
88
|
+
width: opts.clip.width,
|
|
89
|
+
height: opts.clip.height,
|
|
90
|
+
scale: 1,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (opts.fullPage) {
|
|
94
|
+
out.captureBeyondViewport = true;
|
|
95
|
+
}
|
|
96
|
+
if (opts.omitBackground) {
|
|
97
|
+
out.optimizeForSpeed = false;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate the CDP session looks the part.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} session
|
|
106
|
+
*/
|
|
107
|
+
function assertSession(session) {
|
|
108
|
+
if (session === null || typeof session !== 'object' || typeof session.send !== 'function') {
|
|
109
|
+
throw new AdapterContractError(
|
|
110
|
+
'fromCDPSession(session, page) requires a CDP session with a .send() method',
|
|
111
|
+
{ context: { received: typeof session } }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { fromCDPSession };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Barrel for `PageAdapter` factories. See `spec.md` § 10 for the contract.
|
|
5
|
+
*
|
|
6
|
+
* Adapter implementations live in sibling files (puppeteer, playwright,
|
|
7
|
+
* cdp). The corresponding peer dependency must be installed by the caller;
|
|
8
|
+
* the adapter modules themselves do not `require()` the peer.
|
|
9
|
+
*
|
|
10
|
+
* @module screenshot-utils/adapters
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { fromPuppeteerPage } = require('./puppeteer.js');
|
|
14
|
+
const { fromPlaywrightPage } = require('./playwright.js');
|
|
15
|
+
const { fromCDPSession } = require('./cdp.js');
|
|
16
|
+
|
|
17
|
+
module.exports = { fromPuppeteerPage, fromPlaywrightPage, fromCDPSession };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AdapterContractError, BrowserApiError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wrap a Playwright `Page` object in the package's `PageAdapter` interface.
|
|
7
|
+
*
|
|
8
|
+
* Like `fromPuppeteerPage`, this adapter never imports `playwright` itself —
|
|
9
|
+
* the caller passes the `page` they already created. `playwright` remains
|
|
10
|
+
* an optional peer at the package level (spec § 10).
|
|
11
|
+
*
|
|
12
|
+
* Differences from Puppeteer's API (handled here so consumers don't have to):
|
|
13
|
+
* - Playwright uses `page.setViewportSize({ width, height })` instead of
|
|
14
|
+
* `page.setViewport(...)`. The deviceScaleFactor on the Viewport type is
|
|
15
|
+
* not settable post-hoc in Playwright — pass it at browser context
|
|
16
|
+
* creation time.
|
|
17
|
+
* - Playwright's `page.screenshot()` accepts `type: 'png' | 'jpeg'` (no
|
|
18
|
+
* webp). The adapter passes the type through; webp re-encode happens
|
|
19
|
+
* one level up in capture functions.
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
* @param {object} page A Playwright Page instance (or compatible shim).
|
|
23
|
+
* @param {object} [options]
|
|
24
|
+
* @param {string} [options.label] Identifier for log lines.
|
|
25
|
+
* @param {number} [options.screenshotMaxHeight] Override Chromium's 16384 limit.
|
|
26
|
+
* @returns {import('../types/index.d.ts').PageAdapter}
|
|
27
|
+
*/
|
|
28
|
+
function fromPlaywrightPage(page, options = {}) {
|
|
29
|
+
assertPlaywrightPage(page);
|
|
30
|
+
|
|
31
|
+
const label = options.label;
|
|
32
|
+
const screenshotMaxHeight = Number.isFinite(options.screenshotMaxHeight)
|
|
33
|
+
? options.screenshotMaxHeight
|
|
34
|
+
: 16_384;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
async screenshot(opts) {
|
|
38
|
+
return wrapBrowserCall('page.screenshot', () => page.screenshot(opts), label);
|
|
39
|
+
},
|
|
40
|
+
async evaluate(fn, ...args) {
|
|
41
|
+
return wrapBrowserCall(
|
|
42
|
+
'page.evaluate',
|
|
43
|
+
// Playwright expects evaluate(fn, arg) — single arg position.
|
|
44
|
+
// Forward exactly what the caller passed.
|
|
45
|
+
() => (args.length === 0 ? page.evaluate(fn) : page.evaluate(fn, ...args)),
|
|
46
|
+
label
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
async scrollTo(point) {
|
|
50
|
+
await wrapBrowserCall(
|
|
51
|
+
'page.evaluate(scrollTo)',
|
|
52
|
+
() => page.evaluate(({ x, y }) => globalThis.scrollTo(x, y), point),
|
|
53
|
+
label
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
async getMetrics() {
|
|
57
|
+
const metrics = await wrapBrowserCall(
|
|
58
|
+
'page.evaluate(getMetrics)',
|
|
59
|
+
() =>
|
|
60
|
+
page.evaluate(() => {
|
|
61
|
+
const doc = globalThis.document;
|
|
62
|
+
return {
|
|
63
|
+
width: doc.documentElement.scrollWidth,
|
|
64
|
+
height: doc.documentElement.scrollHeight,
|
|
65
|
+
viewport: {
|
|
66
|
+
width: globalThis.innerWidth,
|
|
67
|
+
height: globalThis.innerHeight,
|
|
68
|
+
},
|
|
69
|
+
devicePixelRatio: globalThis.devicePixelRatio,
|
|
70
|
+
url: globalThis.location.href,
|
|
71
|
+
};
|
|
72
|
+
}),
|
|
73
|
+
label
|
|
74
|
+
);
|
|
75
|
+
return { ...metrics, capturedAt: new Date().toISOString() };
|
|
76
|
+
},
|
|
77
|
+
async setViewport(viewport) {
|
|
78
|
+
await wrapBrowserCall(
|
|
79
|
+
'page.setViewportSize',
|
|
80
|
+
() =>
|
|
81
|
+
page.setViewportSize({
|
|
82
|
+
width: viewport.width,
|
|
83
|
+
height: viewport.height,
|
|
84
|
+
}),
|
|
85
|
+
label
|
|
86
|
+
);
|
|
87
|
+
// deviceScaleFactor is intentionally NOT applied: Playwright
|
|
88
|
+
// sets it at context creation time. Adapter contract is
|
|
89
|
+
// "best-effort"; consumers that need DPR control should
|
|
90
|
+
// recreate the page in a new context.
|
|
91
|
+
},
|
|
92
|
+
async reload(opts) {
|
|
93
|
+
await wrapBrowserCall('page.reload', () => page.reload(opts), label);
|
|
94
|
+
},
|
|
95
|
+
get capabilities() {
|
|
96
|
+
return {
|
|
97
|
+
screenshotMaxHeight,
|
|
98
|
+
supportsViewportEmulation: true,
|
|
99
|
+
supportsCDP: false, // Playwright's CDP requires explicit session setup
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
get label() {
|
|
103
|
+
return label;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate the supplied object exposes the methods we use.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} page
|
|
112
|
+
*/
|
|
113
|
+
function assertPlaywrightPage(page) {
|
|
114
|
+
if (page === null || typeof page !== 'object') {
|
|
115
|
+
throw new AdapterContractError(
|
|
116
|
+
'fromPlaywrightPage(page) requires a Playwright Page object',
|
|
117
|
+
{ context: { received: typeof page } }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const required = ['screenshot', 'evaluate', 'setViewportSize', 'reload'];
|
|
121
|
+
const missing = required.filter(name => typeof page[name] !== 'function');
|
|
122
|
+
if (missing.length > 0) {
|
|
123
|
+
throw new AdapterContractError(
|
|
124
|
+
`The provided page object is missing required Playwright methods: ${missing.join(', ')}`,
|
|
125
|
+
{ context: { missing } }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Race a browser call and translate any throw into BrowserApiError.
|
|
132
|
+
*
|
|
133
|
+
* @template T
|
|
134
|
+
* @param {string} operation
|
|
135
|
+
* @param {() => Promise<T>} fn
|
|
136
|
+
* @param {string|undefined} label
|
|
137
|
+
* @returns {Promise<T>}
|
|
138
|
+
*/
|
|
139
|
+
async function wrapBrowserCall(operation, fn, label) {
|
|
140
|
+
try {
|
|
141
|
+
return await fn();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw new BrowserApiError(`Playwright call failed: ${operation}`, {
|
|
144
|
+
cause: error,
|
|
145
|
+
context: { operation, label },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { fromPlaywrightPage };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AdapterContractError, BrowserApiError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wrap a Puppeteer `Page` object in the package's `PageAdapter` interface.
|
|
7
|
+
*
|
|
8
|
+
* The adapter never imports `puppeteer` itself — the caller passes in the
|
|
9
|
+
* `page` they already created. This keeps `puppeteer` an optional peer
|
|
10
|
+
* dependency at the package level (per `spec.md` § 10) and means the same
|
|
11
|
+
* adapter works against any Puppeteer 23.x+ release without re-pinning.
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
* @param {object} page A Puppeteer Page instance (or compatible shim).
|
|
15
|
+
* @param {object} [options]
|
|
16
|
+
* @param {string} [options.label] Identifier for log lines (e.g. context id).
|
|
17
|
+
* @param {number} [options.screenshotMaxHeight] Override Chromium's 16384 limit.
|
|
18
|
+
* @returns {import('../types/index.d.ts').PageAdapter}
|
|
19
|
+
*/
|
|
20
|
+
function fromPuppeteerPage(page, options = {}) {
|
|
21
|
+
assertPuppeteerPage(page);
|
|
22
|
+
|
|
23
|
+
const label = options.label;
|
|
24
|
+
const screenshotMaxHeight = Number.isFinite(options.screenshotMaxHeight)
|
|
25
|
+
? options.screenshotMaxHeight
|
|
26
|
+
: 16_384;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
async screenshot(opts) {
|
|
30
|
+
return wrapBrowserCall('page.screenshot', () => page.screenshot(opts), label);
|
|
31
|
+
},
|
|
32
|
+
async evaluate(fn, ...args) {
|
|
33
|
+
return wrapBrowserCall('page.evaluate', () => page.evaluate(fn, ...args), label);
|
|
34
|
+
},
|
|
35
|
+
async scrollTo(point) {
|
|
36
|
+
await wrapBrowserCall(
|
|
37
|
+
'page.evaluate(scrollTo)',
|
|
38
|
+
// Runs in the browser context; `globalThis` === window.
|
|
39
|
+
() => page.evaluate(({ x, y }) => globalThis.scrollTo(x, y), point),
|
|
40
|
+
label
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
async getMetrics() {
|
|
44
|
+
const metrics = await wrapBrowserCall(
|
|
45
|
+
'page.evaluate(getMetrics)',
|
|
46
|
+
() =>
|
|
47
|
+
// Runs in the browser context; `globalThis` === window.
|
|
48
|
+
page.evaluate(() => {
|
|
49
|
+
const doc = globalThis.document;
|
|
50
|
+
return {
|
|
51
|
+
width: doc.documentElement.scrollWidth,
|
|
52
|
+
height: doc.documentElement.scrollHeight,
|
|
53
|
+
viewport: {
|
|
54
|
+
width: globalThis.innerWidth,
|
|
55
|
+
height: globalThis.innerHeight,
|
|
56
|
+
},
|
|
57
|
+
devicePixelRatio: globalThis.devicePixelRatio,
|
|
58
|
+
url: globalThis.location.href,
|
|
59
|
+
};
|
|
60
|
+
}),
|
|
61
|
+
label
|
|
62
|
+
);
|
|
63
|
+
return { ...metrics, capturedAt: new Date().toISOString() };
|
|
64
|
+
},
|
|
65
|
+
async setViewport(viewport) {
|
|
66
|
+
await wrapBrowserCall('page.setViewport', () => page.setViewport(viewport), label);
|
|
67
|
+
},
|
|
68
|
+
async reload(opts) {
|
|
69
|
+
await wrapBrowserCall('page.reload', () => page.reload(opts), label);
|
|
70
|
+
},
|
|
71
|
+
get capabilities() {
|
|
72
|
+
return {
|
|
73
|
+
screenshotMaxHeight,
|
|
74
|
+
supportsViewportEmulation: true,
|
|
75
|
+
supportsCDP: true,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
get label() {
|
|
79
|
+
return label;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validates that the supplied object has the methods we require.
|
|
86
|
+
* Throws `AdapterContractError` (not a generic TypeError) so consumers can
|
|
87
|
+
* catch by error class without sniffing message strings.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} page
|
|
90
|
+
*/
|
|
91
|
+
function assertPuppeteerPage(page) {
|
|
92
|
+
if (page === null || typeof page !== 'object') {
|
|
93
|
+
throw new AdapterContractError('fromPuppeteerPage(page) requires a Puppeteer Page object', {
|
|
94
|
+
context: { received: typeof page },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const required = ['screenshot', 'evaluate', 'setViewport', 'reload'];
|
|
98
|
+
const missing = required.filter(name => typeof page[name] !== 'function');
|
|
99
|
+
if (missing.length > 0) {
|
|
100
|
+
throw new AdapterContractError(
|
|
101
|
+
`The provided page object is missing required Puppeteer methods: ${missing.join(', ')}`,
|
|
102
|
+
{ context: { missing } }
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Catches errors thrown from the underlying browser library and re-wraps them
|
|
109
|
+
* as `BrowserApiError` so consumers can use a single `instanceof` check.
|
|
110
|
+
*
|
|
111
|
+
* @template T
|
|
112
|
+
* @param {string} operation Logical name (for context.operation).
|
|
113
|
+
* @param {() => Promise<T>} fn Browser-bound call.
|
|
114
|
+
* @param {string|undefined} label Adapter label for context.
|
|
115
|
+
* @returns {Promise<T>}
|
|
116
|
+
*/
|
|
117
|
+
async function wrapBrowserCall(operation, fn, label) {
|
|
118
|
+
try {
|
|
119
|
+
return await fn();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
throw new BrowserApiError(`Puppeteer call failed: ${operation}`, {
|
|
122
|
+
cause: error,
|
|
123
|
+
context: { operation, label },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { fromPuppeteerPage };
|