Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
236
extensions/diffs/README.md
Normal file
236
extensions/diffs/README.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# @openclaw/diffs
|
||||
|
||||
Read-only diff viewer plugin for **OpenClaw** agents.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/diffs
|
||||
```
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
It gives agents one tool, `diffs`, that can:
|
||||
|
||||
- render a gateway-hosted diff viewer for canvas use
|
||||
- render the same diff to a file (PNG or PDF)
|
||||
- accept either arbitrary `before` and `after` text or a unified patch
|
||||
|
||||
## What Agents Get
|
||||
|
||||
The tool can return:
|
||||
|
||||
- `details.viewerUrl`: a gateway URL that can be opened in the canvas
|
||||
- `details.filePath`: a local rendered artifact path when file rendering is requested
|
||||
- `details.fileFormat`: the rendered file format (`png` or `pdf`)
|
||||
- `details.artifactId` and `details.expiresAt`: artifact identity and TTL metadata
|
||||
- `details.context`: available routing metadata such as `agentId`, `sessionId`, `messageChannel`, and `agentAccountId`
|
||||
|
||||
When the plugin is enabled, it also ships a companion skill from `skills/` and prepends stable tool-usage guidance into system-prompt space via `before_prompt_build`. The hook uses `prependSystemContext`, so the guidance stays out of user-prompt space while still being available every turn.
|
||||
|
||||
This means an agent can:
|
||||
|
||||
- call `diffs` with `mode=view`, then pass `details.viewerUrl` to `canvas present`
|
||||
- call `diffs` with `mode=file`, then send the file through the normal `message` tool using `path` or `filePath`
|
||||
- call `diffs` with `mode=both` when it wants both outputs
|
||||
|
||||
## Tool Inputs
|
||||
|
||||
Before and after:
|
||||
|
||||
```json
|
||||
{
|
||||
"before": "# Hello\n\nOne",
|
||||
"after": "# Hello\n\nTwo",
|
||||
"path": "docs/example.md",
|
||||
"mode": "view"
|
||||
}
|
||||
```
|
||||
|
||||
Patch:
|
||||
|
||||
```json
|
||||
{
|
||||
"patch": "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-const x = 1;\n+const x = 2;\n",
|
||||
"mode": "both"
|
||||
}
|
||||
```
|
||||
|
||||
Useful options:
|
||||
|
||||
- `mode`: `view`, `file`, or `both`
|
||||
Deprecated alias: `image` behaves like `file` and is still accepted for backward compatibility.
|
||||
- `layout`: `unified` or `split`
|
||||
- `theme`: `light` or `dark` (default: `dark`)
|
||||
- `fileFormat`: `png` or `pdf` (default: `png`)
|
||||
- `fileQuality`: `standard`, `hq`, or `print`
|
||||
- `fileScale`: device scale override (`1`-`4`)
|
||||
- `fileMaxWidth`: max width override in CSS pixels (`640`-`2400`)
|
||||
- `expandUnchanged`: expand unchanged sections (per-call option only, not a plugin default key)
|
||||
- `path`: display name for before and after input
|
||||
- `lang`: language hint for before/after input; unknown values fall back to plain text
|
||||
- Default syntax highlighting covers common source, config, and documentation languages. Install `diffs-language-pack` for the extended language catalog.
|
||||
- `title`: explicit viewer title
|
||||
- `ttlSeconds`: artifact lifetime for viewer and standalone file outputs
|
||||
- `baseUrl`: override the gateway base URL used in the returned viewer link (origin or origin+base path only; no query/hash)
|
||||
- `viewerBaseUrl` plugin config: persistent fallback used when a tool call omits `baseUrl`
|
||||
|
||||
Legacy input aliases still accepted for backward compatibility:
|
||||
|
||||
- `format` -> `fileFormat`
|
||||
- `imageFormat` -> `fileFormat`
|
||||
- `imageQuality` -> `fileQuality`
|
||||
- `imageScale` -> `fileScale`
|
||||
- `imageMaxWidth` -> `fileMaxWidth`
|
||||
|
||||
Input safety limits:
|
||||
|
||||
- `before` and `after`: max 512 KiB each
|
||||
- `patch`: max 2 MiB
|
||||
- patch rendering cap: max 128 files / 120,000 lines
|
||||
|
||||
## Plugin Defaults
|
||||
|
||||
Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
enabled: true,
|
||||
config: {
|
||||
defaults: {
|
||||
fontFamily: "Fira Code",
|
||||
fontSize: 15,
|
||||
lineSpacing: 1.6,
|
||||
layout: "unified",
|
||||
showLineNumbers: true,
|
||||
diffIndicators: "bars",
|
||||
wordWrap: true,
|
||||
background: true,
|
||||
theme: "dark",
|
||||
fileFormat: "png",
|
||||
fileQuality: "standard",
|
||||
fileScale: 2,
|
||||
fileMaxWidth: 960,
|
||||
mode: "both",
|
||||
ttlSeconds: 21600,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Explicit tool parameters still win over these defaults.
|
||||
|
||||
## Docs
|
||||
|
||||
- https://docs.openclaw.ai/tools/diffs
|
||||
|
||||
## Package
|
||||
|
||||
- Plugin id: `diffs`
|
||||
- Package: `@openclaw/diffs`
|
||||
- Minimum OpenClaw host: `2026.4.30`
|
||||
|
||||
Security options:
|
||||
|
||||
- `security.allowRemoteViewer` (default `false`): allows non-loopback access to `/plugins/diffs/view/...` token URLs
|
||||
- `viewerBaseUrl` (optional): persistent viewer-link origin/path fallback for shareable URLs
|
||||
- `defaults.ttlSeconds` (default `1800`, max `21600`): default artifact lifetime for viewer and standalone file outputs
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
enabled: true,
|
||||
config: {
|
||||
viewerBaseUrl: "https://gateway.example.com/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Example Agent Prompts
|
||||
|
||||
Open in canvas:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool in `view` mode for this before and after content, then open the returned viewer URL in the canvas.
|
||||
|
||||
Path: docs/example.md
|
||||
|
||||
Before:
|
||||
# Hello
|
||||
|
||||
This is version one.
|
||||
|
||||
After:
|
||||
# Hello
|
||||
|
||||
This is version two.
|
||||
```
|
||||
|
||||
Render a file (PNG or PDF):
|
||||
|
||||
```text
|
||||
Use the `diffs` tool in `file` mode for this before and after input. After it returns `details.filePath`, use the `message` tool with `path` or `filePath` to send me the rendered diff file.
|
||||
|
||||
Path: README.md
|
||||
|
||||
Before:
|
||||
OpenClaw supports plugins.
|
||||
|
||||
After:
|
||||
OpenClaw supports plugins and hosted diff views.
|
||||
```
|
||||
|
||||
Do both:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool in `both` mode for this diff. Open the viewer in the canvas and then send the rendered file by passing `details.filePath` to the `message` tool.
|
||||
|
||||
Path: src/demo.ts
|
||||
|
||||
Before:
|
||||
const status = "old";
|
||||
|
||||
After:
|
||||
const status = "new";
|
||||
```
|
||||
|
||||
Patch input:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool with this unified patch in `view` mode. After it returns the viewer URL, present it in the canvas.
|
||||
|
||||
diff --git a/src/example.ts b/src/example.ts
|
||||
--- a/src/example.ts
|
||||
+++ b/src/example.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
export function add(a: number, b: number) {
|
||||
- return a + b;
|
||||
+ return a + b + 1;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
|
||||
- Artifacts are ephemeral and stored in the plugin temp subfolder (`$TMPDIR/openclaw-diffs`).
|
||||
- Default viewer URLs use loopback (`127.0.0.1`) unless you set plugin `viewerBaseUrl`, pass `baseUrl`, or use `gateway.bind=custom` + `gateway.customBindHost`.
|
||||
- If `gateway.trustedProxies` includes loopback for a same-host proxy (for example Tailscale Serve), raw `127.0.0.1` viewer requests without forwarded client-IP headers fail closed by design.
|
||||
- In that topology, prefer `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set plugin `viewerBaseUrl` (or pass a proxy/public `baseUrl`) when you need a shareable viewer URL.
|
||||
- Remote viewer misses are throttled to reduce token-guess abuse.
|
||||
- PNG or PDF rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
|
||||
- If your delivery channel compresses images heavily (for example Telegram or WhatsApp), prefer `fileFormat: "pdf"` to preserve readability.
|
||||
- `N unmodified lines` rows may not always include expand controls for patch input, because many patch hunks do not carry full expandable context data.
|
||||
- Diff rendering is powered by [Diffs](https://diffs.com).
|
||||
11
extensions/diffs/api.ts
Normal file
11
extensions/diffs/api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Diffs API module exposes the plugin public contract.
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
export {
|
||||
definePluginEntry,
|
||||
type AnyAgentTool,
|
||||
type OpenClawPluginApi,
|
||||
type OpenClawPluginConfigSchema,
|
||||
type OpenClawPluginToolContext,
|
||||
type PluginLogger,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
export { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
12
extensions/diffs/index.ts
Normal file
12
extensions/diffs/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Diffs plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "./api.js";
|
||||
import { diffsPluginConfigSchema } from "./src/config.js";
|
||||
import { registerDiffsPlugin } from "./src/plugin.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Read-only diff viewer and PNG/PDF renderer for agents.",
|
||||
configSchema: diffsPluginConfigSchema,
|
||||
register: registerDiffsPlugin,
|
||||
});
|
||||
717
extensions/diffs/npm-shrinkwrap.json
generated
Normal file
717
extensions/diffs/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,717 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "1.2.12",
|
||||
"@shikijs/langs": "4.3.0",
|
||||
"playwright-core": "1.61.1",
|
||||
"typebox": "1.3.3",
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@pierre/diffs": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.12.tgz",
|
||||
"integrity": "sha512-pY/gmgWL03WnagqCyCnBi3QtRXUv4hCIY6FYqd5b1ZGaoI6a4Bsji8j+yRl2RfzPh/8Hf19rCl1GE80G6a1cLQ==",
|
||||
"license": "apache-2.0",
|
||||
"dependencies": {
|
||||
"@pierre/theme": "1.1.0",
|
||||
"@pierre/theming": "0.0.2",
|
||||
"@shikijs/transformers": "^3.0.0 || ^4.0.0",
|
||||
"diff": "9.0.0",
|
||||
"hast-util-to-html": "9.0.5",
|
||||
"lru_map": "0.4.1",
|
||||
"shiki": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1 || ^19.0.0",
|
||||
"react-dom": "^18.3.1 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pierre/theme": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-1.1.0.tgz",
|
||||
"integrity": "sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"vscode": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pierre/theming": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@pierre/theming/-/theming-0.0.2.tgz",
|
||||
"integrity": "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==",
|
||||
"license": "apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@pierre/theme": "^1.1.0",
|
||||
"@shikijs/themes": "^3.0.0 || ^4.0.0",
|
||||
"react": "^18.3.1 || ^19.0.0",
|
||||
"react-dom": "^18.3.1 || ^19.0.0",
|
||||
"shiki": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@pierre/theme": {
|
||||
"optional": true
|
||||
},
|
||||
"@shikijs/themes": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"shiki": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/core": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.0.tgz",
|
||||
"integrity": "sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/primitive": "4.3.0",
|
||||
"@shikijs/types": "4.3.0",
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"hast-util-to-html": "^9.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/engine-javascript": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.0.tgz",
|
||||
"integrity": "sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "4.3.0",
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"oniguruma-to-es": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/engine-oniguruma": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.0.tgz",
|
||||
"integrity": "sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "4.3.0",
|
||||
"@shikijs/vscode-textmate": "^10.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/langs": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.0.tgz",
|
||||
"integrity": "sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "4.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/primitive": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.0.tgz",
|
||||
"integrity": "sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "4.3.0",
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/themes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.0.tgz",
|
||||
"integrity": "sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "4.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/transformers": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.3.0.tgz",
|
||||
"integrity": "sha512-5/elhJEcUrxdyQ95SSx0HzrnbzVPuipk6TYiXZL67tHhw8J+4N5shzmrTYyCaudiSnpuuwPdpaVtciwRFwSA7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/core": "4.3.0",
|
||||
"@shikijs/types": "4.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/types": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.0.tgz",
|
||||
"integrity": "sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/vscode-textmate": {
|
||||
"version": "10.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
|
||||
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz",
|
||||
"integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/character-entities-html4": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
|
||||
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/character-entities-legacy": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
|
||||
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
|
||||
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-html": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
|
||||
"integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"ccount": "^2.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"hast-util-whitespace": "^3.0.0",
|
||||
"html-void-elements": "^3.0.0",
|
||||
"mdast-util-to-hast": "^13.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0",
|
||||
"stringify-entities": "^4.0.0",
|
||||
"zwitch": "^2.0.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-whitespace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html-void-elements": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
|
||||
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lru_map": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz",
|
||||
"integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mdast-util-to-hast": {
|
||||
"version": "13.2.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
|
||||
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/mdast": "^4.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"trim-lines": "^3.0.0",
|
||||
"unist-util-position": "^5.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-util-character": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
|
||||
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "GitHub Sponsors",
|
||||
"url": "https://github.com/sponsors/unifiedjs"
|
||||
},
|
||||
{
|
||||
"type": "OpenCollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-util-encode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
|
||||
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "GitHub Sponsors",
|
||||
"url": "https://github.com/sponsors/unifiedjs"
|
||||
},
|
||||
{
|
||||
"type": "OpenCollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromark-util-sanitize-uri": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
|
||||
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "GitHub Sponsors",
|
||||
"url": "https://github.com/sponsors/unifiedjs"
|
||||
},
|
||||
{
|
||||
"type": "OpenCollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-encode": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-util-symbol": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
|
||||
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "GitHub Sponsors",
|
||||
"url": "https://github.com/sponsors/unifiedjs"
|
||||
},
|
||||
{
|
||||
"type": "OpenCollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromark-util-types": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
|
||||
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "GitHub Sponsors",
|
||||
"url": "https://github.com/sponsors/unifiedjs"
|
||||
},
|
||||
{
|
||||
"type": "OpenCollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oniguruma-parser": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
|
||||
"integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oniguruma-to-es": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
|
||||
"integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"oniguruma-parser": "^0.12.2",
|
||||
"regex": "^6.1.0",
|
||||
"regex-recursion": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
|
||||
"integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
||||
"integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regex-utilities": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regex-recursion": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
|
||||
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regex-utilities": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regex-utilities": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
|
||||
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shiki": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.0.tgz",
|
||||
"integrity": "sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/core": "4.3.0",
|
||||
"@shikijs/engine-javascript": "4.3.0",
|
||||
"@shikijs/engine-oniguruma": "4.3.0",
|
||||
"@shikijs/langs": "4.3.0",
|
||||
"@shikijs/themes": "4.3.0",
|
||||
"@shikijs/types": "4.3.0",
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/space-separated-tokens": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
|
||||
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"character-entities-html4": "^2.0.0",
|
||||
"character-entities-legacy": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/typebox": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
|
||||
"integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unist-util-is": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
|
||||
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-position": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
|
||||
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-stringify-position": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
|
||||
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-visit": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
|
||||
"integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"unist-util-is": "^6.0.0",
|
||||
"unist-util-visit-parents": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-visit-parents": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
|
||||
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"unist-util-is": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"vfile-message": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile-message": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
|
||||
"integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"unist-util-stringify-position": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
218
extensions/diffs/openclaw.plugin.json
Normal file
218
extensions/diffs/openclaw.plugin.json
Normal file
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"id": "diffs",
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"name": "Diffs",
|
||||
"description": "OpenClaw read-only diff viewer plugin and file renderer for agents.",
|
||||
"contracts": {
|
||||
"tools": ["diffs"]
|
||||
},
|
||||
"toolMetadata": {
|
||||
"diffs": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"skills": ["./skills"],
|
||||
"uiHints": {
|
||||
"viewerBaseUrl": {
|
||||
"label": "Viewer Base URL",
|
||||
"help": "Persistent gateway base URL used for returned viewer links when a tool call does not pass baseUrl."
|
||||
},
|
||||
"defaults.fontFamily": {
|
||||
"label": "Default Font",
|
||||
"help": "Preferred font family name for diff content and headers."
|
||||
},
|
||||
"defaults.fontSize": {
|
||||
"label": "Default Font Size",
|
||||
"help": "Base diff font size in pixels."
|
||||
},
|
||||
"defaults.lineSpacing": {
|
||||
"label": "Default Line Spacing",
|
||||
"help": "Line-height multiplier applied to diff rows."
|
||||
},
|
||||
"defaults.layout": {
|
||||
"label": "Default Layout",
|
||||
"help": "Initial diff layout shown in the viewer."
|
||||
},
|
||||
"defaults.showLineNumbers": {
|
||||
"label": "Show Line Numbers",
|
||||
"help": "Show line numbers by default."
|
||||
},
|
||||
"defaults.diffIndicators": {
|
||||
"label": "Diff Indicator Style",
|
||||
"help": "Choose added/removed indicators style."
|
||||
},
|
||||
"defaults.wordWrap": {
|
||||
"label": "Default Word Wrap",
|
||||
"help": "Wrap long lines by default."
|
||||
},
|
||||
"defaults.background": {
|
||||
"label": "Default Background Highlights",
|
||||
"help": "Show added/removed background highlights by default."
|
||||
},
|
||||
"defaults.theme": {
|
||||
"label": "Default Theme",
|
||||
"help": "Initial viewer theme."
|
||||
},
|
||||
"defaults.fileFormat": {
|
||||
"label": "Default File Format",
|
||||
"help": "Rendered file format for file mode (PNG or PDF)."
|
||||
},
|
||||
"defaults.fileQuality": {
|
||||
"label": "Default File Quality",
|
||||
"help": "Quality preset for PNG/PDF rendering."
|
||||
},
|
||||
"defaults.fileScale": {
|
||||
"label": "Default File Scale",
|
||||
"help": "Device scale factor used while rendering file artifacts."
|
||||
},
|
||||
"defaults.fileMaxWidth": {
|
||||
"label": "Default File Max Width",
|
||||
"help": "Maximum file render width in CSS pixels."
|
||||
},
|
||||
"defaults.mode": {
|
||||
"label": "Default Output Mode",
|
||||
"help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, file for PNG/PDF, or both."
|
||||
},
|
||||
"defaults.ttlSeconds": {
|
||||
"label": "Default Artifact TTL",
|
||||
"help": "Default lifetime in seconds for diff viewer and file artifacts. Maximum: 21600."
|
||||
},
|
||||
"security.allowRemoteViewer": {
|
||||
"label": "Allow Remote Viewer",
|
||||
"help": "Allow non-loopback access to diff viewer URLs when the token path is known."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"viewerBaseUrl": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://",
|
||||
"not": {
|
||||
"pattern": "[?#]"
|
||||
}
|
||||
},
|
||||
"defaults": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fontFamily": {
|
||||
"type": "string",
|
||||
"default": "Fira Code"
|
||||
},
|
||||
"fontSize": {
|
||||
"type": "number",
|
||||
"minimum": 10,
|
||||
"maximum": 24,
|
||||
"default": 15
|
||||
},
|
||||
"lineSpacing": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"maximum": 3,
|
||||
"default": 1.6
|
||||
},
|
||||
"layout": {
|
||||
"type": "string",
|
||||
"enum": ["unified", "split"],
|
||||
"default": "unified"
|
||||
},
|
||||
"showLineNumbers": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"diffIndicators": {
|
||||
"type": "string",
|
||||
"enum": ["bars", "classic", "none"],
|
||||
"default": "bars"
|
||||
},
|
||||
"wordWrap": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"background": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark"],
|
||||
"default": "dark"
|
||||
},
|
||||
"fileFormat": {
|
||||
"type": "string",
|
||||
"enum": ["png", "pdf"],
|
||||
"default": "png"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": "Deprecated alias for fileFormat.",
|
||||
"enum": ["png", "pdf"]
|
||||
},
|
||||
"fileQuality": {
|
||||
"type": "string",
|
||||
"enum": ["standard", "hq", "print"],
|
||||
"default": "standard"
|
||||
},
|
||||
"fileScale": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"maximum": 4
|
||||
},
|
||||
"fileMaxWidth": {
|
||||
"type": "number",
|
||||
"minimum": 640,
|
||||
"maximum": 2400
|
||||
},
|
||||
"imageFormat": {
|
||||
"type": "string",
|
||||
"description": "Deprecated alias for fileFormat.",
|
||||
"enum": ["png", "pdf"]
|
||||
},
|
||||
"imageQuality": {
|
||||
"type": "string",
|
||||
"description": "Deprecated alias for fileQuality.",
|
||||
"enum": ["standard", "hq", "print"]
|
||||
},
|
||||
"imageScale": {
|
||||
"type": "number",
|
||||
"description": "Deprecated alias for fileScale.",
|
||||
"minimum": 1,
|
||||
"maximum": 4
|
||||
},
|
||||
"imageMaxWidth": {
|
||||
"type": "number",
|
||||
"description": "Deprecated alias for fileMaxWidth.",
|
||||
"minimum": 640,
|
||||
"maximum": 2400
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["view", "image", "file", "both"],
|
||||
"default": "both"
|
||||
},
|
||||
"ttlSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"maximum": 21600,
|
||||
"default": 1800
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"allowRemoteViewer": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
extensions/diffs/package.json
Normal file
50
extensions/diffs/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw read-only diff viewer plugin and file renderer for agents.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "1.2.12",
|
||||
"@shikijs/langs": "4.3.0",
|
||||
"playwright-core": "1.61.1",
|
||||
"typebox": "1.3.3",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/diffs",
|
||||
"localPath": "extensions/diffs",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.30"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"assetScripts": {
|
||||
"build": "node ../../scripts/build-diffs-viewer-runtime.mjs curated"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./assets/viewer-runtime.js",
|
||||
"output": "assets/viewer-runtime.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
2
extensions/diffs/runtime-api.ts
Normal file
2
extensions/diffs/runtime-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Diffs API module exposes the plugin public contract.
|
||||
export { resolveRequestClientIp } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
23
extensions/diffs/skills/diffs/SKILL.md
Normal file
23
extensions/diffs/skills/diffs/SKILL.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: diffs
|
||||
description: Use the diffs tool to produce real, shareable diffs (viewer URL, file artifact, or both) instead of manual edit summaries.
|
||||
---
|
||||
|
||||
When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.
|
||||
|
||||
The `diffs` tool accepts either `before` + `after` text, or a unified `patch` string.
|
||||
|
||||
Use `mode=view` when you want an interactive gateway-hosted viewer. After the tool returns, use `details.viewerUrl` with the canvas tool via `canvas present` or `canvas navigate`.
|
||||
If the deployment uses a loopback trusted proxy (for example Tailscale Serve with `gateway.trustedProxies` including `127.0.0.1`), raw loopback viewer requests can fail closed without forwarded client-IP headers. In that topology, prefer `mode=file` / `mode=both`, or use a configured `viewerBaseUrl` / explicit proxy/public `baseUrl` when you need a shareable viewer URL.
|
||||
|
||||
Use `mode=file` when you need a rendered file artifact. Set `fileFormat=png` (default) or `fileFormat=pdf`. The tool result includes `details.filePath`.
|
||||
|
||||
For large or high-fidelity files, use `fileQuality` (`standard`|`hq`|`print`) and optionally override `fileScale`/`fileMaxWidth`.
|
||||
|
||||
When you need to deliver the rendered file to a user or channel, do not rely on the raw tool-result renderer. Instead, call the `message` tool and pass `details.filePath` through `path` or `filePath`.
|
||||
|
||||
Use `mode=both` when you want both the gateway viewer URL and the rendered artifact.
|
||||
|
||||
If the user has configured diffs plugin defaults, prefer omitting `mode`, `theme`, `layout`, and related presentation options unless you need to override them for this specific diff.
|
||||
|
||||
Include `path` for before/after text when you know the file name.
|
||||
687
extensions/diffs/src/browser.test.ts
Normal file
687
extensions/diffs/src/browser.test.ts
Normal file
@@ -0,0 +1,687 @@
|
||||
// Diffs tests cover browser plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
|
||||
import { registerDiffsPlugin } from "./plugin.js";
|
||||
import { createTempDiffRoot } from "./test-helpers.js";
|
||||
|
||||
const { launchMock } = vi.hoisted(() => ({
|
||||
launchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
let PlaywrightDiffScreenshotter: typeof import("./browser.js").PlaywrightDiffScreenshotter;
|
||||
let resetSharedBrowserStateForTests: typeof import("./browser.js").resetSharedBrowserStateForTests;
|
||||
|
||||
vi.mock("playwright-core", () => ({
|
||||
chromium: {
|
||||
launch: launchMock,
|
||||
},
|
||||
}));
|
||||
|
||||
function firstMockCall(
|
||||
mock: { mock: { calls: Array<readonly unknown[]> } },
|
||||
label: string,
|
||||
): readonly unknown[] {
|
||||
const call = mock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} call`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("playwright-core");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("PlaywrightDiffScreenshotter", () => {
|
||||
let rootDir: string;
|
||||
let outputPath: string;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ PlaywrightDiffScreenshotter, resetSharedBrowserStateForTests } =
|
||||
await import("./browser.js"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-"));
|
||||
outputPath = path.join(rootDir, "preview.png");
|
||||
launchMock.mockReset();
|
||||
await resetSharedBrowserStateForTests();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await resetSharedBrowserStateForTests();
|
||||
vi.useRealTimers();
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("reuses the same browser across renders and closes it after the idle window", async () => {
|
||||
const { pages, browser, screenshotter } = await createScreenshotterHarness();
|
||||
|
||||
await screenshotter.screenshotHtml({
|
||||
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
|
||||
outputPath,
|
||||
theme: "dark",
|
||||
image: {
|
||||
format: "png",
|
||||
qualityPreset: "standard",
|
||||
scale: 2,
|
||||
maxWidth: 960,
|
||||
maxPixels: 8_000_000,
|
||||
},
|
||||
});
|
||||
await screenshotter.screenshotHtml({
|
||||
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
|
||||
outputPath,
|
||||
theme: "dark",
|
||||
image: {
|
||||
format: "png",
|
||||
qualityPreset: "standard",
|
||||
scale: 2,
|
||||
maxWidth: 960,
|
||||
maxPixels: 8_000_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(launchMock).toHaveBeenCalledTimes(1);
|
||||
expect(browser.newPage).toHaveBeenCalledTimes(2);
|
||||
const firstPageParams = (
|
||||
browser.newPage.mock.calls as Array<[{ deviceScaleFactor?: number }?]>
|
||||
)[0]?.[0];
|
||||
expect(firstPageParams?.deviceScaleFactor).toBe(2);
|
||||
expect(pages).toHaveLength(2);
|
||||
expect(pages[0]?.close).toHaveBeenCalledTimes(1);
|
||||
expect(pages[1]?.close).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(browser.close).toHaveBeenCalledTimes(1);
|
||||
|
||||
await screenshotter.screenshotHtml({
|
||||
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
|
||||
outputPath,
|
||||
theme: "light",
|
||||
image: {
|
||||
format: "png",
|
||||
qualityPreset: "standard",
|
||||
scale: 2,
|
||||
maxWidth: 960,
|
||||
maxPixels: 8_000_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(launchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("renders PDF output when format is pdf", async () => {
|
||||
const { pages, screenshotter } = await createScreenshotterHarness();
|
||||
const pdfPath = path.join(rootDir, "preview.pdf");
|
||||
|
||||
await screenshotter.screenshotHtml({
|
||||
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
|
||||
outputPath: pdfPath,
|
||||
theme: "light",
|
||||
image: {
|
||||
format: "pdf",
|
||||
qualityPreset: "standard",
|
||||
scale: 2,
|
||||
maxWidth: 960,
|
||||
maxPixels: 8_000_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(launchMock).toHaveBeenCalledTimes(1);
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0]?.pdf).toHaveBeenCalledTimes(1);
|
||||
const pdfCall = firstMockCall(pages[0]?.pdf, "PDF render")[0] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!pdfCall) {
|
||||
throw new Error("expected PDF render call");
|
||||
}
|
||||
expect(pdfCall).not.toHaveProperty("pageRanges");
|
||||
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
|
||||
await expect(fs.readFile(pdfPath, "utf8")).resolves.toContain("%PDF-1.7");
|
||||
});
|
||||
|
||||
it("fails fast when PDF render exceeds size limits", async () => {
|
||||
const pages: Array<{
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
screenshot: ReturnType<typeof vi.fn>;
|
||||
pdf: ReturnType<typeof vi.fn>;
|
||||
}> = [];
|
||||
const browser = createMockBrowser(pages, {
|
||||
boundingBox: { x: 40, y: 40, width: 960, height: 60_000 },
|
||||
});
|
||||
launchMock.mockResolvedValue(browser);
|
||||
const screenshotter = new PlaywrightDiffScreenshotter({
|
||||
config: createConfig(),
|
||||
browserIdleMs: 1_000,
|
||||
});
|
||||
const pdfPath = path.join(rootDir, "oversized.pdf");
|
||||
|
||||
await expect(
|
||||
screenshotter.screenshotHtml({
|
||||
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
|
||||
outputPath: pdfPath,
|
||||
theme: "light",
|
||||
image: {
|
||||
format: "pdf",
|
||||
qualityPreset: "standard",
|
||||
scale: 2,
|
||||
maxWidth: 960,
|
||||
maxPixels: 8_000_000,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Diff frame did not render within image size limits.");
|
||||
|
||||
expect(launchMock).toHaveBeenCalledTimes(1);
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0]?.pdf).toHaveBeenCalledTimes(0);
|
||||
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("fails fast when maxPixels is still exceeded at scale 1", async () => {
|
||||
const { pages, screenshotter } = await createScreenshotterHarness();
|
||||
|
||||
await expect(
|
||||
screenshotter.screenshotHtml({
|
||||
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
|
||||
outputPath,
|
||||
theme: "dark",
|
||||
image: {
|
||||
format: "png",
|
||||
qualityPreset: "standard",
|
||||
scale: 1,
|
||||
maxWidth: 960,
|
||||
maxPixels: 10,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Diff frame did not render within image size limits.");
|
||||
expect(pages).toHaveLength(1);
|
||||
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("uses live runtime tool config through the registered tool factory", async () => {
|
||||
type RegisteredTool = {
|
||||
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
type HttpRouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => boolean | Promise<boolean>;
|
||||
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
|
||||
|
||||
let registeredToolFactory:
|
||||
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
|
||||
| undefined;
|
||||
let registeredHttpRouteHandler: HttpRouteHandler | undefined;
|
||||
let configFile: OpenClawConfig = {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
config: {
|
||||
viewerBaseUrl: "https://startup.example.com/openclaw",
|
||||
defaults: {
|
||||
mode: "view",
|
||||
theme: "light",
|
||||
background: false,
|
||||
layout: "split",
|
||||
showLineNumbers: false,
|
||||
diffIndicators: "classic",
|
||||
lineSpacing: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const api = createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
pluginConfig: {
|
||||
viewerBaseUrl: "https://startup.example.com/openclaw",
|
||||
defaults: {
|
||||
mode: "view",
|
||||
theme: "light",
|
||||
background: false,
|
||||
layout: "split",
|
||||
showLineNumbers: false,
|
||||
diffIndicators: "classic",
|
||||
lineSpacing: 2,
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
config: {
|
||||
current: () => configFile,
|
||||
},
|
||||
} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
},
|
||||
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
||||
registeredHttpRouteHandler = params.handler as HttpRouteHandler;
|
||||
},
|
||||
on: vi.fn(),
|
||||
});
|
||||
|
||||
registerDiffsPlugin(api as unknown as OpenClawPluginApi);
|
||||
|
||||
configFile = {
|
||||
...configFile,
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
config: {
|
||||
viewerBaseUrl: "https://live.example.com/gateway",
|
||||
defaults: {
|
||||
mode: "view",
|
||||
theme: "dark",
|
||||
background: true,
|
||||
layout: "unified",
|
||||
showLineNumbers: true,
|
||||
diffIndicators: "bars",
|
||||
lineSpacing: 1.6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const registeredTool = registeredToolFactory?.({
|
||||
agentId: "main",
|
||||
sessionId: "session-456",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
}) as RegisteredTool | undefined;
|
||||
const result = await registeredTool?.execute?.("tool-1", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
});
|
||||
const details = (result as { details?: Record<string, unknown> } | undefined)?.details;
|
||||
const viewerPath = String(details?.viewerPath);
|
||||
const res = createMockServerResponse();
|
||||
const handled = await registeredHttpRouteHandler?.(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(String(details?.viewerUrl)).toContain("https://live.example.com/gateway");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain('body data-theme="dark"');
|
||||
expect(String(res.body)).toContain('"backgroundEnabled":true');
|
||||
expect(String(res.body)).toContain('"diffStyle":"unified"');
|
||||
expect(String(res.body)).toContain('"disableLineNumbers":false');
|
||||
expect(String(res.body)).toContain('"diffIndicators":"bars"');
|
||||
expect(String(res.body)).toContain("--diffs-line-height: 24px;");
|
||||
});
|
||||
|
||||
it("uses live runtime viewer-access config through the registered HTTP handler", async () => {
|
||||
type RegisteredTool = {
|
||||
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
type HttpRouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => boolean | Promise<boolean>;
|
||||
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
|
||||
|
||||
let registeredToolFactory:
|
||||
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
|
||||
| undefined;
|
||||
let registeredHttpRouteHandler: HttpRouteHandler | undefined;
|
||||
const on = vi.fn();
|
||||
let configFile: OpenClawConfig = {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
config: {
|
||||
security: {
|
||||
allowRemoteViewer: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const api = createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
pluginConfig: {
|
||||
defaults: {
|
||||
mode: "view",
|
||||
theme: "light",
|
||||
background: false,
|
||||
layout: "split",
|
||||
showLineNumbers: false,
|
||||
diffIndicators: "classic",
|
||||
lineSpacing: 2,
|
||||
},
|
||||
security: {
|
||||
allowRemoteViewer: true,
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
config: {
|
||||
current: () => configFile,
|
||||
},
|
||||
} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
},
|
||||
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
||||
registeredHttpRouteHandler = params.handler as HttpRouteHandler;
|
||||
},
|
||||
on,
|
||||
});
|
||||
|
||||
registerDiffsPlugin(api as unknown as OpenClawPluginApi);
|
||||
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
const [hookName, beforePromptBuild] = firstMockCall(on, "plugin hook registration");
|
||||
expect(hookName).toBe("before_prompt_build");
|
||||
if (typeof beforePromptBuild !== "function") {
|
||||
throw new Error("expected before_prompt_build callback");
|
||||
}
|
||||
const promptResult = await beforePromptBuild({}, {});
|
||||
expect(promptResult?.prependSystemContext).toBe(
|
||||
[
|
||||
"When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
|
||||
"It accepts either `before` + `after` text or a unified `patch`.",
|
||||
"`mode=view` returns `details.viewerUrl` for canvas use; `mode=file` returns `details.filePath`; `mode=both` returns both.",
|
||||
"If you need to send the rendered file, use the `message` tool with `path` or `filePath`.",
|
||||
"Include `path` when you know the filename, and omit presentation overrides unless needed.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(promptResult?.prependContext).toBeUndefined();
|
||||
|
||||
const registeredTool = registeredToolFactory?.({
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
}) as RegisteredTool | undefined;
|
||||
const result = await registeredTool?.execute?.("tool-1", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
});
|
||||
const viewerPath = String(
|
||||
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
|
||||
);
|
||||
const res = createMockServerResponse();
|
||||
const handled = await registeredHttpRouteHandler?.(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
},
|
||||
);
|
||||
|
||||
configFile = {
|
||||
...configFile,
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
config: {
|
||||
security: {
|
||||
allowRemoteViewer: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const proxiedRes = createMockServerResponse();
|
||||
const proxiedHandled = await registeredHttpRouteHandler?.(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
headers: {
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
},
|
||||
}),
|
||||
proxiedRes,
|
||||
);
|
||||
|
||||
expect(proxiedHandled).toBe(true);
|
||||
expect(proxiedRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("fails closed for remote viewer access when the live diffs plugin entry is removed", async () => {
|
||||
type RegisteredTool = {
|
||||
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
type HttpRouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => boolean | Promise<boolean>;
|
||||
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
|
||||
|
||||
let registeredToolFactory:
|
||||
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
|
||||
| undefined;
|
||||
let registeredHttpRouteHandler: HttpRouteHandler | undefined;
|
||||
let configFile: OpenClawConfig = {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
config: {
|
||||
security: {
|
||||
allowRemoteViewer: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const api = createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
pluginConfig: {
|
||||
security: {
|
||||
allowRemoteViewer: true,
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
config: {
|
||||
current: () => configFile,
|
||||
},
|
||||
} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
},
|
||||
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
||||
registeredHttpRouteHandler = params.handler as HttpRouteHandler;
|
||||
},
|
||||
on: vi.fn(),
|
||||
});
|
||||
|
||||
registerDiffsPlugin(api as unknown as OpenClawPluginApi);
|
||||
|
||||
const registeredTool = registeredToolFactory?.({
|
||||
agentId: "main",
|
||||
sessionId: "session-789",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
}) as RegisteredTool | undefined;
|
||||
const result = await registeredTool?.execute?.("tool-1", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
});
|
||||
const viewerPath = String(
|
||||
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
|
||||
);
|
||||
|
||||
configFile = {
|
||||
...configFile,
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const proxiedRes = createMockServerResponse();
|
||||
const proxiedHandled = await registeredHttpRouteHandler?.(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
headers: {
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
},
|
||||
}),
|
||||
proxiedRes,
|
||||
);
|
||||
|
||||
expect(proxiedHandled).toBe(true);
|
||||
expect(proxiedRes.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
browser: {
|
||||
executablePath: process.execPath,
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: IncomingMessage["headers"];
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
async function createScreenshotterHarness(options?: {
|
||||
boundingBox?: { x: number; y: number; width: number; height: number };
|
||||
}) {
|
||||
const pages: Array<{
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
screenshot: ReturnType<typeof vi.fn>;
|
||||
pdf: ReturnType<typeof vi.fn>;
|
||||
}> = [];
|
||||
const browser = createMockBrowser(pages, options);
|
||||
launchMock.mockResolvedValue(browser);
|
||||
const screenshotter = new PlaywrightDiffScreenshotter({
|
||||
config: createConfig(),
|
||||
browserIdleMs: 1_000,
|
||||
});
|
||||
return { pages, browser, screenshotter };
|
||||
}
|
||||
|
||||
function createMockBrowser(
|
||||
pages: Array<{
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
screenshot: ReturnType<typeof vi.fn>;
|
||||
pdf: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
options?: { boundingBox?: { x: number; y: number; width: number; height: number } },
|
||||
) {
|
||||
const browser = {
|
||||
newPage: vi.fn(async (_options?: unknown) => {
|
||||
const page = createMockPage(options);
|
||||
pages.push(page);
|
||||
return page;
|
||||
}),
|
||||
close: vi.fn(async () => {}),
|
||||
on: vi.fn(),
|
||||
};
|
||||
return browser;
|
||||
}
|
||||
|
||||
function createMockPage(options?: {
|
||||
boundingBox?: { x: number; y: number; width: number; height: number };
|
||||
}) {
|
||||
const box = options?.boundingBox ?? { x: 40, y: 40, width: 640, height: 240 };
|
||||
const screenshot = vi.fn(async ({ path: screenshotPath }: { path: string }) => {
|
||||
await fs.writeFile(screenshotPath, Buffer.from("png"));
|
||||
});
|
||||
const pdf = vi.fn(async ({ path: pdfPath }: { path: string }) => {
|
||||
await fs.writeFile(pdfPath, "%PDF-1.7 mock");
|
||||
});
|
||||
|
||||
return {
|
||||
route: vi.fn(async () => {}),
|
||||
setContent: vi.fn(async () => {}),
|
||||
waitForFunction: vi.fn(async () => {}),
|
||||
evaluate: vi.fn(async () => 1),
|
||||
emulateMedia: vi.fn(async () => {}),
|
||||
locator: vi.fn(() => ({
|
||||
waitFor: vi.fn(async () => {}),
|
||||
boundingBox: vi.fn(async () => box),
|
||||
})),
|
||||
setViewportSize: vi.fn(async () => {}),
|
||||
screenshot,
|
||||
pdf,
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
576
extensions/diffs/src/browser.ts
Normal file
576
extensions/diffs/src/browser.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
// Diffs plugin module implements browser behavior.
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { chromium } from "playwright-core";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import type { DiffRenderOptions, DiffTheme } from "./types.js";
|
||||
import {
|
||||
LANGUAGE_PACK_VIEWER_ASSET_PREFIX,
|
||||
VIEWER_ASSET_PREFIX,
|
||||
getServedLanguagePackViewerAsset,
|
||||
getServedViewerAsset,
|
||||
} from "./viewer-assets.js";
|
||||
|
||||
const DEFAULT_BROWSER_IDLE_MS = 30_000;
|
||||
const SHARED_BROWSER_KEY = "__default__";
|
||||
const IMAGE_SIZE_LIMIT_ERROR = "Diff frame did not render within image size limits.";
|
||||
const PDF_REFERENCE_PAGE_HEIGHT_PX = 1_056;
|
||||
const MAX_PDF_PAGES = 50;
|
||||
const LOCAL_VIEWER_BASE_HREF = "http://127.0.0.1/plugins/diffs/view/local/local";
|
||||
|
||||
export type DiffScreenshotter = {
|
||||
screenshotHtml(params: {
|
||||
html: string;
|
||||
outputPath: string;
|
||||
theme: DiffTheme;
|
||||
image: DiffRenderOptions["image"];
|
||||
}): Promise<string>;
|
||||
};
|
||||
|
||||
type BrowserInstance = Awaited<ReturnType<typeof chromium.launch>>;
|
||||
|
||||
type BrowserLease = {
|
||||
browser: BrowserInstance;
|
||||
release(): Promise<void>;
|
||||
};
|
||||
|
||||
type SharedBrowserState = {
|
||||
browser?: BrowserInstance;
|
||||
browserPromise: Promise<BrowserInstance>;
|
||||
idleTimer: ReturnType<typeof setTimeout> | null;
|
||||
key: string;
|
||||
users: number;
|
||||
};
|
||||
|
||||
type ExecutablePathCache = {
|
||||
key: string;
|
||||
valuePromise: Promise<string | undefined>;
|
||||
};
|
||||
|
||||
let sharedBrowserState: SharedBrowserState | null = null;
|
||||
let executablePathCache: ExecutablePathCache | null = null;
|
||||
|
||||
export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
|
||||
private readonly config: OpenClawConfig;
|
||||
private readonly browserIdleMs: number;
|
||||
|
||||
constructor(params: { config: OpenClawConfig; browserIdleMs?: number }) {
|
||||
this.config = params.config;
|
||||
this.browserIdleMs = params.browserIdleMs ?? DEFAULT_BROWSER_IDLE_MS;
|
||||
}
|
||||
|
||||
async screenshotHtml(params: {
|
||||
html: string;
|
||||
outputPath: string;
|
||||
theme: DiffTheme;
|
||||
image: DiffRenderOptions["image"];
|
||||
}): Promise<string> {
|
||||
const lease = await acquireSharedBrowser({
|
||||
config: this.config,
|
||||
idleMs: this.browserIdleMs,
|
||||
});
|
||||
let page: Awaited<ReturnType<BrowserInstance["newPage"]>> | undefined;
|
||||
let currentScale = params.image.scale;
|
||||
const maxRetries = 2;
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||
page = await lease.browser.newPage({
|
||||
viewport: {
|
||||
width: Math.max(Math.ceil(params.image.maxWidth + 240), 1200),
|
||||
height: 900,
|
||||
},
|
||||
deviceScaleFactor: currentScale,
|
||||
colorScheme: params.theme,
|
||||
});
|
||||
await page.route("**/*", async (route) => {
|
||||
const requestUrl = route.request().url();
|
||||
if (requestUrl === "about:blank" || requestUrl.startsWith("data:")) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(requestUrl);
|
||||
} catch {
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1") {
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
const isBaseViewerAsset = parsed.pathname.startsWith(VIEWER_ASSET_PREFIX);
|
||||
const isLanguagePackViewerAsset = parsed.pathname.startsWith(
|
||||
LANGUAGE_PACK_VIEWER_ASSET_PREFIX,
|
||||
);
|
||||
if (!isBaseViewerAsset && !isLanguagePackViewerAsset) {
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
const pathname = parsed.pathname;
|
||||
const asset = isLanguagePackViewerAsset
|
||||
? await getServedLanguagePackViewerAsset(pathname)
|
||||
: await getServedViewerAsset(pathname);
|
||||
if (!asset) {
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: asset.contentType,
|
||||
body: asset.body,
|
||||
});
|
||||
});
|
||||
await page.setContent(injectBaseHref(params.html), { waitUntil: "load" });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
if (document.documentElement.dataset.openclawDiffsReady === "true") {
|
||||
return true;
|
||||
}
|
||||
return [...document.querySelectorAll("[data-openclaw-diff-host]")].every((element) => {
|
||||
return (
|
||||
element instanceof HTMLElement && element.shadowRoot?.querySelector("[data-diffs]")
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
const frame = document.querySelector(".oc-frame");
|
||||
if (frame instanceof HTMLElement) {
|
||||
frame.dataset.renderMode = "image";
|
||||
}
|
||||
});
|
||||
|
||||
const frame = page.locator(".oc-frame");
|
||||
await frame.waitFor();
|
||||
const initialBox = await frame.boundingBox();
|
||||
if (!initialBox) {
|
||||
throw new Error("Diff frame did not render.");
|
||||
}
|
||||
|
||||
const isPdf = params.image.format === "pdf";
|
||||
const padding = isPdf ? 0 : 20;
|
||||
const clipWidth = Math.ceil(initialBox.width + padding * 2);
|
||||
const clipHeight = Math.ceil(Math.max(initialBox.height + padding * 2, 320));
|
||||
await page.setViewportSize({
|
||||
width: Math.max(clipWidth + padding, 900),
|
||||
height: Math.max(clipHeight + padding, 700),
|
||||
});
|
||||
|
||||
const box = await frame.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Diff frame was lost after resizing.");
|
||||
}
|
||||
|
||||
if (isPdf) {
|
||||
await page.emulateMedia({ media: "screen" });
|
||||
await page.evaluate(() => {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const frameLocal = document.querySelector(".oc-frame");
|
||||
|
||||
html.style.background = "transparent";
|
||||
body.style.margin = "0";
|
||||
body.style.padding = "0";
|
||||
body.style.background = "transparent";
|
||||
body.style.setProperty("-webkit-print-color-adjust", "exact");
|
||||
if (frameLocal instanceof HTMLElement) {
|
||||
frameLocal.style.margin = "0";
|
||||
}
|
||||
});
|
||||
|
||||
const pdfBox = await frame.boundingBox();
|
||||
if (!pdfBox) {
|
||||
throw new Error("Diff frame was lost before PDF render.");
|
||||
}
|
||||
const pdfWidth = Math.max(Math.ceil(pdfBox.width), 1);
|
||||
const pdfHeight = Math.max(Math.ceil(pdfBox.height), 1);
|
||||
const estimatedPixels = pdfWidth * pdfHeight;
|
||||
const estimatedPages = Math.ceil(pdfHeight / PDF_REFERENCE_PAGE_HEIGHT_PX);
|
||||
if (estimatedPixels > params.image.maxPixels || estimatedPages > MAX_PDF_PAGES) {
|
||||
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
|
||||
}
|
||||
|
||||
const pageForPdf = page;
|
||||
await writeExternalArtifactFile({
|
||||
outputPath: params.outputPath,
|
||||
write: async (tempPath) => {
|
||||
await pageForPdf.pdf({
|
||||
path: tempPath,
|
||||
width: `${pdfWidth}px`,
|
||||
height: `${pdfHeight}px`,
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: "0",
|
||||
right: "0",
|
||||
bottom: "0",
|
||||
left: "0",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
return params.outputPath;
|
||||
}
|
||||
|
||||
const dpr = await page.evaluate(() => window.devicePixelRatio || 1);
|
||||
|
||||
// Raw clip in CSS px
|
||||
const rawX = Math.max(box.x - padding, 0);
|
||||
const rawY = Math.max(box.y - padding, 0);
|
||||
const rawRight = rawX + clipWidth;
|
||||
const rawBottom = rawY + clipHeight;
|
||||
|
||||
// Snap to device-pixel grid to avoid soft text from sub-pixel crop
|
||||
const x = Math.floor(rawX * dpr) / dpr;
|
||||
const y = Math.floor(rawY * dpr) / dpr;
|
||||
const right = Math.ceil(rawRight * dpr) / dpr;
|
||||
const bottom = Math.ceil(rawBottom * dpr) / dpr;
|
||||
const cssWidth = Math.max(right - x, 1);
|
||||
const cssHeight = Math.max(bottom - y, 1);
|
||||
const estimatedPixels = cssWidth * cssHeight * dpr * dpr;
|
||||
|
||||
if (estimatedPixels > params.image.maxPixels) {
|
||||
if (currentScale > 1) {
|
||||
const maxScaleForPixels = Math.sqrt(params.image.maxPixels / (cssWidth * cssHeight));
|
||||
const reducedScale = Math.max(
|
||||
1,
|
||||
Math.round(Math.min(currentScale, maxScaleForPixels) * 100) / 100,
|
||||
);
|
||||
if (reducedScale < currentScale - 0.01 && attempt < maxRetries) {
|
||||
await page.close().catch(() => {});
|
||||
page = undefined;
|
||||
currentScale = reducedScale;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
|
||||
}
|
||||
|
||||
const pageForScreenshot = page;
|
||||
await writeExternalArtifactFile({
|
||||
outputPath: params.outputPath,
|
||||
write: async (tempPath) => {
|
||||
await pageForScreenshot.screenshot({
|
||||
path: tempPath,
|
||||
type: "png",
|
||||
scale: "device",
|
||||
clip: {
|
||||
x,
|
||||
y,
|
||||
width: cssWidth,
|
||||
height: cssHeight,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
return params.outputPath;
|
||||
}
|
||||
throw new Error(IMAGE_SIZE_LIMIT_ERROR);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === IMAGE_SIZE_LIMIT_ERROR) {
|
||||
throw error;
|
||||
}
|
||||
const reason = formatErrorMessage(error);
|
||||
throw new Error(
|
||||
`Diff PNG/PDF rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
await page?.close().catch(() => {});
|
||||
await lease.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExternalArtifactFile(params: {
|
||||
outputPath: string;
|
||||
write: (tempPath: string) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const rootDir = path.dirname(params.outputPath);
|
||||
await fs.mkdir(rootDir, { recursive: true });
|
||||
await writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: path.basename(params.outputPath),
|
||||
write: params.write,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetSharedBrowserStateForTests(): Promise<void> {
|
||||
executablePathCache = null;
|
||||
await closeSharedBrowser();
|
||||
}
|
||||
|
||||
function injectBaseHref(html: string): string {
|
||||
if (html.includes("<base ")) {
|
||||
return html;
|
||||
}
|
||||
return html.replace("<head>", `<head><base href="${LOCAL_VIEWER_BASE_HREF}" />`);
|
||||
}
|
||||
|
||||
async function resolveBrowserExecutablePath(config: OpenClawConfig): Promise<string | undefined> {
|
||||
const cacheKey = JSON.stringify({
|
||||
configPath: config.browser?.executablePath?.trim() || "",
|
||||
env: [
|
||||
process.env.OPENCLAW_BROWSER_EXECUTABLE_PATH ?? "",
|
||||
process.env.BROWSER_EXECUTABLE_PATH ?? "",
|
||||
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ?? "",
|
||||
],
|
||||
path: process.env.PATH ?? "",
|
||||
});
|
||||
|
||||
if (executablePathCache?.key === cacheKey) {
|
||||
return await executablePathCache.valuePromise;
|
||||
}
|
||||
|
||||
const valuePromise = resolveBrowserExecutablePathUncached(config).catch((error: unknown) => {
|
||||
if (executablePathCache?.valuePromise === valuePromise) {
|
||||
executablePathCache = null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
executablePathCache = {
|
||||
key: cacheKey,
|
||||
valuePromise,
|
||||
};
|
||||
return await valuePromise;
|
||||
}
|
||||
|
||||
async function resolveBrowserExecutablePathUncached(
|
||||
config: OpenClawConfig,
|
||||
): Promise<string | undefined> {
|
||||
const configPath = config.browser?.executablePath?.trim();
|
||||
if (configPath) {
|
||||
await assertExecutable(configPath, "browser.executablePath");
|
||||
return configPath;
|
||||
}
|
||||
|
||||
const envCandidates = [
|
||||
process.env.OPENCLAW_BROWSER_EXECUTABLE_PATH,
|
||||
process.env.BROWSER_EXECUTABLE_PATH,
|
||||
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
for (const candidate of envCandidates) {
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of await collectExecutableCandidates()) {
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function acquireSharedBrowser(params: {
|
||||
config: OpenClawConfig;
|
||||
idleMs: number;
|
||||
}): Promise<BrowserLease> {
|
||||
const executablePath = await resolveBrowserExecutablePath(params.config);
|
||||
const desiredKey = executablePath || SHARED_BROWSER_KEY;
|
||||
if (sharedBrowserState && sharedBrowserState.key !== desiredKey) {
|
||||
await closeSharedBrowser();
|
||||
}
|
||||
|
||||
if (!sharedBrowserState) {
|
||||
const browserPromise = chromium
|
||||
.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: ["--disable-dev-shm-usage"],
|
||||
})
|
||||
.then((browser) => {
|
||||
if (sharedBrowserState?.browserPromise === browserPromise) {
|
||||
sharedBrowserState.browser = browser;
|
||||
browser.on("disconnected", () => {
|
||||
if (sharedBrowserState?.browser === browser) {
|
||||
clearIdleTimer(sharedBrowserState);
|
||||
sharedBrowserState = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
return browser;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (sharedBrowserState?.browserPromise === browserPromise) {
|
||||
sharedBrowserState = null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
sharedBrowserState = {
|
||||
browserPromise,
|
||||
idleTimer: null,
|
||||
key: desiredKey,
|
||||
users: 0,
|
||||
};
|
||||
}
|
||||
|
||||
clearIdleTimer(sharedBrowserState);
|
||||
const state = sharedBrowserState;
|
||||
const browser = await state.browserPromise;
|
||||
state.users += 1;
|
||||
|
||||
let released = false;
|
||||
return {
|
||||
browser,
|
||||
release: async () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
state.users = Math.max(0, state.users - 1);
|
||||
if (state.users === 0) {
|
||||
scheduleIdleBrowserClose(state, params.idleMs);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleIdleBrowserClose(state: SharedBrowserState, idleMs: number): void {
|
||||
clearIdleTimer(state);
|
||||
state.idleTimer = setTimeout(() => {
|
||||
if (sharedBrowserState === state && state.users === 0) {
|
||||
void closeSharedBrowser();
|
||||
}
|
||||
}, idleMs);
|
||||
}
|
||||
|
||||
function clearIdleTimer(state: SharedBrowserState): void {
|
||||
if (!state.idleTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(state.idleTimer);
|
||||
state.idleTimer = null;
|
||||
}
|
||||
|
||||
async function closeSharedBrowser(): Promise<void> {
|
||||
const state = sharedBrowserState;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
sharedBrowserState = null;
|
||||
clearIdleTimer(state);
|
||||
const browser = state.browser ?? (await state.browserPromise.catch(() => null));
|
||||
await browser?.close().catch(() => {});
|
||||
}
|
||||
|
||||
async function collectExecutableCandidates(): Promise<string[]> {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
for (const command of pathCommandsForPlatform()) {
|
||||
const resolved = await findExecutableInPath(command);
|
||||
if (resolved) {
|
||||
candidates.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of commonExecutablePathsForPlatform()) {
|
||||
candidates.add(candidate);
|
||||
}
|
||||
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function pathCommandsForPlatform(): string[] {
|
||||
if (process.platform === "win32") {
|
||||
return ["chrome.exe", "msedge.exe", "brave.exe"];
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
return ["google-chrome", "chromium", "msedge", "brave-browser", "brave"];
|
||||
}
|
||||
return [
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"msedge",
|
||||
"brave-browser",
|
||||
"brave",
|
||||
];
|
||||
}
|
||||
|
||||
function commonExecutablePathsForPlatform(): string[] {
|
||||
if (process.platform === "darwin") {
|
||||
return [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
];
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? "";
|
||||
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
||||
return [
|
||||
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
path.join(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
path.join(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/msedge",
|
||||
"/usr/bin/brave-browser",
|
||||
"/snap/bin/chromium",
|
||||
];
|
||||
}
|
||||
|
||||
async function findExecutableInPath(command: string): Promise<string | undefined> {
|
||||
const pathValue = process.env.PATH;
|
||||
if (!pathValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const directory of pathValue.split(path.delimiter)) {
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.join(directory, command);
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function assertExecutable(candidate: string, label: string): Promise<void> {
|
||||
if (!(await isExecutable(candidate))) {
|
||||
throw new Error(`${label} not found or not executable: ${candidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function isExecutable(candidate: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(candidate, fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
633
extensions/diffs/src/config.test.ts
Normal file
633
extensions/diffs/src/config.test.ts
Normal file
@@ -0,0 +1,633 @@
|
||||
// Diffs tests cover config plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
validateJsonSchemaValue,
|
||||
type JsonSchemaObject,
|
||||
} from "openclaw/plugin-sdk/json-schema-runtime";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_DIFFS_PLUGIN_SECURITY,
|
||||
DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
diffsPluginConfigSchema,
|
||||
resolveDiffImageRenderOptions,
|
||||
resolveDiffsPluginDefaults,
|
||||
resolveDiffsPluginSecurity,
|
||||
resolveDiffsPluginViewerBaseUrl,
|
||||
} from "./config.js";
|
||||
import { resolveDiffsLanguagePackAvailability } from "./plugin.js";
|
||||
import { ensureCuratedViewerRuntimeForTests } from "./test-helpers.js";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
import {
|
||||
getServedLanguagePackViewerAsset,
|
||||
getServedViewerAsset,
|
||||
resolveViewerRuntimeFileUrl,
|
||||
LANGUAGE_PACK_VIEWER_LOADER_PATH,
|
||||
VIEWER_LOADER_PATH,
|
||||
VIEWER_RUNTIME_PATH,
|
||||
} from "./viewer-assets.js";
|
||||
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
||||
|
||||
const FULL_DEFAULTS = {
|
||||
fontFamily: "JetBrains Mono",
|
||||
fontSize: 17,
|
||||
lineSpacing: 1.8,
|
||||
layout: "split",
|
||||
showLineNumbers: false,
|
||||
diffIndicators: "classic",
|
||||
wordWrap: false,
|
||||
background: false,
|
||||
theme: "light",
|
||||
fileFormat: "pdf",
|
||||
fileQuality: "hq",
|
||||
fileScale: 2.6,
|
||||
fileMaxWidth: 1280,
|
||||
mode: "file",
|
||||
ttlSeconds: 21_600,
|
||||
} as const;
|
||||
|
||||
beforeAll(async () => {
|
||||
await ensureCuratedViewerRuntimeForTests();
|
||||
});
|
||||
|
||||
function compileManifestConfigSchema() {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
) as { configSchema: JsonSchemaObject };
|
||||
return (value: unknown) =>
|
||||
validateJsonSchemaValue({
|
||||
cacheKey: "diffs.manifest.config.test",
|
||||
schema: manifest.configSchema,
|
||||
value,
|
||||
applyDefaults: true,
|
||||
}).ok;
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectFields(value: unknown, fields: Record<string, unknown>) {
|
||||
const record = requireRecord(value, "record");
|
||||
for (const [key, expected] of Object.entries(fields)) {
|
||||
expect(record[key]).toEqual(expected);
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveDiffsPluginDefaults", () => {
|
||||
it("returns built-in defaults when config is missing", () => {
|
||||
expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS);
|
||||
});
|
||||
|
||||
it("applies configured defaults from plugin config", () => {
|
||||
expect(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: FULL_DEFAULTS,
|
||||
}),
|
||||
).toEqual(FULL_DEFAULTS);
|
||||
});
|
||||
|
||||
it("clamps and falls back for invalid line spacing and indicators", () => {
|
||||
expectFields(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
lineSpacing: -5,
|
||||
diffIndicators: "unknown",
|
||||
},
|
||||
}),
|
||||
{
|
||||
lineSpacing: 1,
|
||||
diffIndicators: "bars",
|
||||
},
|
||||
);
|
||||
|
||||
expectFields(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
lineSpacing: 9,
|
||||
},
|
||||
}),
|
||||
{
|
||||
lineSpacing: 3,
|
||||
},
|
||||
);
|
||||
|
||||
expectFields(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
lineSpacing: Number.NaN,
|
||||
},
|
||||
}),
|
||||
{
|
||||
lineSpacing: DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("derives file defaults from quality preset and clamps explicit overrides", () => {
|
||||
expectFields(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
fileQuality: "print",
|
||||
},
|
||||
}),
|
||||
{
|
||||
fileQuality: "print",
|
||||
fileScale: 3,
|
||||
fileMaxWidth: 1400,
|
||||
},
|
||||
);
|
||||
|
||||
expectFields(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
fileQuality: "hq",
|
||||
fileScale: 99,
|
||||
fileMaxWidth: 99999,
|
||||
},
|
||||
}),
|
||||
{
|
||||
fileQuality: "hq",
|
||||
fileScale: 4,
|
||||
fileMaxWidth: 2400,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to png for invalid file format defaults", () => {
|
||||
expectFields(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
fileFormat: "invalid" as "png",
|
||||
},
|
||||
}),
|
||||
{
|
||||
fileFormat: "png",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves file render format from defaults and explicit overrides", () => {
|
||||
const defaults = resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
fileFormat: "pdf",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveDiffImageRenderOptions({ defaults }).format).toBe("pdf");
|
||||
expect(resolveDiffImageRenderOptions({ defaults, fileFormat: "png" }).format).toBe("png");
|
||||
expect(resolveDiffImageRenderOptions({ defaults, format: "png" }).format).toBe("png");
|
||||
});
|
||||
|
||||
it("accepts format as a config alias for fileFormat", () => {
|
||||
expectFields(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
format: "pdf",
|
||||
},
|
||||
}),
|
||||
{
|
||||
fileFormat: "pdf",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts image* config aliases for backward compatibility", () => {
|
||||
expectFields(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
imageFormat: "pdf",
|
||||
imageQuality: "hq",
|
||||
imageScale: 2.2,
|
||||
imageMaxWidth: 1024,
|
||||
},
|
||||
}),
|
||||
{
|
||||
fileFormat: "pdf",
|
||||
fileQuality: "hq",
|
||||
fileScale: 2.2,
|
||||
fileMaxWidth: 1024,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts plugin-wide artifact TTL defaults", () => {
|
||||
expectFields(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
ttlSeconds: 21_600,
|
||||
},
|
||||
}),
|
||||
{
|
||||
ttlSeconds: 21_600,
|
||||
},
|
||||
);
|
||||
|
||||
expectFields(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
ttlSeconds: 99_999,
|
||||
},
|
||||
}),
|
||||
{
|
||||
ttlSeconds: 21_600,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps loader-applied schema defaults from shadowing aliases and quality-derived defaults", () => {
|
||||
const validate = compileManifestConfigSchema();
|
||||
|
||||
const aliasOnly = {
|
||||
defaults: {
|
||||
format: "pdf",
|
||||
imageQuality: "hq",
|
||||
},
|
||||
};
|
||||
expect(validate(aliasOnly)).toBe(true);
|
||||
expectFields(resolveDiffsPluginDefaults(aliasOnly), {
|
||||
fileFormat: "pdf",
|
||||
fileQuality: "hq",
|
||||
fileScale: 2.5,
|
||||
fileMaxWidth: 1200,
|
||||
});
|
||||
|
||||
const qualityOnly = {
|
||||
defaults: {
|
||||
fileQuality: "hq",
|
||||
},
|
||||
};
|
||||
expect(validate(qualityOnly)).toBe(true);
|
||||
expectFields(resolveDiffsPluginDefaults(qualityOnly), {
|
||||
fileQuality: "hq",
|
||||
fileScale: 2.5,
|
||||
fileMaxWidth: 1200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiffsPluginSecurity", () => {
|
||||
it("defaults to local-only viewer access", () => {
|
||||
expect(resolveDiffsPluginSecurity(undefined)).toEqual(DEFAULT_DIFFS_PLUGIN_SECURITY);
|
||||
});
|
||||
|
||||
it("allows opt-in remote viewer access", () => {
|
||||
expect(resolveDiffsPluginSecurity({ security: { allowRemoteViewer: true } })).toEqual({
|
||||
allowRemoteViewer: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiffsPluginViewerBaseUrl", () => {
|
||||
it("defaults to undefined when config is missing", () => {
|
||||
expect(resolveDiffsPluginViewerBaseUrl(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes configured viewer base URLs", () => {
|
||||
expect(
|
||||
resolveDiffsPluginViewerBaseUrl({
|
||||
viewerBaseUrl: "https://example.com/openclaw/",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffs plugin schema surfaces", () => {
|
||||
it("rejects invalid viewerBaseUrl values at manifest-validation time too", () => {
|
||||
const validate = compileManifestConfigSchema();
|
||||
|
||||
expect(validate({ viewerBaseUrl: "javascript:alert(1)" })).toBe(false);
|
||||
expect(validate({ viewerBaseUrl: "https://example.com/openclaw?x=1" })).toBe(false);
|
||||
expect(validate({ viewerBaseUrl: "https://example.com/openclaw#frag" })).toBe(false);
|
||||
expect(validate({ viewerBaseUrl: "https://example.com/openclaw/" })).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves defaults and security for direct safeParse callers", () => {
|
||||
const parsed = requireRecord(
|
||||
diffsPluginConfigSchema.safeParse?.({
|
||||
viewerBaseUrl: "https://example.com/openclaw/",
|
||||
defaults: {
|
||||
theme: "light",
|
||||
ttlSeconds: 21_600,
|
||||
},
|
||||
security: {
|
||||
allowRemoteViewer: true,
|
||||
},
|
||||
}),
|
||||
"parse result",
|
||||
);
|
||||
expect(parsed.success).toBe(true);
|
||||
const data = requireRecord(parsed.data, "parse data");
|
||||
expect(data.viewerBaseUrl).toBe("https://example.com/openclaw");
|
||||
expectFields(data.defaults, {
|
||||
fontFamily: "Fira Code",
|
||||
fontSize: 15,
|
||||
lineSpacing: 1.6,
|
||||
layout: "unified",
|
||||
showLineNumbers: true,
|
||||
diffIndicators: "bars",
|
||||
wordWrap: true,
|
||||
background: true,
|
||||
theme: "light",
|
||||
fileFormat: "png",
|
||||
fileQuality: "standard",
|
||||
fileScale: 2,
|
||||
fileMaxWidth: 960,
|
||||
mode: "both",
|
||||
ttlSeconds: 21_600,
|
||||
});
|
||||
expectFields(data.security, { allowRemoteViewer: true });
|
||||
});
|
||||
|
||||
it("canonicalizes alias-driven defaults for direct safeParse callers", () => {
|
||||
const parsed = requireRecord(
|
||||
diffsPluginConfigSchema.safeParse?.({
|
||||
defaults: {
|
||||
format: "pdf",
|
||||
imageQuality: "hq",
|
||||
},
|
||||
}),
|
||||
"parse result",
|
||||
);
|
||||
expect(parsed.success).toBe(true);
|
||||
const data = requireRecord(parsed.data, "parse data");
|
||||
expectFields(data.defaults, {
|
||||
fileFormat: "pdf",
|
||||
fileQuality: "hq",
|
||||
fileScale: 2.5,
|
||||
fileMaxWidth: 1200,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid viewerBaseUrl config values", () => {
|
||||
const parsed = requireRecord(
|
||||
diffsPluginConfigSchema.safeParse?.({
|
||||
viewerBaseUrl: "javascript:alert(1)",
|
||||
}),
|
||||
"parse result",
|
||||
);
|
||||
expect(parsed.success).toBe(false);
|
||||
const error = requireRecord(parsed.error, "parse error");
|
||||
const issues = error.issues as Array<{ path?: unknown; message?: unknown }>;
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.path).toEqual(["viewerBaseUrl"]);
|
||||
expect(issues[0]?.message).toBe("viewerBaseUrl must use http or https: javascript:alert(1)");
|
||||
});
|
||||
|
||||
it("keeps the runtime json schema in sync with the manifest config schema", () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
) as { configSchema?: unknown };
|
||||
|
||||
expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffs viewer URL helpers", () => {
|
||||
it("defaults to loopback for lan/tailnet bind modes", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "lan", port: 18789 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
|
||||
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "tailnet", port: 24444 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("uses custom bind host when provided", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: { enabled: true },
|
||||
},
|
||||
},
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("joins viewer path under baseUrl pathname", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {},
|
||||
baseUrl: "https://example.com/openclaw",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("prefers normalized viewerBaseUrl strings too", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {},
|
||||
baseUrl: "https://example.com/openclaw/",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("rejects base URLs with query/hash", () => {
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the configured field name in viewerBaseUrl validation errors", () => {
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com?a=1", "viewerBaseUrl")).toThrow(
|
||||
"viewerBaseUrl must not include query/hash",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewer assets", () => {
|
||||
it("prefers the built plugin asset layout when present", async () => {
|
||||
const repoRoot = join(process.cwd(), "tmp", "diffs-viewer-assets-test-repo");
|
||||
const builtRuntimePath = join(
|
||||
repoRoot,
|
||||
"dist",
|
||||
"extensions",
|
||||
"diffs",
|
||||
"assets",
|
||||
"viewer-runtime.js",
|
||||
);
|
||||
const stat = vi.fn(async (path: string) => {
|
||||
if (path === builtRuntimePath) {
|
||||
return { mtimeMs: 1 };
|
||||
}
|
||||
const error = Object.assign(new Error(`missing: ${path}`), { code: "ENOENT" });
|
||||
throw error;
|
||||
});
|
||||
|
||||
const runtimeUrl = await resolveViewerRuntimeFileUrl({
|
||||
baseUrl: pathToFileURL(join(repoRoot, "dist", "extensions", "diffs", "index.js")),
|
||||
stat,
|
||||
});
|
||||
|
||||
expect(fileURLToPath(runtimeUrl)).toBe(builtRuntimePath);
|
||||
expect(stat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the source asset layout when the built artifact is absent", async () => {
|
||||
const repoRoot = join(process.cwd(), "tmp", "diffs-viewer-assets-test-repo");
|
||||
const sourceCandidatePath = join(
|
||||
repoRoot,
|
||||
"extensions",
|
||||
"diffs",
|
||||
"src",
|
||||
"assets",
|
||||
"viewer-runtime.js",
|
||||
);
|
||||
const sourceRuntimePath = join(repoRoot, "extensions", "diffs", "assets", "viewer-runtime.js");
|
||||
const stat = vi.fn(async (path: string) => {
|
||||
if (path === sourceRuntimePath) {
|
||||
return { mtimeMs: 1 };
|
||||
}
|
||||
const error = Object.assign(new Error(`missing: ${path}`), { code: "ENOENT" });
|
||||
throw error;
|
||||
});
|
||||
|
||||
const runtimeUrl = await resolveViewerRuntimeFileUrl({
|
||||
baseUrl: pathToFileURL(join(repoRoot, "extensions", "diffs", "src", "viewer-assets.js")),
|
||||
stat,
|
||||
});
|
||||
|
||||
expect(fileURLToPath(runtimeUrl)).toBe(sourceRuntimePath);
|
||||
expect(stat).toHaveBeenNthCalledWith(1, sourceCandidatePath);
|
||||
expect(stat).toHaveBeenNthCalledWith(2, sourceRuntimePath);
|
||||
});
|
||||
|
||||
it("serves a stable loader that points at the current runtime bundle", async () => {
|
||||
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
|
||||
|
||||
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(loader?.body)).toContain(`./viewer-runtime.js?v=`);
|
||||
});
|
||||
|
||||
it("serves the runtime bundle body", async () => {
|
||||
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
|
||||
|
||||
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(runtime?.body)).toContain("openclawDiffsReady");
|
||||
expect(String(runtime?.body)).toContain('style.width="24px"');
|
||||
expect(String(runtime?.body)).toContain('style.gap="6px"');
|
||||
});
|
||||
|
||||
it("serves the optional language-pack loader only when its generated runtime is present", async () => {
|
||||
const loader = await getServedLanguagePackViewerAsset(LANGUAGE_PACK_VIEWER_LOADER_PATH);
|
||||
|
||||
if (!loader) {
|
||||
expect(loader).toBeNull();
|
||||
return;
|
||||
}
|
||||
expect(loader.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(loader.body)).toContain(`./viewer-runtime.js?v=`);
|
||||
});
|
||||
|
||||
it("returns null for unknown asset paths", async () => {
|
||||
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiffsLanguagePackAvailability", () => {
|
||||
it.each(["assets", "dist/assets"])(
|
||||
"requires both the sibling language-pack manifest and generated runtime asset in %s",
|
||||
(assetDir) => {
|
||||
const root = fs.mkdtempSync(join(os.tmpdir(), "openclaw-diffs-language-pack-"));
|
||||
try {
|
||||
const diffsRoot = join(root, "diffs");
|
||||
const languagePackRoot = join(root, "diffs-language-pack");
|
||||
fs.mkdirSync(diffsRoot, { recursive: true });
|
||||
fs.mkdirSync(languagePackRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
join(languagePackRoot, "openclaw.plugin.json"),
|
||||
'{"id":"diffs-language-pack"}\n',
|
||||
);
|
||||
const api = {
|
||||
rootDir: diffsRoot,
|
||||
config: { plugins: {} },
|
||||
runtime: { config: { current: () => ({ plugins: {} }) } },
|
||||
} as Parameters<typeof resolveDiffsLanguagePackAvailability>[0];
|
||||
|
||||
expect(resolveDiffsLanguagePackAvailability(api)).toBe(false);
|
||||
|
||||
fs.mkdirSync(join(languagePackRoot, assetDir), { recursive: true });
|
||||
fs.writeFileSync(join(languagePackRoot, assetDir, "viewer-runtime.js"), "export {};\n");
|
||||
|
||||
expect(resolveDiffsLanguagePackAvailability(api)).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("parseViewerPayloadJson", () => {
|
||||
function buildValidPayload(): Record<string, unknown> {
|
||||
return {
|
||||
prerenderedHTML: "<div>ok</div>",
|
||||
langs: ["text"],
|
||||
oldFile: {
|
||||
name: "README.md",
|
||||
contents: "before",
|
||||
},
|
||||
newFile: {
|
||||
name: "README.md",
|
||||
contents: "after",
|
||||
},
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: ":host{}",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("accepts valid payload JSON", () => {
|
||||
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
|
||||
expect(parsed.options.diffStyle).toBe("unified");
|
||||
expect(parsed.options.diffIndicators).toBe("bars");
|
||||
});
|
||||
|
||||
it("rejects payloads with invalid shape", () => {
|
||||
const broken = buildValidPayload();
|
||||
broken.options = {
|
||||
...(broken.options as Record<string, unknown>),
|
||||
diffIndicators: "invalid",
|
||||
};
|
||||
|
||||
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
|
||||
"Diff payload has invalid shape.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
|
||||
});
|
||||
});
|
||||
444
extensions/diffs/src/config.ts
Normal file
444
extensions/diffs/src/config.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
// Diffs helper module supports config behavior.
|
||||
import { mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { z } from "zod";
|
||||
import type { OpenClawPluginConfigSchema } from "../api.js";
|
||||
import {
|
||||
DIFF_IMAGE_QUALITY_PRESETS,
|
||||
DIFF_INDICATORS,
|
||||
DIFF_LAYOUTS,
|
||||
DIFF_MODES,
|
||||
DIFF_OUTPUT_FORMATS,
|
||||
DIFF_THEMES,
|
||||
type DiffFileDefaults,
|
||||
type DiffImageQualityPreset,
|
||||
type DiffIndicators,
|
||||
type DiffLayout,
|
||||
type DiffMode,
|
||||
type DiffOutputFormat,
|
||||
type DiffTheme,
|
||||
type DiffToolDefaults,
|
||||
} from "./types.js";
|
||||
import { normalizeViewerBaseUrl } from "./url.js";
|
||||
|
||||
type DiffsPluginConfig = {
|
||||
viewerBaseUrl?: string;
|
||||
defaults?: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
lineSpacing?: number;
|
||||
layout?: DiffLayout;
|
||||
showLineNumbers?: boolean;
|
||||
diffIndicators?: DiffIndicators;
|
||||
wordWrap?: boolean;
|
||||
background?: boolean;
|
||||
theme?: DiffTheme;
|
||||
fileFormat?: DiffOutputFormat;
|
||||
fileQuality?: DiffImageQualityPreset;
|
||||
fileScale?: number;
|
||||
fileMaxWidth?: number;
|
||||
/** @deprecated Use fileFormat. */
|
||||
format?: DiffOutputFormat;
|
||||
/** @deprecated Use fileFormat. */
|
||||
imageFormat?: DiffOutputFormat;
|
||||
/** @deprecated Use fileQuality. */
|
||||
imageQuality?: DiffImageQualityPreset;
|
||||
/** @deprecated Use fileScale. */
|
||||
imageScale?: number;
|
||||
/** @deprecated Use fileMaxWidth. */
|
||||
imageMaxWidth?: number;
|
||||
mode?: DiffMode;
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
security?: {
|
||||
allowRemoteViewer?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_QUALITY_PROFILES = {
|
||||
standard: {
|
||||
scale: 2,
|
||||
maxWidth: 960,
|
||||
maxPixels: 8_000_000,
|
||||
},
|
||||
hq: {
|
||||
scale: 2.5,
|
||||
maxWidth: 1200,
|
||||
maxPixels: 14_000_000,
|
||||
},
|
||||
print: {
|
||||
scale: 3,
|
||||
maxWidth: 1400,
|
||||
maxPixels: 24_000_000,
|
||||
},
|
||||
} as const satisfies Record<
|
||||
DiffImageQualityPreset,
|
||||
{ scale: number; maxWidth: number; maxPixels: number }
|
||||
>;
|
||||
|
||||
export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
|
||||
fontFamily: "Fira Code",
|
||||
fontSize: 15,
|
||||
lineSpacing: 1.6,
|
||||
layout: "unified",
|
||||
showLineNumbers: true,
|
||||
diffIndicators: "bars",
|
||||
wordWrap: true,
|
||||
background: true,
|
||||
theme: "dark",
|
||||
fileFormat: "png",
|
||||
fileQuality: "standard",
|
||||
fileScale: DEFAULT_IMAGE_QUALITY_PROFILES.standard.scale,
|
||||
fileMaxWidth: DEFAULT_IMAGE_QUALITY_PROFILES.standard.maxWidth,
|
||||
mode: "both",
|
||||
ttlSeconds: 1800,
|
||||
};
|
||||
|
||||
type DiffsPluginSecurityConfig = {
|
||||
allowRemoteViewer: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_DIFFS_PLUGIN_SECURITY: DiffsPluginSecurityConfig = {
|
||||
allowRemoteViewer: false,
|
||||
};
|
||||
|
||||
const VIEWER_BASE_URL_JSON_SCHEMA = {
|
||||
type: "string",
|
||||
format: "uri",
|
||||
pattern: "^[Hh][Tt][Tt][Pp][Ss]?://",
|
||||
not: {
|
||||
pattern: "[?#]",
|
||||
},
|
||||
} as const satisfies Record<string, unknown>;
|
||||
|
||||
const DiffsPluginJsonSchemaSource = z.strictObject({
|
||||
viewerBaseUrl: z
|
||||
.string()
|
||||
.superRefine((value, ctx) => {
|
||||
try {
|
||||
normalizeViewerBaseUrl(value, "viewerBaseUrl");
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: error instanceof Error ? error.message : "Invalid viewerBaseUrl",
|
||||
});
|
||||
}
|
||||
})
|
||||
.optional(),
|
||||
defaults: z
|
||||
.strictObject({
|
||||
fontFamily: z.string().default(DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily).optional(),
|
||||
fontSize: z.number().min(10).max(24).default(DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize).optional(),
|
||||
lineSpacing: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(3)
|
||||
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing)
|
||||
.optional(),
|
||||
layout: z.enum(DIFF_LAYOUTS).default(DEFAULT_DIFFS_TOOL_DEFAULTS.layout).optional(),
|
||||
showLineNumbers: z.boolean().default(DEFAULT_DIFFS_TOOL_DEFAULTS.showLineNumbers).optional(),
|
||||
diffIndicators: z
|
||||
.enum(DIFF_INDICATORS)
|
||||
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.diffIndicators)
|
||||
.optional(),
|
||||
wordWrap: z.boolean().default(DEFAULT_DIFFS_TOOL_DEFAULTS.wordWrap).optional(),
|
||||
background: z.boolean().default(DEFAULT_DIFFS_TOOL_DEFAULTS.background).optional(),
|
||||
theme: z.enum(DIFF_THEMES).default(DEFAULT_DIFFS_TOOL_DEFAULTS.theme).optional(),
|
||||
fileFormat: z
|
||||
.enum(DIFF_OUTPUT_FORMATS)
|
||||
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat)
|
||||
.optional(),
|
||||
format: z.enum(DIFF_OUTPUT_FORMATS).optional().describe("Deprecated alias for fileFormat."),
|
||||
fileQuality: z
|
||||
.enum(DIFF_IMAGE_QUALITY_PRESETS)
|
||||
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality)
|
||||
.optional(),
|
||||
fileScale: z.number().min(1).max(4).optional(),
|
||||
fileMaxWidth: z.number().min(640).max(2400).optional(),
|
||||
imageFormat: z
|
||||
.enum(DIFF_OUTPUT_FORMATS)
|
||||
.optional()
|
||||
.describe("Deprecated alias for fileFormat."),
|
||||
imageQuality: z
|
||||
.enum(DIFF_IMAGE_QUALITY_PRESETS)
|
||||
.optional()
|
||||
.describe("Deprecated alias for fileQuality."),
|
||||
imageScale: z.number().min(1).max(4).optional().describe("Deprecated alias for fileScale."),
|
||||
imageMaxWidth: z
|
||||
.number()
|
||||
.min(640)
|
||||
.max(2400)
|
||||
.optional()
|
||||
.describe("Deprecated alias for fileMaxWidth."),
|
||||
mode: z.enum(DIFF_MODES).default(DEFAULT_DIFFS_TOOL_DEFAULTS.mode).optional(),
|
||||
ttlSeconds: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(21_600)
|
||||
.default(DEFAULT_DIFFS_TOOL_DEFAULTS.ttlSeconds)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
security: z
|
||||
.strictObject({
|
||||
allowRemoteViewer: z
|
||||
.boolean()
|
||||
.default(DEFAULT_DIFFS_PLUGIN_SECURITY.allowRemoteViewer)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const diffsPluginConfigSchemaBase = buildPluginConfigSchema(DiffsPluginJsonSchemaSource, {
|
||||
safeParse(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
const result = DiffsPluginJsonSchemaSource.safeParse(value);
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: buildDiffsPluginConfigShape(result.data as DiffsPluginConfig),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
issues: mapPluginConfigIssues(result.error.issues),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const diffsPluginConfigSchema: OpenClawPluginConfigSchema = {
|
||||
...diffsPluginConfigSchemaBase,
|
||||
jsonSchema: {
|
||||
...diffsPluginConfigSchemaBase.jsonSchema,
|
||||
properties: {
|
||||
...(diffsPluginConfigSchemaBase.jsonSchema as { properties?: Record<string, unknown> })
|
||||
.properties,
|
||||
viewerBaseUrl: VIEWER_BASE_URL_JSON_SCHEMA,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function resolveConfiguredValue<T>(options: {
|
||||
primary: T | undefined;
|
||||
aliases: Array<T | undefined>;
|
||||
schemaDefault?: T;
|
||||
}): T | undefined {
|
||||
const alias = options.aliases.find((value): value is T => value !== undefined);
|
||||
if (alias !== undefined && options.primary === options.schemaDefault) {
|
||||
return alias;
|
||||
}
|
||||
return options.primary ?? alias;
|
||||
}
|
||||
|
||||
function buildDiffsPluginConfigShape(config: DiffsPluginConfig): DiffsPluginConfig {
|
||||
const viewerBaseUrl = resolveDiffsPluginViewerBaseUrl(config);
|
||||
return {
|
||||
...(viewerBaseUrl !== undefined ? { viewerBaseUrl } : {}),
|
||||
...(config.defaults !== undefined ? { defaults: resolveDiffsPluginDefaults(config) } : {}),
|
||||
...(config.security !== undefined ? { security: resolveDiffsPluginSecurity(config) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return { ...DEFAULT_DIFFS_TOOL_DEFAULTS };
|
||||
}
|
||||
|
||||
const defaults = (config as DiffsPluginConfig).defaults;
|
||||
if (!defaults || typeof defaults !== "object" || Array.isArray(defaults)) {
|
||||
return { ...DEFAULT_DIFFS_TOOL_DEFAULTS };
|
||||
}
|
||||
|
||||
const fileQuality = normalizeFileQuality(
|
||||
resolveConfiguredValue({
|
||||
primary: defaults.fileQuality,
|
||||
aliases: [defaults.imageQuality],
|
||||
schemaDefault: DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality,
|
||||
}),
|
||||
);
|
||||
const profile = DEFAULT_IMAGE_QUALITY_PROFILES[fileQuality];
|
||||
const fileFormat = resolveConfiguredValue({
|
||||
primary: defaults.fileFormat,
|
||||
aliases: [defaults.imageFormat, defaults.format],
|
||||
schemaDefault: DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat,
|
||||
});
|
||||
const fileScale = resolveConfiguredValue({
|
||||
primary: defaults.fileScale,
|
||||
aliases: [defaults.imageScale],
|
||||
});
|
||||
const fileMaxWidth = resolveConfiguredValue({
|
||||
primary: defaults.fileMaxWidth,
|
||||
aliases: [defaults.imageMaxWidth],
|
||||
});
|
||||
|
||||
return {
|
||||
fontFamily: normalizeFontFamily(defaults.fontFamily),
|
||||
fontSize: normalizeDiffFontSize(defaults.fontSize),
|
||||
lineSpacing: normalizeDiffLineSpacing(defaults.lineSpacing),
|
||||
layout: normalizeLayout(defaults.layout),
|
||||
showLineNumbers: defaults.showLineNumbers !== false,
|
||||
diffIndicators: normalizeDiffIndicators(defaults.diffIndicators),
|
||||
wordWrap: defaults.wordWrap !== false,
|
||||
background: defaults.background !== false,
|
||||
theme: normalizeTheme(defaults.theme),
|
||||
fileFormat: normalizeFileFormat(fileFormat),
|
||||
fileQuality,
|
||||
fileScale: normalizeFileScale(fileScale, profile.scale),
|
||||
fileMaxWidth: normalizeFileMaxWidth(fileMaxWidth, profile.maxWidth),
|
||||
mode: normalizeMode(defaults.mode),
|
||||
ttlSeconds: normalizeTtlSeconds(defaults.ttlSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDiffsPluginSecurity(config: unknown): DiffsPluginSecurityConfig {
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return { ...DEFAULT_DIFFS_PLUGIN_SECURITY };
|
||||
}
|
||||
|
||||
const security = (config as DiffsPluginConfig).security;
|
||||
if (!security || typeof security !== "object" || Array.isArray(security)) {
|
||||
return { ...DEFAULT_DIFFS_PLUGIN_SECURITY };
|
||||
}
|
||||
|
||||
return {
|
||||
allowRemoteViewer: security.allowRemoteViewer === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDiffsPluginViewerBaseUrl(config: unknown): string | undefined {
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const viewerBaseUrl = (config as DiffsPluginConfig).viewerBaseUrl;
|
||||
if (typeof viewerBaseUrl !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = viewerBaseUrl.trim();
|
||||
return normalized ? normalizeViewerBaseUrl(normalized) : undefined;
|
||||
}
|
||||
|
||||
function normalizeFontFamily(fontFamily?: string): string {
|
||||
const normalized = fontFamily?.trim();
|
||||
return normalized || DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily;
|
||||
}
|
||||
|
||||
export function normalizeDiffFontSize(fontSize?: number): number {
|
||||
if (fontSize === undefined || !Number.isFinite(fontSize)) {
|
||||
return DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize;
|
||||
}
|
||||
const rounded = Math.floor(fontSize);
|
||||
return Math.min(Math.max(rounded, 10), 24);
|
||||
}
|
||||
|
||||
export function normalizeDiffLineSpacing(lineSpacing?: number): number {
|
||||
if (lineSpacing === undefined || !Number.isFinite(lineSpacing)) {
|
||||
return DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing;
|
||||
}
|
||||
return Math.min(Math.max(lineSpacing, 1), 3);
|
||||
}
|
||||
|
||||
function normalizeLayout(layout?: DiffLayout): DiffLayout {
|
||||
return layout && DIFF_LAYOUTS.includes(layout) ? layout : DEFAULT_DIFFS_TOOL_DEFAULTS.layout;
|
||||
}
|
||||
|
||||
function normalizeDiffIndicators(diffIndicators?: DiffIndicators): DiffIndicators {
|
||||
return diffIndicators && DIFF_INDICATORS.includes(diffIndicators)
|
||||
? diffIndicators
|
||||
: DEFAULT_DIFFS_TOOL_DEFAULTS.diffIndicators;
|
||||
}
|
||||
|
||||
function normalizeTheme(theme?: DiffTheme): DiffTheme {
|
||||
return theme && DIFF_THEMES.includes(theme) ? theme : DEFAULT_DIFFS_TOOL_DEFAULTS.theme;
|
||||
}
|
||||
|
||||
function normalizeFileFormat(fileFormat?: DiffOutputFormat): DiffOutputFormat {
|
||||
return fileFormat && DIFF_OUTPUT_FORMATS.includes(fileFormat)
|
||||
? fileFormat
|
||||
: DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat;
|
||||
}
|
||||
|
||||
function normalizeFileQuality(fileQuality?: DiffImageQualityPreset): DiffImageQualityPreset {
|
||||
return fileQuality && DIFF_IMAGE_QUALITY_PRESETS.includes(fileQuality)
|
||||
? fileQuality
|
||||
: DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality;
|
||||
}
|
||||
|
||||
function normalizeFileScale(fileScale: number | undefined, fallback: number): number {
|
||||
if (fileScale === undefined || !Number.isFinite(fileScale)) {
|
||||
return fallback;
|
||||
}
|
||||
const rounded = Math.round(fileScale * 100) / 100;
|
||||
return Math.min(Math.max(rounded, 1), 4);
|
||||
}
|
||||
|
||||
function normalizeFileMaxWidth(fileMaxWidth: number | undefined, fallback: number): number {
|
||||
if (fileMaxWidth === undefined || !Number.isFinite(fileMaxWidth)) {
|
||||
return fallback;
|
||||
}
|
||||
const rounded = Math.round(fileMaxWidth);
|
||||
return Math.min(Math.max(rounded, 640), 2400);
|
||||
}
|
||||
|
||||
function normalizeMode(mode?: DiffMode): DiffMode {
|
||||
return mode && DIFF_MODES.includes(mode) ? mode : DEFAULT_DIFFS_TOOL_DEFAULTS.mode;
|
||||
}
|
||||
|
||||
function normalizeTtlSeconds(ttlSeconds?: number): number {
|
||||
if (ttlSeconds === undefined || !Number.isFinite(ttlSeconds)) {
|
||||
return DEFAULT_DIFFS_TOOL_DEFAULTS.ttlSeconds;
|
||||
}
|
||||
const rounded = Math.floor(ttlSeconds);
|
||||
return Math.min(Math.max(rounded, 1), 21_600);
|
||||
}
|
||||
|
||||
export function resolveDiffImageRenderOptions(params: {
|
||||
defaults: DiffFileDefaults;
|
||||
fileFormat?: DiffOutputFormat;
|
||||
format?: DiffOutputFormat;
|
||||
fileQuality?: DiffImageQualityPreset;
|
||||
fileScale?: number;
|
||||
fileMaxWidth?: number;
|
||||
imageFormat?: DiffOutputFormat;
|
||||
imageQuality?: DiffImageQualityPreset;
|
||||
imageScale?: number;
|
||||
imageMaxWidth?: number;
|
||||
}): {
|
||||
format: DiffOutputFormat;
|
||||
qualityPreset: DiffImageQualityPreset;
|
||||
scale: number;
|
||||
maxWidth: number;
|
||||
maxPixels: number;
|
||||
} {
|
||||
const format = normalizeFileFormat(
|
||||
params.fileFormat ?? params.imageFormat ?? params.format ?? params.defaults.fileFormat,
|
||||
);
|
||||
const qualityOverrideProvided =
|
||||
params.fileQuality !== undefined || params.imageQuality !== undefined;
|
||||
const qualityPreset = normalizeFileQuality(
|
||||
params.fileQuality ?? params.imageQuality ?? params.defaults.fileQuality,
|
||||
);
|
||||
const profile = DEFAULT_IMAGE_QUALITY_PROFILES[qualityPreset];
|
||||
|
||||
const scale = normalizeFileScale(
|
||||
params.fileScale ?? params.imageScale,
|
||||
qualityOverrideProvided ? profile.scale : params.defaults.fileScale,
|
||||
);
|
||||
const maxWidth = normalizeFileMaxWidth(
|
||||
params.fileMaxWidth ?? params.imageMaxWidth,
|
||||
qualityOverrideProvided ? profile.maxWidth : params.defaults.fileMaxWidth,
|
||||
);
|
||||
|
||||
return {
|
||||
format,
|
||||
qualityPreset,
|
||||
scale,
|
||||
maxWidth,
|
||||
maxPixels: profile.maxPixels,
|
||||
};
|
||||
}
|
||||
325
extensions/diffs/src/http.ts
Normal file
325
extensions/diffs/src/http.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
// Diffs plugin module implements http behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { PluginLogger } from "../api.js";
|
||||
import { resolveRequestClientIp } from "../runtime-api.js";
|
||||
import type { DiffArtifactStore } from "./store.js";
|
||||
import { DIFF_ARTIFACT_ID_PATTERN, DIFF_ARTIFACT_TOKEN_PATTERN } from "./types.js";
|
||||
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
|
||||
|
||||
const VIEW_PREFIX = "/plugins/diffs/view/";
|
||||
const VIEWER_MAX_FAILURES_PER_WINDOW = 40;
|
||||
const VIEWER_FAILURE_WINDOW_MS = 60_000;
|
||||
const VIEWER_LOCKOUT_MS = 60_000;
|
||||
const VIEWER_LIMITER_MAX_KEYS = 2_048;
|
||||
const VIEWER_CONTENT_SECURITY_POLICY = [
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'self'",
|
||||
"object-src 'none'",
|
||||
].join("; ");
|
||||
|
||||
export function createDiffsHttpHandler(params: {
|
||||
store: DiffArtifactStore;
|
||||
logger?: PluginLogger;
|
||||
allowRemoteViewer?: boolean;
|
||||
trustedProxies?: readonly string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
resolveAccessConfig?: () => {
|
||||
allowRemoteViewer?: boolean;
|
||||
trustedProxies?: readonly string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
};
|
||||
}) {
|
||||
const viewerFailureLimiter = new ViewerFailureLimiter();
|
||||
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
||||
const parsed = parseRequestUrl(req.url);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
|
||||
return await serveAsset(req, res, parsed.pathname, params.logger);
|
||||
}
|
||||
|
||||
if (!parsed.pathname.startsWith(VIEW_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const accessConfig = params.resolveAccessConfig?.() ?? {
|
||||
allowRemoteViewer: params.allowRemoteViewer,
|
||||
trustedProxies: params.trustedProxies,
|
||||
allowRealIpFallback: params.allowRealIpFallback,
|
||||
};
|
||||
const access = resolveViewerAccess(req, {
|
||||
trustedProxies: accessConfig.trustedProxies,
|
||||
allowRealIpFallback: accessConfig.allowRealIpFallback,
|
||||
});
|
||||
if (!access.localRequest && accessConfig.allowRemoteViewer !== true) {
|
||||
respondText(res, 404, "Diff not found");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
respondText(res, 405, "Method not allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!access.localRequest) {
|
||||
const throttled = viewerFailureLimiter.check(access.remoteKey);
|
||||
if (!throttled.allowed) {
|
||||
res.statusCode = 429;
|
||||
setSharedHeaders(res, "text/plain; charset=utf-8");
|
||||
res.setHeader("Retry-After", String(Math.max(1, Math.ceil(throttled.retryAfterMs / 1000))));
|
||||
res.end("Too Many Requests");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const pathParts = parsed.pathname.split("/").filter(Boolean);
|
||||
const id = pathParts[3];
|
||||
const token = pathParts[4];
|
||||
if (
|
||||
!id ||
|
||||
!token ||
|
||||
!DIFF_ARTIFACT_ID_PATTERN.test(id) ||
|
||||
!DIFF_ARTIFACT_TOKEN_PATTERN.test(token)
|
||||
) {
|
||||
recordRemoteFailure(viewerFailureLimiter, access);
|
||||
respondText(res, 404, "Diff not found");
|
||||
return true;
|
||||
}
|
||||
|
||||
const artifact = await params.store.getArtifact(id, token);
|
||||
if (!artifact) {
|
||||
recordRemoteFailure(viewerFailureLimiter, access);
|
||||
respondText(res, 404, "Diff not found or expired");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = await params.store.readHtml(id);
|
||||
resetRemoteFailures(viewerFailureLimiter, access);
|
||||
res.statusCode = 200;
|
||||
setSharedHeaders(res, "text/html; charset=utf-8");
|
||||
res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(html);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
recordRemoteFailure(viewerFailureLimiter, access);
|
||||
params.logger?.warn(`Failed to serve diff artifact ${id}: ${String(error)}`);
|
||||
respondText(res, 500, "Failed to load diff");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parseRequestUrl(rawUrl?: string): URL | null {
|
||||
if (!rawUrl) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(rawUrl, "http://127.0.0.1");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function serveAsset(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
pathname: string,
|
||||
logger?: PluginLogger,
|
||||
): Promise<boolean> {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
respondText(res, 405, "Method not allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const asset = await getServedViewerAsset(pathname);
|
||||
if (!asset) {
|
||||
respondText(res, 404, "Asset not found");
|
||||
return true;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
setSharedHeaders(res, asset.contentType);
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(asset.body);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger?.warn(`Failed to serve diffs asset ${pathname}: ${String(error)}`);
|
||||
respondText(res, 500, "Failed to load asset");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function respondText(res: ServerResponse, statusCode: number, body: string): void {
|
||||
res.statusCode = statusCode;
|
||||
setSharedHeaders(res, "text/plain; charset=utf-8");
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function setSharedHeaders(res: ServerResponse, contentType: string): void {
|
||||
res.setHeader("cache-control", "no-store, max-age=0");
|
||||
res.setHeader("content-type", contentType);
|
||||
res.setHeader("x-content-type-options", "nosniff");
|
||||
res.setHeader("referrer-policy", "no-referrer");
|
||||
}
|
||||
|
||||
function normalizeRemoteClientKey(remoteAddress: string | undefined): string {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(remoteAddress);
|
||||
if (!normalized) {
|
||||
return "unknown";
|
||||
}
|
||||
return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
|
||||
}
|
||||
|
||||
function isLoopbackClientIp(clientIp: string): boolean {
|
||||
return clientIp === "127.0.0.1" || clientIp === "::1";
|
||||
}
|
||||
|
||||
function hasProxyForwardingHints(req: IncomingMessage): boolean {
|
||||
const headers = req.headers ?? {};
|
||||
return Boolean(
|
||||
headers["x-forwarded-for"] ||
|
||||
headers["x-real-ip"] ||
|
||||
headers.forwarded ||
|
||||
headers["x-forwarded-host"] ||
|
||||
headers["x-forwarded-proto"],
|
||||
);
|
||||
}
|
||||
|
||||
function resolveViewerAccess(
|
||||
req: IncomingMessage,
|
||||
params: {
|
||||
trustedProxies?: readonly string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
},
|
||||
): {
|
||||
remoteKey: string;
|
||||
localRequest: boolean;
|
||||
} {
|
||||
const proxyHintsPresent = hasProxyForwardingHints(req);
|
||||
const clientIp =
|
||||
proxyHintsPresent || (params.trustedProxies?.length ?? 0) > 0
|
||||
? // Reuse gateway proxy trust rules and fail closed when a trusted proxy hop
|
||||
// does not provide usable client-origin headers.
|
||||
resolveRequestClientIp(
|
||||
req,
|
||||
params.trustedProxies ? [...params.trustedProxies] : undefined,
|
||||
params.allowRealIpFallback === true,
|
||||
)
|
||||
: req.socket?.remoteAddress;
|
||||
const remoteKey = normalizeRemoteClientKey(clientIp ?? req.socket?.remoteAddress);
|
||||
const localRequest =
|
||||
!proxyHintsPresent && typeof clientIp === "string" && isLoopbackClientIp(remoteKey);
|
||||
return { remoteKey, localRequest };
|
||||
}
|
||||
|
||||
function recordRemoteFailure(
|
||||
limiter: ViewerFailureLimiter,
|
||||
access: { remoteKey: string; localRequest: boolean },
|
||||
): void {
|
||||
if (!access.localRequest) {
|
||||
limiter.recordFailure(access.remoteKey);
|
||||
}
|
||||
}
|
||||
|
||||
function resetRemoteFailures(
|
||||
limiter: ViewerFailureLimiter,
|
||||
access: { remoteKey: string; localRequest: boolean },
|
||||
): void {
|
||||
if (!access.localRequest) {
|
||||
limiter.reset(access.remoteKey);
|
||||
}
|
||||
}
|
||||
|
||||
type RateLimitCheckResult = {
|
||||
allowed: boolean;
|
||||
retryAfterMs: number;
|
||||
};
|
||||
|
||||
type ViewerFailureState = {
|
||||
windowStartMs: number;
|
||||
failures: number;
|
||||
lockUntilMs: number;
|
||||
};
|
||||
|
||||
class ViewerFailureLimiter {
|
||||
private readonly failures = new Map<string, ViewerFailureState>();
|
||||
|
||||
check(key: string): RateLimitCheckResult {
|
||||
this.prune();
|
||||
const state = this.failures.get(key);
|
||||
if (!state) {
|
||||
return { allowed: true, retryAfterMs: 0 };
|
||||
}
|
||||
const now = Date.now();
|
||||
if (state.lockUntilMs > now) {
|
||||
return { allowed: false, retryAfterMs: state.lockUntilMs - now };
|
||||
}
|
||||
if (now - state.windowStartMs >= VIEWER_FAILURE_WINDOW_MS) {
|
||||
this.failures.delete(key);
|
||||
return { allowed: true, retryAfterMs: 0 };
|
||||
}
|
||||
return { allowed: true, retryAfterMs: 0 };
|
||||
}
|
||||
|
||||
recordFailure(key: string): void {
|
||||
this.prune();
|
||||
const now = Date.now();
|
||||
const current = this.failures.get(key);
|
||||
const next =
|
||||
!current || now - current.windowStartMs >= VIEWER_FAILURE_WINDOW_MS
|
||||
? {
|
||||
windowStartMs: now,
|
||||
failures: 1,
|
||||
lockUntilMs: 0,
|
||||
}
|
||||
: {
|
||||
...current,
|
||||
failures: current.failures + 1,
|
||||
};
|
||||
if (next.failures >= VIEWER_MAX_FAILURES_PER_WINDOW) {
|
||||
next.lockUntilMs = now + VIEWER_LOCKOUT_MS;
|
||||
}
|
||||
this.failures.set(key, next);
|
||||
}
|
||||
|
||||
reset(key: string): void {
|
||||
this.failures.delete(key);
|
||||
}
|
||||
|
||||
private prune(): void {
|
||||
if (this.failures.size < VIEWER_LIMITER_MAX_KEYS) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [key, state] of this.failures) {
|
||||
if (state.lockUntilMs <= now && now - state.windowStartMs >= VIEWER_FAILURE_WINDOW_MS) {
|
||||
this.failures.delete(key);
|
||||
}
|
||||
if (this.failures.size < VIEWER_LIMITER_MAX_KEYS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.failures.size >= VIEWER_LIMITER_MAX_KEYS) {
|
||||
this.failures.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
245
extensions/diffs/src/language-hints.test.ts
Normal file
245
extensions/diffs/src/language-hints.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
// Diffs tests cover language hints plugin behavior.
|
||||
import type { FileDiffMetadata } from "@pierre/diffs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeDiffViewerPayloadLanguages,
|
||||
normalizeSupportedLanguageHint,
|
||||
} from "./language-hints.js";
|
||||
|
||||
async function normalizeHints(values: readonly string[], options = {}) {
|
||||
return await Promise.all(values.map((value) => normalizeSupportedLanguageHint(value, options)));
|
||||
}
|
||||
|
||||
describe("normalizeSupportedLanguageHint", () => {
|
||||
it("keeps supported languages", async () => {
|
||||
await expect(normalizeHints(["typescript", "cpp", "text"])).resolves.toEqual([
|
||||
"typescript",
|
||||
"cpp",
|
||||
"text",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes common aliases to base viewer languages", async () => {
|
||||
await expect(
|
||||
normalizeHints(["ts", "c++", "c#", "bash", "dockerfile", "rb", "kt", "ps1"]),
|
||||
).resolves.toEqual([
|
||||
"typescript",
|
||||
"cpp",
|
||||
"csharp",
|
||||
"sh",
|
||||
"docker",
|
||||
"ruby",
|
||||
"kotlin",
|
||||
"powershell",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps mainstream languages in the base viewer without the language pack", async () => {
|
||||
await expect(
|
||||
normalizeHints([
|
||||
"ruby",
|
||||
"swift",
|
||||
"kotlin",
|
||||
"r",
|
||||
"dart",
|
||||
"lua",
|
||||
"powershell",
|
||||
"xml",
|
||||
"toml",
|
||||
]),
|
||||
).resolves.toEqual([
|
||||
"ruby",
|
||||
"swift",
|
||||
"kotlin",
|
||||
"r",
|
||||
"dart",
|
||||
"lua",
|
||||
"powershell",
|
||||
"xml",
|
||||
"toml",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops uncommon languages without the language pack", async () => {
|
||||
await expect(normalizeSupportedLanguageHint("abap")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps uncommon languages when the language pack is available", async () => {
|
||||
await expect(
|
||||
normalizeSupportedLanguageHint("abap", { languagePackAvailable: true }),
|
||||
).resolves.toBe("abap");
|
||||
});
|
||||
|
||||
it("drops invalid languages", async () => {
|
||||
await expect(normalizeSupportedLanguageHint("not-a-real-language")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps valid languages when invalid hints are mixed in", async () => {
|
||||
await expect(normalizeHints(["typescript", "not-a-real-language"])).resolves.toEqual([
|
||||
"typescript",
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeDiffViewerPayloadLanguages", () => {
|
||||
it("rewrites stale patch payload language overrides to plain text", async () => {
|
||||
const result = await normalizeDiffViewerPayloadLanguages({
|
||||
prerenderedHTML: "<div>diff</div>",
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["not-a-real-language" as never],
|
||||
fileDiff: {
|
||||
name: "foo.txt",
|
||||
lang: "not-a-real-language" as never,
|
||||
} as unknown as FileDiffMetadata,
|
||||
});
|
||||
|
||||
expect(result.langs).toEqual(["text"]);
|
||||
expect(result.fileDiff?.lang).toBe("text");
|
||||
});
|
||||
|
||||
it("keeps valid hydrated languages and only downgrades invalid sides", async () => {
|
||||
const result = await normalizeDiffViewerPayloadLanguages({
|
||||
prerenderedHTML: "<div>diff</div>",
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "split",
|
||||
diffIndicators: "classic",
|
||||
disableLineNumbers: true,
|
||||
expandUnchanged: true,
|
||||
themeType: "light",
|
||||
backgroundEnabled: false,
|
||||
overflow: "scroll",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["typescript", "not-a-real-language" as never],
|
||||
oldFile: {
|
||||
name: "before.unknown",
|
||||
contents: "before",
|
||||
lang: "not-a-real-language" as never,
|
||||
},
|
||||
newFile: {
|
||||
name: "after.ts",
|
||||
contents: "after",
|
||||
lang: "typescript",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.langs).toEqual(["typescript", "text"]);
|
||||
expect(result.oldFile?.lang).toBe("text");
|
||||
expect(result.newFile?.lang).toBe("typescript");
|
||||
});
|
||||
|
||||
it("keeps uncommon hydrated languages when the language pack is available", async () => {
|
||||
const result = await normalizeDiffViewerPayloadLanguages(
|
||||
{
|
||||
prerenderedHTML: "<div>diff</div>",
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["abap" as never],
|
||||
fileDiff: {
|
||||
name: "demo.abap",
|
||||
lang: "abap" as never,
|
||||
} as unknown as FileDiffMetadata,
|
||||
},
|
||||
{ languagePackAvailable: true },
|
||||
);
|
||||
|
||||
expect(result.langs).toEqual(["abap"]);
|
||||
expect(result.fileDiff?.lang).toBe("abap");
|
||||
});
|
||||
|
||||
it("rewrites blank explicit language overrides to plain text", async () => {
|
||||
const result = await normalizeDiffViewerPayloadLanguages({
|
||||
prerenderedHTML: "<div>diff</div>",
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: [" " as never],
|
||||
oldFile: {
|
||||
name: "before.unknown",
|
||||
contents: "before",
|
||||
lang: " " as never,
|
||||
},
|
||||
newFile: {
|
||||
name: "after.txt",
|
||||
contents: "after",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.langs).toEqual(["text"]);
|
||||
expect(result.oldFile?.lang).toBe("text");
|
||||
});
|
||||
|
||||
it("does not inject text when a valid file language is the only supported hint", async () => {
|
||||
const result = await normalizeDiffViewerPayloadLanguages({
|
||||
prerenderedHTML: "<div>diff</div>",
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: [],
|
||||
oldFile: {
|
||||
name: "before.ts",
|
||||
contents: "before",
|
||||
lang: "typescript",
|
||||
},
|
||||
newFile: {
|
||||
name: "after.ts",
|
||||
contents: "after",
|
||||
lang: "typescript",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.langs).toEqual(["typescript"]);
|
||||
});
|
||||
});
|
||||
151
extensions/diffs/src/language-hints.ts
Normal file
151
extensions/diffs/src/language-hints.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
// Diffs plugin module implements language hints behavior.
|
||||
import { resolveLanguage } from "@pierre/diffs";
|
||||
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
|
||||
import {
|
||||
bundledLanguagesBase,
|
||||
bundledLanguagesInfo,
|
||||
getBundledLanguageAliases,
|
||||
} from "./shiki-curated-languages.js";
|
||||
import type { DiffViewerPayload } from "./types.js";
|
||||
|
||||
export const BASE_DIFF_VIEWER_LANGUAGE_HINTS = [
|
||||
...Object.keys(bundledLanguagesBase),
|
||||
"text",
|
||||
"ansi",
|
||||
] as const satisfies readonly SupportedLanguages[];
|
||||
|
||||
const BASE_LANGUAGE_HINTS = new Set<SupportedLanguages>(BASE_DIFF_VIEWER_LANGUAGE_HINTS);
|
||||
const BASE_LANGUAGE_ALIASES = new Map<string, SupportedLanguages>(
|
||||
bundledLanguagesInfo.flatMap((language) =>
|
||||
getBundledLanguageAliases(language).map((alias) => [alias, language.id as SupportedLanguages]),
|
||||
),
|
||||
);
|
||||
type DiffPayloadFile = FileContents | FileDiffMetadata;
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export async function normalizeSupportedLanguageHint(
|
||||
value?: string,
|
||||
options: { languagePackAvailable?: boolean } = {},
|
||||
): Promise<SupportedLanguages | undefined> {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const baseAlias = BASE_LANGUAGE_ALIASES.get(normalized);
|
||||
if (baseAlias) {
|
||||
return baseAlias;
|
||||
}
|
||||
if (BASE_LANGUAGE_HINTS.has(normalized as SupportedLanguages)) {
|
||||
return normalized as SupportedLanguages;
|
||||
}
|
||||
if (!options.languagePackAvailable) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
await resolveLanguage(normalized as Exclude<SupportedLanguages, "text" | "ansi">);
|
||||
return normalized as SupportedLanguages;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeSupportedLanguageHints(
|
||||
values: Iterable<string>,
|
||||
options: { fallbackToText: boolean; languagePackAvailable?: boolean },
|
||||
): Promise<SupportedLanguages[]> {
|
||||
const supported = new Set<SupportedLanguages>();
|
||||
for (const value of values) {
|
||||
const normalized = await normalizeSupportedLanguageHint(value, options);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
supported.add(normalized);
|
||||
}
|
||||
if (options.fallbackToText && supported.size === 0) {
|
||||
supported.add("text");
|
||||
}
|
||||
return [...supported];
|
||||
}
|
||||
|
||||
export function collectDiffPayloadLanguageHints(payload: {
|
||||
fileDiff?: FileDiffMetadata;
|
||||
oldFile?: FileContents;
|
||||
newFile?: FileContents;
|
||||
}): SupportedLanguages[] {
|
||||
const langs = new Set<SupportedLanguages>();
|
||||
if (payload.fileDiff?.lang) {
|
||||
langs.add(payload.fileDiff.lang);
|
||||
}
|
||||
if (payload.oldFile?.lang) {
|
||||
langs.add(payload.oldFile.lang);
|
||||
}
|
||||
if (payload.newFile?.lang) {
|
||||
langs.add(payload.newFile.lang);
|
||||
}
|
||||
return [...langs];
|
||||
}
|
||||
|
||||
async function normalizeDiffPayloadFileLanguage(
|
||||
file: DiffPayloadFile | undefined,
|
||||
options: { languagePackAvailable?: boolean },
|
||||
): Promise<DiffPayloadFile | undefined> {
|
||||
if (!file) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof file.lang !== "string") {
|
||||
return file;
|
||||
}
|
||||
const normalized = await normalizeSupportedLanguageHint(file.lang, options);
|
||||
if (file.lang === normalized) {
|
||||
return file;
|
||||
}
|
||||
if (!normalized) {
|
||||
return {
|
||||
...file,
|
||||
lang: "text",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...file,
|
||||
lang: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
export async function normalizeDiffViewerPayloadLanguages(
|
||||
payload: DiffViewerPayload,
|
||||
options: { languagePackAvailable?: boolean } = {},
|
||||
): Promise<DiffViewerPayload> {
|
||||
const [fileDiff, oldFile, newFile, payloadLangs] = await Promise.all([
|
||||
normalizeDiffPayloadFileLanguage(payload.fileDiff, options) as Promise<
|
||||
FileDiffMetadata | undefined
|
||||
>,
|
||||
normalizeDiffPayloadFileLanguage(payload.oldFile, options) as Promise<FileContents | undefined>,
|
||||
normalizeDiffPayloadFileLanguage(payload.newFile, options) as Promise<FileContents | undefined>,
|
||||
normalizeSupportedLanguageHints(payload.langs, { fallbackToText: false, ...options }),
|
||||
]);
|
||||
const langs = new Set<SupportedLanguages>(payloadLangs);
|
||||
for (const lang of collectDiffPayloadLanguageHints({ fileDiff, oldFile, newFile })) {
|
||||
langs.add(lang);
|
||||
}
|
||||
if (langs.size === 0) {
|
||||
langs.add("text");
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
fileDiff,
|
||||
oldFile,
|
||||
newFile,
|
||||
langs: [...langs],
|
||||
};
|
||||
}
|
||||
|
||||
export function isBaseDiffViewerLanguage(lang: string): boolean {
|
||||
return BASE_LANGUAGE_HINTS.has(lang as SupportedLanguages);
|
||||
}
|
||||
17
extensions/diffs/src/manifest.test.ts
Normal file
17
extensions/diffs/src/manifest.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Diffs tests cover manifest plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type DiffsPackageManifest = {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
describe("diffs package manifest", () => {
|
||||
it("keeps runtime dependencies in the package manifest", () => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
||||
) as DiffsPackageManifest;
|
||||
|
||||
expect(packageJson.dependencies).toHaveProperty("@pierre/diffs");
|
||||
});
|
||||
});
|
||||
111
extensions/diffs/src/plugin.ts
Normal file
111
extensions/diffs/src/plugin.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Diffs plugin module implements plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import {
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
type OpenClawConfig,
|
||||
type OpenClawPluginApi,
|
||||
} from "../api.js";
|
||||
import {
|
||||
resolveDiffsPluginDefaults,
|
||||
resolveDiffsPluginSecurity,
|
||||
resolveDiffsPluginViewerBaseUrl,
|
||||
} from "./config.js";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DIFFS_AGENT_GUIDANCE } from "./prompt-guidance.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffsTool } from "./tool.js";
|
||||
|
||||
const DIFFS_LANGUAGE_PACK_PLUGIN_ID = "diffs-language-pack";
|
||||
|
||||
export function registerDiffsPlugin(api: OpenClawPluginApi): void {
|
||||
const store = new DiffArtifactStore({
|
||||
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
|
||||
logger: api.logger,
|
||||
});
|
||||
const resolveCurrentPluginConfig = () =>
|
||||
resolveLivePluginConfigObject(
|
||||
api.runtime.config?.current
|
||||
? () => api.runtime.config.current() as OpenClawConfig
|
||||
: undefined,
|
||||
"diffs",
|
||||
api.pluginConfig as Record<string, unknown>,
|
||||
) ?? {};
|
||||
const resolveCurrentAccessConfig = () => {
|
||||
const currentConfig = (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig;
|
||||
const pluginConfig = resolveCurrentPluginConfig();
|
||||
return {
|
||||
allowRemoteViewer: resolveDiffsPluginSecurity(pluginConfig).allowRemoteViewer,
|
||||
trustedProxies: currentConfig.gateway?.trustedProxies,
|
||||
allowRealIpFallback: currentConfig.gateway?.allowRealIpFallback === true,
|
||||
};
|
||||
};
|
||||
const initialAccessConfig = resolveCurrentAccessConfig();
|
||||
|
||||
api.registerTool(
|
||||
(ctx) => {
|
||||
const pluginConfig = resolveCurrentPluginConfig();
|
||||
return createDiffsTool({
|
||||
api,
|
||||
store,
|
||||
defaults: resolveDiffsPluginDefaults(pluginConfig),
|
||||
viewerBaseUrl: resolveDiffsPluginViewerBaseUrl(pluginConfig),
|
||||
languagePackAvailable: resolveDiffsLanguagePackAvailability(api),
|
||||
context: ctx,
|
||||
});
|
||||
},
|
||||
{
|
||||
name: "diffs",
|
||||
},
|
||||
);
|
||||
api.registerHttpRoute({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
handler: createDiffsHttpHandler({
|
||||
store,
|
||||
logger: api.logger,
|
||||
allowRemoteViewer: initialAccessConfig.allowRemoteViewer,
|
||||
trustedProxies: initialAccessConfig.trustedProxies,
|
||||
allowRealIpFallback: initialAccessConfig.allowRealIpFallback,
|
||||
resolveAccessConfig: resolveCurrentAccessConfig,
|
||||
}),
|
||||
});
|
||||
api.on("before_prompt_build", async () => ({
|
||||
prependSystemContext: DIFFS_AGENT_GUIDANCE,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveDiffsLanguagePackAvailability(api: OpenClawPluginApi): boolean {
|
||||
const currentConfig = (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig;
|
||||
const plugins = currentConfig.plugins;
|
||||
if (plugins?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
if (plugins?.deny?.includes(DIFFS_LANGUAGE_PACK_PLUGIN_ID)) {
|
||||
return false;
|
||||
}
|
||||
if (plugins?.allow && !plugins.allow.includes(DIFFS_LANGUAGE_PACK_PLUGIN_ID)) {
|
||||
return false;
|
||||
}
|
||||
if (plugins?.entries?.[DIFFS_LANGUAGE_PACK_PLUGIN_ID]?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
return hasSiblingLanguagePackRuntime(api.rootDir);
|
||||
}
|
||||
|
||||
function hasSiblingLanguagePackRuntime(rootDir: string | undefined): boolean {
|
||||
if (!rootDir) {
|
||||
return false;
|
||||
}
|
||||
const languagePackRoot = path.join(path.dirname(rootDir), DIFFS_LANGUAGE_PACK_PLUGIN_ID);
|
||||
const runtimePaths = [
|
||||
path.join(languagePackRoot, "assets", "viewer-runtime.js"),
|
||||
path.join(languagePackRoot, "dist", "assets", "viewer-runtime.js"),
|
||||
];
|
||||
return (
|
||||
fs.existsSync(path.join(languagePackRoot, "openclaw.plugin.json")) &&
|
||||
runtimePaths.some((runtimePath) => fs.existsSync(runtimePath))
|
||||
);
|
||||
}
|
||||
8
extensions/diffs/src/prompt-guidance.ts
Normal file
8
extensions/diffs/src/prompt-guidance.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Diffs plugin module implements prompt guidance behavior.
|
||||
export const DIFFS_AGENT_GUIDANCE = [
|
||||
"When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
|
||||
"It accepts either `before` + `after` text or a unified `patch`.",
|
||||
"`mode=view` returns `details.viewerUrl` for canvas use; `mode=file` returns `details.filePath`; `mode=both` returns both.",
|
||||
"If you need to send the rendered file, use the `message` tool with `path` or `filePath`.",
|
||||
"Include `path` when you know the filename, and omit presentation overrides unless needed.",
|
||||
].join("\n");
|
||||
133
extensions/diffs/src/render-target.test.ts
Normal file
133
extensions/diffs/src/render-target.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// Diffs tests cover render target plugin behavior.
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { preloadFileDiffMock, preloadMultiFileDiffMock } = vi.hoisted(() => ({
|
||||
preloadFileDiffMock: vi.fn(async ({ fileDiff }: { fileDiff: unknown }) => ({
|
||||
prerenderedHTML: "<div>mock diff</div>",
|
||||
fileDiff,
|
||||
})),
|
||||
preloadMultiFileDiffMock: vi.fn(
|
||||
async ({ oldFile, newFile }: { oldFile: unknown; newFile: unknown }) => ({
|
||||
prerenderedHTML: "<div>mock diff</div>",
|
||||
oldFile,
|
||||
newFile,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@pierre/diffs/ssr", () => ({
|
||||
preloadFileDiff: preloadFileDiffMock,
|
||||
preloadMultiFileDiff: preloadMultiFileDiffMock,
|
||||
}));
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("@pierre/diffs/ssr");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
||||
|
||||
function createRenderOptions() {
|
||||
return {
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderDiffDocument render targets", () => {
|
||||
beforeEach(() => {
|
||||
preloadFileDiffMock.mockClear();
|
||||
preloadMultiFileDiffMock.mockClear();
|
||||
});
|
||||
|
||||
it("renders only the viewer variant for before/after viewer mode", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
},
|
||||
createRenderOptions(),
|
||||
"viewer",
|
||||
);
|
||||
|
||||
expect(rendered.html).toContain("mock diff");
|
||||
expect(rendered.imageHtml).toBeUndefined();
|
||||
expect(preloadMultiFileDiffMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders both variants for before/after both mode", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
},
|
||||
createRenderOptions(),
|
||||
"both",
|
||||
);
|
||||
|
||||
expect(rendered.html).toContain("mock diff");
|
||||
expect(rendered.imageHtml).toContain("mock diff");
|
||||
expect(preloadMultiFileDiffMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("renders only the image variant for patch image mode", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch: [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-a",
|
||||
"+b",
|
||||
].join("\n"),
|
||||
},
|
||||
createRenderOptions(),
|
||||
"image",
|
||||
);
|
||||
|
||||
expect(rendered.html).toBeUndefined();
|
||||
expect(rendered.imageHtml).toContain("mock diff");
|
||||
expect(preloadFileDiffMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("normalizes stale patch payload languages before serializing viewer output", async () => {
|
||||
preloadFileDiffMock.mockResolvedValueOnce({
|
||||
prerenderedHTML: "<div>mock diff</div>",
|
||||
fileDiff: {
|
||||
name: "a.ts",
|
||||
lang: "not-a-real-language",
|
||||
},
|
||||
});
|
||||
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch: [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-a",
|
||||
"+b",
|
||||
].join("\n"),
|
||||
},
|
||||
createRenderOptions(),
|
||||
"viewer",
|
||||
);
|
||||
|
||||
const payloads = [
|
||||
...(rendered.html ?? "").matchAll(/data-openclaw-diff-payload>(.*?)<\/script>/g),
|
||||
].map((match) => parseViewerPayloadJson(match[1] ?? ""));
|
||||
|
||||
expect(payloads).toHaveLength(1);
|
||||
expect(payloads[0]?.langs).toEqual(["text"]);
|
||||
expect(payloads[0]?.fileDiff?.lang).toBe("text");
|
||||
});
|
||||
});
|
||||
245
extensions/diffs/src/render.test.ts
Normal file
245
extensions/diffs/src/render.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
// Diffs tests cover render plugin behavior.
|
||||
import { disposeHighlighter } from "@pierre/diffs";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
||||
|
||||
describe("renderDiffDocument", () => {
|
||||
afterEach(async () => {
|
||||
await disposeHighlighter();
|
||||
});
|
||||
|
||||
it("renders before/after input into a complete viewer document", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "const value = 1;\n",
|
||||
after: "const value = 2;\n",
|
||||
path: "src/example.ts",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("src/example.ts");
|
||||
expect(rendered.fileCount).toBe(1);
|
||||
expect(rendered.viewerRuntime).toBe("base");
|
||||
expect(rendered.html).toContain("data-openclaw-diff-root");
|
||||
expect(rendered.html).toContain("src/example.ts");
|
||||
expect(rendered.html).toContain("../../assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("../../assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("max-width: 960px;");
|
||||
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
|
||||
expect(rendered.html).toContain("min-height: 100vh;");
|
||||
expect(rendered.html).toContain('"diffIndicators":"bars"');
|
||||
expect(rendered.html).toContain('"disableLineNumbers":false');
|
||||
expect(rendered.html).toContain("--diffs-line-height: 24px;");
|
||||
expect(rendered.html).toContain("--diffs-font-size: 15px;");
|
||||
expect(rendered.html).not.toContain("fonts.googleapis.com");
|
||||
});
|
||||
|
||||
it("normalizes non-finite presentation numbers before rendering CSS", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "old\n",
|
||||
after: "new\n",
|
||||
},
|
||||
{
|
||||
presentation: {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
fontSize: Number.NaN,
|
||||
lineSpacing: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.html).toContain("--diffs-font-size: 15px;");
|
||||
expect(rendered.html).toContain("--diffs-line-height: 24px;");
|
||||
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
|
||||
expect(rendered.html).not.toContain("NaNpx");
|
||||
expect(rendered.imageHtml).not.toContain("NaNpx");
|
||||
});
|
||||
|
||||
it("resolves viewer assets under an optional base path", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "const value = 1;\n",
|
||||
after: "const value = 2;\n",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
);
|
||||
|
||||
const html = rendered.html ?? "";
|
||||
const loaderSrc = html.match(/<script type="module" src="([^"]+)"><\/script>/)?.[1];
|
||||
expect(loaderSrc).toBe("../../assets/viewer.js");
|
||||
expect(
|
||||
new URL(loaderSrc ?? "", "https://example.com/openclaw/plugins/diffs/view/id/token").pathname,
|
||||
).toBe("/openclaw/plugins/diffs/assets/viewer.js");
|
||||
});
|
||||
|
||||
it("downgrades invalid language hints to plain text", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "const value = 1;\n",
|
||||
after: "const value = 2;\n",
|
||||
lang: "not-a-real-language",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
);
|
||||
|
||||
const html = rendered.html ?? "";
|
||||
|
||||
expect(rendered.title).toBe("Text diff");
|
||||
expect(html).toContain("diff.txt");
|
||||
expect(html).not.toContain("not-a-real-language");
|
||||
|
||||
const payloads = [...html.matchAll(/data-openclaw-diff-payload>(.*?)<\/script>/g)].map(
|
||||
(match) => parseViewerPayloadJson(match[1] ?? ""),
|
||||
);
|
||||
expect(payloads).toHaveLength(1);
|
||||
expect(payloads[0]?.langs).toEqual(["text"]);
|
||||
expect(payloads[0]?.oldFile?.lang).toBeUndefined();
|
||||
expect(payloads[0]?.newFile?.lang).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps uncommon language diffs readable without the language pack", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "REPORT z_demo.\n",
|
||||
after: "REPORT z_demo2.\n",
|
||||
lang: "abap",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
"viewer",
|
||||
);
|
||||
|
||||
const html = rendered.html ?? "";
|
||||
const payload = parseViewerPayloadJson(
|
||||
html.match(/data-openclaw-diff-payload>(.*?)<\/script>/)?.[1] ?? "",
|
||||
);
|
||||
|
||||
expect(rendered.viewerRuntime).toBe("base");
|
||||
expect(html).toContain("../../assets/viewer.js");
|
||||
expect(html).not.toContain("diffs-language-pack");
|
||||
expect(payload.langs).toEqual(["text"]);
|
||||
});
|
||||
|
||||
it("uses the language-pack viewer runtime for uncommon languages when available", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "REPORT z_demo.\n",
|
||||
after: "REPORT z_demo2.\n",
|
||||
lang: "abap",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
languagePackAvailable: true,
|
||||
},
|
||||
"viewer",
|
||||
);
|
||||
|
||||
const html = rendered.html ?? "";
|
||||
const payload = parseViewerPayloadJson(
|
||||
html.match(/data-openclaw-diff-payload>(.*?)<\/script>/)?.[1] ?? "",
|
||||
);
|
||||
|
||||
expect(rendered.viewerRuntime).toBe("language-pack");
|
||||
expect(html).toContain("../../../diffs-language-pack/assets/viewer.js");
|
||||
expect(payload.langs).toEqual(["abap"]);
|
||||
});
|
||||
|
||||
it("renders multi-file patch input", async () => {
|
||||
const patch = [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const a = 1;",
|
||||
"+const a = 2;",
|
||||
"diff --git a/b.ts b/b.ts",
|
||||
"--- a/b.ts",
|
||||
"+++ b/b.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const b = 1;",
|
||||
"+const b = 2;",
|
||||
].join("\n");
|
||||
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
title: "Workspace patch",
|
||||
},
|
||||
{
|
||||
presentation: {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
layout: "split",
|
||||
theme: "dark",
|
||||
},
|
||||
image: resolveDiffImageRenderOptions({
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
fileQuality: "hq",
|
||||
fileMaxWidth: 1180,
|
||||
}),
|
||||
expandUnchanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("Workspace patch");
|
||||
expect(rendered.fileCount).toBe(2);
|
||||
expect(rendered.html).toContain("Workspace patch");
|
||||
expect(rendered.imageHtml).toContain("max-width: 1180px;");
|
||||
});
|
||||
|
||||
it("rejects patches that exceed file-count limits", async () => {
|
||||
const patch = Array.from({ length: 129 }, (_, i) => {
|
||||
return [
|
||||
`diff --git a/f${i}.ts b/f${i}.ts`,
|
||||
`--- a/f${i}.ts`,
|
||||
`+++ b/f${i}.ts`,
|
||||
"@@ -1 +1 @@",
|
||||
"-const x = 1;",
|
||||
"+const x = 2;",
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
|
||||
await expect(
|
||||
renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("too many files");
|
||||
});
|
||||
});
|
||||
617
extensions/diffs/src/render.ts
Normal file
617
extensions/diffs/src/render.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
// Diffs plugin module implements render behavior.
|
||||
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
|
||||
import { parsePatchFiles } from "@pierre/diffs";
|
||||
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
|
||||
import { normalizeDiffFontSize, normalizeDiffLineSpacing } from "./config.js";
|
||||
import {
|
||||
collectDiffPayloadLanguageHints,
|
||||
isBaseDiffViewerLanguage,
|
||||
normalizeDiffViewerPayloadLanguages,
|
||||
normalizeSupportedLanguageHint,
|
||||
} from "./language-hints.js";
|
||||
import type {
|
||||
DiffInput,
|
||||
DiffRenderOptions,
|
||||
DiffRenderTarget,
|
||||
DiffViewerOptions,
|
||||
DiffViewerPayload,
|
||||
RenderedDiffDocument,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_FILE_NAME = "diff.txt";
|
||||
const MAX_PATCH_FILE_COUNT = 128;
|
||||
const MAX_PATCH_TOTAL_LINES = 120_000;
|
||||
const VIEWER_LOADER_DOCUMENT_PATH = "../../assets/viewer.js";
|
||||
const LANGUAGE_PACK_VIEWER_LOADER_DOCUMENT_PATH = "../../../diffs-language-pack/assets/viewer.js";
|
||||
|
||||
function escapeCssString(value: string): string {
|
||||
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function escapeJsonScript(value: unknown): string {
|
||||
return JSON.stringify(value).replaceAll("<", "\\u003c");
|
||||
}
|
||||
|
||||
function buildDiffTitle(input: DiffInput): string {
|
||||
if (input.title?.trim()) {
|
||||
return input.title.trim();
|
||||
}
|
||||
if (input.kind === "before_after") {
|
||||
return input.path?.trim() || "Text diff";
|
||||
}
|
||||
return "Patch diff";
|
||||
}
|
||||
|
||||
function resolveBeforeAfterFileName(params: {
|
||||
input: Extract<DiffInput, { kind: "before_after" }>;
|
||||
lang?: SupportedLanguages;
|
||||
}): string {
|
||||
const { input, lang } = params;
|
||||
if (input.path?.trim()) {
|
||||
return input.path.trim();
|
||||
}
|
||||
if (lang && lang !== "text") {
|
||||
return `diff.${lang.replace(/^\.+/, "")}`;
|
||||
}
|
||||
return DEFAULT_FILE_NAME;
|
||||
}
|
||||
|
||||
function buildDiffOptions(options: DiffRenderOptions): DiffViewerOptions {
|
||||
const fontFamily = escapeCssString(options.presentation.fontFamily);
|
||||
const fontSize = normalizeDiffFontSize(options.presentation.fontSize);
|
||||
const lineSpacing = normalizeDiffLineSpacing(options.presentation.lineSpacing);
|
||||
const lineHeight = Math.max(20, Math.round(fontSize * lineSpacing));
|
||||
return {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: options.presentation.layout,
|
||||
diffIndicators: options.presentation.diffIndicators,
|
||||
disableLineNumbers: !options.presentation.showLineNumbers,
|
||||
expandUnchanged: options.expandUnchanged,
|
||||
themeType: options.presentation.theme,
|
||||
backgroundEnabled: options.presentation.background,
|
||||
overflow: options.presentation.wordWrap ? "wrap" : "scroll",
|
||||
unsafeCSS: `
|
||||
:host {
|
||||
--diffs-font-family: "${fontFamily}", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--diffs-header-font-family: "${fontFamily}", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--diffs-font-size: ${fontSize}px;
|
||||
--diffs-line-height: ${lineHeight}px;
|
||||
}
|
||||
|
||||
[data-diffs-header] {
|
||||
min-height: 64px;
|
||||
padding-inline: 18px 14px;
|
||||
}
|
||||
|
||||
[data-header-content] {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-metadata] {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.oc-diff-toolbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-inline-start: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.oc-diff-toolbar-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
line-height: 0;
|
||||
overflow: visible;
|
||||
transition: opacity 120ms ease;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.oc-diff-toolbar-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.oc-diff-toolbar-button[data-active="true"] {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.oc-diff-toolbar-button svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
overflow: visible;
|
||||
flex: 0 0 auto;
|
||||
color: inherit;
|
||||
fill: currentColor;
|
||||
pointer-events: none;
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildImageRenderOptions(options: DiffRenderOptions): DiffRenderOptions {
|
||||
return {
|
||||
...options,
|
||||
presentation: {
|
||||
...options.presentation,
|
||||
fontSize: Math.max(16, normalizeDiffFontSize(options.presentation.fontSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRenderViewer(target: DiffRenderTarget): boolean {
|
||||
return target === "viewer" || target === "both";
|
||||
}
|
||||
|
||||
function shouldRenderImage(target: DiffRenderTarget): boolean {
|
||||
return target === "image" || target === "both";
|
||||
}
|
||||
|
||||
function buildRenderVariants(params: { options: DiffRenderOptions; target: DiffRenderTarget }): {
|
||||
viewerOptions?: DiffViewerOptions;
|
||||
imageOptions?: DiffViewerOptions;
|
||||
} {
|
||||
return {
|
||||
...(shouldRenderViewer(params.target)
|
||||
? { viewerOptions: buildDiffOptions(params.options) }
|
||||
: {}),
|
||||
...(shouldRenderImage(params.target)
|
||||
? { imageOptions: buildDiffOptions(buildImageRenderOptions(params.options)) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderDiffCard(payload: DiffViewerPayload): string {
|
||||
return `<section class="oc-diff-card">
|
||||
<diffs-container class="oc-diff-host" data-openclaw-diff-host>
|
||||
<template shadowrootmode="open">${payload.prerenderedHTML}</template>
|
||||
</diffs-container>
|
||||
<script type="application/json" data-openclaw-diff-payload>${escapeJsonScript(payload)}</script>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function buildHtmlDocument(params: {
|
||||
title: string;
|
||||
bodyHtml: string;
|
||||
theme: DiffRenderOptions["presentation"]["theme"];
|
||||
imageMaxWidth: number;
|
||||
runtimeMode: "viewer" | "image";
|
||||
viewerRuntime: "base" | "language-pack";
|
||||
}): string {
|
||||
const viewerLoaderPath =
|
||||
params.viewerRuntime === "language-pack"
|
||||
? LANGUAGE_PACK_VIEWER_LOADER_DOCUMENT_PATH
|
||||
: VIEWER_LOADER_DOCUMENT_PATH;
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>${escapeHtml(params.title)}</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
background: #05070b;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
padding: 22px;
|
||||
font-family:
|
||||
"Fira Code",
|
||||
"SF Mono",
|
||||
Monaco,
|
||||
Consolas,
|
||||
monospace;
|
||||
background: #05070b;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
body[data-theme="light"] {
|
||||
background: #f3f5f8;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.oc-frame {
|
||||
max-width: 1560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.oc-frame[data-render-mode="image"] {
|
||||
max-width: ${Math.max(640, Math.round(params.imageMaxWidth))}px;
|
||||
}
|
||||
|
||||
[data-openclaw-diff-root] {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.oc-diff-card {
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(15, 23, 42, 0.14);
|
||||
box-shadow: 0 18px 48px rgba(2, 6, 23, 0.22);
|
||||
}
|
||||
|
||||
body[data-theme="light"] .oc-diff-card {
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.oc-diff-host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.oc-frame[data-render-mode="image"] .oc-diff-card {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
[data-openclaw-diff-root] {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="${params.theme}">
|
||||
<main class="oc-frame" data-render-mode="${params.runtimeMode}">
|
||||
<div data-openclaw-diff-root>
|
||||
${params.bodyHtml}
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="${viewerLoaderPath}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
type RenderedSection = {
|
||||
viewer?: string;
|
||||
image?: string;
|
||||
usesLanguagePack?: boolean;
|
||||
};
|
||||
|
||||
function payloadUsesLanguagePack(payload: DiffViewerPayload | undefined): boolean {
|
||||
return payload?.langs.some((lang) => !isBaseDiffViewerLanguage(lang)) ?? false;
|
||||
}
|
||||
|
||||
function buildRenderedSection(params: {
|
||||
viewerPayload?: DiffViewerPayload;
|
||||
imagePayload?: DiffViewerPayload;
|
||||
}): RenderedSection {
|
||||
return {
|
||||
...(params.viewerPayload ? { viewer: renderDiffCard(params.viewerPayload) } : {}),
|
||||
...(params.imagePayload ? { image: renderDiffCard(params.imagePayload) } : {}),
|
||||
usesLanguagePack:
|
||||
payloadUsesLanguagePack(params.viewerPayload) || payloadUsesLanguagePack(params.imagePayload),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRenderedBodies(sections: ReadonlyArray<RenderedSection>): {
|
||||
viewerBodyHtml?: string;
|
||||
imageBodyHtml?: string;
|
||||
} {
|
||||
const viewerSections = sections.flatMap((section) => (section.viewer ? [section.viewer] : []));
|
||||
const imageSections = sections.flatMap((section) => (section.image ? [section.image] : []));
|
||||
return {
|
||||
...(viewerSections.length > 0 ? { viewerBodyHtml: viewerSections.join("\n") } : {}),
|
||||
...(imageSections.length > 0 ? { imageBodyHtml: imageSections.join("\n") } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function renderBeforeAfterDiff(
|
||||
input: Extract<DiffInput, { kind: "before_after" }>,
|
||||
options: DiffRenderOptions,
|
||||
target: DiffRenderTarget,
|
||||
): Promise<{
|
||||
viewerBodyHtml?: string;
|
||||
imageBodyHtml?: string;
|
||||
fileCount: number;
|
||||
usesLanguagePack: boolean;
|
||||
}> {
|
||||
const languagePackAvailable = options.languagePackAvailable === true;
|
||||
const lang = await normalizeSupportedLanguageHint(input.lang, { languagePackAvailable });
|
||||
const fileName = resolveBeforeAfterFileName({ input, lang });
|
||||
const oldFile: FileContents = {
|
||||
name: fileName,
|
||||
contents: input.before,
|
||||
...(lang ? { lang } : {}),
|
||||
};
|
||||
const newFile: FileContents = {
|
||||
name: fileName,
|
||||
contents: input.after,
|
||||
...(lang ? { lang } : {}),
|
||||
};
|
||||
const { viewerOptions, imageOptions } = buildRenderVariants({ options, target });
|
||||
const [viewerResult, imageResult] = await Promise.all([
|
||||
viewerOptions
|
||||
? preloadMultiFileDiffWithFallback({
|
||||
oldFile,
|
||||
newFile,
|
||||
options: viewerOptions,
|
||||
})
|
||||
: Promise.resolve(undefined),
|
||||
imageOptions
|
||||
? preloadMultiFileDiffWithFallback({
|
||||
oldFile,
|
||||
newFile,
|
||||
options: imageOptions,
|
||||
})
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
const [viewerPayload, imagePayload] = await Promise.all([
|
||||
viewerResult && viewerOptions
|
||||
? normalizeDiffViewerPayloadLanguages(
|
||||
{
|
||||
prerenderedHTML: viewerResult.prerenderedHTML,
|
||||
oldFile: viewerResult.oldFile,
|
||||
newFile: viewerResult.newFile,
|
||||
options: viewerOptions,
|
||||
langs: collectDiffPayloadLanguageHints({
|
||||
oldFile: viewerResult.oldFile,
|
||||
newFile: viewerResult.newFile,
|
||||
}),
|
||||
},
|
||||
{ languagePackAvailable },
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
imageResult && imageOptions
|
||||
? normalizeDiffViewerPayloadLanguages(
|
||||
{
|
||||
prerenderedHTML: imageResult.prerenderedHTML,
|
||||
oldFile: imageResult.oldFile,
|
||||
newFile: imageResult.newFile,
|
||||
options: imageOptions,
|
||||
langs: collectDiffPayloadLanguageHints({
|
||||
oldFile: imageResult.oldFile,
|
||||
newFile: imageResult.newFile,
|
||||
}),
|
||||
},
|
||||
{ languagePackAvailable },
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
const section = buildRenderedSection({
|
||||
...(viewerPayload ? { viewerPayload } : {}),
|
||||
...(imagePayload ? { imagePayload } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
...buildRenderedBodies([section]),
|
||||
fileCount: 1,
|
||||
usesLanguagePack: section.usesLanguagePack === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPatchDiff(
|
||||
input: Extract<DiffInput, { kind: "patch" }>,
|
||||
options: DiffRenderOptions,
|
||||
target: DiffRenderTarget,
|
||||
): Promise<{
|
||||
viewerBodyHtml?: string;
|
||||
imageBodyHtml?: string;
|
||||
fileCount: number;
|
||||
usesLanguagePack: boolean;
|
||||
}> {
|
||||
const languagePackAvailable = options.languagePackAvailable === true;
|
||||
const files = await Promise.all(
|
||||
parsePatchFiles(input.patch)
|
||||
.flatMap((entry) => entry.files ?? [])
|
||||
.map((fileDiff) => normalizePatchFileLanguage(fileDiff, { languagePackAvailable })),
|
||||
);
|
||||
if (files.length === 0) {
|
||||
throw new Error("Patch input did not contain any file diffs.");
|
||||
}
|
||||
if (files.length > MAX_PATCH_FILE_COUNT) {
|
||||
throw new Error(`Patch input contains too many files (max ${MAX_PATCH_FILE_COUNT}).`);
|
||||
}
|
||||
const totalLines = files.reduce((sum, fileDiff) => {
|
||||
const splitLines = Number.isFinite(fileDiff.splitLineCount) ? fileDiff.splitLineCount : 0;
|
||||
const unifiedLines = Number.isFinite(fileDiff.unifiedLineCount) ? fileDiff.unifiedLineCount : 0;
|
||||
return sum + Math.max(splitLines, unifiedLines, 0);
|
||||
}, 0);
|
||||
if (totalLines > MAX_PATCH_TOTAL_LINES) {
|
||||
throw new Error(`Patch input is too large to render (max ${MAX_PATCH_TOTAL_LINES} lines).`);
|
||||
}
|
||||
|
||||
const { viewerOptions, imageOptions } = buildRenderVariants({ options, target });
|
||||
const sections = await Promise.all(
|
||||
files.map(async (fileDiff) => {
|
||||
const [viewerResult, imageResult] = await Promise.all([
|
||||
viewerOptions
|
||||
? preloadFileDiffWithFallback({
|
||||
fileDiff,
|
||||
options: viewerOptions,
|
||||
})
|
||||
: Promise.resolve(undefined),
|
||||
imageOptions
|
||||
? preloadFileDiffWithFallback({
|
||||
fileDiff,
|
||||
options: imageOptions,
|
||||
})
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const [viewerPayload, imagePayload] = await Promise.all([
|
||||
viewerResult && viewerOptions
|
||||
? normalizeDiffViewerPayloadLanguages(
|
||||
{
|
||||
prerenderedHTML: viewerResult.prerenderedHTML,
|
||||
fileDiff: viewerResult.fileDiff,
|
||||
options: viewerOptions,
|
||||
langs: collectDiffPayloadLanguageHints({ fileDiff: viewerResult.fileDiff }),
|
||||
},
|
||||
{ languagePackAvailable },
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
imageResult && imageOptions
|
||||
? normalizeDiffViewerPayloadLanguages(
|
||||
{
|
||||
prerenderedHTML: imageResult.prerenderedHTML,
|
||||
fileDiff: imageResult.fileDiff,
|
||||
options: imageOptions,
|
||||
langs: collectDiffPayloadLanguageHints({ fileDiff: imageResult.fileDiff }),
|
||||
},
|
||||
{ languagePackAvailable },
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
return buildRenderedSection({
|
||||
...(viewerPayload ? { viewerPayload } : {}),
|
||||
...(imagePayload ? { imagePayload } : {}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...buildRenderedBodies(sections),
|
||||
fileCount: files.length,
|
||||
usesLanguagePack: sections.some((section) => section.usesLanguagePack === true),
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizePatchFileLanguage(
|
||||
fileDiff: FileDiffMetadata,
|
||||
options: { languagePackAvailable: boolean },
|
||||
): Promise<FileDiffMetadata> {
|
||||
const lang = await normalizeSupportedLanguageHint(fileDiff.lang, options);
|
||||
if (lang === fileDiff.lang) {
|
||||
return fileDiff;
|
||||
}
|
||||
return {
|
||||
...fileDiff,
|
||||
...(lang ? { lang } : { lang: "text" }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderDiffDocument(
|
||||
input: DiffInput,
|
||||
options: DiffRenderOptions,
|
||||
target: DiffRenderTarget = "both",
|
||||
): Promise<RenderedDiffDocument> {
|
||||
const title = buildDiffTitle(input);
|
||||
const rendered =
|
||||
input.kind === "before_after"
|
||||
? await renderBeforeAfterDiff(input, options, target)
|
||||
: await renderPatchDiff(input, options, target);
|
||||
const viewerRuntime = rendered.usesLanguagePack ? "language-pack" : "base";
|
||||
|
||||
return {
|
||||
...(rendered.viewerBodyHtml
|
||||
? {
|
||||
html: buildHtmlDocument({
|
||||
title,
|
||||
bodyHtml: rendered.viewerBodyHtml,
|
||||
theme: options.presentation.theme,
|
||||
imageMaxWidth: options.image.maxWidth,
|
||||
runtimeMode: "viewer",
|
||||
viewerRuntime,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(rendered.imageBodyHtml
|
||||
? {
|
||||
imageHtml: buildHtmlDocument({
|
||||
title,
|
||||
bodyHtml: rendered.imageBodyHtml,
|
||||
theme: options.presentation.theme,
|
||||
imageMaxWidth: options.image.maxWidth,
|
||||
runtimeMode: "image",
|
||||
viewerRuntime,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
title,
|
||||
fileCount: rendered.fileCount,
|
||||
inputKind: input.kind,
|
||||
viewerRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
type PreloadedFileDiffResult = Awaited<ReturnType<typeof preloadFileDiff>>;
|
||||
type PreloadedMultiFileDiffResult = Awaited<ReturnType<typeof preloadMultiFileDiff>>;
|
||||
|
||||
function shouldFallbackToClientHydration(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof TypeError &&
|
||||
error.message.includes('needs an import attribute of "type: json"')
|
||||
);
|
||||
}
|
||||
|
||||
async function preloadFileDiffWithFallback(params: {
|
||||
fileDiff: FileDiffMetadata;
|
||||
options: DiffViewerOptions;
|
||||
}): Promise<PreloadedFileDiffResult> {
|
||||
try {
|
||||
return await preloadFileDiff(params);
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToClientHydration(error)) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
fileDiff: params.fileDiff,
|
||||
prerenderedHTML: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function preloadMultiFileDiffWithFallback(params: {
|
||||
oldFile: FileContents;
|
||||
newFile: FileContents;
|
||||
options: DiffViewerOptions;
|
||||
}): Promise<PreloadedMultiFileDiffResult> {
|
||||
try {
|
||||
return await preloadMultiFileDiff(params);
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToClientHydration(error)) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
oldFile: params.oldFile,
|
||||
newFile: params.newFile,
|
||||
prerenderedHTML: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
87
extensions/diffs/src/shiki-curated-languages.ts
Normal file
87
extensions/diffs/src/shiki-curated-languages.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Diffs plugin module implements shiki curated languages behavior.
|
||||
const javascript = () => import("@shikijs/langs/javascript");
|
||||
const typescript = () => import("@shikijs/langs/typescript");
|
||||
const tsx = () => import("@shikijs/langs/tsx");
|
||||
const jsx = () => import("@shikijs/langs/jsx");
|
||||
const json = () => import("@shikijs/langs/json");
|
||||
const markdown = () => import("@shikijs/langs/markdown");
|
||||
const yaml = () => import("@shikijs/langs/yaml");
|
||||
const css = () => import("@shikijs/langs/css");
|
||||
const html = () => import("@shikijs/langs/html");
|
||||
const sh = () => import("@shikijs/langs/sh");
|
||||
const python = () => import("@shikijs/langs/python");
|
||||
const go = () => import("@shikijs/langs/go");
|
||||
const rust = () => import("@shikijs/langs/rust");
|
||||
const java = () => import("@shikijs/langs/java");
|
||||
const c = () => import("@shikijs/langs/c");
|
||||
const cpp = () => import("@shikijs/langs/cpp");
|
||||
const csharp = () => import("@shikijs/langs/csharp");
|
||||
const php = () => import("@shikijs/langs/php");
|
||||
const sql = () => import("@shikijs/langs/sql");
|
||||
const docker = () => import("@shikijs/langs/docker");
|
||||
const ruby = () => import("@shikijs/langs/ruby");
|
||||
const swift = () => import("@shikijs/langs/swift");
|
||||
const kotlin = () => import("@shikijs/langs/kotlin");
|
||||
const r = () => import("@shikijs/langs/r");
|
||||
const dart = () => import("@shikijs/langs/dart");
|
||||
const lua = () => import("@shikijs/langs/lua");
|
||||
const powershell = () => import("@shikijs/langs/powershell");
|
||||
const xml = () => import("@shikijs/langs/xml");
|
||||
const toml = () => import("@shikijs/langs/toml");
|
||||
|
||||
type CuratedLanguageInfo = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly aliases?: readonly string[];
|
||||
readonly import: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
export const bundledLanguagesInfo = [
|
||||
{ id: "javascript", name: "JavaScript", aliases: ["js", "mjs", "cjs"], import: javascript },
|
||||
{ id: "typescript", name: "TypeScript", aliases: ["ts", "mts", "cts"], import: typescript },
|
||||
{ id: "tsx", name: "TSX", import: tsx },
|
||||
{ id: "jsx", name: "JSX", import: jsx },
|
||||
{ id: "json", name: "JSON", aliases: ["jsonc", "json5", "jsonl"], import: json },
|
||||
{ id: "markdown", name: "Markdown", aliases: ["md"], import: markdown },
|
||||
{ id: "yaml", name: "YAML", aliases: ["yml"], import: yaml },
|
||||
{ id: "css", name: "CSS", import: css },
|
||||
{ id: "html", name: "HTML", import: html },
|
||||
{ id: "sh", name: "Shell", aliases: ["bash", "shell", "shellscript", "zsh"], import: sh },
|
||||
{ id: "python", name: "Python", aliases: ["py"], import: python },
|
||||
{ id: "go", name: "Go", import: go },
|
||||
{ id: "rust", name: "Rust", aliases: ["rs"], import: rust },
|
||||
{ id: "java", name: "Java", import: java },
|
||||
{ id: "c", name: "C", import: c },
|
||||
{ id: "cpp", name: "C++", aliases: ["c++"], import: cpp },
|
||||
{ id: "csharp", name: "C#", aliases: ["c#", "cs"], import: csharp },
|
||||
{ id: "php", name: "PHP", import: php },
|
||||
{ id: "sql", name: "SQL", import: sql },
|
||||
{ id: "docker", name: "Docker", aliases: ["dockerfile"], import: docker },
|
||||
{ id: "ruby", name: "Ruby", aliases: ["rb"], import: ruby },
|
||||
{ id: "swift", name: "Swift", import: swift },
|
||||
{ id: "kotlin", name: "Kotlin", aliases: ["kt", "kts"], import: kotlin },
|
||||
{ id: "r", name: "R", import: r },
|
||||
{ id: "dart", name: "Dart", import: dart },
|
||||
{ id: "lua", name: "Lua", import: lua },
|
||||
{ id: "powershell", name: "PowerShell", aliases: ["ps", "ps1"], import: powershell },
|
||||
{ id: "xml", name: "XML", import: xml },
|
||||
{ id: "toml", name: "TOML", import: toml },
|
||||
] as const satisfies readonly CuratedLanguageInfo[];
|
||||
|
||||
export const bundledLanguagesBase = Object.fromEntries(
|
||||
bundledLanguagesInfo.map((language) => [language.id, language.import]),
|
||||
);
|
||||
export function getBundledLanguageAliases(
|
||||
language: (typeof bundledLanguagesInfo)[number],
|
||||
): readonly string[] {
|
||||
return "aliases" in language ? language.aliases : [];
|
||||
}
|
||||
export const bundledLanguagesAlias = Object.fromEntries(
|
||||
bundledLanguagesInfo.flatMap((language) =>
|
||||
getBundledLanguageAliases(language).map((alias) => [alias, language.import]),
|
||||
),
|
||||
);
|
||||
export const bundledLanguages = {
|
||||
...bundledLanguagesBase,
|
||||
...bundledLanguagesAlias,
|
||||
};
|
||||
491
extensions/diffs/src/store.test.ts
Normal file
491
extensions/diffs/src/store.test.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
// Diffs tests cover store plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import path from "node:path";
|
||||
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness, ensureCuratedViewerRuntimeForTests } from "./test-helpers.js";
|
||||
|
||||
beforeAll(async () => {
|
||||
await ensureCuratedViewerRuntimeForTests();
|
||||
});
|
||||
|
||||
describe("DiffArtifactStore", () => {
|
||||
let rootDir: string;
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
({
|
||||
rootDir,
|
||||
store,
|
||||
cleanup: cleanupRootDir,
|
||||
} = await createDiffStoreHarness("openclaw-diffs-store-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("creates and retrieves an artifact", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
context: {
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const loaded = await store.getArtifact(artifact.id, artifact.token);
|
||||
expect(loaded?.id).toBe(artifact.id);
|
||||
expect(loaded?.context).toEqual({
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
});
|
||||
expect(await store.readHtml(artifact.id)).toBe("<html>demo</html>");
|
||||
});
|
||||
|
||||
it("caps artifact expiry instead of throwing near the Date boundary", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000 - 1_000));
|
||||
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "patch",
|
||||
fileCount: 1,
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
|
||||
expect(artifact.expiresAt).toBe("+275760-09-13T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("expires artifacts after the ttl", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-02-27T16:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "patch",
|
||||
fileCount: 2,
|
||||
ttlMs: 1_000,
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 2_000));
|
||||
const loaded = await store.getArtifact(artifact.id, artifact.token);
|
||||
expect(loaded).toBeNull();
|
||||
});
|
||||
|
||||
it("updates the stored file path", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const filePath = store.allocateFilePath(artifact.id);
|
||||
const updated = await store.updateFilePath(artifact.id, filePath);
|
||||
expect(updated.filePath).toBe(filePath);
|
||||
expect(updated.imagePath).toBe(filePath);
|
||||
});
|
||||
|
||||
it("rejects file paths that escape the store root", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
await expect(store.updateFilePath(artifact.id, "../outside.png")).rejects.toThrow(
|
||||
"escapes store root",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects tampered html metadata paths outside the store root", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
const metaPath = path.join(rootDir, artifact.id, "meta.json");
|
||||
const rawMeta = await fs.readFile(metaPath, "utf8");
|
||||
const meta = JSON.parse(rawMeta) as { htmlPath: string };
|
||||
meta.htmlPath = "../outside.html";
|
||||
await fs.writeFile(metaPath, JSON.stringify(meta), "utf8");
|
||||
|
||||
await expect(store.readHtml(artifact.id)).rejects.toThrow("escapes store root");
|
||||
});
|
||||
|
||||
it("creates standalone file artifacts with managed metadata", async () => {
|
||||
const standalone = await store.createStandaloneFileArtifact({
|
||||
context: {
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
},
|
||||
});
|
||||
expect(standalone.filePath).toMatch(/preview\.png$/);
|
||||
expect(standalone.filePath).toContain(rootDir);
|
||||
expect(Date.parse(standalone.expiresAt)).toBeGreaterThan(Date.now());
|
||||
expect(standalone.context).toEqual({
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("caps standalone file expiry instead of throwing near the Date boundary", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000 - 1_000));
|
||||
|
||||
const standalone = await store.createStandaloneFileArtifact({ ttlMs: 60_000 });
|
||||
|
||||
expect(standalone.expiresAt).toBe("+275760-09-13T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("expires standalone file artifacts using ttl metadata", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-02-27T16:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const standalone = await store.createStandaloneFileArtifact({
|
||||
format: "png",
|
||||
ttlMs: 1_000,
|
||||
});
|
||||
await fs.writeFile(standalone.filePath, Buffer.from("png"));
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 2_000));
|
||||
await store.cleanupExpired();
|
||||
|
||||
const error = await fs.stat(path.dirname(standalone.filePath)).then(
|
||||
() => undefined,
|
||||
(statError: unknown) => statError,
|
||||
);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
||||
});
|
||||
|
||||
it("supports image path aliases for backward compatibility", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const imagePath = store.allocateImagePath(artifact.id, "pdf");
|
||||
expect(imagePath).toMatch(/preview\.pdf$/);
|
||||
const standalone = await store.createStandaloneFileArtifact();
|
||||
expect(standalone.filePath).toMatch(/preview\.png$/);
|
||||
|
||||
const updated = await store.updateImagePath(artifact.id, imagePath);
|
||||
expect(updated.filePath).toBe(imagePath);
|
||||
expect(updated.imagePath).toBe(imagePath);
|
||||
});
|
||||
|
||||
it("allocates PDF file paths when format is pdf", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const artifactPdf = store.allocateFilePath(artifact.id, "pdf");
|
||||
const standalonePdf = await store.createStandaloneFileArtifact({ format: "pdf" });
|
||||
expect(artifactPdf).toMatch(/preview\.pdf$/);
|
||||
expect(standalonePdf.filePath).toMatch(/preview\.pdf$/);
|
||||
});
|
||||
|
||||
it("throttles cleanup sweeps across repeated artifact creation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-02-27T16:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
store = new DiffArtifactStore({
|
||||
rootDir,
|
||||
cleanupIntervalMs: 60_000,
|
||||
});
|
||||
const cleanupSpy = vi.spyOn(store, "cleanupExpired").mockResolvedValue();
|
||||
|
||||
await store.createArtifact({
|
||||
html: "<html>one</html>",
|
||||
title: "One",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
await store.createArtifact({
|
||||
html: "<html>two</html>",
|
||||
title: "Two",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 61_000));
|
||||
await store.createArtifact({
|
||||
html: "<html>three</html>",
|
||||
title: "Three",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDiffsHttpHandler", () => {
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
async function handleLocalGet(url: string) {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(artifact.viewerPath);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(
|
||||
artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects malformed artifact ids before reading from disk", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the shared viewer asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("./viewer-runtime.js?v=");
|
||||
});
|
||||
|
||||
it("serves the shared viewer runtime asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer-runtime.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "allows direct loopback viewer access by default",
|
||||
request: localReq,
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "allows ipv4-mapped ipv6 loopback viewer access by default",
|
||||
request: ipv4MappedLoopbackReq,
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "blocks non-loopback viewer access by default",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks loopback requests that carry proxy forwarding headers by default",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks trusted-proxy loopback requests without client-origin headers by default",
|
||||
request: localReq,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks proxied loopback requests when trusted proxies are configured",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "allows remote access when allowRemoteViewer is enabled",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
])(
|
||||
"$name",
|
||||
async ({ request, headers, trustedProxies, allowRemoteViewer, expectedStatusCode }) => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer, trustedProxies });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
request({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
headers,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
if (expectedStatusCode === 200) {
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rate-limits repeated remote misses", async () => {
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const miss = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
miss,
|
||||
);
|
||||
expect(miss.statusCode).toBe(404);
|
||||
}
|
||||
|
||||
const limited = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
limited,
|
||||
);
|
||||
expect(limited.statusCode).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
async function createViewerArtifact(store: DiffArtifactStore) {
|
||||
return await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function remoteReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "203.0.113.10" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function ipv4MappedLoopbackReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "::ffff:127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
399
extensions/diffs/src/store.ts
Normal file
399
extensions/diffs/src/store.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
// Diffs plugin module implements store behavior.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { MAX_DATE_TIMESTAMP_MS, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { PluginLogger } from "../api.js";
|
||||
import type { DiffArtifactContext, DiffArtifactMeta, DiffOutputFormat } from "./types.js";
|
||||
|
||||
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
||||
const MAX_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const SWEEP_FALLBACK_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const VIEWER_PREFIX = "/plugins/diffs/view";
|
||||
|
||||
type CreateArtifactParams = {
|
||||
html: string;
|
||||
title: string;
|
||||
inputKind: DiffArtifactMeta["inputKind"];
|
||||
fileCount: number;
|
||||
ttlMs?: number;
|
||||
context?: DiffArtifactContext;
|
||||
};
|
||||
|
||||
type CreateStandaloneFileArtifactParams = {
|
||||
format?: DiffOutputFormat;
|
||||
ttlMs?: number;
|
||||
context?: DiffArtifactContext;
|
||||
};
|
||||
|
||||
type StandaloneFileMeta = {
|
||||
kind: "standalone_file";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
filePath: string;
|
||||
context?: DiffArtifactContext;
|
||||
};
|
||||
|
||||
type ArtifactMetaFileName = "meta.json" | "file-meta.json";
|
||||
type ArtifactRoot = Awaited<ReturnType<typeof fsRoot>>;
|
||||
|
||||
export class DiffArtifactStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly logger?: PluginLogger;
|
||||
private readonly cleanupIntervalMs: number;
|
||||
private cleanupInFlight: Promise<void> | null = null;
|
||||
private nextCleanupAt = 0;
|
||||
|
||||
constructor(params: { rootDir: string; logger?: PluginLogger; cleanupIntervalMs?: number }) {
|
||||
this.rootDir = path.resolve(params.rootDir);
|
||||
this.logger = params.logger;
|
||||
this.cleanupIntervalMs =
|
||||
params.cleanupIntervalMs === undefined
|
||||
? DEFAULT_CLEANUP_INTERVAL_MS
|
||||
: Math.max(0, Math.floor(params.cleanupIntervalMs));
|
||||
}
|
||||
|
||||
async createArtifact(params: CreateArtifactParams): Promise<DiffArtifactMeta> {
|
||||
await this.ensureRoot();
|
||||
|
||||
const id = crypto.randomBytes(10).toString("hex");
|
||||
const token = crypto.randomBytes(24).toString("hex");
|
||||
const artifactDir = this.artifactDir(id);
|
||||
const htmlPath = path.join(artifactDir, "viewer.html");
|
||||
const ttlMs = normalizeTtlMs(params.ttlMs);
|
||||
const createdAt = new Date();
|
||||
const createdAtIso = createdAt.toISOString();
|
||||
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
|
||||
const meta: DiffArtifactMeta = {
|
||||
id,
|
||||
token,
|
||||
title: params.title,
|
||||
inputKind: params.inputKind,
|
||||
fileCount: params.fileCount,
|
||||
createdAt: createdAtIso,
|
||||
expiresAt,
|
||||
viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
|
||||
htmlPath,
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
};
|
||||
|
||||
const root = await this.artifactRoot();
|
||||
await root.mkdir(id);
|
||||
await root.write(path.posix.join(id, "viewer.html"), params.html);
|
||||
await this.writeMeta(meta);
|
||||
this.scheduleCleanup();
|
||||
return meta;
|
||||
}
|
||||
|
||||
async getArtifact(id: string, token: string): Promise<DiffArtifactMeta | null> {
|
||||
const meta = await this.readMeta(id);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
if (meta.token !== token) {
|
||||
return null;
|
||||
}
|
||||
if (isExpired(meta)) {
|
||||
await this.deleteArtifact(id);
|
||||
return null;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
async readHtml(id: string): Promise<string> {
|
||||
const meta = await this.readMeta(id);
|
||||
if (!meta) {
|
||||
throw new Error(`Diff artifact not found: ${id}`);
|
||||
}
|
||||
const htmlPath = this.normalizeStoredPath(meta.htmlPath, "htmlPath");
|
||||
return await (await this.artifactRoot()).readText(this.relativeStoredPath(htmlPath));
|
||||
}
|
||||
|
||||
async updateFilePath(id: string, filePath: string): Promise<DiffArtifactMeta> {
|
||||
const meta = await this.readMeta(id);
|
||||
if (!meta) {
|
||||
throw new Error(`Diff artifact not found: ${id}`);
|
||||
}
|
||||
const normalizedFilePath = this.normalizeStoredPath(filePath, "filePath");
|
||||
const next: DiffArtifactMeta = {
|
||||
...meta,
|
||||
filePath: normalizedFilePath,
|
||||
imagePath: normalizedFilePath,
|
||||
};
|
||||
await this.writeMeta(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
|
||||
return this.updateFilePath(id, imagePath);
|
||||
}
|
||||
|
||||
allocateFilePath(id: string, format: DiffOutputFormat = "png"): string {
|
||||
return path.join(this.artifactDir(id), `preview.${format}`);
|
||||
}
|
||||
|
||||
async createStandaloneFileArtifact(
|
||||
params: CreateStandaloneFileArtifactParams = {},
|
||||
): Promise<{ id: string; filePath: string; expiresAt: string; context?: DiffArtifactContext }> {
|
||||
await this.ensureRoot();
|
||||
|
||||
const id = crypto.randomBytes(10).toString("hex");
|
||||
const artifactDir = this.artifactDir(id);
|
||||
const format = params.format ?? "png";
|
||||
const filePath = path.join(artifactDir, `preview.${format}`);
|
||||
const ttlMs = normalizeTtlMs(params.ttlMs);
|
||||
const createdAt = new Date();
|
||||
const createdAtIso = createdAt.toISOString();
|
||||
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
|
||||
const meta: StandaloneFileMeta = {
|
||||
kind: "standalone_file",
|
||||
id,
|
||||
createdAt: createdAtIso,
|
||||
expiresAt,
|
||||
filePath: this.normalizeStoredPath(filePath, "filePath"),
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
};
|
||||
|
||||
await (await this.artifactRoot()).mkdir(id);
|
||||
await this.writeStandaloneMeta(meta);
|
||||
this.scheduleCleanup();
|
||||
return {
|
||||
id,
|
||||
filePath: meta.filePath,
|
||||
expiresAt: meta.expiresAt,
|
||||
...(meta.context ? { context: meta.context } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
allocateImagePath(id: string, format: DiffOutputFormat = "png"): string {
|
||||
return this.allocateFilePath(id, format);
|
||||
}
|
||||
|
||||
scheduleCleanup(): void {
|
||||
this.maybeCleanupExpired();
|
||||
}
|
||||
|
||||
async cleanupExpired(): Promise<void> {
|
||||
const root = await this.artifactRoot();
|
||||
const entries = await root.list("", { withFileTypes: true }).catch(() => []);
|
||||
const now = Date.now();
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory)
|
||||
.map(async (entry) => {
|
||||
const id = entry.name;
|
||||
const meta = await this.readMeta(id);
|
||||
if (meta) {
|
||||
if (isExpired(meta)) {
|
||||
await this.deleteArtifact(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const standaloneMeta = await this.readStandaloneMeta(id);
|
||||
if (standaloneMeta) {
|
||||
if (isExpired(standaloneMeta)) {
|
||||
await this.deleteArtifact(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (now - entry.mtimeMs > SWEEP_FALLBACK_AGE_MS) {
|
||||
await this.deleteArtifact(id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureRoot(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
}
|
||||
|
||||
private async artifactRoot(): Promise<ArtifactRoot> {
|
||||
await this.ensureRoot();
|
||||
return await fsRoot(this.rootDir);
|
||||
}
|
||||
|
||||
private maybeCleanupExpired(): void {
|
||||
const now = Date.now();
|
||||
if (this.cleanupInFlight || now < this.nextCleanupAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nextCleanupAt = now + this.cleanupIntervalMs;
|
||||
const cleanupPromise = this.cleanupExpired()
|
||||
.catch((error: unknown) => {
|
||||
this.nextCleanupAt = 0;
|
||||
this.logger?.warn(`Failed to clean expired diff artifacts: ${String(error)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.cleanupInFlight === cleanupPromise) {
|
||||
this.cleanupInFlight = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.cleanupInFlight = cleanupPromise;
|
||||
}
|
||||
|
||||
private artifactDir(id: string): string {
|
||||
return this.resolveWithinRoot(id);
|
||||
}
|
||||
|
||||
private async writeMeta(meta: DiffArtifactMeta): Promise<void> {
|
||||
await this.writeJsonMeta(meta.id, "meta.json", meta);
|
||||
}
|
||||
|
||||
private async readMeta(id: string): Promise<DiffArtifactMeta | null> {
|
||||
const parsed = await this.readJsonMeta(id, "meta.json", "diff artifact");
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return parsed as DiffArtifactMeta;
|
||||
}
|
||||
|
||||
private async writeStandaloneMeta(meta: StandaloneFileMeta): Promise<void> {
|
||||
await this.writeJsonMeta(meta.id, "file-meta.json", meta);
|
||||
}
|
||||
|
||||
private async readStandaloneMeta(id: string): Promise<StandaloneFileMeta | null> {
|
||||
const parsed = await this.readJsonMeta(id, "file-meta.json", "standalone diff");
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const value = parsed as Partial<StandaloneFileMeta>;
|
||||
if (
|
||||
value.kind !== "standalone_file" ||
|
||||
typeof value.id !== "string" ||
|
||||
typeof value.createdAt !== "string" ||
|
||||
typeof value.expiresAt !== "string" ||
|
||||
typeof value.filePath !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: value.kind,
|
||||
id: value.id,
|
||||
createdAt: value.createdAt,
|
||||
expiresAt: value.expiresAt,
|
||||
filePath: this.normalizeStoredPath(value.filePath, "filePath"),
|
||||
...(value.context ? { context: normalizeArtifactContext(value.context) } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger?.warn(`Failed to normalize standalone diff metadata for ${id}: ${String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeJsonMeta(
|
||||
id: string,
|
||||
fileName: ArtifactMetaFileName,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
await (await this.artifactRoot()).writeJson(path.posix.join(id, fileName), data, { space: 2 });
|
||||
}
|
||||
|
||||
private async readJsonMeta(
|
||||
id: string,
|
||||
fileName: ArtifactMetaFileName,
|
||||
context: string,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const raw = await (await this.artifactRoot()).readText(path.posix.join(id, fileName));
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch (error) {
|
||||
if (isFileNotFound(error)) {
|
||||
return null;
|
||||
}
|
||||
this.logger?.warn(`Failed to read ${context} metadata for ${id}: ${String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteArtifact(id: string): Promise<void> {
|
||||
await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
private resolveWithinRoot(...parts: string[]): string {
|
||||
const candidate = path.resolve(this.rootDir, ...parts);
|
||||
this.assertWithinRoot(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private normalizeStoredPath(rawPath: string, label: string): string {
|
||||
const candidate = path.isAbsolute(rawPath)
|
||||
? path.resolve(rawPath)
|
||||
: path.resolve(this.rootDir, rawPath);
|
||||
this.assertWithinRoot(candidate, label);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private relativeStoredPath(storedPath: string): string {
|
||||
const relativePath = path.relative(this.rootDir, this.normalizeStoredPath(storedPath, "path"));
|
||||
return relativePath.split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
private assertWithinRoot(candidate: string, label = "path"): void {
|
||||
const relative = path.relative(this.rootDir, candidate);
|
||||
if (
|
||||
relative === "" ||
|
||||
(!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Diff artifact ${label} escapes store root: ${candidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTtlMs(value?: number): number {
|
||||
if (!Number.isFinite(value) || value === undefined) {
|
||||
return DEFAULT_TTL_MS;
|
||||
}
|
||||
const rounded = Math.floor(value);
|
||||
if (rounded <= 0) {
|
||||
return DEFAULT_TTL_MS;
|
||||
}
|
||||
return Math.min(rounded, MAX_TTL_MS);
|
||||
}
|
||||
|
||||
function resolveExpiresAtIso(createdAtMs: number, ttlMs: number): string {
|
||||
return (
|
||||
timestampMsToIsoString(createdAtMs + ttlMs) ??
|
||||
timestampMsToIsoString(MAX_DATE_TIMESTAMP_MS) ??
|
||||
"1970-01-01T00:00:00.000Z"
|
||||
);
|
||||
}
|
||||
|
||||
function isExpired(meta: { expiresAt: string }): boolean {
|
||||
const expiresAt = Date.parse(meta.expiresAt);
|
||||
if (!Number.isFinite(expiresAt)) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() >= expiresAt;
|
||||
}
|
||||
|
||||
function isFileNotFound(error: unknown): boolean {
|
||||
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
||||
return code === "ENOENT" || code === "not-found";
|
||||
}
|
||||
|
||||
function normalizeArtifactContext(value: unknown): DiffArtifactContext | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const raw = value as Record<string, unknown>;
|
||||
const context = {
|
||||
agentId: normalizeOptionalString(raw.agentId),
|
||||
sessionId: normalizeOptionalString(raw.sessionId),
|
||||
messageChannel: normalizeOptionalString(raw.messageChannel),
|
||||
agentAccountId: normalizeOptionalString(raw.agentAccountId),
|
||||
};
|
||||
|
||||
return Object.values(context).some((entry) => entry !== undefined) ? context : undefined;
|
||||
}
|
||||
62
extensions/diffs/src/test-helpers.ts
Normal file
62
extensions/diffs/src/test-helpers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Diffs helper module supports test helpers behavior.
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../api.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCuratedViewerRuntimeForTests(): Promise<void> {
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const runtimePath = path.join(repoRoot, "extensions", "diffs", "assets", "viewer-runtime.js");
|
||||
if (await pathExists(runtimePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The curated runtime is generated output. Source tests that serve viewer
|
||||
// assets need a clean-checkout fixture before the normal build hook runs.
|
||||
await execFileAsync(process.execPath, ["scripts/build-diffs-viewer-runtime.mjs", "curated"], {
|
||||
cwd: repoRoot,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTempDiffRoot(prefix: string): Promise<{
|
||||
rootDir: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const rootDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), prefix));
|
||||
return {
|
||||
rootDir,
|
||||
cleanup: async () => {
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDiffStoreHarness(prefix: string): Promise<{
|
||||
rootDir: string;
|
||||
store: DiffArtifactStore;
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const { rootDir, cleanup } = await createTempDiffRoot(prefix);
|
||||
return {
|
||||
rootDir,
|
||||
store: new DiffArtifactStore({ rootDir }),
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
108
extensions/diffs/src/tool-render-output.test.ts
Normal file
108
extensions/diffs/src/tool-render-output.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// Diffs tests cover tool render output plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi } from "../api.js";
|
||||
import type { DiffScreenshotter } from "./browser.js";
|
||||
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
|
||||
const { renderDiffDocumentMock } = vi.hoisted(() => ({
|
||||
renderDiffDocumentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./render.js", () => ({
|
||||
renderDiffDocument: renderDiffDocumentMock,
|
||||
}));
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./render.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("diffs tool rendered output guards", () => {
|
||||
let createDiffsTool: typeof import("./tool.js").createDiffsTool;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
let store: Awaited<ReturnType<typeof createDiffStoreHarness>>["store"];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createDiffsTool } = await import("./tool.js"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
renderDiffDocumentMock.mockReset();
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness(
|
||||
"openclaw-diffs-tool-render-output-",
|
||||
));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("accepts empty string image html for file output", async () => {
|
||||
renderDiffDocumentMock.mockResolvedValue({
|
||||
title: "Text diff",
|
||||
fileCount: 1,
|
||||
inputKind: "before_after",
|
||||
imageHtml: "",
|
||||
});
|
||||
|
||||
const screenshotter = createPngScreenshotter({
|
||||
assertHtml: (html) => {
|
||||
expect(html).toBe("");
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
screenshotter,
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-empty-image-html", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "file",
|
||||
});
|
||||
|
||||
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
|
||||
expect((result.details as Record<string, unknown>).filePath).toMatch(/preview\.png$/);
|
||||
});
|
||||
});
|
||||
|
||||
function createApi(): OpenClawPluginApi {
|
||||
return createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
runtime: {} as OpenClawPluginApi["runtime"],
|
||||
});
|
||||
}
|
||||
|
||||
function createPngScreenshotter(
|
||||
params: {
|
||||
assertHtml?: (html: string) => void;
|
||||
} = {},
|
||||
): DiffScreenshotter {
|
||||
const screenshotHtml: DiffScreenshotter["screenshotHtml"] = vi.fn(
|
||||
async ({ html, outputPath }: { html: string; outputPath: string }) => {
|
||||
params.assertHtml?.(html);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, Buffer.from("png"));
|
||||
return outputPath;
|
||||
},
|
||||
);
|
||||
return {
|
||||
screenshotHtml,
|
||||
};
|
||||
}
|
||||
672
extensions/diffs/src/tool.test.ts
Normal file
672
extensions/diffs/src/tool.test.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
// Diffs tests cover tool plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
|
||||
import type { DiffScreenshotter } from "./browser.js";
|
||||
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
import { createDiffsTool } from "./tool.js";
|
||||
import type { DiffRenderOptions } from "./types.js";
|
||||
|
||||
describe("diffs tool", () => {
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-tool-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("returns a viewer URL in view mode", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-1", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
path: "README.md",
|
||||
mode: "view",
|
||||
});
|
||||
|
||||
const text = readTextContent(result, 0);
|
||||
expect(text).toContain("http://127.0.0.1:18789/plugins/diffs/view/");
|
||||
expect(String(readDetails(result).viewerUrl)).toContain(
|
||||
"http://127.0.0.1:18789/plugins/diffs/view/",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured viewerBaseUrl when tool input omits baseUrl", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi({
|
||||
viewerBaseUrl: "https://example.com/openclaw/",
|
||||
}),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
viewerBaseUrl: "https://example.com/openclaw",
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-viewer-config", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
path: "README.md",
|
||||
mode: "view",
|
||||
});
|
||||
|
||||
expect(readTextContent(result, 0)).toContain(
|
||||
"https://example.com/openclaw/plugins/diffs/view/",
|
||||
);
|
||||
expect(String((result.details as Record<string, unknown>).viewerUrl)).toContain(
|
||||
"https://example.com/openclaw/plugins/diffs/view/",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers per-call baseUrl over configured viewerBaseUrl", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi({
|
||||
viewerBaseUrl: "https://example.com/openclaw",
|
||||
}),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
viewerBaseUrl: "https://example.com/openclaw",
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-viewer-override", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
path: "README.md",
|
||||
mode: "view",
|
||||
baseUrl: "https://preview.example.com/review",
|
||||
});
|
||||
|
||||
expect(readTextContent(result, 0)).toContain(
|
||||
"https://preview.example.com/review/plugins/diffs/view/",
|
||||
);
|
||||
expect(String((result.details as Record<string, unknown>).viewerUrl)).toContain(
|
||||
"https://preview.example.com/review/plugins/diffs/view/",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose reserved format in the tool schema", () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
});
|
||||
|
||||
const properties = readParametersProperties(tool.parameters);
|
||||
expect(properties).not.toHaveProperty("format");
|
||||
});
|
||||
|
||||
it("returns an image artifact in image mode", async () => {
|
||||
const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
|
||||
const screenshotter = createPngScreenshotter({
|
||||
assertHtml: (html) => {
|
||||
expect(html).toContain("../../assets/viewer.js");
|
||||
},
|
||||
assertImage: (image) => {
|
||||
expect(image.format).toBe("png");
|
||||
expect(image.qualityPreset).toBe("standard");
|
||||
expect(image.scale).toBe(2);
|
||||
expect(image.maxWidth).toBe(960);
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createToolWithScreenshotter(store, screenshotter);
|
||||
|
||||
const result = await tool.execute?.("tool-2", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "image",
|
||||
});
|
||||
|
||||
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
|
||||
expect(readTextContent(result, 0)).toContain("Diff PNG generated at:");
|
||||
expect(readTextContent(result, 0)).toContain("Use the `message` tool");
|
||||
expect(result?.content).toHaveLength(1);
|
||||
const details = readDetails(result);
|
||||
expect(requireString(details.filePath, "filePath")).toMatch(/preview\.png$/);
|
||||
expect(requireString(details.imagePath, "imagePath")).toMatch(/preview\.png$/);
|
||||
expect(details.format).toBe("png");
|
||||
expect(details.fileQuality).toBe("standard");
|
||||
expect(details.imageQuality).toBe("standard");
|
||||
expect(details.fileScale).toBe(2);
|
||||
expect(details.imageScale).toBe(2);
|
||||
expect(details.fileMaxWidth).toBe(960);
|
||||
expect(details.imageMaxWidth).toBe(960);
|
||||
expect(details.viewerUrl).toBeUndefined();
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders PDF output when fileFormat is pdf", async () => {
|
||||
const screenshotter = createPdfScreenshotter({
|
||||
assertOutputPath: (outputPath) => {
|
||||
expect(outputPath).toMatch(/preview\.pdf$/);
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
screenshotter,
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-2b", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "image",
|
||||
fileFormat: "pdf",
|
||||
});
|
||||
|
||||
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
|
||||
expect(readTextContent(result, 0)).toContain("Diff PDF generated at:");
|
||||
expect((result.details as Record<string, unknown>).format).toBe("pdf");
|
||||
expect((result.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
|
||||
});
|
||||
|
||||
it("accepts mode=file as an alias for file artifact rendering", async () => {
|
||||
const screenshotter = createPngScreenshotter({
|
||||
assertOutputPath: (outputPath) => {
|
||||
expect(outputPath).toMatch(/preview\.png$/);
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createToolWithScreenshotter(store, screenshotter);
|
||||
|
||||
const result = await tool.execute?.("tool-2c", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "file",
|
||||
});
|
||||
|
||||
expectArtifactOnlyFileResult(screenshotter, result);
|
||||
expect(requireString(readDetails(result).artifactId, "artifactId")).toMatch(/^[a-f0-9]{20}$/u);
|
||||
expect(requireString(readDetails(result).expiresAt, "expiresAt")).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("honors ttlSeconds for artifact-only file output", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-02-27T16:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
const screenshotter = createPngScreenshotter();
|
||||
const tool = createToolWithScreenshotter(store, screenshotter);
|
||||
|
||||
const result = await tool.execute?.("tool-2c-ttl", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "file",
|
||||
ttlSeconds: "1",
|
||||
});
|
||||
const filePath = requireString(readDetails(result).filePath, "filePath");
|
||||
await fs.access(filePath);
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 2_000));
|
||||
await store.cleanupExpired();
|
||||
await expectFsEnoent(fs.stat(filePath));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("caps artifact-only ttlSeconds that bypass schema validation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-02-27T16:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
const screenshotter = createPngScreenshotter();
|
||||
const tool = createToolWithScreenshotter(store, screenshotter);
|
||||
|
||||
const result = await tool.execute?.("tool-2c-ttl-cap", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "file",
|
||||
ttlSeconds: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
|
||||
expect(Date.parse(requireString(readDetails(result).expiresAt, "expiresAt"))).toBe(
|
||||
now.getTime() + 21_600_000,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default ttlSeconds when tool input omits ttlSeconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-02-27T16:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
const screenshotter = createPngScreenshotter();
|
||||
const tool = createToolWithScreenshotter(store, screenshotter, {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-2c-default-ttl", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "file",
|
||||
});
|
||||
const filePath = (result.details as Record<string, unknown>).filePath as string;
|
||||
const stat = await fs.stat(filePath);
|
||||
expect(stat.isFile()).toBe(true);
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 61_000));
|
||||
await store.cleanupExpired();
|
||||
await expectFsEnoent(fs.stat(filePath));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts image* tool options for backward compatibility", async () => {
|
||||
const screenshotter = createPngScreenshotter({
|
||||
assertImage: (image) => {
|
||||
expect(image.qualityPreset).toBe("hq");
|
||||
expect(image.scale).toBe(2.4);
|
||||
expect(image.maxWidth).toBe(1100);
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createToolWithScreenshotter(store, screenshotter);
|
||||
|
||||
const result = await tool.execute?.("tool-2legacy", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "file",
|
||||
imageQuality: "hq",
|
||||
imageScale: "2.4",
|
||||
imageMaxWidth: "1100",
|
||||
});
|
||||
|
||||
expect((result.details as Record<string, unknown>).fileQuality).toBe("hq");
|
||||
expect((result.details as Record<string, unknown>).fileScale).toBe(2.4);
|
||||
expect((result.details as Record<string, unknown>).fileMaxWidth).toBe(1100);
|
||||
});
|
||||
|
||||
it("accepts deprecated format alias for fileFormat", async () => {
|
||||
const screenshotter = createPdfScreenshotter();
|
||||
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
screenshotter,
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-2format", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "file",
|
||||
format: "pdf",
|
||||
});
|
||||
|
||||
expect((result.details as Record<string, unknown>).fileFormat).toBe("pdf");
|
||||
expect((result.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
|
||||
});
|
||||
|
||||
it("honors defaults.mode=file when mode is omitted", async () => {
|
||||
const screenshotter = createPngScreenshotter();
|
||||
const tool = createToolWithScreenshotter(store, screenshotter, {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
mode: "file",
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-2d", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
});
|
||||
|
||||
expectArtifactOnlyFileResult(screenshotter, result);
|
||||
});
|
||||
|
||||
it("falls back to view output when both mode cannot render an image", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
screenshotter: {
|
||||
screenshotHtml: vi.fn(async () => {
|
||||
throw new Error("browser missing");
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-3", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "both",
|
||||
});
|
||||
|
||||
expect(result?.content).toHaveLength(1);
|
||||
expect(readTextContent(result, 0)).toContain("File rendering failed");
|
||||
expect((result.details as Record<string, unknown>).fileError).toBe("browser missing");
|
||||
expect((result.details as Record<string, unknown>).imageError).toBe("browser missing");
|
||||
});
|
||||
|
||||
it("rejects invalid base URLs as tool input errors", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute?.("tool-4", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "view",
|
||||
baseUrl: "javascript:alert(1)",
|
||||
}),
|
||||
).rejects.toThrow("Invalid baseUrl");
|
||||
});
|
||||
|
||||
it("rejects oversized patch payloads", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute?.("tool-oversize-patch", {
|
||||
patch: "x".repeat(2_100_000),
|
||||
mode: "view",
|
||||
}),
|
||||
).rejects.toThrow("patch exceeds maximum size");
|
||||
});
|
||||
|
||||
it("rejects oversized before/after payloads", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
});
|
||||
|
||||
const large = "x".repeat(600_000);
|
||||
await expect(
|
||||
tool.execute?.("tool-oversize-before", {
|
||||
before: large,
|
||||
after: "ok",
|
||||
mode: "view",
|
||||
}),
|
||||
).rejects.toThrow("before exceeds maximum size");
|
||||
});
|
||||
|
||||
it("uses configured defaults when tool params omit them", async () => {
|
||||
const tool = createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults: {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
mode: "view",
|
||||
theme: "light",
|
||||
layout: "split",
|
||||
wordWrap: false,
|
||||
background: false,
|
||||
fontFamily: "JetBrains Mono",
|
||||
fontSize: 17,
|
||||
},
|
||||
context: {
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-5", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
path: "README.md",
|
||||
});
|
||||
|
||||
expect(readTextContent(result, 0)).toContain("Diff viewer ready.");
|
||||
expect((result.details as Record<string, unknown>).mode).toBe("view");
|
||||
expect((result.details as Record<string, unknown>).context).toEqual({
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
});
|
||||
|
||||
const viewerPath = String((result.details as Record<string, unknown>).viewerPath);
|
||||
const id = extractViewerArtifactId(viewerPath);
|
||||
const html = await store.readHtml(id);
|
||||
expect(html).toContain('body data-theme="light"');
|
||||
expect(html).toContain("--diffs-font-size: 17px;");
|
||||
expect(html).toContain("JetBrains Mono");
|
||||
});
|
||||
|
||||
it("prefers explicit tool params over configured defaults", async () => {
|
||||
const screenshotter = createPngScreenshotter({
|
||||
assertHtml: (html) => {
|
||||
expect(html).toContain("../../assets/viewer.js");
|
||||
},
|
||||
assertImage: (image) => {
|
||||
expect(image.format).toBe("png");
|
||||
expect(image.qualityPreset).toBe("print");
|
||||
expect(image.scale).toBe(2.75);
|
||||
expect(image.maxWidth).toBe(1320);
|
||||
},
|
||||
});
|
||||
const tool = createToolWithScreenshotter(store, screenshotter, {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
mode: "view",
|
||||
theme: "light",
|
||||
layout: "split",
|
||||
fileQuality: "hq",
|
||||
fileScale: 2.2,
|
||||
fileMaxWidth: 1180,
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-6", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "both",
|
||||
theme: "dark",
|
||||
layout: "unified",
|
||||
fileQuality: "print",
|
||||
fileScale: 2.75,
|
||||
fileMaxWidth: 1320,
|
||||
});
|
||||
|
||||
expect((result.details as Record<string, unknown>).mode).toBe("both");
|
||||
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
|
||||
expect((result.details as Record<string, unknown>).format).toBe("png");
|
||||
expect((result.details as Record<string, unknown>).fileQuality).toBe("print");
|
||||
expect((result.details as Record<string, unknown>).fileScale).toBe(2.75);
|
||||
expect((result.details as Record<string, unknown>).fileMaxWidth).toBe(1320);
|
||||
const viewerPath = String((result.details as Record<string, unknown>).viewerPath);
|
||||
const id = extractViewerArtifactId(viewerPath);
|
||||
const html = await store.readHtml(id);
|
||||
expect(html).toContain('body data-theme="dark"');
|
||||
});
|
||||
|
||||
it("routes tool context into artifact details for file mode", async () => {
|
||||
const screenshotter = createPngScreenshotter();
|
||||
const tool = createToolWithScreenshotter(store, screenshotter, DEFAULT_DIFFS_TOOL_DEFAULTS, {
|
||||
agentId: "reviewer",
|
||||
sessionId: "session-456",
|
||||
messageChannel: "telegram",
|
||||
agentAccountId: "work",
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-context-file", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "file",
|
||||
});
|
||||
|
||||
expect((result.details as Record<string, unknown>).context).toEqual({
|
||||
agentId: "reviewer",
|
||||
sessionId: "session-456",
|
||||
messageChannel: "telegram",
|
||||
agentAccountId: "work",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createApi(pluginConfig?: Record<string, unknown>): OpenClawPluginApi {
|
||||
return createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
pluginConfig,
|
||||
runtime: {} as OpenClawPluginApi["runtime"],
|
||||
});
|
||||
}
|
||||
|
||||
function createToolWithScreenshotter(
|
||||
store: DiffArtifactStore,
|
||||
screenshotter: DiffScreenshotter,
|
||||
defaults = DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
context: OpenClawPluginToolContext = {
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
},
|
||||
) {
|
||||
return createDiffsTool({
|
||||
api: createApi(),
|
||||
store,
|
||||
defaults,
|
||||
screenshotter,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
function expectArtifactOnlyFileResult(
|
||||
screenshotter: DiffScreenshotter,
|
||||
result: { details?: unknown } | null | undefined,
|
||||
) {
|
||||
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
|
||||
expect((result!.details as Record<string, unknown>).mode).toBe("file");
|
||||
expect((result!.details as Record<string, unknown>).viewerUrl).toBeUndefined();
|
||||
}
|
||||
|
||||
function createPngScreenshotter(
|
||||
params: {
|
||||
assertHtml?: (html: string) => void;
|
||||
assertImage?: (image: DiffRenderOptions["image"]) => void;
|
||||
assertOutputPath?: (outputPath: string) => void;
|
||||
} = {},
|
||||
): DiffScreenshotter {
|
||||
const screenshotHtml: DiffScreenshotter["screenshotHtml"] = vi.fn(
|
||||
async ({
|
||||
html,
|
||||
outputPath,
|
||||
image,
|
||||
}: {
|
||||
html: string;
|
||||
outputPath: string;
|
||||
image: DiffRenderOptions["image"];
|
||||
}) => {
|
||||
params.assertHtml?.(html);
|
||||
params.assertImage?.(image);
|
||||
params.assertOutputPath?.(outputPath);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, Buffer.from("png"));
|
||||
return outputPath;
|
||||
},
|
||||
);
|
||||
return {
|
||||
screenshotHtml,
|
||||
};
|
||||
}
|
||||
|
||||
function createPdfScreenshotter(
|
||||
params: {
|
||||
assertOutputPath?: (outputPath: string) => void;
|
||||
} = {},
|
||||
): DiffScreenshotter {
|
||||
const screenshotHtml: DiffScreenshotter["screenshotHtml"] = vi.fn(
|
||||
async ({ outputPath, image }: { outputPath: string; image: DiffRenderOptions["image"] }) => {
|
||||
expect(image.format).toBe("pdf");
|
||||
params.assertOutputPath?.(outputPath);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, Buffer.from("%PDF-1.7"));
|
||||
return outputPath;
|
||||
},
|
||||
);
|
||||
return { screenshotHtml };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readDetails(result: unknown): Record<string, unknown> {
|
||||
const details = (result as { details?: unknown } | null | undefined)?.details;
|
||||
if (!isRecord(details)) {
|
||||
throw new Error("expected diffs tool result details");
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
function extractViewerArtifactId(viewerPath: string): string {
|
||||
let previousSegment: string | undefined;
|
||||
let currentSegment: string | undefined;
|
||||
for (const segment of viewerPath.split("/")) {
|
||||
if (segment.length === 0) {
|
||||
continue;
|
||||
}
|
||||
previousSegment = currentSegment;
|
||||
currentSegment = segment;
|
||||
}
|
||||
if (!previousSegment) {
|
||||
throw new Error(`Missing artifact id in viewer path: ${viewerPath}`);
|
||||
}
|
||||
return previousSegment;
|
||||
}
|
||||
|
||||
function readParametersProperties(parameters: unknown): Record<string, unknown> {
|
||||
if (isRecord(parameters) && isRecord(parameters.properties)) {
|
||||
return parameters.properties;
|
||||
}
|
||||
throw new Error("expected diffs tool parameter properties");
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function expectFsEnoent(promise: Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
expect((error as { code?: unknown }).code).toBe("ENOENT");
|
||||
return;
|
||||
}
|
||||
throw new Error("expected ENOENT");
|
||||
}
|
||||
|
||||
function readTextContent(result: unknown, index: number): string {
|
||||
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)
|
||||
?.content;
|
||||
const entry = content?.[index];
|
||||
return entry?.type === "text" ? (entry.text ?? "") : "";
|
||||
}
|
||||
552
extensions/diffs/src/tool.ts
Normal file
552
extensions/diffs/src/tool.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
// Diffs plugin module implements tool behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import { optionalFiniteNumberSchema, stringEnum } from "openclaw/plugin-sdk/channel-actions";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { readFiniteNumberParam } from "openclaw/plugin-sdk/param-readers";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { Type } from "typebox";
|
||||
import type { Static } from "typebox";
|
||||
import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
|
||||
import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js";
|
||||
import { resolveDiffImageRenderOptions } from "./config.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
import type { DiffArtifactStore } from "./store.js";
|
||||
import type {
|
||||
DiffArtifactContext,
|
||||
DiffRenderOptions,
|
||||
DiffRenderTarget,
|
||||
DiffToolDefaults,
|
||||
} from "./types.js";
|
||||
import {
|
||||
DIFF_IMAGE_QUALITY_PRESETS,
|
||||
DIFF_LAYOUTS,
|
||||
DIFF_MODES,
|
||||
DIFF_OUTPUT_FORMATS,
|
||||
DIFF_THEMES,
|
||||
type DiffInput,
|
||||
type DiffImageQualityPreset,
|
||||
type DiffLayout,
|
||||
type DiffMode,
|
||||
type DiffOutputFormat,
|
||||
type DiffTheme,
|
||||
} from "./types.js";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
|
||||
const MAX_BEFORE_AFTER_BYTES = 512 * 1024;
|
||||
const MAX_PATCH_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_TITLE_BYTES = 1_024;
|
||||
const MAX_PATH_BYTES = 2_048;
|
||||
const MAX_LANG_BYTES = 128;
|
||||
const MAX_DIFF_ARTIFACT_TTL_SECONDS = 21_600;
|
||||
|
||||
const DiffsToolSchema = Type.Object(
|
||||
{
|
||||
before: Type.Optional(Type.String({ description: "Original text content." })),
|
||||
after: Type.Optional(Type.String({ description: "Updated text content." })),
|
||||
patch: Type.Optional(
|
||||
Type.String({
|
||||
description: "Unified diff or patch text.",
|
||||
maxLength: MAX_PATCH_BYTES,
|
||||
}),
|
||||
),
|
||||
path: Type.Optional(
|
||||
Type.String({
|
||||
description: "Display path for before/after input.",
|
||||
maxLength: MAX_PATH_BYTES,
|
||||
}),
|
||||
),
|
||||
lang: Type.Optional(
|
||||
Type.String({
|
||||
description: "Optional language override for before/after input.",
|
||||
maxLength: MAX_LANG_BYTES,
|
||||
}),
|
||||
),
|
||||
title: Type.Optional(
|
||||
Type.String({
|
||||
description: "Optional title for the rendered diff.",
|
||||
maxLength: MAX_TITLE_BYTES,
|
||||
}),
|
||||
),
|
||||
mode: Type.Optional(
|
||||
stringEnum(DIFF_MODES, {
|
||||
description:
|
||||
"Output mode: view, file, image (deprecated alias for file), or both. Default: both.",
|
||||
}),
|
||||
),
|
||||
theme: Type.Optional(stringEnum(DIFF_THEMES, { description: "Viewer theme. Default: dark." })),
|
||||
layout: Type.Optional(
|
||||
stringEnum(DIFF_LAYOUTS, { description: "Diff layout. Default: unified." }),
|
||||
),
|
||||
fileQuality: Type.Optional(
|
||||
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
|
||||
description: "File quality preset: standard, hq, or print.",
|
||||
}),
|
||||
),
|
||||
fileFormat: Type.Optional(
|
||||
stringEnum(DIFF_OUTPUT_FORMATS, { description: "Rendered file format: png or pdf." }),
|
||||
),
|
||||
fileScale: optionalFiniteNumberSchema({
|
||||
description: "Optional rendered-file device scale factor override (1-4).",
|
||||
minimum: 1,
|
||||
maximum: 4,
|
||||
}),
|
||||
fileMaxWidth: optionalFiniteNumberSchema({
|
||||
description: "Optional rendered-file max width in CSS pixels (640-2400).",
|
||||
minimum: 640,
|
||||
maximum: 2400,
|
||||
}),
|
||||
/** @deprecated Use fileQuality. */
|
||||
imageQuality: Type.Optional(
|
||||
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
|
||||
description: "Deprecated alias for fileQuality.",
|
||||
deprecated: true,
|
||||
}),
|
||||
),
|
||||
/** @deprecated Use fileFormat. */
|
||||
imageFormat: Type.Optional(
|
||||
stringEnum(DIFF_OUTPUT_FORMATS, {
|
||||
description: "Deprecated alias for fileFormat.",
|
||||
deprecated: true,
|
||||
}),
|
||||
),
|
||||
/** @deprecated Use fileScale. */
|
||||
imageScale: optionalFiniteNumberSchema({
|
||||
description: "Deprecated alias for fileScale.",
|
||||
deprecated: true,
|
||||
minimum: 1,
|
||||
maximum: 4,
|
||||
}),
|
||||
/** @deprecated Use fileMaxWidth. */
|
||||
imageMaxWidth: optionalFiniteNumberSchema({
|
||||
description: "Deprecated alias for fileMaxWidth.",
|
||||
deprecated: true,
|
||||
minimum: 640,
|
||||
maximum: 2400,
|
||||
}),
|
||||
expandUnchanged: Type.Optional(
|
||||
Type.Boolean({ description: "Expand unchanged sections instead of collapsing them." }),
|
||||
),
|
||||
ttlSeconds: optionalFiniteNumberSchema({
|
||||
description: "Artifact lifetime in seconds. Default: 1800. Maximum: 21600.",
|
||||
minimum: 1,
|
||||
maximum: MAX_DIFF_ARTIFACT_TTL_SECONDS,
|
||||
}),
|
||||
baseUrl: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Optional gateway base URL override used when building the viewer URL. Overrides configured viewerBaseUrl, for example https://gateway.example.com.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
type DiffsToolParams = Static<typeof DiffsToolSchema>;
|
||||
type DiffsToolRawParams = DiffsToolParams & {
|
||||
/** @deprecated Use fileFormat. */
|
||||
format?: DiffOutputFormat;
|
||||
};
|
||||
|
||||
export function createDiffsTool(params: {
|
||||
api: OpenClawPluginApi;
|
||||
store: DiffArtifactStore;
|
||||
defaults: DiffToolDefaults;
|
||||
viewerBaseUrl?: string;
|
||||
languagePackAvailable?: boolean;
|
||||
screenshotter?: DiffScreenshotter;
|
||||
context?: OpenClawPluginToolContext;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
name: "diffs",
|
||||
label: "Diffs",
|
||||
description:
|
||||
"Create a read-only diff viewer from before/after text or a unified patch. Returns a gateway viewer URL for canvas use and can also render the same diff to a PNG or PDF.",
|
||||
parameters: DiffsToolSchema,
|
||||
execute: async (_toolCallId, rawParams) => {
|
||||
const toolParams = rawParams as DiffsToolRawParams;
|
||||
const rawRecord = rawParams as Record<string, unknown>;
|
||||
const artifactContext = buildArtifactContext(params.context);
|
||||
const input = normalizeDiffInput(toolParams);
|
||||
const mode = normalizeMode(toolParams.mode, params.defaults.mode);
|
||||
const theme = normalizeTheme(toolParams.theme, params.defaults.theme);
|
||||
const layout = normalizeLayout(toolParams.layout, params.defaults.layout);
|
||||
const expandUnchanged = toolParams.expandUnchanged === true;
|
||||
const ttlSeconds =
|
||||
readFiniteNumberParam(rawRecord, "ttlSeconds") ?? params.defaults.ttlSeconds;
|
||||
const fileScale =
|
||||
readFiniteNumberParam(rawRecord, "fileScale") ??
|
||||
readFiniteNumberParam(rawRecord, "imageScale");
|
||||
const fileMaxWidth =
|
||||
readFiniteNumberParam(rawRecord, "fileMaxWidth") ??
|
||||
readFiniteNumberParam(rawRecord, "imageMaxWidth");
|
||||
const ttlMs = normalizeTtlMs(ttlSeconds);
|
||||
const image = resolveDiffImageRenderOptions({
|
||||
defaults: params.defaults,
|
||||
fileFormat: normalizeOutputFormat(
|
||||
toolParams.fileFormat ?? toolParams.imageFormat ?? toolParams.format,
|
||||
),
|
||||
fileQuality: normalizeFileQuality(toolParams.fileQuality ?? toolParams.imageQuality),
|
||||
fileScale,
|
||||
fileMaxWidth,
|
||||
});
|
||||
const renderTarget = resolveRenderTarget(mode);
|
||||
|
||||
const rendered = await renderDiffDocument(
|
||||
input,
|
||||
{
|
||||
presentation: {
|
||||
...params.defaults,
|
||||
layout,
|
||||
theme,
|
||||
},
|
||||
image,
|
||||
expandUnchanged,
|
||||
languagePackAvailable: params.languagePackAvailable,
|
||||
},
|
||||
renderTarget,
|
||||
);
|
||||
|
||||
const screenshotter =
|
||||
params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config });
|
||||
|
||||
if (isArtifactOnlyMode(mode)) {
|
||||
const artifactFile = await renderDiffArtifactFile({
|
||||
screenshotter,
|
||||
store: params.store,
|
||||
html: requireRenderedHtml(rendered.imageHtml, "image"),
|
||||
theme,
|
||||
image,
|
||||
ttlMs,
|
||||
context: artifactContext,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: buildFileArtifactMessage({
|
||||
format: image.format,
|
||||
filePath: artifactFile.path,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: buildArtifactDetails({
|
||||
baseDetails: {
|
||||
...(artifactFile.artifactId ? { artifactId: artifactFile.artifactId } : {}),
|
||||
...(artifactFile.expiresAt ? { expiresAt: artifactFile.expiresAt } : {}),
|
||||
title: rendered.title,
|
||||
inputKind: rendered.inputKind,
|
||||
fileCount: rendered.fileCount,
|
||||
mode,
|
||||
...(artifactContext ? { context: artifactContext } : {}),
|
||||
},
|
||||
artifactFile,
|
||||
image,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const artifact = await params.store.createArtifact({
|
||||
html: requireRenderedHtml(rendered.html, "viewer"),
|
||||
title: rendered.title,
|
||||
inputKind: rendered.inputKind,
|
||||
fileCount: rendered.fileCount,
|
||||
ttlMs,
|
||||
context: artifactContext,
|
||||
});
|
||||
|
||||
const viewerUrl = buildViewerUrl({
|
||||
config: params.api.config,
|
||||
viewerPath: artifact.viewerPath,
|
||||
baseUrl: normalizeBaseUrl(toolParams.baseUrl) ?? params.viewerBaseUrl,
|
||||
});
|
||||
|
||||
const baseDetails = {
|
||||
artifactId: artifact.id,
|
||||
viewerUrl,
|
||||
viewerPath: artifact.viewerPath,
|
||||
title: artifact.title,
|
||||
expiresAt: artifact.expiresAt,
|
||||
inputKind: artifact.inputKind,
|
||||
fileCount: artifact.fileCount,
|
||||
mode,
|
||||
...(artifactContext ? { context: artifactContext } : {}),
|
||||
};
|
||||
|
||||
if (mode === "view") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diff viewer ready.\n${viewerUrl}`,
|
||||
},
|
||||
],
|
||||
details: baseDetails,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const artifactFile = await renderDiffArtifactFile({
|
||||
screenshotter,
|
||||
store: params.store,
|
||||
artifactId: artifact.id,
|
||||
html: requireRenderedHtml(rendered.imageHtml, "image"),
|
||||
theme,
|
||||
image,
|
||||
});
|
||||
await params.store.updateFilePath(artifact.id, artifactFile.path);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: buildFileArtifactMessage({
|
||||
format: image.format,
|
||||
filePath: artifactFile.path,
|
||||
viewerUrl,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: buildArtifactDetails({
|
||||
baseDetails,
|
||||
artifactFile,
|
||||
image,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (mode === "both") {
|
||||
const errorMessage = formatErrorMessage(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diff viewer ready.\n${viewerUrl}\nFile rendering failed: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
...baseDetails,
|
||||
fileError: errorMessage,
|
||||
imageError: errorMessage,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileQuality(
|
||||
fileQuality: DiffImageQualityPreset | undefined,
|
||||
): DiffImageQualityPreset | undefined {
|
||||
return fileQuality && DIFF_IMAGE_QUALITY_PRESETS.includes(fileQuality) ? fileQuality : undefined;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(format: DiffOutputFormat | undefined): DiffOutputFormat | undefined {
|
||||
return format && DIFF_OUTPUT_FORMATS.includes(format) ? format : undefined;
|
||||
}
|
||||
|
||||
function isArtifactOnlyMode(mode: DiffMode): mode is "image" | "file" {
|
||||
return mode === "image" || mode === "file";
|
||||
}
|
||||
|
||||
function resolveRenderTarget(mode: DiffMode): DiffRenderTarget {
|
||||
if (mode === "view") {
|
||||
return "viewer";
|
||||
}
|
||||
if (isArtifactOnlyMode(mode)) {
|
||||
return "image";
|
||||
}
|
||||
return "both";
|
||||
}
|
||||
|
||||
function requireRenderedHtml(html: string | undefined, target: DiffRenderTarget): string {
|
||||
if (html !== undefined) {
|
||||
return html;
|
||||
}
|
||||
throw new Error(`Missing ${target} render output.`);
|
||||
}
|
||||
|
||||
function buildArtifactDetails(params: {
|
||||
baseDetails: Record<string, unknown>;
|
||||
artifactFile: { path: string; bytes: number };
|
||||
image: DiffRenderOptions["image"];
|
||||
}) {
|
||||
return {
|
||||
...params.baseDetails,
|
||||
filePath: params.artifactFile.path,
|
||||
imagePath: params.artifactFile.path,
|
||||
path: params.artifactFile.path,
|
||||
fileBytes: params.artifactFile.bytes,
|
||||
imageBytes: params.artifactFile.bytes,
|
||||
format: params.image.format,
|
||||
fileFormat: params.image.format,
|
||||
fileQuality: params.image.qualityPreset,
|
||||
imageQuality: params.image.qualityPreset,
|
||||
fileScale: params.image.scale,
|
||||
imageScale: params.image.scale,
|
||||
fileMaxWidth: params.image.maxWidth,
|
||||
imageMaxWidth: params.image.maxWidth,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFileArtifactMessage(params: {
|
||||
format: DiffOutputFormat;
|
||||
filePath: string;
|
||||
viewerUrl?: string;
|
||||
}): string {
|
||||
const lines = params.viewerUrl ? [`Diff viewer: ${params.viewerUrl}`] : [];
|
||||
lines.push(`Diff ${params.format.toUpperCase()} generated at: ${params.filePath}`);
|
||||
lines.push("Use the `message` tool with `path` or `filePath` to send this file.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function renderDiffArtifactFile(params: {
|
||||
screenshotter: DiffScreenshotter;
|
||||
store: DiffArtifactStore;
|
||||
artifactId?: string;
|
||||
html: string;
|
||||
theme: DiffTheme;
|
||||
image: DiffRenderOptions["image"];
|
||||
ttlMs?: number;
|
||||
context?: DiffArtifactContext;
|
||||
}): Promise<{ path: string; bytes: number; artifactId?: string; expiresAt?: string }> {
|
||||
const standaloneArtifact = params.artifactId
|
||||
? undefined
|
||||
: await params.store.createStandaloneFileArtifact({
|
||||
format: params.image.format,
|
||||
ttlMs: params.ttlMs,
|
||||
context: params.context,
|
||||
});
|
||||
const outputPath = params.artifactId
|
||||
? params.store.allocateFilePath(params.artifactId, params.image.format)
|
||||
: standaloneArtifact!.filePath;
|
||||
|
||||
await params.screenshotter.screenshotHtml({
|
||||
html: params.html,
|
||||
outputPath,
|
||||
theme: params.theme,
|
||||
image: params.image,
|
||||
});
|
||||
|
||||
const stats = await fs.stat(outputPath);
|
||||
return {
|
||||
path: outputPath,
|
||||
bytes: stats.size,
|
||||
...(standaloneArtifact?.id ? { artifactId: standaloneArtifact.id } : {}),
|
||||
...(standaloneArtifact?.expiresAt ? { expiresAt: standaloneArtifact.expiresAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildArtifactContext(
|
||||
context: OpenClawPluginToolContext | undefined,
|
||||
): DiffArtifactContext | undefined {
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const artifactContext = {
|
||||
agentId: normalizeOptionalString(context.agentId),
|
||||
sessionId: normalizeOptionalString(context.sessionId),
|
||||
messageChannel: normalizeOptionalString(context.messageChannel),
|
||||
agentAccountId: normalizeOptionalString(context.agentAccountId),
|
||||
};
|
||||
|
||||
return Object.values(artifactContext).some((value) => value !== undefined)
|
||||
? artifactContext
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
|
||||
const patch = params.patch?.trim();
|
||||
const before = params.before;
|
||||
const after = params.after;
|
||||
|
||||
if (patch) {
|
||||
assertMaxBytes(patch, "patch", MAX_PATCH_BYTES);
|
||||
if (before !== undefined || after !== undefined) {
|
||||
throw new PluginToolInputError("Provide either patch or before/after input, not both.");
|
||||
}
|
||||
const title = params.title?.trim();
|
||||
if (title) {
|
||||
assertMaxBytes(title, "title", MAX_TITLE_BYTES);
|
||||
}
|
||||
return {
|
||||
kind: "patch",
|
||||
patch,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
if (before === undefined || after === undefined) {
|
||||
throw new PluginToolInputError("Provide patch or both before and after text.");
|
||||
}
|
||||
assertMaxBytes(before, "before", MAX_BEFORE_AFTER_BYTES);
|
||||
assertMaxBytes(after, "after", MAX_BEFORE_AFTER_BYTES);
|
||||
const path = normalizeOptionalString(params.path);
|
||||
const lang = normalizeOptionalString(params.lang);
|
||||
const title = normalizeOptionalString(params.title);
|
||||
if (path) {
|
||||
assertMaxBytes(path, "path", MAX_PATH_BYTES);
|
||||
}
|
||||
if (lang) {
|
||||
assertMaxBytes(lang, "lang", MAX_LANG_BYTES);
|
||||
}
|
||||
if (title) {
|
||||
assertMaxBytes(title, "title", MAX_TITLE_BYTES);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "before_after",
|
||||
before,
|
||||
after,
|
||||
path,
|
||||
lang,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function assertMaxBytes(value: string, label: string, maxBytes: number): void {
|
||||
if (Buffer.byteLength(value, "utf8") <= maxBytes) {
|
||||
return;
|
||||
}
|
||||
throw new PluginToolInputError(`${label} exceeds maximum size (${maxBytes} bytes).`);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl?: string): string | undefined {
|
||||
const normalized = baseUrl?.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return normalizeViewerBaseUrl(normalized);
|
||||
} catch {
|
||||
throw new PluginToolInputError(`Invalid baseUrl: ${normalized}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMode(mode: DiffMode | undefined, fallback: DiffMode): DiffMode {
|
||||
return mode && DIFF_MODES.includes(mode) ? mode : fallback;
|
||||
}
|
||||
|
||||
function normalizeTheme(theme: DiffTheme | undefined, fallback: DiffTheme): DiffTheme {
|
||||
return theme && DIFF_THEMES.includes(theme) ? theme : fallback;
|
||||
}
|
||||
|
||||
function normalizeLayout(layout: DiffLayout | undefined, fallback: DiffLayout): DiffLayout {
|
||||
return layout && DIFF_LAYOUTS.includes(layout) ? layout : fallback;
|
||||
}
|
||||
|
||||
function normalizeTtlMs(ttlSeconds?: number): number | undefined {
|
||||
if (!Number.isFinite(ttlSeconds) || ttlSeconds === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.floor(Math.min(Math.max(ttlSeconds, 1), MAX_DIFF_ARTIFACT_TTL_SECONDS) * 1000);
|
||||
}
|
||||
|
||||
class PluginToolInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ToolInputError";
|
||||
}
|
||||
}
|
||||
130
extensions/diffs/src/types.ts
Normal file
130
extensions/diffs/src/types.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// Diffs type declarations define plugin contracts.
|
||||
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
|
||||
|
||||
export const DIFF_LAYOUTS = ["unified", "split"] as const;
|
||||
export const DIFF_MODES = ["view", "image", "file", "both"] as const;
|
||||
export const DIFF_THEMES = ["light", "dark"] as const;
|
||||
export const DIFF_INDICATORS = ["bars", "classic", "none"] as const;
|
||||
export const DIFF_IMAGE_QUALITY_PRESETS = ["standard", "hq", "print"] as const;
|
||||
export const DIFF_OUTPUT_FORMATS = ["png", "pdf"] as const;
|
||||
|
||||
export type DiffLayout = (typeof DIFF_LAYOUTS)[number];
|
||||
export type DiffMode = (typeof DIFF_MODES)[number];
|
||||
export type DiffTheme = (typeof DIFF_THEMES)[number];
|
||||
export type DiffIndicators = (typeof DIFF_INDICATORS)[number];
|
||||
export type DiffImageQualityPreset = (typeof DIFF_IMAGE_QUALITY_PRESETS)[number];
|
||||
export type DiffOutputFormat = (typeof DIFF_OUTPUT_FORMATS)[number];
|
||||
export type DiffRenderTarget = "viewer" | "image" | "both";
|
||||
|
||||
type DiffPresentationDefaults = {
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
lineSpacing: number;
|
||||
layout: DiffLayout;
|
||||
showLineNumbers: boolean;
|
||||
diffIndicators: DiffIndicators;
|
||||
wordWrap: boolean;
|
||||
background: boolean;
|
||||
theme: DiffTheme;
|
||||
};
|
||||
|
||||
export type DiffFileDefaults = {
|
||||
fileFormat: DiffOutputFormat;
|
||||
fileQuality: DiffImageQualityPreset;
|
||||
fileScale: number;
|
||||
fileMaxWidth: number;
|
||||
};
|
||||
|
||||
export type DiffToolDefaults = DiffPresentationDefaults &
|
||||
DiffFileDefaults & {
|
||||
mode: DiffMode;
|
||||
ttlSeconds: number;
|
||||
};
|
||||
|
||||
type BeforeAfterDiffInput = {
|
||||
kind: "before_after";
|
||||
before: string;
|
||||
after: string;
|
||||
path?: string;
|
||||
lang?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type PatchDiffInput = {
|
||||
kind: "patch";
|
||||
patch: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type DiffInput = BeforeAfterDiffInput | PatchDiffInput;
|
||||
|
||||
export type DiffRenderOptions = {
|
||||
presentation: DiffPresentationDefaults;
|
||||
image: {
|
||||
format: DiffOutputFormat;
|
||||
qualityPreset: DiffImageQualityPreset;
|
||||
scale: number;
|
||||
maxWidth: number;
|
||||
maxPixels: number;
|
||||
};
|
||||
expandUnchanged: boolean;
|
||||
languagePackAvailable?: boolean;
|
||||
};
|
||||
|
||||
export type DiffViewerOptions = {
|
||||
theme: {
|
||||
light: "pierre-light";
|
||||
dark: "pierre-dark";
|
||||
};
|
||||
diffStyle: DiffLayout;
|
||||
diffIndicators: DiffIndicators;
|
||||
disableLineNumbers: boolean;
|
||||
expandUnchanged: boolean;
|
||||
themeType: DiffTheme;
|
||||
backgroundEnabled: boolean;
|
||||
overflow: "scroll" | "wrap";
|
||||
unsafeCSS: string;
|
||||
};
|
||||
|
||||
export type DiffViewerPayload = {
|
||||
prerenderedHTML: string;
|
||||
options: DiffViewerOptions;
|
||||
langs: SupportedLanguages[];
|
||||
oldFile?: FileContents;
|
||||
newFile?: FileContents;
|
||||
fileDiff?: FileDiffMetadata;
|
||||
};
|
||||
|
||||
export type RenderedDiffDocument = {
|
||||
html?: string;
|
||||
imageHtml?: string;
|
||||
title: string;
|
||||
fileCount: number;
|
||||
inputKind: DiffInput["kind"];
|
||||
viewerRuntime: "base" | "language-pack";
|
||||
};
|
||||
|
||||
export type DiffArtifactContext = {
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
messageChannel?: string;
|
||||
agentAccountId?: string;
|
||||
};
|
||||
|
||||
export type DiffArtifactMeta = {
|
||||
id: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
title: string;
|
||||
inputKind: DiffInput["kind"];
|
||||
fileCount: number;
|
||||
viewerPath: string;
|
||||
htmlPath: string;
|
||||
context?: DiffArtifactContext;
|
||||
filePath?: string;
|
||||
imagePath?: string;
|
||||
};
|
||||
|
||||
export const DIFF_ARTIFACT_ID_PATTERN = /^[0-9a-f]{20}$/;
|
||||
export const DIFF_ARTIFACT_TOKEN_PATTERN = /^[0-9a-f]{48}$/;
|
||||
61
extensions/diffs/src/url.ts
Normal file
61
extensions/diffs/src/url.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Diffs plugin module implements url behavior.
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = 18789;
|
||||
type ViewerBaseUrlFieldName = "baseUrl" | "viewerBaseUrl";
|
||||
|
||||
export function buildViewerUrl(params: {
|
||||
config: OpenClawConfig;
|
||||
viewerPath: string;
|
||||
baseUrl?: string;
|
||||
}): string {
|
||||
const baseUrl = params.baseUrl?.trim() || resolveGatewayBaseUrl(params.config);
|
||||
const normalizedBase = normalizeViewerBaseUrl(baseUrl);
|
||||
const viewerPath = params.viewerPath.startsWith("/")
|
||||
? params.viewerPath
|
||||
: `/${params.viewerPath}`;
|
||||
const parsedBase = new URL(normalizedBase);
|
||||
const basePath = parsedBase.pathname === "/" ? "" : parsedBase.pathname.replace(/\/+$/, "");
|
||||
parsedBase.pathname = `${basePath}${viewerPath}`;
|
||||
parsedBase.search = "";
|
||||
parsedBase.hash = "";
|
||||
return parsedBase.toString();
|
||||
}
|
||||
|
||||
export function normalizeViewerBaseUrl(
|
||||
raw: string,
|
||||
fieldName: ViewerBaseUrlFieldName = "baseUrl",
|
||||
): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`Invalid ${fieldName}: ${raw}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`${fieldName} must use http or https: ${raw}`);
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error(`${fieldName} must not include query/hash: ${raw}`);
|
||||
}
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
||||
const withoutTrailingSlash = parsed.toString().replace(/\/+$/, "");
|
||||
return withoutTrailingSlash;
|
||||
}
|
||||
|
||||
function resolveGatewayBaseUrl(config: OpenClawConfig): string {
|
||||
const scheme = config.gateway?.tls?.enabled ? "https" : "http";
|
||||
const port =
|
||||
typeof config.gateway?.port === "number" ? config.gateway.port : DEFAULT_GATEWAY_PORT;
|
||||
const customHost = config.gateway?.customBindHost?.trim();
|
||||
|
||||
if (config.gateway?.bind === "custom" && customHost) {
|
||||
return `${scheme}://${customHost}:${port}`;
|
||||
}
|
||||
|
||||
// Viewer links are used by local canvas/clients; default to loopback to avoid
|
||||
// container/bridge interfaces that are often unreachable from the caller.
|
||||
return `${scheme}://127.0.0.1:${port}`;
|
||||
}
|
||||
190
extensions/diffs/src/viewer-assets.ts
Normal file
190
extensions/diffs/src/viewer-assets.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// Diffs plugin module implements viewer assets behavior.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const VIEWER_ASSET_PREFIX = "/plugins/diffs/assets/";
|
||||
export const VIEWER_LOADER_PATH = `${VIEWER_ASSET_PREFIX}viewer.js`;
|
||||
export const VIEWER_RUNTIME_PATH = `${VIEWER_ASSET_PREFIX}viewer-runtime.js`;
|
||||
export const LANGUAGE_PACK_VIEWER_ASSET_PREFIX = "/plugins/diffs-language-pack/assets/";
|
||||
export const LANGUAGE_PACK_VIEWER_LOADER_PATH = `${LANGUAGE_PACK_VIEWER_ASSET_PREFIX}viewer.js`;
|
||||
export const LANGUAGE_PACK_VIEWER_RUNTIME_PATH = `${LANGUAGE_PACK_VIEWER_ASSET_PREFIX}viewer-runtime.js`;
|
||||
const VIEWER_RUNTIME_RELATIVE_IMPORT_PATH = "./viewer-runtime.js";
|
||||
const VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS = [
|
||||
"./assets/viewer-runtime.js",
|
||||
"../assets/viewer-runtime.js",
|
||||
] as const;
|
||||
const LANGUAGE_PACK_RUNTIME_CANDIDATE_RELATIVE_PATHS = [
|
||||
"../../diffs-language-pack/assets/viewer-runtime.js",
|
||||
"../diffs-language-pack/assets/viewer-runtime.js",
|
||||
] as const;
|
||||
|
||||
type ServedViewerAsset = {
|
||||
body: string | Buffer;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
type RuntimeAssetCache = {
|
||||
mtimeMs: number;
|
||||
runtimeBody: Buffer;
|
||||
loaderBody: string;
|
||||
};
|
||||
|
||||
let runtimeAssetCache: RuntimeAssetCache | null = null;
|
||||
let languagePackRuntimeAssetCache: RuntimeAssetCache | null = null;
|
||||
|
||||
type ViewerRuntimeFileUrlParams = {
|
||||
baseUrl?: string | URL;
|
||||
stat?: (path: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function isMissingFileError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
export async function resolveViewerRuntimeFileUrl(
|
||||
params: ViewerRuntimeFileUrlParams = {},
|
||||
): Promise<URL> {
|
||||
const baseUrl = params.baseUrl ?? import.meta.url;
|
||||
const stat = params.stat ?? ((path: string) => fs.stat(path));
|
||||
let missingFileError: NodeJS.ErrnoException | null = null;
|
||||
|
||||
for (const relativePath of VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS) {
|
||||
const candidateUrl = new URL(relativePath, baseUrl);
|
||||
try {
|
||||
await stat(fileURLToPath(candidateUrl));
|
||||
return candidateUrl;
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
missingFileError = error;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFileError) {
|
||||
throw missingFileError;
|
||||
}
|
||||
|
||||
throw new Error("viewer runtime asset candidates were not checked");
|
||||
}
|
||||
|
||||
export async function getServedViewerAsset(pathname: string): Promise<ServedViewerAsset | null> {
|
||||
if (pathname !== VIEWER_LOADER_PATH && pathname !== VIEWER_RUNTIME_PATH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assets = await loadViewerAssets();
|
||||
if (pathname === VIEWER_LOADER_PATH) {
|
||||
return {
|
||||
body: assets.loaderBody,
|
||||
contentType: "text/javascript; charset=utf-8",
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname === VIEWER_RUNTIME_PATH) {
|
||||
return {
|
||||
body: assets.runtimeBody,
|
||||
contentType: "text/javascript; charset=utf-8",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getServedLanguagePackViewerAsset(
|
||||
pathname: string,
|
||||
): Promise<ServedViewerAsset | null> {
|
||||
if (
|
||||
pathname !== LANGUAGE_PACK_VIEWER_LOADER_PATH &&
|
||||
pathname !== LANGUAGE_PACK_VIEWER_RUNTIME_PATH
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let assets: RuntimeAssetCache;
|
||||
try {
|
||||
const runtimeUrl = await resolveRuntimeFileUrl(LANGUAGE_PACK_RUNTIME_CANDIDATE_RELATIVE_PATHS);
|
||||
assets = await loadRuntimeAssets({
|
||||
runtimeUrl,
|
||||
cache: languagePackRuntimeAssetCache,
|
||||
updateCache: (cache) => {
|
||||
languagePackRuntimeAssetCache = cache;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (pathname === LANGUAGE_PACK_VIEWER_LOADER_PATH) {
|
||||
return {
|
||||
body: assets.loaderBody,
|
||||
contentType: "text/javascript; charset=utf-8",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
body: assets.runtimeBody,
|
||||
contentType: "text/javascript; charset=utf-8",
|
||||
};
|
||||
}
|
||||
|
||||
async function loadViewerAssets(): Promise<RuntimeAssetCache> {
|
||||
const runtimeUrl = await resolveViewerRuntimeFileUrl();
|
||||
return loadRuntimeAssets({
|
||||
runtimeUrl,
|
||||
cache: runtimeAssetCache,
|
||||
updateCache: (cache) => {
|
||||
runtimeAssetCache = cache;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRuntimeAssets(params: {
|
||||
cache: RuntimeAssetCache | null;
|
||||
runtimeUrl: URL;
|
||||
updateCache(cache: RuntimeAssetCache): void;
|
||||
}): Promise<RuntimeAssetCache> {
|
||||
const runtimePath = fileURLToPath(params.runtimeUrl);
|
||||
const runtimeStat = await fs.stat(runtimePath);
|
||||
if (params.cache && params.cache.mtimeMs === runtimeStat.mtimeMs) {
|
||||
return params.cache;
|
||||
}
|
||||
|
||||
const runtimeBody = await fs.readFile(runtimePath);
|
||||
const hash = crypto.createHash("sha1").update(runtimeBody).digest("hex").slice(0, 12);
|
||||
const cache = {
|
||||
mtimeMs: runtimeStat.mtimeMs,
|
||||
runtimeBody,
|
||||
loaderBody: `import "${VIEWER_RUNTIME_RELATIVE_IMPORT_PATH}?v=${hash}";\n`,
|
||||
};
|
||||
params.updateCache(cache);
|
||||
return cache;
|
||||
}
|
||||
|
||||
async function resolveRuntimeFileUrl(relativePaths: readonly string[]): Promise<URL> {
|
||||
let missingFileError: NodeJS.ErrnoException | null = null;
|
||||
|
||||
for (const relativePath of relativePaths) {
|
||||
const candidateUrl = new URL(relativePath, import.meta.url);
|
||||
try {
|
||||
await fs.stat(fileURLToPath(candidateUrl));
|
||||
return candidateUrl;
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
missingFileError = error;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFileError) {
|
||||
throw missingFileError;
|
||||
}
|
||||
|
||||
throw new Error("viewer runtime asset candidates were not checked");
|
||||
}
|
||||
490
extensions/diffs/src/viewer-client.test.ts
Normal file
490
extensions/diffs/src/viewer-client.test.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const disableAutoStartKey = Symbol.for("openclaw.diffs.disableAutoStart");
|
||||
(globalThis as typeof globalThis & Record<symbol, unknown>)[disableAutoStartKey] = true;
|
||||
|
||||
const VIEWER_CLIENT_SRC = readFileSync(
|
||||
path.join(process.cwd(), "extensions/diffs/src/viewer-client.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const XSS_PATTERNS = ["onerror", "<script", "onclick", "javascript:", "onload"];
|
||||
|
||||
const {
|
||||
fileDiffHydrateMock,
|
||||
fileDiffRerenderMock,
|
||||
fileDiffSetOptionsMock,
|
||||
preloadHighlighterMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fileDiffHydrateMock: vi.fn(),
|
||||
fileDiffRerenderMock: vi.fn(),
|
||||
fileDiffSetOptionsMock: vi.fn(),
|
||||
preloadHighlighterMock: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@pierre/diffs", () => ({
|
||||
FileDiff: class {
|
||||
hydrate(params: unknown) {
|
||||
return fileDiffHydrateMock(params);
|
||||
}
|
||||
rerender() {
|
||||
return fileDiffRerenderMock();
|
||||
}
|
||||
setOptions(params: unknown) {
|
||||
return fileDiffSetOptionsMock(params);
|
||||
}
|
||||
},
|
||||
preloadHighlighter: preloadHighlighterMock,
|
||||
}));
|
||||
|
||||
const viewerPayload = JSON.stringify({
|
||||
prerenderedHTML: "<div>diff</div>",
|
||||
options: {
|
||||
theme: { light: "pierre-light", dark: "pierre-dark" },
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["text"],
|
||||
oldFile: { name: "a.ts", lang: "text", contents: "old" },
|
||||
newFile: { name: "a.ts", lang: "text", contents: "new" },
|
||||
});
|
||||
|
||||
function renderCard(payloadOverride?: string): void {
|
||||
const payload = payloadOverride ?? viewerPayload;
|
||||
document.body.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<section class="oc-diff-card">
|
||||
<div data-openclaw-diff-host></div>
|
||||
<script type="application/json" data-openclaw-diff-payload>${payload}</script>
|
||||
</section>`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createToolbarButton icon safety", () => {
|
||||
it("toolbarIconSvg map exists and has exactly 8 icon names", () => {
|
||||
const requiredNames = [
|
||||
"split",
|
||||
"unified",
|
||||
"wrap-on",
|
||||
"wrap-off",
|
||||
"background-on",
|
||||
"background-off",
|
||||
"theme-dark",
|
||||
"theme-light",
|
||||
] as const;
|
||||
for (const name of requiredNames) {
|
||||
expect(
|
||||
VIEWER_CLIENT_SRC.includes(name + ":") || VIEWER_CLIENT_SRC.includes(`"${name}"`),
|
||||
`icon "${name}" should exist in toolbarIconSvg`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("no iconMarkup: string parameter exists", () => {
|
||||
expect(VIEWER_CLIENT_SRC.includes("iconMarkup: string")).toBe(false);
|
||||
});
|
||||
|
||||
it("innerHTML reads only from toolbarIconSvg lookup", () => {
|
||||
expect(VIEWER_CLIENT_SRC.includes("button.innerHTML = toolbarIconSvg[params.icon]")).toBe(true);
|
||||
});
|
||||
|
||||
it("SVG strings in toolbarIconSvg contain no XSS patterns", () => {
|
||||
for (const pattern of XSS_PATTERNS) {
|
||||
expect(VIEWER_CLIENT_SRC.includes(pattern), `source must not contain "${pattern}"`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("old icon functions are removed", () => {
|
||||
const removedFunctions = [
|
||||
"function splitIcon(",
|
||||
"function unifiedIcon(",
|
||||
"function wrapIcon(",
|
||||
"function backgroundIcon(",
|
||||
"function themeIcon(",
|
||||
];
|
||||
for (const fn of removedFunctions) {
|
||||
expect(VIEWER_CLIENT_SRC.includes(fn), `"${fn}" should be removed`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateViewer", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
delete document.documentElement.dataset.openclawDiffsError;
|
||||
delete document.documentElement.dataset.openclawDiffsReady;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("continues hydrating later cards when one card throws", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
renderCard();
|
||||
renderCard();
|
||||
fileDiffHydrateMock.mockImplementationOnce(() => {
|
||||
throw new Error("broken card");
|
||||
});
|
||||
const { controllers, hydrateViewer } = await import("./viewer-client.js");
|
||||
controllers.splice(0);
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2);
|
||||
expect(controllers).toHaveLength(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Skipping diff card that failed to hydrate",
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(document.documentElement.dataset.openclawDiffsError).toBeUndefined();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("does not retain controllers when initial state application throws", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
renderCard();
|
||||
renderCard();
|
||||
fileDiffSetOptionsMock.mockImplementationOnce(() => {
|
||||
throw new Error("broken options");
|
||||
});
|
||||
const { controllers, hydrateViewer } = await import("./viewer-client.js");
|
||||
controllers.splice(0);
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2);
|
||||
expect(fileDiffSetOptionsMock).toHaveBeenCalledTimes(2);
|
||||
expect(controllers).toHaveLength(1);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Skipping diff card that failed to hydrate",
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(document.documentElement.dataset.openclawDiffsError).toBeUndefined();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("replaces stale controllers when hydrating the current cards again", async () => {
|
||||
renderCard();
|
||||
const { controllers, hydrateViewer } = await import("./viewer-client.js");
|
||||
controllers.splice(0);
|
||||
|
||||
await hydrateViewer();
|
||||
expect(controllers).toHaveLength(1);
|
||||
const firstController = controllers[0];
|
||||
|
||||
document.body.innerHTML = "";
|
||||
renderCard();
|
||||
await hydrateViewer();
|
||||
|
||||
expect(controllers).toHaveLength(1);
|
||||
expect(controllers[0]).not.toBe(firstController);
|
||||
expect(fileDiffHydrateMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewerState initialization", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
delete document.documentElement.dataset.openclawDiffsError;
|
||||
delete document.documentElement.dataset.openclawDiffsReady;
|
||||
delete document.body.dataset.theme;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("seeds viewerState from firstPayload options and syncs document theme", async () => {
|
||||
const customPayload = JSON.stringify({
|
||||
prerenderedHTML: "<div>diff</div>",
|
||||
options: {
|
||||
theme: { light: "pierre-light", dark: "pierre-dark" },
|
||||
diffStyle: "split",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "light",
|
||||
backgroundEnabled: false,
|
||||
overflow: "scroll",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["text"],
|
||||
oldFile: { name: "a.ts", lang: "text", contents: "old" },
|
||||
newFile: { name: "a.ts", lang: "text", contents: "new" },
|
||||
});
|
||||
renderCard(customPayload);
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
expect(document.body.dataset.theme).toBe("light");
|
||||
|
||||
const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts.diffStyle).toBe("split");
|
||||
expect(opts.themeType).toBe("light");
|
||||
expect(opts.overflow).toBe("scroll");
|
||||
expect(opts.disableBackground).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults viewerState to dark/unified/wrap/background when firstPayload uses defaults", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
expect(document.body.dataset.theme).toBe("dark");
|
||||
|
||||
const opts = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts.diffStyle).toBe("unified");
|
||||
expect(opts.themeType).toBe("dark");
|
||||
expect(opts.overflow).toBe("wrap");
|
||||
expect(opts.disableBackground).toBe(false);
|
||||
});
|
||||
|
||||
it("preloadHighlighter receives merged language set from all cards", async () => {
|
||||
const payload1 = JSON.stringify({
|
||||
prerenderedHTML: "<div>diff1</div>",
|
||||
options: {
|
||||
theme: { light: "pierre-light", dark: "pierre-dark" },
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["typescript"],
|
||||
oldFile: { name: "a.ts", lang: "typescript", contents: "old" },
|
||||
newFile: { name: "a.ts", lang: "typescript", contents: "new" },
|
||||
});
|
||||
const payload2 = JSON.stringify({
|
||||
prerenderedHTML: "<div>diff2</div>",
|
||||
options: {
|
||||
theme: { light: "pierre-light", dark: "pierre-dark" },
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["python"],
|
||||
oldFile: { name: "b.py", lang: "python", contents: "old" },
|
||||
newFile: { name: "b.py", lang: "python", contents: "new" },
|
||||
});
|
||||
renderCard(payload1);
|
||||
renderCard(payload2);
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
const preloadArg = (preloadHighlighterMock.mock.calls as unknown[][])[0]?.[0] as
|
||||
| { langs: string[]; themes: string[] }
|
||||
| undefined;
|
||||
expect(preloadArg).toBeDefined();
|
||||
expect(preloadArg!.langs).toContain("typescript");
|
||||
expect(preloadArg!.langs).toContain("python");
|
||||
expect(preloadArg!.themes).toEqual(["pierre-light", "pierre-dark"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolbar button toggles", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
delete document.body.dataset.theme;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("layout toggle switches between unified and split", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts1.diffStyle).toBe("unified");
|
||||
|
||||
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
|
||||
const toolbar = renderHeaderMetadata();
|
||||
const buttons = toolbar.querySelectorAll("button");
|
||||
|
||||
buttons[0].click();
|
||||
|
||||
expect(fileDiffRerenderMock).toHaveBeenCalled();
|
||||
|
||||
const opts2 = fileDiffSetOptionsMock.mock.calls[
|
||||
fileDiffSetOptionsMock.mock.calls.length - 1
|
||||
]?.[0] as Record<string, unknown>;
|
||||
expect(opts2.diffStyle).toBe("split");
|
||||
});
|
||||
|
||||
it("theme toggle switches between dark and light", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts1.themeType).toBe("dark");
|
||||
|
||||
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
|
||||
const toolbar = renderHeaderMetadata();
|
||||
const buttons = toolbar.querySelectorAll("button");
|
||||
|
||||
buttons[3].click();
|
||||
|
||||
const lastOpts = fileDiffSetOptionsMock.mock.calls[
|
||||
fileDiffSetOptionsMock.mock.calls.length - 1
|
||||
]?.[0] as Record<string, unknown>;
|
||||
expect(lastOpts.themeType).toBe("light");
|
||||
expect(document.body.dataset.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("wrap toggle switches between wrap and scroll", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts1.overflow).toBe("wrap");
|
||||
|
||||
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
|
||||
const toolbar = renderHeaderMetadata();
|
||||
const buttons = toolbar.querySelectorAll("button");
|
||||
|
||||
buttons[1].click();
|
||||
|
||||
const lastOpts = fileDiffSetOptionsMock.mock.calls[
|
||||
fileDiffSetOptionsMock.mock.calls.length - 1
|
||||
]?.[0] as Record<string, unknown>;
|
||||
expect(lastOpts.overflow).toBe("scroll");
|
||||
});
|
||||
|
||||
it("background toggle inverts disableBackground", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
const opts1 = fileDiffSetOptionsMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(opts1.disableBackground).toBe(false);
|
||||
|
||||
const renderHeaderMetadata = opts1.renderHeaderMetadata as () => HTMLElement;
|
||||
const toolbar = renderHeaderMetadata();
|
||||
const buttons = toolbar.querySelectorAll("button");
|
||||
|
||||
buttons[2].click();
|
||||
|
||||
const lastOpts = fileDiffSetOptionsMock.mock.calls[
|
||||
fileDiffSetOptionsMock.mock.calls.length - 1
|
||||
]?.[0] as Record<string, unknown>;
|
||||
expect(lastOpts.disableBackground).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureShadowRoot", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("attaches shadow root from template and removes template element", async () => {
|
||||
renderCard();
|
||||
const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
|
||||
const template = document.createElement("template");
|
||||
template.setAttribute("shadowrootmode", "open");
|
||||
template.innerHTML = "<div>shadow content</div>";
|
||||
host.append(template);
|
||||
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
expect(host.shadowRoot).toBeDefined();
|
||||
expect(host.shadowRoot!.querySelector("div")?.textContent).toBe("shadow content");
|
||||
expect(host.querySelector("template")).toBeNull();
|
||||
});
|
||||
|
||||
it("skips shadow root attachment when no template is present", async () => {
|
||||
renderCard();
|
||||
const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
|
||||
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
expect(host.shadowRoot).toBeNull();
|
||||
expect(fileDiffHydrateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips shadow root when already attached", async () => {
|
||||
renderCard();
|
||||
const host = document.querySelector<HTMLElement>("[data-openclaw-diff-host]")!;
|
||||
host.attachShadow({ mode: "open" });
|
||||
host.shadowRoot!.innerHTML = "<span>existing</span>";
|
||||
|
||||
const template = document.createElement("template");
|
||||
template.setAttribute("shadowrootmode", "open");
|
||||
template.innerHTML = "<div>new content</div>";
|
||||
host.append(template);
|
||||
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
await hydrateViewer();
|
||||
|
||||
expect(host.shadowRoot!.querySelector("span")?.textContent).toBe("existing");
|
||||
expect(host.querySelector("template")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHydrateProps branching", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes fileDiff directly when payload has fileDiff", async () => {
|
||||
const fileDiffPayload = JSON.stringify({
|
||||
prerenderedHTML: "<div>diff</div>",
|
||||
options: {
|
||||
theme: { light: "pierre-light", dark: "pierre-dark" },
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: "",
|
||||
},
|
||||
langs: ["text"],
|
||||
fileDiff: { name: "patch.diff", lang: "text", hunks: [] },
|
||||
});
|
||||
renderCard(fileDiffPayload);
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
const hydrateArg = fileDiffHydrateMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(hydrateArg.fileDiff).toEqual({ name: "patch.diff", lang: "text", hunks: [] });
|
||||
expect(hydrateArg.oldFile).toBeUndefined();
|
||||
expect(hydrateArg.newFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes oldFile and newFile when payload has them without fileDiff", async () => {
|
||||
renderCard();
|
||||
const { hydrateViewer } = await import("./viewer-client.js");
|
||||
|
||||
await hydrateViewer();
|
||||
|
||||
const hydrateArg = fileDiffHydrateMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(hydrateArg.fileDiff).toBeUndefined();
|
||||
expect(hydrateArg.oldFile).toEqual({ name: "a.ts", lang: "text", contents: "old" });
|
||||
expect(hydrateArg.newFile).toEqual({ name: "a.ts", lang: "text", contents: "new" });
|
||||
});
|
||||
});
|
||||
364
extensions/diffs/src/viewer-client.ts
Normal file
364
extensions/diffs/src/viewer-client.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
// Diffs plugin module implements viewer client behavior.
|
||||
import { FileDiff, preloadHighlighter } from "@pierre/diffs";
|
||||
import type {
|
||||
FileContents,
|
||||
FileDiffMetadata,
|
||||
FileDiffOptions,
|
||||
SupportedLanguages,
|
||||
} from "@pierre/diffs";
|
||||
import { normalizeDiffViewerPayloadLanguages } from "./language-hints.js";
|
||||
import type { DiffViewerPayload, DiffLayout, DiffTheme } from "./types.js";
|
||||
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
||||
|
||||
type ViewerState = {
|
||||
theme: DiffTheme;
|
||||
layout: DiffLayout;
|
||||
backgroundEnabled: boolean;
|
||||
wrapEnabled: boolean;
|
||||
};
|
||||
|
||||
type DiffController = {
|
||||
payload: DiffViewerPayload;
|
||||
diff: FileDiff;
|
||||
};
|
||||
|
||||
export const controllers: DiffController[] = [];
|
||||
|
||||
const viewerState: ViewerState = {
|
||||
theme: "dark",
|
||||
layout: "unified",
|
||||
backgroundEnabled: true,
|
||||
wrapEnabled: true,
|
||||
};
|
||||
|
||||
function parsePayload(element: HTMLScriptElement): DiffViewerPayload {
|
||||
const raw = element.textContent?.trim();
|
||||
if (!raw) {
|
||||
throw new Error("Diff payload was empty.");
|
||||
}
|
||||
return parseViewerPayloadJson(raw);
|
||||
}
|
||||
|
||||
function getCards(): Array<{ host: HTMLElement; payload: DiffViewerPayload }> {
|
||||
const cards: Array<{ host: HTMLElement; payload: DiffViewerPayload }> = [];
|
||||
for (const card of document.querySelectorAll<HTMLElement>(".oc-diff-card")) {
|
||||
const host = card.querySelector<HTMLElement>("[data-openclaw-diff-host]");
|
||||
const payloadNode = card.querySelector<HTMLScriptElement>("[data-openclaw-diff-payload]");
|
||||
if (!host || !payloadNode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
cards.push({ host, payload: parsePayload(payloadNode) });
|
||||
} catch (error) {
|
||||
console.warn("Skipping invalid diff payload", error);
|
||||
}
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
function ensureShadowRoot(host: HTMLElement): void {
|
||||
if (host.shadowRoot) {
|
||||
return;
|
||||
}
|
||||
const template = host.querySelector<HTMLTemplateElement>(
|
||||
":scope > template[shadowrootmode='open']",
|
||||
);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
const shadowRoot = host.attachShadow({ mode: "open" });
|
||||
shadowRoot.append(template.content.cloneNode(true));
|
||||
template.remove();
|
||||
}
|
||||
|
||||
function getHydrateProps(payload: DiffViewerPayload): {
|
||||
fileDiff?: FileDiffMetadata;
|
||||
oldFile?: FileContents;
|
||||
newFile?: FileContents;
|
||||
} {
|
||||
if (payload.fileDiff) {
|
||||
return { fileDiff: payload.fileDiff };
|
||||
}
|
||||
return {
|
||||
oldFile: payload.oldFile,
|
||||
newFile: payload.newFile,
|
||||
};
|
||||
}
|
||||
|
||||
type ToolbarIconName =
|
||||
| "split"
|
||||
| "unified"
|
||||
| "wrap-on"
|
||||
| "wrap-off"
|
||||
| "background-on"
|
||||
| "background-off"
|
||||
| "theme-dark"
|
||||
| "theme-light";
|
||||
|
||||
const toolbarIconSvg: Record<ToolbarIconName, string> = {
|
||||
split: `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M14 0H8.5v16H14a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2m-1.5 6.5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0"></path>
|
||||
<path fill="currentColor" opacity="0.5" d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5.5V0zm.5 7.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1"></path>
|
||||
</svg>`,
|
||||
unified: `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M16 14a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V8.5h16zm-8-4a.5.5 0 0 0-.5.5v1h-1a.5.5 0 0 0 0 1h1v1a.5.5 0 0 0 1 0v-1h1a.5.5 0 0 0 0-1h-1v-1A.5.5 0 0 0 8 10" clip-rule="evenodd"></path>
|
||||
<path fill="currentColor" fill-rule="evenodd" opacity="0.5" d="M14 0a2 2 0 0 1 2 2v5.5H0V2a2 2 0 0 1 2-2zM6.5 3.5a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
|
||||
</svg>`,
|
||||
"wrap-on": `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" opacity="1" d="M3.868 3.449a1.21 1.21 0 0 0-.473-.329c-.274-.111-.623-.15-1.055-.076a3.5 3.5 0 0 0-.71.208c-.082.035-.16.077-.235.125l-.043.03v1.056l.168-.139c.15-.124.326-.225.527-.303.196-.074.4-.113.604-.113.188 0 .33.051.431.157.087.095.137.248.147.456l-.962.144c-.219.03-.41.086-.57.166a1.245 1.245 0 0 0-.398.311c-.103.125-.181.27-.229.426-.097.33-.093.68.011 1.008a1.096 1.096 0 0 0 .638.67c.155.063.328.093.528.093a1.25 1.25 0 0 0 .978-.441v.345h1.007V4.65c0-.255-.03-.484-.089-.681a1.423 1.423 0 0 0-.275-.52zm-.636 1.896v.236c0 .119-.018.231-.055.341a.745.745 0 0 1-.377.447.694.694 0 0 1-.512.027.454.454 0 0 1-.156-.094.389.389 0 0 1-.094-.139.474.474 0 0 1-.035-.186c0-.077.01-.147.024-.212a.33.33 0 0 1 .078-.141.436.436 0 0 1 .161-.109 1.3 1.3 0 0 1 .305-.073l.661-.097zm5.051-1.067a2.253 2.253 0 0 0-.244-.656 1.354 1.354 0 0 0-.436-.459 1.165 1.165 0 0 0-.642-.173 1.136 1.136 0 0 0-.69.223 1.33 1.33 0 0 0-.264.266V1H5.09v6.224h.918v-.281c.123.152.287.266.472.328.098.032.208.047.33.047.255 0 .483-.06.677-.177.192-.115.355-.278.486-.486a2.29 2.29 0 0 0 .293-.718 3.87 3.87 0 0 0 .096-.886 3.714 3.714 0 0 0-.078-.773zm-.86.758c0 .232-.02.439-.06.613-.036.172-.09.315-.159.424a.639.639 0 0 1-.233.237.582.582 0 0 1-.565.014.683.683 0 0 1-.21-.183.925.925 0 0 1-.142-.283A1.187 1.187 0 0 1 6 5.5v-.517c0-.164.02-.314.06-.447.036-.132.087-.242.156-.336a.668.668 0 0 1 .228-.208.584.584 0 0 1 .29-.071.554.554 0 0 1 .496.279c.063.099.108.214.143.354.031.143.05.306.05.482zM2.407 9.9a.913.913 0 0 1 .316-.239c.218-.1.547-.105.766-.018.104.042.204.1.32.184l.33.26V8.945l-.097-.062a1.932 1.932 0 0 0-.905-.215c-.308 0-.593.057-.846.168-.25.11-.467.27-.647.475-.18.21-.318.453-.403.717-.09.272-.137.57-.137.895 0 .289.043.561.13.808.086.249.211.471.373.652.161.185.361.333.597.441.232.104.493.155.778.155.233 0 .434-.028.613-.084.165-.05.322-.123.466-.217l.078-.061v-.889l-.2.095a.4.4 0 0 1-.076.026c-.05.017-.099.035-.128.049-.036.023-.227.09-.227.09-.06.024-.14.043-.218.059a.977.977 0 0 1-.599-.057.827.827 0 0 1-.306-.225 1.088 1.088 0 0 1-.205-.376 1.728 1.728 0 0 1-.076-.529c0-.21.028-.399.083-.56.054-.158.13-.294.22-.4zM14 6h-4V5h4.5l.5.5v6l-.5.5H7.879l2.07 2.071-.706.707-2.89-2.889v-.707l2.89-2.89L9.95 9l-2 2H14V6z"></path>
|
||||
</svg>`,
|
||||
"wrap-off": `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" opacity="0.85" d="M3.868 3.449a1.21 1.21 0 0 0-.473-.329c-.274-.111-.623-.15-1.055-.076a3.5 3.5 0 0 0-.71.208c-.082.035-.16.077-.235.125l-.043.03v1.056l.168-.139c.15-.124.326-.225.527-.303.196-.074.4-.113.604-.113.188 0 .33.051.431.157.087.095.137.248.147.456l-.962.144c-.219.03-.41.086-.57.166a1.245 1.245 0 0 0-.398.311c-.103.125-.181.27-.229.426-.097.33-.093.68.011 1.008a1.096 1.096 0 0 0 .638.67c.155.063.328.093.528.093a1.25 1.25 0 0 0 .978-.441v.345h1.007V4.65c0-.255-.03-.484-.089-.681a1.423 1.423 0 0 0-.275-.52zm-.636 1.896v.236c0 .119-.018.231-.055.341a.745.745 0 0 1-.377.447.694.694 0 0 1-.512.027.454.454 0 0 1-.156-.094.389.389 0 0 1-.094-.139.474.474 0 0 1-.035-.186c0-.077.01-.147.024-.212a.33.33 0 0 1 .078-.141.436.436 0 0 1 .161-.109 1.3 1.3 0 0 1 .305-.073l.661-.097zm5.051-1.067a2.253 2.253 0 0 0-.244-.656 1.354 1.354 0 0 0-.436-.459 1.165 1.165 0 0 0-.642-.173 1.136 1.136 0 0 0-.69.223 1.33 1.33 0 0 0-.264.266V1H5.09v6.224h.918v-.281c.123.152.287.266.472.328.098.032.208.047.33.047.255 0 .483-.06.677-.177.192-.115.355-.278.486-.486a2.29 2.29 0 0 0 .293-.718 3.87 3.87 0 0 0 .096-.886 3.714 3.714 0 0 0-.078-.773zm-.86.758c0 .232-.02.439-.06.613-.036.172-.09.315-.159.424a.639.639 0 0 1-.233.237.582.582 0 0 1-.565.014.683.683 0 0 1-.21-.183.925.925 0 0 1-.142-.283A1.187 1.187 0 0 1 6 5.5v-.517c0-.164.02-.314.06-.447.036-.132.087-.242.156-.336a.668.668 0 0 1 .228-.208.584.584 0 0 1 .29-.071.554.554 0 0 1 .496.279c.063.099.108.214.143.354.031.143.05.306.05.482zM2.407 9.9a.913.913 0 0 1 .316-.239c.218-.1.547-.105.766-.018.104.042.204.1.32.184l.33.26V8.945l-.097-.062a1.932 1.932 0 0 0-.905-.215c-.308 0-.593.057-.846.168-.25.11-.467.27-.647.475-.18.21-.318.453-.403.717-.09.272-.137.57-.137.895 0 .289.043.561.13.808.086.249.211.471.373.652.161.185.361.333.597.441.232.104.493.155.778.155.233 0 .434-.028.613-.084.165-.05.322-.123.466-.217l.078-.061v-.889l-.2.095a.4.4 0 0 1-.076.026c-.05.017-.099.035-.128.049-.036.023-.227.09-.227.09-.06.024-.14.043-.218.059a.977.977 0 0 1-.599-.057.827.827 0 0 1-.306-.225 1.088 1.088 0 0 1-.205-.376 1.728 1.728 0 0 1-.076-.529c0-.21.028-.399.083-.56.054-.158.13-.294.22-.4zM14 6h-4V5h4.5l.5.5v6l-.5.5H7.879l2.07 2.071-.706.707-2.89-2.889v-.707l2.89-2.89L9.95 9l-2 2H14V6z"></path>
|
||||
</svg>`,
|
||||
"background-on": `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" opacity="0.5" d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25"></path>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
|
||||
<path fill="currentColor" opacity="0.5" d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75"></path>
|
||||
</svg>`,
|
||||
"background-off": `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" opacity="0.34" d="M0 2.25a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 2.25"></path>
|
||||
<path fill="currentColor" opacity="0.34" fill-rule="evenodd" d="M15 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zM2.5 9a.5.5 0 0 0 0 1h8a.5.5 0 0 0 0-1zm0-2a.5.5 0 0 0 0 1h11a.5.5 0 0 0 0-1z" clip-rule="evenodd"></path>
|
||||
<path fill="currentColor" opacity="0.34" d="M0 14.75A.75.75 0 0 1 .75 14h5.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75"></path>
|
||||
<path d="M2.5 13.5 13.5 2.5" stroke="currentColor" stroke-width="1.35" stroke-linecap="round"></path>
|
||||
</svg>`,
|
||||
"theme-dark": `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M10.794 3.647a.217.217 0 0 1 .412 0l.387 1.162c.173.518.58.923 1.097 1.096l1.162.388a.217.217 0 0 1 0 .412l-1.162.386a1.73 1.73 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.74 1.74 0 0 0 9.31 7.092l-1.162-.386a.217.217 0 0 1 0-.412l1.162-.388a1.73 1.73 0 0 0 1.097-1.096zM13.863.598a.144.144 0 0 1 .221-.071.14.14 0 0 1 .053.07l.258.775c.115.345.386.616.732.731l.774.258a.145.145 0 0 1 0 .274l-.774.259a1.16 1.16 0 0 0-.732.732l-.258.773a.145.145 0 0 1-.274 0l-.258-.773a1.16 1.16 0 0 0-.732-.732l-.774-.259a.145.145 0 0 1 0-.273l.774-.259c.346-.115.617-.386.732-.732z"></path>
|
||||
<path fill="currentColor" d="M6.25 1.742a.67.67 0 0 1 .07.75 6.3 6.3 0 0 0-.768 3.028c0 2.746 1.746 5.084 4.193 5.979H1.774A7.2 7.2 0 0 1 1 8.245c0-3.013 1.85-5.598 4.484-6.694a.66.66 0 0 1 .766.19M.75 12.499a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z"></path>
|
||||
</svg>`,
|
||||
"theme-light": `<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M8.21 2.109a.256.256 0 0 0-.42 0L6.534 3.893a.256.256 0 0 1-.316.085l-1.982-.917a.256.256 0 0 0-.362.21l-.196 2.174a.256.256 0 0 1-.232.232l-2.175.196a.256.256 0 0 0-.209.362l.917 1.982a.256.256 0 0 1-.085.316L.11 9.791a.256.256 0 0 0 0 .418L1.23 11H3.1a5 5 0 1 1 9.8 0h1.869l1.123-.79a.256.256 0 0 0 0-.42l-1.785-1.257a.256.256 0 0 1-.085-.316l.917-1.982a.256.256 0 0 0-.21-.362l-2.174-.196a.256.256 0 0 1-.232-.232l-.196-2.175a.256.256 0 0 0-.362-.209l-1.982.917a.256.256 0 0 1-.316-.085z"></path>
|
||||
<path fill="currentColor" d="M4 10q.001.519.126 1h7.748A4 4 0 1 0 4 10M.75 12a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z"></path>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
function createToolbarButton(params: {
|
||||
title: string;
|
||||
active: boolean;
|
||||
icon: ToolbarIconName;
|
||||
onClick: () => void;
|
||||
}): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "oc-diff-toolbar-button";
|
||||
button.dataset.active = String(params.active);
|
||||
button.title = params.title;
|
||||
button.setAttribute("aria-label", params.title);
|
||||
button.innerHTML = toolbarIconSvg[params.icon];
|
||||
applyToolbarButtonStyles(button, params.active);
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
params.onClick();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function applyToolbarStyles(toolbar: HTMLElement): void {
|
||||
toolbar.style.display = "inline-flex";
|
||||
toolbar.style.alignItems = "center";
|
||||
toolbar.style.gap = "6px";
|
||||
toolbar.style.marginInlineStart = "6px";
|
||||
toolbar.style.flex = "0 0 auto";
|
||||
}
|
||||
|
||||
function applyToolbarButtonStyles(button: HTMLButtonElement, active: boolean): void {
|
||||
button.style.display = "inline-flex";
|
||||
button.style.alignItems = "center";
|
||||
button.style.justifyContent = "center";
|
||||
button.style.width = "24px";
|
||||
button.style.height = "24px";
|
||||
button.style.padding = "0";
|
||||
button.style.margin = "0";
|
||||
button.style.border = "0";
|
||||
button.style.borderRadius = "0";
|
||||
button.style.background = "transparent";
|
||||
button.style.boxShadow = "none";
|
||||
button.style.lineHeight = "0";
|
||||
button.style.cursor = "pointer";
|
||||
button.style.overflow = "visible";
|
||||
button.style.flex = "0 0 auto";
|
||||
button.style.opacity = active ? "0.92" : "0.6";
|
||||
button.style.color =
|
||||
viewerState.theme === "dark" ? "rgba(226, 232, 240, 0.74)" : "rgba(15, 23, 42, 0.52)";
|
||||
button.dataset.active = String(active);
|
||||
const icon = button.querySelector("svg");
|
||||
if (!icon) {
|
||||
return;
|
||||
}
|
||||
icon.style.display = "block";
|
||||
icon.style.width = "16px";
|
||||
icon.style.height = "16px";
|
||||
icon.style.minWidth = "16px";
|
||||
icon.style.minHeight = "16px";
|
||||
icon.style.overflow = "visible";
|
||||
icon.style.flex = "0 0 auto";
|
||||
icon.style.color = "inherit";
|
||||
icon.style.fill = "currentColor";
|
||||
icon.style.pointerEvents = "none";
|
||||
}
|
||||
|
||||
function createToolbar(): HTMLElement {
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "oc-diff-toolbar";
|
||||
applyToolbarStyles(toolbar);
|
||||
|
||||
toolbar.append(
|
||||
createToolbarButton({
|
||||
title: viewerState.layout === "unified" ? "Switch to split diff" : "Switch to unified diff",
|
||||
active: viewerState.layout === "split",
|
||||
icon: viewerState.layout === "split" ? "split" : "unified",
|
||||
onClick: () => {
|
||||
viewerState.layout = viewerState.layout === "unified" ? "split" : "unified";
|
||||
syncAllControllers();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
toolbar.append(
|
||||
createToolbarButton({
|
||||
title: viewerState.wrapEnabled ? "Disable word wrap" : "Enable word wrap",
|
||||
active: viewerState.wrapEnabled,
|
||||
icon: viewerState.wrapEnabled ? "wrap-on" : "wrap-off",
|
||||
onClick: () => {
|
||||
viewerState.wrapEnabled = !viewerState.wrapEnabled;
|
||||
syncAllControllers();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
toolbar.append(
|
||||
createToolbarButton({
|
||||
title: viewerState.backgroundEnabled
|
||||
? "Hide background highlights"
|
||||
: "Show background highlights",
|
||||
active: viewerState.backgroundEnabled,
|
||||
icon: viewerState.backgroundEnabled ? "background-on" : "background-off",
|
||||
onClick: () => {
|
||||
viewerState.backgroundEnabled = !viewerState.backgroundEnabled;
|
||||
syncAllControllers();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
toolbar.append(
|
||||
createToolbarButton({
|
||||
title: viewerState.theme === "dark" ? "Switch to light theme" : "Switch to dark theme",
|
||||
active: viewerState.theme === "dark",
|
||||
icon: viewerState.theme === "dark" ? "theme-dark" : "theme-light",
|
||||
onClick: () => {
|
||||
viewerState.theme = viewerState.theme === "dark" ? "light" : "dark";
|
||||
syncAllControllers();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
function createRenderOptions(payload: DiffViewerPayload): FileDiffOptions<undefined> {
|
||||
return {
|
||||
theme: payload.options.theme,
|
||||
themeType: viewerState.theme,
|
||||
diffStyle: viewerState.layout,
|
||||
diffIndicators: payload.options.diffIndicators,
|
||||
expandUnchanged: payload.options.expandUnchanged,
|
||||
overflow: viewerState.wrapEnabled ? "wrap" : "scroll",
|
||||
disableLineNumbers: payload.options.disableLineNumbers,
|
||||
disableBackground: !viewerState.backgroundEnabled,
|
||||
unsafeCSS: payload.options.unsafeCSS,
|
||||
renderHeaderMetadata: () => createToolbar(),
|
||||
};
|
||||
}
|
||||
|
||||
function syncDocumentTheme(): void {
|
||||
document.body.dataset.theme = viewerState.theme;
|
||||
}
|
||||
|
||||
function applyState(controller: DiffController): void {
|
||||
controller.diff.setOptions(createRenderOptions(controller.payload));
|
||||
controller.diff.rerender();
|
||||
}
|
||||
|
||||
function syncAllControllers(): void {
|
||||
syncDocumentTheme();
|
||||
for (const controller of controllers) {
|
||||
applyState(controller);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hydrateViewer(): Promise<void> {
|
||||
// Rehydration replaces the current DOM card set; do not retain controllers
|
||||
// from a previous render because they can keep stale DOM references alive.
|
||||
controllers.length = 0;
|
||||
const cards = await Promise.all(
|
||||
getCards().map(async ({ host, payload }) => ({
|
||||
host,
|
||||
payload: await normalizeDiffViewerPayloadLanguages(payload),
|
||||
})),
|
||||
);
|
||||
const langs = new Set<SupportedLanguages>();
|
||||
const firstPayload = cards[0]?.payload;
|
||||
|
||||
if (firstPayload) {
|
||||
viewerState.theme = firstPayload.options.themeType;
|
||||
viewerState.layout = firstPayload.options.diffStyle;
|
||||
viewerState.backgroundEnabled = firstPayload.options.backgroundEnabled;
|
||||
viewerState.wrapEnabled = firstPayload.options.overflow === "wrap";
|
||||
}
|
||||
|
||||
for (const { payload } of cards) {
|
||||
for (const lang of payload.langs) {
|
||||
langs.add(lang);
|
||||
}
|
||||
}
|
||||
|
||||
await preloadHighlighter({
|
||||
themes: ["pierre-light", "pierre-dark"],
|
||||
langs: [...langs],
|
||||
});
|
||||
|
||||
syncDocumentTheme();
|
||||
|
||||
for (const { host, payload } of cards) {
|
||||
try {
|
||||
ensureShadowRoot(host);
|
||||
const diff = new FileDiff(createRenderOptions(payload));
|
||||
diff.hydrate({
|
||||
fileContainer: host,
|
||||
prerenderedHTML: payload.prerenderedHTML,
|
||||
...getHydrateProps(payload),
|
||||
});
|
||||
const controller = { payload, diff };
|
||||
applyState(controller);
|
||||
controllers.push(controller);
|
||||
} catch (error) {
|
||||
console.warn("Skipping diff card that failed to hydrate", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
await hydrateViewer();
|
||||
document.documentElement.dataset.openclawDiffsReady = "true";
|
||||
} catch (error) {
|
||||
document.documentElement.dataset.openclawDiffsError = "true";
|
||||
console.error("Failed to hydrate diff viewer", error);
|
||||
}
|
||||
}
|
||||
|
||||
export const disableAutoStartKey = Symbol.for("openclaw.diffs.disableAutoStart");
|
||||
|
||||
const autoStartDisabled = Boolean(
|
||||
(globalThis as typeof globalThis & Record<symbol, unknown>)[disableAutoStartKey],
|
||||
);
|
||||
|
||||
if (typeof document !== "undefined" && !autoStartDisabled) {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void main();
|
||||
});
|
||||
} else {
|
||||
void main();
|
||||
}
|
||||
}
|
||||
95
extensions/diffs/src/viewer-payload.ts
Normal file
95
extensions/diffs/src/viewer-payload.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Diffs plugin module implements viewer payload behavior.
|
||||
import { DIFF_INDICATORS, DIFF_LAYOUTS, DIFF_THEMES } from "./types.js";
|
||||
import type { DiffViewerPayload } from "./types.js";
|
||||
|
||||
const OVERFLOW_VALUES = ["scroll", "wrap"] as const;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseViewerPayloadJson(raw: string): DiffViewerPayload {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Diff payload is not valid JSON.");
|
||||
}
|
||||
|
||||
if (!isDiffViewerPayload(parsed)) {
|
||||
throw new Error("Diff payload has invalid shape.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isDiffViewerPayload(value: unknown): value is DiffViewerPayload {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value.prerenderedHTML !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.langs) || !value.langs.every((lang) => typeof lang === "string")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isViewerOptions(value.options)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasFileDiff = isRecord(value.fileDiff);
|
||||
const hasBeforeAfterFiles = isRecord(value.oldFile) && isRecord(value.newFile);
|
||||
if (!hasFileDiff && !hasBeforeAfterFiles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isViewerOptions(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isRecord(value.theme)) {
|
||||
return false;
|
||||
}
|
||||
if (value.theme.light !== "pierre-light" || value.theme.dark !== "pierre-dark") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!includesValue(DIFF_LAYOUTS, value.diffStyle)) {
|
||||
return false;
|
||||
}
|
||||
if (!includesValue(DIFF_INDICATORS, value.diffIndicators)) {
|
||||
return false;
|
||||
}
|
||||
if (!includesValue(DIFF_THEMES, value.themeType)) {
|
||||
return false;
|
||||
}
|
||||
if (!includesValue(OVERFLOW_VALUES, value.overflow)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value.disableLineNumbers !== "boolean") {
|
||||
return false;
|
||||
}
|
||||
if (typeof value.expandUnchanged !== "boolean") {
|
||||
return false;
|
||||
}
|
||||
if (typeof value.backgroundEnabled !== "boolean") {
|
||||
return false;
|
||||
}
|
||||
if (typeof value.unsafeCSS !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function includesValue<T extends readonly string[]>(values: T, value: unknown): value is T[number] {
|
||||
return typeof value === "string" && values.includes(value as T[number]);
|
||||
}
|
||||
16
extensions/diffs/tsconfig.json
Normal file
16
extensions/diffs/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user