4 Commits

15 changed files with 481 additions and 12 deletions
+8
View File
@@ -1,7 +1,13 @@
/**
* board 目录分页、树构建、祖先定位与筛选测试。请求由内存函数提供,不访问实际 workspace 服务。
* 列表顺序不保证父项先到;孤儿和循环节点仍须可访问,不能递归死循环。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadBoardDirectory, boardDirectory, activeBoardAncestors, filterBoardDirectory } from '../src/lib/board-directory.ts';
// 生成带 ID、slug 和 parent_board_id 的最小目录项,默认 title 使用 ID。
const board=(id,parent,title=id)=>({id,parent_board_id:parent,title,workspace_id:'w',kind:'board',version:1,slug:id+'-slug'});
// 第一页故意子先父后,下一页才给孙项;检查 include_subgraphs、offset 与 URL 编码,再检查层级、slug 祖先链和筛选路径。
test('directory includes subgraphs, traverses all pages and preserves nested ownership',async()=>{
const calls=[];const request=async(path)=>{calls.push(path);const offset=new URLSearchParams(path.split('?')[1]).get('offset');return offset==='0'?{items:[board('child','parent'),board('parent')],next_offset:100}:{items:[board('grandchild','child')],next_offset:null};};
const boards=await loadBoardDirectory(request,'space slug');assert.equal(calls.length,2);assert.ok(calls.every(path=>path.includes('include_subgraphs=true')));assert.ok(calls[1].endsWith('offset=100'));assert.ok(calls[0].includes('space%20slug'));
@@ -10,9 +16,11 @@ test('directory includes subgraphs, traverses all pages and preserves nested own
assert.deepEqual(filterBoardDirectory(roots,'grandchild')[0].children[0].children.map(item=>item.board.id),['grandchild']);
assert.equal(filterBoardDirectory(roots,'parent')[0].children.length,1);
});
// 父项缺失及双节点循环都提升为可见根项,子列表为空,避免丢失入口或无限嵌套。
test('missing parents and corrupt cycles remain accessible without recursive loops',()=>{
const roots=boardDirectory([board('orphan','missing'),board('a','b'),board('b','a')]);assert.deepEqual(roots.map(item=>item.board.id),['orphan','a','b']);assert.ok(roots.every(item=>item.children.length===0));
});
// 非递增 next_offset 明确拒绝;已经 abort 的信号由请求替身抛回,加载器不得吞掉取消。
test('directory rejects non-advancing pagination and propagates aborts',async()=>{
await assert.rejects(loadBoardDirectory(async()=>({items:[],next_offset:0}),'w'),/invalid_pagination/);
const controller=new AbortController();controller.abort();await assert.rejects(loadBoardDirectory(async(_path,options)=>{options.signal.throwIfAborted();},'w',controller.signal),{name:'AbortError'});
+10
View File
@@ -1,10 +1,17 @@
/**
* 旧画布附件上传助手和显示 URL 转换测试。请求为替身,检查 FormData、错误和资源地址,不是真实上传或图像解码。
* 这里的上传重试属于 canvas-assets helper,不可等同于 headless ledger 的持久冻结请求恢复。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { uploadCanvasImage } from '../src/lib/canvas-assets.ts';
import { managedImageSource } from '../src/lib/canvas-v2.ts';
// 固定 data URL 仅用来构造与对比二进制前缀,并非完整 PNG 解码样本。
const source='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
// 固定资产身份;返回值必须是项目管理的地址,任意外域响应不被当作受管上传结果。
const id='77777777-7777-4777-8777-777777777777';
const asset={id,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${id}`,width:1,height:1,bytes:24,mime_type:'image/png'};
// 首例直接比对 FormData.image 原字节、上传路径、POST 和幂等键形状,返回值保留服务资产元数据。
test('attachment upload sends the original file bytes',async()=>{
const bytes=Buffer.from(source.split(',')[1],'base64');let calls=0;
const result=await uploadCanvasImage(async(path,options)=>{
@@ -13,15 +20,18 @@ test('attachment upload sends the original file bytes',async()=>{
},'w',new Blob([bytes],{type:'image/png'}),'image.png');
assert.equal(calls,1);assert.deepEqual(result,asset);
});
// 权限拒绝直接传播;格式类似但地址不受管理的成功响应也要拒绝,不能把任意 URL 写成上传结果。
test('attachment upload propagates permission failures and rejects unmanaged response URLs',async()=>{
const file=new Blob(['test'],{type:'image/png'});
await assert.rejects(uploadCanvasImage(async()=>{throw new Error('forbidden');},'w',file,'test.png'),/forbidden/);
await assert.rejects(uploadCanvasImage(async()=>({...asset,url:'https://invalid.example/a.png'}),'w',file,'test.png'),/invalid_asset_response/);
});
// 模拟第一次连接丢失、第二次成功;此例直接断言两次调用与相同幂等键,文件字节传递由首例单独检查。
test('uncertain upload retries the exact bytes with the same idempotency key',async()=>{
let calls=0,key;
await uploadCanvasImage(async(_path,options)=>{calls++;if(!key)key=options.headers['Idempotency-Key'];assert.equal(options.headers['Idempotency-Key'],key);if(calls===1)throw new TypeError('connection lost');return asset;},'w',new Blob(['test'],{type:'image/png'}),'test.png');assert.equal(calls,2);
});
// 受管原始 URL 转同源认证代理,preview 仅加显示变体参数;普通外部图片地址保持原样。
test('preview URLs are renderer-only and do not replace original managed references',()=>{
assert.equal(managedImageSource(asset.url),`/api/foundry/workspaces/w/assets/${id}`);assert.equal(managedImageSource(asset.url,true),`/api/foundry/workspaces/w/assets/${id}?variant=preview`);
assert.equal(managedImageSource('https://example.com/photo.png',true),'https://example.com/photo.png');
+14
View File
@@ -1,16 +1,23 @@
/**
* 旧 frameId 分区成员模型的拖入、随动与恢复测试;不是 headless ownerCanvasId 树或 linked-board 深子 canvas 的全部规则。
* 成员关系由明确 drop 决定,几何重叠本身不代表已经加入分区。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { assignFrameMembership, frameDropFeedback } from "../src/lib/canvas-frames.ts";
// frame、位于内部但尚未登记的 member,以及外部 icon;可分别检查 drop 准入和已有成员随动。
function seeded() {
const frame = makeCanvasNode("frame", { x: 100, y: 100 });
const member = makeCanvasNode("text", { x: 160, y: 160 });
const outsider = makeCanvasNode("icon", { x: 900, y: 120 });
return { frame, member, outsider, document: { ...emptyCanvas(), nodes: [frame, member, outsider] } };
}
// 把给定快照包成空历史结构,保持所有测试通过相同 reducer 入口。
function state(document) { return { past: [], present: document, future: [] }; }
// 多种内容 drop 到重叠 frame 时预览应与最终成员选择一致;较小 frame 优先,移到外部则同时显示和执行 detach。
test("drop previews match actual assignment for every content type, exits and overlapping frames", () => {
const { frame } = seeded();
const smaller = makeCanvasNode("frame", { x: 120, y: 120 }); smaller.width = 300; smaller.height = 240;
@@ -26,6 +33,7 @@ test("drop previews match actual assignment for every content type, exits and ov
}
});
// 只给 dropped IDs 分配成员,拖 frame 或其他节点不能顺手收集原本重叠的内容。
test("only dropped content joins a frame; pre-existing overlapping content stays outside", () => {
const { frame, member, outsider, document } = seeded();
assert.equal(document.nodes[1].data.frameId, undefined);
@@ -38,6 +46,7 @@ test("only dropped content joins a frame; pre-existing overlapping content stays
assert.equal(assignFrameMembership([frame, member, outsider], [member.id])[2].data.frameId, undefined);
});
// 已有成员随 frame 同量移动,旁边 outsider 不改位置和归属;undo 恢复成员坐标与 frameId。
test("frames carry existing members without collecting newly overlapped content and undo restores positions", () => {
const { frame, member, outsider, document } = seeded();
member.data.frameId = frame.id;
@@ -51,6 +60,7 @@ test("frames carry existing members without collecting newly overlapped content
assert.equal(history.present.nodes[1].data.frameId, frame.id);
});
// 已有成员拖出清除 frameId,落进另一 frame 则换归属;以同一位置比较两种节点集合。
test("dragging out detaches content and dragging between frames reassigns membership", () => {
const { frame, member } = seeded();
member.data.frameId = frame.id;
@@ -60,6 +70,7 @@ test("dragging out detaches content and dragging between frames reassigns member
assert.equal(assignFrameMembership([frame, second, member], [member.id])[2].data.frameId, second.id);
});
// 同时上报父 frame 和成员的最终位置时,不能再把父位移叠加到成员,避免双重移动。
test("multi-selection does not move frame members twice", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
@@ -70,6 +81,7 @@ test("multi-selection does not move frame members twice", () => {
assert.deepEqual(result.present.nodes[1].position, { x: 260, y: 260 });
});
// 旧模型删除 frame 保留内容并清成员关联,undo 可恢复原 frameId;这不是 headless 删除 owning 子树的产品规则。
test("deleting a frame keeps its content and undo restores membership", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
@@ -80,6 +92,7 @@ test("deleting a frame keeps its content and undo restores membership", () => {
assert.equal(canvasReducer(removed, { type: "undo" }).present.nodes[1].data.frameId, frame.id);
});
// hidden/fixed/scaled 标签、名称与 frameId 往返保存,frame 渲染层序为 -1,设置历史可撤销。
test("all label modes, frame names and memberships survive save/restore and settings undo", () => {
const { frame, member, document } = seeded();
member.data.frameId = frame.id;
@@ -95,6 +108,7 @@ test("all label modes, frame names and memberships survive save/restore and sett
}
});
// 缺失 frame、frame 作为别的内容成员、非法标签和 frame 旋转均拒绝;限制属于本旧格式。
test("invalid frame references, label modes, frame nesting and rotation are rejected", () => {
for (const mutate of [
({ member }) => { member.data.frameId = "missing"; },
+15
View File
@@ -1,12 +1,19 @@
/**
* 旧 CanvasElement 几何与能力测试,直接验证类、纯函数和旧 reducer;不分派真实 pointer 事件。
* 多方向/旋转样本用对边锚点及连接端点作为几何不变量,避免只比较宽高变化。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { CanvasElement, CardElement, TextCardElement, ImageCardElement, ScriptCardElement, PlainTextElement, elementCapabilities } from "../src/lib/canvas-elements.ts";
import { nodeConnector, connectorBezier, nodeCenter, nodeSize, resizeFromPointer, rotateVector, rotationFromPointer } from "../src/lib/canvas-geometry.ts";
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas } from "../src/lib/canvas.ts";
// 浮点误差容差为 1e-5,失败消息保留 actual/expected,供旋转与坐标换算诊断。
function close(actual, expected) { assert(Math.abs(actual - expected) < 0.00001, `${actual} != ${expected}`); }
// 将几何 patch 与旧 node.data 中的 rotation/fontSize 合并,重算中心和端点时使用同一模型形状。
function transformed(node, patch) { return { ...node, ...patch, data: { ...node.data, rotation: patch.rotation ?? node.data.rotation, fontSize: patch.fontSize ?? node.data.fontSize } }; }
// 三种卡片共享变换和 branching;PlainText 只继承公共变换,不能通过错误 ID 或自身 ID 绕过不连线/不分支能力。
test("card types inherit common element transforms and add branching; plain text inherits only common transforms", () => {
for (const Type of [TextCardElement, ImageCardElement, ScriptCardElement]) {
const element = new Type({ x: 0, y: 0 });
@@ -27,12 +34,14 @@ test("card types inherit common element transforms and add branching; plain text
assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [node] }, node.id, "right"), /纯文本/);
});
// 五种旋转角度乘八方向 handle,检查受影响尺寸和相反局部锚点世界坐标保持不变。
test("all eight handles resize cards while retaining the opposite local anchor, including rotated cards", () => {
const directions = ["top", "right", "bottom", "left", "top-left", "top-right", "bottom-left", "bottom-right"];
for (const rotation of [0, 37, 90, 180, 270]) {
for (const direction of directions) {
const node = new TextCardElement({ x: 100, y: 200 }).toNode();
node.data.rotation = rotation;
// hx/hy 表示当前 handle 的局部符号;位移先旋转到世界,再比较缩放前后相反侧锚点的世界位置。
const hx = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
const hy = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
const delta = rotateVector({ x: hx * 60, y: hy * 40 }, rotation);
@@ -49,6 +58,7 @@ test("all eight handles resize cards while retaining the opposite local anchor,
}
});
// 纯文本宽高独立改变,最小值受限但不改变字号;仅拉宽或拉高不应连带另一维度。
test("plain text width and height resize independently without changing the font", () => {
const node = new PlainTextElement({ x: 0, y: 0 }).toNode();
const patch = resizeFromPointer(node, "bottom-right", { x: 240, y: 72 });
@@ -61,6 +71,7 @@ test("plain text width and height resize independently without changing the font
assert(min.width >= 60); assert(min.height >= 24); assert.equal(min.fontSize, undefined);
});
// 旋转跟随指针绕中心方向,Shift 风格吸附到 15 度,负方向归一为 0..360 范围。
test("rotation follows the pointer, supports 15-degree snapping and normalizes the angle", () => {
const node = new TextCardElement({ x: 0, y: 0 }).toNode();
const center = nodeCenter(node);
@@ -71,6 +82,7 @@ test("rotation follows the pointer, supports 15-degree snapping and normalizes t
close(rotationFromPointer(node, start, { x: center.x, y: center.y - 100 }), 270);
});
// checkpoint 内变换可保存恢复并一次 undo;纯文本 type、宽高、旋转和原字号各自保持正确。
test("geometry and plain-text type survive save/restore and transforms undo as one gesture", () => {
const node = new PlainTextElement({ x: 20, y: 30 }).toNode();
let state = { past: [], present: { ...emptyCanvas(), nodes: [node] }, future: [] };
@@ -84,6 +96,7 @@ test("geometry and plain-text type survive save/restore and transforms undo as o
close(undo.present.nodes[0].data.rotation, 0);
});
// 旧 material/text 草稿仍解码成卡片且补默认 rotation;负尺寸、超界角度及超大字号逐项拒绝。
test("old text-card drafts remain cards and invalid transform values are rejected", () => {
const old = { id: "old", type: "material", position: { x: 0, y: 0 }, data: { kind: "text", title: "Old", text: "Keep" } };
const document = { ...emptyCanvas(), nodes: [old] };
@@ -94,6 +107,7 @@ test("old text-card drafts remain cards and invalid transform values are rejecte
}
});
// 对 0/90/180 度检查四个边中心和单位法线,再验证缩放后的实际 right/bottom 锚点。
test("connection endpoints stay on the exact border centers after resize and rotation", () => {
const node = new TextCardElement({ x: 20, y: 30 }).toNode();
const expected = {
@@ -115,6 +129,7 @@ test("connection endpoints stay on the exact border centers after resize and rot
assert.deepEqual(nodeConnector(resized, "bottom").point, { x: 200, y: 280 });
});
// Bezier 字符串必须从精确 source anchor 开始到 target anchor 结束;selected 仅改变 UI,不改变几何路径。
test("Bezier paths include both exact card anchors, independent of selected handle sizes", () => {
const source = new TextCardElement({ x: 20, y: 30 }).toNode();
const target = new TextCardElement({ x: 500, y: 180 }).toNode();
+13
View File
@@ -1,3 +1,7 @@
/**
* 本地图标目录/分片和共享 ResourceLoader 测试。直接读仓库生成资产,校验本地哈希和引用,不访问图标 CDN。
* loader 的活跃并发与缓存容量受限,不能据此推断待处理队列本身有长度上限。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
@@ -8,10 +12,12 @@ import { iconAsset, validateIconShard } from "../src/lib/canvas-icon-loader.ts";
import { decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { createResourceLoader } from "../src/lib/resource-loader.ts";
// 核对目录数量、ID 唯一性、分片文件哈希/体积/合法 SVG 数据、每个图标和分类存在;选取三个 ID 再验证旧快照往返。
test("all Lucide and experimental Tabler IDs map to valid local shards and survive snapshots", async () => {
assert.equal(catalog.filter((icon) => icon.source === "lucide").length, 2313);
assert.equal(catalog.filter((icon) => icon.source === "tabler").length, 24);
assert.equal(new Set(catalog.map((icon) => icon.name)).size, catalog.length);
// 按 manifest 逐分片读取本地 JSON;文件名哈希取 trimEnd 后文本的 SHA-256 前 12 位,单片小于 20000 字节。
const loaded = new Map();
for (const asset of Object.values(manifest.assets)) {
const content = await readFile(`public${manifest.root}/${asset}`, "utf8");
@@ -30,13 +36,16 @@ test("all Lucide and experimental Tabler IDs map to valid local shards and survi
}
});
// 对 script、onload、外部 paint URL 和 use/href 的样例逐个拒绝;这是数据校验例,不是完整浏览器 SVG 安全审计。
test("icon data rejects executable shapes, events and external paint references", () => {
for (const node of [["script", {}], ["path", { onload: "alert(1)" }], ["path", { fill: "url(https://example.com)" }], ["use", { href: "bad" }]]) {
assert.throws(() => validateIconShard({ Star: [node] }));
}
});
// 让 setImmediate 推进到调度任务阶段;用于观察尚未开始、排队及已释放容量的加载。
const tick = () => new Promise((resolve) => setImmediate(resolve));
// request 后立即 abort,尚未进入调度时不能启动网络加载,也不能把未完成值留入缓存。
test("an immediately unmounted icon does not start a network request", async () => {
let calls = 0;
const loader = createResourceLoader(async () => { calls++; return "unused"; });
@@ -47,8 +56,10 @@ test("an immediately unmounted icon does not start a network request", async ()
assert.equal(calls, 0); assert.equal(loader.peek("unused"), undefined);
});
// 同 key a 两个订阅只加载一次,容量二时 c 等待;完成后缓存命中不新增调用,峰值为二。
test("loader bounds concurrency, deduplicates shared shards and reuses cache", async () => {
let active = 0, peak = 0, calls = 0;
// 手动完成 active 加载,用于在容量为二时观察第三项等待及共享 key 去重。
const finish = [];
const loader = createResourceLoader(async (key) => {
active++; peak = Math.max(peak, active); calls++;
@@ -62,6 +73,7 @@ test("loader bounds concurrency, deduplicates shared shards and reuses cache", a
assert.equal(await loader.request("a"), "a"); assert.equal(calls, 3); assert.equal(peak, 2);
});
// 取消排队 b 后它永不启动;取消 a 的一个订阅不能中止另一个订阅共享的 active a。
test("unused queued loads never start; a remaining subscriber keeps a shared load alive", async () => {
const seen = []; let release;
const loader = createResourceLoader(async (key) => { seen.push(key); if (key === "a") await new Promise((resolve) => release = resolve); return key; }, 1);
@@ -73,6 +85,7 @@ test("unused queued loads never start; a remaining subscriber keeps a shared loa
await rejected; assert.equal(await shared, "a"); await tick(); assert.deepEqual(seen, ["a"]);
});
// active abort 后释放容量,失败 key 可重新加载;容量二的 LRU 在加入 a/b 后淘汰更早 fail,保留仍在缓存的 a。
test("active aborts and failed requests can retry; least recently used cache is bounded", async () => {
let fail = true;
const loader = createResourceLoader(async (key, signal) => {
+7
View File
@@ -1,3 +1,7 @@
/**
* 旧画布 CanvasElement 类体系的纯图片能力及图标搜索测试,与 headless component registry 的模型定义分开。
* 只操作节点数据、几何和序列化,不挂载 renderer 或读取图片字节。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
@@ -5,6 +9,7 @@ import { CanvasElement, CardElement, PlainImageElement } from "../src/lib/canvas
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
import { iconCatalog, iconsById, searchIcons } from "../src/components/canvas/icon-catalog.ts";
// PlainImageElement 继承通用变换但不继承 CardElement,比例缩放、名称和 src 可恢复,重命名可撤销。
test("plain images inherit transforms without card connections and preserve source/name on restore", () => {
const image = new PlainImageElement({ x: 0, y: 0 }, "", "data:image/png;base64,YQ==");
assert(image instanceof CanvasElement); assert.equal(image instanceof CardElement, false);
@@ -18,6 +23,7 @@ test("plain images inherit transforms without card connections and preserve sour
assert.equal(canvasReducer(renamed, { type: "undo" }).present.nodes[0].data.title, "图片");
});
// 纯图片不能作为卡片连线目标;删除连线后 javascript: src 仍必须被解码器拒绝。
test("plain image sources are required and pure images cannot acquire card connections", () => {
const image = makeCanvasNode("plain-image", { x: 0, y: 0 }, "data:image/png;base64,YQ==");
const card = makeCanvasNode("text", { x: 300, y: 0 });
@@ -27,6 +33,7 @@ test("plain image sources are required and pure images cannot acquire card conne
assert.throws(() => decodeCanvas(JSON.stringify(document)));
});
// 图标 ID 唯一并符合后端字符形状,中文类别与英文词可搜索;仅验证当前本地目录,不请求后端搜索。
test("the complete Lucide catalog has unique backend-compatible identifiers and searchable names", () => {
assert(iconCatalog.length > 2000);
assert.equal(iconsById.size, iconCatalog.length);
+10
View File
@@ -1,9 +1,15 @@
/**
* 响应头、body 总传输与空闲三类截止时间的组合测试。用 Response/ReadableStream 和 fetch 替身验证读取、取消与诊断。
* HTTP 状态 200 不代表 body 可用:传输中断、非法 JSON、后端错误和 204 在 api 层需要分别表达。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { fetchCanvasResponse } from '../src/lib/canvas-response.ts';
import { api, ApiError } from '../src/lib/api.ts';
// 延迟第二个真实流块,使传输超过 header 截止但仍在 body/idle 截止内;不是网络服务计时。
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
// header 返回后取消其截止约束,两个 UTF-8 块继续读完;ETag、完整字节数、chunk 数和 transfer_ms 报告均要保留。
test('snapshot body outlives the header deadline and reports transfer timing and bytes',async()=>{
const reports=[]; const bytes=new TextEncoder().encode('{"version":210,"document":{"title":"测试"}}');
const upstream=new Response(new ReadableStream({async start(c){ c.enqueue(bytes.slice(0,10));await wait(50);c.enqueue(bytes.slice(10));c.close();}}),{headers:{ETag:'"210"'}});
@@ -11,15 +17,18 @@ test('snapshot body outlives the header deadline and reports transfer timing and
assert.equal(response.headers.get('etag'),'"210"');assert.equal((await response.json()).version,210);
assert.equal(reports.length,1);assert.equal(reports[0].outcome,'complete');assert.equal(reports[0].bytes,bytes.length);assert.ok(reports[0].transfer_ms>=30);assert.equal(reports[0].chunks,2);
});
// 首块后不再产生数据,触发 idle 超时;consumer 必须收到 TimeoutError,上游 cancel 被调用,不能返回空快照。
test('snapshot idle timeout aborts unfinished bodies instead of returning a valid empty snapshot',async()=>{
const reports=[];let cancelled=false;
const upstream=new Response(new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('{"version":'));},cancel(){cancelled=true;}}));
const response=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:15},async()=>upstream,r=>reports.push(r));
await assert.rejects(response.json(),{name:'TimeoutError'});assert.ok(cancelled);assert.equal(reports[0].outcome,'error');assert.equal(reports[0].error,'TimeoutError');
});
// fetch 尚未返回头时监听 AbortSignal,header 超时应以 TimeoutError 拒绝,而不是等待 body 读取。
test('header deadline rejects a server that has not responded',async()=>{
await assert.rejects(fetchCanvasResponse('https://example.test',{}, {headersMs:10,bodyMs:500,idleMs:100},(_url,options)=>new Promise((_resolve,reject)=>options.signal.addEventListener('abort',()=>reject(options.signal.reason)))),{name:'TimeoutError'});
});
// 调用方 abort 和 consumer 直接 cancel 两条路径都释放上游读取,分别核对 AbortError 和 cancel 总次数。
test('disconnect and consumer cancellation release the upstream read',async()=>{
const abort=new AbortController();let cancelled=0;
const make=()=>new Response(new ReadableStream({cancel(){cancelled++;}}));
@@ -27,6 +36,7 @@ test('disconnect and consumer cancellation release the upstream read',async()=>{
abort.abort();await assert.rejects(response.text(),{name:'AbortError'});
const second=await fetchCanvasResponse('https://example.test',{}, {headersMs:100,bodyMs:500,idleMs:100},async()=>make());await second.body.cancel();assert.equal(cancelled,2);
});
// 临时替换全局 fetch,检查 interrupted/invalid JSON/server error/null 四种结果;finally 恢复原 fetch,避免污染其他用例。
test('client distinguishes a failed 200 body transfer, malformed JSON, server errors and 204',async()=>{
const original=globalThis.fetch;
try {
+9
View File
@@ -1,11 +1,16 @@
/**
* 旧快照画布的分支创建、文字样式、图标变换与恢复校验。这里只验证 legacy reducer/schema,不代表 headless 增量事务实现。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { CanvasElement, CardElement, IconElement } from "../src/lib/canvas-elements.ts";
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
// 每次返回一个新文本节点,避免样式/分支变更在不同测试之间共享可变实例。
function seeded() { return { ...emptyCanvas(), nodes: [makeCanvasNode("text", { x: 100, y: 200 })] }; }
// 从同一父节点分别创建 text/script/image,检查连线端口、图片源、快照往返,再用一次 replace 历史整体撤销。
test("branch type selection creates the chosen text, script or uploaded image and keeps the link in one undo step", () => {
for (const kind of ["text", "script", "image"]) {
const original = seeded();
@@ -22,6 +27,7 @@ test("branch type selection creates the chosen text, script or uploaded image an
}
});
// 连线释放在任意画布坐标时,新 script 直接落在该 release 点,并只增加一条边。
test("a connection released on the canvas creates the chosen node at the release point", () => {
const original = seeded();
const at = { x: -300, y: 700 };
@@ -30,6 +36,7 @@ test("a connection released on the canvas creates the chosen node at the release
assert.equal(result.edges.length, 1);
});
// 颜色/背景/字体/行高等样式与字号可保存和遍历历史;部分样式清除只改目标键,保留未涉及颜色。
test("all text styles and font size survive serialization, undo and redo", () => {
const original = seeded();
const style = { color: "#123456", background: "#fedcba", bold: true, italic: true, underline: true, align: "right", fontFamily: "serif", lineHeight: 1.8 };
@@ -45,6 +52,7 @@ test("all text styles and font size survive serialization, undo and redo", () =>
assert.equal(decodeCanvas(JSON.stringify(cleared.present)).nodes[0].data.textStyle.bold, false);
});
// 逐个非法样式/类型和可执行样式片段均拒绝;非法 icon ID 也不能通过旧快照恢复。
test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a restored canvas", () => {
for (const style of [{ color: "url(https://example.com)" }, { background: "red" }, { fontFamily: "custom" }, { lineHeight: Infinity }, { bold: "true" }, { align: "justify" }, { align: ["left"] }, { fontFamily: ["sans"] }, { position: "fixed" }, []]) {
const doc = seeded(); doc.nodes[0].data.textStyle = style;
@@ -55,6 +63,7 @@ test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a res
assert.throws(() => decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })), /图标/);
});
// IconElement 继承通用变换而非 CardElement,ID、颜色、旋转可恢复;等比缩放有最小尺寸且不能分支。
test("icon elements inherit transforms without card branching, preserve their identity and resize proportionally", () => {
const element = new IconElement({ x: 10, y: 20 });
assert(element instanceof CanvasElement);
+18
View File
@@ -1,12 +1,20 @@
/**
* 旧 CanvasSync 完整快照自动保存及旧加载/草稿恢复测试。正在发送的请求串行,排队文档只保留最新份。
* 显式 retry 可以发送最新文档,与 headless 冻结请求的未知结果查询/原字节重试是不同契约,不可相互替代。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { CanvasSync, canvasPath, canvasPayload, readLocalRecovery } from "../src/lib/canvas-sync.ts";
import { emptyCanvas, makeCanvasNode, canvasReducer } from "../src/lib/canvas.ts";
import { loadBoardCanvas } from "../src/lib/canvas-loader.ts";
// 返回固定 text ID 的新快照,便于只改变内容而不被随机 ID 差异触发多余保存。
function document(text = "") { const doc = emptyCanvas(); const node = makeCanvasNode("text", { x: 0, y: 0 }); node.id = "text"; node.data.text = text; doc.nodes.push(node); return doc; }
// 手动完成首个请求以检查在途期间版本和队列合并,reject 也供故障场景使用。
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
// setImmediate 让保存泵进入 request,再添加后续文档,避免只测同一同步调用窗口。
const tick = () => new Promise((resolve) => setImmediate(resolve));
// A 请求挂起时排队 B/C 不并发发送;A ACK=2 后只发送 C,并使用连续 If-Match 1→2。
test("serialized autosaves use successive versions and coalesce queued documents", async () => {
const calls = [], first = deferred();
const writer = new CanvasSync(async (path, options) => {
@@ -23,6 +31,7 @@ test("serialized autosaves use successive versions and coalesce queued documents
assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "C"); assert.equal(writer.version, 3);
});
// 首次 version_conflict 停止自动写,后续 B 不再发送,旧服务版本和错误保留。
test("version conflicts stop automatic writes and preserve the latest pending content", async () => {
const calls = [];
const writer = new CanvasSync(async (_path, options) => { calls.push(options); throw new Error("version_conflict"); }, "canvas", { version: 7, document: emptyCanvas() }, () => {});
@@ -30,6 +39,7 @@ test("version conflicts stop automatic writes and preserve the latest pending co
assert.equal(calls.length, 1); assert.equal(writer.error, "version_conflict"); assert.equal(writer.version, 7);
});
// 故障后手动 retry 发送当前最新 B,成功清错误;没有承诺保留最初 A 的冻结字节。
test("explicit retry sends the newest document after a failed write", async () => {
const calls = [];
const writer = new CanvasSync(async (_path, options) => { calls.push(options); if (calls.length === 1) throw new Error("upstream_unavailable"); return { version: 2 }; }, "canvas", { version: 1, document: emptyCanvas() }, () => {});
@@ -37,6 +47,7 @@ test("explicit retry sends the newest document after a failed write", async () =
assert.equal(calls.length, 2); assert.equal(JSON.parse(calls[1].body).document.nodes[0].data.text, "B"); assert.equal(writer.error, "");
});
// selected/measured 的变更不产生保存,payload 去 UI 字段;真正内容变化才调用一次接口。
test("selection and measured dimensions do not trigger redundant writes or leak UI fields", async () => {
const initial = document("keep"); let calls = 0;
const writer = new CanvasSync(async () => { calls++; return { version: 2 }; }, "canvas", { version: 1, document: initial }, () => {});
@@ -48,6 +59,7 @@ test("selection and measured dimensions do not trigger redundant writes or leak
assert.equal(calls, 1);
});
// workspace/board 的 ID 与 slug 四组合都得到 canonical board 身份及保存快照,路径仍按传入引用构造。
test("all ID/slug combinations load the same canonical board and snapshot", async () => {
for (const workspace of ["space-id", "creative-space"]) for (const board of ["board-id", "my-board"]) {
const calls = [];
@@ -57,11 +69,13 @@ test("all ID/slug combinations load the same canonical board and snapshot", asyn
}
});
// 快照读取失败或 board 身份不符直接拒绝,不以空文档允许用户覆盖远端。
test("failed snapshot loads and mismatched boards prevent editing rather than creating an empty replacement", async () => {
await assert.rejects(loadBoardCanvas(async (path) => { if (path.endsWith("/canvas")) throw new Error("upstream_unavailable"); return { id: "board", workspace_id: "space", kind: "board" }; }, "space", "board"), /upstream/);
await assert.rejects(loadBoardCanvas(async (path) => path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "other", workspace_id: "space", kind: "board" }, "space", "board"), /invalid_reference/);
});
// dirty 及旧草稿可提供显式恢复候选;clean 备份或与远端相同内容返回 null,不能静默覆盖服务。
test("dirty local recovery and legacy drafts are offered for explicit migration; clean backups never replace server data", () => {
const remote = { version: 3, document: document("server") };
const local = document("local");
@@ -72,6 +86,7 @@ test("dirty local recovery and legacy drafts are offered for explicit migration;
assert.equal(readLocalRecovery(storage({ "key:recovery": JSON.stringify({ document: remote.document, dirty: true }) }), "key", remote), null);
});
// 指纹按内容规范化键序,textStyle 属性顺序不同不触发保存,也不被当作未保存草稿差异。
test("canonical fingerprints ignore server JSON key ordering", async () => {
const remote = document("same"), local = document("same");
remote.nodes[0].data.textStyle = { bold: true, color: "#123456" };
@@ -85,6 +100,7 @@ test("canonical fingerprints ignore server JSON key ordering", async () => {
});
// 先挂起两种读取再检查 started=2,metadata/snapshot 必须并发启动。
test("board metadata and snapshot begin concurrently without a request waterfall", async () => {
const started = []; const releases = [];
const loading = loadBoardCanvas((path) => { started.push(path); return new Promise((resolve) => releases.push(() => resolve(path.endsWith("/canvas") ? { version: 1, document: emptyCanvas() } : { id: "board", workspace_id: "space", kind: "board" }))); }, "space", "board");
@@ -92,6 +108,7 @@ test("board metadata and snapshot begin concurrently without a request waterfall
});
// 微小 viewport 浮点误差保持同一 state,正常 viewport 也不单独保存;随后内容变化才写一次。
test("viewport restoration roundoff does not create an autosave", async () => {
const doc=document("keep");doc.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392};
const state={past:[],present:doc,future:[]};
@@ -104,6 +121,7 @@ test("viewport restoration roundoff does not create an autosave", async () => {
});
// pendingUpload/Blob 节点和触边只在本地可编辑;传输排除它们,确认后的新 src、最新坐标和连线才一起进入快照。
test("editable local uploads and touching edges never enter transport snapshots",async()=>{
const pending={...makeCanvasNode("image",{x:30,y:40},"blob:local-file"),pendingUpload:"upload-one"};pending.id="image-local";
const initial=emptyCanvas(),calls=[];
+17
View File
@@ -1,3 +1,7 @@
/**
* 旧 schema_version=2 linked-board 快照的兼容与投影测试。schema_version、headless 文档格式和增量协议版本不是同一编号。
* 检查展开分区、曲线/样式/执行配置、owner 投影与最低共同祖先;不把任意远程 AI 请求实际执行。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { decodeCanvas, emptyCanvas, makeCanvasNode } from '../src/lib/canvas.ts';
@@ -5,7 +9,9 @@ import { curveSegments, cubicPoint, insertCurveKnot } from '../src/lib/canvas-cu
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData } from '../src/lib/canvas-tree.ts';
import { resizeFromPointer } from '../src/lib/canvas-geometry.ts';
import { managedImageSource } from '../src/lib/canvas-v2.ts';
// 固定子 board 身份,投影时与符号节点 ID 组合,避免把 renderer ID 当作持久节点身份。
const childId = '11111111-1111-4111-8111-111111111111';
// 大尺寸 frame 引用子 board 和输出 port,父文字含 AI 配置,边包含完整曲线/动画/marker 数据。
function fixture() {
const frame = makeCanvasNode('frame', {x:100,y:100}); frame.id='frame'; frame.width=5000;
frame.data={...frame.data,subgraph:{boardId:childId},presentation:{collapsed:false,contentScale:.2,expandedSize:{width:5000,height:400},collapsedSize:{width:240,height:140}},ports:[{id:'output',direction:'output',nodeId:'inside',handle:'right'}]};
@@ -14,33 +20,40 @@ function fixture() {
const edge={id:'edge',source:'frame',target:'outside',sourceHandle:'port:output',targetHandle:'left',data:{appearance:{color:'#123456',width:2,line:'dashed',head:{type:'triangle',filled:false,size:12},tail:{type:'circle',size:8}},animation:{mode:'executing',effect:'flow',direction:'forward',durationMs:1200,color:'#ffffff'},path:{type:'bezier-chain',knots:[{id:'k',position:{x:700,y:150},in:{x:-30,y:0},out:{x:30,y:0},mode:'smooth'}]},execution:{enabled:true}}};
return {frame,text,inside,root:{...emptyCanvas(),schema_version:2,nodes:[frame,text],edges:[edge]},child:{...emptyCanvas(),schema_version:2,nodes:[inside]}};
}
// V2 JSON 往返保留 subgraph、AI 与全部边 data,且已解码对象再次解码结果稳定。
test('V2 roundtrip preserves linked frames, all curve/animation/marker data and AI config',()=>{
const {root}=fixture(); const decoded=decodeCanvas(JSON.stringify(root));
assert.deepEqual(decoded.edges[0].data,root.edges[0].data); assert.deepEqual(decoded.nodes[0].data.subgraph,root.nodes[0].data.subgraph); assert.deepEqual(decoded.nodes[1].data.ai,root.nodes[1].data.ai);assert.equal(decoded.nodes[0].width,5000);
assert.deepEqual(decodeCanvas(JSON.stringify(decoded)),decoded);
});
// 逐项破坏平滑控制向量、动画、线宽、端口方向、普通元素尺寸或 AI 参数容器,解码均失败。
test('V2 rejects bad control data, wrong port directions and oversized ordinary elements',()=>{
for(const mutate of [d=>d.edges[0].data.path.knots[0].out.x=-30,d=>d.edges[0].data.animation.mode='random',d=>d.edges[0].data.appearance.width=100,d=>d.nodes[0].data.ports[0].direction='input',d=>d.nodes[1].width=5000,d=>d.nodes[1].data.ai.parameters=[]]){
const {root}=fixture();mutate(root);assert.throws(()=>decodeCanvas(JSON.stringify(root)));
}
});
// 展开时子节点只投影一次且边解析到真实内部端点;折叠改画到 frame,不改父子原文档或子局部坐标。
test('expanded child nodes are projected once and boundary edges resolve to their actual nodes',()=>{
const {root,child}=fixture(), documents=new Map([['root',root],[childId,child]]);const view=projectCanvasTree('root',documents);
assert.equal(view.nodes.length,3);const projected=view.nodes.find(n=>n.id===childId+'::inside');assert.deepEqual(projected.position,{x:126,y:140});assert.equal(projected.width,52);assert.equal(view.nodeRefs.get(projected.id).boardId,childId);assert.equal(view.edges[0].source,projected.id);assert.equal(root.nodes.length,2);assert.equal(child.nodes.length,1);
root.nodes[0].data.presentation.collapsed=true;const collapsed=projectCanvasTree('root',documents);assert.equal(collapsed.nodes.length,2);assert.equal(collapsed.edges[0].source,'frame');assert.deepEqual(child.nodes[0].position,{x:10,y:20});
});
// 边的点与向量经 owner→projected→owner 转换应精确返回原 data。
test('curve coordinates and vectors invert exactly between owner and projected canvases',()=>{
const {root}=fixture();const ref={boardId:'b',id:'e',origin:{x:100,y:-40},scale:.2};assert.deepEqual(transformEdgeData(transformEdgeData(root.edges[0].data,ref),ref,true),root.edges[0].data);
});
// 插入中点把一段 cubic 拆为两段;101 个参数样本与原曲线距离小于 1e-8,验证形状而非仅端点。
test('adding a curve point preserves both halves of the original cubic',()=>{
const from={point:{x:0,y:0},normal:{x:1,y:0}},to={point:{x:500,y:200},normal:{x:-1,y:0}};
const old=curveSegments(from,to)[0], split=insertCurveKnot(from,to,undefined), next=curveSegments(from,to,split.path);
assert.equal(next.length,2);for(let i=0;i<=100;i++){const t=i/100,a=cubicPoint(old,t),b=cubicPoint(next[t<=.5?0:1],t<=.5?t*2:(t-.5)*2);assert.ok(Math.hypot(a.x-b.x,a.y-b.y)<1e-8);}
});
// 远程生成文字可与本地几何三方合并,但同字段本地文字冲突必须抛 version_conflict。
test('AI remote output merges with local geometry but conflicts with locally edited text',()=>{
const base=fixture().child,local=structuredClone(base),remote=structuredClone(base);local.nodes[0].position={x:200,y:300};remote.nodes[0].data.text='generated';
const merged=mergeCanvasDocuments(base,local,remote);assert.deepEqual(merged.nodes[0].position,{x:200,y:300});assert.equal(merged.nodes[0].data.text,'generated');local.nodes[0].data.text='user typing';assert.throws(()=>mergeCanvasDocuments(base,local,remote),/version_conflict/);
});
// 五类节点乘八方向在显式比例锁定时保持原比例;自由模式仅右侧拉伸不改图高,frame 可大于普通卡片上限。
test('eight-direction resize is free by default in resize mode and Shift locks the aspect',()=>{
for(const kind of ['plain-text','plain-image','text','image','frame']){
const node=makeCanvasNode(kind,{x:10,y:20});const oldRatio=node.width/node.height;
@@ -51,10 +64,12 @@ test('eight-direction resize is free by default in resize mode and Shift locks t
const image=makeCanvasNode('plain-image',{x:0,y:0});assert.equal(resizeFromPointer(image,'right',{x:100,y:0},false).height,image.height);
const frame=makeCanvasNode('frame',{x:0,y:0});assert.ok(resizeFromPointer(frame,'right',{x:10000,y:0},false).width>3000);
});
// 受管资产显示转认证同源代理,普通外部 URL 保留;这里只检查地址转换,不验证实际认证请求。
test('managed assets render through the authenticated same-origin proxy',()=>{
assert.equal(managedImageSource('https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/a'),'/api/foundry/workspaces/w/assets/a');assert.equal(managedImageSource('https://example.com/a.png'),'https://example.com/a.png');
});
// 连线持久 owner 是最低共同祖先,事务参与者恰为两条 owner 路径的并集;祖先本身不需要重复登记。
test('connection plan uses the lowest common ancestor and exactly both ownership paths',async()=>{
const {connectionTransactionPlan}=await import('../src/lib/canvas-subgraphs.ts');const {root,child}=fixture();
const nested='33333333-3333-4333-8333-333333333333';const frame=makeCanvasNode('frame',{x:0,y:0});frame.data.subgraph={boardId:nested};child.nodes.push(frame);
@@ -63,10 +78,12 @@ test('connection plan uses the lowest common ancestor and exactly both ownership
assert.equal(result.boardId,'root');assert.deepEqual(new Set(result.participants),new Set(['root',childId,nested]));assert.equal(result.source.node_id,'a');
const inner=connectionTransactionPlan(docs,{boardId:nested,id:'a',handle:'bottom'},{boardId:childId,id:'b',handle:'top'});assert.equal(inner.boardId,childId);assert.deepEqual(new Set(inner.participants),new Set([childId,nested]));
});
// 旧 schema 或 subgraph owner 绑定不同的草稿不能恢复到已迁移 V2;等价深拷贝才可作为候选。
test('incompatible drafts cannot overwrite migrated V2 ownership',async()=>{
const {canRestoreCanvasDraft}=await import('../src/lib/canvas-sync.ts');const {root}=fixture();const old={...structuredClone(root),schema_version:1};assert.equal(canRestoreCanvasDraft(old,root),false);
const changed=structuredClone(root);changed.nodes[0].data.subgraph.boardId='55555555-5555-4555-8555-555555555555';assert.equal(canRestoreCanvasDraft(changed,root),false);assert.equal(canRestoreCanvasDraft(structuredClone(root),root),true);
});
// 同时拖选父 frame 与 child 的投影结果,只应用共同位移一次,反推 owner 局部坐标保持原值。
test('selecting both parent frame and child applies a shared drag once',async()=>{
const {referenceAfterMoves}=await import('../src/lib/canvas-tree.ts');const {root,child}=fixture();const docs=new Map([['root',root],[childId,child]]),view=projectCanvasTree('root',docs);
const projected=view.nodes.find(node=>node.id===childId+'::inside');const delta={x:50,y:80};const moved={x:projected.position.x+delta.x,y:projected.position.y+delta.y};
+14
View File
@@ -1,7 +1,12 @@
/**
* 旧画布快照 reducer 的基本历史、节点/边、序列化与分支规则测试。past/present/future 是旧版完整快照结构。
* 这里的 checkpoint/replace 不能当作 headless 操作账本、条件增量请求或项目恢复协议的验证。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
// 构造两张不同卡片及一条边,返回空 past/future 的独立历史,用于比较编辑前后内容和 viewport。
function seeded() {
const document = emptyCanvas();
document.nodes = [makeCanvasNode("text", { x: 20, y: 30 }), makeCanvasNode("script", { x: 400, y: 30 })];
@@ -9,6 +14,7 @@ function seeded() {
return { past: [], present: document, future: [] };
}
// checkpoint 后拖动形成一个历史起点;undo/redo 改内容位置但保留当前 viewport,视图导航不跟着旧快照回退。
test("node dragging updates its position; undo and redo retain the current viewport", () => {
let state = seeded();
const id = state.present.nodes[0].id;
@@ -23,6 +29,7 @@ test("node dragging updates its position; undo and redo retain the current viewp
assert.deepEqual(state.present.nodes[0].position, { x: 220, y: -30 });
});
// 删节点连同附边一起删除,随后重复边删除通知不应再追加历史;一次 undo 恢复节点与边。
test("deleting a node removes attached edges and undo restores both", () => {
const initial = seeded();
const removed = canvasReducer(initial, { type: "nodes", changes: [{ id: initial.present.nodes[0].id, type: "remove" }] });
@@ -35,6 +42,7 @@ test("deleting a node removes attached edges and undo restores both", () => {
assert.equal(restored.present.edges.length, 1);
});
// 同一 checkpoint 内多次文字修改只留一个 past,undo 回到开始编辑前文字。
test("a text editing session is undoable as one operation", () => {
let state = seeded();
const id = state.present.nodes[0].id;
@@ -45,6 +53,7 @@ test("a text editing session is undoable as one operation", () => {
assert.equal(canvasReducer(state, { type: "undo" }).present.nodes[0].data.text, "");
});
// JSON 往返保留文本、图片、连线与 viewport,selected 作为临时 UI 字段不恢复。
test("export/import retains nodes, edges, text, images and the viewport", () => {
const document = seeded().present;
const image = makeCanvasNode("image", { x: 80, y: 90 }, "data:image/png;base64,YQ==");
@@ -60,6 +69,7 @@ test("export/import retains nodes, edges, text, images and the viewport", () =>
assert.deepEqual(restored.viewport, document.viewport);
});
// 悬空端点、重复 ID、不安全图片 URL 和非法 zoom 分别构造非法输入,任一项都使整个 decode 失败。
test("import rejects dangling connections, duplicate nodes, unsafe images and invalid zoom", () => {
for (const mutate of [
(doc) => { doc.edges[0].target = "missing"; },
@@ -73,11 +83,13 @@ test("import rejects dangling connections, duplicate nodes, unsafe images and in
}
});
// 旧草稿 key 包含 workspace 与 board,不能因同名图谱跨作用域覆盖。
test("draft storage is isolated by workspace and graph", () => {
assert.notEqual(canvasStorageKey("workspace-a", "board"), canvasStorageKey("workspace-b", "board"));
assert.notEqual(canvasStorageKey("workspace", "board-a"), canvasStorageKey("workspace", "board-b"));
});
// 50 次 replace 后 past 受限为 40;undo 产生 redo,新 add 必须清空 redo。
test("undo history is bounded and new edits clear the redo stack", () => {
let state = seeded();
for (let i = 0; i < 50; i++) state = canvasReducer(state, { type: "replace", document: { ...state.present, edges: [] } });
@@ -89,6 +101,7 @@ test("undo history is bounded and new edits clear the redo stack", () => {
});
// 四方向分支检查相对位置、选中切换、source/target ID 和相反 handle;快照可恢复,整组可撤销。
test("selected nodes can branch in all four directions with matching connection handles", () => {
const opposite = { top: "bottom", right: "left", bottom: "top", left: "right" };
for (const direction of Object.keys(opposite)) {
@@ -114,6 +127,7 @@ test("selected nodes can branch in all four directions with matching connection
}
});
// 同侧重复添加要避开已有节点,第二个新节点不能占据第一次相同位置。
test("repeated additions on the same side avoid existing nodes", () => {
const document = emptyCanvas();
const parent = makeCanvasNode("text", { x: 0, y: 0 });
+154
View File
@@ -439,21 +439,27 @@ async function conflictReplanCase(name,assert){
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-conflict-replan`);}
}
// 已无未决写入时采用更新的完整规范基线:预览不发布,apply 后文档、版本和原历史一起持久化,重开不重发已确认写入。
async function canonicalRefreshCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
const target={id:'a',incarnation:1},element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}};
const original=createDocument(root,[{id:root,parent:null}],[element]),storage=createBrowserStorage({name:`${name}-canonical-refresh`}),calls=[];let server=original,version=1,engine;
// 完整读取和局部回执来自同一内存服务状态,不代表真实远程读取。
const model=complete=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:[],canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:{},versions:{[root]:version}});
// 服务替身只应用 changes 并递增版本,不在此重复验证所有条件协议;重点是客户端刷新后的持久化。
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);for(const operation of body.operations)server=applyChanges(server,operation.changes);version++;return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();
// 保存已确认编辑的历史 ID 与原冻结请求,随后模拟外部几何修改并推进到版本 3。
const historyId=engine.getSnapshot().history.undo[0],request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];
server=applyChanges(server,[{kind:'field',target,field:'geometry',before:element.geometry,after:{...element.geometry,x:400}}]);version++;
// 恢复读是预览,working.x 仍为 0,undo 被恢复屏障拒绝;apply 才发布 x=400。
const session=await engine.prepareRecovery();assert(session.conflict===undefined,'clean canonical refresh reported a conflict');
assert(engine.getSnapshot().document.elements.get('a').geometry.x===0,'preview mutated the working document');
assert(engine.editor.history.undo().code==='recovery_in_progress','history bypassed the recovery fence');
assert((await session.apply()).status==='applied','fresh canonical state did not publish');await engine.close();
// 重开核对标题、几何、版本及同一历史 ID;请求表/body 不变且只发过最初一次 POST。
engine=await openProjectEngine(options);const restored=engine.getSnapshot();
assert(restored.document.elements.get('a').geometry.x===400&&restored.document.elements.get('a').data.title==='local','canonical refresh did not survive IndexedDB recovery');
assert(restored.runtime.versions.get(root)===3&&restored.history.undo[0]===historyId,'refresh lost its version/history cut');
@@ -462,13 +468,16 @@ async function canonicalRefreshCase(name,assert){
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-canonical-refresh`);}
}
// AI 受理成功但响应丢失后,真实 IndexedDB 重开先查询原受理,再消费事件;游标与生成内容必须处于同一持久化切点。
async function runRecoveryCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),runId=crypto.randomUUID();
const registry=createRegistry(builtinComponents()),element={id:'a',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:root,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:'a',text:'original'}},original=createDocument(root,[{id:root,parent:null}],[element]);
const storage=createBrowserStorage({name:`${name}-runs`}),calls=[];let accepted,after,plan,engine;
// 生成单根局部模型供受理版本 2、结果版本 3 使用;服务任务本身仍为内存替身。
const chunk=(document,version)=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:{},versions:{[root]:version}});
const transport=async request=>{
calls.push(request);
// 受理先构造 AI 配置、冻结计划和已生成结果,但返回 502,不能在客户端据此直接发布 RunRecord。
if(request.method==='POST'){
const input=JSON.parse(request.body),configured=applyChanges(original,[{kind:'field',target:{id:'a',incarnation:1},field:'data.ai',before:null,beforePresent:false,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:{},propagate:false},afterPresent:true}]);
plan={steps:[{step_id:'step-a',board_id:root,node_id:'a',target_incarnation:1,canonical_root:root}],edges:[],frozen_versions:{[root]:2}};
@@ -476,73 +485,102 @@ async function runRecoveryCase(name,assert){
after=applyChanges(configured,[{kind:'field',target:{id:'a',incarnation:1},field:'data.text',before:'original',after:'durable generated',beforePresent:true,afterPresent:true}]);
return{status:502,body:{error:'response_lost'}};
}
// 按原请求查询时返回保存的 accepted;其他 GET 模拟带序号的 queued/applied 事件页。
if(request.path.includes('/ai/operations/'))return{status:200,body:accepted};
return{status:200,body:{run_id:runId,status:'succeeded',plan,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'},{seq:2,run_id:runId,type:'step.applied',step_id:'step-a',board_id:root,node_id:'a',target_incarnation:1,document_format_version:1,canvas_version:3,model:chunk(after,3)}],next_seq:2,has_more:false,outputs:[],steps:[]}};
};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport}),runs:createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport})};
try{
// 最后一个 view detach 不停止后台受理;等待后 runs 仍应为空,不能猜测丢失响应的身份。
engine=await openProjectEngine(options);const detach=engine.sessions.attach('view');engine.startRun({target:{id:'a',incarnation:1},boardId:root,modelId:'text',prompt:'Generate',parameters:{},propagate:false});detach();await engine.settle();
const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(engine.getSnapshot().runtime.runs.size===0,'lost admission was guessed as resolved');await engine.close();
// 前移恢复时钟并有界等待 cursor=2;POST 仍只有一次,必须查过原 admission 请求。
engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}});
for(let i=0;i<100&&engine.getSnapshot().runtime.runs.get(runId)?.cursor!==2;i++)await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
assert(calls.filter(call=>call.method==='POST').length===1,'recovery invoked a second generation');assert(calls.some(call=>call.path.endsWith('/ai/operations/'+request.id)),'recovery lost the original admission identity');
assert(engine.getSnapshot().runtime.runs.get(runId)?.cursor===2&&engine.getSnapshot().document.elements.get('a').data.text==='durable generated','run and canonical result did not publish together');assert(engine.getSnapshot().history.undo.length===0,'ACK replay created user history');await engine.close();
// 关闭 engine 后直接取得独占 owner 读镜像,检查游标与文字都已落盘;这比只看内存结果更强。
const leader=await storage.claim(options.scope);try{const image=await storage.recover(leader);assert(image.payload.runtime.runs[0][1].cursor===2,'run cursor was not durable');assert(image.payload.document.elements[0].data.text==='durable generated','cursor advanced without durable document');}finally{await leader.release();}
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-runs`);}
}
// StoragePort 原子文件删除:purge marker、Root 与文件字节一起提交,删除请求入队后注入异常必须整体回滚,并且隔离 project。
async function atomicResourceDeletionCase(name,assert){
const database=`${name}-file-collection`,storage=createBrowserStorage({name:database,checkpointEvery:2}),scope={accountId:'account',workspaceId:'workspace',projectId:'project'};let leader,other;
// 使用最小 v3 恢复 payload 直接测试存储删除约束,不把它当作完整业务模型。
const marker={version:1,unreferencedAt:100,purgedAt:200},image={formatVersion:3,rootRevision:1,localRevision:0,payload:{runtime:{resources:[['asset',{retirement:marker}]]}}};
try{
// 两个 project 允许同名 asset;只在当前 scope 回收,不能影响另一 owner。
other=await storage.claim({...scope,projectId:'other-project'});await storage.commit(other,{image:{formatVersion:1,rootRevision:0,localRevision:0,payload:{}},files:[{id:'asset',bytes:new Blob(['other project'])}]});
leader=await storage.claim(scope);await storage.commit(leader,{image:{formatVersion:1,rootRevision:0,localRevision:0,payload:{}},files:[{id:'asset',bytes:new Blob(['delete me'])},{id:'keep',bytes:new Blob(['keep me'])}]});
// 保存原生 delete,临时在真实删除请求排入事务后抛错;finally 恢复原型,防止污染后续场景。
const nativeDelete=IDBObjectStore.prototype.delete;let queued=false;
IDBObjectStore.prototype.delete=function(key){const request=nativeDelete.call(this,key);if(this.name==='files'&&Array.isArray(key)&&key[1]==='asset'){queued=true;throw Error('fault_after_delete');}return request;};
let failure;try{await storage.commit(leader,{image,deleteFiles:['asset']});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.delete=nativeDelete;}
// 必须确实命中故障;原 asset 仍可读且 Root 保持 0,证明事务没有半提交。
assert(queued&&failure==='fault_after_delete','fault did not occur after a real IDB delete request');assert(await(await storage.readFile(leader,'asset')).text()==='delete me','aborted GC transaction lost bytes');assert((await storage.recover(leader)).rootRevision===0,'aborted GC transaction advanced recovery');
// 调用 commit 后立即把传入删除列表改为 keep;实现必须已捕获 asset,不能误删后来的列表项。
const ids=['asset'],pending=storage.commit(leader,{image,deleteFiles:ids});ids[0]='keep';await pending;
assert(await storage.readFile(leader,'asset')===undefined&&await(await storage.readFile(leader,'keep')).text()==='keep me','mutable deletion descriptor escaped capture');assert((await storage.recover(leader)).formatVersion===3,'GC cut did not persist');await storage.commit(leader,{image,deleteFiles:['asset']});
// 无匹配 purge marker 的 keep 删除被拒绝;同一已完成切点可重试,重开保持删除且另一项目字节不变。
let code;try{await storage.commit(leader,{image:{...image,rootRevision:2},deleteFiles:['keep']});}catch(error){code=error.code;}assert(code==='invalid_file_deletion','file deletion without a matching purge marker was accepted');
await leader.release();leader=await storage.claim(scope);assert(await storage.readFile(leader,'asset')===undefined&&(await storage.recover(leader)).rootRevision===1,'GC did not survive owner reopening');assert(await(await storage.readFile(other,'asset')).text()==='other project','local GC crossed its project scope');
}finally{await other?.release();await leader?.release();storage.close();indexedDB.deleteDatabase(database);}
}
// 终态 partial 导入的源 Blob 可在保留期后清理,但失败报告、已提交内容及 undo/redo 不能依赖已退休的原始源文件。
async function importSourceCollectionCase(name,assert){
const database=`${name}-import-source-gc`,storage=createBrowserStorage({name:database,checkpointEvery:3}),scope={accountId:'account',workspaceId:'workspace',projectId:'project'},registry=createRegistry(builtinComponents()),original=createDocument('project',[{id:'P',parent:null}],[]),receipts=new Map();let engine,owner,server=original,version=1,now=1000;
// 文档服务替身按意图应用并存回执,收集源文件本身不应创建额外内容修改。
const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{P:version}}),async submit(request){const {intent}=JSON.parse(request.body);server=applyChanges(server,intent.changes);receipts.set(request.id,{document:server,versions:new Map([['P',++version]])});return{outcome:'applied'};},async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},canonical:request=>receipts.get(request.id)};
// 包装 commit 捕获当前 owner,仅供测试随后直接读真实 IndexedDB 文件和镜像。
const options={scope,registry,document:original,versions:new Map([['P',1]]),storage:{...storage,commit:(leader,batch)=>{owner=leader;return storage.commit(leader,batch);}},persistence:port,clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}};
try{
// 先导入合法一项再遇非法 JSON,记录 partial 作业源 fileId 和当前文档/history 引用。
engine=await openProjectEngine(options);const line={kind:'element',key:'text',record:{id:'text',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'$target',data:{title:'text',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}},id=engine.imports.startNdjson(new Blob([JSON.stringify(line)+'\nnot-json']),'P');await engine.settle();const job=engine.imports.getJobs().get(id),fileId=job.source.fileId;assert(job.status==='partial','partial source fixture did not finish');const document=engine.getSnapshot().document,history=engine.getSnapshot().history;
// 先软标记再推进时间到保留期,随后包装 IDB delete 注入一次事务内故障。
await engine.collectImportSources({retentionMs:100});now+=100;const nativeDelete=IDBObjectStore.prototype.delete;let injected=false;
IDBObjectStore.prototype.delete=function(key){const request=nativeDelete.call(this,key);if(!injected&&this.name==='files'&&Array.isArray(key)&&key[1]===fileId){injected=true;throw Error('source_gc_abort');}return request;};
let failure;try{await engine.collectImportSources({retentionMs:100});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.delete=nativeDelete;}
// 删除失败后源字节仍在,v17 镜像只能含软标记,不能提前写 purgedAt。
assert(injected&&failure==='source_gc_abort','source GC did not reach an IDB deletion fault');assert((await storage.readFile(owner,fileId)).size===job.source.byteLength,'source bytes vanished after aborted GC');const soft=await storage.recover(owner);assert(soft.formatVersion===17&&soft.payload.runtime.imports[0][1].source.retirement.purgedAt===undefined,'aborted source GC advanced the purge cut');
// persist 重试原清理切点,字节删除与 purgedAt=1100 同时恢复;文档/history 引用必须不变。
await engine.persist();assert(await storage.readFile(owner,fileId)===undefined,'source deletion retry did not remove bytes');const cut=await storage.recover(owner);assert(cut.formatVersion===17&&decodeRecovery(cut,registry).runtime.imports.get(id).source.retirement.purgedAt===1100,'source purge and recovery marker disagree');assert(engine.getSnapshot().document===document&&engine.getSnapshot().history===history,'source GC changed content/history');
// 低版本解释器不得接受源退休语义;重开保持原 partial 报告,openSource 明确拒绝,undo/redo 仍可操作已导入内容。
let oldReader;try{decodeRecovery({...cut,formatVersion:3},registry);}catch(error){oldReader=error.code;}assert(oldReader==='unsupported_recovery_version','source retirement was accepted as v3');await engine.close();engine=await openProjectEngine(options);await engine.imports.resume(id);await engine.settle();assert(engine.imports.getJobs().get(id).status==='partial'&&engine.imports.getJobs().get(id).failures.length===1,'reopening a retired source lost its failure report');let retired;try{await engine.imports.openSource(id);}catch(error){retired=error.code;}assert(retired==='import_source_retired','retired source was silently reinterpreted or fetched as an asset');assert(engine.editor.history.undo().status==='applied','undo depended on purged source');await engine.settle();assert(engine.getSnapshot().document.elements.size===0,'undo retained imported data');assert(engine.editor.history.redo().status==='applied','redo depended on purged source');await engine.settle();assert(engine.getSnapshot().document.elements.size===1,'redo did not restore imported content');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
// 盘点未登记文件时仍尊重元素引用,分页只读;孤立字节回收有保留期、原子 purge、项目隔离及持久化身份退休。
async function orphanInventoryCase(name,assert){
const database=`${name}-orphan-inventory`,storage=createBrowserStorage({name:database,checkpointEvery:3}),scope={accountId:'account',workspaceId:'workspace',projectId:'project'},registry=createRegistry(builtinComponents()),document=createDocument('project',[{id:'P',parent:null}],[{id:'image',incarnation:1,ownerCanvasId:'P',structure:'node',componentId:'image',dataVersion:1,data:{title:'image',text:'',assetId:'z-pinned'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}]);let engine,owner,other,now=1000;
// 所有文档网络方法故意抛错,确保盘点/回收不偷偷走业务同步;claim 包装只捕获测试 owner。
const options={scope,registry,document,versions:new Map([['P',1]]),storage:{...storage,claim:async scope=>{owner=await storage.claim(scope);return owner;}},persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected write');},canonical(){throw Error('unexpected write');}},clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}};
try{
// 通过底层存储添加未登记的孤立文件与被元素引用的文件,模拟旧版本留下的字节。
engine=await openProjectEngine(options);await engine.persist();await engine.close();let legacy=await storage.claim(scope),cut=await storage.recover(legacy);await storage.commit(legacy,{image:{...cut,rootRevision:cut.rootRevision+1},files:[{id:'a-orphan',bytes:new Blob(['orphan bytes'])},{id:'z-pinned',bytes:new Blob(['pinned bytes'])}]});await legacy.release();
other=await storage.claim({...scope,projectId:'other'});await storage.commit(other,{image:{formatVersion:1,rootRevision:0,localRevision:0,payload:{}},files:[{id:'a-foreign',bytes:new Blob(['other project'])}]});engine=await openProjectEngine(options);
// 两页盘点按 ID 有序且 Root 引用不变;即使资源表未登记,z-pinned 仍必须有 pin。
const before=engine.getSnapshot(),first=await engine.inventoryLocalFiles({limit:1});assert(first.files.length===1&&first.files[0].id==='a-orphan'&&first.nextAfter==='a-orphan','inventory paging/scope prefix is wrong');assert(engine.getSnapshot()===before,'read-only inventory mutated the Root');const last=await engine.inventoryLocalFiles({after:first.nextAfter,limit:1});assert(last.files[0].id==='z-pinned'&&last.files[0].pins.length>0&&last.nextAfter===undefined,'referenced unregistered bytes lost their pin');
// 软标记后等满 100,再于孤立文件 delete 请求入队后注入故障。
await engine.collectOrphanFiles({retentionMs:100});now+=100;const nativeDelete=IDBObjectStore.prototype.delete;let injected=false;
IDBObjectStore.prototype.delete=function(key){const request=nativeDelete.call(this,key);if(!injected&&this.name==='files'&&Array.isArray(key)&&key[1]==='a-orphan'){injected=true;throw Error('orphan_gc_abort');}return request;};
let failure;try{await engine.collectOrphanFiles({retentionMs:100});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.delete=nativeDelete;}
// 失败只保留 v5 软标记和字节;persist 重试才删除孤立项,被引用文件与其他项目文件都保留。
assert(injected&&failure==='orphan_gc_abort','orphan GC did not reach post-delete rollback');assert(await(await storage.readFile(owner,'a-orphan')).text()==='orphan bytes','orphan byte rollback failed');cut=await storage.recover(owner);assert(cut.formatVersion===5&&cut.payload.runtime.orphanFiles[0][1].retirement.purgedAt===undefined,'aborted orphan GC advanced its cut');await engine.persist();assert(await storage.readFile(owner,'a-orphan')===undefined,'orphan deletion retry failed');assert(await(await storage.readFile(owner,'z-pinned')).text()==='pinned bytes','orphan GC deleted referenced bytes');assert(await(await storage.readFile(other,'a-foreign')).text()==='other project','orphan GC crossed its project scope');
// 解码 purgedAt 后再用旧 rootRevision 扫描,必须拒绝过期 inventory,不能基于旧引用集合继续清理。
cut=await storage.recover(owner);assert(decodeRecovery(cut,registry).runtime.orphanFiles.get('a-orphan').retirement.purgedAt===1100,'orphan v5 record failed recovery');let stale;try{await storage.scanFiles(owner,{expectedRootRevision:before.rootRevision,limit:1});}catch(error){stale=error.code;}assert(stale==='stale_file_inventory','old inventory revision was accepted');
// 即使绕过 engine 直接 commit 也不能复用已退休 ID;重开显示该 ID 应报 local_file_retired,不猜成远程资产。
await engine.close();legacy=await storage.claim(scope);let reuse;try{await storage.commit(legacy,{image:{...cut,rootRevision:cut.rootRevision+1},files:[{id:'a-orphan',bytes:new Blob(['reused identity'])}]});}catch(error){reuse=error.code;}assert(reuse==='resource_identity_conflict','retired file identity was reused');await legacy.release();engine=await openProjectEngine(options);let retired;try{await engine.displayResource('a-orphan');}catch(error){retired=error.code;}assert(retired==='local_file_retired','retired orphan became a guessed remote asset');assert(engine.getSnapshot().document.elements.has('image'),'orphan GC mutated the document');
}finally{await engine?.close();await other?.release();storage.close();indexedDB.deleteDatabase(database);}
}
// 本地图片先显示,上传结果未知时文档依赖等待;真实 IndexedDB 重开查询同一资源请求,回收后仍可使用其已确认远程身份。
async function resourceRecoveryCase(name, assert) {
const scope = { accountId: crypto.randomUUID(), workspaceId: crypto.randomUUID(), projectId: crypto.randomUUID() };
const registry = createRegistry(builtinComponents()), document = createDocument(scope.projectId, [{ id: scope.projectId, parent: null }], []);
const storage = createBrowserStorage({ name: `${name}-resources` }), receipts = new Map(), uploads = [], queries = [], writes = [];
// 真实 Foundry 资产适配器接内存 transport:检查 FormData 原文件文本,保存成功回执后故意返回 502。字节不是可解码 PNG。
const assets = createFoundryAssets({ workspaceId: scope.workspaceId, transport: async input => {
if (input.method === 'POST') {
uploads.push(input); const file = input.body.get('image');
@@ -553,28 +591,33 @@ async function resourceRecoveryCase(name, assert) {
queries.push(input); return { status: 200, body: receipts.get(input.path.split('/').at(-1)) };
} });
let server = document;
// 文档替身只在上传已确认后应用图片创建;writes 数量用于给规范回执计算版本。
const persistence = {
compile: (intent, id, snapshot) => ({ payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: { [scope.projectId]: snapshot.runtime.versions.get(scope.projectId) } }),
async submit(request) { writes.push(request); server = applyChanges(server, JSON.parse(request.body).intent.changes); return { outcome: 'applied' }; },
async query() { return { outcome: 'unresolved' }; },
canonical: () => ({ document: server, versions: new Map([[scope.projectId, writes.length + 1]]) }),
};
// historyLimit=1 让后面可有意挤掉旧撤销单元,检验资源什么时候真正失去历史 pin。
const options = { scope, registry, document, versions: new Map([[scope.projectId, 1]]), storage, persistence, assets,historyLimit:1 };
let engine, resource;
try {
engine = await openProjectEngine(options);
// 同一个本地事务暂存 Blob 与图片引用;用户无需等待上传才看到元素和本地预览。
const result = engine.stageFiles([{ name: 'picture.png', bytes: new Blob(['durable-image-bytes'], { type: 'image/png' }) }], ([ref], tx) => {
resource = ref;
tx.dispatch({ type: 'element.create', element: { id: 'picture', incarnation: 1, componentId: 'image', dataVersion: 1, structure: 'node', ownerCanvasId: scope.projectId, data: { title: 'Picture', text: '', src: ref.src, assetId: ref.id }, geometry: { x: 10, y: 20, width: 100, height: 80, rotation: 0 } } });
});
assert(result.status === 'applied' && engine.getSnapshot().document.elements.has('picture'), 'pending image was hidden until prepare/upload');
const handle = await engine.displayResource(resource.id); assert(handle.url.startsWith('blob:'), 'pending image did not expose a local preview'); handle.release();
// 上传只发一次且文档尚未发;关闭后直接读取 IndexedDB 核对文件、资源元数据与元素同一切点。
await engine.settle(); assert(uploads.length === 1 && writes.length === 0, 'document was sent before its resource was confirmed');
await engine.close();
const leader = await storage.claim(scope), image = await storage.recover(leader);
assert(await (await storage.readFile(leader, resource.id)).text() === 'durable-image-bytes', 'file bytes did not survive actual IndexedDB recovery');
assert(image.payload.runtime.resources[0][1].id === resource.id && image.payload.document.elements[0].data.assetId === resource.id, 'resource metadata and element belong to different durable cuts');
await leader.release();
// 前移时钟触发查询,上传不重发;同一资源身份确认后才释放图片文档写入,不新增节点或历史。
engine = await openProjectEngine({ ...options, clock: { now: () => Date.now() + 100000, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: id => clearTimeout(id) } });
await new Promise(resolve => setTimeout(resolve, 10)); await engine.settle();
assert(uploads.length === 1 && queries.length === 1 && writes.length === 1, 'reopen uploaded again or lost the pending element operation');
@@ -582,9 +625,11 @@ async function resourceRecoveryCase(name, assert) {
assert(engine.getSnapshot().runtime.resources.get(resource.id).stored.id === resource.id, 'confirmed asset changed resource identity');
assert(engine.getSnapshot().history.undo.length === 1 && engine.getSnapshot().document.elements.size === 1, 'resource ACK recreated the node/history');
assert((await engine.collectResources({retentionMs:0})).purgedIds.length===0,'live image was collected');
// 删除图片后仍由 undo 持有资源,不能立即回收;再创建文本使受限历史淘汰旧单元,才允许清理字节。
engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});await engine.settle();assert((await engine.collectResources({retentionMs:0})).purgedIds.length===0,'undo-owned image was collected');
engine.editor.dispatch({type:'element.create',element:{id:'keep-text',incarnation:1,componentId:'text',dataVersion:1,structure:'node',ownerCanvasId:scope.projectId,data:{title:'keep',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}});await engine.settle();
const collected=await engine.collectResources({retentionMs:0});assert(collected.purgedIds.length===1&&collected.purgedIds[0]===resource.id,'unreferenced local image was not collected');await engine.close();
// 直接检查 v3 purge marker 和文件不存在是否一致,之后重开应回退到原有远程资产 URL,不能重新上传。
const inspected=await storage.claim(scope),cut=await storage.recover(inspected);assert(cut.formatVersion===3&&await storage.readFile(inspected,resource.id)===undefined,'recovered GC marker and byte deletion disagree');assert(decodeRecovery(cut,registry).runtime.resources.get(resource.id).retirement.purgedAt!==undefined,'GC marker did not decode');await inspected.release();
engine=await openProjectEngine(options);const remote=await engine.displayResource(resource.id);assert(remote.url===assets.displayURL(resource.id),'purged confirmed image did not fall back to its existing remote asset');remote.release();assert(uploads.length===1,'reopening collected bytes repeated upload');
@@ -592,147 +637,217 @@ async function resourceRecoveryCase(name, assert) {
}
// 导入源按 SHA-256 绑定精确字节;等长替换也应在恢复尾部前被拒绝,保留已提交前缀及稳定 ID,原字节还原后继续同一作业。
async function sourceIntegrityCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),registry=createRegistry(builtinComponents()),databaseName=`${name}-source-integrity`,storage=createBrowserStorage({name:databaseName}),scope={accountId:crypto.randomUUID(),workspaceId:workspace,projectId:root},original=createDocument(root,[{id:root,parent:null}],[]);let document=original,versions=new Map([[root,1]]),engine,calls=0,raw;
// 服务替身按参与版本应用增量,calls 计数用来证明发现源篡改前没有新增网络工作。
const transport=async request=>{calls++;const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'integrity lost a version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(row=>row.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(versions)}}};};
// 两条来源记录使用 $target 映射;first 已提交后暂停,last 尚待恢复。
const node=id=>({kind:'element',key:id,record:{id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'$target',data:{title:id,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}}}),bytes=new Blob([[node('first'),node('last')].map(row=>JSON.stringify(row)).join('\n')]),options={scope,registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
// 等一个 committedSourceKey 后 pause 并核对源摘要已存在,再关闭引擎保留恢复点。
engine=await openProjectEngine(options);const id=engine.imports.startNdjson(bytes,root);await new Promise(resolve=>{const stop=engine.editor.subscribe((_,snapshot)=>{if(snapshot.runtime.imports.get(id).committedSourceKeys.length===1){stop();resolve();}});});await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id),first=before.stableIds['element:first'];assert(/^[a-f0-9]{64}$/.test(before.source.sha256),'source hash was not bound before publication');await engine.close();const sent=calls;
// 原始 IDB 连接定位作用域内 source.fileId,绕过正常存储不可变规则以制造损坏。
raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(databaseName);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const key=[JSON.stringify([scope.accountId,scope.workspaceId,scope.projectId]),before.source.fileId];
// 把 last 改为等长 evil,核对 Blob 大小不变;resume 必须报告 import_source_changed,不能靠 byteLength 放行。
const write=async value=>{const tx=raw.transaction('files','readwrite'),done=new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);});tx.objectStore('files').put(value,key);await done;};const text=await bytes.text(),changed=text.replaceAll('last','evil');assert(new Blob([changed]).size===bytes.size,'corruption fixture changed its byte length');await write(new Blob([changed]));engine=await openProjectEngine(options);await engine.imports.resume(id);await engine.settle();assert(engine.imports.getJobs().get(id).error==='import_source_changed','same-length source tampering was accepted');assert(engine.getSnapshot().document.elements.size===1&&engine.getSnapshot().document.elements.has(first)&&calls===sent,'source tampering changed the preserved prefix or sent new work');
// 修复为原 Blob 后重用同一 task/first 映射继续,只补未完成尾部。
await write(bytes);await engine.imports.resume(id);await engine.settle();assert(engine.imports.getJobs().get(id).status==='completed'&&document.elements.size===2&&engine.imports.getJobs().get(id).stableIds['element:first']===first,'restoring exact bytes did not resume the original task');
}finally{raw?.close();await engine?.close();storage.close();indexedDB.deleteDatabase(databaseName);}
}
// 真实 IndexedDB 的差量日志链:迁移旧 checkpoint,小修改只散列差量,基线校验与版本链完整,压实时再散列完整镜像。
async function deltaJournalCase(name,assert){
// 大部分 rows 固定,只有第一行随 revision 变化,冻结请求字节不变,用来观察差量大小。
const databaseName=`${name}-delta-journal`,scope={accountId:'delta-account',workspaceId:'workspace',projectId:'project'},key=JSON.stringify(Object.values(scope)),rows=Array.from({length:100},(_,i)=>({id:String(i),text:'x'.repeat(1000)})),image=revision=>({formatVersion:1,rootRevision:revision,localRevision:revision,payload:{rows:rows.map((row,i)=>i===0?{...row,text:`version-${revision}`}:row),ledger:{frozenBody:'exact original request bytes'}}});
// done 等事务完成/abort,read 等单条请求结果;两者职责不同,不能用 put 成功代替事务成功。
const done=tx=>new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);}),read=request=>new Promise((resolve,reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
// 手工建 DB v1 和旧对象 checkpoint,checksum 覆盖旧完整 JSON,保证实际经历兼容迁移。
let raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(databaseName,1);request.onupgradeneeded=()=>{for(const store of ['metadata','checkpoints','journal','files'])request.result.createObjectStore(store);};request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
const initial=image(0),checksum=[...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(initial))))].map(byte=>byte.toString(16).padStart(2,'0')).join('');let tx=raw.transaction(['metadata','checkpoints'],'readwrite'),terminal=done(tx);tx.objectStore('metadata').put({fence:0,throughRevision:0,checkpointRevision:0},key);tx.objectStore('checkpoints').put({image:initial,checksum},[key,0]);await terminal;raw.close();
let storage=createBrowserStorage({name:databaseName,checkpointEvery:3}),leader;
try{
leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(initial),'legacy checkpoint did not migrate');
// 临时包装真实 SubtleCrypto 只记录哈希输入长度;首个小改动应只散列一次差量,finally 恢复 API。
const digest=crypto.subtle.digest,hashed=[];crypto.subtle.digest=function(algorithm,bytes){hashed.push(bytes.byteLength);return digest.call(this,algorithm,bytes);};try{const first=image(1);await storage.commit(leader,{image:sealRecoveryImage(first.formatVersion,first.rootRevision,first.localRevision,first.payload),files:[{id:'asset',bytes:new Blob(['immutable'])}]});}finally{crypto.subtle.digest=digest;}
assert(hashed.length===1&&hashed[0]<JSON.stringify(image(1)).length/10,'a delta commit still hashed the whole image');
raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(databaseName);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
// 直接读 journal row,要求 journalVersion=2、没有 full image 且长度小于样例全镜像十分之一。
const row=await read(raw.transaction('journal').objectStore('journal').get([key,1]));assert(row.journalVersion===2&&!row.image&&JSON.stringify(row).length<JSON.stringify(image(1)).length/10,'small edit repeated the full image');
// 新文件先加入、后一个既有 ID 冲突时整批回滚;修复后连写两步并重建 adapter,验证差量链可重放。
let code;try{await storage.commit(leader,{image:image(2),files:[{id:'orphan',bytes:new Blob(['orphan'])},{id:'asset',bytes:new Blob(['replacement'])}]});}catch(error){code=error.code;}assert(code==='resource_identity_conflict'&&await storage.readFile(leader,'orphan')===undefined,'delta transaction leaked a file or advanced its cut');assert((await storage.recover(leader)).rootRevision===1,'aborted delta advanced the head');await storage.commit(leader,{image:image(2)});await leader.release();storage.close();storage=createBrowserStorage({name:databaseName,checkpointEvery:3});leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(image(2)),'fresh adapter did not replay the delta chain');
const intact=await read(raw.transaction('journal').objectStore('journal').get([key,2]));
// 构造旧 v1 差量与新 v2 链接混合,按各自规则重算 checksum,检查混合链兼容。
const hash=async value=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(value))))].map(byte=>byte.toString(16).padStart(2,'0')).join(''),legacy={...row,journalVersion:1,checksum:await hash(image(1))},mixed={...intact,baseChecksum:await hash(image(1))};mixed.checksum=await hash({journalVersion:mixed.journalVersion,baseRevision:mixed.baseRevision,baseChecksum:mixed.baseChecksum,header:mixed.header,patches:mixed.patches});
// 真实写入混合日志后恢复应精确等于 image(2),随后还原标准日志供独立损坏场景。
let mixedTx=raw.transaction('journal','readwrite'),mixedDone=done(mixedTx);mixedTx.objectStore('journal').put(legacy,[key,1]);mixedTx.objectStore('journal').put(mixed,[key,2]);await mixedDone;assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(image(2)),'mixed legacy-v1 and chained-v2 deltas did not recover');mixedTx=raw.transaction('journal','readwrite');mixedDone=done(mixedTx);mixedTx.objectStore('journal').put(row,[key,1]);mixedTx.objectStore('journal').put(intact,[key,2]);await mixedDone;await storage.recover(leader);
// 逐个替换 revision 2 日志,分别损坏 patch、header、baseChecksum 和编码版本;每轮检查对应错误后还原。
const write=async value=>{const tx=raw.transaction('journal','readwrite'),wait=done(tx);tx.objectStore('journal').put(value,[key,2]);await wait;};
for(const[kind,expected]of [['payload','recovery_checksum_mismatch'],['header','recovery_checksum_mismatch'],['base','recovery_delta_base_mismatch'],['future','recovery_delta_interpreter_unavailable']]){const damaged=structuredClone(intact);if(kind==='payload')damaged.patches[0].value='tampered';else if(kind==='header')damaged.header.formatVersion=2;else if(kind==='base')damaged.baseChecksum='0'.repeat(64);else damaged.journalVersion=99;await write(damaged);code=undefined;try{await storage.recover(leader);}catch(error){code=error.code;}assert(code===expected,`delta ${kind} corruption was not detected: ${code}`);await write(intact);}
// revision 3 触发完整 checkpoint,要求散列完整编码字节且旧 journal 被压实清空。
const checkpointHashes=[];crypto.subtle.digest=function(algorithm,bytes){checkpointHashes.push(bytes.byteLength);return digest.call(this,algorithm,bytes);};try{await storage.commit(leader,{image:image(3)});}finally{crypto.subtle.digest=digest;}assert(checkpointHashes.length===1&&checkpointHashes[0]===new TextEncoder().encode(JSON.stringify(image(3))).byteLength,'checkpoint did not hash the full image');assert((await storage.recover(leader)).rootRevision===3,'checkpoint after deltas failed');assert(await read(raw.transaction('journal').objectStore('journal').count())===0,'checkpoint did not compact prior deltas');
// 恢复结果的调用方修改不能改缓存基线;两个并发 commit 串行捕获,各自使用调用时镜像,晚改第五版不生效。
const detached=await storage.recover(leader);detached.payload.rows[0].text='caller mutation';const fourth=image(4),fifth=image(5),one=storage.commit(leader,{image:fourth}),two=storage.commit(leader,{image:fifth});fifth.payload.rows[0].text='late mutation';await Promise.all([one,two]);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(image(5)),'queued commits or recovered callers mutated the cached base');await storage.commit(leader,{image:image(5)});
// 相同修订需精确序列化一致;仅调换 payload 属性顺序也不能当作同切点幂等重试。
const reordered=image(5);reordered.payload={ledger:reordered.payload.ledger,rows:reordered.payload.rows};code=undefined;try{await storage.commit(leader,{image:reordered});}catch(error){code=error.code;}assert(code==='storage_revision_conflict','same-cut retry accepted different serialized property order');
// 额外 header 字段无法无损差量时使用完整 Blob 行,字段与属性顺序都应保留。
await storage.commit(leader,{image:image(6)});const extended={marker:'retained header',...image(7)};await storage.commit(leader,{image:extended});assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(extended),'noncanonical header lost fields or property order');const fullRow=await read(raw.transaction('journal').objectStore('journal').get([key,7]));assert(fullRow.checkpointVersion===1&&fullRow.bytes instanceof Blob,'noncanonical header was encoded as a lossy delta');
// 手工删掉 durable head 后不能凭内存缓存继续写;失败不推进基线,还原 metadata 后仍恢复 revision 7。
const metadata=await read(raw.transaction('metadata').objectStore('metadata').get(key));let metadataTx=raw.transaction('metadata','readwrite'),metadataDone=done(metadataTx);const missingHead={...metadata};delete missingHead.throughRevision;metadataTx.objectStore('metadata').put(missingHead,key);await metadataDone;code=undefined;try{await storage.commit(leader,{image:image(8)});}catch(error){code=error.code;}assert(code==='recovery_delta_base_mismatch','cached baseline overwrote missing durable head');metadataTx=raw.transaction('metadata','readwrite');metadataDone=done(metadataTx);metadataTx.objectStore('metadata').put(metadata,key);await metadataDone;assert((await storage.recover(leader)).rootRevision===7,'head validation failure advanced cached state');
// 旧 DB schema 1 客户端必须得到 VersionError,不能用旧解释器读取新差量编码。
const oldError=await new Promise(resolve=>{const request=indexedDB.open(databaseName,1);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(oldError==='VersionError','old database reader accepted the delta format');
}finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(databaseName);}
}
// 源 Blob 持久化被阻挡时,渐进导入只可推进有界本地前缀,网络保持零发送;解锁后完成并经 IndexedDB 重开验证全部进度。
async function importBackpressureCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-import-pressure`,storage=createBrowserStorage({name:database}),original=createDocument(root,[{id:root,parent:null}],[]);let document=original,versions=new Map([[root,1]]),engine,calls=0,release;const gate=new Promise(resolve=>{release=resolve;});
// 内存服务仍检查每个参与版本,calls 作为网络发送屏障的观测点。
const transport=async request=>{calls++;const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'pressure lost a version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);const participants=Object.keys(body.expected_versions);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(row=>row.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()],elements:[...document.elements.values()],tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}}};};
// 只在 commit 批次含文件时等待 gate,刻意阻挡源文件落盘,而不是暂停所有主线程工作。
const options={scope:{accountId:crypto.randomUUID(),workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage:{...storage,async commit(owner,batch){if(batch.files?.length)await gate;return storage.commit(owner,batch);}},persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
// 500 条记录用于触及未持久 Root 预算;初始引擎状态先 persist,排除初始化脏状态干扰。
engine=await openProjectEngine(options);await engine.persist();const id=engine.imports.startNdjson(new Blob([Array.from({length:500},(_,index)=>JSON.stringify({kind:'element',key:`n-${index}`,record:{id:`n-${index}`,incarnation:1,componentId:'text',dataVersion:1,structure:'node',ownerCanvasId:'$target',data:{title:`n-${index}`,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}}})).join('\n')]),root);
// 等 rootRevision 与 durableRootRevision 差至少 32,5 秒内没触及预算则失败;订阅和超时都在完成时清理。
await new Promise((resolve,reject)=>{const stop=engine.editor.subscribe((_,snapshot)=>{if(snapshot.rootRevision-engine.getDurability().durableRootRevision>=32){clearTimeout(timer);stop();resolve();}}),timer=setTimeout(()=>{stop();reject(Error('browser import did not reach its durability budget'));},5000);});
// 前缀必须多于一项但未到 500,20ms 后数量仍不变且网络为零;gate 放开后才完成并重开核对全部来源 key。
const count=engine.imports.getJobs().get(id).committedSourceKeys.length;assert(count>1&&count<500&&calls===0,'source durability did not bound speculative progress or fence the network');await new Promise(resolve=>setTimeout(resolve,20));assert(engine.imports.getJobs().get(id).committedSourceKeys.length===count,'backpressure did not stop the producer');release();await engine.settle();assert(document.elements.size===500&&engine.imports.getJobs().get(id).status==='completed','pressure release did not complete all records');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.size===500&&engine.imports.getJobs().get(id).committedSourceKeys.length===500,'IndexedDB lost the completed prefix');
}finally{release();await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
// frame 端口引用子元素而子 canvas 又以 frame 为入口,形成必须一组提交的引用闭包;导入与深复制都应重映射,不连接原对象。
async function referenceCycleCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-reference-cycle`,storage=createBrowserStorage({name:database}),original=createDocument(root,[{id:root,parent:null}],[]);
let document=original,versions=new Map([[root,1]]),engine;
// 协议替身按参与版本应用整个批次并回执移除 canvas,真实 IndexedDB 保存本地事务/历史。
const transport=async request=>{const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'cycle lost a version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);const participants=Object.keys(body.expected_versions);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(row=>row.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(row=>participants.includes(row.id)),elements:[...document.elements.values()].filter(row=>participants.includes(row.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}}};};
const options={scope:{accountId:crypto.randomUUID(),workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
// frame 的 ports.nodeId 指向来源 A,A.owner 指向来源 S,S.parent 又指向 F;来源顺序不能替代依赖规划。
const node=(id,owner,componentId='text')=>({kind:'element',key:id,record:{id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:'',...(componentId==='frame'?{ports:[{id:'in',direction:'input',nodeId:'A',handle:'left'}]}:{})},geometry:{x:0,y:0,width:200,height:100,rotation:0}}});
try{
// 先导入 F/S/A 并核对端口指向新 A,再复制 F 的深闭包,要求 F′ 端口指向 A′。
engine=await openProjectEngine(options);const imported=engine.imports.startNdjson(new Blob([[node('F','$target','frame'),{kind:'canvas',key:'S',record:{id:'S',parent:{canvasId:'$target',entryElementId:'F'}}},node('A','S')].map(row=>JSON.stringify(row)).join('\n')]),root);await engine.settle();const first=engine.imports.getJobs().get(imported),F=first.stableIds['element:F'],A=first.stableIds['element:A'];assert(first.status==='completed'&&first.committedSourceKeys.length===3,'native cycle did not complete atomically');assert(document.elements.get(F).data.ports[0].nodeId===A,'native port kept the source ID');
const copied=await engine.imports.duplicate([{id:F,incarnation:1}]);await engine.settle();const job=engine.imports.getJobs().get(copied),F2=job.stableIds[`element:${F}`],A2=job.stableIds[`element:${A}`];assert(job.status==='completed'&&job.committedSourceKeys.length===3,'copy cycle lost a member');assert(document.elements.get(F2).data.ports[0].nodeId===A2&&A2!==A,'copy port attached to the original child');
// 重开保持重映射;undo 不留下半组,redo 后子元素是第二代且端口仍连接同一新 ID。
await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get(F2).data.ports[0].nodeId===A2,'IndexedDB lost the copied reference');assert(engine.editor.history.undo().status==='applied','cycle undo failed');await engine.settle();assert(!document.elements.has(F2)&&!document.elements.has(A2)&&document.elements.has(F),'cycle undo exposed half a group');assert(engine.editor.history.redo().status==='applied','cycle redo failed');await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get(F2).data.ports[0].nodeId===A2&&engine.getSnapshot().document.elements.get(A2).incarnation===2,'cycle redo/reopen lost the restored identity');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
// 深复制在准备和已加入前缀两个阶段都可暂停/重开;稳定映射、用户编辑、共享资源和上级 C→F′ 连线都必须保留。
async function duplicateRecoveryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-duplicate`,storage=createBrowserStorage({name:database});
// 图片源带共享 assetId 和 runId;复制应复用资源但省略执行元数据,不生成新任务。
const node=(id,owner=root,componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'',...(componentId==='image'?{assetId:'shared',src:'asset:shared',runId:'do-not-run'}:{})},geometry:{x:0,y:0,width:200,height:100,rotation:0}});
// 原 C→F 连线属于根 canvas;复制 F 与其子 A 不应删除原线,而应新增 C→F′。
const edge={id:'incoming',incarnation:1,structure:'connection',ownerCanvasId:root,componentId:'connection',dataVersion:1,source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}},original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[node('C'),node('F',root,'frame'),node('A',child,'image'),edge]);
let document=original,versions=new Map([[root,1],[child,1]]),engine;
const model=participants=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
// 协议替身按当前参与版本应用复制增量;每个 ACK 只返回参与 canvas 的完整成员。
const transport=async request=>{const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'copy lost expected version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(Object.keys(body.expected_versions))}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
let queryMismatch=false,queryPublications=0;
// 每次重开安装发布观察者,检查 queries.document/revision 与当次业务快照一致,避免复制暴露错位索引。
const open=async()=>{const current=await openProjectEngine(options);current.editor.subscribe((_,snapshot)=>{queryPublications++;try{const queries=current.editor.getQueries(snapshot.document);if(queries.document!==snapshot.document||queries.revision!==snapshot.revision)queryMismatch=true;}catch{queryMismatch=true;}});return current;};
try{
engine=await open();const id=await engine.imports.duplicate([{id:'F',incarnation:1},{id:'A',incarnation:1}]);
// 准备来源仍为 duplicate-snapshot 时暂停,重开后选择集合与暂停状态应保留,再继续规划。
await engine.imports.pause(id);await engine.settle();assert(engine.imports.getJobs().get(id).source.kind==='duplicate-snapshot','copy did not expose its preparing source');await engine.close();engine=await open();assert(engine.imports.getJobs().get(id).status==='paused'&&engine.imports.getJobs().get(id).preparation.selection.length===2,'preparing copy source/selection did not survive IndexedDB');await engine.imports.resume(id);
// 等首个来源提交再暂停,这次保存的是执行前缀;随后用户修改复制出来的 frame 标题。
await new Promise(resolve=>{if(engine.imports.getJobs().get(id).committedSourceKeys.length){resolve();return;}const release=engine.editor.subscribe((_,snapshot)=>{if(snapshot.runtime.imports.get(id).committedSourceKeys.length===1){release();resolve();}});});await engine.imports.pause(id);await engine.settle();
const before=engine.imports.getJobs().get(id),copied=before.stableIds['element:F'];assert(before.status==='paused','copy pause lost');engine.editor.dispatch({type:'element.data',target:{id:copied,incarnation:1},patch:{title:'edited copy'}});await engine.settle();await engine.close();engine=await open();
// 重开为 duplicate-plan 且 stableIds 不变,继续时只补尾部,不能重新创建前缀覆盖用户编辑。
const restored=engine.imports.getJobs().get(id);assert(restored.source.kind==='duplicate-plan'&&JSON.stringify(restored.stableIds)===JSON.stringify(before.stableIds),'copy source or mapping changed on reopen');await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id),image=document.elements.get(job.stableIds['element:A']),incoming=document.elements.get(job.stableIds['element:incoming']);
assert(job.status==='completed'&&job.failures.length===0,'copy did not complete after reopening');assert(document.elements.get(copied).data.title==='edited copy','copy replay overwrote an edit');assert(image.data.assetId==='shared'&&!Object.hasOwn(image.data,'runId'),'copy reissued image execution');assert(incoming.source.elementId==='C'&&incoming.target.elementId===copied&&incoming.ownerCanvasId===root,'copy lost upper incoming source');assert(document.elements.has('incoming')&&document.canvases.has(job.stableIds[`canvas:${child}`]),'copy changed originals or lost its child canvas');const queries=engine.editor.getQueries();assert(queries.members(job.stableIds[`canvas:${child}`]).includes(job.stableIds['element:A']),'recovered query lost copied member');assert(queries.relations.incidentEdges.get('C').has(job.stableIds['element:incoming']),'query lost upper incoming edge');
// 终态作业被 undo 后重开不能自动复活被撤销对象,查询中的子 canvas 绑定也应移除。
assert(engine.editor.history.undo().status==='applied','copy undo failed');await engine.settle();const count=document.elements.size;await engine.close();engine=await open();await engine.settle();assert(document.elements.size===count&&!document.elements.has(job.stableIds['element:A']),'terminal copy revived undone records');assert(!engine.editor.getQueries().relations.childCanvasByEntry.has(copied),'undo retained a stale child binding');assert(!queryMismatch&&queryPublications>0,'publication exposed a document/index revision mismatch');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
// 500 个兄弟元素的 DFS 规划游标要真正持久化,而不是每次恢复重新从选择/成员扫描;准备阶段不应提前发布内容。
async function duplicatePlannerRecoveryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-duplicate-planner`,storage=createBrowserStorage({name:database});
const node=(id,owner,componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}});
// frame 下 500 个成员,加上被复制 canvas,共应产生 502 个来源记录与稳定映射。
const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[node('F',root,'frame'),...Array.from({length:500},(_,i)=>node(`member-${i}`,child))]);
let document=original,versions=new Map([[root,1],[child,1]]),engine;
// 内存服务核对参与版本,返回局部规范模型;大集合遍历及 checkpoint 使用真实引擎和存储。
const transport=async request=>{const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'planner recovery lost expected version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);const participants=Object.keys(body.expected_versions);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);
// 最多等 30 秒进入有 work 的 DFS 规划状态;命中后取消订阅和截止定时器。
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>{release();reject(new Error('no durable DFS checkpoint'));},30000);const release=engine.editor.subscribe((_,snapshot)=>{const planner=snapshot.runtime.imports.get(id)?.preparation?.planner;if(planner?.phase==='dfs'&&planner.work.length){clearTimeout(timer);release();resolve();}});});
// pause 后持久化准备 v4、DFS 栈、sourceToken/sha256,且 committedSourceKeys 仍为零。
await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id),saved=JSON.stringify(before.preparation.planner);assert(before.preparation.version===4&&before.preparation.planner.phase==='dfs','did not pause within DFS');assert(before.preparation.planner.sourceToken===before.source.sha256,'checkpoint lost source binding');assert(before.committedSourceKeys.length===0,'preparation published content prematurely');await engine.close();engine=await openProjectEngine(options);
// 重开比较保存游标字节;恢复观察者要求 steps 不倒退,也不能重进 selection/canvases/members 等前期阶段。
assert(JSON.stringify(engine.imports.getJobs().get(id).preparation.planner)===saved,'IndexedDB changed the DFS checkpoint');let resumed=false;const release=engine.editor.subscribe((_,snapshot)=>{const planner=snapshot.runtime.imports.get(id)?.preparation?.planner;if(planner){assert(planner.steps>=before.preparation.steps,'resume regressed the cursor');assert(!['selection','canvases','members','roots','seed'].includes(planner.phase),'resume repeated an earlier preparation phase');resumed=true;}});
// 全部 502 项只分配一次,文档/画布计数和每个兄弟 owner 都核对,避免仅用 completed 状态宣称无重复。
await engine.imports.resume(id);await engine.settle();release();const job=engine.imports.getJobs().get(id);assert(resumed&&job.status==='completed'&&job.failures.length===0,'DFS recovery did not complete');assert(job.committedSourceKeys.length===502&&Object.keys(job.stableIds).length===502,'DFS recovery lost a source record');assert(document.elements.size===1002&&document.canvases.size===3,'DFS recovery duplicated or lost content');const copiedChild=job.stableIds[`canvas:${child}`];for(let i=0;i<500;i++)assert(document.elements.get(job.stableIds[`element:member-${i}`])?.ownerCanvasId===copiedChild,'DFS sibling lost its copied owner');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
// 原生 NDJSON 导入的 Blob、游标、稳定 ID 和失败报告跨重开保留;用户改过前缀后,继续导入与终态重开都不能覆盖或复活它。
async function nativeImportRecoveryCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),original=createDocument(root,[{id:root,parent:null}],[]),database=`${name}-native-import`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1]]),engine;
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
// 逐参与版本提交增量,返回局部模型;真实服务写入/授权并非本场景验证范围。
const transport=async request=>{calls.push(request);const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'import lost the version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
// 来源含旧 incarnation=5、任务字段、提前出现的连线和晚到的 child;导入用新身份并清除执行元数据,非法尾行只记录失败。
const node=id=>({kind:'element',key:id,record:{id,incarnation:5,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:id,text:'',taskId:'not copied'},geometry:{x:0,y:0,width:200,height:100,rotation:0}}}),edge={kind:'element',key:'edge',record:{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'$target',source:{elementId:'first'},target:{elementId:'second'},path:null,data:{}}},text=[{...node('first'),record:{...node('first').record,componentId:'frame'}},edge,{...node('second'),record:{...node('second').record,ownerCanvasId:'child'}},{kind:'canvas',key:'child',record:{id:'child',parent:{canvasId:'$target',entryElementId:'first'}}}].map(value=>JSON.stringify(value)).join('\n')+'\n{bad}';
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);const id=engine.imports.startNdjson(new Blob([text]),root);
// 首项加入后暂停,记录其映射与源 fileId;用户在已加入前缀改标题再重开。
await new Promise(resolve=>{const release=engine.editor.subscribe((_,snapshot)=>{if(snapshot.runtime.imports.get(id).committedSourceKeys.length===1){release();resolve();}});});await engine.imports.pause(id);await engine.settle();
const first=engine.imports.getJobs().get(id).stableIds['element:first'],fileId=engine.imports.getJobs().get(id).source.fileId;engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'user edit'}});await engine.settle();await engine.close();engine=await openProjectEngine(options);
// 恢复暂停后 resume,应得到四项已提交和一个失败,ID/用户标题不变,也不创建运行任务。
assert(engine.imports.getJobs().get(id).status==='paused','paused import did not survive IndexedDB');await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);
assert(job.status==='partial'&&job.committedSourceKeys.length===4&&job.failures.length===1,'partial native import lost progress or failure report');assert(job.stableIds['element:first']===first&&document.elements.get(first).data.title==='user edit','import reallocated an identity or overwrote user content');assert(document.canvases.size===2&&document.elements.size===3&&engine.getSnapshot().runtime.runs.size===0,'native import duplicated an object or launched a run');assert(!Object.hasOwn(document.elements.get(first).data,'taskId'),'native import retained execution metadata');
// 关闭 engine 后独占读取持久源 Blob,必须精确等于原 NDJSON 文本,再重开验证历史。
const leaderRead=await (async()=>{await engine.close();const leader=await storage.claim(options.scope);try{return await storage.readFile(leader,fileId);}finally{await leader.release();}})();assert(await leaderRead.text()===text,'original source bytes were not durably stored');engine=await openProjectEngine(options);
// undo 只移除后续导入批次,保留中断期间用户编辑过的第一项;终态作业重开后不能补回已撤销项。
assert(engine.editor.history.undo().status==='applied','import batch undo failed');await engine.settle();assert(document.elements.size===1&&document.elements.has(first),'undo did not preserve the interrupted user edit');await engine.close();engine=await openProjectEngine(options);await engine.settle();assert(document.elements.size===1&&engine.getSnapshot().document.elements.size===1,'terminal job revived records removed by undo');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
// 完全未发送的节点或整分区创建→删除可本地抵消,但历史、墓碑高水位和本地证明仍需持久化;不能伪造服务成功。
async function elisionRecoveryCase(name,assert){
// 分别覆盖普通节点与 frame/canvas/member 整体生命期。
for(const structural of [false,true]){
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:200,height:100,rotation:0}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),database=`${name}-elision-${structural}`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1]]),engine;
const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))});
// 后续真实需要发送的恢复/编辑仍按当前服务版本校验;服务基线不包含本地抵消产生的墓碑。
const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'elision restore lost the actual server version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};};
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
// 复用同一 IndexedDB 和服务状态重开,不复用旧 engine 的交互会话。
const reopen=async()=>{await engine.close();engine=await openProjectEngine(options);};
try{
engine=await openProjectEngine(options);const created=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:structural?{...node('temporary'),componentId:'frame'}:node('temporary')});if(structural){tx.dispatch({type:'canvas.create',canvas:{id:child,parent:{canvasId:root,entryElementId:'temporary'}}});tx.dispatch({type:'element.create',element:node('member',child)});}}),removed=engine.editor.dispatch({type:'element.remove',target:{id:'temporary',incarnation:1}});await engine.settle();
// 创建和删除保留两个历史单元,但请求数为零;账本以 elided 结案而非 applied。
const ids=[...created.operationIds,...removed.operationIds];assert(ids.length===2&&calls.length===0,'closed unsent lifetime still sent a request');assert(engine.getSnapshot().runtime.ledger.requests.size===0&&engine.getSnapshot().history.undo.length===2,'local elision forged a request or collapsed history');
// 重开保留本地证明和第一代墓碑,同时 ACK 基线没有该墓碑,不能混淆本地与服务高水位。
await reopen();for(const id of ids)assert(engine.getSnapshot().runtime.ledger.operations.get(id).resolution==='elided','local completion proof did not survive IndexedDB');assert(engine.getSnapshot().document.tombstones.get('temporary')===1&&!engine.getSnapshot().runtime.acknowledged.tombstones.has('temporary'),'local lifecycle contaminated the server baseline');
// 未发送 undo/redo 仍可再次抵消,生成第二代本地墓碑但不发请求。
const localUndo=engine.editor.history.undo(),localRedo=engine.editor.history.redo();assert(localUndo.status==='applied'&&localRedo.status==='applied','local traversal pair failed');ids.push(...localUndo.operationIds,...localRedo.operationIds);await engine.settle();await reopen();assert(calls.length===0&&engine.getSnapshot().document.tombstones.get('temporary')===2,'repeated local elision lost its next incarnation');
// 无关 ACK 与完整规范刷新后,本地第二代高水位仍必须保留。
engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'unrelated ACK'}});await engine.settle();const session=await engine.prepareRecovery();assert((await session.apply()).status==='applied','canonical refresh after elision failed');await engine.settle();await reopen();assert(engine.getSnapshot().document.tombstones.get('temporary')===2,'ACK or fresh canonical read lost the local high-water mark');
// 先撤销无关编辑再撤销抵消删除,真正发送的新恢复为第三代;结构分支同时恢复成员及子 canvas。
assert(engine.editor.history.undo().status==='applied','unrelated undo failed');await engine.settle();assert(engine.editor.history.undo().status==='applied','elided deletion undo failed');await engine.settle();assert(document.elements.get('temporary').incarnation===3,'undo reused the canceled incarnation');if(structural)assert(document.canvases.has(child)&&document.elements.get('member').incarnation===3,'partition undo lost its members or lifecycle');
// redo 归档第三代,再重开核对墓碑;此前所有 elided 逻辑 ID 都不能混入后端请求。
assert(engine.editor.history.redo().status==='applied','elided deletion redo failed');await engine.settle();assert(!document.elements.has('temporary')&&document.tombstones.get('temporary')===3,'redo did not archive the fresh life');await reopen();assert(engine.getSnapshot().document.tombstones.get('temporary')===3,'server/local lifecycle merge regressed after reopening');assert(calls.filter(call=>call.method==='POST').length===4,'elision history was retransmitted');for(const request of engine.getSnapshot().runtime.ledger.requests.values())assert(!request.operationIds.some(id=>ids.includes(id)),'locally elided IDs appeared in a backend request');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
}
// 一次有序请求可覆盖两个逻辑编辑并压缩字段 wire,但每个编辑仍独立进入 undo;丢 ACK 后恢复查询不能合并历史或更改冻结体。
async function batchRecoveryCase(name,assert){
const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node={id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:'a',text:''}},document=createDocument(root,[{id:root,parent:null}],[node]);
// 长中间字符串使 wire 压缩效果可测,不能只看批次数便断言投影确实减少变化。
const firstTitle='first '.repeat(100),secondTitle='second '.repeat(100);
const database=`${name}-batch-recovery`,storage=createBrowserStorage({name:database}),calls=[],receipts=new Map();let server=document,version=1,lose=true,engine;
// 按顺序应用操作,但一次批次只推进一次 canvas 版本;回执含全部逻辑 ID,首次响应故意丢失。
const transport=async request=>{
calls.push(request);
if(request.method==='GET')return{status:200,body:receipts.get(request.path.split('/').at(-1))};
@@ -744,24 +859,30 @@ async function batchRecoveryCase(name,assert){
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
try{
engine=await openProjectEngine(options);
// 两个命令同一窗口提交,第二个标题立即可见,不等 prepare 或网络批次。
const first=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:firstTitle}}),second=engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:secondTitle}});
assert(engine.getSnapshot().document.elements.get('a').data.title===secondTitle,'local publication waited for batch preparation');await engine.settle();
const frozen=[...engine.getSnapshot().runtime.ledger.requests.values()][0];
// 核对逻辑 ID 顺序、一次版本递增及实际 wire 长度;原始 before 和最终 after 必须保留。
assert(calls.length===1&&frozen.operationIds.length===2&&frozen.operationIds[0]===first.operationIds[0]&&frozen.operationIds[1]===second.operationIds[0],'logical edits were not covered by one ordered request');assert(version===2,'batch advanced a canvas version per operation');const wire=JSON.parse(frozen.body).operations,raw=frozen.operationIds.map(id=>({id,changes:engine.getSnapshot().runtime.ledger.operations.get(id).intent.changes}));assert(JSON.stringify(wire).length<JSON.stringify(raw).length,'eligible long field batch was not compacted');assert(wire.at(-1).changes[0].before==='a'&&wire.at(-1).changes[0].after===secondTitle,'wire projection did not retain original before and final after');
// 重开后只能查询原请求,body 和两个历史单元都保留,再分别核对两个 undo 的中间值。
await engine.close();engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}});await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
assert(calls.length===2&&calls[1].method==='GET'&&calls[1].path.endsWith(frozen.id),'batch recovery sent a new request');
assert(engine.getSnapshot().runtime.ledger.requests.get(frozen.id).body===frozen.body&&engine.getSnapshot().history.undo.length===2,'batch changed frozen bytes or collapsed history units');
for(const id of frozen.operationIds)assert(engine.getSnapshot().runtime.ledger.operations.get(id).phase==='resolved','batch ACK omitted a logical operation');
assert(engine.editor.history.undo().status==='applied','second edit undo failed');await engine.settle();assert(server.elements.get('a').data.title===firstTitle,'batch undo lost the intermediate historical value');
assert(engine.editor.history.undo().status==='applied','first edit undo failed');await engine.settle();assert(server.elements.get('a').data.title==='a','batch first inverse failed');
// redo 第一项只能回到 firstTitle,不能因传输合批跳过独立历史状态。
assert(engine.editor.history.redo().status==='applied','first edit redo failed');await engine.settle();assert(server.elements.get('a').data.title===firstTitle,'batch redo skipped an independent history unit');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
// 真实 Foundry v2 适配器连内存 transport,首次写入保存成功回执后返回超时;IndexedDB 恢复保留协议元数据、原体和原查询 key。
async function protocolRecoveryCase(name,assert){
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000003',account='10000000-0000-4000-8000-000000000004';
const registry=createRegistry(builtinComponents()),document=createDocument(root,[{id:root,parent:null}],[{id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:260,height:200,rotation:0},data:{title:'a',text:''}}]);
const calls=[],receipts=new Map();let server=document;
// POST 使用增量 operations;GET 按 URL 的原 requestId 查询,服务成功模型版本为 2。
const transport=async input=>{
calls.push(input);
if(input.method==='POST'){
@@ -775,7 +896,9 @@ async function protocolRecoveryCase(name,assert){
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document,versions:new Map([[root,1]]),storage,persistence};let engine;
try{
engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'durable-v2'}});await engine.settle();
// 冻结请求 protocolVersion 必须为 2,不得在序列化/恢复时默认为旧格式。
const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert(request.protocolVersion===2,'new protocol metadata was replaced by a legacy default');await engine.close();
// 前移时钟后仅查询一次;最终标题、单个 undo 单元和 applied 回执不因恢复重复出现。
engine=await openProjectEngine({...options,clock:{now:()=>Date.now()+100000,setTimer:(callback,delay)=>setTimeout(callback,delay),clearTimer:handle=>clearTimeout(handle)}});
await new Promise(resolve=>setTimeout(resolve,10));await engine.settle();
assert(calls.length===2&&calls[1].method==='GET','protocol 2 recovery must query the original request');
@@ -784,10 +907,12 @@ async function protocolRecoveryCase(name,assert){
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-protocol`)}
}
// 真实浏览器存储接最小 persistence 端口:detach 不终止任务,engine close/open 恢复同一未知请求、历史和操作事实,views 不持久化。
async function engineRecoveryCase(name, assert) {
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' }, registry = createRegistry(builtinComponents());
const document = createDocument('project', [{ id: 'canvas', parent: null }], [{ id: 'element', incarnation: 1, ownerCanvasId: 'canvas', componentId: 'text', dataVersion: 1, structure: 'node', geometry: { x: 0, y: 0, width: 100, height: 100, rotation: 0 }, data: { title: 'initial', text: '' } }]);
let server = document; const receipts = new Map(), calls = [];
// 服务替身先执行 submit 却返回 unresolved,query 才确认成功,canonical 从已保存回执取规范基线。
const port = {
compile(intent, requestId, snapshot) { return { payload: { intent, requestId }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(snapshot.runtime.versions) }; },
async submit(request) { calls.push(['submit', request]); const intent = JSON.parse(request.body).intent; server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['canvas', 2]]) }); return { outcome: 'unresolved' }; },
@@ -798,69 +923,98 @@ async function engineRecoveryCase(name, assert) {
let engine;
try {
engine = await openProjectEngine(options);
// 附加视图后编辑再 detach,后台仍应发送一次;视图不拥有引擎任务生命周期。
const detach = engine.sessions.attach('view');
engine.editor.dispatch({ type: 'element.data', target: { id: 'element', incarnation: 1 }, patch: { title: 'durable edit' } });
detach(); await engine.settle();
assert(calls.length === 1, 'detaching last view must not abort persistent work');
const request = calls[0][1];
await engine.close();
// 前移恢复墙上时钟以触发重试查询,实际 timer 仍由浏览器提供。
const futureClock = { now: () => Date.now() + 100_000, setTimer: (callback, delay) => setTimeout(callback, delay), clearTimer: handle => clearTimeout(handle) };
engine = await openProjectEngine({ ...options, clock: futureClock });
await new Promise(resolve => setTimeout(resolve, 10)); await engine.settle();
// 重开先 query 同一 ID/body,不再次 submit;pending 不得重放两遍,history/operations 都各一项。
assert(calls.length === 2 && calls[1][0] === 'query', 'recovered dispatched request must query before a new submit');
assert(calls[1][1].id === request.id && calls[1][1].body === request.body, 'request ID/body must survive real IndexedDB recovery exactly');
assert(engine.getSnapshot().document.elements.get('element').data.title === 'durable edit', 'recovered pending changes must not replay twice');
assert(engine.getSnapshot().history.undo.length === 1 && engine.getSnapshot().operations.size === 1, 'history and ledger must recover the same state cut');
// 新引擎没有旧 transient view;回执协调完成后 Root 修订必须已越过本地持久化屏障。
assert(engine.sessions.getSnapshot().views.size === 0, 'transient views must not be recovered as document data');
assert(engine.getDurability().rootRevision === engine.getDurability().durableRootRevision, 'final receipt and replay must cross the durable barrier');
} finally { await engine?.close(); storage.close(); indexedDB.deleteDatabase(`${name}-engine`); }
}
// 部分加载但子 canvas 已完整认证时,可在子作用域导入;源 Blob 与暂停状态重开可恢复,不必等无关根成员尾部。
async function loadingImportRecoveryCase(name,assert){
// loading v5 含来源树、owner manifest 和计数:根缺 A,子 C 已完整,原始完整模型保存在 fixture 服务。
const root=crypto.randomUUID(),child=crypto.randomUUID(),scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:root},registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}}),frame={...node('F'),componentId:'frame'},canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],full=createDocument(root,canvases,[frame,node('A'),node('C',child)]),prefix=createDocument(root,canvases,[frame,node('C',child)]),loading={version:5,sourceToken:'browser-import-cut',declaredCanvasIds:[root,child],sourceCanvases:canvases,receivedElements:2,totalElements:3,canvasElementCounts:{[root]:2,[child]:1},elementOwners:{F:root,A:root,C:child}},database=name+'-loading-import',storage=createBrowserStorage({name:database}),versions=new Map([[root,1],[child,1]]);let server=full,engine;const calls=[];
// 仅允许导入条件 POST,按参与 canvas 回执;不能为一次子导入强行读取/替换完整项目。
const persistence=createFoundryPersistence({registry,workspaceId:scope.workspaceId,rootId:root,transport:async input=>{assert(input.method==='POST','unexpected import read');calls.push(input);const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert(body.expected_versions[id]===versions.get(id),'stale partial import version');server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));for(const id of participants)versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,canvases:[...server.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...server.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)])),removedCanvasIds:[]}}};}});
const options={scope,registry,storage,persistence,document:prefix,versions:new Map(versions),loading};
// 根 canStart 拒绝、子允许;pause/reopen/resume 后导入完成但 loading 仍 v5,稳定 ID 再重开不变,undo 仅另发一次删除。
try{engine=await openProjectEngine(options);assert(!engine.imports.canStart(root).enabled&&engine.imports.canStart(child).enabled,'import admission ignored coverage');const id=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'imported',record:node('imported','$target')})+'\n']),child);await engine.imports.pause(id);await engine.settle();await engine.close();engine=await openProjectEngine(options);assert(engine.imports.getJobs().get(id).status==='paused','paused v11 task did not reopen');await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id),mapped=job.stableIds['element:imported'];assert(job.status==='completed'&&server.elements.has(mapped),'recovered source bytes did not import before tail');assert(engine.loading.getState().version===5,'import replaced the partial read');await engine.close();engine=await openProjectEngine(options);assert(engine.imports.getJobs().get(id).stableIds['element:imported']===mapped,'reopening reallocated imported identities');assert(engine.editor.history.undo().status==='applied','partial import history did not reopen');await engine.settle();assert(!server.elements.has(mapped),'partial import undo left a server object');assert(calls.length===2,'import recovery submitted duplicate operations');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
// loading v6 允许已认证子 canvas 暂存图片,上传与本地预览先行;丢 ACK 恢复查询后才放行文档,旧来源完成不能覆盖新生命期。
async function loadingAssetRecoveryCase(name,assert){
const root=crypto.randomUUID(),child=crypto.randomUUID(),scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:root},registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}}),frame={...node('F'),componentId:'frame'},canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],full=createDocument(root,canvases,[frame,node('A'),node('C',child)]),prefix=createDocument(root,canvases,[frame,node('C',child)]),loading={version:6,sourceToken:'browser-import-cut',declaredCanvasIds:[root,child],sourceCanvases:canvases,receivedElements:2,totalElements:3,canvasElementCounts:{[root]:2,[child]:1},elementOwners:{F:root,A:root,C:child}},database=name+'-loading-asset',storage=createBrowserStorage({name:database}),versions=new Map([[root,1],[child,1]]);let server=full,engine;const calls=[];
// 文档服务只接参与 owner 的增量,父 canvas 仍未完成,不能让图片创建突破局部覆盖约束。
const persistence=createFoundryPersistence({registry,workspaceId:scope.workspaceId,rootId:root,transport:async input=>{assert(input.method==='POST','unexpected import read');calls.push(input);const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert(body.expected_versions[id]===versions.get(id),'stale partial import version');server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));for(const id of participants)versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,canvases:[...server.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...server.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)])),removedCanvasIds:[]}}};}});
// 资产替身先保存元数据再抛丢 ACK;query 返回同一结果,假时钟只记录 now、不自动执行 timer。
let time=0;const receipts=new Map(),uploads=[],assets={sourceURL:id=>`https://assets.example/${id}`,displayURL:id=>`/assets/${id}`,async upload(request,bytes){uploads.push(request);const body=JSON.parse(request.body),stored={id:body.resource_id,url:assets.sourceURL(body.resource_id),bytes:bytes.size,width:20,height:10,mime_type:bytes.type};receipts.set(request.id,stored);throw Error('lost image ACK');},async query(request){return{outcome:'applied',canonical:receipts.get(request.id)};},accept:(_request,status)=>status.canonical},clock={now:()=>time,setTimer:()=>0,clearTimer(){}};
const options={scope,registry,storage,persistence,assets,clock,document:prefix,versions:new Map(versions),loading};
// 父禁入/子可入;图片立即本地可见但文档等待上传,重开后不重复上传,undo/redo 使用第二代,合并旧尾部仍保留资源与历史。
try{engine=await openProjectEngine(options);assert(!engine.canStageFiles(root).enabled&&engine.canStageFiles(child).enabled,'image admission ignored coverage');let resource;const result=engine.stageFiles([{name:'picture.png',bytes:new Blob(['durable image bytes'],{type:'image/png'})}],([ref],tx)=>{resource=ref;tx.dispatch({type:'element.create',element:{...node('picture',child),componentId:'image',data:{title:'Picture',text:'',assetId:ref.id,src:ref.src}}});});assert(result.status==='applied'&&engine.getSnapshot().document.elements.has('picture'),'image did not display locally');await engine.settle();assert(uploads.length===1&&calls.length===0,'image write bypassed upload dependency');const request=uploads[0],display=await engine.displayResource(resource.id);assert((await(await fetch(display.url)).blob()).size>0,'saved Blob could not display');display.release();await engine.close();time=10_000;engine=await openProjectEngine(options);await engine.settle();assert(uploads.length===1&&engine.getSnapshot().runtime.ledger.requests.get(request.id).body===request.body,'lost ACK changed the upload identity');assert(server.elements.get('picture').data.assetId===resource.id,'reopened upload did not release the image document');assert(engine.editor.history.undo().status==='applied','image undo failed');await engine.settle();assert(!server.elements.has('picture'),'image undo left a server element');assert(engine.editor.history.redo().status==='applied','image redo failed');await engine.settle();assert(server.elements.get('picture').incarnation===2&&uploads.length===1,'image redo uploaded again or reused the deleted incarnation');assert((await engine.loading.complete(full,new Map([[root,1],[child,1]]),{sourceToken:loading.sourceToken})).status==='applied','old tail could not complete with stored image');await engine.settle();assert(engine.getSnapshot().document.elements.get('picture').incarnation===2&&engine.getSnapshot().runtime.resources.get(resource.id).stored,'old tail lost resource/history');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);}
}
// 逐个覆盖 loading v1-v4 对应 recovery v7-v10;局部编辑/历史须保存,完成来源时强制完整 checkpoint,失败保持 partial 切点与发送屏障。
async function loadingRecoveryCase(name,assert,coverage=false,scoped=false,structural=false){
// 测试参数把能力描述与恢复包版本分开,同时给每条分支独立数据库名。
const format=structural?10:scoped?9:coverage?8:7;name+=structural?"-structural":scoped?"-scoped":coverage?"-coverage":"-legacy";
// 源包含 A/B、已载入前缀只有 A;可选 count、作用域认证和 owner manifest 逐层增加能力。
const root=crypto.randomUUID(),scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:root},registry=createRegistry(builtinComponents()),node=id=>({id,incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:120,height:80,rotation:0}}),a=node('A'),b=node('B'),canvases=[{id:root,parent:null}],prefix=createDocument(root,canvases,[a]),full=createDocument(root,canvases,[a,b]),versions=new Map([[root,1]]),loading={...(coverage?{version:structural?4:scoped?3:2,canvasElementCounts:{[root]:2},...(structural?{elementOwners:{A:root,B:root}}:{})}:{version:1}),sourceToken:'browser-captured-read',declaredCanvasIds:[root],receivedElements:1,totalElements:2};
// 包装 claim 捕获实际 owner,仅供故障后直接检查上一个已保存 partial 镜像。
const adapter=createBrowserStorage({name:`${name}-loading`,checkpointEvery:3});let ownerForTest;const storage={...adapter,async claim(scope){ownerForTest=await adapter.claim(scope);return ownerForTest;}};const calls=[],receipts=new Map();let server=full,version=1,engine;
const model=(complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases,elements:[...server.elements.values()],tombstones:{},versions:{[root]:version},removedCanvasIds:[]});
// 内存服务检查版本并回执;当前唯一 owner 尚不完整,因此编辑和历史遍历在来源完成前都不应冻结请求。
const transport=async input=>{if(input.method==='GET')return{status:200,body:{model:model()}};calls.push(input);const body=JSON.parse(input.body);assert(body.expected_versions[root]===version,'loading writer used a stale version');server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));version++;const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false)};receipts.set(input.requestId,receipt);return{status:200,body:receipt};};
const persistence=createFoundryPersistence({registry,workspaceId:scope.workspaceId,rootId:root,transport}),open=()=>openProjectEngine({scope,registry,document:prefix,versions,loading,storage,persistence,createId:()=>crypto.randomUUID()});
// 先改已加载 A,再重开验证对应 recovery 格式以及 undo/redo;加载 hold 期间请求数仍为零。
try{engine=await open();const target={id:'A',incarnation:1};assert(engine.editor.dispatch({type:'element.data',target,patch:{text:'local before tail'}}).status==='applied','loading field rejected');await engine.settle();assert(calls.length===0&&engine.getSnapshot().runtime.ledger.requests.size===0,'loading prefix froze or sent a request');await engine.close();
const owner=await storage.claim(scope),image=await storage.recover(owner);assert(image.formatVersion===format,'loading state did not require its versioned recovery envelope');decodeRecovery(image,registry);await owner.release();engine=await open();assert(engine.getSnapshot().document.elements.get('A').data.text==='local before tail','loading edit lost after IndexedDB reopen');assert(engine.editor.history.undo().status==='applied'&&engine.editor.history.redo().status==='applied','loading history did not reopen');await engine.settle();assert(calls.length===0,'history traversal escaped the loading hold');
// 保存已确认修订,包装原生 put,只在首次写入非 partial 格式 checkpoint 时排入写请求后抛错。
const before=engine.getDurability().durableRootRevision,nativePut=IDBObjectStore.prototype.put;let injected=false,failure;
IDBObjectStore.prototype.put=function(value,key){const request=nativePut.call(this,value,key);if(!injected&&this.name==='checkpoints'&&(value?.image?.formatVersion??value?.header?.formatVersion)!==format){injected=true;throw Error('loading_full_cut_abort');}return request;};
try{await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken});}catch(error){failure=error.message;}finally{IDBObjectStore.prototype.put=nativePut;}
// 完整切点失败后,从 adapter 读到的仍是原 revision/partial 格式,网络不得放行。
assert(injected&&failure==='loading_full_cut_abort','completion did not reach forced checkpoint rollback');const partial=await adapter.recover(ownerForTest);assert(partial.rootRevision===before&&partial.formatVersion===format&&calls.length===0,'aborted full checkpoint released loading writes or lost the partial cut');
// 重试完整切点的保存,持久化屏障完成才放行发送;最终 loading 清空且旧尾部不能覆盖本地编辑。
await engine.persist();await engine.settle();
assert(!engine.loading.getState()&&server.elements.get('A').data.text==='local before tail'&&engine.getSnapshot().document.elements.size===2,'full tail overwrote the local prefix');await engine.close();engine=await open();await engine.settle();assert(!engine.loading.getState()&&engine.getSnapshot().document.elements.size===2,'completed recovery reopened as partial');
// 直接读取所有 checkpoint 与 journal,要求完成后不再残留 format>=7 的 partial 依赖,不能仅检查内存 loading 已清空。
const database=await new Promise((resolve,reject)=>{const request=indexedDB.open(`${name}-loading`);request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
try{const transaction=database.transaction(['checkpoints','journal'],'readonly'),read=store=>new Promise((resolve,reject)=>{const request=transaction.objectStore(store).getAll();request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});const cuts=(await Promise.all([read('checkpoints'),read('journal')])).flat();assert(cuts.every(cut=>(cut.image?.formatVersion??cut.header?.formatVersion)<7),'completed storage still requires a partial journal base');}finally{database.close();}
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-loading`);}
}
// 完整认证的子作用域可先写,未完整父作用域等待;丢 ACK 恢复后读来源版本与最新 ACK 版本必须独立保留,旧尾部再唤醒父写。
async function scopedAckRecoveryCase(name,assert,structural=false){
// 分别覆盖 loading v3 字段编辑和 v4 新元素创建,使用不同数据库。
name+=structural?"-structural":"-fields";
// prefix 已含子 C、但根缺 B 与跨层 E;完整计数/可选 manifest 证明哪个 owner 真正可写。
const root=crypto.randomUUID(),child=crypto.randomUUID(),scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:root},registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:120,height:80,rotation:0}}),a=node('A'),b=node('B'),f={...node('F'),componentId:'frame'},c=node('C',child),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'C'},path:null},canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],prefix=createDocument(root,canvases,[a,f,c]),full=createDocument(root,canvases,[a,b,f,c,edge]),versions=new Map([[root,1],[child,1]]),loading={version:structural?4:3,...(structural?{elementOwners:{A:root,B:root,F:root,C:child,E:root}}:{}),sourceToken:'scoped-browser-source',declaredCanvasIds:[root,child],receivedElements:3,totalElements:5,canvasElementCounts:{[root]:4,[child]:1}},storage=createBrowserStorage({name:name+'-scope-ack'}),receipts=new Map(),bodies=[];
let server=full,currentVersions=new Map(versions),time=0,engine;
// 局部 ACK 只返回当前参与 owner,不能用它覆盖整份旧来源或另一个尚未认证 owner。
const model=(participants,complete=false)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...server.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...server.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:{},versions:Object.fromEntries(participants.map(id=>[id,currentVersions.get(id)])),removedCanvasIds:[]});
// 首个子写实际成功后抛丢 ACK,GET 查询原回执;每个后续 POST 都校验最新 owner 版本。
const transport=async input=>{if(input.method==='GET')return{status:200,body:receipts.get(input.path.split('/').at(-1))};const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);bodies.push(input.body);for(const id of participants)assert(body.expected_versions[id]===currentVersions.get(id),'scoped request used a stale owner version');server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));for(const id of participants)currentVersions.set(id,currentVersions.get(id)+1);const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(participants)};receipts.set(input.requestId,receipt);if(bodies.length===1)throw Error('lost scoped ACK');return{status:200,body:receipt};};
const persistence=createFoundryPersistence({registry,workspaceId:scope.workspaceId,rootId:root,transport}),clock={now:()=>time,setTimer:()=>0,clearTimer(){}},open=()=>openProjectEngine({scope,registry,document:prefix,versions,loading,storage,persistence,clock});
// 父 A 编辑等待,子修改或出生先发送;重开查询不增加 POST,child ACK 为 2 而 loadingRead 仍为捕获版本 1。
try{engine=await open();engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'root waits'}});engine.editor.dispatch(structural?{type:'element.create',element:{...node('born-browser',child),data:{title:'Born',text:'child applied'}}}:{type:'element.data',target:{id:'C',incarnation:1},patch:{text:'child applied'}});await engine.settle();assert(bodies.length===1&&server.elements.get(structural?'born-browser':'C')?.data.text==='child applied'&&server.elements.get('A').data.text==='original','incomplete owner blocked another certified owner or sent prematurely');const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];await engine.close();time=1000;engine=await open();await engine.settle();assert(bodies.length===1&&engine.getSnapshot().runtime.ledger.requests.get(request.id).body===request.body,'reopen repeated the lost-ACK request');assert(engine.getSnapshot().runtime.versions.get(child)===2&&engine.getSnapshot().runtime.loadingRead.versions.get(child)===1,'read and ACK baselines did not recover independently');
// 接收原版本完整尾部只补缺失来源,唤醒父编辑;complete 后移除 loadingRead,但两个 owner 的新结果都保留。
assert(engine.loading.accept(full,versions,{...loading,receivedElements:5}).status==='applied','old source tail did not reconcile');await engine.settle();assert(bodies.length===2&&server.elements.get('A').data.text==='root waits','newly complete owner did not wake');assert((await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken})).status==='applied','original source could not complete after scoped ACKs');await engine.settle();assert(engine.getSnapshot().runtime.loadingRead===undefined&&engine.getSnapshot().document.elements.get(structural?'born-browser':'C')?.data.text==='child applied'&&engine.getSnapshot().document.elements.get('A').data.text==='root waits','old completion replaced ACKs or retained the source');
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(name+'-scope-ack');}
}
+173 -12
View File
@@ -1,8 +1,14 @@
/**
* 复制实际 dashboard/GraphPage/headless renderer/Next proxy 到临时应用,连接本地 HTTP fixture,再用隔离 Chrome 操作真实页面。
* 引擎、ReactFlow、IndexedDB 和代理走应用实现,文档/AI/上传/授权响应由下面的内存服务提供,不是远程 Foundry 验证。
* 执行会启动 Next 编译/开发服务,性能生产模式还会 build;本文件不是轻量 node:test,也不会在纯注释检查时执行。
* 计数与测试入口仅写入临时副本,所有嵌入字符串都是测试输入;finally 释放屏障、连接、进程和临时目录。
*/
import {execFileSync} from 'node:child_process';
import {createCdpCalls} from './cdp-client.mjs';
import {measureRenderer} from './renderer-performance.mjs';
import {createRendererMigrationFixture,verifyRendererMigration} from './renderer-migration.mjs';
/** Actual dashboard, GraphPage, headless renderer, browser storage and Next proxy. */
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtemp, cp, copyFile, symlink, readFile, writeFile, rm, realpath } from 'node:fs/promises';
@@ -11,98 +17,137 @@ import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { setTimeout as pause } from 'node:timers/promises';
import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts';
// 默认 webpack,可显式选择 turbopack;两者在 fixture 放置路径与启动参数上采用不同约束。
const bundler=process.argv.includes('--turbopack')?'turbopack':'webpack';
// 生产模式只允许与性能入口组合,防止把功能测试默认为生产构建。
const productionMode=process.argv.includes('--production');if(productionMode&&!process.argv.includes('--performance'))throw Error('--production requires --performance');
// migration-only、hooks-only 和 performance 分支各走自己的验证入口;迁移分支禁止混合其他模式。
const migrationOnly=process.argv.includes('--migration-only');
const hooksOnly=process.argv.includes('--hooks-only'),performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000);
if(migrationOnly&&(hooksOnly||performanceMode))throw Error('migration-only requires its own functional entry');
// 性能规模必须是 1..10000 的安全整数;复制期间原生输入探针还要求至少 1000 节点。
if(performanceMode&&(!Number.isSafeInteger(performanceCount)||performanceCount<1||performanceCount>10000))throw Error('invalid performance count');
if(process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'&&(!performanceMode||performanceCount<1000))throw Error('concurrent input requires --performance and at least 1000 nodes');
// 诊断 baseline 只接受十六进制 commit 标识;稍后只替换三个指定模块,不表示回放完整历史版本。
const baseline=process.env.HEADLESS_PERFORMANCE_BASELINE;if(baseline&&!/^[a-f0-9]{7,40}$/.test(baseline))throw Error('invalid diagnostic baseline');
const source = resolve(import.meta.dirname, '../..');
// Turbopack fences real dependency paths to its workspace root. Keep its isolated
// fixture under the physical dependency workspace, then remove this exact path
// in finally. Webpack fixtures retain their ordinary OS-temp location.
// Turbopack 要求真实依赖路径位于 workspace root,临时应用放在依赖的物理目录旁;webpack 使用系统临时目录。
const fixtureRoot=bundler==='turbopack'?dirname(await realpath(join(source,'node_modules'))):tmpdir();
// 应用和浏览器 profile 分别随机建目录;进程及临时文件归本次检查持有。
const directory = await mkdtemp(join(fixtureRoot, bundler==='turbopack'?'.headless-renderer-':'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-'));
// 稳定 UUID 形式用于测试作用域/任务身份,与业务元素 a/b 等符号 ID 分开。
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
// 各 Map 是内存服务事实源;calls 记录上游实际收到的字节/头,供断言请求次数与协议传递。
const account = id(1), workspace = id(2), root = id(3), other = id(4), partition = id(5), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(), assetBytes=new Map(), aiRuns = new Map();
// 建立合法第一代文本节点;功能模式从两个节点和带可编辑路径的连线开始。
const node = (id, ownerCanvasId, x) => ({ id, ownerCanvasId, incarnation: 1, componentId: 'text', dataVersion: 1, structure: 'node', data: { title: 'Card ' + x, text: 'Editable content' }, geometry: { x, y: 150, width: 260, height: 200, rotation: 0 } });
documents.set(root, createDocument(root, [{ id: root, parent: null }], [node('a', root, 80), node('b', root, 450), { id: 'edge', incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: root, source: { elementId: 'a' }, target: { elementId: 'b' }, data: {}, path: { type: 'bezier-chain', knots: [{ id: 'k', mode: 'corner', position: { x: 400, y: 250 }, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] } }]));
documents.set(other, createDocument(other, [{ id: other, parent: null }], []));
// 性能模式替换根模型:展开的分区含 count 个网格节点,根锚点与入口连线用于复制和离屏边验证。
if(performanceMode){
const frame={...node('perf-frame',root,30),componentId:'frame',data:{title:'Performance partition',text:''},geometry:{x:30,y:50,width:1000,height:600,rotation:0}},children=Array.from({length:performanceCount},(_,index)=>({...node(`perf-${index}`,partition,(index%20)*280),geometry:{x:(index%20)*280,y:Math.floor(index/20)*220,width:260,height:200,rotation:0}})),incoming={id:'perf-incoming',incarnation:1,componentId:'connection',dataVersion:1,structure:'connection',ownerCanvasId:root,source:{elementId:'perf-source'},target:{elementId:'perf-frame'},data:{},path:null};
versions.set(partition,1);documents.set(root,createDocument(root,[{id:root,parent:null},{id:partition,parent:{canvasId:root,entryElementId:frame.id}}],[node('perf-source',root,-300),frame,...children,incoming]));
}
// 应用目录和标题接口返回两个根 board,canvas 规范模型仍由 documents 保存。
const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind: 'board', version: 1, title: i ? 'Other board' : 'Headless board' }));
// 固定 PNG 只供显示 URL 返回;上传元数据和原始上传字节另外保存,不能从此图片推导所有资源都做过解码。
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64');
// 某个后续场景在前三项后挂住流,验证完整子 owner 可先写,而其他 owner 继续加载。
let scopeStreamRoot=null,releaseScopeStream;const scopeStreamGate=new Promise(resolve=>releaseScopeStream=resolve);
// 分别标记错误尾部与被导航放弃的来源;abandonedGate 可令旧响应晚到。
let brokenStreamRoot=null,abandonedRoot=null,releaseAbandoned;const abandonedGate=new Promise(resolve=>releaseAbandoned=resolve);
// 功能模式首读只发第一个元素即等待,给“边加载边编辑”留出可控窗口。
let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode&&!migrationOnly;
// 包含特定导入标题的文档写入可挂起,editReceipts 按请求 ID 保存规范写入结果。
let releaseImport;const importGate=new Promise(resolve=>{releaseImport=resolve;});let importWriteSeen=false;const editReceipts=new Map();
// AI 显式应用先改变服务状态再挂起响应,用于检查本地操作与未确认回执交错。
let releaseApply;const applyGate=new Promise(resolve=>{releaseApply=resolve;});
// 上传收到字节后先记录资源,uploadGate 决定何时确认,使本地预览与后端确认可分开观察。
let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; });
// 将指定参与 canvas 的完整成员编码为局部/完整规范模型;此替身 removedCanvasIds 固定为空,不涵盖任意删除协议。
const model = (document, complete = true, participants=[...document.canvases.keys()]) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)), elements: [...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)), tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries(participants.map(id => [id, versions.get(id)])), removedCanvasIds: [] });
// 后续恢复场景可拒绝下一次编辑,资源采用场景可拒绝或延迟元数据授权。
let rejectNextEdit=false,rejectedEdit,denyAssetMetadata=false,metadataWait,releaseMetadata;
// 仅迁移入口装配专用路由/旧数据集;其他模式不运行迁移 fixture。
const migrationFixture=migrationOnly?createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}):undefined;
// 上游在真实 HTTP 上接请求,但内部仅操作 Map;异常转成 500,使页面协议错误可观察。
const upstream = createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = [];
for await (const chunk of request) chunks.push(chunk);
const bytes = Buffer.concat(chunks); calls.push({ method: request.method, path, bytes, headers: request.headers });
// 统一 JSON 响应助手;登录、个人资料、workspace 和目录路由只满足实际页面装配。
const reply = (status, body) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); };
if (path.endsWith('/auth/logout')) return reply(204, null);
if (path.endsWith('/profile')) return reply(200, { id: account, username: 'fixture', display_name: 'Creator', email: null });
if (path.endsWith('/workspaces')) return reply(200, [{ id: workspace, name: 'Fixture workspace', role: 'admin' }]);
if (path.endsWith('/materials')) return reply(200, { items: boards, next_offset: null });
const board = boards.find(board => path.endsWith('/materials/' + board.id)); if (board) return reply(200, board);
// 迁移专用处理器先尝试接管,再进入公共模型路由,避免两个 fixture 同时修改一个请求。
if(await migrationFixture?.handle(request,path,reply,bytes))return;
const read = path.match(/\/materials\/([^/]+)\/canvas\/model$/);
// 只有协商的 Accept 才返回规范 NDJSON;未协商请求走下面的完整 JSON 路径。
if(read&&request.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'){
const value=model(documents.get(read[1])),token='fixture-snapshot-'+calls.length;let sequence=0;
response.writeHead(200,{'Content-Type':'application/vnd.foundry.canvas-model+ndjson;version=1'});
// 同一 snapshotToken 内序号递增,每帧为一行 JSON;header 先提供完整 canvas、owner 清单和成员计数。
const line=record=>response.write(JSON.stringify({streamVersion:1,snapshotToken:token,sequence:sequence++,...record})+'\n');
line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length,canvasElementCounts:Object.fromEntries(value.canvases.map(canvas=>[canvas.id,value.elements.filter(element=>element.ownerCanvasId===canvas.id).length])),elementOwners:Object.fromEntries(value.elements.map(element=>[element.id,element.ownerCanvasId]))});
// 错误尾部先发送有效首元素,再结束为非法 JSON;应保留可见前缀并报告失败。
if(read[1]===brokenStreamRoot){line({kind:'elements',elements:value.elements.slice(0,1)});response.end('{broken tail}\n');return;}
let start=0;
// 作用域流和被放弃流各有自己的首块大小与 gate;响应已销毁时停止追加尾部。
if(read[1]===scopeStreamRoot){line({kind:'elements',elements:value.elements.slice(0,3)});start=3;await scopeStreamGate;if(response.destroyed)return;}
if(read[1]===abandonedRoot){line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await abandonedGate;if(response.destroyed)return;}
// 首个根读取只触发一次 gate;其余元素随后每帧最多 64 项,再发送 complete 并结束 HTTP body。
if(gatedFirstModel&&read[1]===root&&value.elements.length){gatedFirstModel=false;line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await firstModelGate;}
for(let offset=start;offset<value.elements.length;offset+=64)line({kind:'elements',elements:value.elements.slice(offset,offset+64)});
line({kind:'complete',elementCount:value.elements.length});return response.end();
}
if (read) return reply(200, { model: model(documents.get(read[1])) });
const write = path.match(/\/materials\/([^/]+)\/canvas\/model\/operations$/);
// 规范增量写入路径:检查账户、幂等键与参与版本,在内存服务模型上按顺序应用,再保存回执。此处不实现完整生产幂等数据库。
if (write) {
const batch = JSON.parse(bytes.toString()), previous = documents.get(write[1]);
// 特定导入写入到达后仅设置观测标记并等待 gate,阻塞发生在文档提交前。
if(batch.operations.some(operation=>operation.changes.some(change=>change.kind==='element'&&change.after?.data?.title==='Imported NDJSON node'))){importWriteSeen=true;await importGate;}
assert.equal(request.headers['x-expected-account'], account); assert.equal(request.headers['idempotency-key'], batch.request_id);
// 拒绝指定下一编辑时同时模拟远程 b 文字改成 Remote reviewed;返回绑定原请求的 terminal 零写入证据。
if(rejectNextEdit){rejectNextEdit=false;rejectedEdit=batch;const before=previous.elements.get('b');documents.set(write[1],applyChanges(previous,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:before.data.text,after:'Remote reviewed',beforePresent:true,afterPresent:true}]));versions.set(root,versions.get(root)+1);return reply(409,{protocol_version:2,request_id:batch.request_id,status:'not-applied',terminal:true,error:'version_conflict'});}
for(const [id,version] of Object.entries(batch.expected_versions))assert.equal(version,versions.get(id)??0);
let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes);
documents.set(write[1], next);for(const id of Object.keys(batch.expected_versions))versions.set(id,(versions.get(id)??0)+1);
// 一次有序批次只把参与版本各推进一次,回执仍列出全部逻辑 operation IDs,供客户端覆盖检查。
const receipt={ protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false,Object.keys(batch.expected_versions)) };editReceipts.set(batch.request_id,receipt);return reply(200,receipt);
}
// GET 查询原文档请求回执;查不到只给 unknown,不提供未执行证明。
const editQuery=path.match(/\/canvas\/model\/operations\/([^/]+)$/);if(editQuery)return editReceipts.has(editQuery[1])?reply(200,editReceipts.get(editQuery[1])):reply(404,{request_id:editQuery[1],status:'unknown'});
if(path.endsWith('/canvas/ai/models'))return reply(200,{models:[{id:'fixture-text',label:'Fixture text',available:true,input_modalities:['text'],output_modalities:['text']}]});
// AI 受理检查账户、幂等键、If-Match 和完整预期版本向量;先保存配置再冻结计划,不在受理时自动写生成文字。
if(path.endsWith('/canvas/ai/runs')&&request.method==='POST'){
const input=JSON.parse(bytes.toString()),owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');assert.deepEqual(input.expected_versions,Object.fromEntries([...documents.get(root).canvases.keys()].map(id=>[id,versions.get(id)])));
const previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.ai',before:element.data.ai??null,beforePresent:element.data.ai!==undefined,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:input.parameters,propagate:input.propagate},afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1);
const runId=id(700+aiRuns.size*10),plan={steps:[{step_id:'step-b',board_id:owner,node_id:input.node_id,canonical_root:root,target_incarnation:input.target_incarnation,kind:'text',model_id:input.model_id,status:'queued',dependencies:[],static_inputs:[]}],edges:[],frozen_versions:Object.fromEntries([...next.canvases.keys()].map(id=>[id,versions.get(id)]))};
// 受理以 202 返回任务/计划/目标 incarnation 及当前 owner 局部模型;事件初始只有 queued。
const admitted={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,admission:'accepted',status:'queued',run_id:runId,plan,canvas_version:versions.get(owner),document_format_version:1,target_incarnation:input.target_incarnation,model:model(next,false,[owner])};
aiRuns.set(runId,{status:'queued',plan,admitted,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'},{seq:2,run_id:runId,type:'step.queued',step_id:'step-b',board_id:owner,node_id:input.node_id,status:'queued'}]});return reply(202,admitted);
}
const aiAction=path.match(/\/canvas\/ai\/runs\/([^/]+)\/(cancel|apply)$/);
// 取消与应用回执均携带请求/操作/任务身份;取消只改控制状态,应用则检查当前 owner 与版本后修改文字。
if(aiAction){
const run=aiRuns.get(aiAction[1]),input=JSON.parse(bytes.toString()),base={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,action:'run.'+aiAction[2],status:'applied',run_id:aiAction[1]};
assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['x-expected-account'],account);
// 取消追加 completed(cancelled) 事件并立即回执,不经过 applyGate。
if(aiAction[2]==='cancel'){run.status='cancelled';run.events.push({seq:run.events.length+1,run_id:aiAction[1],type:'run.completed',status:'cancelled'});return reply(200,{...base,run_status:'cancelled'});}
const owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(input.target_canvas_id,owner);assert.equal(input.target_incarnation,1);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');
const output=run.events.find(event=>event.type==='step.output'&&event.output.output_id===input.output_id).output,previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.text',before:element.data.text,after:output.text,beforePresent:true,afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1);
// 应用输出先在服务模型中生效并追加 step.applied,再等待回执 gate,模拟成功已发生但客户端仍在途。
const applied={...base,step_id:'step-b',board_id:owner,origin_board_id:run.plan.steps[0].board_id,canonical_root:root,node_id:input.node_id,output_id:input.output_id,document_format_version:1,target_incarnation:1,canvas_version:versions.get(owner),model:model(next,false,[owner])};run.events.push({...applied,seq:run.events.length+1,type:'step.applied'});await applyGate;return reply(200,applied);
}
// 事件页按 after_seq 过滤,next_seq 为最后返回序号;此 fixture 一页返回全部剩余事件且无后续页。
const aiPoll=path.match(/\/canvas\/ai\/runs\/([^/]+)$/);
if(aiPoll){const run=aiRuns.get(aiPoll[1]),after=Number(url.searchParams.get('after_seq')||0),events=run.events.filter(event=>event.seq>after);return reply(200,{run_id:aiPoll[1],status:run.status,plan:run.plan,steps:[],events,next_seq:events.at(-1)?.seq??after,has_more:false,outputs:[]});}
// 用真实 Request.formData 解析代理送来的 multipart,按资源 ID 保存字节和元数据;等上传 gate 后才返回 201。
if (path.endsWith('/assets') && request.method === 'POST') {
const upload = new Request('http://fixture', { method: 'POST', body: bytes, headers: { 'Content-Type': request.headers['content-type'] } }), form = await upload.formData(), file = form.get('image');
const asset = { id: request.headers['x-resource-id'], bytes: file.size, mime_type: 'image/png', width: 4, height: 2, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${workspace}/assets/${request.headers['x-resource-id']}` };
@@ -110,24 +155,31 @@ const upstream = createServer(async (request, response) => {
return reply(201, { protocol_version: 2, request_id: request.headers['idempotency-key'], status: 'applied', asset });
}
const assetMetadata=path.match(/\/assets\/metadata\/([^/]+)$/);
// 元数据可延迟/拒绝;成功时对真实上传字节算 SHA-256,给授权采用与导入检查使用。
if(assetMetadata){if(metadataWait)await metadataWait;if(denyAssetMetadata||!assets.has(assetMetadata[1]))return reply(404,{error:'not_found'});const original=assetBytes.get(assetMetadata[1]),sha256=[...new Uint8Array(await crypto.subtle.digest('SHA-256',original))].map(n=>n.toString(16).padStart(2,'0')).join('');return reply(200,{metadata_version:1,asset:assets.get(assetMetadata[1]),sha256});}
// 普通资产显示路径返回固定 PNG,和上面的原始上传/摘要元数据检查属于不同证据。
if (/\/assets\/[^/]+$/.test(path)) { response.writeHead(200, { 'Content-Type': 'image/png' }); return response.end(png); }
reply(404, { error: 'not_found' });
} catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: error.message })); }
});
// 先绑定本地上游,短暂监听器再为 Next 选端口;释放保留端口后才启动 Next,不是持续端口占用锁。
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
const reserve = createServer(); await new Promise(resolve => reserve.listen(0, '127.0.0.1', resolve)); const port = reserve.address().port; await new Promise(resolve => reserve.close(resolve));
let next, chrome, socket, logs = '';
try {
// 所有源码插桩只写入临时副本,原仓库 src 不会被这些 replace 修改。
await cp(join(source, 'src'), join(directory, 'src'), { recursive: true });
await cp(join(source, 'public'), join(directory, 'public'), { recursive: true });
// baseline 模式只将 engine/scheduler/queries 替换为指定 commit 的文件,属于局部诊断对照。
if(performanceMode&&baseline)for(const file of ['src/lib/canvas-runtime/engine.ts','src/lib/canvas-runtime/scheduler.ts','src/lib/canvas-core/queries.ts'])await writeFile(join(directory,file),execFileSync('git',['show',`${baseline}:${file}`],{cwd:source}));
// 堆诊断仅在临时 storage.commit 注入回执保留计数,记录 Root/文档/回执元素规模,不能直接当作完整保留堆分析。
if(performanceMode&&process.env.HEADLESS_PERFORMANCE_HEAP==='1'){
const file=join(directory,'src/lib/canvas-runtime/storage.ts'),text=await readFile(file,'utf8'),marker='async commit(leader: StorageLeader, batch: DurableBatch) {';assert.ok(text.includes(marker));await writeFile(file,text.replace(marker,marker+`const host=globalThis as typeof globalThis&{__fixtureReceiptRetention?:unknown};const payload=batch.image.payload as any;const receipts=payload.runtime?.receipts??[];const counts=receipts.map((row:any)=>Array.isArray(row[1]?.model?.elements)?row[1].model.elements.length:0);host.__fixtureReceiptRetention={rootRevision:batch.image.rootRevision,documentElements:payload.document?.elements?.length,receipts:receipts.length,modelElements:counts.reduce((a:number,b:number)=>a+b,0),maxModelElements:Math.max(0,...counts)};`));
}
// Count delivered measurement batches in the isolated fixture only.
// 包装批量测量更新,记录每批 ID 数;assert 源码锚点存在,避免插桩未命中却继续报告。
const internalsFile=join(directory,'src/lib/canvas-bindings/node-internals.ts'),internals=await readFile(internalsFile,'utf8');assert.ok(internals.includes('if(ids.size)update([...ids]);'));await writeFile(internalsFile,internals.replace('if(ids.size)update([...ids]);',`if(ids.size){const host=globalThis as typeof globalThis&{__fixtureInternalsBatches?:number[]};(host.__fixtureInternalsBatches??=[]).push(ids.size);update([...ids]);}`));
// 功能模式复制插件、hooks 与选择观察 fixture,并把组件接入临时页面;性能模式不装这些观察者。
if(!performanceMode){
await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx'));
await copyFile(join(source,'tests/headless/react-hooks-fixture.tsx'),join(directory,'src/components/canvas/hooks-fixture.tsx'));
@@ -135,22 +187,25 @@ try {
const canvasFile=join(directory,'src/components/canvas/headless-graph-canvas.tsx'),canvasSource=await readFile(canvasFile,'utf8');
await writeFile(canvasFile,canvasSource.replace('"use client";','"use client";\nimport {NativeSelectionFixture} from "./selection-listener-fixture";').replace('<Background variant=','<NativeSelectionFixture/><Background variant='));
// Count actual scene rebuilds in the isolated fixture copy; production has no diagnostic global.
// 只统计完整场景 rebuild;已有 patch 路径不计数,逐元素 projection 另外统计,避免混用两个指标。
const sceneFile=join(directory,'src/lib/canvas-bindings/scene-projection.ts'),sceneSource=await readFile(sceneFile,'utf8');assert.ok(sceneSource.includes('const result=patched??projectCanvas('));await writeFile(sceneFile,sceneSource.replace('const result=patched??projectCanvas(',`const host=globalThis as typeof globalThis&{__fixtureSceneBuilds?:number};if(!patched)host.__fixtureSceneBuilds=(host.__fixtureSceneBuilds??0)+1;const result=patched??projectCanvas(`));
const projectionFile=join(directory,'src/lib/canvas-bindings/projection.ts'),projectionSource=await readFile(projectionFile,'utf8');assert.ok(projectionSource.includes(':ProjectionNode{'));await writeFile(projectionFile,projectionSource.replace(':ProjectionNode{',`:ProjectionNode{const host=globalThis as typeof globalThis&{__fixtureProjectedNodes?:Record<string,number>};host.__fixtureProjectedNodes??={};host.__fixtureProjectedNodes[element.id]=(host.__fixtureProjectedNodes[element.id]??0)+1;`));
// Delay only the full-source Worker result in the isolated copy, independently of parser I/O.
// 摘要 gate 延迟的是 Worker 已算出的全源结果,独立于后续 GatedDocument 的 parser I/O 屏障。
const digestFile=join(directory,'src/lib/canvas-runtime/browser-digests.ts'),digestSource=await readFile(digestFile,'utf8');assert.ok(digestSource.includes('else finish(undefined,value.sha256);'));await writeFile(digestFile,digestSource.replace('else finish(undefined,value.sha256);',`else {const host=globalThis as typeof globalThis&{__fixtureDigestGate?:Promise<void>;__fixtureDigestWaiting?:boolean};if(host.__fixtureDigestGate){host.__fixtureDigestWaiting=true;void host.__fixtureDigestGate.then(()=>finish(undefined,value.sha256));}else finish(undefined,value.sha256);}`));
// A fixture Host action exercises the public engine API; no debug entry point ships in the app.
// 通过临时 effect 暴露实际 engine 和公开 collectImportSources 动作;卸载时只清理同一注册函数。
const importsFile=join(directory,'src/components/canvas/headless-imports.tsx'),importsSource=await readFile(importsFile,'utf8');
await writeFile(importsFile,importsSource.replace('import { useCallback,','import { useEffect, useCallback,').replace(' const subscribe =',` useEffect(()=>{const host=globalThis as typeof globalThis&{__headlessCollectSources?:()=>ReturnType<ProjectEngine['collectImportSources']>;__fixtureActualEngine?:ProjectEngine};const collect=()=>engine.collectImportSources({retentionMs:0});host.__headlessCollectSources=collect;host.__fixtureActualEngine=engine;return()=>{if(host.__headlessCollectSources===collect)delete host.__headlessCollectSources;};},[engine]);
const subscribe =`));
// 在 Host 扩展装配点安装自定义 renderer 与 HookChecks,锚点改变则测试准备失败,不静默少测插件。
const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";\nimport {HookChecks} from "@/components/canvas/hooks-fixture";').replace('extensions=[]','extensions=[fixtureExtension]').replace('{children}</Renderers.Provider>','{children}<HookChecks/></Renderers.Provider>'));
}
// 临时应用沿用项目配置和 loader,再链接现有 node_modules,不另装依赖。
for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs', 'next.config.ts']) await copyFile(join(source, file), join(directory, file));
await cp(join(source,'scripts/loaders'),join(directory,'scripts/loaders'),{recursive:true});
if(!performanceMode){
const loaderFile=join(directory,'scripts/loaders/xyflow-retained.cjs');
// 附加 loader 包装统计 ReactFlow 原生选择 selector 扫描次数;字符串写入临时 loader,不影响产品构建。
const instrument=`
const nativeTransform=module.exports;
module.exports=function(source){return nativeTransform.call(this,source).replace('const selector$l = (s) => {','const selector$l = (s) => { globalThis.__fixtureSelectionScans=(globalThis.__fixtureSelectionScans??0)+1;');};
@@ -159,50 +214,64 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
}
await symlink(join(source, 'node_modules'), join(directory, 'node_modules'));
// 统一 Next 子进程启动和日志尾部收集;独立进程组供 finally 一并终止,API origin 指向本次上游。
const launchNext=args=>{
const child=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),...args],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'endoleap'},stdio:['ignore','pipe','pipe']});
child.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-16000);});child.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-16000);});return child;
};
let buildMs;
// 生产性能入口先完成 Next build 并记录耗时,非零退出立即失败;其他入口用 dev,仍可能触发页面编译。
if(productionMode){
console.log('BUILD production fixture');const started=Date.now();next=launchNext(['build',`--${bundler}`]);
await new Promise((resolve,reject)=>{next.once('error',reject);next.once('exit',(code,signal)=>code===0?resolve():reject(Error(`production fixture build failed (${code??signal})\n${logs}`)));});
buildMs=Date.now()-started;console.log(`BUILD complete ${buildMs} ms`);
}
next=launchNext([productionMode?'start':'dev',...productionMode?[]:[`--${bundler}`],'--hostname','127.0.0.1','--port',String(port)]);
// 有界探测真实代理 profile,并检查 Next 提前退出;后续页面等待还会验证应用是否实际可用。
for (let i = 0; i < 120; i++) { if (next.exitCode !== null) throw new Error(logs); try { const r = await fetch(`http://127.0.0.1:${port}/api/foundry/profile`, { signal: AbortSignal.timeout(1000) }); if (r.ok) break; } catch {} await pause(250); }
// 只保留本次隔离 Chrome 的日志尾部,CHROME_BIN 可覆盖可执行路径,不连接日常 profile。
let browserLogs='';
chrome = spawn(process.env.CHROME_BIN ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ['--headless=new', '--no-first-run', '--disable-background-networking', '--disable-component-update', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore','ignore','pipe'] });
chrome.stderr.on('data',chunk=>{browserLogs=(browserLogs+chunk).slice(-16000);});
// 从本次 profile 的 DevToolsActivePort 发现调试端口,失败则不继续浏览器命令。
let debugPort;
for (let i = 0; i < 100; i++) { try { debugPort = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); } }
assert.ok(debugPort, 'Chrome did not start');
const tabs = await fetch(`http://127.0.0.1:${debugPort}/json/list`).then(response => response.json());
socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
const exceptions=[];
// 使用有截止时间的共享 CDP 调用器;收集页面异常与 fatal 事件,便于失败时定位。
const call=createCdpCalls(socket,{onEvent(message){if(['Inspector.targetCrashed','Target.targetCrashed','Inspector.detached','Page.javascriptDialogOpening'].includes(message.method))browserLogs=(browserLogs+'\nCDP '+JSON.stringify(message)).slice(-16000);if(message.method==='Runtime.consoleAPICalled'&&message.params.type==='error')console.log('BROWSER ERROR',message.params.args.map(arg=>arg.description??arg.value).join(' '));if(message.method==='Runtime.exceptionThrown')exceptions.push(message.params.exceptionDetails.exception?.description??message.params.exceptionDetails.text);}});
// 页面表达式等待 Promise 并按值返回;exceptionDetails 直接转换为测试失败。
const evaluate = async expression => { const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text); return r.result?.value; };
// 最多 200 轮条件检查,先检查页面异常,每轮约隔 100ms;失败附作业摘要、页面文本和 Next 日志,整体等待仍包含各 CDP 调用耗时。
async function until(expression, label) { for (let i = 0; i < 200; i++) { if (exceptions.length) throw new Error(exceptions.join('\n')); if (await evaluate(expression)) return; await pause(100); } throw new Error(label + '\n' + JSON.stringify(await evaluate(`[...window.__fixtureActualEngine?.imports.getJobs().values()??[]].map(job=>({id:job.id,status:job.status,error:job.error,sourceError:job.source?.integrityError}))`)) + '\n' + await evaluate('document.body.innerText') + '\n' + logs); }
// 启用页面/异常事件并固定 1280×900 视口,减少交互坐标和虚拟化断言随屏幕环境漂移。
await call('Runtime.enable');await call('Page.enable');await call('Target.setDiscoverTargets',{discover:true}); await call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${migrationFixture?.states[0].root??root}`;
await call('Page.navigate', { url: route });
// 按模式选择性能、迁移、hooks 或完整交互;每条专用入口成功后都回到公共 finally 清理。
if(performanceMode){
await measureRenderer({call,evaluate,documents,calls,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',bundler,buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
}else if(migrationOnly){await verifyRendererMigration({fixture:migrationFixture,call,evaluate,until,port,workspace,account,pause});
}else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report);
}else{
// 完整交互先要求首个元素在尾部 gate 前可编辑,b 尚不存在、创建工具禁用且没有文档 POST。
await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail');
assert.equal(await evaluate('!!document.querySelector(".react-flow__node[data-id=b]")'),false);
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,0);
assert.equal(await evaluate('document.querySelector("[aria-label=添加文本卡片]").disabled'),true);
await until('document.querySelector(".react-flow__node[data-id=a]").getBoundingClientRect().x>300','initial partial node remained under the sidebar');
// 使用 CDP 原生双击进入已载入文本,输入 partial-local;来源还不完整时不能发送文档请求。
const partialTextPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=a] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+60,y:r.y+30};})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...partialTextPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...partialTextPoint,button:'left',buttons:0,clickCount:2});
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=a] textarea')&&!document.activeElement.readOnly`,'loaded prefix text did not enter edit mode');
await call('Input.insertText',{text:' partial-local'});const partialText=await evaluate('document.activeElement.value');assert.ok(partialText.endsWith(' partial-local'));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,0);
await evaluate(`window.partialTextarea=document.activeElement;void 0`);
// 捕获同一个 textarea 和 viewport 后放行来源,完成不得替换焦点 DOM 或重置视口。
const previewTransform=await evaluate('document.querySelector("[data-headless-canvas] .react-flow__viewport").style.transform');
await writeFile('/private/tmp/headless-loading-editable.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
// 只有这里才放行首读尾部;随后核对服务文字和 undo,证明前缀编辑的历史继续可用。
releaseFirstModel();
await until('!!document.querySelector("[data-headless-canvas]:not([data-model-loading])") && document.querySelectorAll(".react-flow__node").length === 2','full model did not finish the partial editor');
assert.equal(await evaluate('window.partialTextarea.isConnected&&document.activeElement===window.partialTextarea'),true,'tail completion remounted the active text editor');
@@ -211,11 +280,13 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});
await evaluate(`document.querySelector('[aria-label="撤销"]').click()`);for(let i=0;i<100&&documents.get(root).elements.get('a').data.text!=='Editable content';i++)await pause(20);assert.equal(documents.get(root).elements.get('a').data.text,'Editable content');
console.log('PASS actual GraphPage edits the first prefix before the gated tail, keeps its active textarea/history/viewport and sends only after durable completion');
// 启用原生选择监听后改选 b,停用时事件数和 selector 扫描数都应冻结,再启用后继续收到事件。
await until('!!window.__fixtureNativeSelection','native selection fixture did not mount');
await evaluate('window.__priorNativeSelection=window.__fixtureNativeSelection.selected();window.__fixtureNativeSelection.enable(true);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await evaluate('window.__fixtureNativeSelection.select(["b"]);true');await until('window.__fixtureNativeSelectionEvents.at(-1)?.nodes.join() === "b"','native selection hook missed its selected node');
await evaluate('window.__fixtureNativeSelection.enable(false);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
const unsubscribedSelectionEvents=await evaluate('window.__fixtureNativeSelectionEvents.length');
// 实际扫描计数必须先大于零,避免“从未装上监听器”也通过卸载后零变化的检查。
const subscribedSelectionScans=await evaluate('window.__fixtureSelectionScans??0');assert.ok(subscribedSelectionScans>0,'native scan instrumentation did not observe the active hook');
await evaluate('window.__fixtureNativeSelection.select(["a"]);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureNativeSelectionEvents.length'),unsubscribedSelectionEvents,'native selection hook fired after unsubscription');assert.equal(await evaluate('window.__fixtureSelectionScans??0'),subscribedSelectionScans,'unsubscribed native selection still scanned the graph');
await evaluate('window.__fixtureNativeSelection.enable(true);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
@@ -227,10 +298,13 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot');
assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true);
// 按钮辅助函数走实际页面 click;下一个键盘脚本只检查事件是否被 canvas 声明消费。
const click = label => evaluate(`document.querySelector('[aria-label=${JSON.stringify(label)}]').click()`);
// Ctrl/Meta、IME 组合态和未知键不被抢占,普通 h/v 工具快捷键应 preventDefault。
assert.deepEqual(await evaluate(`(()=>{const canvas=document.querySelector('[data-headless-canvas]');const fire=(key,options={})=>{const e=new KeyboardEvent('keydown',{key,bubbles:true,cancelable:true,...options});canvas.dispatchEvent(e);return e.defaultPrevented;};return [fire('h',{ctrlKey:true}),fire('v',{metaKey:true}),fire('h',{isComposing:true}),fire('F9'),fire('h'),fire('v')];})()`),[false,false,false,false,true,true]);
console.log('PASS canvas keyboard routing leaves modifiers, IME and unknown keys unclaimed and consumes plain tool shortcuts');
// 创建、undo、redo 同时核对 DOM 节点数与 fixture 文档元素数,后者还含原连线,不能直接把两种计数混用。
await click('添加文本卡片'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'create did not render');
for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20);
assert.equal(documents.get(root).elements.size, 4);
@@ -239,15 +313,18 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(documents.get(root).elements.size, 3);
await click('重做'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'redo did not render');
for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20);
// 记录拖动前文档请求数,并在页面收集原生 pointer/mouse/capture 事件供失败诊断。
const beforeDrag = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
await evaluate(`window.inputEvents=[];for(const type of ['pointerdown','mousedown','pointerup','mouseup','pointercancel','lostpointercapture'])window.addEventListener(type,event=>window.inputEvents.push({type,x:event.clientX,y:event.clientY,target:event.target.outerHTML?.slice(0,120)}),true);`);
const rect = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()');
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x, y: rect.y, button: 'left', buttons: 1, clickCount: 1 });
// 十帧 pointer move 只更新预览,释放时只允许一个文档请求,最终位置以 pointerup 坐标为准。
for (let step = 1; step <= 10; step++) await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + step * 5, y: rect.y + step * 3, button: 'left', buttons: 1 });
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + 50, y: rect.y + 30, button: 'left', buttons: 0 });
for (let i = 0; i < 100 && documents.get(root).elements.get('b').geometry.x === 450; i++) await pause(20);
assert.ok(Math.abs(documents.get(root).elements.get('b').geometry.x - 500) < 1, JSON.stringify({ geometry: documents.get(root).elements.get('b').geometry, rect, writes: calls.filter(call => call.path.endsWith('/model/operations')).map(call => JSON.parse(call.bytes.toString()).operations), inputs: await evaluate('window.inputEvents'), text: await evaluate('document.body.innerText') }));
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeDrag + 1, 'pointer frames became document requests');
// 再次移动后用 Escape 取消,再发送迟到 mouseReleased;服务几何和请求数都不能变化。
const beforeCancel = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
const moved = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()');
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: moved.x, y: moved.y, button: 'left', buttons: 1, clickCount: 1 });
@@ -256,6 +333,7 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: moved.x + 35, y: moved.y + 25, button: 'left', buttons: 0 });
await pause(100); assert.equal(documents.get(root).elements.get('b').geometry.x, 500); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel);
// 拖动中切 H/pan 也必须取消所属预览;后来的 pointerup 不能提交,V 恢复选择工具及已提交位置。
const toolPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...toolPoint,button:'left',buttons:1,clickCount:1});
await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:toolPoint.x+35,y:toolPoint.y+25,button:'left',buttons:1});
@@ -270,6 +348,7 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await until(`document.querySelector('[aria-label="选择工具(V)"]')?.getAttribute('aria-pressed')==='true'`,'selection tool did not render');
await until(`(()=>{const n=document.querySelector('.react-flow__node[data-id=b]'),m=new DOMMatrix(n.style.transform);return Math.abs(m.m41-500)<.01&&!n.classList.contains('dragging');})()`,'canceled native node did not return to its committed position');
console.log('PASS actual drag-to-pan tool switch cancels preview and late pointerup produces no document request');
// 用实际 resize handle 开始缩放,期间合成 foreign pointerId=999 事件,不能提交或取消当前手势。
const corner = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label="拖动右下角调整大小"]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: corner.x, y: corner.y, button: 'left', buttons: 1, clickCount: 1 });
await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: corner.x + 20, y: corner.y + 10, button: 'left', buttons: 1 });
@@ -279,10 +358,12 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
for (let i=0;i<100&&documents.get(root).elements.get('b').geometry.width!==300;i++)await pause(20);
assert.equal(documents.get(root).elements.get('b').geometry.width,300,JSON.stringify({geometry:documents.get(root).elements.get('b').geometry,inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText')}));assert.equal(documents.get(root).elements.get('b').geometry.height,220);
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel+1);
// resize 模式 Escape 分三种:原生 select 内、子控件已 preventDefault、工具栏自身;只有未被控件接管的工具栏 Escape 清理模式。
await click('八向拉伸模式');await until(`document.querySelector('[data-resize-toggle="b"]')?.getAttribute('aria-pressed')==='true'`,'resize mode did not open');
await evaluate(`document.querySelector('[data-node-style="b"] select').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','native selector Escape dismissed the canvas mode');
await evaluate(`(()=>{const button=document.querySelector('[data-resize-toggle="b"]');button.addEventListener('keydown',event=>event.preventDefault(),{once:true});button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));})()`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','toolbar ignored a child control that had claimed Escape');
await evaluate(`document.querySelector('[data-resize-toggle="b"]').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');return !!node&&[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].every(view=>view.modes.get('b')!=='resize')&&!node.querySelector('[data-resize-handle]')&&!document.querySelector('[data-resize-toggle="b"][aria-pressed="true"]');})()`,'toolbar did not dismiss its own resize mode').catch(async error=>{console.log('MODE DIAGNOSTIC',await evaluate(`JSON.stringify({views:[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].map(view=>({id:view.id,modes:[...view.modes]})),buttons:[...document.querySelectorAll('[data-resize-toggle="b"]')].map(node=>({pressed:node.getAttribute('aria-pressed'),html:node.outerHTML})),target:document.querySelector('.react-flow__node[data-id=b]')?.outerHTML.slice(0,1500)})`));throw error;});
// 真实双击进入文本,IME 组合再提交中文;undo 应只撤销该输入,不回退此前已提交的缩放。
const textPoint = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+60,y:r.y+30}})()`);
await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...textPoint, button: 'left', buttons: 1, clickCount: 2 });
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', ...textPoint, button: 'left', buttons: 0, clickCount: 2 });
@@ -298,15 +379,17 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(documents.get(root).elements.get('b').data.text,'Editable content');assert.equal(documents.get(root).elements.get('b').geometry.width,300);
await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'edits did not settle before hit-test');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// Hit from the pane beyond the native edge interaction stroke, using screen-space tolerance.
// 沿可见曲线法线寻找落在 pane 的点,特意避开节点并超出原生 SVG 交互 stroke,检验屏幕容差命中。
const nearCurve=await evaluate(`(()=>{const path=document.querySelector('.react-flow__edge[data-id="edge"] .react-flow__edge-path');if(!path)return null;const length=path.getTotalLength(),matrix=path.getScreenCTM();for(let t=.1;t<.99;t+=.01){const a=path.getPointAtLength(length*t),b=path.getPointAtLength(length*t+1),p=new DOMPoint(a.x,a.y).matrixTransform(matrix),q=new DOMPoint(b.x,b.y).matrixTransform(matrix),dx=q.x-p.x,dy=q.y-p.y,d=Math.hypot(dx,dy);for(const sign of [-1,1]){const x=p.x-sign*dy/d*11,y=p.y+sign*dx/d*11;if(document.elementFromPoint(x,y)?.classList.contains('react-flow__pane')&&![...document.querySelectorAll('.react-flow__node')].some(node=>{const r=node.getBoundingClientRect();return x>=r.left-13&&x<=r.right+13&&y>=r.top-13&&y<=r.bottom+13;}))return{x,y};}}return null;})()`);
assert.ok(nearCurve,'no pane point beyond the SVG interaction stroke');
// 边命中是只读选择:出现曲线控制点但不得产生文档 operation。
const beforeHit=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
await call('Input.dispatchMouseEvent',{type:'mousePressed',...nearCurve,button:'left',buttons:1,clickCount:1});
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...nearCurve,button:'left',buttons:0,clickCount:1});
await until(`!!document.querySelector('[aria-label="曲线点 1"]')`,'pane near-curve hit did not select the edge');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeHit,'read-only hit query created a document operation');
console.log('PASS screen-tolerance pane hit selects the displayed curve beyond native SVG stroke without a model operation');
// 重叠区域控制点仍需可命中,拖动末坐标保存为 (440,265),整个曲线手势只新增一次请求。
const curveHandle=await evaluate(`(()=>{const r=document.querySelector('[aria-label="曲线点 1"]').getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2};})()`);
assert.equal(await evaluate(`document.elementFromPoint(${curveHandle.x},${curveHandle.y})?.getAttribute('aria-label')`),'曲线点 1','selected curve control must remain hit-testable where a card overlaps');
const beforeCurve = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
@@ -320,6 +403,7 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);
await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'curve edit did not settle before opening recovery editor');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// 在 b 的实际可见 label 中扫描 elementFromPoint,找未被遮挡的原生双击位置,避免脚本 focus 跳过页面编辑流程。
const recoveryPoint=await evaluate(`(()=>{const label=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]'),r=label.getBoundingClientRect();
for(let y=Math.max(0,r.top)+8;y<Math.min(innerHeight,r.bottom);y+=8)for(let x=Math.max(0,r.left)+8;x<Math.min(innerWidth,r.right);x+=8)
if(label.contains(document.elementFromPoint(x,y)))return{x,y};
@@ -327,57 +411,77 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...recoveryPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...recoveryPoint,button:'left',buttons:0,clickCount:2});
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'recovery edit did not open');
// 下一写入明确拒绝并由 fixture 改出远程值;本地文字保留,页面应显示可进入恢复的动作。
rejectNextEdit=true;await call('Input.insertText',{text:'Local recovery'});await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected content did not expose recovery');
// 保存本地候选文字和原 rejected requestId;恢复选择必须呈现 Remote reviewed 作为用户审阅基线。
const localRecoveryText=await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value`),rejectedId=rejectedEdit.request_id;
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')`,'recovery choices did not load');
assert.ok(await evaluate(`document.querySelector('[role=dialog]').innerText.includes('Remote reviewed')`),'review omitted the server value');
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);
// 选择 keep-local 后先预览,按钮可用才 apply;等待对话框关闭与服务端新内容,不用 UI 关闭代替数据核对。
await click('预览处理方案');await until(`!document.querySelector('[aria-label="应用处理方案"]').disabled`,'reviewed recovery plan did not become applicable');await click('应用处理方案');
await until(`!document.querySelector('[role=dialog]')`,'recovery dialog did not close after persistence');
for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!==localRecoveryText;i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,localRecoveryText);
// 替代请求必须有新 requestId/operationId,条件 before 是已审阅远程值;下一次 undo 也必须回到这个远程基线。
const recompiled=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).at(-1),recompiledBody=JSON.parse(recompiled.bytes.toString());assert.notEqual(recompiledBody.request_id,rejectedId);assert.notEqual(recompiledBody.operations[0].id,rejectedEdit.operations[0].id);assert.equal(recompiledBody.operations[0].changes[0].before,'Remote reviewed');
await click('撤销');for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!=='Remote reviewed';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Remote reviewed');
console.log('PASS actual recovery dialog reviews remote/local content, recompiles with new identities, saves before dispatch and preserves the reviewed undo baseline');
// 改测结构创建拒绝:界面要标明创建元素和关联结构变更,应用后保留该新元素的撤销能力。
rejectNextEdit=true;await click('添加文本卡片');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected creation did not expose recovery');const createdId=rejectedEdit.operations[0].changes.find(change=>change.kind==='element'&&change.before===null).id;
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('创建元素')`,'creation review omitted the structural change');
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));rejectNextEdit=true;await click('撤销');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected undo did not expose recovery');
// 再拒绝该创建的 undo,恢复文案要识别为未同步撤销,选择保留撤销而不是重新创建普通内容操作。
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('未同步的撤销')`,'undo review did not identify the history action');assert.ok(await evaluate(`document.querySelector('select[aria-label="处理第1项修改"]').innerText.includes('保留这次撤销')`));
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('重做记录由')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'undo recovery omitted its redo stack');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'undo recovery did not finish');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
// 恢复撤销后 redo 以第二代还原,再 undo 删除;检查的是栈方向和生命期,不只是节点是否存在。
await click('重做');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.get(createdId).incarnation,2);await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
console.log('PASS actual rejected undo review labels the traversal, previews both stacks, replans deletion and retains redo with a new lifecycle');
console.log('PASS actual structural recovery dialog reviews a rejected creation and related changes, applies it with a new identity and retains undo');
// 辅助动作先原生点击空白清选择,再选 b 下缘并等 AI 工具可用;避免复用上个场景的选中状态。
const selectB=async()=>{await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height-30}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI tool did not accept the selected element');};
// 从实际 AI 面板读取 fixture 模型,设置 prompt 后执行;受理可见时服务只应创建一个任务。
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="AI 模型"] option[value="fixture-text"]')`,'AI catalog did not load through the host');
await evaluate(`(()=>{const input=document.querySelector('[aria-label="AI 指令"]');Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'Summarize this content');input.dispatchEvent(new Event('input',{bubbles:true}));})()`);
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI command did not become executable');await click('执行 AI');
await until(`document.body.innerText.includes('已受理')`,'AI admission did not persist');assert.equal(aiRuns.size,1);
// 记录 CSS zoom、内容宽高和字号,后续比较这些具体样式值;没有在此做完整像素或全部排版属性的等价检查。
const visualMetric=`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]'),frame=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return {zoom:frame.style.zoom,width:frame.style.width,height:frame.style.height,font:getComputedStyle(node.querySelector('textarea')).fontSize}})()`,beforeVisual=await evaluate(visualMetric);
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);await until(`location.pathname.endsWith('${other}')&&document.querySelectorAll('.react-flow__node').length===0`,'AI view detach failed');
// 导航到另一个项目后,由 fixture 在根项目创建缩放分区并移动 b,保留其元素身份,再追加生成结果事件。
const run=aiRuns.get(id(700)),beforeAI=documents.get(root),beforeNode=beforeAI.elements.get('b');
const frame={...node('relocation-frame',root,30),componentId:'frame',geometry:{x:30,y:50,width:1000,height:600,rotation:0},data:{title:'Relocated partition',text:'',presentation:{contentScale:2}}};
// 按新 owner 的局部坐标换算几何并写 unitScale=.5;这是测试准备的已知变换,不由浏览器拖拽自动产生。
const movedModel=applyChanges(beforeAI,[{kind:'element',id:frame.id,before:null,after:frame},{kind:'canvas',id:partition,before:null,after:{id:partition,parent:{canvasId:root,entryElementId:frame.id}}},{kind:'field',target:{id:'b',incarnation:1},field:'ownerCanvasId',before:root,after:partition},{kind:'field',target:{id:'b',incarnation:1},field:'geometry',before:beforeNode.geometry,after:{...beforeNode.geometry,x:(beforeNode.geometry.x-54)/2,y:(beforeNode.geometry.y-86)/2,width:beforeNode.geometry.width/2,height:beforeNode.geometry.height/2}},{kind:'field',target:{id:'b',incarnation:1},field:'data.presentation',before:beforeNode.data.presentation??null,beforePresent:Object.hasOwn(beforeNode.data,'presentation'),after:{...beforeNode.data.presentation,unitScale:.5},afterPresent:true}]);
const generated=applyChanges(movedModel,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:beforeNode.data.text,after:'Generated while another canvas was open',beforePresent:true,afterPresent:true}]);documents.set(root,generated);versions.set(root,versions.get(root)+1);versions.set(partition,2);run.status='succeeded';
run.events.push({seq:3,run_id:id(700),type:'step.output',step_id:'step-b',board_id:root,node_id:'b',output:{output_id:id(701),kind:'text',text:'Generated while another canvas was open'}},{seq:4,run_id:id(700),type:'step.applied',step_id:'step-b',board_id:partition,origin_board_id:root,canonical_root:root,node_id:'b',canvas_version:versions.get(partition),document_format_version:1,target_incarnation:1,model:model(generated,true)},{seq:5,run_id:id(700),type:'run.completed',status:'succeeded'});
// 离开视图后仍应继续轮询持久任务;另一项目页面必须保持无节点,不能显示原项目晚到结果。
const pollsBefore=calls.filter(call=>call.path.includes('/ai/runs/')).length;
for(let i=0;i<200&&calls.filter(call=>call.path.includes('/ai/runs/')).length===pollsBefore;i++)await pause(20);
assert.ok(calls.filter(call=>call.path.includes('/ai/runs/')).length>pollsBefore,'view detach stopped persistent AI polling');await pause(200);
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'),0,'AI output leaked into a different project view');
await call('Page.reload');await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'other project reload failed');
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);await until(`!!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelector('.react-flow__node[data-id=b] textarea')?.value==='Generated while another canvas was open'`,'AI output did not survive reload');
// 重载并返回原项目,面板要恢复 RunRecord;核对仅一个生成 POST、一个 b,以及缩放前后相同视觉采样。
await selectB();await click('AI 创作');await until(`document.body.innerText.includes('已更新当前元素')`,'durable RunRecord did not recover');await click('关闭 AI 工具');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1,'AI recovery invoked generation twice');assert.equal(documents.get(root).elements.get('b').geometry.width,150);assert.equal(documents.get(root).elements.get('b').data.presentation.unitScale,.5);assert.deepEqual(await evaluate(visualMetric),beforeVisual,'normalized units changed actual rendered size or typography');assert.equal(documents.get(root).elements.get('b').ownerCanvasId,partition);assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node[data-id=b]').length`),1);
console.log('PASS actual AI panel follows a scaled owner move with stable rendered size/typography, polls after navigation, and restores one element/RunRecord without re-execution');
// 下一任务走实际取消按钮,完成后保留当前内容,再启动第三个任务构造可显式选择的冲突输出。
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="执行 AI"]')&&!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI action did not reopen');await click('执行 AI');await until(`!!document.querySelector('[aria-label="取消生成"]')`,'cancel control did not appear');await click('取消生成');await until(`document.body.innerText.includes('已取消生成')`,'durable cancellation did not finish');assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'cancelled run did not release execution');await click('执行 AI');
for(let i=0;i<100&&!aiRuns.has(id(720));i++)await pause(20);assert.ok(aiRuns.has(id(720)));
// fixture 注入 output/conflict/completed 事件;冲突只保留结果,不应自动覆盖当前元素。
const conflicted=aiRuns.get(id(720));conflicted.status='conflict';conflicted.events.push({seq:3,run_id:id(720),type:'step.output',step_id:'step-b',board_id:partition,node_id:'b',output:{output_id:id(721),kind:'text',text:'Chosen conflict result'}},{seq:4,run_id:id(720),type:'step.conflict',step_id:'step-b',board_id:partition,node_id:'b',status:'conflict',error:'target_changed'},{seq:5,run_id:id(720),type:'run.completed',status:'conflict'});
await until(`[...document.querySelectorAll('button')].some(button=>button.textContent==='使用此结果替换当前内容')`,'conflict result did not expose explicit application');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent==='使用此结果替换当前内容').click()`);
// 选择输出后本地立即变化;applyGate 尚未释放时先 undo,应立即恢复旧内容,再允许服务回执协调。
await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Chosen conflict result'`,'chosen output was not optimistic');await click('撤销');await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Generated while another canvas was open'`,'unknown apply could not be locally undone');releaseApply();
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Generated while another canvas was open';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');await click('重做');
// redo 恢复已选择输出,不重新生成或重复 apply;最后精确检查取消一次、应用一次、总受理三次。
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Chosen conflict result';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Chosen conflict result');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/cancel')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/apply')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,3);await click('关闭 AI 工具');
console.log('PASS actual AI cancellation and optimistic conflict application use durable actions; undo/redo while awaiting acknowledgment never regenerate');
// 在页面生成真实 4×2 PNG 并经 File input 导入;上传 gate 仍挂起,本地 Blob URL 和尺寸应先出现。
await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelectorAll('.react-flow__node').length === 5 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload');
// 从 Blob 预览找到新元素,等待 height=130;比例操作可用后,一次 undo 应删除整个已补尺寸创建,redo 恢复尺寸。
const pendingImageId=await evaluate(`document.querySelector('[data-headless-canvas] img[src^="blob:"]').closest('.react-flow__node').getAttribute('data-id')`);
await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'default image size did not automatically use locally decoded 4:2 aspect ratio before upload ACK');
await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整');
@@ -385,72 +489,91 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await click('重做');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]')?.style.height==='130px'`,'redo lost the enriched creation dimensions');assert.equal(uploads.length<=1,true);
await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="${pendingImageId}"]');const content=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return content?.style.height==='130px';})()`,'image content retained a stale measured height after automatic sizing and redo');
console.log('PASS default image sizing precedes upload ACK; creation undo/redo includes decoded dimensions without a second history unit or upload');
// 截图是此时 pending 图片状态的辅助产物,随后另外核对上游上传数与文档元素数。
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64'));
for (let i = 0; i < 100 && !uploads.length; i++) await pause(20);
assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 5);
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);
await until(`location.pathname.endsWith('${other}') && !!document.querySelector('[data-headless-canvas]') && document.querySelectorAll('.react-flow__node').length === 0`, 'other board did not open');
// 导航到另一 board 后才确认上传;原项目后台继续写图片,当前页面不得被其晚到响应污染。
releaseUpload();
for (let i = 0; i < 200 && documents.get(root).elements.size !== 6; i++) await pause(20);
assert.equal(documents.get(root).elements.size, 6, 'view detach aborted the upload or document operation');
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'), 0, 'late result appeared on the wrong board');
// 另一项目用资源选择器采用已上传身份;先验证每次采用都重新授权,拒绝时不能创建元素或 undo 单元。
await click('复用其它画布图片');await until(`!!document.querySelector('[role="dialog"] [aria-label="添加图片:视觉参考"]')`,'other opened project image did not appear in the picker');
const pickerScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-resource-picker.png',Buffer.from(pickerScreenshot.data,'base64'));
denyAssetMetadata=true;await click('添加图片:视觉参考');await until(`document.querySelector('[role="dialog"] [role="alert"]')?.textContent.includes('访问权限')`,'fresh authorization rejection was not readable');assert.equal(documents.get(other).elements.size,0);assert.equal(await evaluate(`document.querySelector('[aria-label="撤销"]').disabled`),true);denyAssetMetadata=false;
// 授权读取挂起时按 Escape 关闭选择器,再放行旧响应;已关闭会话不得接纳这个迟到结果。
metadataWait=new Promise(resolve=>releaseMetadata=resolve);const metadataCalls=calls.filter(call=>call.path.includes('/assets/metadata/')).length;await click('添加图片:视觉参考');for(let i=0;i<100&&calls.filter(call=>call.path.includes('/assets/metadata/')).length===metadataCalls;i++)await pause(20);assert.ok(calls.filter(call=>call.path.includes('/assets/metadata/')).length>metadataCalls,'picker did not start authorization');await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await until(`!document.querySelector('[role="dialog"]')`,'Escape did not close the resource picker');releaseMetadata();metadataWait=undefined;await pause(100);assert.equal(documents.get(other).elements.size,0,'closed picker admitted a late authorization result');
// 重新打开后成功采用同一 assetId,比例来自授权元数据且上传总数仍为一;再检查 undo/redo 进入第二代。
await click('复用其它画布图片');await until(`!!document.querySelector('[role="dialog"] [aria-label="添加图片:视觉参考"]')`,'resource picker did not reopen');await click('添加图片:视觉参考');await until(`!document.querySelector('[role="dialog"]')&&document.querySelectorAll('.react-flow__node').length===1`,'adopted image did not appear');for(let i=0;i<100&&documents.get(other).elements.size!==1;i++)await pause(20);const adopted=[...documents.get(other).elements.values()][0];assert.equal(adopted.data.assetId,uploads[0].id);assert.equal(uploads.length,1,'cross-project picker uploaded a duplicate');assert.equal(adopted.geometry.width/adopted.geometry.height,2);
await click('撤销');for(let i=0;i<100&&documents.get(other).elements.size;i++)await pause(20);assert.equal(documents.get(other).elements.size,0);await click('重做');for(let i=0;i<100&&documents.get(other).elements.get(adopted.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(other).elements.get(adopted.id).incarnation,2);assert.equal(uploads.length,1);console.log('PASS cross-project resource picker reauthorizes, cancels a late result, creates one shared image and supports undo/redo without uploading again');
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);
// 返回原项目并重载,文档/资源身份继续存在且没有退回 legacy canvas 读取。
await until('document.querySelectorAll(".react-flow__node").length === 5', 'returning project lost its engine');
assert.equal(uploads.length, 1);
await call('Page.reload'); await until('document.querySelectorAll(".react-flow__node").length === 5', 'page reload did not recover the document');
assert.equal(uploads.length, 1); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false);
console.log('PASS pending image displays before upload; navigation preserves work and fences late view updates; reload retains resource identity');
console.log('PASS canvas floating controls remain visible while the sidebar is expanded');
// 完整页面模式也调用独立 HookChecks,报告来自真实 React mount 与内存端口组合,具体步骤在 hooks fixture 中说明。
const hookReports=await evaluate('window.__runCanvasHookChecks()');for(const report of hookReports)console.log('PASS React hooks: '+report);
// 导入 NDJSON 含一条合法记录和一个坏尾行;合法项先本地加入,服务写入仍由 importGate 挂住。
const beforeImport=documents.get(root).elements.size,importRecord={kind:'element',key:'ui-import',record:{id:'ui-import',incarnation:1,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:'Imported NDJSON node',text:'Visible before ACK'},geometry:{x:700,y:450,width:240,height:160,rotation:0}}},nativeText=JSON.stringify(importRecord)+'\n{bad}';
await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(nativeText)}],'canvas.ndjson',{type:'application/x-ndjson'}));const input=document.querySelector('[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelectorAll('.react-flow__node').length===6&&document.body.innerText.includes('部分加入')`,'native import did not show its successful prefix and partial report');
// 确认网络已进入屏障但服务元素数未增加,页面却已有新节点和 partial 报告,区分显示与 ACK。
for(let i=0;i<100&&!importWriteSeen;i++)await pause(20);assert.ok(importWriteSeen);assert.equal(documents.get(root).elements.size,beforeImport,'import waited for its backend before displaying content');
// 展开失败详情检查可读原因,截图后才释放文档 gate;重载要同时恢复内容与 partial 作业报告。
await evaluate(`[...document.querySelectorAll('summary')].find(summary=>summary.textContent.includes('条未加入记录')).click()`);await until(`document.body.innerText.includes('记录格式错误')`,'import failure reason was hidden');
const importScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-integration.png',Buffer.from(importScreenshot.data,'base64'));releaseImport();
for(let i=0;i<200&&documents.get(root).elements.size!==beforeImport+1;i++)await pause(20);assert.equal(documents.get(root).elements.size,beforeImport+1);await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('部分加入')`,'import task and content did not recover after reload');
// 记录模型 POST 基数,通过临时 Host 公开动作清理导入源;UI 状态和重载结果要保留,但清理不能产生内容操作。
const beforeSourceCollection=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
await until(`typeof window.__headlessCollectSources==='function'`,'fixture Host action was not installed');const sourceCollection=await evaluate(`window.__headlessCollectSources()`);assert.equal(sourceCollection.purgedIds.length,1);
await evaluate(`(()=>{const details=document.querySelector('[aria-label="画布导入"] details');if(!details.open)details.querySelector('summary').click();})()`);
await until(`document.body.innerText.includes('本地源文件已清理')`,'source collection status was not displayed');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeSourceCollection,'source GC created a document operation');
const sourceScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-source-collection.png',Buffer.from(sourceScreenshot.data,'base64'));await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('本地源文件已清理')`,'retired source status/content did not recover');
console.log('PASS Host-triggered source collection displays its status, survives reload and creates zero model operations');
// 源文件已经清理,undo 仍能移除成功前缀;终态导入作业重载后也不能复活已撤销记录。
await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import undo did not remove its successful content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import recovered an undone record');
console.log('PASS actual NDJSON import displays before backend ACK, reports a failed record, restores task/source state and never revives undone content');
await evaluate(`document.querySelector('.react-flow__node[data-id=b]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
await until(`!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'copy selection button remained disabled');
// 工具栏复制走持久导入作业,准备完成后重载保持结果;undo 再重载不能由作业重放补回内容。
await click('深复制选中内容');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]').textContent.includes('复制选中内容')`,'copy did not publish through the durable task pipeline');
await until(`document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="正在准备复制"]')`,'copy preparation did not finish');
await pause(300);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]').textContent.includes('复制选中内容')`,'copy task did not recover after reload');
await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy history did not undo its published content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy replay revived an undone record');
console.log('PASS actual deep copy toolbar action publishes a durable task, survives reload and does not revive its undone content');
// Portalled frame-title drafts are not represented by a node editing mode.
// portal 分区标题草稿由显式 overlay owner 持有,不以节点 mode 代表。
await evaluate(`document.querySelector('.react-flow__node[data-id="relocation-frame"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
await until(`!!document.querySelector('[aria-label="分区名称"]')`,'frame title editor did not open');
// 折叠只影响投影:b 模型端点仍存在且上级连线仍画到分区锚点;展开后成员重新出现。
await click('收起子画布');await until(`!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelectorAll('.react-flow__edge').length>0`,'collapsed partition did not render its upper-canvas connection');assert.equal(documents.get(root).elements.get('edge').target.elementId,'b');assert.ok(documents.get(root).elements.has('b'));await click('展开子画布');await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'expanding partition did not restore the member');
console.log('PASS collapsed partitions retain model endpoints and render upper-canvas connections through headless frame anchors');
// portal 标题需显式注册 view/target;其工具栏按钮的普通 h 键不能被 canvas 工具路由抢走。
assert.ok(await evaluate(`document.querySelector('[aria-label="分区名称"]').closest('[data-canvas-overlay-target]')?.dataset.canvasOverlayView`),'portalled frame title did not register its explicit view/target placement');
assert.equal(await evaluate(`(()=>{const overlay=document.querySelector('[aria-label="分区名称"]').closest('[data-canvas-overlay-target]'),button=overlay.querySelector('button'),event=new KeyboardEvent('keydown',{key:'h',bubbles:true,cancelable:true});button.dispatchEvent(event);return event.defaultPrevented;})()`),false,'portalled toolbar button key was claimed by the canvas tool');
await evaluate(`(()=>{window.pinnedFrameTitle=document.querySelector('[aria-label="分区名称"]');window.pinnedFrameTitle.focus();window.pinnedFrameTitle.select();})()`);await call('Input.insertText',{text:'Pinned partition draft'});
// 两个页面脚本分别大幅平移和回到原点,供 portal 与 IME retention 检查共用;不直接写业务几何。
const panFar=`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`,panHome=`(()=>{const matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:matrix.e/.5,deltaY:matrix.f/.5,bubbles:true,cancelable:true}));})()`;
// 标题仍在编辑时把所属节点移出视口,要求相同 DOM、焦点和草稿保留;blur 保存后恢复裁剪,回视口再挂载。
await evaluate(panFar);await pause(250);assert.equal(await evaluate(`window.pinnedFrameTitle.isConnected&&document.activeElement===window.pinnedFrameTitle&&window.pinnedFrameTitle.value==='Pinned partition draft'`),true,'viewport culling discarded an unsaved frame title');
await evaluate('window.pinnedFrameTitle.blur()');for(let i=0;i<100&&documents.get(root).elements.get('relocation-frame').data.title!=='Pinned partition draft';i++)await pause(20);assert.equal(documents.get(root).elements.get('relocation-frame').data.title,'Pinned partition draft');
await until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after title blur');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'panning back did not remount the node');
await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);
// 文本 IME 组合态也要在离屏时保留同一编辑器;提交后 Escape 释放,再平移回来核对已保存文字。
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'text editor did not enter pinned mode');await evaluate('window.pinnedText=document.activeElement; void 0');await call('Input.imeSetComposition',{text:'保',selectionStart:1,selectionEnd:1});await evaluate(panFar);await pause(250);
assert.equal(await evaluate(`window.pinnedText.isConnected&&document.activeElement===window.pinnedText&&window.pinnedText.value.includes('保')`),true,'viewport culling interrupted active IME');await call('Input.insertText',{text:'保留输入'});for(let i=0;i<100&&!documents.get(root).elements.get('b').data.text.endsWith('保留输入');i++)await pause(20);assert.ok(documents.get(root).elements.get('b').data.text.endsWith('保留输入'));
await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after IME editing');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'edited node did not remount');assert.ok(await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value.endsWith('保留输入')`));
console.log('PASS native viewport culling retains unsaved portalled frame titles and active IME across pan, then resumes and remounts saved content');
await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'retiring editor did not focus');
// 删除仍有焦点的节点时不能等待 blur 才释放所有权;旧 textarea 脱离后应恢复裁剪,历史可重新还原内容。
assert.equal(await evaluate(`(()=>{window.__retiredEditor=document.activeElement;const engine=window.__fixtureActualEngine,element=engine.getSnapshot().document.elements.get('b');return engine.editor.dispatch({type:'element.remove',target:{id:element.id,incarnation:element.incarnation}}).status;})()`),'applied');
await until(`!document.querySelector('.react-flow__node[data-id=b]')`,'focused node did not retire');assert.equal(await evaluate('window.__retiredEditor.isConnected'),false);
await evaluate(panFar);await until(`document.querySelectorAll('.react-flow__node').length===0`,'detached focus left the graph permanently retained');
@@ -458,7 +581,7 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
console.log('PASS deleting a focused renderer releases detached ownership without requiring blur and history restores its content');
// Exceed the curve-inspection budget without retaining unrelated child cards.
// 4097 条边超过曲线检查预算,但仅三个端点/入口 owner 需要挂载;200 个无关离屏子卡应继续裁剪。
const budgetRoot=id(870),budgetChild=id(871),budgetFrame={...node('budget-frame',budgetRoot,0),componentId:'frame',geometry:{x:0,y:400,width:1000,height:600,rotation:0}},budgetNodes=[node('budget-a',budgetRoot,80),node('budget-b',budgetRoot,450),budgetFrame,...Array.from({length:200},(_,index)=>node('budget-hidden-'+index,budgetChild,12000+index*280))],budgetEdges=Array.from({length:4097},(_,index)=>({id:'budget-edge-'+index,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:budgetRoot,source:{elementId:'budget-a'},target:{elementId:'budget-b'},data:{},path:null}));
documents.set(budgetRoot,createDocument(budgetRoot,[{id:budgetRoot,parent:null},{id:budgetChild,parent:{canvasId:budgetRoot,entryElementId:budgetFrame.id}}],[...budgetNodes,...budgetEdges]));versions.set(budgetRoot,1);versions.set(budgetChild,1);boards.push({id:budgetRoot,workspace_id:workspace,kind:'board',version:1,title:'Curve budget board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${budgetRoot}`});
@@ -466,21 +589,25 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="budget-hidden-0"]')`),false);assert.equal(documents.get(budgetRoot).elements.size,4300);assert.equal(await evaluate('window.__fixtureActualEngine.getSnapshot().document.elements.size'),4300);assert.equal(await evaluate(`window.__fixtureActualEngine.getSnapshot().document.elements.get('budget-hidden-199')?.ownerCanvasId`),budgetChild);
console.log('PASS exhausted curve inspection retains all 4097 edges and their endpoint owners while 200 unrelated offscreen cards stay culled');
// Read a fresh project whose known envelope contains opaque component/version payloads.
// 构造未知 component、未来 dataVersion 和不透明 path/ports/presentation;明确区分已知外壳与无法解释的内部字段。
const opaqueRoot=id(800),opaqueChild=id(801),opaqueU={...node('opaque-u',opaqueRoot,50),componentId:'plugin:future',dataVersion:9,data:{title:'Opaque source',ports:{find:'future field'},presentation:{unitScale:'future'},assetId:'opaque-asset',__headlessFallback:{handles:'untrusted'},opaque:{preserve:true}}},opaqueV={...node('opaque-v',opaqueRoot,400),dataVersion:99,data:{title:'Future card',text:{preserve:true},ports:'future field'}},opaqueFrame={...node('opaque-frame',opaqueRoot,100),componentId:'frame',dataVersion:99,geometry:{x:100,y:450,width:350,height:220,rotation:0},data:{title:'Future partition',ports:{preserve:true},presentation:{unitScale:'future'}}},opaqueKnown=node('opaque-known',opaqueRoot,800),opaqueMember=node('opaque-member',opaqueChild,20),opaqueEdge={id:'opaque-edge',incarnation:1,structure:'connection',componentId:'plugin:connection',dataVersion:9,ownerCanvasId:opaqueRoot,source:{elementId:opaqueU.id,portId:'custom-out'},target:{elementId:opaqueV.id,portId:'custom-in'},path:{type:'future',preserve:true},data:{appearance:{color:{preserve:true}}}},regularEdge={...opaqueEdge,id:'opaque-regular',componentId:'connection',dataVersion:1,target:{elementId:opaqueKnown.id,portId:'left'},path:null,data:{}},partitionEdge={...regularEdge,id:'opaque-partition',source:{elementId:opaqueKnown.id,portId:'bottom'},target:{elementId:opaqueMember.id}};
const opaqueOriginal=createDocument(opaqueRoot,[{id:opaqueRoot,parent:null},{id:opaqueChild,parent:{canvasId:opaqueRoot,entryElementId:opaqueFrame.id}}],[opaqueU,opaqueV,opaqueFrame,opaqueKnown,opaqueMember,opaqueEdge,regularEdge,partitionEdge]);documents.set(opaqueRoot,opaqueOriginal);versions.set(opaqueRoot,1);versions.set(opaqueChild,1);boards.push({id:opaqueRoot,workspace_id:workspace,kind:'board',version:1,title:'Compatibility board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${opaqueRoot}`});await until(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length===3&&document.querySelectorAll('.react-flow__edge').length===3`,'opaque placeholders or their original connections did not render');
assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node').length`),4);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="opaque-member"]')`),false);assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').classList.contains('draggable')`),false);assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').querySelectorAll('textarea,input').length`),0);assert.ok(await evaluate(`[...document.querySelector('.react-flow__node[data-id="opaque-u"]').querySelectorAll('[data-handleid]')].some(handle=>handle.dataset.handleid==='custom-out'&&!handle.classList.contains('connectablestart')&&!handle.classList.contains('connectableend'))`));
// 本场景的 opaque-u 占位仍可选且有视觉反馈,但不可拖动/编辑/连线;已知邻居编辑不得改写不透明记录。
const opaqueBorder=await evaluate(`getComputedStyle(document.querySelector('[data-id="opaque-u"] [role=note]')).borderColor`);await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`document.querySelector('.react-flow__node[data-id="opaque-u"]').classList.contains('selected')`,'opaque selection did not publish');assert.notEqual(await evaluate(`getComputedStyle(document.querySelector('[data-id="opaque-u"] [role=note]')).borderColor`),opaqueBorder,'placeholder selection was not visible');
await evaluate(`(()=>{const card=document.querySelector('.react-flow__node[data-id="opaque-known"]');card.dispatchEvent(new MouseEvent('click',{bubbles:true}));card.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="opaque-known"] textarea')&&!document.activeElement.readOnly`,'known neighbour was not editable');await evaluate('document.activeElement.select()');await call('Input.insertText',{text:'Known edit with opaque neighbours'});await evaluate('document.activeElement.blur()');for(let i=0;i<100&&documents.get(opaqueRoot).elements.get('opaque-known').data.text!=='Known edit with opaque neighbours';i++)await pause(20);assert.equal(documents.get(opaqueRoot).elements.get('opaque-known').data.text,'Known edit with opaque neighbours');for(const record of [opaqueU,opaqueV,opaqueFrame,opaqueEdge])assert.deepEqual(documents.get(opaqueRoot).elements.get(record.id),record);assert.equal(calls.some(call=>call.path.endsWith('/assets/opaque-asset')),false);
// 重载后占位和连接仍在,未知 frame 的子成员不应递归展示;并核对未请求 opaque-asset 的显示字节。
await call('Page.reload');await until(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length===3&&document.querySelectorAll('.react-flow__edge').length===3`,'opaque content or connections did not survive recovery');assert.equal(documents.get(opaqueRoot).elements.get('opaque-partition').target.elementId,'opaque-member');
await click('适应画布');await pause(100);await click('缩小');await pause(100);await click('缩小');await pause(100);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await writeFile(join(tmpdir(),'headless-opaque-renderer.png'),Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
console.log('PASS unknown types and future versions render safe read-only placeholders with custom/partition connections, preserve opaque payloads through edits and recovery, and never load opaque asset fields');
// 宿主扩展安装三个计量节点、两条自定义边和一条内置边,检查自定义端口与原始 data/path 保留。
const extensionRoot=id(810),meter={...node('meter-one',extensionRoot,350),componentId:'fixture:meter',data:{metric:3,raw:{retained:true}}},meterTwo={...meter,id:'meter-two',geometry:{...meter.geometry,x:750},data:{metric:9}},meterThree={...meter,id:'meter-three',geometry:{...meter.geometry,x:1150},data:{metric:12}},meterEdge={id:'meter-edge',incarnation:1,structure:'connection',componentId:'fixture:metric-edge',dataVersion:1,ownerCanvasId:extensionRoot,source:{elementId:meter.id,portId:'custom-out'},target:{elementId:meterTwo.id,portId:'custom-in'},data:{metric:55},path:{type:'metric-curve',retained:true}};
documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterThree,meterEdge,{...meterEdge,id:'unrelated-meter-edge',source:{elementId:'meter-two',portId:'custom-out'},target:{elementId:'meter-three',portId:'custom-in'}},{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===3&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===2`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===3`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension baseline did not settle');await pause(200);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersBefore=await evaluate('({...window.__fixtureRenders})'),projectsBefore=await evaluate('({...window.__fixtureProjectedNodes})');await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension edit/ACK did not settle');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersAfter=await evaluate('({...window.__fixtureRenders})');assert.ok(rendersAfter['meter-one']>rendersBefore['meter-one'],'edited custom node did not render');assert.equal(rendersAfter['meter-two'],rendersBefore['meter-two'],'unrelated custom node rendered during edit/ACK');assert.equal(rendersAfter['unrelated-meter-edge'],rendersBefore['unrelated-meter-edge'],'unrelated custom edge rendered during edit/ACK');const projectsAfter=await evaluate('({...window.__fixtureProjectedNodes})');assert.ok(projectsAfter['meter-one']>projectsBefore['meter-one']);assert.equal(projectsAfter['meter-two'],projectsBefore['meter-two'],'unrelated node projection rebuilt during edit/ACK');assert.equal(projectsAfter['meter-three'],projectsBefore['meter-three'],'unrelated node projection rebuilt during edit/ACK');console.log('PASS actual registered renderer updates the edited node while unrelated node/edge render counts remain unchanged through ACK');
await until(`window.__fixtureActualEngine?.projectId===${JSON.stringify(extensionRoot)}||window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(extensionRoot)}`,'extension engine unavailable');
// 完整 scene rebuild 与逐节点 projection 分开计数;viewport/tool/mode 来回变化不应触发整图重建。
const beforeScene=await evaluate('window.__fixtureSceneBuilds');assert.ok(beforeScene>0);
await evaluate(`(()=>{const e=window.__fixtureActualEngine,v=[...e.sessions.getSnapshot().views.values()][0];window.__fixtureViewBefore={id:v.id,viewport:v.viewport,tool:v.tool};e.sessions.viewport(v.id,{x:80,y:45,zoom:1.2});e.sessions.tool(v.id,'pan');e.sessions.mode(v.id,'meter-one','projection-check');})()`);
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');await pause(100);
@@ -489,23 +616,31 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureSceneBuilds'),beforeScene);
console.log('PASS actual CanvasView viewport/tool/mode transitions reuse the scene without projection traversal');
// 插件按钮的 metric/raw 两次修改属于同一事务历史;undo/redo/reload 均通过公开命令和存储恢复。
await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover');
console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload');
// 无 rendered target 的旧 editing facade 必须无效,不能仅凭元素 ID 越过作用域校验。
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,before=engine.getSnapshot();window.__fixtureUnbound['meter-one'].rename('meter-one','unbound callback');return engine.getSnapshot()===before;})()`),true,'headless legacy editing without a rendered target was allowed');
// 目标按钮先 preventDefault 的 Escape 保留手势;后续另测 shadow textarea、select 和未消费的外部 Escape。
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,view=[...engine.sessions.getSnapshot().views.values()][0],record=engine.getSnapshot().document.elements.get('meter-one'),button=document.createElement('button');document.querySelector('[data-headless-canvas]').append(button);button.addEventListener('keydown',event=>event.preventDefault());engine.sessions.begin(view.id,[{id:record.id,incarnation:record.incarnation}]);const token=engine.sessions.getSnapshot().gesture?.token;button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));const retained=!!token&&engine.sessions.getSnapshot().gesture?.token===token;button.remove();engine.sessions.cancel('fixture_cleanup');return retained;})()`),true,'capture-phase Escape canceled a gesture before the target could claim it');
assert.deepEqual(await evaluate(`(()=>{const engine=window.__fixtureEngine,view=[...engine.sessions.getSnapshot().views.values()][0],record=engine.getSnapshot().document.elements.get('meter-one'),canvas=document.querySelector('[data-headless-canvas]'),host=document.createElement('div');canvas.append(host);const shadow=host.attachShadow({mode:'open'}),textarea=document.createElement('textarea');shadow.append(textarea);const select=document.createElement('select');canvas.append(select);const fire=element=>{const event=new KeyboardEvent('keydown',{key:'h',composed:true,bubbles:true,cancelable:true});element.dispatchEvent(event);return event.defaultPrevented;};const protectedKeys=[fire(textarea),fire(select)];host.remove();select.remove();engine.sessions.begin(view.id,[{id:record.id,incarnation:record.incarnation}]);const outside=document.createElement('button');document.body.append(outside);const event=new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true});outside.dispatchEvent(event);outside.remove();return{protectedKeys,tool:engine.sessions.getSnapshot().views.get(view.id).tool,canceled:!engine.sessions.getSnapshot().gesture,claimed:event.defaultPrevented};})()`),{protectedKeys:[false,false],tool:'select',canceled:true,claimed:true});
console.log('PASS keyboard targets claim Escape before canvas cancellation; native selects and shadow text retain their input, while unclaimed outside Escape cancels the owned gesture');
// 删除再 undo 后元素进入第二代;捕获的第一代旧 facade 必须拒绝编辑并保持 Root 引用不变。
const staleEdit=await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],target=engine.getSnapshot().document.elements.get('meter-one');window.__fixtureStaleLegacy=old;engine.editor.dispatch({type:'element.remove',target:{id:target.id,incarnation:target.incarnation}});engine.editor.history.undo();const restored=engine.getSnapshot();old.rename('meter-one','stale callback');return{unchanged:engine.getSnapshot()===restored,incarnation:restored.document.elements.get('meter-one').incarnation};})()`);assert.equal(staleEdit.incarnation,2);assert.equal(staleEdit.unchanged,true,'old rendered edit callback modified a restored incarnation');
console.log('PASS old rendered edit callbacks cannot modify a restored incarnation');
// 等待 renderer 绑定新身份后新 facade 才可写;旧目标 resize 清理也不能取消另一个目标的 mode。
await until(`window.__fixtureLegacy['meter-one']!==window.__fixtureStaleLegacy&&window.__fixtureEngine.getSnapshot().document.elements.get('meter-one').incarnation===2`,'restored renderer did not bind a fresh edit target');await evaluate(`window.__fixtureLegacy['meter-one'].rename('meter-one','fresh callback')`);for(let i=0;i<200&&documents.get(extensionRoot).elements.get('meter-one')?.data.title!=='fresh callback';i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.title,'fresh callback');
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],other=window.__fixtureLegacy['meter-two'];old.resize('meter-one');other.resize('meter-two');old.resize(null);const view=[...engine.sessions.getSnapshot().views.values()][0],retained=view.modes.get('meter-two')==='resize';other.resize(null);return retained;})()`),true,'old target mode cleanup canceled another target');
await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension state did not settle before insertion');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const insertionBefore=await evaluate('({projects:{...window.__fixtureProjectedNodes},scenes:window.__fixtureSceneBuilds})'),extensionSize=documents.get(extensionRoot).elements.size;await click('添加文本卡片');
// 新增普通文本并等 ACK 后,三个未变化计量节点 projection 计数与完整 scene 计数应都不增加。
for(let i=0;i<200&&documents.get(extensionRoot).elements.size!==extensionSize+1;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.size,extensionSize+1);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'inserted card did not synchronize');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const insertionAfter=await evaluate('({projects:{...window.__fixtureProjectedNodes},scenes:window.__fixtureSceneBuilds})');for(const id of ['meter-one','meter-two','meter-three'])assert.equal(insertionAfter.projects[id],insertionBefore.projects[id],'ordinary card insertion rebuilt '+id);assert.equal(insertionAfter.scenes,insertionBefore.scenes,'ordinary card insertion rebuilt the whole scene');console.log('PASS actual toolbar card insertion and ACK retain every unrelated node projection and avoid full scene traversal');
// 导航前保存旧 editing facade;视图 detach 后项目 engine 可保留,但旧视图回调不得再写。
await evaluate(`(()=>{window.__detachedEngine=window.__fixtureEngine;window.__detachedEditing=window.__fixtureLegacy['meter-one'];[...document.querySelectorAll('a')].find(link=>link.pathname.endsWith('/${other}')).click();})()`);await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'renderer view did not detach on navigation');assert.equal(await evaluate(`(()=>{const before=window.__detachedEngine.getSnapshot();window.__detachedEditing.rename('meter-one','detached callback');return window.__detachedEngine.getSnapshot()===before;})()`),true,'detached renderer wrote into its retained project');
console.log('PASS restored renderers can edit with fresh bindings and detached views cannot write through old callbacks');
// 合法首元素后返回非法流尾,页面保留 partial editor 和可读错误;修复 fixture 后重新加载成为完整编辑器,全程无模型写入。
brokenStreamRoot=id(830);const brokenRoot=brokenStreamRoot;
documents.set(brokenRoot,createDocument(brokenRoot,[{id:brokenRoot,parent:null}],[{...node('prefix-visible',brokenRoot,80),data:{title:'Preserved prefix',text:'prefix-kept'}},node('tail-node',brokenRoot,450)]));versions.set(brokenRoot,1);boards.push({id:brokenRoot,workspace_id:workspace,kind:'board',version:1,title:'Interrupted board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${brokenRoot}`});
@@ -515,29 +650,37 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&document.querySelectorAll('.react-flow__node').length===2&&!document.querySelector('[role="alert"]')`,'retry did not replace the failed prefix with a complete editor');
console.log('PASS malformed model tail retains its legal partial editor, reports a readable error and retries into the complete editor without model writes');
// 首帧包含完整根 owner,却缺子 canvas 一项;下面检查根的编辑、创建、导入、复制和图片可先 ACK,不必等子尾部。
scopeStreamRoot=id(850);const scopedRoot=scopeStreamRoot,scopedChild=id(851),scopeFrame={...node('scope-frame',scopedRoot,450),componentId:'frame',geometry:{x:450,y:150,width:600,height:450,rotation:0}};
documents.set(scopedRoot,createDocument(scopedRoot,[{id:scopedRoot,parent:null},{id:scopedChild,parent:{canvasId:scopedRoot,entryElementId:scopeFrame.id}}],[node('scope-node',scopedRoot,80),scopeFrame,node('scope-child',scopedChild,20),node('scope-tail',scopedChild,320)]));versions.set(scopedRoot,1);versions.set(scopedChild,1);boards.push({id:scopedRoot,workspace_id:workspace,kind:'board',version:1,title:'Scope ACK board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${scopedRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="scope-node"]')`,'scope fixture did not show its certified owner');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await until(`(()=>{const layer=document.querySelector('.react-flow__node[data-id="scope-node"] [aria-label^="单击选中"]');if(!layer)return false;const r=layer.getBoundingClientRect();for(const x of [.25,.5,.75,.9])for(const y of [.25,.5,.75]){const point={x:r.x+r.width*x,y:r.y+r.height*y};if(layer.contains(document.elementFromPoint(point.x,point.y))){window.__scopeEditPoint=point;return true;}}return false;})()`,'scope text remained covered by another surface');const scopePoint=await evaluate('window.__scopeEditPoint');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'certified scope text did not enter editing');await call('Input.insertText',{text:' ACK before tail'});const scopeText=await evaluate('document.activeElement.value');
// 完整根作用域先到版本 2,子作用域仍为 1 且 scope-tail 未显示,证明不是等整个项目完成才发送。
for(let i=0;i<150&&documents.get(scopedRoot).elements.get('scope-node').data.text!==scopeText;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get('scope-node').data.text,scopeText,'certified scope edit waited for another owner tail');assert.equal(versions.get(scopedRoot),2);assert.equal(versions.get(scopedChild),1);assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`),false);
assert.equal(await evaluate(`document.querySelector('button[aria-label="添加文本卡片"]').disabled`),false);await click('添加文本卡片');
// 来源未完整时新建卡片的 undo/redo 仍使用新 incarnation;根版本递增到 5,不能把出生与旧源清单混淆。
for(let i=0;i<150&&documents.get(scopedRoot).elements.size!==5;i++)await pause(20);const born=[...documents.get(scopedRoot).elements.values()].find(element=>!['scope-node','scope-frame','scope-child','scope-tail'].includes(element.id));assert.ok(born,'certified scope did not create a new card before the tail');await click('撤销');for(let i=0;i<150&&documents.get(scopedRoot).elements.has(born.id);i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.has(born.id),false);await click('重做');for(let i=0;i<150&&documents.get(scopedRoot).elements.get(born.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);assert.equal(versions.get(scopedRoot),5);
assert.equal(await evaluate(`document.querySelector('button[aria-label="导入画布记录"]').disabled`),false);
// 在认证根内从文件输入加入记录,再复制已闭合的 scope-node;每项都检查尾部到达前服务模型已增加。
await evaluate(`(()=>{const input=document.querySelector('input[data-canvas-import]'),transfer=new DataTransfer();transfer.items.add(new File([JSON.stringify({kind:'element',key:'partial-import',record:{id:'partial-import',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'Imported before tail',text:'streaming'},geometry:{x:0,y:0,width:100,height:80,rotation:0}}})+'\\n'],'partial.ndjson',{type:'application/x-ndjson'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
for(let i=0;i<200&&![...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail');i++)await pause(20);assert.ok([...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail'),'partial page import did not reach server before tail');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('button[aria-label="深复制选中内容"]').disabled`,'closed source copy stayed disabled during loading');const copyCount=documents.get(scopedRoot).elements.size;await click('深复制选中内容');
for(let i=0;i<200&&documents.get(scopedRoot).elements.size===copyCount;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.size,copyCount+1,'page copy did not publish before tail');await until(`!!document.querySelector('button[aria-label="深复制选中内容"]')`,'copy did not finish');assert.equal(await evaluate(`document.querySelector('button[aria-label="添加图片"]').disabled`),false);const beforeImage=documents.get(scopedRoot).elements.size,uploadsBefore=uploads.length;
await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),transfer=new DataTransfer();transfer.items.add(new File([bytes],'loading-pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
// 真实 PNG 在同一认证根上传并自动定高 130,loading 标志仍在;保存此时版本供尾部合并检查。
for(let i=0;i<200&&documents.get(scopedRoot).elements.size===beforeImage;i++)await pause(20);const loadingImage=[...documents.get(scopedRoot).elements.values()].find(element=>element.componentId==='image');assert.ok(loadingImage,'scoped image waited for the unrelated tail');assert.equal(uploads.length,uploadsBefore+1);assert.equal(loadingImage.geometry.height,130,'scoped image lost default sizing');assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);const scopeVersion=versions.get(scopedRoot);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'original text focus did not return after structural history');
// 放行最初捕获尾部后保留同一 textarea/焦点/文字、第二代出生记录和已推进版本,不能重复发送旧来源。
await evaluate('window.__scopeTextarea=document.activeElement;true');releaseScopeStream();await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`,'original stream did not complete after a scoped ACK');assert.equal(await evaluate('window.__scopeTextarea.isConnected&&document.activeElement===window.__scopeTextarea'),true);assert.equal(await evaluate('window.__scopeTextarea.value'),scopeText);assert.equal(versions.get(scopedRoot),scopeVersion,'old tail caused a duplicate write');assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);console.log('PASS certified owner image/import/copy, edits and create/undo/redo ACK before another canvas tail, then original-source completion preserves content, focus, history and advanced versions');
// 另一个 partial 项目在未完整 owner 上有本地编辑;导航离开后由 project owner 采用新完整读取完成保存和同步。
abandonedRoot=id(840);const backgroundRoot=abandonedRoot;
documents.set(backgroundRoot,createDocument(backgroundRoot,[{id:backgroundRoot,parent:null}],[node('background-node',backgroundRoot,80),node('background-tail',backgroundRoot,450)]));versions.set(backgroundRoot,1);boards.push({id:backgroundRoot,workspace_id:workspace,kind:'board',version:1,title:'Background loading board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${backgroundRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="background-node"]')`,'background fixture did not open the prefix');
// 先原生编辑前缀并断言无 POST,再导航另一项目;检查后台取得非 NDJSON 新完整读且晚到旧流不能改当前路由。
const backgroundPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="background-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+60,y:r.y+30};})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...backgroundPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...backgroundPoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="background-node"] textarea')&&!document.activeElement.readOnly`,'background prefix did not enter editing');await call('Input.insertText',{text:' background-local'});const backgroundText=await evaluate('document.activeElement.value');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.includes('/'+backgroundRoot+'/canvas/model/operations')).length,0);
@@ -545,56 +688,70 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
for(let i=0;i<150&&documents.get(backgroundRoot).elements.get('background-node').data.text!==backgroundText;i++)await pause(20);assert.equal(documents.get(backgroundRoot).elements.get('background-node').data.text,backgroundText,'leaving the view stranded a local edit behind the loading hold');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+backgroundRoot+'/canvas/model')&&call.headers.accept!=='application/vnd.foundry.canvas-model+ndjson;version=1'));
releaseAbandoned();await pause(100);assert.ok((await evaluate('location.pathname')).endsWith(other));console.log('PASS leaving a partial editor hands completion to the project owner, persists and synchronizes its edit through a fresh read without reopening the view');
// JSON 数组通过真实 file input 进入原生记录导入,既核对页面和服务内容,也在 reload 后检查来源文件名与作业。
await evaluate(`(()=>{const transfer=new DataTransfer(),record={kind:'element',key:'json-ui',record:{id:'json-ui',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'JSON array UI',text:'JSON array UI text'},geometry:{x:80,y:100,width:240,height:160,rotation:0}}};transfer.items.add(new File([JSON.stringify([record])],'records.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelector('[aria-label="画布导入"]').textContent.includes('已加入画布')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')`,'JSON array did not render through the file picker');
for(let i=0;i<200&&![...documents.get(other).elements.values()].some(e=>e.data.title==='JSON array UI');i++)await pause(20);assert.ok([...documents.get(other).elements.values()].some(e=>e.data.title==='JSON array UI'));
await call('Page.reload');await until(`[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')&&document.querySelector('[aria-label="画布导入"]').textContent.includes('records.json')`,'JSON array task/content did not recover after reload');
console.log('PASS JSON array file picker imports and synchronizes native records then recovers its original task and content after reload');
// 临时拦截带 download 的 anchor,只读取 Blob 导出文本;随即恢复原 click,不真的发起下载。
await evaluate(`(()=>{const original=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){if(this.download){window.__exportedDocument=fetch(this.href).then(response=>response.text());return;}return original.call(this);};document.querySelector('[aria-label="导出图谱"]').click();HTMLAnchorElement.prototype.click=original;})()`);
// 完整文档导出格式为 1,重导入应增加与源元素数相同的新元素;parserVersion=5 核对使用流式文档解释器。
const exported=await evaluate('window.__exportedDocument');assert.equal(JSON.parse(exported).documentFormatVersion,1);const beforeDocumentImport=documents.get(other).elements.size,roundTripElementCount=JSON.parse(exported).elements.length;
await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(exported)}],'round-trip.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'exported document did not reimport');
for(let i=0;i<200&&documents.get(other).elements.size!==beforeDocumentImport+roundTripElementCount;i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeDocumentImport+roundTripElementCount);await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'document round trip task/content did not recover');
assert.equal(await evaluate(`(()=>{const job=[...window.__fixtureActualEngine.imports.getJobs().values()].find(job=>job.source?.name==='round-trip.json');return job?.parserVersion;})()`),5,'document picker did not recover the streaming interpreter');
console.log('PASS actual toolbar JSON export reimports through file picker with fresh elements and recovers after reload');
// 文档图片故意带现有 assetId 与不可信旧 src,导入必须重新授权并重建显示引用,不重复上传。
const importedAssetId=[...assets.keys()][0],uploadsBeforeDocumentImage=uploads.length,imageImportDocument={documentFormatVersion:1,projectId:'image-source',canvases:[{id:'image-source',parent:null}],elements:[{id:'image-from-file',incarnation:1,ownerCanvasId:'image-source',componentId:'image',dataVersion:1,structure:'node',geometry:{x:400,y:100,width:200,height:100,rotation:0},data:{title:'Document imported image',text:'',assetId:importedAssetId,src:'https://untrusted.test/stale'}}],tombstones:{}};
const beforeImageImport=documents.get(other).elements.size;
await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(JSON.stringify(imageImportDocument))}],'image-document.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
for(let i=0;i<200&&![...documents.get(other).elements.values()].some(e=>e.data.title==='Document imported image');i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeImageImport+1);const importedImage=[...documents.get(other).elements.values()].find(e=>e.data.title==='Document imported image');assert.equal(importedImage.data.assetId,importedAssetId);assert.notEqual(importedImage.data.src,'https://untrusted.test/stale');assert.equal(uploads.length,uploadsBeforeDocumentImage);
await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('image-document.json')&&!!document.querySelector('.react-flow__node[data-id="${importedImage.id}"] img')`,'authorized document image did not recover after reload');assert.equal(uploads.length,uploadsBeforeDocumentImage);
console.log('PASS document image import reauthorizes existing resource, ignores stale URL and recovers without reuploading');
// 分别控制 parser I/O 与全源摘要:先挂住摘要 Worker 结果,让解析完成和来源验证可以独立观察。
await evaluate(`(()=>{window.__fixtureDigestWaiting=false;window.__fixtureDigestGate=new Promise(resolve=>{window.__releaseSourceDigest=()=>{delete window.__fixtureDigestGate;resolve();};});})()`);
// GatedDocument.slice 只延迟 16KiB 解析读取且偏移至少 32KiB 的尾部;首项可先入画布,摘要由另一 gate 控制。
await evaluate(`(()=>{const engine=window.__fixtureActualEngine,view=[...engine.sessions.getSnapshot().views.values()][0];let release;const gate=new Promise(resolve=>release=resolve);window.__releaseDocumentTail=()=>{window.__documentTailReleased=true;release();};class GatedDocument extends Blob{slice(start=0,end=this.size,...rest){const part=super.slice(start,end,...rest);if(end-start!==16384||start<32768||window.__documentTailReleased)return part;return{async arrayBuffer(){window.__documentTailWaiting=true;await gate;return part.arrayBuffer();}};}}const element=(id,text)=>({id,incarnation:1,structure:'node',ownerCanvasId:'stream-source',componentId:'text',dataVersion:1,data:{title:id,text},geometry:{x:0,y:0,width:260,height:200,rotation:0}}),input={documentFormatVersion:1,projectId:'stream-source',canvases:[{id:'stream-source',parent:null}],elements:[element('dom-prefix','Document progressive prefix'),element('dom-tail','x'.repeat(100000))],tombstones:{}};window.__streamDocumentJob=engine.imports.startDocument(new GatedDocument([JSON.stringify(input)]),view.canvasId);})()`);
// 确认 parser 尾部实际等待且未放开时,第一项 textarea 已挂载;随后原生输入必须能修改该前缀。
await until(`(()=>{const job=window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob),id=job.stableIds['element:dom-prefix'];window.__streamDocumentFirst=id;return window.__documentTailWaiting&&!window.__documentTailReleased&&!!document.querySelector('.react-flow__node[data-id="'+id+'"] textarea');})()`,'document prefix did not display while parser tail was blocked');
await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id="'+window.__streamDocumentFirst+'"]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="'+window.__streamDocumentFirst+'"] textarea')&&!document.activeElement.readOnly`,'progressive document prefix did not accept editing');await call('Input.insertText',{text:' edited before tail'});assert.equal(await evaluate(`window.__fixtureActualEngine.getSnapshot().document.elements.get(window.__streamDocumentFirst).data.text.endsWith('edited before tail')`),true);assert.equal(await evaluate('window.__documentTailReleased===true'),false);await evaluate('window.__releaseDocumentTail()');
// parser completed 时 sha256 仍缺失且服务还无首项;释放摘要后才同步,并保留摘要/尾部等待期间的用户编辑。
await until(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).status==='completed'&&window.__fixtureDigestWaiting`,'streaming document did not complete parsing while full SHA was blocked');const streamFirstId=await evaluate('window.__streamDocumentFirst');assert.equal(await evaluate(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).source.sha256`),undefined);assert.equal(documents.get(other).elements.has(streamFirstId),false,'unverified source reached backend');await evaluate('window.__releaseSourceDigest()');for(let i=0;i<200&&!documents.get(other).elements.get(streamFirstId)?.data.text.endsWith('edited before tail');i++)await pause(20);assert.ok(documents.get(other).elements.get(streamFirstId).data.text.endsWith('edited before tail'));assert.equal(await evaluate(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).committedSourceKeys.length`),2);
console.log('PASS actual document prefix displays and accepts native text edits before full SHA; parser completion cannot sync until verification, then preserves those edits');
// 两端节点矩形在视口外,但自定义曲线弯入视口;不能只用端点矩形决定把可见边和必要 owner 裁掉。
const bowRoot=id(75);versions.set(bowRoot,1);boards.push({id:bowRoot,workspace_id:workspace,kind:'board',version:1,title:'Bowed paint board'});documents.set(bowRoot,createDocument(bowRoot,[{id:bowRoot,parent:null}],[{...node('bow-source',bowRoot,0),geometry:{x:0,y:2000,width:260,height:200,rotation:0}},{...node('bow-target',bowRoot,600),geometry:{x:600,y:2000,width:260,height:200,rotation:0}},{id:'bow-edge',incarnation:1,ownerCanvasId:bowRoot,structure:'connection',componentId:'connection',dataVersion:1,data:{},source:{elementId:'bow-source'},target:{elementId:'bow-target'},path:{type:'bezier-chain',knots:[{id:'bow-knot',mode:'corner',position:{x:400,y:0},in:{x:-100,y:0},out:{x:100,y:0}}]}}]));
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${bowRoot}`});await until(`!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path')`,'bowed curve fixture did not open');
await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),r=pane.getBoundingClientRect(),m=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(400*m.a+m.e-r.width/2)/.5,deltaY:(m.f-r.height/2)/.5,bubbles:true,cancelable:true}));})()`);
await until(`(()=>{const viewport=document.querySelector('.react-flow__pane').getBoundingClientRect(),nodes=[...document.querySelectorAll('.react-flow__node')];return nodes.length===2&&nodes.every(node=>{const r=node.getBoundingClientRect();return r.bottom<viewport.top||r.top>viewport.bottom;})&&!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path');})()`,'offscreen endpoints caused visible bowed paint to unmount');
assert.equal(documents.get(bowRoot).elements.size,3);console.log('PASS bowed curve paint remains mounted with both endpoint rectangles outside the viewport');
// 嵌套节点的拖动预览绑定起点坐标上下文;外部移动其 ancestor 时必须取消该预览。
const contextRoot=id(852),contextChild=id(853),contextFrame={...node('context-frame',contextRoot,400),componentId:'frame',geometry:{x:400,y:100,width:640,height:400,rotation:0}},contextNode={...node('context-node',contextChild,50),geometry:{x:50,y:60,width:260,height:200,rotation:0}};
documents.set(contextRoot,createDocument(contextRoot,[{id:contextRoot,parent:null},{id:contextChild,parent:{canvasId:contextRoot,entryElementId:contextFrame.id}}],[contextFrame,contextNode]));versions.set(contextRoot,1);versions.set(contextChild,1);boards.push({id:contextRoot,workspace_id:workspace,kind:'board',version:1,title:'Gesture context board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${contextRoot}`});await until(`!!document.querySelector('.react-flow__node[data-id="context-node"]')&&[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'nested gesture context board did not settle');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// 先 elementFromPoint 证实子节点可点,原生按下/移动后等待实际手势 token,再通过公开命令移动父 frame。
const contextPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="context-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+r.width*.3,y:r.y+Math.min(20,r.height*.2)}})()`);
assert.equal(await evaluate(`document.elementFromPoint(${contextPoint.x},${contextPoint.y})?.closest('.react-flow__node')?.getAttribute('data-id')`),'context-node','nested test pointer is covered by another surface');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...contextPoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:contextPoint.x+35,y:contextPoint.y+25,button:'left',buttons:1});
await until(`window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(contextRoot)}&&window.__fixtureActualEngine.sessions.getSnapshot().gesture?.targets.some(target=>target.id==='context-node')`,'nested node did not acquire its drag');
assert.equal(await evaluate(`window.__fixtureActualEngine.editor.dispatch({type:'element.geometry',target:{id:'context-frame',incarnation:1},patch:{x:600}}).status`),'applied');
// 父变换发布后预览应清空;迟到 mouseReleased 不得写子局部几何,页面改按新父位置投影。
assert.equal(await evaluate('window.__fixtureActualEngine.sessions.getSnapshot().gesture'),null,'ancestor edit retained the old coordinate preview');
await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:contextPoint.x+35,y:contextPoint.y+25,button:'left',buttons:0});
for(let i=0;i<100&&documents.get(contextRoot).elements.get('context-frame').geometry.x!==600;i++)await pause(20);
assert.equal(documents.get(contextRoot).elements.get('context-frame').geometry.x,600);assert.deepEqual(documents.get(contextRoot).elements.get('context-node').geometry,contextNode.geometry);
await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="context-node"]');return node&&Math.abs(new DOMMatrix(node.style.transform).m41-674)<.01&&!node.classList.contains('dragging');})()`,'nested node did not return to its new ancestor projection');
// 检查服务收到的 changes 只涉及父移动,不含 context-node,补足单看 DOM 复位无法证明的“未写旧坐标”。
const contextWrites=calls.filter(call=>call.method==='POST'&&call.path.includes('/'+contextRoot+'/canvas/model/operations')).flatMap(call=>JSON.parse(call.bytes.toString()).operations.flatMap(operation=>operation.changes));assert.ok(contextWrites.length);assert.equal(contextWrites.some(change=>change.target?.id==='context-node'),false,'late nested drag wrote the old coordinate result');
console.log('PASS ancestor movement cancels the actual nested drag; late pointerup preserves child geometry and renders its new parent position');
// 最后走实际账户菜单退出登录,要求安全到 /login 且无 render error;另核对至少有一次真实代理 NDJSON 协商。
await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`);
await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view');
assert.ok(calls.some(call=>call.path.endsWith('/canvas/model')&&call.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'));
@@ -602,9 +759,13 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
console.log('PASS logout retires the runtime before dropping its view without a render error');
}
} finally {
// 统一放开挂起响应并关闭 CDP;不依赖正在等待 gate 的请求自行退出。
releaseScopeStream();releaseAbandoned();releaseFirstModel();releaseImport();releaseApply();releaseUpload(); socket?.close();
// 先请求浏览器退出,三秒仍未结束则 SIGKILL,再等待 exit 并销毁输出流。
if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await Promise.race([exit,pause(3000)]);if(chrome.exitCode===null)chrome.kill('SIGKILL');await exit;chrome.stdin?.destroy();chrome.stdout?.destroy();chrome.stderr?.destroy(); }
// 按进程组向 Next 发 SIGTERM,超时用 SIGKILL,避免只杀父进程留下编译子进程。
if (next?.pid) { const exit = new Promise(resolve => { if (next.exitCode !== null) resolve(); else next.once('exit', resolve); }); try { process.kill(-next.pid, 'SIGTERM'); } catch {} await Promise.race([exit, pause(3000)]); if (next.exitCode === null) try { process.kill(-next.pid, 'SIGKILL'); } catch {} }
// 强制关闭上游残留连接再停止监听,最后删除本次应用和 profile 两个确切目录。
upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve));
await rm(directory, { recursive: true, force: true, maxRetries: 5 }); await rm(profile, { recursive: true, force: true, maxRetries: 5 });
}
+6
View File
@@ -1,7 +1,12 @@
/**
* 会话 cookie 的期限与属性单元测试。直接调用格式化函数,不连接浏览器 cookie jar 或真实认证后端。
* 后端剩余期限与前端 30 天上限取有效结果;数值 0 表示立即过期,非法输入回退默认值。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { sessionCookie, sessionMaxAge, SESSION_IDLE_SECONDS } from "../src/lib/session-cookie.ts";
// 核对秒数常量、可解析整数字符串与最大期限;0 不可误回退,负数、非有限值、非整数等使用默认 30 天。
test("session cookies respect backend remaining lifetime and a 30-day maximum", () => {
assert.equal(SESSION_IDLE_SECONDS, 2592000);
assert.equal(sessionMaxAge(2592000), 2592000);
@@ -12,6 +17,7 @@ test("session cookies respect backend remaining lifetime and a 30-day maximum",
for (const invalid of [Infinity, "garbage", "1.5", NaN, {}, null]) assert.equal(sessionMaxAge(invalid), 2592000);
});
// 只检查生成的 Set-Cookie 文本:后端期限、HttpOnly、SameSite=Lax、可选 Secure 和 Max-Age=0;不代表端到端会话续期已验证。
test("renewed cookies retain HttpOnly/SameSite and use the backend-capped deadline", () => {
const cookie = sessionCookie("session-token", 42, true);
assert.match(cookie, /Max-Age=42;/); assert.match(cookie, /HttpOnly/); assert.match(cookie, /SameSite=Lax/); assert.match(cookie, /; Secure$/);
+13
View File
@@ -1,7 +1,12 @@
/**
* workspace 首页、board 创建/重命名与 URL 的请求契约单元测试。request 替身记录调用次序和 payload,不运行 HTTP 或数据库。
* 首次创建 workspace 与 board 不是一个服务端事务:workspace 成功后需先通知调用者保留身份,即使后续 board 失败。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { boardHref, createBoard, renameBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome } from "../src/lib/workspace.ts";
// 把两个请求都挂起后检查启动记录,证明 profile/workspaces 无串行瀑布;分别释放再检查结果整合和头像。
test("profile and workspace reads both start before either completes", async () => {
const calls = [];
const resolve = {};
@@ -20,6 +25,7 @@ test("profile and workspace reads both start before either completes", async ()
assert.equal(result.workspaces[0].id, "space");
});
// 没有 workspace 时先创建个人空间并回调选中,再提交 board;标题去首尾空白,不能在通知调用者前继续第二步。
test("a first board creates its personal workspace before posting the board", async () => {
const calls = [];
let selected = "";
@@ -44,6 +50,7 @@ test("a first board creates its personal workspace before posting the board", as
assert.equal(calls[1][1].title, "周末的灵感");
});
// 第一次 board 失败仍保留回调给出的 workspace ID;重试只创建 board,个人空间计数仍为一。
test("a board failure retains the new workspace, so retry creates no duplicate space", async () => {
let selected = "";
let spaces = 0;
@@ -68,6 +75,7 @@ test("a board failure retains the new workspace, so retry creates no duplicate s
assert.equal(spaces, 1);
});
// 已有 workspace 直接向其 materials 提交,创建空间回调不得执行。
test("an existing workspace posts a board directly", async () => {
const calls = [];
await createBoard(
@@ -83,6 +91,7 @@ test("an existing workspace posts a board directly", async () => {
});
// 空白名称回退默认图谱标题,payload 明确 kind/content schema;返回身份采用服务分配 ID,不能另造本地替代 ID。
test("new graphs use a default name and retain the Foundry-assigned identity", async () => {
const created = await createBoard(async (path, options) => {
assert.equal(path, "workspaces/space/materials");
@@ -97,12 +106,14 @@ test("new graphs use a default name and retain the Foundry-assigned identity", a
assert.equal(boardHref(created.workspace_id, created.id), "/dashboard/space/server-generated-id");
});
// workspace 和 board 分段编码,特殊斜线/问号不能改变路由结构;demo 查询参数单独追加。
test("graph URLs preserve workspace identity and safely encode route segments", () => {
assert.equal(boardHref("space/a", "id?b"), "/dashboard/space%2Fa/id%3Fb");
assert.equal(boardHref("demo", "board", true), "/dashboard/demo/board?demo=1");
});
// 重命名前读取最新版本,以 If-Match=7 PATCH,保留 description/content;响应为空时 helper 合成标题及 version=8 返回值,非服务端实际版本证明。
test("renaming reads the latest version and preserves description and content", async () => {
const calls = [];
const content = { schema_version: 1, preserved: { key: "value" } };
@@ -119,12 +130,14 @@ test("renaming reads the latest version and preserves description and content",
assert.equal(renamed.version, 8);
});
// 空白或超过 100 字符的标题在请求前拒绝;用 assert.fail 确保没有任何 API 调用。
test("rename rejects empty or oversized names without any API calls", async () => {
for (const name of [" ", "字".repeat(101)]) {
await assert.rejects(renameBoard(() => assert.fail("unexpected API call"), "space", "board", name), /invalid_title/);
}
});
// PATCH 版本冲突原样抛出且只调用一次,不能自动重试覆盖新内容。
test("rename surfaces a version conflict without retrying or overwriting", async () => {
let patches = 0;
await assert.rejects(renameBoard(async (_path, options) => {