refactor(coding-agent): consolidated tool surface onto xd:// devices and hub
- Added the `xd://` virtual device protocol (`internal-urls/xd-protocol.ts`, `tools/xdev.ts`): tools declaring `loadMode: "discoverable"` are unmounted from the request tools array and driven via `read xd://` (list/docs+schema) and `write xd://<tool>` (execute), gated by the `tools.xdev` setting (default on) and inlined into the system prompt. - Merged the `irc`, `job`, and `launch` tools into a single `hub` tool (`tools/hub/`, `async/job-manager.ts`): messaging keeps `send`/`inbox`/`list`, job control maps to `wait`/`cancel`/`jobs`, process supervision keeps `start`/`logs`/`stop`/`restart`/`describe` with `ps`, and the unified `wait` races background jobs against peer messages; SDK `IrcTool`/`JobTool`/`LaunchTool` are replaced by `HubTool`. - Removed the hidden `resolve` tool in favor of the `xd://resolve`/`xd://reject`/`xd://propose` resolution devices, auto-including `write` whenever a deferrable tool or plan mode is present. - Removed the BM25 tool-discovery system: the `search_tool_bm25` tool, the `tool-discovery` module, the `tools.discoveryMode`/`mcp.discoveryMode`/`mcp.discoveryDefaultServers`/`tools.essentialOverride` settings, per-tool MCP selection, and the `mcp_tool_selection` message type. - Unified tool presentation on `ToolLoadMode` (`essential`|`discoverable`), replacing the custom-tool `xdev?: boolean` opt-out; custom, extension, MCP, RPC host, image-generation, and TTS tools now default to `discoverable`, and added a `satisfies` predicate to `SoftToolRequirement`. - Removed the standalone `ssh` command tool and `ssh/ssh-executor` (the `ssh://` read/write/search protocol stays), and made `--tools` address hidden built-ins. - Updated collab-web to render `xd://` dispatches and `hub` op families, dropped the `search_tool_bm25`/`ssh`/`report-finding` renderers, refreshed tool docs and prompts, and migrated the affected tests and changelogs.
This commit is contained in:
@@ -2,6 +2,15 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced the `irc`/`job` tool renderers (and their legacy `await`/`poll`/`cancel_job` aliases) with a single `hub` renderer that dispatches per op family — messaging, background jobs, and process supervision — keeping the existing irc/job visuals.
|
||||
- Collab-web renders successful `xd://` device dispatches through the inner tool's renderer: a `write` result carrying `details.xdev` (execute mode) resolves the renderer by `xdev.tool`, uses `xdev.args`, and swaps in `xdev.inner` as details, with the card labeled `xd://<tool>` (preserves generated-image thumbnails and MCP/autoresearch presentation).
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed custom visualization for `search_tool_bm25` tool; falls back to generic rendering
|
||||
|
||||
## [16.5.1] - 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -36,15 +36,33 @@ function normalizeArgs(raw: unknown): { args: Record<string, unknown>; intent: s
|
||||
return { args, intent };
|
||||
}
|
||||
|
||||
interface XdevDispatch {
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
inner: unknown;
|
||||
}
|
||||
|
||||
function executeXdevDispatch(props: ToolViewProps): XdevDispatch | null {
|
||||
if (props.name !== "write" || props.result?.isError === true || !isRecord(props.result?.details)) return null;
|
||||
const xdev = props.result.details.xdev;
|
||||
if (!isRecord(xdev) || xdev.mode !== "execute" || typeof xdev.tool !== "string") return null;
|
||||
return { tool: xdev.tool, args: isRecord(xdev.args) ? xdev.args : {}, inner: xdev.inner };
|
||||
}
|
||||
|
||||
export function ToolView(props: ToolViewProps): ReactNode {
|
||||
const [open, setOpen] = useState(props.defaultOpen ?? false);
|
||||
const xdev = executeXdevDispatch(props);
|
||||
const { args, intent: argIntent } = normalizeArgs(props.args);
|
||||
const intent = props.intent?.trim() || argIntent;
|
||||
const renderer = resolveToolRenderer(props.name);
|
||||
const name = xdev?.tool ?? props.name;
|
||||
const result = xdev
|
||||
? { content: props.result!.content, details: xdev.inner, isError: props.result!.isError }
|
||||
: props.result;
|
||||
const renderer = resolveToolRenderer(name);
|
||||
const renderProps: ToolRenderProps = {
|
||||
name: props.name,
|
||||
args,
|
||||
result: props.result,
|
||||
name,
|
||||
args: xdev?.args ?? args,
|
||||
result,
|
||||
running: props.running,
|
||||
host: props.host,
|
||||
};
|
||||
@@ -67,7 +85,7 @@ export function ToolView(props: ToolViewProps): ReactNode {
|
||||
) : (
|
||||
<span className={`tv-status tv-status--${status}`} aria-hidden="true" />
|
||||
)}
|
||||
<span className="tv-name">{props.name}</span>
|
||||
<span className="tv-name">{xdev ? `xd://${name}` : name}</span>
|
||||
<span className="tv-sum">
|
||||
<renderer.Summary {...renderProps} />
|
||||
</span>
|
||||
|
||||
@@ -17,19 +17,15 @@ import { githubRenderer } from "./tools/github";
|
||||
import { globRenderer } from "./tools/glob";
|
||||
import { goalRenderer } from "./tools/goal";
|
||||
import { grepRenderer } from "./tools/grep";
|
||||
import { hubRenderer } from "./tools/hub";
|
||||
import { inspectImageRenderer } from "./tools/inspect-image";
|
||||
import { ircRenderer } from "./tools/irc";
|
||||
import { jobRenderer } from "./tools/job";
|
||||
import { lspRenderer } from "./tools/lsp";
|
||||
import { recallRenderer } from "./tools/memory-recall";
|
||||
import { reflectRenderer } from "./tools/memory-reflect";
|
||||
import { retainRenderer } from "./tools/memory-retain";
|
||||
import { readRenderer } from "./tools/read";
|
||||
import { reportFindingRenderer } from "./tools/report-finding";
|
||||
import { reportToolIssueRenderer } from "./tools/report-tool-issue";
|
||||
import { resolveRenderer } from "./tools/resolve";
|
||||
import { searchBm25Renderer } from "./tools/search-bm25";
|
||||
import { sshRenderer } from "./tools/ssh";
|
||||
import { taskRenderer } from "./tools/task";
|
||||
import { todoRenderer } from "./tools/todo";
|
||||
import { webSearchRenderer } from "./tools/web-search";
|
||||
@@ -58,23 +54,16 @@ const RENDERERS: Record<string, ToolRenderer> = {
|
||||
github: githubRenderer,
|
||||
goal: goalRenderer,
|
||||
inspect_image: inspectImageRenderer,
|
||||
irc: ircRenderer,
|
||||
job: jobRenderer,
|
||||
await: jobRenderer,
|
||||
poll: jobRenderer,
|
||||
cancel_job: jobRenderer,
|
||||
hub: hubRenderer,
|
||||
lsp: lspRenderer,
|
||||
recall: recallRenderer,
|
||||
reflect: reflectRenderer,
|
||||
retain: retainRenderer,
|
||||
read: readRenderer,
|
||||
report_finding: reportFindingRenderer,
|
||||
report_tool_issue: reportToolIssueRenderer,
|
||||
resolve: resolveRenderer,
|
||||
grep: grepRenderer,
|
||||
search: grepRenderer,
|
||||
search_tool_bm25: searchBm25Renderer,
|
||||
ssh: sshRenderer,
|
||||
task: taskRenderer,
|
||||
todo: todoRenderer,
|
||||
web_search: webSearchRenderer,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* `hub` — merged agent-coordination surface: peer messaging (irc rendering),
|
||||
* background jobs (job rendering), and process supervision (generic rendering,
|
||||
* matching the pre-merge launch tool). Dispatches per op family so each
|
||||
* result keeps its original look.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { genericRenderer } from "../generic";
|
||||
import type { ToolRenderer, ToolRenderProps } from "../types";
|
||||
import { detailsRecord } from "../util";
|
||||
import { ircRenderer } from "./irc";
|
||||
import { jobRenderer } from "./job";
|
||||
|
||||
const LAUNCH_OPS = new Set(["start", "ps", "logs", "stop", "restart", "describe"]);
|
||||
|
||||
type Family = "launch" | "jobs" | "messaging";
|
||||
|
||||
/** Results dispatch on what actually happened; calls fall back to the arg shape. */
|
||||
function familyOf({ args, result }: ToolRenderProps): Family {
|
||||
const details = detailsRecord(result);
|
||||
if (details) {
|
||||
// `state`/`cursor` cover logs results, which may carry neither a daemon
|
||||
// snapshot nor terminal rows; coordination details never define these keys.
|
||||
if (
|
||||
"daemon" in details ||
|
||||
"daemons" in details ||
|
||||
"terminalRows" in details ||
|
||||
"spec" in details ||
|
||||
"state" in details ||
|
||||
"cursor" in details
|
||||
) {
|
||||
return "launch";
|
||||
}
|
||||
if (Array.isArray(details.jobs) || Array.isArray(details.agents)) return "jobs";
|
||||
if ("receipts" in details || "waited" in details || "inbox" in details || "peers" in details) {
|
||||
return "messaging";
|
||||
}
|
||||
}
|
||||
const op = typeof args.op === "string" ? args.op : "";
|
||||
if (LAUNCH_OPS.has(op)) return "launch";
|
||||
const name = typeof args.name === "string" && args.name.length > 0;
|
||||
const to = typeof args.to === "string" && args.to.length > 0;
|
||||
const from = typeof args.from === "string" && args.from.length > 0;
|
||||
if ((op === "send" || op === "wait") && name && !to && !from) return "launch";
|
||||
if (op === "jobs" || op === "cancel") return "jobs";
|
||||
if (op === "wait" && !from) return "jobs";
|
||||
return "messaging";
|
||||
}
|
||||
|
||||
/** Hub args → legacy job-renderer arg shape (`poll`/`cancel`/`list`). */
|
||||
function toJobProps(props: ToolRenderProps): ToolRenderProps {
|
||||
const op = typeof props.args.op === "string" ? props.args.op : "";
|
||||
const args: Record<string, unknown> =
|
||||
op === "cancel" ? { cancel: props.args.ids } : op === "jobs" ? { list: true } : { poll: props.args.ids };
|
||||
return { ...props, args };
|
||||
}
|
||||
|
||||
function Summary(props: ToolRenderProps): ReactNode {
|
||||
switch (familyOf(props)) {
|
||||
case "launch":
|
||||
return <genericRenderer.Summary {...props} />;
|
||||
case "jobs": {
|
||||
const jobProps = toJobProps(props);
|
||||
return <jobRenderer.Summary {...jobProps} />;
|
||||
}
|
||||
default:
|
||||
return <ircRenderer.Summary {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function Body(props: ToolRenderProps): ReactNode {
|
||||
switch (familyOf(props)) {
|
||||
case "launch":
|
||||
return genericRenderer.Body ? <genericRenderer.Body {...props} /> : null;
|
||||
case "jobs": {
|
||||
const jobProps = toJobProps(props);
|
||||
return jobRenderer.Body ? <jobRenderer.Body {...jobProps} /> : null;
|
||||
}
|
||||
default:
|
||||
return ircRenderer.Body ? <ircRenderer.Body {...props} /> : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const hubRenderer: ToolRenderer = { Summary, Body };
|
||||
@@ -1,54 +0,0 @@
|
||||
/** `report_finding` — a structured code-review finding from a reviewer agent. */
|
||||
import type { ReactNode } from "react";
|
||||
import type { Tone } from "../parts";
|
||||
import { Badge, Badges, Output, PathText, ResultText } from "../parts";
|
||||
import type { ToolRenderer, ToolRenderProps } from "../types";
|
||||
import { num, str, truncate } from "../util";
|
||||
|
||||
function priorityTone(priority: string): Tone | undefined {
|
||||
switch (priority) {
|
||||
case "P0":
|
||||
return "err";
|
||||
case "P1":
|
||||
return "warn";
|
||||
case "P3":
|
||||
return "accent";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function Summary({ args }: ToolRenderProps): ReactNode {
|
||||
const priority = str(args.priority);
|
||||
const title = str(args.title);
|
||||
return (
|
||||
<>
|
||||
{priority && <Badge tone={priorityTone(priority)}>{priority}</Badge>}
|
||||
{title && <span> {truncate(title.replace(/^\[P\d\]\s*/, ""), 80)}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ args, result }: ToolRenderProps): ReactNode {
|
||||
const priority = str(args.priority);
|
||||
const confidence = num(args.confidence);
|
||||
const filePath = str(args.file_path);
|
||||
const lineStart = num(args.line_start);
|
||||
const lineEnd = num(args.line_end);
|
||||
const body = str(args.body);
|
||||
return (
|
||||
<>
|
||||
<Badges
|
||||
items={[
|
||||
priority && <Badge tone={priorityTone(priority)}>{priority}</Badge>,
|
||||
confidence !== null && <Badge>confidence {(confidence * 100).toFixed(0)}%</Badge>,
|
||||
filePath && <PathText path={filePath} from={lineStart ?? undefined} to={lineEnd ?? undefined} />,
|
||||
]}
|
||||
/>
|
||||
{body && <Output text={body} maxLines={12} />}
|
||||
{result?.isError && <ResultText result={result} maxLines={6} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const reportFindingRenderer: ToolRenderer = { Summary, Body };
|
||||
@@ -1,118 +0,0 @@
|
||||
/** `search_tool_bm25` — BM25 tool discovery: query in, ranked tool matches out. */
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Badges, InvalidArg, Kv, KvGrid, Note, ResultText, Row } from "../parts";
|
||||
import type { ToolRenderer, ToolRenderProps } from "../types";
|
||||
import { detailsRecord, isRecord, normalizeWs, num, str, truncate } from "../util";
|
||||
|
||||
interface Bm25Match {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
serverName: string | null;
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
function matchOf(value: unknown): Bm25Match | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const name = str(value.name) ?? "";
|
||||
const label = str(value.label) ?? name;
|
||||
if (!label) return null;
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
description: str(value.description) ?? "",
|
||||
serverName: str(value.server_name),
|
||||
score: num(value.score),
|
||||
};
|
||||
}
|
||||
|
||||
function strList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item === "string" && item) out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function Summary({ args, result }: ToolRenderProps): ReactNode {
|
||||
const query = str(args.query);
|
||||
const limit = num(args.limit);
|
||||
const details = detailsRecord(result);
|
||||
const tools = details && Array.isArray(details.tools) ? details.tools : null;
|
||||
return (
|
||||
<>
|
||||
{query !== null ? (
|
||||
<span className="tv-pattern">{truncate(normalizeWs(query), 64) || "(empty query)"}</span>
|
||||
) : (
|
||||
<InvalidArg what="query" />
|
||||
)}
|
||||
{limit !== null && <Badge>limit:{limit}</Badge>}
|
||||
{tools && (
|
||||
<Badge tone={tools.length > 0 ? "ok" : "warn"}>
|
||||
{tools.length} match{tools.length === 1 ? "" : "es"}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ args, result }: ToolRenderProps): ReactNode {
|
||||
const details = detailsRecord(result);
|
||||
const query = str(details?.query) ?? str(args.query);
|
||||
const limit = num(details?.limit) ?? num(args.limit);
|
||||
const totalTools = num(details?.total_tools);
|
||||
const activated = strList(details?.activated_tools);
|
||||
const activeSelected = strList(details?.active_selected_tools);
|
||||
const matches: Bm25Match[] = [];
|
||||
if (details && Array.isArray(details.tools)) {
|
||||
for (const item of details.tools) {
|
||||
const match = matchOf(item);
|
||||
if (match) matches.push(match);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<KvGrid>
|
||||
<Kv k="query">
|
||||
{query !== null ? <span className="tv-pattern">{query}</span> : <InvalidArg what="query" />}
|
||||
</Kv>
|
||||
{limit !== null && <Kv k="limit">{limit}</Kv>}
|
||||
{details && (
|
||||
<Kv k="tools">
|
||||
{matches.length} matched
|
||||
{activeSelected.length > 0 && ` · ${activeSelected.length} active`}
|
||||
{totalTools !== null && ` · ${totalTools} total`}
|
||||
</Kv>
|
||||
)}
|
||||
{activated.length > 0 && (
|
||||
<Kv k="activated">
|
||||
<Badges items={activated} />
|
||||
</Kv>
|
||||
)}
|
||||
</KvGrid>
|
||||
{details && !result?.isError && matches.length === 0 && (
|
||||
<Note tone="warn">
|
||||
{totalTools === 0 ? "No discoverable tools are currently loaded." : "No matching tools found."}
|
||||
</Note>
|
||||
)}
|
||||
{matches.length > 0 && (
|
||||
<div className="tv-list">
|
||||
{matches.map((match, i) => (
|
||||
<Row key={i} k={match.score !== null ? match.score.toFixed(3) : undefined}>
|
||||
<span className="tv-pattern">{match.label}</span>
|
||||
{match.serverName && <Badge>{match.serverName}</Badge>}
|
||||
{match.name && match.name !== match.label && <span className="tv-faint"> {match.name}</span>}
|
||||
{match.description && (
|
||||
<span className="tv-muted"> — {truncate(normalizeWs(match.description), 140)}</span>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(!details || result?.isError) && <ResultText result={result} maxLines={10} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const searchBm25Renderer: ToolRenderer = { Summary, Body };
|
||||
@@ -1,104 +0,0 @@
|
||||
/** `ssh` — remote command execution on a configured SSH host. */
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Badges, InvalidArg, Note, Output, ResultText } from "../parts";
|
||||
import type { ToolRenderer, ToolRenderProps } from "../types";
|
||||
import {
|
||||
detailsRecord,
|
||||
isRecord,
|
||||
normalizeWs,
|
||||
num,
|
||||
replaceTabs,
|
||||
resultTextOf,
|
||||
shortenPath,
|
||||
str,
|
||||
truncate,
|
||||
} from "../util";
|
||||
|
||||
/** Subset of the coding-agent `TruncationMeta` we surface. */
|
||||
interface TruncationInfo {
|
||||
totalLines: number | null;
|
||||
outputLines: number | null;
|
||||
artifactId: string | null;
|
||||
}
|
||||
|
||||
function truncationOf(result: ToolRenderProps["result"]): TruncationInfo | null {
|
||||
const meta = detailsRecord(result)?.meta;
|
||||
if (!isRecord(meta) || !isRecord(meta.truncation)) return null;
|
||||
const t = meta.truncation;
|
||||
return {
|
||||
totalLines: num(t.totalLines),
|
||||
outputLines: num(t.outputLines),
|
||||
artifactId: str(t.artifactId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool wrapper bakes a trailing `[Showing lines …]` notice into the
|
||||
* LLM-facing text. Like the TUI, drop it when we render our own styled
|
||||
* truncation note. Only called when truncation meta is present.
|
||||
*/
|
||||
function stripTruncationNotice(text: string): string {
|
||||
const trimmed = text.trimEnd();
|
||||
if (!trimmed.endsWith("]")) return trimmed;
|
||||
const idx = trimmed.lastIndexOf("\n[Showing ");
|
||||
if (idx >= 0) return trimmed.slice(0, idx).trimEnd();
|
||||
return trimmed.startsWith("[Showing ") && !trimmed.includes("\n") ? "" : trimmed;
|
||||
}
|
||||
|
||||
function Summary({ args }: ToolRenderProps): ReactNode {
|
||||
const host = str(args.host);
|
||||
const command = str(args.command);
|
||||
return (
|
||||
<>
|
||||
<Badge tone="accent">{host ?? "…"}</Badge>{" "}
|
||||
{command !== null
|
||||
? truncate(normalizeWs(command), 80)
|
||||
: args.command !== undefined && <InvalidArg what="command" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ args, result }: ToolRenderProps): ReactNode {
|
||||
const host = str(args.host);
|
||||
const command = str(args.command);
|
||||
const cwd = str(args.cwd);
|
||||
const timeout = num(args.timeout);
|
||||
const trunc = truncationOf(result);
|
||||
const stripped =
|
||||
trunc !== null && result?.isError !== true ? stripTruncationNotice(resultTextOf(result).trim()) : null;
|
||||
return (
|
||||
<>
|
||||
<Badges
|
||||
items={[
|
||||
host !== null ? <Badge tone="accent">{host}</Badge> : <InvalidArg what="host" />,
|
||||
cwd !== null && <Badge>cwd {shortenPath(cwd)}</Badge>,
|
||||
timeout !== null && <Badge>timeout {timeout}s</Badge>,
|
||||
]}
|
||||
/>
|
||||
{command !== null ? (
|
||||
<div className="tv-cmd">
|
||||
<span className="tv-cmd-prompt">$</span>
|
||||
<span className="tv-cmd-text">{replaceTabs(command)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<InvalidArg what="command" />
|
||||
)}
|
||||
{stripped !== null ? (
|
||||
stripped !== "" && <Output text={stripped} maxLines={12} />
|
||||
) : (
|
||||
<ResultText result={result} maxLines={12} />
|
||||
)}
|
||||
{trunc !== null && (
|
||||
<Note tone="warn">
|
||||
Output truncated
|
||||
{trunc.outputLines !== null &&
|
||||
trunc.totalLines !== null &&
|
||||
` — showing ${trunc.outputLines} of ${trunc.totalLines} lines`}
|
||||
{trunc.artifactId !== null && ` · full output at artifact://${trunc.artifactId}`}
|
||||
</Note>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const sshRenderer: ToolRenderer = { Summary, Body };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ToolView } from "../src/tool-render/ToolView";
|
||||
|
||||
describe("ToolView xd:// dispatches", () => {
|
||||
it("renders successful execute-mode xdev writes as the inner generate_image tool", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ToolView
|
||||
name="write"
|
||||
defaultOpen
|
||||
result={{
|
||||
content: [],
|
||||
details: {
|
||||
xdev: {
|
||||
tool: "generate_image",
|
||||
mode: "execute",
|
||||
args: { subject: "alpine lake" },
|
||||
inner: {
|
||||
images: [{ data: "aW1hZ2U=", mimeType: "image/png" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("xd://generate_image");
|
||||
expect(html).toContain("alpine lake");
|
||||
expect(html).toContain('src="data:image/png;base64,aW1hZ2U="');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user