@lalalic/markcut 3.2.11 → 3.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/mcp/bridge.mjs +4 -3
- package/src/mcp/preview.mjs +94 -36
- package/src/mcp/server.mjs +9 -21
- package/src/player/server.mjs +12 -0
- package/tests/mcp-preview.test.ts +47 -18
package/package.json
CHANGED
package/src/mcp/bridge.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export function buildPreviewBridgeScript(submitToolName) {
|
|
2
2
|
return `
|
|
3
|
-
const feedback=document.getElementById('feedback'),status=document.getElementById('status'),
|
|
3
|
+
const feedback=document.getElementById('feedback'),status=document.getElementById('status'),player=document.getElementById('video-player'),pathLabel=document.getElementById('path-label');
|
|
4
4
|
let nextRequestId=0;
|
|
5
5
|
const pendingRequests=new Map();
|
|
6
6
|
function send(message){window.parent.postMessage(message,'*');}
|
|
7
7
|
function rpcRequest(method,params){const id=++nextRequestId;send({jsonrpc:'2.0',id,method,params});return new Promise((resolve,reject)=>pendingRequests.set(id,{resolve,reject}));}
|
|
8
|
-
function
|
|
8
|
+
async function waitForVideo(url){status.textContent='Preparing video…';for(;;){try{const response=await fetch(url+'api/ready',{cache:'no-store'});if(response.ok){const ready=await response.json();if(ready.ready){player.src=url;status.textContent='Video ready for review.';return;}}}catch{}await new Promise(resolve=>setTimeout(resolve,1500));}}
|
|
9
|
+
function applyToolResult(params){const result=params?.result||params||{},data=result.structuredContent||result.structured_content||{};if(typeof data.path==='string')pathLabel.textContent=data.path;if(typeof data.previewUrl==='string')waitForVideo(data.previewUrl);}
|
|
9
10
|
window.addEventListener('message',event=>{if(event.source!==window.parent)return;const message=event.data;if(!message||typeof message!=='object'||message.jsonrpc!=='2.0')return;if(Object.prototype.hasOwnProperty.call(message,'id')&&pendingRequests.has(message.id)){const pending=pendingRequests.get(message.id);pendingRequests.delete(message.id);if(message.error)pending.reject(new Error(message.error.message||'MCP request failed'));else pending.resolve(message.result);return;}if(message.method==='ui/notifications/tool-result')applyToolResult(message.params);});
|
|
10
|
-
async function initializeBridge(){try{await rpcRequest('ui/initialize',{appInfo:{name:'Markcut Preview',version:'1.
|
|
11
|
+
async function initializeBridge(){try{await rpcRequest('ui/initialize',{appInfo:{name:'Markcut Preview',version:'1.1.0'},appCapabilities:{},protocolVersion:'2026-01-26'});send({jsonrpc:'2.0',method:'ui/notifications/initialized'});if(!player.src)status.textContent='Waiting for video preview…';}catch(error){status.textContent=error.message||'MCP initialization failed.';}}
|
|
11
12
|
async function submit(decision){const value=feedback.value.trim();if(decision==='changes_requested'&&!value){status.textContent='Feedback is required.';feedback.focus();return;}const result={decision};if(value&&decision==='changes_requested')result.feedback=value;status.textContent='Submitting...';try{await rpcRequest('tools/call',{name:'${submitToolName}',arguments:result});await rpcRequest('ui/message',{role:'user',content:[{type:'text',text:JSON.stringify(result)}]});status.textContent=decision==='approved'?'Approved.':'Changes requested.';}catch(error){status.textContent=error.message||'Unable to submit review.';}}
|
|
12
13
|
initializeBridge();
|
|
13
14
|
document.getElementById('approve').onclick=()=>submit('approved');document.getElementById('changes').onclick=()=>submit('changes_requested');
|
package/src/mcp/preview.mjs
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { isAbsolute, extname, dirname, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
import { buildPreviewBridgeScript } from "./bridge.mjs";
|
|
4
7
|
|
|
5
8
|
export const TOOL_NAME = "markcut.preview";
|
|
6
9
|
export const SUBMIT_TOOL_NAME = "markcut.preview.submit";
|
|
7
|
-
export const RESOURCE_URI = "ui://markcut/
|
|
10
|
+
export const RESOURCE_URI = "ui://markcut/video-preview-v3.html";
|
|
11
|
+
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
12
|
+
export const PLAYER_PORT = 4571;
|
|
13
|
+
export const PLAYER_ORIGIN = `http://127.0.0.1:${PLAYER_PORT}`;
|
|
14
|
+
|
|
15
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const playerServer = resolve(moduleDir, "../player/server.mjs");
|
|
17
|
+
let managedPlayer = null;
|
|
18
|
+
let managedPath = null;
|
|
19
|
+
let startPromise = null;
|
|
20
|
+
|
|
21
|
+
export function buildPreviewResourceResult() {
|
|
22
|
+
return {
|
|
23
|
+
ttlMs: 0,
|
|
24
|
+
cacheScope: "private",
|
|
25
|
+
contents: [{
|
|
26
|
+
uri: RESOURCE_URI,
|
|
27
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
28
|
+
text: buildPreviewHtml(),
|
|
29
|
+
_meta: {
|
|
30
|
+
ui: {
|
|
31
|
+
prefersBorder: true,
|
|
32
|
+
csp: {
|
|
33
|
+
connectDomains: [PLAYER_ORIGIN],
|
|
34
|
+
resourceDomains: [],
|
|
35
|
+
frameDomains: [PLAYER_ORIGIN],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
8
42
|
|
|
9
43
|
export function validateMarkdownPath(value) {
|
|
10
44
|
if (typeof value !== "string" || !isAbsolute(value)) {
|
|
@@ -16,13 +50,59 @@ export function validateMarkdownPath(value) {
|
|
|
16
50
|
return value;
|
|
17
51
|
}
|
|
18
52
|
|
|
19
|
-
export async function
|
|
53
|
+
export async function ensureVideoPreview(path, options = {}) {
|
|
20
54
|
const validatedPath = validateMarkdownPath(path);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
55
|
+
await access(validatedPath, fsConstants.R_OK);
|
|
56
|
+
if (managedPlayer && managedPath === validatedPath && managedPlayer.exitCode == null && !managedPlayer.killed) {
|
|
57
|
+
return { path: validatedPath, previewUrl: `${PLAYER_ORIGIN}/` };
|
|
58
|
+
}
|
|
59
|
+
if (startPromise && managedPath === validatedPath) return startPromise;
|
|
60
|
+
|
|
61
|
+
if (managedPlayer && managedPlayer.exitCode == null && !managedPlayer.killed) {
|
|
62
|
+
managedPlayer.kill("SIGTERM");
|
|
25
63
|
}
|
|
64
|
+
managedPlayer = null;
|
|
65
|
+
managedPath = validatedPath;
|
|
66
|
+
|
|
67
|
+
const spawnImpl = options.spawnImpl || spawn;
|
|
68
|
+
const settleMs = options.settleMs ?? 250;
|
|
69
|
+
startPromise = new Promise((resolvePromise, rejectPromise) => {
|
|
70
|
+
const child = spawnImpl(process.execPath, [playerServer, validatedPath, `--port=${PLAYER_PORT}`], {
|
|
71
|
+
cwd: resolve(moduleDir, "../.."),
|
|
72
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
73
|
+
});
|
|
74
|
+
managedPlayer = child;
|
|
75
|
+
let stderr = "";
|
|
76
|
+
let settled = false;
|
|
77
|
+
const finish = (error) => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
startPromise = null;
|
|
81
|
+
if (error) {
|
|
82
|
+
if (managedPlayer === child) managedPlayer = null;
|
|
83
|
+
rejectPromise(error);
|
|
84
|
+
} else {
|
|
85
|
+
resolvePromise({ path: validatedPath, previewUrl: `${PLAYER_ORIGIN}/` });
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
89
|
+
child.once("error", (error) => finish(new Error(`unable to start Markcut player: ${error.message}`)));
|
|
90
|
+
child.once("exit", (code, signal) => {
|
|
91
|
+
if (managedPlayer === child) managedPlayer = null;
|
|
92
|
+
if (!settled) finish(new Error(`Markcut player exited during startup (${signal || code || "unknown"})${stderr ? `: ${stderr.trim()}` : ""}`));
|
|
93
|
+
});
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
if (!settled) finish();
|
|
96
|
+
}, settleMs);
|
|
97
|
+
});
|
|
98
|
+
return startPromise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function stopManagedVideoPreview() {
|
|
102
|
+
if (managedPlayer && managedPlayer.exitCode == null && !managedPlayer.killed) managedPlayer.kill("SIGTERM");
|
|
103
|
+
managedPlayer = null;
|
|
104
|
+
managedPath = null;
|
|
105
|
+
startPromise = null;
|
|
26
106
|
}
|
|
27
107
|
|
|
28
108
|
export function reviewResult(decision, feedback = "") {
|
|
@@ -40,38 +120,16 @@ export function reviewMessage(result) {
|
|
|
40
120
|
return {
|
|
41
121
|
jsonrpc: "2.0",
|
|
42
122
|
method: "ui/message",
|
|
43
|
-
params: {
|
|
44
|
-
role: "user",
|
|
45
|
-
content: [{ type: "text", text: JSON.stringify(normalized) }],
|
|
46
|
-
},
|
|
123
|
+
params: { role: "user", content: [{ type: "text", text: JSON.stringify(normalized) }] },
|
|
47
124
|
};
|
|
48
125
|
}
|
|
49
126
|
|
|
50
|
-
function
|
|
51
|
-
return value.replace(/[&<>"']/g, (character) => ({
|
|
52
|
-
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
53
|
-
})[character]);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function renderMarkdown(markdown) {
|
|
57
|
-
return markdown.split(/\r?\n/).map((line) => {
|
|
58
|
-
const escaped = escapeHtml(line);
|
|
59
|
-
if (line.startsWith("### ")) return `<h3>${escaped.slice(4)}</h3>`;
|
|
60
|
-
if (line.startsWith("## ")) return `<h2>${escaped.slice(3)}</h2>`;
|
|
61
|
-
if (line.startsWith("# ")) return `<h1>${escaped.slice(2)}</h1>`;
|
|
62
|
-
if (line.startsWith("- ")) return `<li>${escaped.slice(2)}</li>`;
|
|
63
|
-
return line.trim() ? `<p>${escaped}</p>` : "";
|
|
64
|
-
}).join("\n");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function buildPreviewHtml({ path = "", markdown = "" }) {
|
|
68
|
-
const safePath = escapeHtml(path);
|
|
69
|
-
const rendered = renderMarkdown(markdown);
|
|
127
|
+
export function buildPreviewHtml() {
|
|
70
128
|
return `<!doctype html>
|
|
71
|
-
<html><head><meta charset="utf-8"><title>
|
|
72
|
-
<style>body{font:15px system-ui,sans-serif;margin:0;
|
|
73
|
-
<body><main><h1>Review
|
|
74
|
-
<label for="feedback">Feedback (required for requested changes)</label><textarea id="feedback" placeholder="What should be changed?"></textarea><div id="status" role="status"
|
|
129
|
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Markcut video review</title>
|
|
130
|
+
<style>body{font:15px system-ui,sans-serif;margin:0;color:#17202a;background:#fff}main{max-width:980px;margin:auto;padding:16px}.meta{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.meta h1{font-size:18px;margin:0}.path{font-size:12px;color:#656d76;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.player{width:100%;aspect-ratio:9/16;max-height:72vh;border:1px solid #d8dee4;border-radius:10px;background:#0d1117;overflow:hidden}.player iframe{width:100%;height:100%;border:0;display:block}textarea{box-sizing:border-box;width:100%;min-height:82px;margin:12px 0 8px;padding:10px;border:1px solid #8c959f;border-radius:6px;font:inherit}button{border:0;border-radius:6px;padding:9px 14px;margin-right:8px;font:inherit;cursor:pointer}#approve{background:#1f883d;color:#fff}#changes{background:#cf222e;color:#fff}#status{min-height:1.4em;margin:8px 0;color:#656d76}</style></head>
|
|
131
|
+
<body><main><div class="meta"><h1>Review video</h1><div class="path" id="path-label"></div></div><div class="player"><iframe id="video-player" title="Markcut video preview" allow="autoplay; fullscreen"></iframe></div>
|
|
132
|
+
<label for="feedback">Feedback (required for requested changes)</label><textarea id="feedback" placeholder="What should be changed?"></textarea><div id="status" role="status">Starting preview…</div>
|
|
75
133
|
<button id="approve">Approve</button><button id="changes">Request changes</button></main>
|
|
76
134
|
<script>${buildPreviewBridgeScript(SUBMIT_TOOL_NAME)}</script></body></html>`;
|
|
77
135
|
}
|
package/src/mcp/server.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
4
|
-
import { registerAppResource, registerAppTool
|
|
4
|
+
import { registerAppResource, registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import {
|
|
7
|
-
TOOL_NAME, SUBMIT_TOOL_NAME, RESOURCE_URI,
|
|
7
|
+
TOOL_NAME, SUBMIT_TOOL_NAME, RESOURCE_URI, PLAYER_ORIGIN, buildPreviewResourceResult, ensureVideoPreview, reviewResult,
|
|
8
8
|
} from "./preview.mjs";
|
|
9
9
|
|
|
10
10
|
const server = new McpServer({ name: "markcut", version: "3.2.0" });
|
|
@@ -13,7 +13,7 @@ registerAppTool(
|
|
|
13
13
|
server,
|
|
14
14
|
TOOL_NAME,
|
|
15
15
|
{
|
|
16
|
-
description: "
|
|
16
|
+
description: "Show the Markcut video player for a Markdown video definition and ask a human to approve it or request changes.",
|
|
17
17
|
inputSchema: z.object({
|
|
18
18
|
path: z.string().describe("Absolute path to a .md file"),
|
|
19
19
|
}),
|
|
@@ -23,9 +23,9 @@ registerAppTool(
|
|
|
23
23
|
},
|
|
24
24
|
async ({ path }) => {
|
|
25
25
|
try {
|
|
26
|
-
const preview = await
|
|
26
|
+
const preview = await ensureVideoPreview(path);
|
|
27
27
|
return {
|
|
28
|
-
content: [{ type: "text", text: `
|
|
28
|
+
content: [{ type: "text", text: `Video ready for review: ${preview.path}` }],
|
|
29
29
|
structuredContent: { ...preview, resourceUri: RESOURCE_URI },
|
|
30
30
|
_meta: {
|
|
31
31
|
ui: { resourceUri: RESOURCE_URI, props: preview },
|
|
@@ -45,7 +45,7 @@ registerAppTool(
|
|
|
45
45
|
server,
|
|
46
46
|
SUBMIT_TOOL_NAME,
|
|
47
47
|
{
|
|
48
|
-
description: "Submit the review decision from the
|
|
48
|
+
description: "Submit the review decision from the Markcut video preview UI.",
|
|
49
49
|
inputSchema: z.object({
|
|
50
50
|
decision: z.enum(["approved", "changes_requested"]),
|
|
51
51
|
feedback: z.string().optional(),
|
|
@@ -72,29 +72,17 @@ registerAppTool(
|
|
|
72
72
|
|
|
73
73
|
registerAppResource(
|
|
74
74
|
server,
|
|
75
|
-
"
|
|
75
|
+
"Markcut video review",
|
|
76
76
|
RESOURCE_URI,
|
|
77
77
|
{
|
|
78
78
|
_meta: {
|
|
79
79
|
ui: {
|
|
80
80
|
prefersBorder: true,
|
|
81
|
-
csp: { connectDomains: [], resourceDomains: [] },
|
|
81
|
+
csp: { connectDomains: [PLAYER_ORIGIN], resourceDomains: [], frameDomains: [PLAYER_ORIGIN] },
|
|
82
82
|
},
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
|
-
async () => (
|
|
86
|
-
contents: [{
|
|
87
|
-
uri: RESOURCE_URI,
|
|
88
|
-
mimeType: RESOURCE_MIME_TYPE,
|
|
89
|
-
text: buildPreviewHtml({ path: "", markdown: "" }),
|
|
90
|
-
_meta: {
|
|
91
|
-
ui: {
|
|
92
|
-
prefersBorder: true,
|
|
93
|
-
csp: { connectDomains: [], resourceDomains: [] },
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
}],
|
|
97
|
-
}),
|
|
85
|
+
async () => buildPreviewResourceResult(),
|
|
98
86
|
);
|
|
99
87
|
|
|
100
88
|
await server.connect(new StdioServerTransport());
|
package/src/player/server.mjs
CHANGED
|
@@ -1158,6 +1158,18 @@ Edit request: ${text}`;
|
|
|
1158
1158
|
return;
|
|
1159
1159
|
}
|
|
1160
1160
|
|
|
1161
|
+
// API: lightweight readiness probe for embedded MCP App previews.
|
|
1162
|
+
if (path === "/api/ready" && req.method === "GET") {
|
|
1163
|
+
const ready = compiledRootCache.has(variantLabel);
|
|
1164
|
+
res.writeHead(200, {
|
|
1165
|
+
"Content-Type": "application/json",
|
|
1166
|
+
"Cache-Control": "no-store",
|
|
1167
|
+
"Access-Control-Allow-Origin": "*",
|
|
1168
|
+
});
|
|
1169
|
+
res.end(JSON.stringify({ ready }));
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1161
1173
|
// API: Get video.json data for a specific variant
|
|
1162
1174
|
if (path === "/api/video-data") {
|
|
1163
1175
|
try {
|
|
@@ -3,7 +3,16 @@ import { mkdtempSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import vm from "node:vm";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildPreviewHtml,
|
|
8
|
+
buildPreviewResourceResult,
|
|
9
|
+
PLAYER_ORIGIN,
|
|
10
|
+
RESOURCE_MIME_TYPE,
|
|
11
|
+
RESOURCE_URI,
|
|
12
|
+
reviewMessage,
|
|
13
|
+
reviewResult,
|
|
14
|
+
validateMarkdownPath,
|
|
15
|
+
} from "../src/mcp/preview.mjs";
|
|
7
16
|
import { buildPreviewBridgeScript } from "../src/mcp/bridge.mjs";
|
|
8
17
|
|
|
9
18
|
describe("MarkCut MCP Markdown preview", () => {
|
|
@@ -12,21 +21,29 @@ describe("MarkCut MCP Markdown preview", () => {
|
|
|
12
21
|
expect(() => validateMarkdownPath("/tmp/notes.txt")).toThrow(/Markdown/);
|
|
13
22
|
});
|
|
14
23
|
|
|
15
|
-
it("
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const preview = await readMarkdownPreview(path);
|
|
20
|
-
const html = buildPreviewHtml(preview);
|
|
21
|
-
expect(preview.markdown).toContain("# Hello");
|
|
22
|
-
expect(html).toContain("This is a review.");
|
|
24
|
+
it("builds a video-player review UI instead of rendering Markdown", () => {
|
|
25
|
+
const html = buildPreviewHtml();
|
|
26
|
+
expect(html).toContain('id="video-player"');
|
|
27
|
+
expect(html).toContain("Review video");
|
|
23
28
|
expect(html).toContain("Approve");
|
|
24
29
|
expect(html).toContain("Request changes");
|
|
25
|
-
expect(html).toContain("ui/initialize");
|
|
26
30
|
expect(html).toContain("ui/notifications/tool-result");
|
|
27
|
-
expect(html).toContain("
|
|
28
|
-
expect(html).toContain("
|
|
29
|
-
|
|
31
|
+
expect(html).toContain("previewUrl");
|
|
32
|
+
expect(html).not.toContain("<article>");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns Codex-compatible private no-cache resource metadata", () => {
|
|
36
|
+
const result = buildPreviewResourceResult();
|
|
37
|
+
expect(result.ttlMs).toBe(0);
|
|
38
|
+
expect(result.cacheScope).toBe("private");
|
|
39
|
+
expect(result.contents).toHaveLength(1);
|
|
40
|
+
expect(result.contents[0]).toMatchObject({
|
|
41
|
+
uri: RESOURCE_URI,
|
|
42
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
43
|
+
});
|
|
44
|
+
expect(result.contents[0].text).toContain("ui/initialize");
|
|
45
|
+
expect(result.contents[0].text).toContain("ui/notifications/initialized");
|
|
46
|
+
expect(result.contents[0]._meta.ui.csp.frameDomains).toEqual([PLAYER_ORIGIN]);
|
|
30
47
|
});
|
|
31
48
|
|
|
32
49
|
it("returns approved", () => {
|
|
@@ -58,7 +75,7 @@ describe("MarkCut MCP Markdown preview", () => {
|
|
|
58
75
|
id: 1,
|
|
59
76
|
method: "ui/initialize",
|
|
60
77
|
params: {
|
|
61
|
-
appInfo: { name: "Markcut Preview", version: "1.
|
|
78
|
+
appInfo: { name: "Markcut Preview", version: "1.1.0" },
|
|
62
79
|
appCapabilities: {},
|
|
63
80
|
protocolVersion: "2026-01-26",
|
|
64
81
|
},
|
|
@@ -76,6 +93,11 @@ describe("MarkCut MCP Markdown preview", () => {
|
|
|
76
93
|
await tick();
|
|
77
94
|
expect(harness.messages[1]).toMatchObject({ method: "ui/notifications/initialized" });
|
|
78
95
|
|
|
96
|
+
harness.toolResult({ path: "/tmp/video.md", previewUrl: PLAYER_ORIGIN + "/" });
|
|
97
|
+
await tick();
|
|
98
|
+
expect(harness.elements.player.src).toBe(PLAYER_ORIGIN + "/");
|
|
99
|
+
expect(harness.elements.pathLabel.textContent).toBe("/tmp/video.md");
|
|
100
|
+
|
|
79
101
|
harness.elements.approve.onclick();
|
|
80
102
|
expect(harness.messages[2]).toMatchObject({ id: 2, method: "tools/call" });
|
|
81
103
|
expect(harness.messages).not.toContainEqual(expect.objectContaining({ method: "ui/message" }));
|
|
@@ -114,23 +136,30 @@ function createBridgeHarness() {
|
|
|
114
136
|
const elements = {
|
|
115
137
|
feedback: element(),
|
|
116
138
|
status: element(),
|
|
117
|
-
|
|
139
|
+
player: element(),
|
|
118
140
|
pathLabel: element(),
|
|
119
141
|
approve: element(),
|
|
120
142
|
changes: element(),
|
|
121
143
|
};
|
|
122
144
|
const parent = { postMessage(message: any) { messages.push(message); } };
|
|
123
145
|
const document = {
|
|
124
|
-
getElementById(id: string) {
|
|
125
|
-
|
|
146
|
+
getElementById(id: string) {
|
|
147
|
+
if (id === "feedback") return elements.feedback;
|
|
148
|
+
if (id === "status") return elements.status;
|
|
149
|
+
if (id === "video-player") return elements.player;
|
|
150
|
+
if (id === "path-label") return elements.pathLabel;
|
|
151
|
+
if (id === "approve") return elements.approve;
|
|
152
|
+
return elements.changes;
|
|
153
|
+
},
|
|
126
154
|
};
|
|
127
155
|
const window = { parent, addEventListener(_type: string, listener: (event: any) => void) { listeners.push(listener); } };
|
|
128
|
-
const context = vm.createContext({ window, document });
|
|
156
|
+
const context = vm.createContext({ window, document, setTimeout, fetch: async () => ({ ok: true, json: async () => ({ ready: true }) }) });
|
|
129
157
|
return {
|
|
130
158
|
messages,
|
|
131
159
|
elements,
|
|
132
160
|
run() { vm.runInContext(buildPreviewBridgeScript("markcut.preview.submit"), context); },
|
|
133
161
|
dispatch(event: any) { listeners.forEach((listener) => listener(event)); },
|
|
134
162
|
respond(message: any) { this.dispatch({ source: parent, data: { jsonrpc: "2.0", ...message } }); },
|
|
163
|
+
toolResult(structuredContent: any) { this.dispatch({ source: parent, data: { jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { result: { structuredContent } } } }); },
|
|
135
164
|
};
|
|
136
165
|
}
|