@gakr-gakr/diffs 0.1.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/src/browser.ts ADDED
@@ -0,0 +1,564 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { formatErrorMessage } from "autobot/plugin-sdk/error-runtime";
5
+ import { writeExternalFileWithinRoot } from "autobot/plugin-sdk/security-runtime";
6
+ import { chromium } from "playwright-core";
7
+ import type { AutoBotConfig } from "../api.js";
8
+ import type { DiffRenderOptions, DiffTheme } from "./types.js";
9
+ import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
10
+
11
+ const DEFAULT_BROWSER_IDLE_MS = 30_000;
12
+ const SHARED_BROWSER_KEY = "__default__";
13
+ const IMAGE_SIZE_LIMIT_ERROR = "Diff frame did not render within image size limits.";
14
+ const PDF_REFERENCE_PAGE_HEIGHT_PX = 1_056;
15
+ const MAX_PDF_PAGES = 50;
16
+ const LOCAL_VIEWER_BASE_HREF = "http://127.0.0.1/plugins/diffs/view/local/local";
17
+
18
+ export type DiffScreenshotter = {
19
+ screenshotHtml(params: {
20
+ html: string;
21
+ outputPath: string;
22
+ theme: DiffTheme;
23
+ image: DiffRenderOptions["image"];
24
+ }): Promise<string>;
25
+ };
26
+
27
+ type BrowserInstance = Awaited<ReturnType<typeof chromium.launch>>;
28
+
29
+ type BrowserLease = {
30
+ browser: BrowserInstance;
31
+ release(): Promise<void>;
32
+ };
33
+
34
+ type SharedBrowserState = {
35
+ browser?: BrowserInstance;
36
+ browserPromise: Promise<BrowserInstance>;
37
+ idleTimer: ReturnType<typeof setTimeout> | null;
38
+ key: string;
39
+ users: number;
40
+ };
41
+
42
+ type ExecutablePathCache = {
43
+ key: string;
44
+ valuePromise: Promise<string | undefined>;
45
+ };
46
+
47
+ let sharedBrowserState: SharedBrowserState | null = null;
48
+ let executablePathCache: ExecutablePathCache | null = null;
49
+
50
+ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
51
+ private readonly config: AutoBotConfig;
52
+ private readonly browserIdleMs: number;
53
+
54
+ constructor(params: { config: AutoBotConfig; browserIdleMs?: number }) {
55
+ this.config = params.config;
56
+ this.browserIdleMs = params.browserIdleMs ?? DEFAULT_BROWSER_IDLE_MS;
57
+ }
58
+
59
+ async screenshotHtml(params: {
60
+ html: string;
61
+ outputPath: string;
62
+ theme: DiffTheme;
63
+ image: DiffRenderOptions["image"];
64
+ }): Promise<string> {
65
+ const lease = await acquireSharedBrowser({
66
+ config: this.config,
67
+ idleMs: this.browserIdleMs,
68
+ });
69
+ let page: Awaited<ReturnType<BrowserInstance["newPage"]>> | undefined;
70
+ let currentScale = params.image.scale;
71
+ const maxRetries = 2;
72
+
73
+ try {
74
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
75
+ page = await lease.browser.newPage({
76
+ viewport: {
77
+ width: Math.max(Math.ceil(params.image.maxWidth + 240), 1200),
78
+ height: 900,
79
+ },
80
+ deviceScaleFactor: currentScale,
81
+ colorScheme: params.theme,
82
+ });
83
+ await page.route("**/*", async (route) => {
84
+ const requestUrl = route.request().url();
85
+ if (requestUrl === "about:blank" || requestUrl.startsWith("data:")) {
86
+ await route.continue();
87
+ return;
88
+ }
89
+ let parsed: URL;
90
+ try {
91
+ parsed = new URL(requestUrl);
92
+ } catch {
93
+ await route.abort();
94
+ return;
95
+ }
96
+ if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1") {
97
+ await route.abort();
98
+ return;
99
+ }
100
+ if (!parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
101
+ await route.abort();
102
+ return;
103
+ }
104
+ const pathname = parsed.pathname;
105
+ const asset = await getServedViewerAsset(pathname);
106
+ if (!asset) {
107
+ await route.abort();
108
+ return;
109
+ }
110
+ await route.fulfill({
111
+ status: 200,
112
+ contentType: asset.contentType,
113
+ body: asset.body,
114
+ });
115
+ });
116
+ await page.setContent(injectBaseHref(params.html), { waitUntil: "load" });
117
+ await page.waitForFunction(
118
+ () => {
119
+ if (document.documentElement.dataset.autobotDiffsReady === "true") {
120
+ return true;
121
+ }
122
+ return [...document.querySelectorAll("[data-autobot-diff-host]")].every((element) => {
123
+ return (
124
+ element instanceof HTMLElement && element.shadowRoot?.querySelector("[data-diffs]")
125
+ );
126
+ });
127
+ },
128
+ {
129
+ timeout: 10_000,
130
+ },
131
+ );
132
+ await page.evaluate(async () => {
133
+ await document.fonts.ready;
134
+ });
135
+ await page.evaluate(() => {
136
+ const frame = document.querySelector(".oc-frame");
137
+ if (frame instanceof HTMLElement) {
138
+ frame.dataset.renderMode = "image";
139
+ }
140
+ });
141
+
142
+ const frame = page.locator(".oc-frame");
143
+ await frame.waitFor();
144
+ const initialBox = await frame.boundingBox();
145
+ if (!initialBox) {
146
+ throw new Error("Diff frame did not render.");
147
+ }
148
+
149
+ const isPdf = params.image.format === "pdf";
150
+ const padding = isPdf ? 0 : 20;
151
+ const clipWidth = Math.ceil(initialBox.width + padding * 2);
152
+ const clipHeight = Math.ceil(Math.max(initialBox.height + padding * 2, 320));
153
+ await page.setViewportSize({
154
+ width: Math.max(clipWidth + padding, 900),
155
+ height: Math.max(clipHeight + padding, 700),
156
+ });
157
+
158
+ const box = await frame.boundingBox();
159
+ if (!box) {
160
+ throw new Error("Diff frame was lost after resizing.");
161
+ }
162
+
163
+ if (isPdf) {
164
+ await page.emulateMedia({ media: "screen" });
165
+ await page.evaluate(() => {
166
+ const html = document.documentElement;
167
+ const body = document.body;
168
+ const frame = document.querySelector(".oc-frame");
169
+
170
+ html.style.background = "transparent";
171
+ body.style.margin = "0";
172
+ body.style.padding = "0";
173
+ body.style.background = "transparent";
174
+ body.style.setProperty("-webkit-print-color-adjust", "exact");
175
+ if (frame instanceof HTMLElement) {
176
+ frame.style.margin = "0";
177
+ }
178
+ });
179
+
180
+ const pdfBox = await frame.boundingBox();
181
+ if (!pdfBox) {
182
+ throw new Error("Diff frame was lost before PDF render.");
183
+ }
184
+ const pdfWidth = Math.max(Math.ceil(pdfBox.width), 1);
185
+ const pdfHeight = Math.max(Math.ceil(pdfBox.height), 1);
186
+ const estimatedPixels = pdfWidth * pdfHeight;
187
+ const estimatedPages = Math.ceil(pdfHeight / PDF_REFERENCE_PAGE_HEIGHT_PX);
188
+ if (estimatedPixels > params.image.maxPixels || estimatedPages > MAX_PDF_PAGES) {
189
+ throw new Error(IMAGE_SIZE_LIMIT_ERROR);
190
+ }
191
+
192
+ const pageForPdf = page;
193
+ await writeExternalArtifactFile({
194
+ outputPath: params.outputPath,
195
+ write: async (tempPath) => {
196
+ await pageForPdf.pdf({
197
+ path: tempPath,
198
+ width: `${pdfWidth}px`,
199
+ height: `${pdfHeight}px`,
200
+ printBackground: true,
201
+ margin: {
202
+ top: "0",
203
+ right: "0",
204
+ bottom: "0",
205
+ left: "0",
206
+ },
207
+ });
208
+ },
209
+ });
210
+ return params.outputPath;
211
+ }
212
+
213
+ const dpr = await page.evaluate(() => window.devicePixelRatio || 1);
214
+
215
+ // Raw clip in CSS px
216
+ const rawX = Math.max(box.x - padding, 0);
217
+ const rawY = Math.max(box.y - padding, 0);
218
+ const rawRight = rawX + clipWidth;
219
+ const rawBottom = rawY + clipHeight;
220
+
221
+ // Snap to device-pixel grid to avoid soft text from sub-pixel crop
222
+ const x = Math.floor(rawX * dpr) / dpr;
223
+ const y = Math.floor(rawY * dpr) / dpr;
224
+ const right = Math.ceil(rawRight * dpr) / dpr;
225
+ const bottom = Math.ceil(rawBottom * dpr) / dpr;
226
+ const cssWidth = Math.max(right - x, 1);
227
+ const cssHeight = Math.max(bottom - y, 1);
228
+ const estimatedPixels = cssWidth * cssHeight * dpr * dpr;
229
+
230
+ if (estimatedPixels > params.image.maxPixels) {
231
+ if (currentScale > 1) {
232
+ const maxScaleForPixels = Math.sqrt(params.image.maxPixels / (cssWidth * cssHeight));
233
+ const reducedScale = Math.max(
234
+ 1,
235
+ Math.round(Math.min(currentScale, maxScaleForPixels) * 100) / 100,
236
+ );
237
+ if (reducedScale < currentScale - 0.01 && attempt < maxRetries) {
238
+ await page.close().catch(() => {});
239
+ page = undefined;
240
+ currentScale = reducedScale;
241
+ continue;
242
+ }
243
+ }
244
+ throw new Error(IMAGE_SIZE_LIMIT_ERROR);
245
+ }
246
+
247
+ const pageForScreenshot = page;
248
+ await writeExternalArtifactFile({
249
+ outputPath: params.outputPath,
250
+ write: async (tempPath) => {
251
+ await pageForScreenshot.screenshot({
252
+ path: tempPath,
253
+ type: "png",
254
+ scale: "device",
255
+ clip: {
256
+ x,
257
+ y,
258
+ width: cssWidth,
259
+ height: cssHeight,
260
+ },
261
+ });
262
+ },
263
+ });
264
+ return params.outputPath;
265
+ }
266
+ throw new Error(IMAGE_SIZE_LIMIT_ERROR);
267
+ } catch (error) {
268
+ if (error instanceof Error && error.message === IMAGE_SIZE_LIMIT_ERROR) {
269
+ throw error;
270
+ }
271
+ const reason = formatErrorMessage(error);
272
+ throw new Error(
273
+ `Diff PNG/PDF rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
274
+ { cause: error },
275
+ );
276
+ } finally {
277
+ await page?.close().catch(() => {});
278
+ await lease.release();
279
+ }
280
+ }
281
+ }
282
+
283
+ async function writeExternalArtifactFile(params: {
284
+ outputPath: string;
285
+ write: (tempPath: string) => Promise<void>;
286
+ }): Promise<void> {
287
+ const rootDir = path.dirname(params.outputPath);
288
+ await fs.mkdir(rootDir, { recursive: true });
289
+ await writeExternalFileWithinRoot({
290
+ rootDir,
291
+ path: path.basename(params.outputPath),
292
+ write: params.write,
293
+ });
294
+ }
295
+
296
+ export async function resetSharedBrowserStateForTests(): Promise<void> {
297
+ executablePathCache = null;
298
+ await closeSharedBrowser();
299
+ }
300
+
301
+ function injectBaseHref(html: string): string {
302
+ if (html.includes("<base ")) {
303
+ return html;
304
+ }
305
+ return html.replace("<head>", `<head><base href="${LOCAL_VIEWER_BASE_HREF}" />`);
306
+ }
307
+
308
+ async function resolveBrowserExecutablePath(config: AutoBotConfig): Promise<string | undefined> {
309
+ const cacheKey = JSON.stringify({
310
+ configPath: config.browser?.executablePath?.trim() || "",
311
+ env: [
312
+ process.env.AUTOBOT_BROWSER_EXECUTABLE_PATH ?? "",
313
+ process.env.BROWSER_EXECUTABLE_PATH ?? "",
314
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ?? "",
315
+ ],
316
+ path: process.env.PATH ?? "",
317
+ });
318
+
319
+ if (executablePathCache?.key === cacheKey) {
320
+ return await executablePathCache.valuePromise;
321
+ }
322
+
323
+ const valuePromise = resolveBrowserExecutablePathUncached(config).catch((error) => {
324
+ if (executablePathCache?.valuePromise === valuePromise) {
325
+ executablePathCache = null;
326
+ }
327
+ throw error;
328
+ });
329
+ executablePathCache = {
330
+ key: cacheKey,
331
+ valuePromise,
332
+ };
333
+ return await valuePromise;
334
+ }
335
+
336
+ async function resolveBrowserExecutablePathUncached(
337
+ config: AutoBotConfig,
338
+ ): Promise<string | undefined> {
339
+ const configPath = config.browser?.executablePath?.trim();
340
+ if (configPath) {
341
+ await assertExecutable(configPath, "browser.executablePath");
342
+ return configPath;
343
+ }
344
+
345
+ const envCandidates = [
346
+ process.env.AUTOBOT_BROWSER_EXECUTABLE_PATH,
347
+ process.env.BROWSER_EXECUTABLE_PATH,
348
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
349
+ ]
350
+ .map((value) => value?.trim())
351
+ .filter((value): value is string => Boolean(value));
352
+
353
+ for (const candidate of envCandidates) {
354
+ if (await isExecutable(candidate)) {
355
+ return candidate;
356
+ }
357
+ }
358
+
359
+ for (const candidate of await collectExecutableCandidates()) {
360
+ if (await isExecutable(candidate)) {
361
+ return candidate;
362
+ }
363
+ }
364
+
365
+ return undefined;
366
+ }
367
+
368
+ async function acquireSharedBrowser(params: {
369
+ config: AutoBotConfig;
370
+ idleMs: number;
371
+ }): Promise<BrowserLease> {
372
+ const executablePath = await resolveBrowserExecutablePath(params.config);
373
+ const desiredKey = executablePath || SHARED_BROWSER_KEY;
374
+ if (sharedBrowserState && sharedBrowserState.key !== desiredKey) {
375
+ await closeSharedBrowser();
376
+ }
377
+
378
+ if (!sharedBrowserState) {
379
+ const browserPromise = chromium
380
+ .launch({
381
+ headless: true,
382
+ ...(executablePath ? { executablePath } : {}),
383
+ args: ["--disable-dev-shm-usage"],
384
+ })
385
+ .then((browser) => {
386
+ if (sharedBrowserState?.browserPromise === browserPromise) {
387
+ sharedBrowserState.browser = browser;
388
+ browser.on("disconnected", () => {
389
+ if (sharedBrowserState?.browser === browser) {
390
+ clearIdleTimer(sharedBrowserState);
391
+ sharedBrowserState = null;
392
+ }
393
+ });
394
+ }
395
+ return browser;
396
+ })
397
+ .catch((error) => {
398
+ if (sharedBrowserState?.browserPromise === browserPromise) {
399
+ sharedBrowserState = null;
400
+ }
401
+ throw error;
402
+ });
403
+
404
+ sharedBrowserState = {
405
+ browserPromise,
406
+ idleTimer: null,
407
+ key: desiredKey,
408
+ users: 0,
409
+ };
410
+ }
411
+
412
+ clearIdleTimer(sharedBrowserState);
413
+ const state = sharedBrowserState;
414
+ const browser = await state.browserPromise;
415
+ state.users += 1;
416
+
417
+ let released = false;
418
+ return {
419
+ browser,
420
+ release: async () => {
421
+ if (released) {
422
+ return;
423
+ }
424
+ released = true;
425
+ state.users = Math.max(0, state.users - 1);
426
+ if (state.users === 0) {
427
+ scheduleIdleBrowserClose(state, params.idleMs);
428
+ }
429
+ },
430
+ };
431
+ }
432
+
433
+ function scheduleIdleBrowserClose(state: SharedBrowserState, idleMs: number): void {
434
+ clearIdleTimer(state);
435
+ state.idleTimer = setTimeout(() => {
436
+ if (sharedBrowserState === state && state.users === 0) {
437
+ void closeSharedBrowser();
438
+ }
439
+ }, idleMs);
440
+ }
441
+
442
+ function clearIdleTimer(state: SharedBrowserState): void {
443
+ if (!state.idleTimer) {
444
+ return;
445
+ }
446
+ clearTimeout(state.idleTimer);
447
+ state.idleTimer = null;
448
+ }
449
+
450
+ async function closeSharedBrowser(): Promise<void> {
451
+ const state = sharedBrowserState;
452
+ if (!state) {
453
+ return;
454
+ }
455
+ sharedBrowserState = null;
456
+ clearIdleTimer(state);
457
+ const browser = state.browser ?? (await state.browserPromise.catch(() => null));
458
+ await browser?.close().catch(() => {});
459
+ }
460
+
461
+ async function collectExecutableCandidates(): Promise<string[]> {
462
+ const candidates = new Set<string>();
463
+
464
+ for (const command of pathCommandsForPlatform()) {
465
+ const resolved = await findExecutableInPath(command);
466
+ if (resolved) {
467
+ candidates.add(resolved);
468
+ }
469
+ }
470
+
471
+ for (const candidate of commonExecutablePathsForPlatform()) {
472
+ candidates.add(candidate);
473
+ }
474
+
475
+ return [...candidates];
476
+ }
477
+
478
+ function pathCommandsForPlatform(): string[] {
479
+ if (process.platform === "win32") {
480
+ return ["chrome.exe", "msedge.exe", "brave.exe"];
481
+ }
482
+ if (process.platform === "darwin") {
483
+ return ["google-chrome", "chromium", "msedge", "brave-browser", "brave"];
484
+ }
485
+ return [
486
+ "chromium",
487
+ "chromium-browser",
488
+ "google-chrome",
489
+ "google-chrome-stable",
490
+ "msedge",
491
+ "brave-browser",
492
+ "brave",
493
+ ];
494
+ }
495
+
496
+ function commonExecutablePathsForPlatform(): string[] {
497
+ if (process.platform === "darwin") {
498
+ return [
499
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
500
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
501
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
502
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
503
+ ];
504
+ }
505
+
506
+ if (process.platform === "win32") {
507
+ const localAppData = process.env.LOCALAPPDATA ?? "";
508
+ const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
509
+ const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
510
+ return [
511
+ path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
512
+ path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
513
+ path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
514
+ path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
515
+ path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
516
+ path.join(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
517
+ path.join(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
518
+ ];
519
+ }
520
+
521
+ return [
522
+ "/usr/bin/chromium",
523
+ "/usr/bin/chromium-browser",
524
+ "/usr/bin/google-chrome",
525
+ "/usr/bin/google-chrome-stable",
526
+ "/usr/bin/msedge",
527
+ "/usr/bin/brave-browser",
528
+ "/snap/bin/chromium",
529
+ ];
530
+ }
531
+
532
+ async function findExecutableInPath(command: string): Promise<string | undefined> {
533
+ const pathValue = process.env.PATH;
534
+ if (!pathValue) {
535
+ return undefined;
536
+ }
537
+
538
+ for (const directory of pathValue.split(path.delimiter)) {
539
+ if (!directory) {
540
+ continue;
541
+ }
542
+ const candidate = path.join(directory, command);
543
+ if (await isExecutable(candidate)) {
544
+ return candidate;
545
+ }
546
+ }
547
+
548
+ return undefined;
549
+ }
550
+
551
+ async function assertExecutable(candidate: string, label: string): Promise<void> {
552
+ if (!(await isExecutable(candidate))) {
553
+ throw new Error(`${label} not found or not executable: ${candidate}`);
554
+ }
555
+ }
556
+
557
+ async function isExecutable(candidate: string): Promise<boolean> {
558
+ try {
559
+ await fs.access(candidate, fsConstants.X_OK);
560
+ return true;
561
+ } catch {
562
+ return false;
563
+ }
564
+ }