// screensue「从路径加载」的行为判据(2026-10-05)。
//
// ## 要判的是什么
//
// 改之前 screensue 只能把内容**内联**在命令字符串里:
//
// homeagent-screensue
…
//
// 于是场景被限死了:
// · 一屏复杂看板(几十 KB HTML)塞不进 cmd_type 字段
// · 模型得先把整段 HTML 生成出来再当参数传,token 成本与出错面都大
// · 内容改了要重新发一次命令
//
// 新增三种写法:
// ① @<路径> 从文件读,按扩展名决定渲染方式
// ② @<路径>#<块名> 抽 … 之间的片段
// ③ <字面内容> 原行为,**必须完全不变**
//
// ## 判据形态
//
// 真跑 loadCapabilitySource:造临时 .html/.md 文件与多块文件,
// 验「按扩展名渲染 / 块抽取 / 缺块报错 / 目录拒绝 / 超限拒绝 /
// @前缀才触发加载(字面内容不受影响)」。
//
// 运行:node cmd/gui/screensue-source.test.mjs
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import vm from "node:vm";
import { createRequire } from "node:module";
const nodeRequire = createRequire(import.meta.url);
import * as nodeFs from "node:fs";
const here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, "main.js"), "utf8");
let failures = 0;
const check = (name, ok, detail) => {
if (ok) console.log(` ✓ ${name}`);
else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
function extractFn(name) {
let start = src.indexOf(`function ${name}(`);
if (start === -1) throw new Error(`cannot find function ${name} in main.js`);
let i = src.indexOf("{", start);
let depth = 0;
for (let j = i; j < src.length; j++) {
const c = src[j];
if (c === '"' || c === "'" || c === "`") {
const q = c;
j++;
while (j < src.length && src[j] !== q) {
if (src[j] === "\\") j++;
j++;
}
continue;
}
if (c === "/" && src[j + 1] === "/") {
while (j < src.length && src[j] !== "\n") j++;
continue;
}
if (c === "/" && src[j + 1] === "*") {
j = src.indexOf("*/", j) + 1;
continue;
}
if (c === "{") depth++;
else if (c === "}") {
depth--;
if (depth === 0) return src.slice(start, j + 1);
}
}
throw new Error(`unbalanced braces for ${name}`);
}
const dir = mkdtempSync(join(tmpdir(), "screensue-test-"));
const p = (n) => join(dir, n);
writeFileSync(p("board.html"), "看板
hello
", "utf-8");
writeFileSync(p("notes.md"), "# 标题\n- 一点", "utf-8");
writeFileSync(
p("multi.html"),
"A1 A2
\n" +
"三点开会
",
"utf-8",
);
writeFileSync(p("big.html"), "x".repeat(2 * 1024 * 1024 + 10), "utf-8");
const sandbox = {
console,
// main.js 里用的是 CJS 的 require(...),在 vm 沙箱里必须显式注入;
// 不能写 require: (m) => require(m)(ESM 作用域里没有 require)。
require: (m) => nodeRequire(m),
fs: nodeFs,
};
// ⚠️ 不能写 require: (m) => require(m):本文件是 ESM(.mjs),没有 require。
vm.createContext(sandbox);
vm.runInContext(
[
"const CAPABILITY_SOURCE_MAX_BYTES = 2 * 1024 * 1024;",
extractFn("loadCapabilitySource"),
extractFn("resolveCapabilityArg"),
"globalThis.__load = loadCapabilitySource;",
"globalThis.__resolve = resolveCapabilityArg;",
].join("\n"),
sandbox,
);
const load = sandbox.__load;
const resolve = sandbox.__resolve;
console.log("screensue 从路径加载");
// ① .html 按 HTML 原样渲染
{
const r = load(p("board.html"), "", "html");
check("读 .html 成功", r.ok, r.err);
check(".html 原样返回(不被转义)", r.ok && r.content.includes("看板
"),
r.ok ? r.content.slice(0, 40) : "");
check("from 回报绝对路径", r.ok && r.from === p("board.html"));
}
// ② .md 按纯文本等宽渲染(转义)
{
const r = load(p("notes.md"), "", "html");
check("读 .md 成功", r.ok, r.err);
check(".md 包 且被转义", r.ok && r.content.startsWith(""),
r.ok ? r.content.slice(0, 50) : "");
}
// ③ 命名块抽取
{
const a = load(p("multi.html"), "alerts", "html");
const s = load(p("multi.html"), "schedule", "html");
check("抽指定块 alerts", a.ok && a.content.includes("A1 A2") && !a.content.includes("三点开会"),
a.ok ? a.content : a.err);
check("抽另一块 schedule", s.ok && s.content.includes("三点开会") && !s.content.includes("A1"),
s.ok ? s.content : s.err);
check("from 带 #块名", a.ok && a.from.endsWith("#alerts"));
const miss = load(p("multi.html"), "nope", "html");
check("缺块明确报错", !miss.ok && /未找到块/.test(miss.err), miss.err);
}
// ④ 错误路径都给出可读原因,不静默
{
check("目录被拒", !load(dir, "", "html").ok);
check("不存在的文件被拒", !load(p("nope.html"), "", "html").ok);
check("空路径被拒", !load("", "", "html").ok);
const big = load(p("big.html"), "", "html");
check("超 2MB 被拒且说明大小", !big.ok && /过大/.test(big.err), big.err);
}
// ⑤ ★ 字面内容(无 @ 前缀)必须完全不受影响
// 调用点只在 raw.startsWith("@") 时才走加载;这里直接判该分支条件。
{
check("字面内容不触发路径加载(需 @ 前缀)", !"".startsWith("@"));
check("@ 字面量确实会触发", "@foo".startsWith("@"));
}
// ⑥ ★ speakeruse / clipboardsue 复用同一个 loader(text 模式)
// 这三个能力都是「<名字> <内联文本>」,长文本都塞不进 command 字段。
{
const r = load(p("notes.md"), "", "text");
check("text 模式返回纯文本(不包
)",
r.ok && r.content.startsWith("# 标题") && !r.content.includes("看板"), h.ok ? h.content.slice(0, 40) : h.err);
const blk = load(p("multi.html"), "alerts", "text");
check("text 模式同样支持命名块", blk.ok && blk.content.includes("A1 A2"));
}
// ⑦ resolveCapabilityArg 的解析口径
{
check("无 @ 前缀 ⇒ 不命中(保持内联行为)", resolve("你好").hit === false);
check("@ 路径解析", resolve("@/tmp/a.html").hit === true);
check("@ 路径 + #块名", (() => {
const r = resolve("@/tmp/a.html#alerts");
return r.hit && r.filePath === "/tmp/a.html" && r.blockName === "alerts";
})());
check("@ 路径无块名 ⇒ blockName 为空",
resolve("@/tmp/a.html").blockName === "");
}
rmSync(dir, { recursive: true, force: true });
console.log(
failures === 0
? "\nAll screensue-source checks passed."
: `\n${failures} check(s) failed.`,
);
process.exit(failures === 0 ? 0 : 1);