@openclaw/diffs 2026.5.2 → 2026.5.3-beta.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.
@@ -1,659 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import type { IncomingMessage, ServerResponse } from "node:http";
3
- import path from "node:path";
4
- import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
5
- import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
6
- import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
7
- import type { OpenClawConfig } from "../api.js";
8
- import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
9
- import { registerDiffsPlugin } from "./plugin.js";
10
- import { createTempDiffRoot } from "./test-helpers.js";
11
-
12
- const { launchMock } = vi.hoisted(() => ({
13
- launchMock: vi.fn(),
14
- }));
15
-
16
- let PlaywrightDiffScreenshotter: typeof import("./browser.js").PlaywrightDiffScreenshotter;
17
- let resetSharedBrowserStateForTests: typeof import("./browser.js").resetSharedBrowserStateForTests;
18
-
19
- vi.mock("playwright-core", () => ({
20
- chromium: {
21
- launch: launchMock,
22
- },
23
- }));
24
-
25
- describe("PlaywrightDiffScreenshotter", () => {
26
- let rootDir: string;
27
- let outputPath: string;
28
- let cleanupRootDir: () => Promise<void>;
29
-
30
- beforeAll(async () => {
31
- ({ PlaywrightDiffScreenshotter, resetSharedBrowserStateForTests } =
32
- await import("./browser.js"));
33
- });
34
-
35
- beforeEach(async () => {
36
- vi.useFakeTimers();
37
- ({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-"));
38
- outputPath = path.join(rootDir, "preview.png");
39
- launchMock.mockReset();
40
- await resetSharedBrowserStateForTests();
41
- });
42
-
43
- afterEach(async () => {
44
- await resetSharedBrowserStateForTests();
45
- vi.useRealTimers();
46
- await cleanupRootDir();
47
- });
48
-
49
- it("reuses the same browser across renders and closes it after the idle window", async () => {
50
- const { pages, browser, screenshotter } = await createScreenshotterHarness();
51
-
52
- await screenshotter.screenshotHtml({
53
- html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
54
- outputPath,
55
- theme: "dark",
56
- image: {
57
- format: "png",
58
- qualityPreset: "standard",
59
- scale: 2,
60
- maxWidth: 960,
61
- maxPixels: 8_000_000,
62
- },
63
- });
64
- await screenshotter.screenshotHtml({
65
- html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
66
- outputPath,
67
- theme: "dark",
68
- image: {
69
- format: "png",
70
- qualityPreset: "standard",
71
- scale: 2,
72
- maxWidth: 960,
73
- maxPixels: 8_000_000,
74
- },
75
- });
76
-
77
- expect(launchMock).toHaveBeenCalledTimes(1);
78
- expect(browser.newPage).toHaveBeenCalledTimes(2);
79
- expect(browser.newPage).toHaveBeenNthCalledWith(
80
- 1,
81
- expect.objectContaining({
82
- deviceScaleFactor: 2,
83
- }),
84
- );
85
- expect(pages).toHaveLength(2);
86
- expect(pages[0]?.close).toHaveBeenCalledTimes(1);
87
- expect(pages[1]?.close).toHaveBeenCalledTimes(1);
88
-
89
- await vi.advanceTimersByTimeAsync(1_000);
90
- expect(browser.close).toHaveBeenCalledTimes(1);
91
-
92
- await screenshotter.screenshotHtml({
93
- html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
94
- outputPath,
95
- theme: "light",
96
- image: {
97
- format: "png",
98
- qualityPreset: "standard",
99
- scale: 2,
100
- maxWidth: 960,
101
- maxPixels: 8_000_000,
102
- },
103
- });
104
-
105
- expect(launchMock).toHaveBeenCalledTimes(2);
106
- });
107
-
108
- it("renders PDF output when format is pdf", async () => {
109
- const { pages, screenshotter } = await createScreenshotterHarness();
110
- const pdfPath = path.join(rootDir, "preview.pdf");
111
-
112
- await screenshotter.screenshotHtml({
113
- html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
114
- outputPath: pdfPath,
115
- theme: "light",
116
- image: {
117
- format: "pdf",
118
- qualityPreset: "standard",
119
- scale: 2,
120
- maxWidth: 960,
121
- maxPixels: 8_000_000,
122
- },
123
- });
124
-
125
- expect(launchMock).toHaveBeenCalledTimes(1);
126
- expect(pages).toHaveLength(1);
127
- expect(pages[0]?.pdf).toHaveBeenCalledTimes(1);
128
- const pdfCall = pages[0]?.pdf.mock.calls[0]?.[0] as Record<string, unknown> | undefined;
129
- expect(pdfCall).toBeDefined();
130
- expect(pdfCall).not.toHaveProperty("pageRanges");
131
- expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
132
- await expect(fs.readFile(pdfPath, "utf8")).resolves.toContain("%PDF-1.7");
133
- });
134
-
135
- it("fails fast when PDF render exceeds size limits", async () => {
136
- const pages: Array<{
137
- close: ReturnType<typeof vi.fn>;
138
- screenshot: ReturnType<typeof vi.fn>;
139
- pdf: ReturnType<typeof vi.fn>;
140
- }> = [];
141
- const browser = createMockBrowser(pages, {
142
- boundingBox: { x: 40, y: 40, width: 960, height: 60_000 },
143
- });
144
- launchMock.mockResolvedValue(browser);
145
- const screenshotter = new PlaywrightDiffScreenshotter({
146
- config: createConfig(),
147
- browserIdleMs: 1_000,
148
- });
149
- const pdfPath = path.join(rootDir, "oversized.pdf");
150
-
151
- await expect(
152
- screenshotter.screenshotHtml({
153
- html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
154
- outputPath: pdfPath,
155
- theme: "light",
156
- image: {
157
- format: "pdf",
158
- qualityPreset: "standard",
159
- scale: 2,
160
- maxWidth: 960,
161
- maxPixels: 8_000_000,
162
- },
163
- }),
164
- ).rejects.toThrow("Diff frame did not render within image size limits.");
165
-
166
- expect(launchMock).toHaveBeenCalledTimes(1);
167
- expect(pages).toHaveLength(1);
168
- expect(pages[0]?.pdf).toHaveBeenCalledTimes(0);
169
- expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
170
- });
171
-
172
- it("fails fast when maxPixels is still exceeded at scale 1", async () => {
173
- const { pages, screenshotter } = await createScreenshotterHarness();
174
-
175
- await expect(
176
- screenshotter.screenshotHtml({
177
- html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
178
- outputPath,
179
- theme: "dark",
180
- image: {
181
- format: "png",
182
- qualityPreset: "standard",
183
- scale: 1,
184
- maxWidth: 960,
185
- maxPixels: 10,
186
- },
187
- }),
188
- ).rejects.toThrow("Diff frame did not render within image size limits.");
189
- expect(pages).toHaveLength(1);
190
- expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
191
- });
192
- });
193
-
194
- describe("diffs plugin registration", () => {
195
- it("uses live runtime tool config through the registered tool factory", async () => {
196
- type RegisteredTool = {
197
- execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
198
- };
199
- type HttpRouteHandler = (
200
- req: IncomingMessage,
201
- res: ServerResponse,
202
- ) => boolean | Promise<boolean>;
203
- type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
204
-
205
- let registeredToolFactory:
206
- | ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
207
- | undefined;
208
- let registeredHttpRouteHandler: HttpRouteHandler | undefined;
209
- let configFile: OpenClawConfig = {
210
- gateway: {
211
- port: 18789,
212
- bind: "loopback",
213
- },
214
- plugins: {
215
- entries: {
216
- diffs: {
217
- config: {
218
- viewerBaseUrl: "https://startup.example.com/openclaw",
219
- defaults: {
220
- mode: "view",
221
- theme: "light",
222
- background: false,
223
- layout: "split",
224
- showLineNumbers: false,
225
- diffIndicators: "classic",
226
- lineSpacing: 2,
227
- },
228
- },
229
- },
230
- },
231
- },
232
- } as OpenClawConfig;
233
-
234
- const api = createTestPluginApi({
235
- id: "diffs",
236
- name: "Diffs",
237
- description: "Diffs",
238
- source: "test",
239
- config: {
240
- gateway: {
241
- port: 18789,
242
- bind: "loopback",
243
- },
244
- },
245
- pluginConfig: {
246
- viewerBaseUrl: "https://startup.example.com/openclaw",
247
- defaults: {
248
- mode: "view",
249
- theme: "light",
250
- background: false,
251
- layout: "split",
252
- showLineNumbers: false,
253
- diffIndicators: "classic",
254
- lineSpacing: 2,
255
- },
256
- },
257
- runtime: {
258
- config: {
259
- current: () => configFile,
260
- },
261
- } as never,
262
- registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
263
- registeredToolFactory = typeof tool === "function" ? tool : () => tool;
264
- },
265
- registerHttpRoute(params: RegisteredHttpRouteParams) {
266
- registeredHttpRouteHandler = params.handler as HttpRouteHandler;
267
- },
268
- on: vi.fn(),
269
- });
270
-
271
- registerDiffsPlugin(api as unknown as OpenClawPluginApi);
272
-
273
- configFile = {
274
- ...configFile,
275
- plugins: {
276
- entries: {
277
- diffs: {
278
- config: {
279
- viewerBaseUrl: "https://live.example.com/gateway",
280
- defaults: {
281
- mode: "view",
282
- theme: "dark",
283
- background: true,
284
- layout: "unified",
285
- showLineNumbers: true,
286
- diffIndicators: "bars",
287
- lineSpacing: 1.6,
288
- },
289
- },
290
- },
291
- },
292
- },
293
- } as OpenClawConfig;
294
-
295
- const registeredTool = registeredToolFactory?.({
296
- agentId: "main",
297
- sessionId: "session-456",
298
- messageChannel: "discord",
299
- agentAccountId: "default",
300
- }) as RegisteredTool | undefined;
301
- const result = await registeredTool?.execute?.("tool-1", {
302
- before: "one\n",
303
- after: "two\n",
304
- });
305
- const details = (result as { details?: Record<string, unknown> } | undefined)?.details;
306
- const viewerPath = String(details?.viewerPath);
307
- const res = createMockServerResponse();
308
- const handled = await registeredHttpRouteHandler?.(
309
- localReq({
310
- method: "GET",
311
- url: viewerPath,
312
- }),
313
- res,
314
- );
315
-
316
- expect(handled).toBe(true);
317
- expect(String(details?.viewerUrl)).toContain("https://live.example.com/gateway");
318
- expect(res.statusCode).toBe(200);
319
- expect(String(res.body)).toContain('body data-theme="dark"');
320
- expect(String(res.body)).toContain('"backgroundEnabled":true');
321
- expect(String(res.body)).toContain('"diffStyle":"unified"');
322
- expect(String(res.body)).toContain('"disableLineNumbers":false');
323
- expect(String(res.body)).toContain('"diffIndicators":"bars"');
324
- expect(String(res.body)).toContain("--diffs-line-height: 24px;");
325
- });
326
-
327
- it("uses live runtime viewer-access config through the registered HTTP handler", async () => {
328
- type RegisteredTool = {
329
- execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
330
- };
331
- type HttpRouteHandler = (
332
- req: IncomingMessage,
333
- res: ServerResponse,
334
- ) => boolean | Promise<boolean>;
335
- type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
336
-
337
- let registeredToolFactory:
338
- | ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
339
- | undefined;
340
- let registeredHttpRouteHandler: HttpRouteHandler | undefined;
341
- const on = vi.fn();
342
- let configFile: OpenClawConfig = {
343
- gateway: {
344
- port: 18789,
345
- bind: "loopback",
346
- },
347
- plugins: {
348
- entries: {
349
- diffs: {
350
- config: {
351
- security: {
352
- allowRemoteViewer: true,
353
- },
354
- },
355
- },
356
- },
357
- },
358
- } as OpenClawConfig;
359
-
360
- const api = createTestPluginApi({
361
- id: "diffs",
362
- name: "Diffs",
363
- description: "Diffs",
364
- source: "test",
365
- config: {
366
- gateway: {
367
- port: 18789,
368
- bind: "loopback",
369
- },
370
- },
371
- pluginConfig: {
372
- defaults: {
373
- mode: "view",
374
- theme: "light",
375
- background: false,
376
- layout: "split",
377
- showLineNumbers: false,
378
- diffIndicators: "classic",
379
- lineSpacing: 2,
380
- },
381
- security: {
382
- allowRemoteViewer: true,
383
- },
384
- },
385
- runtime: {
386
- config: {
387
- current: () => configFile,
388
- },
389
- } as never,
390
- registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
391
- registeredToolFactory = typeof tool === "function" ? tool : () => tool;
392
- },
393
- registerHttpRoute(params: RegisteredHttpRouteParams) {
394
- registeredHttpRouteHandler = params.handler as HttpRouteHandler;
395
- },
396
- on,
397
- });
398
-
399
- registerDiffsPlugin(api as unknown as OpenClawPluginApi);
400
-
401
- expect(on).toHaveBeenCalledTimes(1);
402
- expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
403
- const beforePromptBuild = on.mock.calls[0]?.[1];
404
- const promptResult = await beforePromptBuild?.({}, {});
405
- expect(promptResult).toMatchObject({
406
- prependSystemContext: expect.stringContaining("prefer the `diffs` tool"),
407
- });
408
- expect(promptResult?.prependContext).toBeUndefined();
409
-
410
- const registeredTool = registeredToolFactory?.({
411
- agentId: "main",
412
- sessionId: "session-123",
413
- messageChannel: "discord",
414
- agentAccountId: "default",
415
- }) as RegisteredTool | undefined;
416
- const result = await registeredTool?.execute?.("tool-1", {
417
- before: "one\n",
418
- after: "two\n",
419
- });
420
- const viewerPath = String(
421
- (result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
422
- );
423
- const res = createMockServerResponse();
424
- const handled = await registeredHttpRouteHandler?.(
425
- localReq({
426
- method: "GET",
427
- url: viewerPath,
428
- }),
429
- res,
430
- );
431
-
432
- expect(handled).toBe(true);
433
- expect(res.statusCode).toBe(200);
434
- expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
435
- {
436
- agentId: "main",
437
- sessionId: "session-123",
438
- messageChannel: "discord",
439
- agentAccountId: "default",
440
- },
441
- );
442
-
443
- configFile = {
444
- ...configFile,
445
- plugins: {
446
- entries: {
447
- diffs: {
448
- config: {
449
- security: {
450
- allowRemoteViewer: false,
451
- },
452
- },
453
- },
454
- },
455
- },
456
- } as OpenClawConfig;
457
-
458
- const proxiedRes = createMockServerResponse();
459
- const proxiedHandled = await registeredHttpRouteHandler?.(
460
- localReq({
461
- method: "GET",
462
- url: viewerPath,
463
- headers: {
464
- "x-forwarded-for": "203.0.113.10",
465
- },
466
- }),
467
- proxiedRes,
468
- );
469
-
470
- expect(proxiedHandled).toBe(true);
471
- expect(proxiedRes.statusCode).toBe(404);
472
- });
473
-
474
- it("fails closed for remote viewer access when the live diffs plugin entry is removed", async () => {
475
- type RegisteredTool = {
476
- execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
477
- };
478
- type HttpRouteHandler = (
479
- req: IncomingMessage,
480
- res: ServerResponse,
481
- ) => boolean | Promise<boolean>;
482
- type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
483
-
484
- let registeredToolFactory:
485
- | ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
486
- | undefined;
487
- let registeredHttpRouteHandler: HttpRouteHandler | undefined;
488
- let configFile: OpenClawConfig = {
489
- gateway: {
490
- port: 18789,
491
- bind: "loopback",
492
- },
493
- plugins: {
494
- entries: {
495
- diffs: {
496
- config: {
497
- security: {
498
- allowRemoteViewer: true,
499
- },
500
- },
501
- },
502
- },
503
- },
504
- } as OpenClawConfig;
505
-
506
- const api = createTestPluginApi({
507
- id: "diffs",
508
- name: "Diffs",
509
- description: "Diffs",
510
- source: "test",
511
- config: {
512
- gateway: {
513
- port: 18789,
514
- bind: "loopback",
515
- },
516
- },
517
- pluginConfig: {
518
- security: {
519
- allowRemoteViewer: true,
520
- },
521
- },
522
- runtime: {
523
- config: {
524
- current: () => configFile,
525
- },
526
- } as never,
527
- registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
528
- registeredToolFactory = typeof tool === "function" ? tool : () => tool;
529
- },
530
- registerHttpRoute(params: RegisteredHttpRouteParams) {
531
- registeredHttpRouteHandler = params.handler as HttpRouteHandler;
532
- },
533
- on: vi.fn(),
534
- });
535
-
536
- registerDiffsPlugin(api as unknown as OpenClawPluginApi);
537
-
538
- const registeredTool = registeredToolFactory?.({
539
- agentId: "main",
540
- sessionId: "session-789",
541
- messageChannel: "discord",
542
- agentAccountId: "default",
543
- }) as RegisteredTool | undefined;
544
- const result = await registeredTool?.execute?.("tool-1", {
545
- before: "one\n",
546
- after: "two\n",
547
- });
548
- const viewerPath = String(
549
- (result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
550
- );
551
-
552
- configFile = {
553
- ...configFile,
554
- plugins: {
555
- entries: {},
556
- },
557
- } as OpenClawConfig;
558
-
559
- const proxiedRes = createMockServerResponse();
560
- const proxiedHandled = await registeredHttpRouteHandler?.(
561
- localReq({
562
- method: "GET",
563
- url: viewerPath,
564
- headers: {
565
- "x-forwarded-for": "203.0.113.10",
566
- },
567
- }),
568
- proxiedRes,
569
- );
570
-
571
- expect(proxiedHandled).toBe(true);
572
- expect(proxiedRes.statusCode).toBe(404);
573
- });
574
- });
575
-
576
- function createConfig(): OpenClawConfig {
577
- return {
578
- browser: {
579
- executablePath: process.execPath,
580
- },
581
- } as OpenClawConfig;
582
- }
583
-
584
- function localReq(input: {
585
- method: string;
586
- url: string;
587
- headers?: IncomingMessage["headers"];
588
- }): IncomingMessage {
589
- return {
590
- ...input,
591
- headers: input.headers ?? {},
592
- socket: { remoteAddress: "127.0.0.1" },
593
- } as unknown as IncomingMessage;
594
- }
595
-
596
- async function createScreenshotterHarness(options?: {
597
- boundingBox?: { x: number; y: number; width: number; height: number };
598
- }) {
599
- const pages: Array<{
600
- close: ReturnType<typeof vi.fn>;
601
- screenshot: ReturnType<typeof vi.fn>;
602
- pdf: ReturnType<typeof vi.fn>;
603
- }> = [];
604
- const browser = createMockBrowser(pages, options);
605
- launchMock.mockResolvedValue(browser);
606
- const screenshotter = new PlaywrightDiffScreenshotter({
607
- config: createConfig(),
608
- browserIdleMs: 1_000,
609
- });
610
- return { pages, browser, screenshotter };
611
- }
612
-
613
- function createMockBrowser(
614
- pages: Array<{
615
- close: ReturnType<typeof vi.fn>;
616
- screenshot: ReturnType<typeof vi.fn>;
617
- pdf: ReturnType<typeof vi.fn>;
618
- }>,
619
- options?: { boundingBox?: { x: number; y: number; width: number; height: number } },
620
- ) {
621
- const browser = {
622
- newPage: vi.fn(async () => {
623
- const page = createMockPage(options);
624
- pages.push(page);
625
- return page;
626
- }),
627
- close: vi.fn(async () => {}),
628
- on: vi.fn(),
629
- };
630
- return browser;
631
- }
632
-
633
- function createMockPage(options?: {
634
- boundingBox?: { x: number; y: number; width: number; height: number };
635
- }) {
636
- const box = options?.boundingBox ?? { x: 40, y: 40, width: 640, height: 240 };
637
- const screenshot = vi.fn(async ({ path: screenshotPath }: { path: string }) => {
638
- await fs.writeFile(screenshotPath, Buffer.from("png"));
639
- });
640
- const pdf = vi.fn(async ({ path: pdfPath }: { path: string }) => {
641
- await fs.writeFile(pdfPath, "%PDF-1.7 mock");
642
- });
643
-
644
- return {
645
- route: vi.fn(async () => {}),
646
- setContent: vi.fn(async () => {}),
647
- waitForFunction: vi.fn(async () => {}),
648
- evaluate: vi.fn(async () => 1),
649
- emulateMedia: vi.fn(async () => {}),
650
- locator: vi.fn(() => ({
651
- waitFor: vi.fn(async () => {}),
652
- boundingBox: vi.fn(async () => box),
653
- })),
654
- setViewportSize: vi.fn(async () => {}),
655
- screenshot,
656
- pdf,
657
- close: vi.fn(async () => {}),
658
- };
659
- }