@driftengine/core 3.61.1 → 3.61.2
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.json +11 -0
- package/package.json +4 -3
- package/scripts/browser.d.mts +17 -0
- package/scripts/browser.mjs +353 -0
- package/scripts/cdp.d.mts +28 -0
- package/scripts/cdp.mjs +320 -0
- package/scripts/frames.mjs +163 -0
- package/scripts/heldClock.mjs +98 -0
- package/scripts/mesh-bench.ts +71 -0
- package/scripts/png.d.mts +17 -0
- package/scripts/png.mjs +201 -0
- package/scripts/releases.mjs +109 -0
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "3.61.2",
|
|
4
|
+
"date": "2026-09-13",
|
|
5
|
+
"entries": [
|
|
6
|
+
{
|
|
7
|
+
"kind": "fixed",
|
|
8
|
+
"short": "core ships its scripts, and a gate now asserts every tracked file reaches a consumer.",
|
|
9
|
+
"text": "**`packages/core/scripts/` had never been published**, though `AGENTS.md` documents importing `@driftengine/core/scripts/browser.mjs` and a consumer does exactly that in eight files. It ships now, along with the `scripts/` of `package`, `physics` and `script`.\\n\\n**The gate matters more than the fix.** `packages.test.mjs` now asserts that every file a package tracks reaches its tarball, excluding tests, snapshots and build configs. None of this was visible from inside the workspace: a path dependency resolves through a symlink into the tree, where every file exists whatever `files` says, so the defect could only appear once somebody installed from the registry. Three packages shipped broken in 3.61.0 for exactly that reason."
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
},
|
|
2
13
|
{
|
|
3
14
|
"version": "3.61.1",
|
|
4
15
|
"date": "2026-09-13",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@driftengine/core",
|
|
3
|
-
"version": "3.61.
|
|
3
|
+
"version": "3.61.2",
|
|
4
4
|
"description": "DriftEngine reusable browser 3D runtime",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
22
|
"!src/**/*.test.mjs",
|
|
23
23
|
"!src/**/__snapshots__",
|
|
24
|
+
"scripts",
|
|
24
25
|
"CHANGELOG.json",
|
|
25
26
|
"README.md",
|
|
26
27
|
"LICENSE",
|
|
@@ -28,8 +29,8 @@
|
|
|
28
29
|
],
|
|
29
30
|
"sideEffects": false,
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"@driftengine/drft": "3.61.
|
|
32
|
-
"@driftengine/physics": "3.61.
|
|
32
|
+
"@driftengine/drft": "3.61.2",
|
|
33
|
+
"@driftengine/physics": "3.61.2",
|
|
33
34
|
"gl-matrix": "^3.4.3"
|
|
34
35
|
},
|
|
35
36
|
"author": "Drift Technologies",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CdpClient } from './cdp.d.mts';
|
|
2
|
+
|
|
3
|
+
/** A running browser's debugging port, and how to stop it. */
|
|
4
|
+
export interface LaunchedBrowser {
|
|
5
|
+
readonly port: number;
|
|
6
|
+
readonly binary: string;
|
|
7
|
+
close(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function chromePath(): string;
|
|
11
|
+
export function launch(options?: {
|
|
12
|
+
flags?: readonly string[];
|
|
13
|
+
headless?: boolean;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}): Promise<LaunchedBrowser>;
|
|
16
|
+
export function rendererName(client: CdpClient): Promise<string>;
|
|
17
|
+
export function requireHardwareGpu(client: CdpClient): Promise<string>;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A browser that is really using the GPU, or an error saying it is not.
|
|
3
|
+
*
|
|
4
|
+
* **This module exists because the default is wrong and the default is silent.** Plain
|
|
5
|
+
* `--headless=new` on this machine selects SwiftShader, measured: the renderer string comes back
|
|
6
|
+
* `ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)` and every
|
|
7
|
+
* frame it produces is a lie about what a player sees. It also saturates and overheats the CPU,
|
|
8
|
+
* which is why `AGENTS.md` forbids it outright. Nothing warns. The pictures look plausible.
|
|
9
|
+
*
|
|
10
|
+
* So the flags below are not tuning, and neither is the check after them. A visual comparison
|
|
11
|
+
* taken on a software renderer is worse than no comparison, because it reads exactly like
|
|
12
|
+
* evidence.
|
|
13
|
+
*
|
|
14
|
+
* **This module also cannot be allowed to leak a browser, and it takes three mechanisms to keep
|
|
15
|
+
* that promise, not one.** Measured on this machine: 132 live headless Chrome processes across 12
|
|
16
|
+
* orphaned profiles, plus 59 stale directories, after a few hours of sessions that each thought
|
|
17
|
+
* their `finally` block would cover them. It didn't, for three separate reasons, and each needs
|
|
18
|
+
* its own fix. First, `spawn` was not detached: Chrome is a tree, not a process — it forks a
|
|
19
|
+
* zygote, a GPU process and one renderer per page — and killing the top of the tree alone leaves
|
|
20
|
+
* the rest running with the port closed and the profile gone, which is exactly the state those
|
|
21
|
+
* 132 were found in. Second, `close()` only runs when the process gets far enough to call it, and
|
|
22
|
+
* SIGINT, a shell timeout, an uncaught exception outside the `try`, and SIGKILL all skip it; a
|
|
23
|
+
* handler can catch the first three, but nothing can run during the fourth. Third, and this is the
|
|
24
|
+
* one that actually bounds the count, nothing was reaping a previous run's corpse, so every leak
|
|
25
|
+
* was permanent and the total only ever grew. A sweep at the start of every launch is the only
|
|
26
|
+
* mechanism that can cover the SIGKILL case, because it runs *after* the crash instead of during
|
|
27
|
+
* it, and it is what turns "leaks accumulate forever" into "at most one leak per still-running
|
|
28
|
+
* process."
|
|
29
|
+
*/
|
|
30
|
+
import { spawn } from 'node:child_process';
|
|
31
|
+
import { once } from 'node:events';
|
|
32
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { tmpdir } from 'node:os';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { sleep } from './cdp.mjs';
|
|
36
|
+
|
|
37
|
+
/** Where a Chrome or Chromium lives, in the order worth trying. `CHROME_PATH` wins. */
|
|
38
|
+
const CANDIDATES = [
|
|
39
|
+
'/opt/google/chrome/chrome',
|
|
40
|
+
'/usr/bin/google-chrome',
|
|
41
|
+
'/usr/bin/google-chrome-stable',
|
|
42
|
+
'/usr/bin/chromium',
|
|
43
|
+
'/usr/bin/chromium-browser',
|
|
44
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What it takes to reach the real card from a headless process.
|
|
49
|
+
*
|
|
50
|
+
* `--use-angle=vulkan` with the feature flag beside it is what moved this machine from
|
|
51
|
+
* SwiftShader to an RX 9070 XT through radv. A caller on other hardware overrides `flags`
|
|
52
|
+
* entirely; the guard below is what tells them whether they need to.
|
|
53
|
+
*
|
|
54
|
+
* **WebGPU needs no third flag, and it does need a secure context.** `navigator.gpu` is present
|
|
55
|
+
* on `http://localhost` with the two below and absent on `about:blank`, which is not one — so a
|
|
56
|
+
* probe that opens a blank page to ask what the adapter is gets told there is none on a machine
|
|
57
|
+
* that has one, and `--enable-unsafe-webgpu` alongside changes neither answer. Both measured, in
|
|
58
|
+
* both directions, because the first reading of that absence was that the card had no adapter.
|
|
59
|
+
* What it reports here is `vendor amd`, `architecture rdna-4`.
|
|
60
|
+
*/
|
|
61
|
+
const GPU_FLAGS = ['--use-angle=vulkan', '--enable-features=Vulkan'];
|
|
62
|
+
|
|
63
|
+
const BASE_FLAGS = [
|
|
64
|
+
'--headless=new',
|
|
65
|
+
'--no-first-run',
|
|
66
|
+
'--no-default-browser-check',
|
|
67
|
+
'--password-store=basic',
|
|
68
|
+
'--hide-scrollbars',
|
|
69
|
+
/* The port is asked for as 0 and read back out of the profile, so two runs at once cannot
|
|
70
|
+
collide on a number somebody picked. */
|
|
71
|
+
'--remote-debugging-port=0',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** Names a software rasteriser goes by. Any of them means the measurement is worthless. */
|
|
75
|
+
const SOFTWARE = /swiftshader|llvmpipe|softpipe|software|microsoft basic/i;
|
|
76
|
+
|
|
77
|
+
/** Every throwaway profile this module makes, so a sweep can recognise its own litter. */
|
|
78
|
+
const PROFILE_PREFIX = 'driftengine-shots-';
|
|
79
|
+
/** Written into each profile so a later run can tell whether anybody still owns it. */
|
|
80
|
+
const OWNER_FILE = 'driftengine-owner.json';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Signal an entire process group, escalating if it does not go.
|
|
84
|
+
*
|
|
85
|
+
* **Chrome is a tree, not a process.** It forks a zygote, a GPU process and one renderer per
|
|
86
|
+
* page, and signalling the parent alone leaves the rest running with the port closed and the
|
|
87
|
+
* profile deleted, which is the state 132 of them were found in. `launch` puts the browser in
|
|
88
|
+
* its own group with `detached`, so the negative PID below reaches every descendant in one call.
|
|
89
|
+
*
|
|
90
|
+
* SIGTERM first because a browser asked politely flushes and exits; SIGKILL after a grace period
|
|
91
|
+
* because one that is wedged never will, and a wedged browser is exactly the case this exists for.
|
|
92
|
+
*/
|
|
93
|
+
function killTree(pid, graceMs = 2000) {
|
|
94
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
95
|
+
try {
|
|
96
|
+
process.kill(-pid, 'SIGTERM');
|
|
97
|
+
} catch {
|
|
98
|
+
/* Already gone, or never in its own group. Either way there is nothing to signal. */
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const deadline = Date.now() + graceMs;
|
|
102
|
+
const timer = setTimeout(
|
|
103
|
+
() => {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(-pid, 'SIGKILL');
|
|
106
|
+
} catch {
|
|
107
|
+
/* it went during the grace period, which is the outcome we wanted */
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
Math.max(0, deadline - Date.now()),
|
|
111
|
+
);
|
|
112
|
+
/* Do not hold the event loop open on a browser that has already been told to go. */
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* How long `close()` waits for Chrome to actually exit before touching its directory.
|
|
118
|
+
*
|
|
119
|
+
* Measured on this machine: `killTree` followed immediately by `rmSync`, with no wait between
|
|
120
|
+
* them, raced Chrome's own multi-process shutdown and lost on nearly every ordinary run, leaving
|
|
121
|
+
* a directory behind after almost every `close()` call rather than only the SIGKILL case this
|
|
122
|
+
* module exists for. `killTree`'s own escalation to SIGKILL fires at the 2s mark, so this only
|
|
123
|
+
* needs to clear that by a margin wide enough to let a well-behaved shutdown actually finish
|
|
124
|
+
* writing and release its files; a browser that is still standing after that is wedged rather
|
|
125
|
+
* than slow, and gets left for the next launch's sweep, same as any other case this can't cover.
|
|
126
|
+
*/
|
|
127
|
+
const CLOSE_WAIT_MS = 3500;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Remove every throwaway profile whose owning Node process is gone, killing whatever still runs
|
|
131
|
+
* in it.
|
|
132
|
+
*
|
|
133
|
+
* **This is the part that makes the guarantee absolute, and the reason is worth stating.** Exit
|
|
134
|
+
* handlers cover a crash; they cannot cover `SIGKILL`, a power cut, or an OOM kill, because no
|
|
135
|
+
* code of ours runs in those. Something has to clean up *afterwards*, and the only moment that is
|
|
136
|
+
* guaranteed to come is the next launch. So a leak can survive its own run and cannot survive the
|
|
137
|
+
* next one, which is what stops the count from growing.
|
|
138
|
+
*
|
|
139
|
+
* Every capability is a parameter because the alternative is a test that spawns browsers to check
|
|
140
|
+
* that browsers get cleaned up.
|
|
141
|
+
*/
|
|
142
|
+
export function reapOrphanProfiles({
|
|
143
|
+
root = tmpdir(),
|
|
144
|
+
listProfiles = () => defaultListProfiles(root),
|
|
145
|
+
readOwner = defaultReadOwner,
|
|
146
|
+
isAlive = defaultIsAlive,
|
|
147
|
+
killTree: kill = killTree,
|
|
148
|
+
remove = (dir) => rmSync(dir, { recursive: true, force: true }),
|
|
149
|
+
} = {}) {
|
|
150
|
+
let reaped = 0;
|
|
151
|
+
for (const dir of listProfiles()) {
|
|
152
|
+
try {
|
|
153
|
+
const owner = readOwner(dir);
|
|
154
|
+
/* A live owner is a run in progress, possibly in another terminal. Leave it alone. */
|
|
155
|
+
if (owner !== null && isAlive(owner.ownerPid)) continue;
|
|
156
|
+
if (owner !== null) kill(owner.chromePid);
|
|
157
|
+
remove(dir);
|
|
158
|
+
reaped++;
|
|
159
|
+
} catch {
|
|
160
|
+
/* One unreadable or busy directory must not abandon the rest: a sweep that stops at the
|
|
161
|
+
first problem is a sweep that stops working the first time it is needed. */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return reaped;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function defaultListProfiles(root) {
|
|
168
|
+
return readdirSync(root)
|
|
169
|
+
.filter((name) => name.startsWith(PROFILE_PREFIX))
|
|
170
|
+
.map((name) => path.join(root, name));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function defaultReadOwner(dir) {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(readFileSync(path.join(dir, OWNER_FILE), 'utf8'));
|
|
176
|
+
return typeof parsed?.ownerPid === 'number' && typeof parsed?.chromePid === 'number'
|
|
177
|
+
? parsed
|
|
178
|
+
: null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Signal 0 asks the kernel whether a PID exists without touching it. */
|
|
185
|
+
function defaultIsAlive(pid) {
|
|
186
|
+
try {
|
|
187
|
+
process.kill(pid, 0);
|
|
188
|
+
return true;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function chromePath() {
|
|
195
|
+
const asked = process.env['CHROME_PATH'];
|
|
196
|
+
if (asked !== undefined && asked !== '') return asked;
|
|
197
|
+
const found = CANDIDATES.find((candidate) => existsSync(candidate));
|
|
198
|
+
if (found === undefined) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`no Chrome or Chromium found. Tried:\n ${CANDIDATES.join('\n ')}\nSet CHROME_PATH to one.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return found;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Start a browser and wait for its debugging port.
|
|
208
|
+
*
|
|
209
|
+
* Returns `{ port, close }`. `close` kills the process and removes the throwaway profile, and a
|
|
210
|
+
* caller that forgets leaves a browser running for the length of the session, which is the other
|
|
211
|
+
* half of the rule about stopping temporary sessions after a check.
|
|
212
|
+
*/
|
|
213
|
+
export async function launch({ flags = GPU_FLAGS, headless = true, timeoutMs = 20_000 } = {}) {
|
|
214
|
+
/* A launch that begins by clearing the last run's corpse is what bounds the leaked count at
|
|
215
|
+
one per still-running process instead of letting it grow across every session that forgot. */
|
|
216
|
+
reapOrphanProfiles();
|
|
217
|
+
|
|
218
|
+
const binary = chromePath();
|
|
219
|
+
const profile = mkdtempSync(path.join(tmpdir(), PROFILE_PREFIX));
|
|
220
|
+
const args = [
|
|
221
|
+
...BASE_FLAGS.filter((flag) => headless || flag !== '--headless=new'),
|
|
222
|
+
`--user-data-dir=${profile}`,
|
|
223
|
+
...flags,
|
|
224
|
+
'about:blank',
|
|
225
|
+
];
|
|
226
|
+
/* `detached` puts Chrome in its own process group so `killTree` can reach the zygote, the GPU
|
|
227
|
+
process and every renderer with one signal to the negative PID. `unref()` is deliberately
|
|
228
|
+
not called: the parent must stay attached, or its own exit and signal handlers below never
|
|
229
|
+
fire, and those are half of what closes the other three holes. */
|
|
230
|
+
const child = spawn(binary, args, { stdio: 'ignore', detached: true });
|
|
231
|
+
writeFileSync(
|
|
232
|
+
path.join(profile, OWNER_FILE),
|
|
233
|
+
JSON.stringify({ ownerPid: process.pid, chromePid: child.pid }),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
/*
|
|
237
|
+
* The paths `close()` never gets to run on. `exit` must stay synchronous and best-effort by
|
|
238
|
+
* necessity, not by choice: a `process.once('exit', ...)` handler cannot await anything, full
|
|
239
|
+
* stop, so there is no version of this that waits for Chrome to actually leave before touching
|
|
240
|
+
* its directory. Do not "fix" the race here by making this async — it will not run. The next
|
|
241
|
+
* launch's sweep is the backstop for exactly this case, the same as it is for a SIGKILL this
|
|
242
|
+
* process never even got a handler for. The signal handlers re-raise after cleaning up so the
|
|
243
|
+
* exit code still means what it meant, which matters to a CI character reading it, and they go
|
|
244
|
+
* through this same synchronous path for the same reason: by the time one fires, the process is
|
|
245
|
+
* on its way out either way.
|
|
246
|
+
*/
|
|
247
|
+
let cleaned = false;
|
|
248
|
+
const cleanup = () => {
|
|
249
|
+
if (cleaned) return;
|
|
250
|
+
cleaned = true;
|
|
251
|
+
killTree(child.pid);
|
|
252
|
+
try {
|
|
253
|
+
rmSync(profile, { recursive: true, force: true });
|
|
254
|
+
} catch {
|
|
255
|
+
/* the browser may still hold a handle; the next launch's sweep will collect it */
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
const onSignal = (signal) => {
|
|
259
|
+
cleanup();
|
|
260
|
+
process.removeListener(signal, onSignal);
|
|
261
|
+
process.kill(process.pid, signal);
|
|
262
|
+
};
|
|
263
|
+
process.once('exit', cleanup);
|
|
264
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, onSignal);
|
|
265
|
+
|
|
266
|
+
const portFile = path.join(profile, 'DevToolsActivePort');
|
|
267
|
+
const started = Date.now();
|
|
268
|
+
let port = 0;
|
|
269
|
+
while (port === 0) {
|
|
270
|
+
if (existsSync(portFile)) {
|
|
271
|
+
const first = readFileSync(portFile, 'utf8').split('\n')[0];
|
|
272
|
+
if (first !== undefined && first !== '') port = Number(first);
|
|
273
|
+
}
|
|
274
|
+
if (port === 0) {
|
|
275
|
+
if (Date.now() - started > timeoutMs) {
|
|
276
|
+
/* The fourth hole: this used to be `child.kill()`, which killed one process and left the
|
|
277
|
+
profile on disk. `cleanup()` is the same path every other exit takes. */
|
|
278
|
+
cleanup();
|
|
279
|
+
throw new Error(`${binary} did not open a debugging port within ${timeoutMs} ms`);
|
|
280
|
+
}
|
|
281
|
+
await sleep(150);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
port,
|
|
287
|
+
binary,
|
|
288
|
+
async close() {
|
|
289
|
+
if (!cleaned) {
|
|
290
|
+
cleaned = true;
|
|
291
|
+
killTree(child.pid);
|
|
292
|
+
/*
|
|
293
|
+
* Unlike `cleanup()` above, this is already inside an async function, so it is not stuck
|
|
294
|
+
* removing the directory in the same tick as the signal. Waiting for the real exit event
|
|
295
|
+
* (or the grace period, whichever comes first) is what turns "usually leaves a directory
|
|
296
|
+
* behind" into "does not," on the one path that runs every single time.
|
|
297
|
+
*/
|
|
298
|
+
await Promise.race([once(child, 'exit'), sleep(CLOSE_WAIT_MS)]);
|
|
299
|
+
try {
|
|
300
|
+
rmSync(profile, { recursive: true, force: true });
|
|
301
|
+
} catch {
|
|
302
|
+
/* still busy after waiting: wedged rather than slow. The next launch's sweep collects it. */
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/* Unconditional and idempotent, so a second close() call (or one that follows a signal that
|
|
306
|
+
already ran cleanup()) still detaches every listener rather than leaving the leftover two
|
|
307
|
+
behind. Without this, a long-lived process launching many browsers accumulates one
|
|
308
|
+
listener per signal per launch and eventually trips Node's max-listeners warning. */
|
|
309
|
+
process.removeListener('exit', cleanup);
|
|
310
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'])
|
|
311
|
+
process.removeListener(signal, onSignal);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Which renderer this browser actually chose, asked of a real WebGL2 context.
|
|
318
|
+
*
|
|
319
|
+
* `WEBGL_debug_renderer_info` is the only way to see past the masked string, and its absence is
|
|
320
|
+
* itself worth reporting rather than papering over: a browser that hides it cannot be checked.
|
|
321
|
+
*/
|
|
322
|
+
export async function rendererName(client) {
|
|
323
|
+
const page = await client.page('about:blank', 320, 240);
|
|
324
|
+
try {
|
|
325
|
+
return await page.eval(`(() => {
|
|
326
|
+
const gl = document.createElement('canvas').getContext('webgl2');
|
|
327
|
+
if (gl === null) return 'no webgl2';
|
|
328
|
+
const info = gl.getExtension('WEBGL_debug_renderer_info');
|
|
329
|
+
if (info === null) return 'unknown: WEBGL_debug_renderer_info absent';
|
|
330
|
+
return gl.getParameter(info.UNMASKED_RENDERER_WEBGL);
|
|
331
|
+
})()`);
|
|
332
|
+
} finally {
|
|
333
|
+
await page.close();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Refuse to go on unless a physical GPU is drawing. Returns the renderer string.
|
|
339
|
+
*
|
|
340
|
+
* Called by `shots.mjs` before anything is captured. A consumer driving its own captures should
|
|
341
|
+
* call it too, once per browser, for the same reason.
|
|
342
|
+
*/
|
|
343
|
+
export async function requireHardwareGpu(client) {
|
|
344
|
+
const name = await rendererName(client);
|
|
345
|
+
if (SOFTWARE.test(name) || name.startsWith('no webgl2')) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`this browser is drawing with ${name}, which cannot be measured or looked at.\n` +
|
|
348
|
+
`Try launch({ flags: ['--use-angle=vulkan', '--enable-features=Vulkan'] }), or a ` +
|
|
349
|
+
`different backend for this platform, or run with headless: false.`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return name;
|
|
353
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** A tab opened through `client.page`, sized and ready to be driven. */
|
|
2
|
+
export interface CdpPage {
|
|
3
|
+
call(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
4
|
+
readonly logs: string[];
|
|
5
|
+
/** `Runtime.evaluate` with `awaitPromise` and `returnByValue`; throws on a page exception. */
|
|
6
|
+
eval(expression: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
7
|
+
settled(expression?: string, options?: { timeoutMs?: number; settleMs?: number }): Promise<void>;
|
|
8
|
+
frames(count?: number): Promise<void>;
|
|
9
|
+
complaints(): string[];
|
|
10
|
+
screenshot(path: string): Promise<string>;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A session against a browser already listening on its debugging port. */
|
|
15
|
+
export interface CdpClient {
|
|
16
|
+
send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<unknown>;
|
|
17
|
+
on(listener: (message: unknown) => void): void;
|
|
18
|
+
page(
|
|
19
|
+
url: string,
|
|
20
|
+
width?: number,
|
|
21
|
+
height?: number,
|
|
22
|
+
options?: { beforeLoad?: string },
|
|
23
|
+
): Promise<CdpPage>;
|
|
24
|
+
close(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sleep(ms: number): Promise<void>;
|
|
28
|
+
export function connect(port: number, options?: { timeoutMs?: number }): Promise<CdpClient>;
|