From 2a0b6bb9d969c769b87191c482196ae14364f565 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 11:57:00 +0800 Subject: [PATCH] docs(tests): explain legacy canvas scenario setup and recovery flows --- tests/api-smoke.mjs | 40 ++++++++++++++++++++++++++++++++ tests/canvas-member-scenario.mjs | 31 +++++++++++++++++++++++++ tests/canvas-ui-check.mjs | 35 ++++++++++++++++++++++++++++ tests/canvas-upload-scenario.mjs | 23 ++++++++++++++++++ tests/canvas-v2-scenario.mjs | 25 ++++++++++++++++++++ tests/feedback-scenario.mjs | 14 +++++++++++ 6 files changed, 168 insertions(+) diff --git a/tests/api-smoke.mjs b/tests/api-smoke.mjs index 7c3c589..715da72 100644 --- a/tests/api-smoke.mjs +++ b/tests/api-smoke.mjs @@ -1,10 +1,17 @@ +/** + * 已构建 Next 应用的本地 HTTP smoke:启动临时 Foundry 替身和 next start,遍历页面、代理、资源及认证响应。 + * 需要已有可用构建,运行会占用 127.0.0.1:3005;脚本不执行 next build,也不连接真实 Foundry/模型服务。 + * 断言以 HTTP、文件字节及内存状态为准,不验证浏览器交互、真实登录提供商或数据库事务。 + */ import assert from "node:assert/strict"; import { createServer } from "node:http"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { fileURLToPath } from "node:url"; import { readdir, readFile } from "node:fs/promises"; +// 应用使用固定端口;upstream 另选随机端口,由子进程环境变量指定为代理源站。 const host = "http://127.0.0.1:3005"; +// 图谱、画布版本、会话剩余秒数与 profile 都是本次进程的内存夹具。 const graphs = new Map(); let releaseStream = () => {}; let canvasSnapshot = { version: 1, document: { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; @@ -13,23 +20,28 @@ let mockProfile = { id: "user", username: "test", display_name: "Test User", email: "test@example.com", avatar_url: "https://lh3.googleusercontent.com/test-avatar=s96-c", }; +// 最小 Foundry HTTP 替身:有效测试会话才发续期提示,并按路由返回页面代理需要的响应。 const upstream = createServer(async (req, res) => { res.setHeader("Content-Type", "application/json"); if (req.headers.authorization === "Bearer test-only-session" && !req.url.endsWith("/auth/logout")) res.setHeader("X-Foundry-Session-Expires-In", mockSessionSeconds); + // 把 workspace/board slug 归一到同一记录,供四种 ID/slug 组合读取校验;不是通用路由解析器。 const canonical = req.url.replace("/workspaces/creative-space/", "/workspaces/space/").replace("/materials/my-board", "/materials/foundry-generated-id"); if (req.url.includes("/canvas")) assert.equal(req.headers.accept, "application/vnd.foundry.canvas+json;version=2"); + // 资源 GET 返回短 PNG 头字节或不支持 MIME;POST 汇总 multipart 原始字节并回显幂等键。 if (req.url.startsWith("/projects/endoleap/workspaces/space/assets")) { if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({error:"invalid_session"})); return; } if (req.method === "GET") { res.setHeader("X-Asset-Variant", new URL(req.url,"http://mock").searchParams.get("variant") === "preview" ? "preview" : "original"); res.setHeader("Content-Type", req.url.endsWith("/bad") ? "image/svg+xml" : "image/png"); res.end(Buffer.from([137,80,78,71,13,10,26,10])); return; } let bytes = Buffer.alloc(0); for await (const chunk of req) bytes = Buffer.concat([bytes, chunk]); res.end(JSON.stringify({type:req.headers["content-type"],bytes:bytes.length,key:req.headers["idempotency-key"],multipart:bytes.includes(Buffer.from('name="image"'))})); return; } + // 先写 JSON 前缀,再延迟 15.2 秒完成 body,用于检查旧总超时不截断合法传输。 if (canonical.endsWith("/materials/slow-body-board/canvas")) { res.setHeader("ETag", '\"210\"'); res.write('{"version":210,"document":'); await new Promise((resolve) => setTimeout(resolve, 15200)); res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return; } + // 由 releaseStream 控制尾部放行:代理必须在完整 JSON 可用之前转交头和首块。 if (canonical.endsWith("/materials/stream-board/canvas")) { assert.equal(req.headers.authorization, "Bearer test-only-session"); res.setHeader("ETag", '\"1\"'); res.setHeader("X-Canvas-Schema-Version", "2"); @@ -37,9 +49,11 @@ const upstream = createServer(async (req, res) => { await new Promise((resolve) => { releaseStream = resolve; }); res.end('{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}}'); return; } + // V2 子命令只回显 URL、方法与关键头;这个分支不执行真正的连接、AI 或结构事务。 if (canonical.includes("/canvas/")) { res.end(JSON.stringify({url:req.url,method:req.method,accept:req.headers.accept,key:req.headers["idempotency-key"],version:req.headers["if-match"]})); return; } + // 旧完整快照 GET/PATCH 独立维护版本,If-Match 过期时返回 412;不与图谱元信息共用版本。 if (canonical === "/projects/endoleap/workspaces/space/materials/foundry-generated-id/canvas") { if (req.headers.authorization !== "Bearer test-only-session") { res.statusCode = 401; res.end(JSON.stringify({ error: "invalid_session" })); return; } if (req.method === "GET") { res.setHeader("X-Canvas-Schema-Version", String(canvasSnapshot.document.schema_version)); res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify(canvasSnapshot)); return; } @@ -50,6 +64,7 @@ const upstream = createServer(async (req, res) => { canvasSnapshot = { version: canvasSnapshot.version + 1, document }; res.setHeader("ETag", '"' + canvasSnapshot.version + '"'); res.end(JSON.stringify({ version: canvasSnapshot.version })); return; } + // 其余分支提供登出、图谱创建/重命名、登录 token 和 profile;未知路由返回 404。 if (req.url.endsWith("/auth/logout")) { res.statusCode = 204; res.end(); @@ -59,6 +74,7 @@ const upstream = createServer(async (req, res) => { for await (const chunk of req) raw += chunk; const input = JSON.parse(raw); assert.deepEqual(input, { kind: "board", title: "未命名图谱", content: { schema_version: 1 } }); + // 图谱 ID 由替身分配,同时保留 slug;创建响应只回身份字段,详细内容需后续 GET。 const graph = { ...input, slug: "my-board", id: "foundry-generated-id", workspace_id: "space", version: 1 }; graphs.set(graph.id, graph); res.statusCode = 201; @@ -100,8 +116,10 @@ const upstream = createServer(async (req, res) => { res.end("{}"); } }); +// 先监听随机端口再启动 Next 子进程,保证应用首次代理请求就能连到替身。 await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); const port = upstream.address().port; +// 启动 next start 消费已有构建,捕获 stdout/stderr 供启动失败诊断,不运行开发服务器。 const app = spawn( process.execPath, [ @@ -118,10 +136,12 @@ const app = spawn( stdio: ["ignore", "pipe", "pipe"], }, ); +// 收集子进程启动输出;轮询中若提前退出,错误包含这些诊断文本。 let startup = ""; app.stdout.on("data", (x) => (startup += x)); app.stderr.on("data", (x) => (startup += x)); try { + // 最多轮询 100 次,失败后等待 100ms,再检查首页是否可用;不能只凭 spawn 返回就宣称服务已启动。 let ready = false; for (let n = 0; n < 100; n++) { if (app.exitCode !== null) @@ -135,6 +155,7 @@ try { await new Promise((r) => setTimeout(r, 100)); } assert(ready, "Frontend did not start"); + // 逐页检查状态和公共元信息;这些是服务端 HTML 断言,不代表页面客户端交互已验证。 for (const path of ["/", "/login", "/auth/callback", "/dashboard", "/dashboard?demo=1", "/dashboard/space/foundry-generated-id"]) { const page = await fetch(host + path); assert.equal(page.status, 200, path); @@ -146,6 +167,7 @@ try { assert(html.includes('/brand/icons/favicon.ico')); assert(html.includes('/brand/icons/apple-touch-icon.png')); } + // 逐个下载本地品牌资产并与仓库原字节比较,检查静态服务没有替换或遗漏文件。 const brandRoot = new URL("../public/brand/", import.meta.url); const assets = (await readdir(brandRoot, { recursive: true })).filter((file) => /\.(png|ico|svg)$/.test(file)); assert.equal(assets.length, 88); @@ -154,6 +176,7 @@ try { assert.equal(response.status, 200, asset); assert.deepEqual(Buffer.from(await response.arrayBuffer()), await readFile(new URL(asset, brandRoot)), asset); } + // 按 manifest 枚举图标分片,核对 immutable 缓存头和 JSON 内容,与目录数量的日志文案区分。 const iconManifest = JSON.parse(await readFile(new URL("../src/lib/canvas-icon-assets.json", import.meta.url), "utf8")); for (const file of Object.values(iconManifest.assets)) { const path = iconManifest.root + "/" + file; @@ -163,6 +186,7 @@ try { assert.deepEqual(await response.json(), JSON.parse(await readFile(new URL("../public" + path, import.meta.url), "utf8"))); } assert.equal((await fetch(host + "/missing-page")).status, 404); + // 登录代理必须去掉响应体 token,改设 HttpOnly/SameSite cookie,并报告上游计时。 const res = await fetch(host + "/api/foundry/auth/login", { method: "POST", headers: { "Content-Type": "application/json", Origin: host }, @@ -190,6 +214,7 @@ try { while (true) { const next = await reader.read(); if (next.done) break; raw += new TextDecoder().decode(next.value); } assert.equal(JSON.parse(raw).document.schema_version, 2); } finally { clearTimeout(streamTimer); releaseStream(); } + // 子命令循环只检查头透传;随后检查资源 variant、目录分页与 AI 查询参数白名单。 const v2Base = host + "/api/foundry/workspaces/space/materials/foundry-generated-id/canvas"; const idem = "11111111-1111-4111-8111-111111111111"; for (const path of ["connections", "subgraphs", "subgraphs/migrate", "subgraphs/frame-1/members", "subgraphs/frame-1/dissolve", "transactions/tx-1/undo", "ai/runs", "ai/runs/run-1/cancel", "ai/runs/run-1/apply"]) { @@ -202,6 +227,7 @@ try { assert.equal(directoryQuery.get("include_subgraphs"), "true"); assert.equal(directoryQuery.get("offset"), "100"); const events = await (await fetch(v2Base + "/ai/runs/run-1?after_seq=12&limit=100&status=active&include_descendants=true&cursor=cursor&secret=drop", { headers: { Cookie: session } })).json(); const query = new URL(events.url, "http://mock").searchParams; assert.equal(query.get("after_seq"), "12"); assert.equal(query.get("include_descendants"), "true"); assert.equal(query.get("cursor"), "cursor"); assert.equal(query.get("secret"), null); + // 验证慢 body 完整读取和 trace 标识,再检查资源认证、MIME 拒绝、nosniff 与 multipart 转发。 const slowSnapshot = await fetch(host + "/api/foundry/workspaces/space/materials/slow-body-board/canvas", { headers: { Cookie: session } }); assert.ok(slowSnapshot.headers.get("x-canvas-trace-id")); assert.equal((await slowSnapshot.json()).version, 210, "A valid body can finish after the old 15-second total deadline"); const asset = await fetch(host + "/api/foundry/workspaces/space/assets/image-1", { headers: { Cookie: session } }); @@ -211,6 +237,7 @@ try { const form = new FormData(); form.set("image", new Blob([new Uint8Array([137,80,78,71])], { type: "image/png" }), "test.png"); const upload = await (await fetch(host + "/api/foundry/workspaces/space/assets", { method: "POST", headers: { Cookie: session, Origin: host, "Idempotency-Key": idem }, body: form })).json(); assert.match(upload.type, /^multipart\/form-data; boundary=/); assert.equal(upload.key, idem); assert.ok(upload.multipart); + // 有效会话首页重定向且不缓存私有结果;dashboard 初始 HTML 不应包含画布或 landing。 const signedInHome = await fetch(host + "/", { headers: { Cookie: session }, redirect: "manual" }); assert.equal(signedInHome.status, 307); assert.equal(signedInHome.headers.get("location"), "/dashboard"); @@ -222,6 +249,7 @@ try { assert.match(dashboardHtml, /aria-label="(?:工作空间侧边栏|正在加载侧边栏)"/); assert(!dashboardHtml.includes('data-testid="rf__wrapper"')); assert(!dashboardHtml.includes('class="landing"')); + // 无会话或过期会话首页保持 landing,避免错误重定向到个人工作区。 for (const guestHeaders of [{}, { Cookie: "foundry_session=expired-session" }]) { const guestHome = await fetch(host + "/", { headers: guestHeaders, redirect: "manual" }); assert.equal(guestHome.status, 200); @@ -240,6 +268,7 @@ try { 200, ); assert.equal((await fetch(host + "/api/foundry/profile")).status, 401); + // 续期只接受有效剩余秒数;非法上游值和无效会话不应产生新的 Set-Cookie。 mockSessionSeconds = "3600"; const renewed = await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } }); assert.equal(renewed.status, 200); assert.match(renewed.headers.get("set-cookie") || "", /Max-Age=3600/); @@ -251,6 +280,7 @@ try { const expiredSession = await fetch(host + "/api/foundry/profile", { headers: { Cookie: "foundry_session=expired" } }); assert.equal(expiredSession.status, 401); assert.equal(expiredSession.headers.get("set-cookie"), null); + // 读取头像再修改公开资料,确认未涉及的头像字段保持。 const profile = await (await fetch(host + "/api/foundry/profile", { headers: { Cookie: session } })).json(); assert.equal(profile.avatar_url, "https://lh3.googleusercontent.com/test-avatar=s96-c"); const updated = await fetch(host + "/api/foundry/profile", { @@ -263,6 +293,7 @@ try { assert.equal(updatedProfile.display_name, "Updated name"); assert.equal(updatedProfile.avatar_url, profile.avatar_url); + // 经真实代理创建、读取和重命名内存图谱,检查服务分配身份、内容保留和元信息版本递增。 const create = await fetch(host + "/api/foundry/workspaces/space/materials", { method: "POST", headers: { "Content-Type": "application/json", Cookie: session, Origin: host }, @@ -287,6 +318,7 @@ try { assert.equal(renamed.version, 2); assert.deepEqual(renamed.content, stored.content); + // workspace/board 各自使用 ID 或 slug 的四种组合都应定位同一图谱和独立画布版本。 for (const workspace of ["space", "creative-space"]) for (const board of ["foundry-generated-id", "my-board"]) { const path = `/api/foundry/workspaces/${workspace}/materials/${board}`; const material = await (await fetch(host + path, { headers: { Cookie: session } })).json(); @@ -296,6 +328,7 @@ try { assert.equal((await snapshot.json()).version, 1); } const canvasPath = "/api/foundry/workspaces/creative-space/materials/my-board/canvas"; + // 生成超过 1 MiB 的完整快照经代理保存/恢复,再用旧版本重发检查 412;不是增量或大文件性能测试。 const largeDocument = { schema_version: 1, nodes: Array.from({ length: 15 }, (_, index) => ({ id: "text-" + index, type: "material", position: { x: index * 300, y: 100 }, width: 260, height: 200, data: { kind: "text", title: "保存测试", text: "x".repeat(90000), fontSize: 24, rotation: 30, textStyle: { bold: true, color: "#123456" } } })), edges: [], viewport: { x: 20, y: 40, zoom: 0.75 } }; const savedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) }); assert.equal(savedCanvas.status, 200); assert.equal(savedCanvas.headers.get("etag"), '"2"'); @@ -304,6 +337,7 @@ try { assert.deepEqual(restoredCanvas.document, largeDocument); const conflict = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, Origin: host, "Content-Type": "application/json", "If-Match": '"1"' }, body: JSON.stringify({ document: largeDocument }) }); assert.equal(conflict.status, 412); assert.equal((await conflict.json()).error, "version_conflict"); + // 旧 frameId 成员关系及三种名称显示模式往返,逐次使用当前画布版本。 const frameDocument = structuredClone(largeDocument); frameDocument.nodes.unshift({ id: "frame-1", type: "frame", position: { x: 0, y: 0 }, width: 640, height: 400, data: { kind: "frame", title: "参考分区", text: "", rotation: 0, labelMode: "fixed" } }); frameDocument.nodes[1].data.frameId = "frame-1"; @@ -317,10 +351,12 @@ try { } assert.equal((await fetch(host + canvasPath)).status, 401); assert.equal((await fetch(host + "/api/foundry/workspaces/space/search/my-board/canvas")).status, 404); + // 超过画布 body 上限的请求必须返回 413,不能转成成功保存。 const oversizedCanvas = await fetch(host + canvasPath, { method: "PATCH", headers: { Cookie: session, "Content-Type": "application/json" }, body: JSON.stringify({ document: "x".repeat(5 * 1024 * 1024) }) }); assert.equal(oversizedCanvas.status, 413); + // 检查跨源拒绝、路径白名单、OAuth challenge、Secure cookie、非法 JSON、大小上限及方法过滤。 for (const origin of ["https://example.com", "null"]) assert.equal( ( @@ -351,17 +387,20 @@ try { assert.equal(invalid.status, status); } assert.equal((await fetch(host + "/api/foundry/profile", { method: "PUT" })).status, 405); + // 查询保留 kind/limit 而删除未许可 secret;此处使用替身回显观察最终上游路径。 const materials = await fetch(host + "/api/foundry/workspaces/space/materials?kind=board&limit=100&secret=drop", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: "{}", }); assert.deepEqual(await materials.json(), { url: "/projects/endoleap/workspaces/space/materials?kind=board&limit=100", method: "PATCH", }); + // OAuth 只检查重定向地址与 challenge,不实际打开提供商或完成授权。 for (const provider of ["google", "slack"]) { const redirect = await fetch(host + "/api/oauth/" + provider + "?challenge=" + "a".repeat(43), { redirect: "manual" }); assert.equal(redirect.status, 302); assert.equal(redirect.headers.get("location"), `http://127.0.0.1:${port}/projects/endoleap/auth/${provider}?challenge=${"a".repeat(43)}`); } + // 登出保持 204 空体并清除 cookie;下方 PASS 长文是摘要,实际覆盖以逐项断言为准。 const logout = await fetch(host + "/api/foundry/auth/logout", { method: "POST", headers: { "Content-Type": "application/json", Cookie: session }, body: "{}", }); @@ -371,6 +410,7 @@ try { console.log( "PASS: V2 commands, idempotency and query forwarding, authenticated binary assets and multipart uploads, Canvas GET/PATCH and independent versions, ETag/If-Match, 1+ MiB snapshot restore, conflict and 5 MiB limit, four ID/slug combinations, authenticated home redirect, anonymous and expired-session landing, canvas-free dashboard, 88 original brand assets, 97 hashed icon shards with immutable caching, favicon metadata, avatar and profile updates, App Router pages and metadata, body validation, method and query filtering, OAuth redirects, logout, session cookie, token removal, authenticated proxy, invalid session, cross-origin rejection, path allowlist, OAuth validation", ); +// 结束或断言失败均终止 Next 子进程并关闭替身监听;此处没有额外的强制退出超时。 } finally { const exited = once(app, "exit"); app.kill("SIGTERM"); diff --git a/tests/canvas-member-scenario.mjs b/tests/canvas-member-scenario.mjs index 635fd0c..1bccb57 100644 --- a/tests/canvas-member-scenario.mjs +++ b/tests/canvas-member-scenario.mjs @@ -1,21 +1,32 @@ +/** + * 旧 linked-board 成员迁移的 hook 与 DOM 场景,由 canvas-ui-check 提供运行环境。 + * 内存 records/receipt 模拟版本、丢响应和能力探测;它验证客户端协调,不能替代后端数据库原子性或持久恢复测试。 + */ import assert from 'node:assert/strict'; +// 先直接驱动 useCanvasTree 精确控制竞态,再以 GraphCanvas 的拖放事件检查局部状态和其他节点可编辑。 export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasNode,emptyCanvas,tick,mock,GraphCanvas,event,window}) { + // 父画布拥有分区入口和其他文本,子画布拥有待迁移图标;投影 ID 与服务端本地 ID 区分。 const parent='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',child='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='frame';frame.data.subgraph={boardId:child}; const icon=makeCanvasNode('icon',{x:10,y:20});icon.id='moving'; const other=makeCanvasNode('text',{x:950,y:100});other.id='other'; let records,tree,posts,saves,release,mode,receipt,key,body,capability=true,capMode="",capCalls=0; + // 统一生成 JSON 响应,HTTP 错误也由客户端常规解析路径处理。 const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}); + // 只挂载 hook 并暴露最新 tree,测试直接调用命令而不依赖 DOM 手势。 function Fixture(){const seed=structuredClone(records.get(parent));tree=useCanvasTree(parent,'w',seed.document,seed);return null;} + // 卸载后重建父子快照与请求记录;模式选择控制能力慢响应、失败、版本冲突、丢 ACK 或挂起提交。 const reset=async(nextMode='success')=>{ await act(async()=>{root.render(null);await tick();});localStorage.clear(); records=new Map([[parent,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(frame),structuredClone(other)]}}],[child,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(icon)]}}]]); posts=[];saves=[];release=null;mode=nextMode;receipt=null;key=null;body=null;capMode=nextMode;capCalls=0; if(mode==='queued'){const second=structuredClone(icon);second.id='moving-two';second.position={x:100,y:20};records.get(child).document.nodes.push(second);mode='delayed';} + // 内存 API 按路径分流:完整快照保存、版本命令、分区创建与成员迁移,各自记录参与者版本。 mock(async(url,options={})=>{ const parts=new URL(url,'http://localhost').pathname.split('/'),id=parts[6],suffix=parts.slice(8).join('/'),method=options.method||'GET'; if(suffix==='ai/runs')return json({runs:[]}); + // 慢响应延迟超过两秒;一次网络失败只影响首个请求,允许检查重新探测而非永久判为不支持。 if(suffix==='capabilities'){ capCalls++;if(capMode==='slowcap'){await new Promise(r=>setTimeout(r,2100));if(options.signal?.aborted)throw new DOMException('Aborted','AbortError');} if(capMode==='cap-failure'&&capCalls===1)throw new TypeError('network unavailable'); @@ -27,6 +38,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN const document=JSON.parse(options.body).document;saves.push({id,document});records.set(id,{version:records.get(id).version+1,document});return json({version:records.get(id).version}); } if(suffix==='ai/runs'&&method==='POST'){const next=structuredClone(records.get(id));next.version++;records.set(id,next);return json({canvas_version:next.version,board_id:id});} + // 模拟创建父入口和新子图,可在状态已写入后暂缓返回,制造上传完成先于命令响应的窗口。 if(suffix==='subgraphs'&&method==='POST'){ const input=JSON.parse(options.body).frame,next=structuredClone(records.get(parent)),childID='66666666-6666-4666-8666-666666666666'; const added=makeCanvasNode('frame',input.position);added.id=input.id;added.data.subgraph={boardId:childID};next.document.nodes.push(added);next.version++;records.set(parent,next); @@ -34,29 +46,36 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN if(mode==='create-delayed')await new Promise(resolve=>{release=resolve;}); return json({transaction_id:crypto.randomUUID(),parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],child:{board_id:childID,...empty}}); } + // 保存原 body/key;命中上一次回执必须逐字节相同,返回既有结果而不再移动成员。 assert.equal(suffix,'subgraphs/frame/members');posts.push({body:options.body,key:options.headers['Idempotency-Key']}); if(receipt && options.headers['Idempotency-Key']===key){assert.equal(options.body,body);return json(receipt);} receipt=null; body=options.body;key=options.headers['Idempotency-Key'];const input=JSON.parse(body); + // 先核对双方期望版本,412/409 模式在迁移前返回;成功路径转换 owner 局部坐标并同时更新两个记录。 for(const id of [parent,child])assert.equal(input.expected_versions[id],records.get(id).version); if(mode==='conflict'){records.get(parent).version++;mode='success';return json({error:'version_conflict'},412);} if(mode==='rejected'){mode='success';return json({error:'ownership_conflict'},409);} const source=structuredClone(records.get(input.source_board_id)),target=structuredClone(records.get(input.target_board_id)); const moving=source.document.nodes.find(n=>n.id===input.node_ids[0]);const pos=input.positions?.[moving.id]||moving.position; + // 本夹具中分区内容原点相对父画布为 (124,136),进出分区据此增减坐标。 const converted=input.source_board_id===child?{x:pos.x+124,y:pos.y+136}:{x:pos.x-124,y:pos.y-136}; source.document.nodes=source.document.nodes.filter(n=>n.id!==moving.id);target.document.nodes.push({...moving,position:converted});source.version++;target.version++;records.set(input.source_board_id,source);records.set(input.target_board_id,target); receipt={transaction_id:crypto.randomUUID(),snapshots:[...records].map(([board_id,value])=>({board_id,...structuredClone(value)}))}; + // lost 在记录已更新后回 502;delayed 则挂起成功 ACK,分别覆盖不确定重试与并行本地编辑。 if(mode==='lost')return json({error:'upstream_unavailable'},502); if(mode==='delayed')await new Promise(resolve=>{release=resolve;}); return json(receipt); }); await act(async()=>{root.render(h(Fixture));await tick();await tick();}); }; + // 有限轮询并推进 React effect,超时描述具体等待条件,不把请求已发出当作已确认。 const wait=async(check,label)=>{for(let i=0;i<120;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);}; + // 选中投影图标并模拟拖到父坐标,再提交跨 owner 迁移,等待请求实际进入替身。 const start=async()=>{ await act(async()=>{tree.select(child+'::moving');tree.setDragging(true);tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:950,y:200},dragging:false}]);tree.setDragging(false);void tree.moveMembers(parent,'frame',child,parent,['moving'],[parent,child]);await tick();}); await wait(()=>posts.length,'move admission'); }; + // 迁移携带落点,不先 PATCH;挂起期间只保护待移动成员,其他文本/拖动仍可继续。 await reset('delayed');await start(); assert.equal(saves.length,0,'Drop-only atomic moves must not PATCH first');assert.equal(tree.busy,false,'Member moves must not lock all editing');assert.equal(tree.memberJobs.length,1); assert.deepEqual(JSON.parse(posts[0].body).positions.moving,{x:826,y:64}); @@ -64,6 +83,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'同步期间编辑'); await act(async()=>{tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:1200,y:900},dragging:false}]);await tick();}); assert.deepEqual(tree.current.current.get(child).nodes[0].position,{x:826,y:64},'Pending member cannot move again'); + // 放行 ACK 后检查坐标、选中和唯一归属,同时保留并最终保存无关编辑;其 undo 不能复活旧 owner。 await act(async()=>{release();await tick();await tick();});await wait(()=>!tree.memberJobs.length,'move acknowledged'); assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'同步期间编辑','Remote snapshots preserve local text'); assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130},'Remote snapshots preserve unrelated dragging'); @@ -76,13 +96,16 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN await act(async()=>{await tree.redo();await tick();}); assert.deepEqual(tree.rootDocument.nodes.find(n=>n.id==='other').position,{x:1100,y:130}); console.log('PASS member sync: one POST, no preceding PATCH, local editing during delayed commit, geometry and draft merge'); + // 丢 ACK 的重试沿用完全相同的 body/key,并保留响应丢失之后的新编辑。 await reset('lost');await start();await wait(()=>tree.memberJobs[0]?.error,'uncertain result'); assert.equal(tree.busy,false);await act(async()=>{tree.nodeAction('other',id=>({type:'edit',id,text:'响应丢失后的编辑'}));tree.retryMembers();await tick();}); await wait(()=>!tree.memberJobs.length,'idempotent retry');assert.equal(posts.length,2);assert.equal(posts[0].body,posts[1].body);assert.equal(posts[0].key,posts[1].key);assert.equal(tree.rootDocument.nodes.find(n=>n.id==='other').data.text,'响应丢失后的编辑'); console.log('PASS member retry: lost acknowledgement reuses body/key, keeps edits and does not duplicate ownership'); + // 明确拒绝后读取新版本再构造新命令,因此新幂等键与旧键不同。 await reset('conflict');await start();await wait(()=>tree.memberJobs[0]?.error,'version rejection'); await act(async()=>{tree.retryMembers();await tick();});await wait(()=>!tree.memberJobs.length,'fresh version retry');assert.equal(posts.length,2);assert.notEqual(posts[0].key,posts[1].key);assert.equal(JSON.parse(posts[1].body).expected_versions[parent],2); console.log('PASS member conflict: definite zero-write rejection reloads versions before a fresh command'); + // 两个迁移共享参与者,第二个等待首个 ACK 后的新版本;不额外发送坐标 PATCH。 await reset('queued');await start(); await act(async()=>{tree.nodesChanged([{type:'position',id:child+'::moving-two',position:{x:1000,y:300},dragging:false}]);void tree.moveMembers(parent,'frame',child,parent,['moving-two'],[parent,child]);await tick();}); assert.equal(posts.length,1,'Second ownership change waits for participant versions'); @@ -91,6 +114,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN assert.equal(saves.length,0);assert.equal(tree.rootDocument.nodes.filter(n=>n.id.startsWith('moving')).length,2);assert.equal(tree.current.current.get(child).nodes.length,0); console.log('PASS member queue: unrelated cross-frame drops serialize versions without extra coordinate PATCH'); await reset(); + // 未上传图片尚无服务端节点:本地切换归属可撤销;上传到当前 owner 后,后续 undo/redo 才使用服务端迁移。 const pendingImage=makeCanvasNode('image',{x:200,y:200},'blob:editable-upload');pendingImage.id='pending-image';pendingImage.pendingUpload=pendingImage.id; await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:pendingImage}]});tree.movePendingImage(parent,child,pendingImage.id);await tick();}); assert.equal(posts.length,0);assert.equal(saves.length,0);assert.deepEqual(tree.current.current.get(child).nodes.find(n=>n.id===pendingImage.id).position,{x:76,y:64}); @@ -102,6 +126,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN await act(async()=>{await tree.undo();await tick();});assert.ok(tree.rootDocument.nodes.some(n=>n.id===pendingImage.id));assert.ok(!tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id)); await act(async()=>{await tree.redo();await tick();});assert.ok(tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));assert.equal(posts.length,2,'Undo/redo after persistence use real ownership transactions'); console.log('PASS pending image ownership: editable local moves, atomic local undo, current-owner upload resolution and transactional undo after persistence'); + // 版本刷新和分区创建响应都不能覆盖本地 pending/已完成图片;重复父快照也只能采用一次。 await reset('create-delayed'); const asyncImage=makeCanvasNode('image',{x:800,y:500},'blob:async-upload');asyncImage.id='async-image';asyncImage.pendingUpload=asyncImage.id; await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:asyncImage}]});await tick();}); @@ -112,13 +137,17 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN await act(async()=>{tree.resolveImageUpload(asyncImage.id,{id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777'});release();await creation;await tick();}); assert.ok(tree.rootDocument.nodes.some(n=>n.id===asyncImage.id&&!n.pendingUpload),'Concurrent command response cannot overwrite the resolved image');assert.equal(tree.rootDocument.nodes.filter(n=>n.id==='new-frame').length,1,'Repeated parent snapshots are adopted only once'); console.log('PASS upload command races: version-only refresh and duplicate transaction snapshots retain pending/ready local images'); + // 慢能力探测和首次探测失败都不能误触发旧协议回退,失败后需重新探测。 await reset('slowcap');await start();await wait(()=>!tree.memberJobs.length,'slow capability move');assert.equal(saves.length,0,'A slow capability response must not trigger legacy PATCH fallback'); await reset('cap-failure');await start();await wait(()=>!tree.memberJobs.length,'capability retry');assert.equal(capCalls,2);assert.equal(saves.length,0,'A failed prefetch is retried rather than cached as unsupported'); console.log('PASS member capabilities: responses slower than two seconds and transient prefetch errors retain atomic moves'); + // 明确不支持 positions 时才走旧的坐标保存加迁移两请求路径。 capability=false;await reset();await start();await wait(()=>!tree.memberJobs.length,'legacy move');assert.equal(saves.length,1,'Old backend retains the coordinate save');assert.equal(JSON.parse(posts[0].body).positions,undefined); console.log('PASS member compatibility: capability-gated positions and legacy two-request fallback'); + // 切到真实 GraphCanvas DOM 事件:拖出子图显示局部同步徽标,另一个节点仍可拖动且 ACK 不复位它。 capability=true;await reset('delayed'); await act(async()=>{root.render(null);await tick();root.render(h(GraphCanvas,{board:{id:parent,workspace_id:'w',kind:'board',title:'Member gestures',version:1},snapshot:structuredClone(records.get(parent))}));await tick();await tick();}); + // 每次按投影 ID 重新查子图图标;成功迁移后应以父画布本地 ID 出现。 const member=()=>document.querySelector(`.react-flow__node[data-id="${child}::moving"]`); await wait(()=>member(),'expanded icon'); await event(member(),'mousedown',{clientX:150,clientY:160,button:0,buttons:1}); @@ -127,6 +156,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN assert.ok(document.querySelector('[aria-label="元素同步状态"]'),'Moving node has a local sync badge'); assert.equal([...document.querySelectorAll('[role="status"]')].some(n=>n.textContent==='正在同步画布…'),false,'No full-canvas synchronization overlay'); assert.equal(member().classList.contains('draggable'),false,'Only pending nodes and frame geometry are protected'); + // 记录无关节点变换,确认同步中实际拖动生效,随后与 ACK 后 DOM 比较。 const otherNode=()=>document.querySelector('.react-flow__node[data-id="other"]');assert.equal(otherNode().classList.contains('draggable'),true); const originalTransform=otherNode().style.transform; await event(otherNode(),'mousedown',{clientX:1000,clientY:150,button:0,buttons:1});await event(window,'mousemove',{clientX:1010,clientY:150,buttons:1});await event(window,'mousemove',{clientX:1100,clientY:180,buttons:1});await event(window,'mouseup',{clientX:1100,clientY:180,button:0}); @@ -136,5 +166,6 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN assert.equal(otherNode().style.transform,changedTransform,'Acknowledgement does not reset concurrent dragging'); assert.ok(document.querySelector('.react-flow__node[data-id="moving"]'),'Moved node has exactly one new owner'); console.log('PASS member UI: real cross-frame icon gesture, local status, no overlay and concurrent unrelated drag'); + // 正常结束卸载 hook/画布;整个 JSDOM 与全局资源由外层 canvas-ui-check 清理。 await act(async()=>{root.render(null);await tick();}); } diff --git a/tests/canvas-ui-check.mjs b/tests/canvas-ui-check.mjs index ac2df3b..560ecc4 100644 --- a/tests/canvas-ui-check.mjs +++ b/tests/canvas-ui-check.mjs @@ -1,3 +1,8 @@ +/** + * 旧画布 JSDOM 回归入口:临时 esbuild 打包实际 React 组件,再注入 DOM/布局/网络替身执行场景。 + * 运行会编译临时 fixture;不启动浏览器或开发服务器。模拟尺寸、命中和指针捕获不能替代浏览器布局/手势验证。 + * CANVAS_UI_FEEDBACK_ONLY 仅跑反馈;ROTATION_BASELINE 对打包输入移除旧点击阻止逻辑,用于复现旋转取消。 + */ // Isolated DOM regression check; no browser or dev server is used. // npm install --prefix /tmp/canvas-ui-tools --ignore-scripts esbuild jsdom // CANVAS_UI_TOOLS=/tmp/canvas-ui-tools node tests/canvas-ui-check.mjs @@ -7,37 +12,46 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; +// 测试工具从 CANVAS_UI_TOOLS 指定目录解析,业务 React/组件仍来自当前仓库依赖。 const tools = createRequire(resolve(process.env.CANVAS_UI_TOOLS || "/tmp/canvas-ui-tools", "package.json")); const { JSDOM } = tools("jsdom"); const { build, stop } = tools("esbuild"); +// 独立 DOM 和 localhost origin 提供 localStorage 等环境,不请求 localhost 服务。 const dom = new JSDOM('
', { url: "http://localhost", pretendToBeVisual: true }); const { window } = dom; // React's async act creates MessageChannels; track and close test-owned ports. const NativeMessageChannel = globalThis.MessageChannel; const channels = []; globalThis.MessageChannel = class extends NativeMessageChannel { constructor() { super(); channels.push(this); } }; +// 把组件依赖的 DOM API 暴露到 Node 全局;这是独立测试进程,finally 不逐项恢复这些全局。 for (const key of ["window", "document", "HTMLElement", "Element", "Node", "SVGElement", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "MutationObserver", "localStorage", "getComputedStyle"]) Object.defineProperty(globalThis, key, { value: key === "window" ? window : typeof window[key] === "function" && key === "getComputedStyle" ? window[key].bind(window) : window[key], configurable: true }); Object.defineProperty(globalThis, "navigator", { value: window.navigator, configurable: true }); globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window); globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window); +// 媒体查询、矩阵和 React act 环境使用最小替身,仅满足组件读取的字段。 window.matchMedia = () => ({ matches: false, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} }); globalThis.IS_REACT_ACT_ENVIRONMENT = true; globalThis.DOMMatrixReadOnly = window.DOMMatrixReadOnly = class { m22 = 1; }; +// 把 MouseEvent 扩为最小 pointer 事件并固定鼠标类型;不是原生多指输入实现。 class PointerEvent extends window.MouseEvent { constructor(type, init = {}) { super(type, init); this.pointerId = init.pointerId || 1; this.isPrimary = true; this.pointerType = "mouse"; } } globalThis.PointerEvent = window.PointerEvent = PointerEvent; +// WeakMap 记录元素持有的 pointerId,仅模拟捕获 API 状态,不会自动重定向后续事件。 const captures = new WeakMap(); window.HTMLElement.prototype.setPointerCapture = function (id) { captures.set(this, id); }; window.HTMLElement.prototype.hasPointerCapture = function (id) { return captures.get(this) === id; }; window.HTMLElement.prototype.releasePointerCapture = function () { captures.delete(this); }; +// 优先用内联宽高,其余给确定默认值;所有矩形起点为零,不进行 CSS 排版计算。 const width = (element) => parseFloat(element.style.width) || (element.className?.includes?.("react-flow__node") ? 260 : 1200); const height = (element) => parseFloat(element.style.height) || (element.getAttribute("aria-label") === "图标列表" ? 192 : element.className?.includes?.("react-flow__node") ? 200 : 800); for (const [key, get] of [["offsetWidth", width], ["offsetHeight", height], ["clientWidth", width], ["clientHeight", height]]) Object.defineProperty(window.HTMLElement.prototype, key, { get() { return get(this); } }); window.HTMLElement.prototype.getBoundingClientRect = function () { return { x: 0, y: 0, left: 0, top: 0, width: width(this), height: height(this), right: width(this), bottom: height(this) }; }; +// observe 立即同步回调尺寸,unobserve/disconnect 无行为;不验证真实观察器调度顺序。 globalThis.ResizeObserver = window.ResizeObserver = class { constructor(callback) { this.callback = callback; } observe(element) { this.callback([{ target: element, contentRect: element.getBoundingClientRect() }]); } unobserve() {} disconnect() {} }; +// 场景可替换 Foundry 请求处理器;图标请求记录后从本地 public 读 JSON,拒绝其他网络目标。 let apiMock = null; const requests = []; globalThis.fetch = async (url, options) => { @@ -47,26 +61,33 @@ globalThis.fetch = async (url, options) => { if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError"); return new Response(await readFile(resolve("public", String(url).slice(1))), { headers: { "Content-Type": "application/json" } }); }; +// 临时目录只存打包 fixture,finally 删除;不把生成文件写入项目源码。 const folder = await mkdtemp(resolve(tmpdir(), "endoleap-canvas-ui-")); try { const outfile = resolve(folder, "fixture.cjs"); + // 打包实际组件和 React 为 CJS,CSS 置空;因此本测试不能证明最终样式或 Next 路由行为。 await build({ stdin: { contents: 'export { default as GraphCanvas } from "./src/components/canvas/graph-canvas"; export { AppFeedback } from "./src/components/ui/app-feedback"; export { IconButton } from "./src/components/ui/icon-button"; export { notify, dismissNotification } from "./src/lib/notifications"; export { BoardDirectory } from "./src/components/workspace/board-directory"; export { useCanvasTree } from "./src/components/canvas/use-canvas-tree"; export { useCanvasAI } from "./src/components/canvas/use-canvas-ai"; export { IconPicker } from "./src/components/canvas/icon-picker"; export { makeCanvasNode, emptyCanvas } from "./src/lib/canvas"; export { createRoot } from "react-dom/client"; export { act, createElement } from "react";', resolveDir: process.cwd(), loader: "tsx" }, outfile, bundle: true, platform: "node", format: "cjs", jsx: "automatic", logLevel: "silent", nodePaths: [resolve("node_modules")], loader: { ".css": "empty" }, plugins: [{ name: "dynamic-component", setup(build) { + // Next Link 替换为 a,dynamic 替换为 lazy/Suspense;这些模块不经过实际 Next 运行时。 build.onResolve({ filter: /^next\/link$/ }, () => ({ path: "link", namespace: "fixture" })); build.onResolve({ filter: /^next\/dynamic$/ }, () => ({ path: "dynamic", namespace: "fixture" })); build.onLoad({ filter: /.*/, namespace: "fixture" }, ({ path }) => ({ contents: path === "link" ? 'import React from "react"; export default function Link(props) { return React.createElement("a",props); }' : 'import React from "react"; export default function dynamic(loader) { const Component = React.lazy(loader); return function Dynamic(props) { return React.createElement(React.Suspense, {fallback:null}, React.createElement(Component,props)); }; }', resolveDir: process.cwd() })); + // 只修改本次编译读入的 toolbar 文本来复现旧 portal 冒泡问题,不改磁盘源码。 if (process.env.ROTATION_BASELINE === "1") build.onLoad({ filter: /node-style-toolbar\.tsx$/ }, async ({ path }) => ({ contents: (await readFile(path, "utf8")).replace('onClick={(event) => event.stopPropagation()}', ''), loader: "tsx" })); } }], }); const { GraphCanvas, AppFeedback, IconButton, notify, dismissNotification, BoardDirectory, useCanvasAI, useCanvasTree, IconPicker, makeCanvasNode, emptyCanvas, act, createElement: h, createRoot } = createRequire(import.meta.url)(outfile); + // 共享 React root 供各场景替换组件;tick 让 effect 推进,event 在 act 内选择事件类型并派发。 const root = createRoot(document.querySelector("#app")); const tick = () => new Promise((resolve) => setTimeout(resolve, 30)); const event = async (target, type, init = {}) => { assert.ok(target, `Missing target for ${type}`); await act(async () => { const Type = type.startsWith("pointer") ? PointerEvent : type.startsWith("key") ? window.KeyboardEvent : window.MouseEvent; target.dispatchEvent(new Type(type, { bubbles: true, cancelable: true, view: window, ...init })); await tick(); }); }; + // 非 feedback-only 才执行目录、轮询、旧画布工具和三个子场景;反馈场景总是执行。 if (process.env.CANVAS_UI_FEEDBACK_ONLY !== "1") { { + // 目录 fixture 验证折叠、当前项祖先展开以及搜索保留完整父路径。 const boards = [{ id: "root", title: "主画布", workspace_id: "w", slug: "root-slug" }, { id: "child", title: "未命名分区", parent_board_id: "root", workspace_id: "w", slug: "child-slug" }, { id: "nested", title: "孙画布", parent_board_id: "child", workspace_id: "w" }]; const renderDirectory = (active, query = "") => h(BoardDirectory, { boards, workspace: "space", active, query, demo: false }); await act(async () => { root.render(renderDirectory("root-slug")); await tick(); }); @@ -81,12 +102,14 @@ try { console.log("PASS directory: nested children, collapse, active ancestor expansion and search paths"); } { + // 记录长定时任务并手动触发,检查空闲轮询退避和可见性暂停;finally 清理所有捕获计时器。 const originalSetTimeout = globalThis.setTimeout, originalClearTimeout = globalThis.clearTimeout; const scheduled = []; globalThis.setTimeout = (callback, delay, ...args) => { const handle = originalSetTimeout(callback, delay, ...args); if (delay >= 1000) scheduled.push({ callback, delay, handle }); return handle; }; let calls = 0, controls, renders = 0; apiMock = async () => { calls++; return Response.json({ runs: [] }); }; const tree = { busy: false, writers: { current: new Map() } }; + // 挂载 AI hook 并计数渲染,检查空结果轮询既不重复请求也不造成额外渲染。 function Probe() { renders++; controls = useCanvasAI("w", "root", true, tree); return null; } try { await act(async () => { root.render(h(Probe)); await tick(); }); @@ -105,13 +128,16 @@ try { console.log("PASS polling: single discovery, idle backoff, no empty rerenders, hidden pause and visible resume"); } finally { globalThis.setTimeout = originalSetTimeout; for (const item of scheduled) originalClearTimeout(item.handle); apiMock = null; } } + // 播种 demo 旧快照、分区和边,随后以 DOM 事件检查工具栏和历史操作。 const node = makeCanvasNode("text", { x: 100, y: 100 }); node.id = "text-one"; const frameNode = makeCanvasNode("frame", { x: 500, y: 100 }); frameNode.id = "frame-one"; frameNode.data.title = "分区 A"; localStorage.setItem("endoleap_canvas_v1:w:b", JSON.stringify({ ...emptyCanvas(), nodes: [node, frameNode, { ...makeCanvasNode("text", { x: 900, y: 100 }), id: "text-two" }], edges: [{ id: "edge-test", source: "text-one", target: "text-two", sourceHandle: "right", targetHandle: "left" }] })); await act(async () => { root.render(h(GraphCanvas, { board: { id: "b", workspace_id: "w", kind: "board", title: "test", version: 1 } })); await tick(); }); await event(document.querySelector('[data-id="text-one"]'), "click"); + // 每次重查旋转按钮,避免 portal 重渲染后持有旧引用。 const toggle = () => document.querySelector('[aria-label="旋转模式"]'); await event(toggle(), "click"); + // baseline 预期复现即时取消;正常路径要求旋转保持并可 Escape、undo/redo。 if (process.env.ROTATION_BASELINE === "1") { assert.equal(toggle().getAttribute("aria-pressed"), "false", "Baseline should reproduce immediate cancellation"); console.log("PASS baseline: portal click cancels rotation before the fix"); @@ -131,6 +157,7 @@ try { assert.equal(requests.length, 0, "Closed picker and text-only board must not load icon shapes"); console.log("PASS rotation: portal click, pointer rotation, Escape, undo and redo"); + // 分区进出通过 mouse 拖动触发,分别检查松开前预览、归属徽标和状态提示。 const textNode = document.querySelector('.react-flow__node[data-id="text-one"]'); await event(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 }); await event(window, "mousemove", { clientX: 210, clientY: 200, buttons: 1 }); @@ -149,6 +176,7 @@ try { assert.ok(document.querySelector('[role="status"]').textContent.includes("已移出「分区 A」")); console.log("PASS frames: actual drag previews, membership badge, count and join/leave announcements"); + // 分区名称模式支持鼠标与键盘选择及历史,八向缩放控制可由 Escape 退出。 const frameElement = document.querySelector('.react-flow__node[data-id="frame-one"]'); await event(frameElement, "click"); const group = document.querySelector('[role="radiogroup"][aria-label="分区名称显示模式"]'); @@ -170,6 +198,7 @@ try { assert.equal(document.querySelectorAll('[data-resize-handle]').length, 0); console.log("PASS frames: icon segments, no member count, keyboard choice, undo/redo and eight resize handles"); + // 连线编辑覆盖曲线点增删、箭头 marker、常驻动画和无执行事件时停止动画。 await event(document.querySelector('.react-flow__edge[data-id="edge-test"]'), "click"); assert.ok(document.querySelector('[aria-label="连线编辑"]')); await event(document.querySelector('[aria-label="添加曲线点"]'), "click"); @@ -186,6 +215,7 @@ try { assert.equal(document.querySelector('[aria-label="曲线点 1"]'), null); console.log("PASS edges: select, add/remove curve knot, markers and always/execution-only animation"); + // IconPicker 以确定容器高度测试窗口化 DOM、按需分片、共享 Tabler 缓存和分类后滚动复位。 let choice; await act(async () => { root.render(h(IconPicker, { close() {}, choose(name) { choice = name; } })); await tick(); }); await act(async () => { await tick(); }); @@ -209,20 +239,25 @@ try { assert.equal(grid.querySelector("button").dataset.index, "0", "Category change resets virtual window"); console.log("PASS picker: bounded DOM, on-demand data, shared Tabler load and selection"); } + // 正常模式追加 V2 linked-board 场景;baseline 跳过它,但成员和上传场景仍会执行。 if (process.env.ROTATION_BASELINE !== "1") { let scenario; await act(async () => { scenario = await import("./canvas-v2-scenario.mjs"); await tick(); }); const { canvasV2Scenario } = scenario; await canvasV2Scenario({ root, act, h, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } }); } + // 注入 hook 与 mock setter,成员场景可精确控制版本、能力和 ACK 竞态。 const { canvasMemberScenario } = await import("./canvas-member-scenario.mjs"); await canvasMemberScenario({ root, act, h, useCanvasTree, GraphCanvas, event, window, makeCanvasNode, emptyCanvas, tick, mock: (handler) => { apiMock = handler; } }); + // 上传场景复用 DOM/root,以文件、Image 和 API 替身检查预览、编辑与清理。 const { canvasUploadScenario } = await import("./canvas-upload-scenario.mjs"); await canvasUploadScenario({ root, act, h, GraphCanvas, window, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } }); } + // 最后验证通用通知/tooltip 与 demo 画布提示,再卸载 root。 const { feedbackScenario } = await import("./feedback-scenario.mjs"); await feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick }); await act(async () => { root.unmount(); await tick(); }); +// 清理临时打包文件、JSDOM、esbuild 及 act 创建的 MessageChannel 端口,恢复原通道构造器。 } finally { await rm(folder, { recursive: true, force: true }); window.close(); stop(); for (const channel of channels) { channel.port1.close(); channel.port2.close(); } diff --git a/tests/canvas-upload-scenario.mjs b/tests/canvas-upload-scenario.mjs index 76ea8ca..d643b8d 100644 --- a/tests/canvas-upload-scenario.mjs +++ b/tests/canvas-upload-scenario.mjs @@ -1,19 +1,31 @@ +/** + * 旧快照画布的 DOM 上传回归场景,由 canvas-ui-check 注入 React root、事件和 API 替身。 + * 不访问真实上传服务;文件内容是字符串 test,图片尺寸/加载事件由测试提供,不能证明真实图片解码或浏览器布局。 + */ import assert from 'node:assert/strict'; +// 依次验证本地预览、上传期间编辑、失败重试、取消/删除、迟到响应、尺寸和卸载清理。 export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyCanvas,event,tick,mock}) { + // 固定画布与资源身份;asset 是模拟服务返回的受管原图地址和元数据。 const parent='cccccccc-cccc-4ccc-8ccc-cccccccccccc',assetID='dddddddd-dddd-4ddd-8ddd-dddddddddddd'; const asset={id:assetID,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${assetID}`,width:640,height:1280,bytes:4,mime_type:'image/png'}; let snapshot,uploads,saves,release,mode; + // 把替身结果编码为 Response,走应用实际的响应处理路径。 const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}); + // 包装 revokeObjectURL 记录释放行为,同时调用原实现;finally 恢复包装和 Image 构造器。 const revoked=[],originalRevoke=URL.revokeObjectURL;URL.revokeObjectURL=(url)=>{revoked.push(url);originalRevoke(url);}; const NativeImage=window.Image; + // 最多轮询 100 次,每轮等待 30ms 并通过 act 推进 effect;超时用 label 标记未完成阶段。 const wait=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);}; + // 先卸载旧画布再清空草稿,为每种响应模式重建空快照、上传记录和 PATCH 记录。 async function reset(nextMode='delayed'){ await act(async()=>{root.render(null);await tick();});localStorage.clear();snapshot={version:1,document:{...emptyCanvas(),schema_version:2}};uploads=[];saves=[];release=null;mode=nextMode; + // 内存服务:上传可立即成功、403 失败或挂起;快照 PATCH 检查版本,并禁止持久化 blob URL。 mock(async(url,options={})=>{ const parts=new URL(url,'http://localhost').pathname.split('/'),suffix=parts.slice(8).join('/'),method=options.method||'GET'; if(parts.at(-1)==='assets'&&method==='POST'){ const file=options.body.get('image');uploads.push({key:options.headers['Idempotency-Key'],bytes:Buffer.from(await file.arrayBuffer()),signal:options.signal}); if(mode==='failed')return json({error:'forbidden'},403); + // 仅延迟模式保留 release,场景手动释放后才返回成功,制造本地编辑与 ACK 的竞态。 if(mode==='delayed')await new Promise(resolve=>{release=resolve;}); return json(asset,201); } @@ -28,6 +40,7 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC }); await act(async()=>{root.render(h(GraphCanvas,{board:{id:parent,workspace_id:'w',kind:'board',title:'Upload test',version:1},snapshot:structuredClone(snapshot)}));await tick();}); } + // 点击工具栏并注入 FileList 替身,再派发 change;只模拟选文件流程,不调用系统文件选择器。 async function choose(label='添加图片'){ await event(document.querySelector(`[aria-label="${label}"]`),'click'); const input=document.querySelector('input[type="file"][accept^="image/"]'); @@ -36,12 +49,15 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC await wait(()=>uploads.length,'upload started'); } try { + // 首次上传先检查 blob 预览已出现、字节到达替身,但服务端文档和保存记录仍为空。 await reset();await choose(); const local=document.querySelector('.react-flow__node img[src^="blob:"]');assert.ok(local,'Selected file immediately appears on the canvas before upload acknowledgement');const localURL=local.getAttribute('src'); assert.ok(document.querySelector('[aria-label="图片上传状态"]'));assert.equal(snapshot.document.nodes.length,0);assert.equal(saves.length,0);assert.deepEqual(uploads[0].bytes,Buffer.from('test')); const imageNodeID=local.closest('.react-flow__node').dataset.id; + // 按最初节点 ID 每次重查 DOM,避免异步重渲染后继续持有已替换元素。 const editable=()=>document.querySelector(`.react-flow__node[data-id="${imageNodeID}"]`); assert.ok(editable().classList.contains('draggable'),'Uploading images are real draggable nodes'); + // 上传挂起时实际派发拖动、标题输入和缩放事件,记录结果用于 ACK 后比较。 await event(editable(),'mousedown',{clientX:200,clientY:200,button:0,buttons:1});await event(window,'mousemove',{clientX:210,clientY:200,buttons:1});await event(window,'mousemove',{clientX:280,clientY:250,buttons:1});await event(window,'mouseup',{clientX:280,clientY:250,button:0}); const editedPosition=editable().style.transform; await event(editable().querySelector('[title="双击编辑名称"]'),'dblclick'); @@ -53,19 +69,23 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC const resize=document.querySelector('[data-resize-handle="bottom-right"]'),surface=editable().querySelector('[style*="rotate("]'); await event(resize,'pointerdown',{clientX:400,clientY:400,button:0});await event(surface,'pointermove',{clientX:460,clientY:440,buttons:1});await event(surface,'pointerup',{clientX:460,clientY:440});await event(window,'keydown',{key:'Escape'}); const editedWidth=editable().style.width,editedHeight=editable().style.height; + // 上传不锁住其他内容创建;确认后先保留本地预览,等远端 load 事件才释放 blob URL。 await event(document.querySelector('[aria-label="添加文本卡片"]'),'click');assert.equal(document.querySelectorAll('.react-flow__node').length,2,'Other content can be added during upload'); await act(async()=>{release();await tick();});await wait(()=>document.querySelector('.react-flow__node img[src$="?variant=preview"]'),'managed image rendered'); assert.ok(document.querySelector(`img[src="${localURL}"]`),'Local image remains visible while remote preview loads'); const remote=document.querySelector('.react-flow__node img[src$="?variant=preview"]');await act(async()=>{remote.dispatchEvent(new window.Event('load'));await tick();}); assert.ok(!document.querySelector(`img[src="${localURL}"]`));assert.ok(revoked.includes(localURL));await wait(()=>snapshot.document.nodes.some(n=>n.data.src===asset.url),'original reference saved'); + // 确认资源引用只保存一次,并保留上传期间的标题、位置和尺寸修改。 const persisted=snapshot.document.nodes.find(n=>n.id===imageNodeID);assert.equal(persisted.data.title,'上传前改名');assert.equal(editable().style.transform,editedPosition);assert.equal(editable().style.width,editedWidth);assert.equal(editable().style.height,editedHeight); assert.equal(snapshot.document.nodes.filter(n=>n.data.src===asset.url).length,1);assert.equal(snapshot.document.nodes.find(n=>n.data.src===asset.url).data.assetId,assetID); console.log('PASS uploads: immediate local preview, unrelated editing, original-byte attachment and no blob URL persistence'); + // 403 后预览和文件仍保留;手动重试沿用第一次幂等键,最终只落一个节点。 await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'visible upload failure'); assert.ok(document.querySelector('img[src^="blob:"]'));assert.equal(saves.length,0);const key=uploads[0].key;mode='delayed'; await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='重试'),'click');assert.equal(uploads.length,2);assert.equal(uploads[1].key,key,'Manual retry retains the original idempotency key'); await act(async()=>{release();await tick();});await wait(()=>snapshot.document.nodes.length===1,'retry saves exactly one image');assert.equal(snapshot.document.nodes[0].data.src,asset.url); console.log('PASS upload retry: retained file/preview, stable idempotency key and exactly one persisted node'); + // 取消相当于可撤销的本地删除;随后单独检查上传中删除、ACK 迟到以及撤销后的受管资源恢复。 await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'failed cancellation'); const cancelledURL=document.querySelector('img[src^="blob:"]').getAttribute('src');await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='取消'),'click');assert.equal(document.querySelector('img[src^="blob:"]'),null);assert.equal(saves.length,0);await event(document.querySelector('[aria-label="撤销"]'),'click');assert.ok(document.querySelector(`img[src="${cancelledURL}"]`),'Undo restores a locally deleted upload'); await reset();await choose();const deletedID=document.querySelector('.react-flow__node').dataset.id; @@ -73,10 +93,13 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC await act(async()=>{release();await tick();});await act(async()=>{await new Promise(r=>setTimeout(r,900));});assert.equal(snapshot.document.nodes.length,0,'Late upload completion must not resurrect a deleted node'); await event(document.querySelector('[aria-label="撤销"]'),'click');await wait(()=>snapshot.document.nodes.length===1,'undo after upload completed');assert.equal(snapshot.document.nodes[0].id,deletedID);assert.equal(snapshot.document.nodes[0].data.src,asset.url); console.log('PASS editable uploads: move/resize/rename persist through acknowledgement, late completion respects deletion and undo'); + // 尺寸替身异步回调 640×960;立即/延迟上传两条路径都应形成 320×480,元数据确认不额外占撤销步骤。 window.Image=class {naturalWidth=640;naturalHeight=960;set src(_){setTimeout(()=>this.onload?.(),0);}}; await reset('immediate');await choose('添加纯图片');await wait(()=>snapshot.document.nodes.length===1,'fast upload dimensions');assert.equal(snapshot.document.nodes[0].width,320);assert.equal(snapshot.document.nodes[0].height,480);await event(document.querySelector('[aria-label="撤销"]'),'click');assert.equal(document.querySelector('.react-flow__node'),null,'Intrinsic size and attachment acknowledgement do not add undo steps'); await reset();await choose('添加纯图片');assert.equal(document.querySelector('.react-flow__node').style.width,'320px');assert.equal(document.querySelector('.react-flow__node').style.height,'480px'); + // 卸载后检查 signal 已取消与 URL 已释放;再放行旧响应,不能触发文档保存。 const unmountedURL=document.querySelector('img[src^="blob:"]').getAttribute('src');await act(async()=>{root.render(null);await tick();});assert.ok(uploads[0].signal.aborted);assert.ok(revoked.includes(unmountedURL));release();await tick();assert.equal(saves.length,0); console.log('PASS upload cleanup: undoable deletion, unmount revocation/abort and plain-image aspect ratio'); + // 无论断言是否失败,都恢复全局替身并卸载此场景的组件。 } finally {window.Image=NativeImage;URL.revokeObjectURL=originalRevoke;await act(async()=>{root.render(null);await tick();});} } diff --git a/tests/canvas-v2-scenario.mjs b/tests/canvas-v2-scenario.mjs index a05819f..6b84f17 100644 --- a/tests/canvas-v2-scenario.mjs +++ b/tests/canvas-v2-scenario.mjs @@ -1,18 +1,27 @@ +/** + * 旧 schema_version=2 linked-board 画布的 JSDOM 集成场景,不是 headless 增量协议版本号。 + * 真实组件消费内存 API 替身:快照、事务回执、AI 模型/事件和图片均由此文件生成;不调用模型供应商或实际 Foundry 服务。 + */ import assert from 'node:assert/strict'; +// 依次检查只读加载、子图编辑/AI、折叠展开、分区 undo/redo、跨 owner 连线与图片处理。 export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvasNode,emptyCanvas,event,tick,mock}) { + // 构造父入口、子节点、外部 AI 文本与图片;父边引用入口 port,子内容只存在子快照中。 const parent='22222222-2222-4222-8222-222222222222', child='11111111-1111-4111-8111-111111111111'; const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='linked-frame';frame.data={...frame.data,title:'真实子图',subgraph:{boardId:child},ports:[{id:'out',nodeId:'child-text',handle:'right',direction:'output'}],presentation:{collapsed:false,contentScale:1,expandedSize:{width:640,height:400},collapsedSize:{width:240,height:140}}}; const inside=makeCanvasNode('text',{x:10,y:20});inside.id='child-text';inside.data.title='子节点';inside.data.text='原始输入'; const target=makeCanvasNode('text',{x:950,y:100});target.id='outside';target.data.ai={enabled:true,modelId:'test-text',prompt:'继续整理',parameters:{}}; const image=makeCanvasNode('image',{x:1100,y:450},'https://example.com/reference.png');image.id='image-target';image.data.title='图片目标'; const legacy=makeCanvasNode('plain-image',{x:1600,y:100},'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB');legacy.id='legacy-image'; + // Map 是模拟服务端快照事实;版本随每个写分支递增,不能与投影画面中的 :: ID 混用。 const records=new Map([[parent,{version:10,document:{...emptyCanvas(),schema_version:2,nodes:[frame,target,image,legacy],edges:[{id:'boundary',source:frame.id,sourceHandle:'port:out',target:target.id,targetHandle:'left',data:{animation:{mode:'executing',effect:'flow'}}}]}}],[child,{version:3,document:{...emptyCanvas(),schema_version:2,nodes:[inside]}}]]); let connectionReceipt=null,connectionKey=null,connectionBody=null,connectionRequests=0; const receipts=new Map();let createCount=0; let assetUploads=0; const saves=[];let job=null,poll=0,seq=0,imageRuns=0;const events=[]; + // Response 包装走应用解码;emit 用递增 seq 支持 after_seq 事件过滤。 const json=(data,status=200)=>new Response(JSON.stringify(data),{status,headers:{'content-type':'application/json'}}); const emit=(type,data)=>events.push({seq:++seq,type,...data}); + // 按 assets、快照、能力、模型、AI、连线和事务路径分流;未列出的请求立即失败。 mock(async(url,options={})=>{ if(new URL(url,'http://localhost').pathname.endsWith('/assets') && options.method==='POST') { assetUploads++;assert.ok(options.headers['Idempotency-Key']);assert.ok(options.body.get('image').size>0); @@ -22,6 +31,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas const parsed=new URL(url,'http://localhost'), parts=parsed.pathname.split('/');const board=parts[6], suffix=parts.slice(8).join('/'), method=options.method||'GET'; const snapshot=records.get(board);assert.ok(snapshot,`Unknown canvas ${board} ${url}`); if(!suffix&&method==='GET')return json(structuredClone(snapshot)); + // 快照保存检查 If-Match,禁止投影 ID 持久化和把子节点重复写入父画布。 if(!suffix&&method==='PATCH'){ if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412); const payload=JSON.parse(options.body).document; @@ -32,6 +42,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas if(suffix==='capabilities')return json({supported_schema_versions:[1,2],subgraphs:{connections:true}}); if(suffix==='ai/models')return json({models:[{id:'test-text',label:'测试文本模型',available:true,output_modalities:['text'],input_modalities:['text'],parameters_schema:{type:'object',properties:{},additionalProperties:false}},{id:'test-image',label:'测试图片模型',available:true,output_modalities:['image'],input_modalities:['text','image'],parameters_schema:{type:'object',properties:{},additionalProperties:false}}]}); if(suffix==='ai/runs'&&method==='GET')return json({runs:job&&['queued','running'].includes(job.status)?[{run_id:'test-run',board_id:child,status:job.status}]:[],next_cursor:null}); + // 根据目标区分文本/图片模型及参与画布,校验幂等键与版本,再重置这次模拟 run 的事件序列。 if(suffix==='ai/runs'&&method==='POST'){ const input=JSON.parse(options.body);const isImage=input.node_id==='image-target';assert.equal(board,isImage?parent:child);assert.equal(input.node_id,isImage?'image-target':'child-text');assert.equal(input.model_id,isImage?'test-image':'test-text');assert.equal(input.propagate,!isImage);assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);assert.ok(options.headers['Idempotency-Key']); snapshot.document.nodes.find(node=>node.id===input.node_id).data.ai={enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:{},propagate:true};snapshot.version++; @@ -40,6 +51,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas if(suffix.startsWith('ai/runs/image-run-')&&suffix.endsWith('/cancel')&&method==='POST'){ job.status='cancelled';emit('step.cancelled',{board_id:parent,node_id:'image-target'});return json({run_id:job.id,status:'cancelled'}); } + // 图片轮询第二次生成 src/assetId 并发出 output/applied;取消状态不走成功写回分支。 if(suffix.startsWith('ai/runs/image-run-')&&method==='GET'){ poll++;if(job.status!=='cancelled')job.status=poll<2?'running':'succeeded'; if(poll===2&&job.status==='succeeded'){ @@ -48,6 +60,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas } const after=+(parsed.searchParams.get('after_seq')||0);return json({run_id:job.id,status:job.status,next_seq:seq,has_more:false,events:events.filter(e=>e.seq>after),steps:[{step_id:'image-step',board_id:parent,node_id:'image-target',status:job.status}],outputs:[],applied_versions:{}}); } + // 三轮推进子文本生成、跨边活跃及父文本传播;应用靠 seq 游标读取新增事件。 if(suffix==='ai/runs/test-run'&&method==='GET'){ poll++; if(poll===1){job.status='running';emit('step.running',{board_id:child,node_id:'child-text'});} @@ -55,6 +68,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas if(poll>=3&&job.status!=='succeeded'){job.status='succeeded';records.get(parent).document.nodes.find(node=>node.id==='outside').data.text='向下传播结果';records.get(parent).version++;emit('step.applied',{board_id:parent,node_id:'outside',canvas_version:records.get(parent).version,patch:{text:'向下传播结果'}});emit('edge.completed',{logical_edge_id:parent+'/boundary'});} const after=+(parsed.searchParams.get('after_seq')||0);return json({run_id:'test-run',status:job.status,next_seq:seq,has_more:false,events:events.filter(e=>e.seq>after),steps:[{step_id:'one',board_id:child,node_id:'child-text',status:poll>=2?'succeeded':'running'},{step_id:'two',board_id:parent,node_id:'outside',status:poll>=3?'succeeded':poll>=2?'running':'queued'}],outputs:[],applied_versions:{}}); } + // 首次连线在父入口增 port 与父边并更新双方版本,故意回 502;重试核对原 body/key 后返回同一回执。 if(suffix==='connections'&&method==='POST'){ connectionRequests++; if(connectionReceipt){assert.equal(options.headers['Idempotency-Key'],connectionKey);assert.equal(options.body,connectionBody);return json(connectionReceipt,201);} @@ -67,11 +81,13 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas connectionReceipt={transaction_id:'tx-connect',parent:{board_id:parent,...structuredClone(records.get(parent))},snapshots:[{board_id:parent,...structuredClone(records.get(parent))},{board_id:child,...structuredClone(records.get(child))}],undo_receipt:{expires_at:new Date(Date.now()+86400000).toISOString()}}; return json({error:'upstream_unavailable'},502); // Simulate a lost response after the atomic commit. } + // 创建的撤销检查当前父/子版本,以新父版本恢复原内容并返回归档子 ID;回执由内存生成。 if(suffix.startsWith('transactions/tx-create-')&&suffix.endsWith('/undo')&&method==='POST'){ const id=suffix.split('/')[1],receipt=receipts.get(id),input=JSON.parse(options.body);assert.ok(receipt);assert.deepEqual(Object.keys(input.expected_versions).sort(),[parent,receipt.child].sort());assert.equal(input.expected_versions[parent],records.get(parent).version);assert.equal(input.expected_versions[receipt.child],records.get(receipt.child).version); const next={...receipt.before,version:records.get(parent).version+1};records.set(parent,next);records.get(receipt.child).version++; return json({transaction_id:'undo-'+id,parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],archived_board_ids:[receipt.child],applied_versions:{[parent]:next.version,[receipt.child]:records.get(receipt.child).version}}); } + // 每次创建新子 ID 和事务回执,记录创建前父快照,供后续撤销与重做参与者检查。 if(suffix==='subgraphs'&&method==='POST'){ const input=JSON.parse(options.body).frame;const newChild=crypto.randomUUID();const tx='tx-create-'+(++createCount);const beforeCreate=structuredClone(snapshot);assert.equal(options.headers['If-Match'],`"${snapshot.version}"`); const node=makeCanvasNode('frame',input.position);node.id=input.id;node.data.title=input.title;node.data.subgraph={boardId:newChild};snapshot.document.nodes.push(node);snapshot.version++; @@ -81,6 +97,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas } throw new Error(`Unhandled V2 API ${method} ${url}`); }); + // 用非整数 viewport 检查加载、测量与缩放都不写内容;视图状态仅记本地 viewport key。 const readOnly=structuredClone(records.get(parent));readOnly.document.viewport={x:132.834729193,y:-73.098456241,zoom:0.637185392}; await act(async()=>{root.render(h(GraphCanvas,{key:'read-only',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 read only',version:1},snapshot:readOnly}));await tick();}); await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));}); @@ -94,10 +111,12 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas await act(async()=>{root.render(null);await tick();}); await act(async()=>{root.render(h(GraphCanvas,{key:'v2',board:{id:parent,workspace_id:'w',kind:'board',title:'V2 test',version:1},snapshot:structuredClone(records.get(parent))}));await tick();}); await act(async()=>{await tick();}); + // 通过 child::localId 查展开投影;只读挂载不得 PATCH,子节点必须只出现一次。 const node=()=>document.querySelector(`.react-flow__node[data-id="${child}::child-text"]`); await act(async()=>{await new Promise(resolve=>setTimeout(resolve,1100));}); assert.equal(saves.length,0,'Opening a root and its child without edits must not PATCH'); assert.ok(node(),'Child is expanded from its own snapshot');assert.equal(document.querySelectorAll(`.react-flow__node[data-id="${child}::child-text"]`).length,1); + // 选择子节点进入 AI 模式,加载文本模型并开启下游传播;拖动与事件写回交错。 await event(node(),'click');await event(document.querySelector('[aria-label="AI 模式"]'),'click');await act(async()=>{await tick();}); assert.ok(document.querySelector('[aria-label="元素 AI 工具"]'));assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试文本模型')); await event(document.querySelector('[aria-label="继续向下传播"]'),'click'); @@ -105,6 +124,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas assert.ok(job);assert.equal(document.querySelector('[aria-label="AI 模式"]').getAttribute('aria-pressed'),'true'); // Move a child while the run executes. Its original owner receives the save. await event(node(),'mousedown',{clientX:200,clientY:200,button:0,buttons:1});await event(window,'mousemove',{clientX:210,clientY:200,buttons:1});await event(window,'mousemove',{clientX:240,clientY:215,buttons:1});await event(window,'mouseup',{clientX:240,clientY:215,button:0}); + // 有限轮询真实组件消费替身事件的结果;通过 act 推进 effect,而非直接替组件改 DOM。 const waitUntil=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,80));});}throw new Error('Timed out: '+label);}; await waitUntil(()=>node()?.querySelector('textarea')?.value==='生成后的子图内容','child AI result'); assert.ok(document.querySelector('.react-flow__edge[data-id="boundary"] animateMotion'),'Actual edge.active event starts execution-only animation'); @@ -112,6 +132,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas await waitUntil(()=>document.querySelector('.react-flow__node[data-id="outside"] textarea')?.value==='向下传播结果','downstream AI result'); assert.equal(document.querySelector('.react-flow__edge[data-id="boundary"] animateMotion'),null,'Completed run stops execution-only animation'); assert.ok(saves.some(save=>save.board===child),'Child edits save to child canvas'); + // 折叠隐藏投影、展开恢复,子局部位置应保持;随后新建分区并反复 undo/redo,检查新子参与者。 const childPosition=structuredClone(records.get(child).document.nodes[0].position); await event(document.querySelector('.react-flow__node[data-id="linked-frame"]'),'click'); await event(document.querySelector('[aria-label="收起子画布"]'),'click');assert.equal(node(),null);assert.ok(document.querySelector('[aria-label="展开子画布"]')); @@ -127,17 +148,20 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas await event(node(),'click'); const fromHandle=node().querySelector('[data-handleid="bottom"]'); const toHandle=document.querySelector('.react-flow__node[data-id="outside"] [data-handleid="left"]'); + // JSDOM 没有真实命中布局,临时将 elementFromPoint 指向目标 handle,驱动连线事件后恢复。 const originalHitTest=document.elementFromPoint;document.elementFromPoint=()=>toHandle; await event(fromHandle,'mousedown',{clientX:250,clientY:250,button:0,buttons:1}); await event(document,'mousemove',{clientX:500,clientY:250,buttons:1}); await event(document,'mouseup',{clientX:500,clientY:250,button:0}); document.elementFromPoint=originalHitTest; await waitUntil(()=>records.get(parent).document.nodes.find(node=>node.id==='linked-frame').data.ports.some(port=>port.id==='generated-port'),'cross-canvas connection transaction'); + // 等待不确定结果提示再点击重试;两次请求最终只产生一条额外边。 await waitUntil(()=>[...document.querySelectorAll('button')].some(button=>button.textContent==='重试同步'),'uncertain transaction recovery'); await event([...document.querySelectorAll('button')].find(button=>button.textContent==='重试同步'),'click'); assert.equal(connectionRequests,2);assert.equal(document.querySelector('.react-flow__edge[data-id="'+connectionReceipt.parent.document.edges.at(-1).id+'"]')!==null,true); assert.equal(records.get(parent).document.edges.length,2); console.log('PASS connections: real handle gesture, capability gate, LCA transaction and exact participant versions'); + // AI 模式保持开启但按新图片目标筛选模型;生成只更新资源,尺寸/标题保持,下一次 run 可取消。 await event(document.querySelector('.react-flow__node[data-id="image-target"]'),'click'); await act(async()=>{await tick();}); assert.ok(document.querySelector('[aria-label="AI 模型"]').textContent.includes('测试图片模型'),'Sticky AI mode selects models for the newly selected image'); @@ -151,6 +175,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas await waitUntil(()=>document.querySelector('[aria-label="元素 AI 工具"]')?.textContent.includes('已取消生成'),'cancelled image run'); assert.equal(records.get(parent).document.nodes.find(node=>node.id==='image-target').data.src,generated.data.src); console.log('PASS AI images: sticky mode, modality filtering, original-node asset output and cancellation'); + // 现有 inline 图片不触发隐式后台上传或引用改写;受管图片继续使用 preview 渲染。 assert.equal([...document.querySelectorAll('button')].some(button=>button.textContent==='优化图片加载'),false,'Temporary migration controls are absent even with an inline image'); assert.equal(assetUploads,0,'Existing inline images do not trigger background uploads'); assert.ok(records.get(parent).document.nodes.find(node=>node.id==='legacy-image').data.src.startsWith('data:image/'),'Existing image references are not rewritten'); diff --git a/tests/feedback-scenario.mjs b/tests/feedback-scenario.mjs index 87833dd..47fa6cd 100644 --- a/tests/feedback-scenario.mjs +++ b/tests/feedback-scenario.mjs @@ -1,16 +1,25 @@ +/** + * 应用反馈组件的 JSDOM 场景:原生 title 回退、provider tooltip、通知更新及画布成功/错误提示。 + * 只断言 DOM 属性、事件和状态,不证明真实屏幕阅读器播报或浏览器浮层排版。 + */ import assert from "node:assert/strict"; +// 复用外层 React root,先验证独立控件,再验证带 AppFeedback 的画布交互。 export async function feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick }) { await act(async () => { root.render(null); await tick(); }); let button, clicks = 0; + // 最小 SVG 图标替身只满足 IconButton 接口,不依赖真实图标资源。 const icon = () => h("svg", { "aria-hidden": true }); + // ref 必须直接指向业务 button;点击计数用于检查 tooltip 不吞掉原操作。 const control = () => h(IconButton, { icon, label: "旋转工具", title: "旋转选中元素", ref: (value) => { button = value; }, onClick: () => { clicks++; } }); + // 没有 provider 时保留原生 title;加入 provider 后不增加包裹层,改用关联 tooltip。 await act(async () => { root.render(control()); await tick(); }); assert.equal(button.title, "旋转选中元素", "Standalone controls retain native explanations"); await act(async () => { root.render(h(AppFeedback, null, h("div", { id: "controls" }, control()))); await tick(); }); assert.ok(document.querySelector("#controls").firstElementChild === button, "Tooltip does not wrap the button or alter toolbar geometry"); assert.equal(button.tagName, "BUTTON", "Forwarded ref still points at the actual button"); assert.equal(button.title, "", "A provider avoids duplicate native and Radix tooltips"); + // 键盘聚焦应出现说明并建立 aria-describedby,Escape 隐藏说明后点击仍能触发业务回调。 await act(async () => { button.focus(); await tick(); }); const tooltip = document.querySelector('[role="tooltip"]'); assert.ok(tooltip?.textContent.includes("旋转选中元素"), "Keyboard focus exposes the explanation"); @@ -18,6 +27,7 @@ export async function feedbackScenario({ root, act, h, AppFeedback, IconButton, await event(document, "keydown", { key: "Escape" }); assert.ok(!document.querySelector('[role="tooltip"]'), "Escape dismisses the tooltip"); await event(button, "click"); assert.equal(clicks, 1, "Tooltips preserve business clicks"); + // 同一通知 ID 更新内容,只保留一个 toast;随后显式 dismiss 并等待退出清理。 await act(async () => { notify({ id: "feedback-check", title: "正在处理" }); await tick(); notify({ id: "feedback-check", title: "已完成", level: "success" }); await tick(); @@ -30,12 +40,14 @@ export async function feedbackScenario({ root, act, h, AppFeedback, IconButton, assert.ok(!document.querySelector('[data-sonner-toast]'), "Dismissed notifications are removed"); console.log("PASS feedback: native fallback, forwarded ref, keyboard tooltip/Escape, clicks and stable notification IDs"); + // 播种旧版 demo 本地草稿,包含文本与分区;这不是服务端成员迁移测试。 const id = "feedback-board"; const node = { ...makeCanvasNode("text", { x: 100, y: 100 }), id: "feedback-text" }; const frame = { ...makeCanvasNode("frame", { x: 500, y: 100 }), id: "feedback-frame" }; frame.data.title = "提示分区"; localStorage.setItem(`endoleap_canvas_v1:w:${id}`, JSON.stringify({ ...emptyCanvas(), nodes: [node, frame] })); await act(async () => { root.render(h(AppFeedback, null, h(GraphCanvas, { board: { id, workspace_id: "w", title: "反馈测试", kind: "board", version: 1 } }))); await tick(); }); + // 拖入分区后同时核对成功提示和实际归属徽标,不能只看到 toast 就算操作生效。 const textNode = document.querySelector('.react-flow__node[data-id="feedback-text"]'); await event(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 }); await event(window, "mousemove", { clientX: 210, clientY: 200, buttons: 1 }); @@ -43,12 +55,14 @@ export async function feedbackScenario({ root, act, h, AppFeedback, IconButton, await event(window, "mouseup", { clientX: 700, clientY: 200, button: 0 }); assert.ok(document.querySelector('[data-sonner-toast][data-type="success"]')?.textContent.includes("已加入「提示分区」")); assert.ok(document.querySelector('[title="属于:提示分区"]'), "Notifications do not replace actual membership state"); + // 用 PDF MIME 模拟不支持的图片选择,错误通知出现后继续创建文本,检查错误不阻塞编辑。 const fileInput = document.querySelector('input[type="file"][accept^="image/"]'); Object.defineProperty(fileInput, "files", { configurable: true, value: [new File(["test"], "test.pdf", { type: "application/pdf" })] }); await act(async () => { fileInput.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); }); assert.ok(document.querySelector('[data-sonner-toast][data-type="error"]')?.textContent.includes("演示模式请选择")); await event(document.querySelector('[aria-label="添加文本卡片"]'), "click"); assert.equal(document.querySelectorAll('.react-flow__node').length, 3, "An error notification never blocks editing"); + // 关闭此画布错误通知并卸载组件,避免影响后续共享 root 的场景。 await act(async () => { dismissNotification(`canvas:${id}:error`); root.render(null); await tick(); }); console.log("PASS canvas feedback: membership success, input errors and continued local editing through the application provider"); }