Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cda4db5ec8 | |||
| fe80df375c | |||
| 4f75dcb9fd | |||
| 2a0b6bb9d9 | |||
| 412e31e898 | |||
| 6ad003eb37 | |||
| 50675ffed3 | |||
| bd728d2d94 | |||
| 6298202dd7 | |||
| f08310b83c | |||
| e0e4276e71 | |||
| 167e01af18 | |||
| 9d788e1712 | |||
| bd4f1a34a0 | |||
| dd36d4f795 | |||
| 9ae7d2d56a | |||
| bad68c6d59 | |||
| a1e3a32b61 | |||
| 7b0e734e16 | |||
| d5ab402592 | |||
| 3714af788f | |||
| 2f55f59bfc | |||
| dc0f25cad0 |
@@ -1,2 +1,4 @@
|
||||
# Next 服务端访问 Foundry 的源站;本地代理从这里发起后端 HTTP 请求,不是浏览器页面地址。
|
||||
FOUNDRY_API_ORIGIN=https://api.foundry.entroia.com
|
||||
# 公开的项目路由标识,可进入浏览器构建;不是密码、会话令牌或授权凭证。
|
||||
NEXT_PUBLIC_FOUNDRY_PROJECT=endoleap
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# 安装依赖不入库;依赖版本由包清单与 lock 文件记录。
|
||||
node_modules/
|
||||
# Next 开发/生产构建缓存与产物,不作为源代码提交。
|
||||
.next/
|
||||
# 独立 headless ESM 包由 scripts/build-headless.mjs 重建。
|
||||
.headless-dist/
|
||||
# 忽略本机环境变量文件;下一条只放行可公开的示例模板。
|
||||
.env*
|
||||
!.env.example
|
||||
# TypeScript 增量检查缓存,由工具重新生成。
|
||||
*.tsbuildinfo
|
||||
# macOS Finder 的目录元数据。
|
||||
.DS_Store
|
||||
# 仓库根 docs 是本地研究/草案目录,当前约定不跟踪其中内容。
|
||||
/docs/
|
||||
|
||||
@@ -142,3 +142,73 @@ Core `subgraphs.member_positions` 能力启用时,最终落点(源画布局
|
||||
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
|
||||
|
||||
上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 blob 地址由传输层排除,不发送给 Core;文件仍未可用时,依赖服务端文件的 AI 生成需等待上传完成。
|
||||
|
||||
|
||||
## 配置与生成数据阅读说明
|
||||
|
||||
本节为不适合内嵌注释的配置和严格 JSON 提供旁注;实际值仍以对应文件为准。源码、脚本和测试中的函数说明保留在各自文件内。包版本、TypeScript 输出目标、服务协议版本、文档格式版本和组件数据版本是不同概念。
|
||||
|
||||
### 应用包与执行入口
|
||||
|
||||
根 `package.json` 的 `private: true` 防止常规 npm 发布,`version: 0.1.0` 是应用包版本。`dependencies` 是应用运行/构建引用的库,`devDependencies` 包含编译器、类型声明和 Tailwind 工具;它们的精确版本及传递依赖由 `package-lock.json` 锁定。lock 文件由包管理器维护,不在其中手写说明或改某个嵌套版本。
|
||||
|
||||
| scripts 项 | 执行内容与边界 |
|
||||
| --- | --- |
|
||||
| `dev` | 在 localhost:17321 启动 Next 开发服务器,会按需编译。 |
|
||||
| `build` / `start` | 前者执行 Next 生产构建,后者在 localhost:17321 运行已有构建。 |
|
||||
| `typecheck` | `tsc --noEmit` 检查类型,不生成应用 JS。 |
|
||||
| `test:headless` | 运行 `tests/headless/*.test.mjs`;不自动执行所有 `.integration.mjs` 或浏览器场景。 |
|
||||
| `test:headless:browser` | 浏览器存储、Worker 等适配器场景入口,环境装配见 `browser-storage.mjs`。 |
|
||||
| `test:headless:proxy` | Foundry 同源代理的隔离集成入口,启动与替身链路见 `proxy.integration.mjs`。 |
|
||||
| `test:headless:renderer` / `test:headless:performance` | 运行页面集成入口;后者附加 `--performance` 选择性能场景。 |
|
||||
| `build:headless` | 清理并重建 `.headless-dist` 的 ESM、声明和 Worker 引用,再复制包清单/文档。 |
|
||||
| `test:headless:package` | 构建、打包到隔离目录并运行消费方及浏览器验收,不是单纯语法检查。 |
|
||||
|
||||
集成入口可能编译或启动本地进程,具体动作见各脚本模块注释;本节列出命令不表示这些检查已经运行或通过。
|
||||
|
||||
### 应用 TypeScript 配置
|
||||
|
||||
`tsconfig.json` 面向应用检查;以下选项共同作用,并非运行时开关:
|
||||
|
||||
| 配置项 | 用途 |
|
||||
| --- | --- |
|
||||
| `target: ES2020` / `lib` | 声明语言输出目标和可用类型库;`dom`、`dom.iterable`、`esnext` 提供 API 类型,不会安装浏览器 polyfill。 |
|
||||
| `allowJs` / `skipLibCheck` | 允许 JS 进入项目;跳过声明文件内部的完整检查。它们不关闭项目 TS 源码的检查。 |
|
||||
| `strict` / `noEmit` | 启用严格类型检查,默认只检查而不生成 JS/声明。 |
|
||||
| `allowImportingTsExtensions` | 允许源码相对导入使用 `.ts` 等 TS 扩展名;独立包输出由另一配置重写扩展名。 |
|
||||
| `esModuleInterop` | 为 CommonJS/ESM 互操作提供 TypeScript 的兼容处理,不把整个项目改成 CommonJS。 |
|
||||
| `module: esnext` / `moduleResolution: bundler` | 使用 ESM 语法并按 bundler 模式解析依赖;应用最终交由 Next 构建链处理。 |
|
||||
| `resolveJsonModule` | 允许 TS 引用并推断 JSON 模块,例如图标 manifest。 |
|
||||
| `isolatedModules` / `jsx: react-jsx` | 按单文件转换约束检查源码,并使用 React 自动 JSX 转换形式。 |
|
||||
| `incremental` | 允许 TypeScript 使用增量检查缓存;生成的 `*.tsbuildinfo` 被忽略。 |
|
||||
| `paths` | `@/*` 在 TS 解析时指向 `src/*`,这不是把字符串路径写进业务文档。 |
|
||||
| `plugins` | 登记 Next TypeScript 插件供支持它的工具使用。 |
|
||||
| `include` / `exclude` | 纳入应用 TS/TSX、`next-env.d.ts` 与 Next 生成类型入口;排除依赖和独立包输出目录。由这些源码导入的依赖仍可参与类型解析。 |
|
||||
|
||||
`next-env.d.ts` 是 Next 维护的类型入口,引用框架/图片和生成路由类型。其路径会随开发/构建环境变化,不在这个生成文件中追加维护注释。
|
||||
|
||||
### 独立 headless 包
|
||||
|
||||
`tsconfig.headless.json` 继承应用配置,显式覆盖输出部分:`target`/`module` 为 ES2022,`rootDir` 为 `src/lib`,`outDir` 为 `.headless-dist/modules`;`noEmit: false` 与 `declaration: true` 生成 JS 和 `.d.ts`。`incremental: false` 不使用这次编译的增量缓存,`rewriteRelativeImportExtensions` 将可重写的相对 TS 导入转成输出扩展名。`types: []` 和 `plugins: []` 清空自动纳入的类型包列表与继承插件条目,不禁止源码显式导入类型;未覆盖的 `lib` 等项仍继承。四组 `include` 从 core/runtime/service/bindings 的 `.ts` 入口收集源码,导入关系还会带入所需依赖。
|
||||
|
||||
`headless/package.json` 是复制到产物根的包模板,源目录本身没有生成后的 `modules`。它的 `type: module` 声明 ESM,Node 引擎要求为 `>=22`。`exports` 为每个公开入口分别指定类型声明和 ESM 文件:根及 `/core` 指向 core,另有 `/runtime`、`/browser`、`/service`、`/bindings`、`/react-flow` 与 `/react`。这些是产物目录中的路径,不是指向 `src` 的别名,也没有声明 CommonJS `require` 入口。
|
||||
|
||||
`files` 限定包收录的模块、消费示例和验收文档。React、React DOM、React Flow 以带版本范围的可选 peer 声明,供选择相关适配入口的宿主提供;“可选”不表示调用 UI 适配器时可以缺少它依赖的框架。便携入口的使用和依赖边界见 `headless/README.md`。`private: true` 保持预览包私有,不代表已经发布到 registry。
|
||||
|
||||
### 组件生成器配置
|
||||
|
||||
`components.json` 是 UI 组件生成器读取的配置,不是应用运行时组件 registry。`$schema` 指向配置的 schema;`style: new-york`、`tsx: true` 和 `rsc: false` 记录生成风格、TSX 和 RSC 选项,不改变整个 App Router 的路由/渲染模式。`tailwind.css` 指向 `src/styles/globals.css`,空 `tailwind.config` 没有指定独立配置路径,`baseColor: neutral` 与 `cssVariables: true` 是生成器的配色约定。`aliases` 指示生成文件的组件、工具、UI、lib 和 hooks 导入位置;其中 `@/hooks` 不保证已经存在对应目录。
|
||||
|
||||
### 图标生成数据
|
||||
|
||||
`src/components/canvas/icon-catalog-data.json` 是检索目录数组,每项包含 `name`(持久引用的图标 ID)、`slug`、`source`、`label`、`categories`、`common` 和 `keywords`;不包含 SVG 路径。`icon-catalog.ts` 用它构建名称 Map 并执行筛选,选择器的虚拟窗口另由 UI 控制。
|
||||
|
||||
`src/lib/canvas-icon-assets.json` 保存 `root` URL 前缀与 `assets` 桶名到摘要文件名的映射。`iconAsset()` 对 Lucide 名称做 31 进制滚动散列并模 96,Tabler 名称统一使用 `tabler` 桶。`public/canvas-icons/<版本目录>/*.json` 每个分片以图标名索引 `[tag, props][]`,消费端 `validateIconShard()` 再检查支持的图形与属性。
|
||||
|
||||
这三类数据由 `scripts/generate-canvas-icons.mjs` 同批生成,中文标注来自 `scripts/canvas-icon-labels.mjs`。文件名摘要是紧凑 JSON 内容的 SHA-256 前 12 位,写盘时另加换行;它与分桶散列用途不同。分片变更须同步目录/manifest 并更新摘要名;脚本不会自动删除旧分片。不要在 JSON 中插入注释或只手改其中一份,否则会破坏读取、引用或缓存命名约定。
|
||||
|
||||
### 协议样例与忽略规则
|
||||
|
||||
`tests/headless/fixtures/foundry-model-operation-v2.json` 是服务适配器单测的完整请求样例。协议 2 的 `request_id` 标识冻结请求,`expected_versions` 是参与 canvas 的已确认版本,`operations[].id` 是逻辑操作 ID。样例把第一代元素 `a` 的 `data.title` 从 `a` 改为 `renamed`;`beforePresent`/`afterPresent` 区分字段缺失与有值。字段旁注与断言在 `tests/headless/service.test.mjs`,静态样例不代表真实服务已执行。
|
||||
|
||||
`.gitignore` 排除依赖、Next/headless 产物、环境变量、增量缓存和系统文件;仅放行 `.env.example` 作为模板。根 `/docs/` 当前作为本地研究目录忽略。忽略规则只影响未跟踪文件,不会把已经跟踪的文件自动移出 Git。`.env.example` 的公开项目标识与服务端源站说明写在模板内;实际凭据不应写进示例或公开构建变量。
|
||||
|
||||
@@ -80,6 +80,9 @@ for (const [key, nodes] of shards) {
|
||||
totalBytes += Buffer.byteLength(content);
|
||||
}
|
||||
// 两份生成JSON分别服务搜索目录与按需加载器;严格JSON不能内嵌注释,其结构与生成流程在本脚本说明。
|
||||
// catalog 每项是 name/slug/source/label/categories/common/keywords;不带 SVG node,搜索只读取这些元数据。
|
||||
// manifest.root 为公开 URL 前缀,assets 将 lucide-N 或 tabler 逻辑桶映射到带内容摘要的 JSON 文件名。
|
||||
// 分片对象按图标 name 索引 [tag, props][];props 为可序列化 SVG 属性,消费端还会执行标签/属性校验。
|
||||
await writeFile("src/components/canvas/icon-catalog-data.json", JSON.stringify(catalog) + "\n");
|
||||
await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n");
|
||||
// 分发路径数据同时复制原包许可证;最后只打印资源统计,不启动应用或验证浏览器显示。
|
||||
|
||||
@@ -1,91 +1,124 @@
|
||||
/* 画布与节点的局部样式:React Flow 全局类通过 :global 组合,状态类由组件/手势控制器切换;CSS 只控制呈现和命中,不负责数据归属、提交或锁。 */
|
||||
/* 画布铺满定位父容器;随后覆盖 React Flow 背景、选中边框和 attribution 外观。 */
|
||||
.editor { position: absolute; inset: 0; width: 100%; height: 100%; background: #fffefb; }
|
||||
.editor :global(.react-flow) { --xy-background-color-default: #fffefb; --xy-node-border-selected-default: 1px solid #a47764; }
|
||||
.editor :global(.react-flow__attribution) { bottom: 5px; right: 5px; font-size: 9px; background: #fffefbc9; }
|
||||
/* 手形模式在 pane/节点上给 grab 光标,拖动 pane 时改为 grabbing。 */
|
||||
.handMode :global(.react-flow__pane), .handMode :global(.react-flow__node) { cursor: grab; }
|
||||
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
|
||||
/* 卡片基础边框/圆角/阴影,selectedNode 单独标记选择;标题绝对放在节点上方。 */
|
||||
.node { position: relative; padding: 0; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
|
||||
.selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; }
|
||||
.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; max-width: 100%; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
|
||||
/* 文本填满 flex 容器,允许尺寸缩到零且关闭原生 resize;焦点时覆盖全局输入阴影,避免额外边框。 */
|
||||
.cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; }
|
||||
.cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
|
||||
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; }
|
||||
/* 卡片图片保持原比例装入可用空间,不以图片固有尺寸撑开节点。 */
|
||||
.image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; border-radius: inherit; object-fit: contain; }
|
||||
/* 工具栏按 aria-pressed 高亮当前模式;disabled 光标不表示实际业务锁逻辑。 */
|
||||
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||
.toolbar button:disabled { cursor: not-allowed; }
|
||||
/* 右上工具条纵排;缩放值固定 48px 并使用等宽数字,避免百分比变化挤动旁边按钮。 */
|
||||
.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
|
||||
.toolbar .zoomValue { box-sizing: border-box; flex: 0 0 48px; width: 48px; height: var(--toolbar-control-height, 32px); padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; }
|
||||
.toolbar .zoomValue:hover { background: #e9e7e3; }
|
||||
/* 底部主工具栏相对画布居中;帮助、错误等浮层各自定位和设层级。 */
|
||||
.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; }
|
||||
/* 帮助面板固定在视口右下方,有宽高上限和内部滚动,长内容不溢出视口。 */
|
||||
.helpPopup { position: fixed; inset: auto 12px 62px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
|
||||
.helpPopup strong { color: #49372e; font-size: 13px; }
|
||||
.helpPopup p { margin-top: 9px; }
|
||||
/* 画布错误在上方居中显示,宽度随视口收缩,不参与节点几何计算。 */
|
||||
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
|
||||
.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; }
|
||||
/* 迁移控制面板处于较高层级;按钮状态、是否允许迁移由组件判断。 */
|
||||
.migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; }
|
||||
.migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
|
||||
.migrationPanel button:disabled { cursor: default; opacity: .5; }
|
||||
/* 旧/迁移画布表面铺满宿主,具体渲染内容由桥接组件决定。 */
|
||||
.migrationSurface { position: absolute; inset: 0; }
|
||||
/* 窄屏把右上工具条下移避让标题,底部工具条改左对齐并横向滚动,预留右侧帮助空间。 */
|
||||
@media (max-width: 700px) {
|
||||
.topRight { top: 62px; right: 12px; }
|
||||
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; }
|
||||
}
|
||||
|
||||
/* 手形模式让文本/图片不拦截指针;右上工具条自身横向滚动以容纳窄视口。 */
|
||||
.handMode .node textarea, .handMode .node img { pointer-events: none; }
|
||||
|
||||
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
|
||||
|
||||
/* 隐藏旧 handle 且禁用命中,新的中心点交互在后续规则控制。 */
|
||||
.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* 变换容器以中心为 transform-origin,保留溢出给标题、handle 和提示;边框/阴影变化有短过渡。 */
|
||||
.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; transition: border-color 160ms ease, box-shadow 160ms ease; }
|
||||
/* 纯内容 frame 默认透明边框,选中才显示;plainText 后置 padding 覆盖卡片文本的 14px。 */
|
||||
.plainFrame { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
|
||||
.plainFrame.selectedFrame { border-color: #a47764; }
|
||||
.plainText { padding: 4px; }
|
||||
/* 中心连接点默认透明且不响应指针;选中只改变可见性,connectionindicator 才开放命中。 */
|
||||
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 0; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; opacity: 0; pointer-events: none; transition: opacity 160ms ease, background 160ms ease, border-color 160ms ease; }
|
||||
.selectedNode :global(.react-flow__handle).centerHandle { opacity: 1; }
|
||||
.node :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
|
||||
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; }
|
||||
/* 右下缩放按钮单独命中、禁止触摸默认行为;旋转模式隐藏它,避免两种手势同时起动。 */
|
||||
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 1; scale: 1; pointer-events: auto; transition: opacity 160ms ease, scale 160ms ease; }
|
||||
.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
|
||||
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
|
||||
/* 旋转时由 frame 接收手势,文本和中心连接点让出指针。 */
|
||||
.rotatingFrame { cursor: grabbing; touch-action: none; }
|
||||
.rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
|
||||
/* 角度提示放在 frame 下方,禁止命中以免遮住正在进行的手势。 */
|
||||
.rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; }
|
||||
/* 节点样式工具栏采用横排可滚动表面,按钮尺寸固定;数值/选择器共用高度。 */
|
||||
.styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; }
|
||||
.styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; }
|
||||
.styleToolbar button:hover, .styleToolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||
.styleToolbar select, .styleToolbar input[type="number"] { height: 28px; border: 0; border-radius: 3px; background: transparent; padding: 0 3px; font-size: 12px; }
|
||||
.styleToolbar input[type="number"] { width: 48px; }
|
||||
/* 可见圆点只显示颜色,透明原生 color input 覆盖整个点击区域。 */
|
||||
.colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; }
|
||||
.colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; }
|
||||
.colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
/* 分支菜单固定定位并高于普通工具条,菜单项整行点击;定位坐标由组件计算。 */
|
||||
.branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; }
|
||||
.branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; }
|
||||
.branchMenu header button { display: grid; place-items: center; width: 24px; height: 24px; }
|
||||
.branchMenu > button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 8px; border-radius: 5px; font-size: 13px; text-align: left; }
|
||||
.branchMenu button:hover { background: #f1e6df; }
|
||||
/* 节点图标填满内容区域,SVG 自身不截获指针,事件交给外层节点。 */
|
||||
.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; }
|
||||
.iconContent svg { pointer-events: none; }
|
||||
/* 图标选择器固定在左侧,可用高度限定并采用纵向 flex,头/搜索/分类与列表分区。 */
|
||||
.iconPicker { position: fixed; top: 70px; left: 12px; bottom: 70px; z-index: 60; display: flex; flex-direction: column; width: 272px; max-width: calc(100vw - 24px); max-height: 550px; padding: 14px; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a14; }
|
||||
.iconPicker header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 14px; }
|
||||
.iconPicker header button { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 4px; }
|
||||
.iconPicker input { width: 100%; padding: 7px 9px; border: 1px solid #ded7ce; border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
|
||||
/* 普通图标网格五列并内部滚动;后续 virtualIconGrid 是虚拟列表视口,窗口项目由 JS 定位。 */
|
||||
.iconGrid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; overflow-y: auto; }
|
||||
.iconGrid button { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; }
|
||||
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
|
||||
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
|
||||
/* 来源选择器限制宽度,分类按钮可换行并按 aria-pressed 显示当前筛选。 */
|
||||
.iconSource { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 11px; }
|
||||
.iconSource select { min-width: 0; max-width: 175px; padding: 4px; border: 1px solid #ded7ce; border-radius: 5px; background: transparent; }
|
||||
.iconCategories { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
|
||||
.iconCategories button { padding: 3px 6px; border-radius: 4px; color: #92877c; font-size: 10px; }
|
||||
.iconCategories button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||
/* flex:1 让列表吃剩余高度,overscroll contain 减少滚动传到外层;CSS 不负责裁剪数据窗口。 */
|
||||
.virtualIconGrid { flex: 1; min-height: 96px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.virtualIconGrid button { display: grid; place-items: center; border: 3px solid transparent; border-radius: 8px; background-clip: padding-box; color: #6d6054; }
|
||||
.virtualIconGrid button:hover { background-color: #f1e6df; }
|
||||
.virtualIconGrid button:focus-visible, .iconCategories button:focus-visible { outline: 2px solid #a47764; outline-offset: -2px; }
|
||||
/* 图标资源加载提示与选择器使用相近层级/起点。 */
|
||||
.iconLoading { position: absolute; left: 12px; top: 70px; z-index: 60; padding: 14px; border-radius: 10px; background: #fffefb; font-size: 12px; }
|
||||
/* 归属徽标放在节点下方,限制长名称且不参与命中;旋转时隐藏以减少遮挡。 */
|
||||
.membershipBadge { position: absolute; bottom: -25px; left: 0; display: flex; align-items: center; gap: 4px; max-width: 280px; padding: 3px 6px; border-radius: 4px; background: #e9f1e9; color: #3e684b; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
|
||||
.rotatingFrame .membershipBadge { display: none; }
|
||||
.frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; }
|
||||
/* 名称模式按钮宽度随文字,菜单固定定位且限制在视口内,勾选列保持对齐。 */
|
||||
.styleToolbar .frameModeTrigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: auto; min-width: 96px; flex: 0 0 auto; padding: 0 7px; font-size: 12px; }
|
||||
.frameModeMenu { position: fixed; z-index: 120; width: 220px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); overflow-y: auto; padding: 5px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 8px 28px #32251a1f; }
|
||||
.frameModeMenu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 7px; border-radius: 5px; text-align: left; }
|
||||
@@ -94,33 +127,43 @@
|
||||
.frameModeCheck { display: grid; place-items: center; width: 15px; flex: 0 0 15px; }
|
||||
.frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; }
|
||||
.frameModeMenu small { display: block; margin-top: 2px; color: #92877c; font-size: 10px; }
|
||||
/* 加入目标用绿色边框/光晕,移出预览改虚线;提示元素本身不拦截拖放。 */
|
||||
.frameNode.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; }
|
||||
.frameNode.frameDropExit { border-style: dashed; }
|
||||
.frameDropHint { position: absolute; top: 12px; left: 12px; transform-origin: top left; border: 1px solid #9db8a4; border-radius: 6px; padding: 6px 9px; background: #f3f8f3; color: #3e684b; font-size: 12px; white-space: nowrap; pointer-events: none; }
|
||||
/* 归属变更通知居中放在底部工具栏上方;空内容隐藏,非空也不接管指针。 */
|
||||
.membershipNotice { position: absolute; bottom: 70px; left: 50%; transform: translateX(-50%); z-index: 20; max-width: min(540px, calc(100vw - 32px)); padding: 8px 12px; border: 1px solid #c8d9ca; border-radius: 8px; background: #f3f8f3; color: #3e684b; font-size: 12px; pointer-events: none; }
|
||||
.membershipNotice:empty { display: none; }
|
||||
/* 手形模式统一关闭文本、缩放、中心点和样式工具条的编辑入口。 */
|
||||
.handMode .plainText { pointer-events: none; }
|
||||
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
|
||||
.handMode .cornerResize { pointer-events: none; opacity: 0.6; }
|
||||
.handMode .styleToolbar { display: none; }
|
||||
/* 第一组减少动态效果规则关闭 frame、角缩放和中心点的过渡;后面还有分段滑块/连线动画的关闭规则。 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; }
|
||||
}
|
||||
|
||||
|
||||
/* 分区主体 pointer-events:none,让内部节点/画布继续接收事件;标签和四边命中条另行启用 pointer-events。 */
|
||||
.frameNode { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; }
|
||||
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; }
|
||||
/* 名称放在分区顶部外侧并以左下角缩放;文字截断但标签自身可拖动。 */
|
||||
.frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; }
|
||||
/* 四条 10px 命中带跨在视觉边框两侧,扩大拖动区域,不给整个分区内部铺点击层。 */
|
||||
.frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
|
||||
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
|
||||
.frameBorder_bottom { bottom: -5px; left: 0; right: 0; height: 10px; }
|
||||
.frameBorder_left { left: -5px; top: 0; bottom: 0; width: 10px; }
|
||||
.frameBorder_right { right: -5px; top: 0; bottom: 0; width: 10px; }
|
||||
/* 分区改名输入沿用工具栏高度;手形模式下标签和命中边框也不接管事件。 */
|
||||
.styleToolbar .frameNameInput { width: 150px; height: 28px; padding: 0 6px; border: 1px solid #ded7ce; border-radius: 4px; font-size: 12px; background: transparent; }
|
||||
.handMode .frameBorderHit, .handMode .frameLabel { pointer-events: none; }
|
||||
/* 同步错误提示在顶部居中并允许按钮换行,说明文字独占一行。 */
|
||||
.syncError { position: absolute; left: 50%; top: 70px; translate: -50% 0; z-index: 22; display: flex; flex-wrap: wrap; gap: 8px; max-width: min(580px, calc(100vw - 24px)); padding: 12px; border: 1px solid #e5c7b8; border-radius: 8px; background: #fff9f3; color: #a25439; font-size: 12px; }
|
||||
.syncError span { flex-basis: 100%; }
|
||||
|
||||
/* 恢复对话框限制视口宽度并居中,backdrop 给遮罩色;模态焦点行为来自 dialog 而不是 CSS。 */
|
||||
.recoveryDialog { max-width: min(460px, calc(100vw - 32px)); padding: 24px; margin: auto; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 12px 50px #32251a20; }
|
||||
.recoveryDialog::backdrop { background: #30251e33; }
|
||||
.recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; }
|
||||
@@ -128,24 +171,32 @@
|
||||
.recoveryDialog > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
|
||||
.recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; }
|
||||
|
||||
/* 文本编辑容器允许 flex 收缩;readonly textarea 让出指针,由上层选择层处理选择/拖动。 */
|
||||
.textEditor { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; border-radius: inherit; }
|
||||
.textEditor textarea[readonly] { pointer-events: none; }
|
||||
/* 透明选择层盖住内容区域,选中后给 grab 光标;手形模式取消该层命中。 */
|
||||
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
|
||||
.handMode .textSelectLayer { pointer-events: none; }
|
||||
|
||||
.selectedFrame .textSelectLayer { cursor: grab; }
|
||||
.textSelectLayer:active { cursor: grabbing; }
|
||||
/* 纯图片没有卡片 padding,图片仅显示不接受选择/拖动,外层控制变换。 */
|
||||
.plainImageFrame { padding: 0; }
|
||||
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; }
|
||||
/* 标题省略与改名输入分开;输入固定目标宽度并继承字体。 */
|
||||
.nodeTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nodeTitleInput { width: 180px; min-width: 0; height: 22px; padding: 0 4px; border: 1px solid #cbb8a8; border-radius: 4px; background: #fffefb; font: inherit; color: #78523f; }
|
||||
|
||||
/* 三段图标选项用 isolation 建局部层叠,按钮在滑块上方,背景由滑块提供。 */
|
||||
.frameLabelSegments { position: relative; display: flex; padding: 3px; border-radius: 6px; background: #f1eee8; isolation: isolate; }
|
||||
.frameLabelSegments button { position: relative; z-index: 1; width: 28px; height: 28px; flex: 0 0 28px; background: transparent !important; color: #92877c; }
|
||||
.frameLabelSegments button[aria-checked="true"] { color: #78523f; }
|
||||
/* 滑块绝对定位并用 transform 做短过渡,实际位移由组件按选项设置。 */
|
||||
.frameLabelSlider { position: absolute; left: 3px; top: 3px; width: 28px; height: 28px; border-radius: 4px; background: #fffefb; box-shadow: 0 1px 4px #32251a16; transition: transform 160ms ease; }
|
||||
/* 八向缩放 handle 按中心锚定,pointer-events:auto 覆盖外层让出命中的行为;手形模式再禁用。 */
|
||||
.resizeHandle { position: absolute; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1px solid #a47764; border-radius: 2px; padding: 0; background: #fffefb; z-index: 12; touch-action: none; pointer-events: auto; }
|
||||
.handMode .resizeHandle { pointer-events: none; }
|
||||
/* 连线工具条可换行,数字、颜色、范围和选择器分别限宽,避免长表单撑出视口。 */
|
||||
.edgeToolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 7px; max-width: min(680px, calc(100vw - 24px)); padding: 10px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a14; }
|
||||
.edgeToolbar label { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||
.edgeToolbar input[type="number"] { width: 50px; }
|
||||
@@ -156,17 +207,24 @@
|
||||
.edgeToolbar button:hover { background: #f1e6df; }
|
||||
.edgeToolbar button:disabled { opacity: .4; }
|
||||
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
|
||||
/* 曲线控制点用 pointer-events:all 开放 SVG 命中,触摸默认行为关闭,拖动逻辑由事件处理器负责。 */
|
||||
.curveControl { cursor: move; touch-action: none; pointer-events: all; }
|
||||
/* 连线效果分别用 dashoffset 流动和 opacity 脉冲;是否附加动画类由执行状态/配置决定。 */
|
||||
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
|
||||
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
|
||||
/* 虚线一个周期移动 40 单位;脉冲从低透明到不透明再返回。 */
|
||||
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
|
||||
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
|
||||
/* 第二组减少动态效果规则关闭滑块过渡和两种连线动画。 */
|
||||
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } }
|
||||
/* 全画布 busy 覆盖层是独立呈现态;成员局部同步使用后部 memberSync,不应从这个类推断所有任务都会锁画布。 */
|
||||
.canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; }
|
||||
/* 子图显示比例输入窄排布;折叠子图的摘要占内部 inset 区域并可点击。 */
|
||||
.subgraphScale { display: flex; align-items: center; gap: 2px; font-size: 11px; }
|
||||
.subgraphScale input { width: 45px; }
|
||||
.collapsedSubgraph { position: absolute; inset: 15px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: #92877c; pointer-events: auto; font-size: 12px; }
|
||||
.collapsedSubgraph small { font-size: 10px; }
|
||||
/* 元素 AI 面板限制视口宽度;标题信息省略,提示词可纵向伸展,底部模型/传播/执行按钮横排。 */
|
||||
.aiPanel { width: min(440px, calc(100vw - 28px)); border: 1px solid #ded7ce; border-radius: 13px; padding: 14px; background: #fffefb; box-shadow: 0 8px 30px #32251a16; color: #49372e; }
|
||||
.aiPanel header { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 9px; }
|
||||
.aiPanel header span { margin-left: auto; color: #92877c; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
|
||||
@@ -176,32 +234,40 @@
|
||||
.aiPanel footer label { display: flex; align-items: center; gap: 4px; font-size: 10px; white-space: nowrap; }
|
||||
.aiPanel footer button { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border-radius: 8px; background: #78523f; color: white; }
|
||||
.aiPanel footer button:disabled { opacity: .35; }
|
||||
/* 普通提示与 role=alert 错误采用不同颜色;冲突候选区域独立滚动并保留预格式文本换行。 */
|
||||
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
|
||||
.aiPanel p[role="alert"] { color: #a25439; }
|
||||
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
|
||||
.aiConflict pre { white-space: pre-wrap; }
|
||||
.aiConflict img { max-height: 100px; }
|
||||
.aiConflict button { padding: 6px; color: #78523f; }
|
||||
/* 对通用变换 frame 再声明中心 handle:默认不可见不可命中,选中显示、connectionindicator 开启命中。 */
|
||||
.transformFrame :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; border: 0; background: #a47764; opacity: 0; pointer-events: none; }
|
||||
.selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; }
|
||||
.transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
|
||||
/* 端口先定义几何,再在下一条默认隐藏/禁用;选中只显示,是否可连仍由 connectionindicator 决定。 */
|
||||
.framePort { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; }
|
||||
.framePort { opacity: 0; pointer-events: none; }
|
||||
.selectedFrame .framePort { opacity: 1; }
|
||||
.framePort:global(.connectionindicator) { pointer-events: auto; }
|
||||
.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
|
||||
/* busy 状态中的恢复/重试按钮仍显示明确可点表面。 */
|
||||
.canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; }
|
||||
|
||||
/* 图片上传状态浮在底部工具栏上方,宽度受容器限制,不作为节点持久数据。 */
|
||||
.imageUploadStatus { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; }
|
||||
|
||||
/* 成员同步只显示紧凑状态和下划线操作按钮,不使用全画布遮罩。 */
|
||||
.memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; }
|
||||
.memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; }
|
||||
|
||||
/* 导入状态面板位于右上,有宽高上限和内部滚动;summary 切换详情,任务之间用边线分隔。 */
|
||||
.importPanel { position: absolute; top: 84px; right: 12px; z-index: 12; width: max-content; max-width: min(310px, calc(100vw - 28px)); max-height: 45vh; overflow: auto; border: 1px solid #ded7ce; border-radius: 10px; padding: 6px 9px; background: #fffefb; box-shadow: 0 4px 18px #32251a10; color: #49372e; font-size: 11px; }
|
||||
.importPanel summary { cursor: pointer; padding: 5px 0; }
|
||||
.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; }
|
||||
.importPanel p { margin: 5px 0; }
|
||||
.importPanel small { color: #92877c; }
|
||||
.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; }
|
||||
/* 导入动作横排,其余任务按钮使用文字色与局部留白,任务状态由 runtime/组件决定。 */
|
||||
.importActions { display: flex; align-items: center; gap: 8px; }
|
||||
.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; }
|
||||
|
||||
@@ -1,95 +1,121 @@
|
||||
/* 当前 LandingPage 的局部配色、两栏首屏、可选预览卡片、功能区和响应式布局;预览交互由组件状态驱动,不使用真实画布模型。 */
|
||||
/* 页面作用域内定义墨色/弱文字/边线变量,局部规则覆盖全局品牌类时使用 :global。 */
|
||||
.page { --ink: #21221f; --muted: #797b73; --line: #e5e6df; background: #fff; color: var(--ink); font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
/* 移除触摸高亮并保留明确的键盘可见焦点描边。 */
|
||||
.page a, .page button { -webkit-tap-highlight-color: transparent; }
|
||||
.page a:focus-visible, .page button:focus-visible { outline: 2px solid #6879c2; outline-offset: 4px; }
|
||||
/* 导航内容限制最大宽度,品牌、导航链接和右侧操作区横排。 */
|
||||
.nav { display: flex; align-items: center; gap: 40px; height: 88px; max-width: 1320px; padding: 0 48px; margin: auto; }
|
||||
.page :global(.brand) { color: var(--ink); gap: 8px; font-size: 19px; letter-spacing: -0.6px; }
|
||||
.page :global(.brand img) { width: 30px; height: 30px; }
|
||||
.page :global(.brand small) { color: #92958c; font-size: 11px; font-weight: 400; letter-spacing: 0; }
|
||||
.nav nav { display: flex; gap: 28px; font-size: 13px; color: #72766c; }
|
||||
.nav nav a:hover { color: var(--ink); }
|
||||
/* margin-left:auto 将登录和小 CTA 推到最右侧,hover 只改变表面色。 */
|
||||
.navActions { margin-left: auto; display: flex; align-items: center; gap: 24px; font-size: 13px; }
|
||||
.smallCta { display: inline-flex; align-items: center; gap: 12px; padding: 10px 15px; border: 1px solid #cfd2c8; border-radius: 5px; }
|
||||
.smallCta:hover { background: #f5f6f0; }
|
||||
/* 桌面首屏文案/预览用接近等宽的两列;clamp 让标题在边界内随视口缩放。 */
|
||||
.hero { max-width: 1320px; margin: auto; padding: 82px 48px 90px; display: grid; grid-template-columns: 1fr 1.04fr; align-items: center; gap: 46px; }
|
||||
.eyebrow { font: 10px/1.6 ui-monospace, "SFMono-Regular", monospace; letter-spacing: 1.25px; color: #90958a; }
|
||||
.heroCopy .eyebrow { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.statusDot { width: 5px; height: 5px; background: #83966c; border-radius: 50%; }
|
||||
.hero h1 { font-size: clamp(38px, 4.4vw, 61px); font-weight: 600; line-height: 1.23; letter-spacing: -3px; margin: 22px 0; }
|
||||
.hero h1 > span { position: relative; white-space: nowrap; }
|
||||
/* 标题装饰线通过伪元素绘制,不增加实际文本或影响行内宽度。 */
|
||||
.hero h1 > span::after { content: ""; position: absolute; left: 0; bottom: -3px; width: 100%; height: 5px; border-bottom: 2px solid #b9c87c; border-radius: 50%; rotate: -2deg; }
|
||||
.heroCopy > p { color: #6f7369; font-size: 15px; line-height: 1.9; font-weight: 400; }
|
||||
/* CTA 可换行,主按钮悬停上移 1px;旁边文字链接保持次级视觉权重。 */
|
||||
.heroActions { display: flex; flex-wrap: wrap; align-items: center; gap: 22px; margin-top: 30px; }
|
||||
.primary { display: inline-flex; align-items: center; justify-content: center; gap: 16px; padding: 14px 20px; background: var(--ink); color: #fff; border: 1px solid var(--ink); border-radius: 5px; font-size: 13px; font-weight: 500; box-shadow: 0 2px 2px #21221f08; transition: background 160ms, translate 160ms; }
|
||||
.primary:hover { background: #41453b; translate: 0 -1px; }
|
||||
.textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: #6e7565; }
|
||||
.textLink:hover { color: #21221f; }
|
||||
/* 手写风附注和星形装饰仅参与视觉排版。 */
|
||||
.heroNote { margin-top: 34px; font-family: "Kaiti SC", "STKaiti", Georgia, serif; color: #969a8b; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.scribbleStar { font-family: Georgia, serif; color: #929a74; font-size: 24px; }
|
||||
/* 预览外壳轻微旋转并设置阴影,min-width:0 防止网格项被内容撑宽。 */
|
||||
.preview { min-width: 0; border: 1px solid #e0e3d9; border-radius: 9px; background: #fafafa; box-shadow: 0 12px 50px #3c442508, 0 2px 8px #3c442505; rotate: -1deg; }
|
||||
.previewHeading { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; font-size: 10px; color: #777d70; }
|
||||
.previewHeading > span:first-child { display: flex; align-items: center; gap: 7px; color: #69745e; }
|
||||
.previewHeading > span:last-child { font: 8px ui-monospace, monospace; letter-spacing: 1px; }
|
||||
.tinyLogo { font-size: 20px; color: #4c5841; }
|
||||
/* 点阵预览固定宽高比,卡片和 SVG 连线在相同相对容器内绝对定位。 */
|
||||
.scene { position: relative; aspect-ratio: 600 / 440; background-image: radial-gradient(#dce1d2 0.65px, transparent 0.65px); background-size: 17px 17px; }
|
||||
.wires { position: absolute; inset: 0; width: 100%; height: 100%; stroke: #a3ad90; stroke-width: 1; }
|
||||
.wires circle { fill: #a3ad90; }
|
||||
/* 预览卡片作为按钮可点击,hover 上移,activeCard 通过边框/阴影反馈当前选择。 */
|
||||
.scene > button { position: absolute; display: flex; flex-direction: column; text-align: left; border: 1px solid #e1e4d8; border-radius: 5px; padding: 14px; background: #fff; color: #373a35; box-shadow: 0 2px 5px #32471504; transition: box-shadow 180ms, border-color 180ms, translate 180ms; }
|
||||
.scene > button:hover { translate: 0 -3px; }
|
||||
.scene .activeCard { border-color: #9aa77d; box-shadow: 0 0 0 2px #9aa77d15, 0 4px 12px #32471508; }
|
||||
.cardLabel { display: flex; align-items: center; gap: 5px; font-size: 9px; color: #9aab88; }
|
||||
/* 三类预览卡片各有位置、旋转和排版;纸角、艺术图与标签是局部装饰。 */
|
||||
.ideaCard { left: 5%; top: 17%; width: 42%; min-height: 41%; rotate: -3deg; }
|
||||
.ideaCard strong { margin-top: 14px; margin-bottom: 13px; font-size: clamp(13px, 1.6vw, 22px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.55; }
|
||||
.cardText { font-size: 9px; line-height: 1.8; color: #9ba48f; }
|
||||
.paperCorner { position: absolute; bottom: 4px; right: 4px; width: 10px; height: 10px; border-right: 1px solid #d9dece; border-bottom: 1px solid #d9dece; }
|
||||
/* 艺术卡片给 SVG 可用高度,使用无填充线稿;便签另有底色与字号。 */
|
||||
.scene .artCard { top: 8%; right: 7%; width: 29%; min-height: 49%; background: #edeaf7; rotate: 4deg; }
|
||||
.artCard svg { width: 100%; max-height: 150px; flex: 1; stroke: #53584d; stroke-width: 1.2; fill: none; }
|
||||
.artCaption { font: italic 8px Georgia, serif; text-align: center; color: #909e79; }
|
||||
.scene .noteCard { left: 38%; bottom: 9%; width: 37%; min-height: 27%; background: #f0eedf; rotate: 2deg; }
|
||||
.noteCard strong { margin: 9px 0 12px; font-size: clamp(11px, 1.4vw, 18px); font-family: "Songti SC", Georgia, serif; font-weight: 400; line-height: 1.5; }
|
||||
.noteTag { align-self: flex-start; font-size: 8px; padding: 3px 5px; border: 1px solid #d6d6ba; border-radius: 3px; color: #8f9278; }
|
||||
/* 随手附注与假协作光标定位在预览中,光标不拦截鼠标事件。 */
|
||||
.looseNote { position: absolute; left: 8%; bottom: 12%; color: #aab39c; rotate: -10deg; font: italic 14px Georgia, serif; }
|
||||
.cursor { position: absolute; right: 17%; bottom: 20%; color: #879567; rotate: -12deg; pointer-events: none; }
|
||||
.cursor span { position: absolute; top: 20px; left: 12px; background: #879567; color: white; border-radius: 3px; padding: 3px 7px; font-size: 9px; }
|
||||
/* 预览页脚横排指示点与页码,aria-pressed 标示当前点,最后一项推到右侧。 */
|
||||
.previewFooter { display: flex; align-items: center; gap: 10px; padding: 13px 18px; border-top: 1px solid var(--line); font-size: 9px; color: #a1a992; }
|
||||
.previewFooter > div { display: flex; gap: 5px; }
|
||||
.previewFooter button { width: 5px; height: 5px; border-radius: 50%; background: #d8dfcc; }
|
||||
.previewFooter button[aria-pressed="true"] { background: #879567; }
|
||||
.previewFooter > span:last-child { margin-left: auto; font: 9px ui-monospace, monospace; }
|
||||
/* 宣言横条用上下边线与两侧注记分隔首屏和后续内容。 */
|
||||
.manifestoStrip { display: flex; align-items: center; justify-content: space-between; gap: 20px; max-width: 1224px; margin: 0 auto; padding: 25px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.manifestoStrip > span { font: 8px ui-monospace, monospace; letter-spacing: 0.6px; color: #a0a494; }
|
||||
.manifestoStrip p { font-size: 13px; color: #646c58; letter-spacing: 0.3px; }
|
||||
.manifestoStrip p span { color: #9ba28e; }
|
||||
/* 功能区域限制最大宽度;标题与说明左右对齐,功能卡使用三列网格。 */
|
||||
.space { max-width: 1320px; margin: auto; padding: 105px 48px; }
|
||||
.sectionHeading { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-bottom: 48px; }
|
||||
.sectionHeading h2, .faq h2 { font-size: 34px; line-height: 1.5; letter-spacing: -1px; font-weight: 500; margin-top: 18px; }
|
||||
.sectionHeading > p { padding-bottom: 5px; color: #6c7166; line-height: 1.9; font-size: 13px; }
|
||||
.featureGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
|
||||
.featureGrid article { min-width: 0; }
|
||||
/* 每个功能插画使用固定高度裁剪容器,内部收集/连线/创作布局分别绘制。 */
|
||||
.featureArt { position: relative; height: 185px; border: 1px solid #eaede4; border-radius: 6px; background: #f9faf6; overflow: hidden; margin-bottom: 25px; }
|
||||
/* 收集示例用轻微旋转和水平位移错开三条素材,保持纵向居中。 */
|
||||
.collectArt { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; }
|
||||
.collectArt span { display: flex; align-items: center; gap: 9px; padding: 8px 12px; min-width: 70%; border: 1px solid #e1e6d7; border-radius: 4px; background: #fff; font-size: 10px; color: #859471; rotate: -2deg; }
|
||||
.collectArt span:nth-child(2) { rotate: 2deg; translate: 8px 0; }
|
||||
.collectArt span:nth-child(3) { rotate: -1deg; translate: -5px 0; }
|
||||
.collectArt svg { width: 14px; }
|
||||
/* 连线示例 SVG 铺满插画,两个端点卡片和装饰字形单独定位。 */
|
||||
.connectArt svg { width: 100%; height: 100%; stroke: #b1b99e; }
|
||||
.connectArt span { position: absolute; top: 39px; left: 23px; padding: 10px 13px; border: 1px solid #e1e5d6; border-radius: 5px; background: #fff; color: #8a9877; font-size: 11px; }
|
||||
.connectArt span:nth-of-type(2) { top: 110px; left: auto; right: 35px; background: #edf0e1; }
|
||||
.connectArt i { position: absolute; top: 38px; right: 25px; color: #a0ad86; font: 32px Georgia, serif; }
|
||||
/* 创作示例居中纸张和标题,编辑徽标独立固定在插画右下附近。 */
|
||||
.createArt { display: grid; place-items: center; }
|
||||
.createArt > div { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px; background: #f1efe3; width: 72%; rotate: -2deg; border: 1px solid #e0dfcf; }
|
||||
.createArt > div > span { font: 7px ui-monospace, monospace; letter-spacing: 1px; color: #9da184; }
|
||||
.createArt strong { font: 25px "Songti SC", Georgia, serif; color: #6e7857; letter-spacing: 1px; }
|
||||
.editBadge { position: absolute; right: 14px; bottom: 24px; display: flex; align-items: center; gap: 5px; padding: 5px 8px; border: 1px solid #dce3cf; background: #fff; border-radius: 4px; font-size: 8px; color: #889874; }
|
||||
/* 编号、功能标题与正文使用逐级字号,正文行高便于多行阅读。 */
|
||||
.featureNumber { color: #a4ab98; font: 9px ui-monospace, monospace; letter-spacing: 0.7px; }
|
||||
.featureGrid h3 { font-size: 18px; font-weight: 500; margin: 12px 0; letter-spacing: -0.4px; }
|
||||
.featureGrid p { font-size: 12px; color: #6c7166; line-height: 1.9; }
|
||||
/* 工作流区域用浅底与上下边线分隔,两列分别承载引语和步骤。 */
|
||||
.workflow { background: #f7f7f4; border-top: 1px solid #e9ece1; border-bottom: 1px solid #e9ece1; display: grid; grid-template-columns: 1fr 1fr; gap: 90px; padding: 85px max(48px, calc((100vw - 1224px) / 2)); }
|
||||
/* 引号绝对定位在文案旁,标题自身保持相对定位,签名字体与正文区分。 */
|
||||
.quote { position: relative; }
|
||||
.quoteMark { font: 80px/1 Georgia, serif; position: absolute; top: -30px; left: -12px; color: #d9dfca; }
|
||||
.quote h2 { position: relative; font: 31px/1.7 "Songti SC", Georgia, serif; color: #586449; }
|
||||
.quote em { display: block; font-style: normal; }
|
||||
.quote p { font-size: 12px; color: #747a6d; line-height: 2; margin-top: 18px; }
|
||||
.signature { display: block; font: italic 13px Georgia, serif; color: #a3ad94; margin-top: 30px; }
|
||||
/* 步骤纵向居中,每项含编号、文字和右侧不收缩的图标。 */
|
||||
.workflowList { display: flex; flex-direction: column; justify-content: center; }
|
||||
.workflowList > div { display: flex; gap: 18px; padding: 24px 0; border-bottom: 1px solid #dfe5d4; }
|
||||
.workflowList > div:first-child { padding-top: 0; }
|
||||
@@ -97,11 +123,13 @@
|
||||
.workflowList h3 { font-size: 15px; font-weight: 500; color: #5e6e4e; margin-bottom: 8px; }
|
||||
.workflowList p { font-size: 11px; line-height: 1.8; color: #747a6d; }
|
||||
.workflowList svg { flex-shrink: 0; color: #a5b38e; margin-left: auto; }
|
||||
/* FAQ 桌面为标题/问题两列,问题按钮整行可点击;展开内容是否存在由组件决定。 */
|
||||
.faq { max-width: 1320px; padding: 105px 48px; margin: auto; display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 90px; }
|
||||
.faqItem { border-bottom: 1px solid var(--line); }
|
||||
.faqItem button { display: flex; align-items: center; justify-content: space-between; gap: 20px; width: 100%; padding: 23px 0; font-size: 14px; text-align: left; }
|
||||
.faqItem svg { flex-shrink: 0; color: #929b84; transition: rotate 160ms; }
|
||||
.faqItem p { padding: 0 28px 24px 0; font-size: 12px; line-height: 1.9; color: #8b947e; }
|
||||
/* 收尾 CTA 居中,星形装饰绝对定位;页脚内容横排并用 auto margin 推开链接。 */
|
||||
.closing { position: relative; border-top: 1px solid var(--line); text-align: center; padding: 85px 24px 90px; }
|
||||
.closing h2 { font-size: 38px; letter-spacing: -1.3px; font-weight: 500; margin: 18px 0; }
|
||||
.closing p { font-size: 13px; color: #74796d; margin-bottom: 28px; }
|
||||
@@ -110,8 +138,11 @@
|
||||
.footer > p { font-size: 11px; color: #a1a992; }
|
||||
.footer > a:not(:first-child) { margin-left: auto; display: flex; gap: 7px; align-items: center; font-size: 11px; color: #7e8b6a; }
|
||||
.footer > span { color: #abb39d; font: 8px ui-monospace, monospace; }
|
||||
/* 断行标记默认隐藏,本文件没有另行显示它的媒体查询。 */
|
||||
.mobileBreak { display: none; }
|
||||
/* 大屏仅增大首屏上下留白。 */
|
||||
@media (min-width: 1400px) { .hero { padding-top: 105px; padding-bottom: 110px; } }
|
||||
/* 中屏压缩页面边距、标题、按钮及区块间距,仍保留主要双栏/三栏结构。 */
|
||||
@media (max-width: 1050px) {
|
||||
.nav, .hero, .space, .faq, .footer { padding-left: 30px; padding-right: 30px; }
|
||||
.hero { gap: 26px; }
|
||||
@@ -124,6 +155,7 @@
|
||||
.featureGrid { gap: 20px; }
|
||||
.faq { gap: 45px; }
|
||||
}
|
||||
/* 窄屏隐藏导航链接,将首屏、功能、工作流和 FAQ 转为单列,并缩小装饰及页脚内容。 */
|
||||
@media (max-width: 760px) {
|
||||
.nav { height: 74px; gap: 15px; padding-left: 22px; padding-right: 22px; }
|
||||
.nav nav { display: none; }
|
||||
@@ -159,6 +191,7 @@
|
||||
.footer > p { display: none; }
|
||||
.footer > span { flex-basis: 100%; }
|
||||
}
|
||||
/* 极窄屏进一步压缩边距和预览卡片文字,隐藏品牌副标,避免最小视口拥挤。 */
|
||||
@media (max-width: 380px) {
|
||||
.page :global(.brand small) { display: none; }
|
||||
.hero { padding-left: 20px; padding-right: 20px; }
|
||||
@@ -170,4 +203,5 @@
|
||||
.ideaCard strong { margin-top: 8px; margin-bottom: 8px; }
|
||||
.noteTag { font-size: 7px; padding: 2px; }
|
||||
}
|
||||
/* 按减少动态效果偏好关闭链接、按钮和 FAQ 图标的过渡。 */
|
||||
@media (prefers-reduced-motion: reduce) { .page a, .page button, .faqItem svg { transition: none; } }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* IconButton 的共享尺寸、交互态和 tooltip 外观;尺寸从所在 Toolbar 的自定义属性继承,缺省为 32px 控件/20px 图标。 */
|
||||
/* 固定 flex 尺寸避免拥挤时压缩;inline-flex 居中图标,透明背景显露宿主表面,文字颜色继承宿主。 */
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -13,10 +15,13 @@
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* SVG 同时约束宽高与 flex-basis,防止图标在文字/布局挤压下变形。 */
|
||||
.button svg { width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); flex: 0 0 var(--toolbar-icon-size, 20px); }
|
||||
/* 悬停填底、键盘可见焦点描边、禁用态降低透明度并提示不可操作。 */
|
||||
.button:hover { background: #e9e7e3; }
|
||||
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||
.button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
/* tooltip 使用高层级深色面板与有限宽度;arrow 颜色与面板一致。 */
|
||||
.tooltip { z-index: 1000; max-width: 240px; padding: 6px 9px; border-radius: 6px; background: #49372e; color: #fffdf9; font-size: 12px; line-height: 1.45; box-shadow: 0 3px 12px #372f2420; user-select: none; }
|
||||
.arrow { fill: #49372e; }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* Toolbar 共享表面:统一控件尺寸变量、排列间距、边框和背景;不负责自身在画布中的定位。 */
|
||||
/* 提供后代按钮消费的 CSS 变量;半透明背景与 backdrop blur 处理工具栏背后的内容。 */
|
||||
.surface {
|
||||
--toolbar-icon-size: 20px;
|
||||
--toolbar-control-width: 32px;
|
||||
@@ -18,5 +20,7 @@
|
||||
}
|
||||
|
||||
/* Text in the view controls already creates wider visual spacing. */
|
||||
/* 含缩放文字的视图工具栏增加横向留白,不改变控件本身的尺寸。 */
|
||||
.relaxed { column-gap: 6px; padding-inline: 6px; }
|
||||
/* 分隔线保持 1px flex 基准,避免被相邻控件挤宽或挤没。 */
|
||||
.divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; }
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/* EditableTitle 的只读标题/输入框切换样式;壳体作为错误提示的定位参照,网络和提交状态由组件控制。 */
|
||||
/* 相对定位保留标题所在行,绝对定位错误框不会把工具栏向下撑开。 */
|
||||
.shell { position: relative; display: flex; align-items: center; }
|
||||
/* 两种状态共用高度、字体和透明边框,降低进入编辑时的尺寸跳变。 */
|
||||
.title, .input { box-sizing: border-box; min-width: 0; max-width: 100%; height: 32px; padding: 0 6px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; font: inherit; line-height: 30px; }
|
||||
/* 非编辑标题单行省略;悬停仅作用于可用按钮,选中态由 data-selected 控制。 */
|
||||
.title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; }
|
||||
.title:hover:not(:disabled) { background: #f0ece6; }
|
||||
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
|
||||
.title:disabled { cursor: default; }
|
||||
/* 编辑态给定目标宽度但受上方 max-width 限制,readonly 时降低透明度。 */
|
||||
.input { width: 220px; border-color: #a47764; background: white; outline: none; }
|
||||
.input[readonly] { opacity: 0.65; }
|
||||
/* 错误框显示在标题下方,并以视口宽度限制最大宽度;长说明允许换行。 */
|
||||
.error { position: absolute; top: calc(100% + 10px); left: 0; width: 260px; max-width: calc(100vw - 40px); padding: 8px 10px; border: 1px solid #e5c7b8; border-radius: 7px; background: #fff9f3; color: #a25439; font-size: 12px; line-height: 1.6; white-space: normal; box-shadow: 0 4px 12px #32251a0a; }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* 工作区壳体、目录、账户菜单与画布浮动标题;CSS Module 负责显示,账号/画布生命周期和侧栏开关由组件管理。 */
|
||||
/* 侧栏宽度变量供侧栏本体和浮动标题共同消费,根容器至少占满动态视口高度。 */
|
||||
.shell {
|
||||
--sidebar-width: 270px;
|
||||
display: flex;
|
||||
@@ -5,6 +7,7 @@
|
||||
background: #fffefb;
|
||||
color: #37352f;
|
||||
}
|
||||
/* 侧栏插槽 sticky 固定在滚动视口顶部;裁剪展开过渡期间的内容,层级高于画布。 */
|
||||
.sidebarSlot {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -14,6 +17,7 @@
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
}
|
||||
/* 侧栏采用纵向 flex,导航区可滚动、账户区留在底部;最大宽度给主区域保留至少 40px。 */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -29,30 +33,38 @@
|
||||
letter-spacing: -0.15px;
|
||||
color: #37352f;
|
||||
}
|
||||
/* 顶行按钮分居两端;搜索框 focus-within 让输入聚焦时整个框有反馈。 */
|
||||
.topRow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; color: #85817b; }
|
||||
.search { display: flex; align-items: center; gap: 7px; min-height: 32px; border: 1px solid #e6e3df; border-radius: 7px; padding: 4px 8px; color: #97938d; }
|
||||
.search:focus-within { border-color: #b2a292; box-shadow: 0 0 0 2px #b2a29218; }
|
||||
.search input { min-width: 0; width: 100%; padding: 0; background: transparent; border: 0; outline: none; font: inherit; color: #37352f; }
|
||||
.search input::placeholder { color: #9a9690; }
|
||||
/* flex:1 配合 min-height:0 才允许长目录在侧栏内部滚动。 */
|
||||
.navigation { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
|
||||
/* 首页与目录链接共用行高/图标间距,长标题省略;aria-current 对当前页提供选中背景。 */
|
||||
.home, .pageLink { display: flex; align-items: center; gap: 8px; padding: 4px 8px; min-height: 30px; border-radius: 6px; font-weight: 450; }
|
||||
.home { width: fit-content; font-weight: 600; background: #ecebe8; }
|
||||
.pageLink { margin: 1px 0; }
|
||||
.pageLink svg { flex-shrink: 0; color: #87837c; }
|
||||
.pageLink span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pageLink:hover, .pageLink[aria-current="page"] { background: #eae7e2; }
|
||||
/* 分组名称、空态提示和错误分别排版;错误允许在任意位置折行。 */
|
||||
.sectionLabel { display: flex; justify-content: space-between; padding: 18px 7px 6px; color: #9b9690; font-size: 11px; font-weight: 400; letter-spacing: 0; }
|
||||
.hint { padding: 4px 8px; color: #9a948c; line-height: 1.6; font-size: 12px; }
|
||||
.error { margin: 6px 4px; font-size: 12px; color: #b14b3c; overflow-wrap: anywhere; }
|
||||
/* 账户区域向侧栏底部靠齐,按钮占满宽度以便整行点击。 */
|
||||
.accountArea { margin-top: auto; padding-top: 6px; }
|
||||
.accountButton { display: flex; align-items: center; gap: 8px; padding: 6px 7px; min-height: 36px; width: 100%; border-radius: 6px; text-align: left; color: #37352f; }
|
||||
.accountButton:hover { background: #edeae5; }
|
||||
/* 头像固定尺寸并裁成圆形,图片 object-fit:cover;缺图时容纳首字占位。 */
|
||||
.avatar { display: grid; place-items: center; flex: 0 0 24px; width: 24px; height: 24px; overflow: hidden; border-radius: 50%; background: #e8e0d6; color: #705844; font-size: 12px; font-weight: 600; }
|
||||
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatarLarge { flex-basis: 36px; width: 36px; height: 36px; font-size: 16px; }
|
||||
/* 账户文字允许收缩和省略,副文字在此布局隐藏。 */
|
||||
.accountText { display: flex; flex: 1; flex-direction: column; min-width: 0; }
|
||||
.accountText strong { font-size: 13px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.accountText small { display: none; }
|
||||
/* 原生 popover 的面板位置/宽度约束;自身可滚动且从左下角进入,不用 CSS 切换 open 状态。 */
|
||||
.accountPopup {
|
||||
position: fixed;
|
||||
inset: auto auto 50px 8px;
|
||||
@@ -73,31 +85,40 @@
|
||||
letter-spacing: -0.15px;
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
/* 仅 popover 打开时播放进入动画;backdrop 保持透明,但是否可交互仍由 popover 行为决定。 */
|
||||
.accountPopup:popover-open { animation: account-enter 130ms ease-out; }
|
||||
.accountPopup::backdrop { background: transparent; }
|
||||
/* 菜单头、工作区字母头像及身份行共用纵向文字布局,长名称省略。 */
|
||||
.accountHeading { display: flex; align-items: center; gap: 10px; padding: 8px 7px 10px; }
|
||||
.workspaceAvatar { display: grid; place-items: center; flex: 0 0 36px; height: 36px; border-radius: 7px; color: #72533e; background: #eee8df; font-size: 19px; font-weight: 600; }
|
||||
.accountHeading > div, .popupIdentity > div { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
|
||||
.accountHeading strong { font-size: 14px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.accountHeading small { color: #8b8780; font-size: 12px; }
|
||||
/* 菜单分组和身份区域提供内间距,邮箱允许断行;工作区列表限制高度后独立滚动。 */
|
||||
.menuGroup { padding: 1px 0; }
|
||||
.popupIdentity { display: flex; align-items: center; gap: 8px; padding: 8px 7px 7px; }
|
||||
.popupIdentity strong { font-size: 12px; font-weight: 500; }
|
||||
.popupIdentity small { color: #8b8780; font-size: 11px; overflow-wrap: anywhere; letter-spacing: 0; }
|
||||
.workspaceList { max-height: min(220px, 30dvh); overflow-y: auto; }
|
||||
/* 菜单项整行可点,文字可收缩,图标保持宽度;disabled 态显式降低透明度。 */
|
||||
.menuItem { display: flex; align-items: center; gap: 9px; width: 100%; min-height: 31px; padding: 5px 8px; border-radius: 5px; text-align: left; font-size: 13px; line-height: 20px; font-weight: 450; }
|
||||
.menuItem > span:not(.workspaceMini) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.menuItem > svg { flex-shrink: 0; }
|
||||
.menuItem:hover { background: #f1f0ed; }
|
||||
.menuItem:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
/* 工作区小头像与新建/登出颜色区分,separator 分隔组而不引入额外布局容器。 */
|
||||
.workspaceMini { display: grid; place-items: center; flex: 0 0 20px; height: 20px; border-radius: 4px; color: #82634c; background: #f1ebe3; font-size: 11px; font-weight: 600; }
|
||||
.newWorkspace { color: #2383e2; }
|
||||
.logout { color: #78746d; font-size: 12px; }
|
||||
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
|
||||
/* 左上浮动条以 --floating-left 定位;侧栏展开时偏移到侧栏右侧,位置和宽度作 240ms 过渡。 */
|
||||
.floating { --floating-left: 12px; position: fixed; left: var(--floating-left); top: 12px; max-width: calc(100vw - var(--floating-left) - 12px); z-index: 40; transition: left 240ms cubic-bezier(0.22, 1, 0.36, 1), max-width 240ms cubic-bezier(0.22, 1, 0.36, 1); }
|
||||
/* 展开侧栏时使用实际受视口约束的侧栏宽度计算偏移;账户 popover 也继承同一偏移变量。 */
|
||||
.floating[data-sidebar-collapsed="false"], .accountPopup[data-sidebar-collapsed="false"] { --floating-left: calc(min(var(--sidebar-width), calc(100vw - 40px)) + 12px); }
|
||||
/* 浮动条错误绝对定位于条下方,账户设置面板则沿用对话框容器。 */
|
||||
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
|
||||
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
|
||||
/* 设置表单纵向排布身份、字段及操作;提交按钮区分主动作,禁用时保留位置。 */
|
||||
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
|
||||
.settingsIdentity { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #89837b; overflow-wrap: anywhere; }
|
||||
.settingsForm label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 500; }
|
||||
@@ -106,35 +127,48 @@
|
||||
.settingsActions button { padding: 6px 12px; border: 1px solid #e2ddd6; border-radius: 6px; font-size: 13px; }
|
||||
.settingsActions button[type="submit"] { background: #37352f; color: white; border-color: #37352f; }
|
||||
.settingsActions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
/* 固定全视口画布置于底层;点阵背景与加载/错误状态由后续类分别承担。 */
|
||||
.graph { position: fixed; inset: 0; z-index: 0; display: flex; width: 100%; height: 100dvh; background: #fffefb; }
|
||||
.graphSurface { width: 100%; height: 100%; background-image: radial-gradient(#b8aea245 1px, transparent 1px); background-size: 22px 22px; }
|
||||
.graphStatus { position: absolute; left: 50%; top: 72px; transform: translateX(-50%); max-width: calc(100vw - 48px); padding: 8px 12px; border-radius: 6px; background: #fffefbe6; color: #9a948c; text-align: center; font-size: 13px; }
|
||||
/* 账户菜单从轻微下移/缩小过渡到正常位置与不透明状态。 */
|
||||
@keyframes account-enter { from { opacity: 0; transform: translateY(4px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
/* 较窄视口将侧栏目标宽度收为 240px;仍受上方 max-width 约束。 */
|
||||
@media (max-width: 600px) { .shell { --sidebar-width: 240px; } }
|
||||
/* 减少动态效果偏好下关闭菜单进入动画;浮动条过渡在文件后部单独关闭。 */
|
||||
@media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } }
|
||||
|
||||
/* 浮动条 logo 链接沿用 Toolbar 控件尺寸与键盘焦点样式,保证和旁边按钮对齐。 */
|
||||
.logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); padding: 4px 6px; border-radius: 5px; }
|
||||
.logoLink img { display: block; width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); }
|
||||
.logoLink:hover { background: #e9e7e3; }
|
||||
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||
|
||||
/* 浮动头像按钮同样使用共享尺寸;从浮动条打开的账户面板改为顶端定位。 */
|
||||
.floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 32px); height: var(--toolbar-control-height, 32px); border-radius: 5px; }
|
||||
.floatingAvatarButton:hover { background: #e9e7e3; }
|
||||
/* clamp 把账户面板左边界限制在视口内;transform-origin 随顶部锚点改为左上。 */
|
||||
.accountPopup[data-floating="true"] { inset: 62px auto auto clamp(8px, var(--floating-left, 12px), max(8px, calc(100vw - 308px))); max-height: calc(100dvh - 76px); transform-origin: top left; }
|
||||
|
||||
/* 浮动头像采用图标尺寸,标题允许 flex 收缩但保留溢出区域给编辑错误提示。 */
|
||||
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
|
||||
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
|
||||
/* 中等窄屏降低标题最大宽度,不改变标题内容或编辑状态。 */
|
||||
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
|
||||
/* 极窄且侧栏仍展开时隐藏 logo/标题/分隔,保留浮动条其余控件。 */
|
||||
@media (max-width: 420px) {
|
||||
.floating[data-sidebar-collapsed="false"] .logoLink,
|
||||
.floating[data-sidebar-collapsed="false"] .floatingTitle,
|
||||
.floating[data-sidebar-collapsed="false"] > span[aria-hidden="true"] { display: none; }
|
||||
}
|
||||
/* 按用户偏好关闭浮动条位移/宽度过渡。 */
|
||||
@media (prefers-reduced-motion: reduce) { .floating { transition: none; } }
|
||||
|
||||
/* 递归目录清除列表默认缩进,层级缩进由组件布局提供;行本身允许标题收缩。 */
|
||||
.directoryList { list-style: none; padding: 0; margin: 0; }
|
||||
.directoryRow { display: flex; align-items: center; min-width: 0; }
|
||||
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
|
||||
/* 可折叠项按钮与叶项占位共享 20px 列宽,保持各行标题对齐;焦点内描边不扩尺寸。 */
|
||||
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; }
|
||||
.directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
|
||||
.directoryToggle:hover { background: #eae7e2; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"; },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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=[];
|
||||
|
||||
@@ -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('<!doctype html><html><body><div id="app"></div></body></html>', { 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(); }
|
||||
|
||||
@@ -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();});}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,30 +1,54 @@
|
||||
/**
|
||||
* 真实浏览器存储的 Blob checkpoint 场景,由 browser-storage-cases 调用并注入断言函数。
|
||||
* 分别处理 DB schema 4→5、checkpoint 编码版本与恢复镜像格式版本,三个版本号不是同一时钟。
|
||||
* 覆盖旧行升级、精确字节校验、未来格式、事务回滚、owner 失效与被旧连接阻挡的升级。
|
||||
*/
|
||||
import {createBrowserStorage} from '../../src/lib/canvas-runtime/storage.ts';
|
||||
import {sealRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
|
||||
// 手工建立旧数据库后再通过生产 storage 升级,直接观察持久化行及重新打开后的恢复结果。
|
||||
export async function blobCheckpointCase(assert){
|
||||
// 随机数据库名隔离场景;镜像含 Unicode、大量文本和固定请求字节,用来检查序列化保持精确内容。
|
||||
const name=`blob-checkpoint-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},key=JSON.stringify(['a','w','p']),image=revision=>({formatVersion:1,rootRevision:revision,localRevision:revision,payload:{rows:Array.from({length:250},(_,i)=>({id:i,text:`版本 ${revision} 👀 ${'x'.repeat(100)}`})),request:{id:'frozen',body:'{"exact":"original bytes"}'}}});
|
||||
// 三个 Promise 工具分别等待 IDB 请求、事务完成/abort 和原始 Blob SHA-256;事务成功不能只看单个 put 成功。
|
||||
const read=req=>new Promise((resolve,reject)=>{req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);}),done=tx=>new Promise((resolve,reject)=>{tx.oncomplete=resolve;tx.onabort=()=>reject(tx.error);}),hash=async blob=>[...new Uint8Array(await crypto.subtle.digest('SHA-256',await blob.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join('');
|
||||
// 手工打开 DB v4 并创建旧表,绕开新适配器自动建库,保证后面确实经历旧 checkpoint 升级。
|
||||
let raw=await new Promise((resolve,reject)=>{const request=indexedDB.open(name,4);request.onupgradeneeded=()=>{for(const store of ['metadata','checkpoints','journal','files'])request.result.createObjectStore(store);};request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||
// 把旧对象镜像与其 checksum 一起写入 revision 0,并关闭旧连接后再启动新适配器。
|
||||
const initial=image(0),checksum=await hash(new Blob([JSON.stringify(initial)]));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,checkpointEvery:1}),leader;
|
||||
try{
|
||||
// 先核对旧对象恢复不变,再提交带 sealed 镜像与文件的首个新切点。
|
||||
leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(initial),'DB v4 legacy checkpoint changed during upgrade');const first=image(1);await storage.commit(leader,{image:sealRecoveryImage(1,1,1,first.payload),files:[{id:'kept',bytes:new Blob(['kept bytes'])}]});
|
||||
// DB 应升级为 5;新 checkpoint 存 Blob 而非克隆文档对象,文本和 checksum 覆盖精确序列化字节。
|
||||
raw=await read(indexedDB.open(name));assert(raw.version===5,'Blob cuts did not fence old DB clients');const saved=await read(raw.transaction('checkpoints').objectStore('checkpoints').get([key,1]));assert(saved.checkpointVersion===1&&saved.bytes instanceof Blob&&!saved.image,'full cut is still a cloned document object');assert(await saved.bytes.text()===JSON.stringify(first),'Blob checkpoint changed serialized bytes');assert(saved.checksum===await hash(saved.bytes),'Blob checksum did not cover exact source bytes');
|
||||
// 旧 DB v4 客户端再打开必须得到 VersionError,防止老解释器接受新 Blob 行。
|
||||
const old=await new Promise(resolve=>{const request=indexedDB.open(name,4);request.onerror=event=>{event.preventDefault();resolve(request.error.name);};request.onsuccess=()=>{request.result.close();resolve('accepted');};});assert(old==='VersionError','DB v4 reader accepted Blob checkpoint rows');
|
||||
// 用原始 IDB 连接替换指定 checkpoint 行,构造损坏输入后仍等待事务真正完成。
|
||||
const write=async value=>{const tx=raw.transaction('checkpoints','readwrite'),terminal=done(tx);tx.objectStore('checkpoints').put(value,[key,1]);await terminal;};
|
||||
// 分别破坏字节、header、编码版本和 UTF-8;每次恢复检查对应错误,并还原原行供下一场景继续。
|
||||
for(const [kind,expected]of [['bytes','recovery_checksum_mismatch'],['header','recovery_checksum_mismatch'],['future','recovery_checkpoint_interpreter_unavailable'],['utf8','corrupt_recovery_image']]){
|
||||
const damaged=structuredClone(saved);if(kind==='bytes')damaged.bytes=new Blob([JSON.stringify(image(9))]);else if(kind==='header')damaged.header.formatVersion=2;else if(kind==='future')damaged.checkpointVersion=2;else{damaged.bytes=new Blob([new Uint8Array([123,34,120,34,58,34,255,34,125])]);damaged.checksum=await hash(damaged.bytes);}
|
||||
await write(damaged);let code;try{await storage.recover(leader);}catch(error){code=error.code;}assert(code===expected,`${kind} cut was accepted: ${code}`);if(kind==='future'){code=undefined;try{await storage.commit(leader,{image:image(2)});}catch(error){code=error.code;}assert(code===expected,'writer replaced a future checkpoint');}await write(saved);
|
||||
}
|
||||
// 暂时包装原生 put:先排入写请求再抛错,把故障放在事务已安排部分写入的窗口。
|
||||
const nativePut=IDBObjectStore.prototype.put;let injected=false,error;
|
||||
// 只在 revision 2 的新 Blob checkpoint 写入点注入异常;其他表/版本继续调用真实 IndexedDB。
|
||||
IDBObjectStore.prototype.put=function(value,k){const request=nativePut.call(this,value,k);if(this.name==='checkpoints'&&value?.checkpointVersion===1&&value.header.rootRevision===2){injected=true;throw Error('abort_blob_checkpoint');}return request;};
|
||||
// 带新文件的提交失败后恢复原 put,再验证旧 Root 仍可读且新文件字节没有泄漏。
|
||||
try{await storage.commit(leader,{image:image(2),files:[{id:'orphan',bytes:new Blob(['must rollback'])}]});}catch(cause){error=cause.message;}finally{IDBObjectStore.prototype.put=nativePut;}
|
||||
assert(injected&&error==='abort_blob_checkpoint','checkpoint fault was not injected after put');assert((await storage.recover(leader)).rootRevision===1&&await storage.readFile(leader,'orphan')===undefined,'aborted Blob cut leaked head or file bytes');
|
||||
// 释放并重建适配器,检查 Blob 与同批文件在新 owner 下仍能恢复。
|
||||
await leader.release();storage.close();storage=createBrowserStorage({name,checkpointEvery:1});leader=await storage.claim(scope);assert(JSON.stringify(await storage.recover(leader))===JSON.stringify(first),'fresh adapter lost Blob content');assert(await(await storage.readFile(leader,'kept')).text()==='kept bytes','Blob reopening lost atomic file');
|
||||
// 大切点准备期间释放 owner,完成结果必须是 storage_leader_lost;重新 claim 后不得看到新切点或晚到文件。
|
||||
const large=sealRecoveryImage(1,2,2,{rows:Array.from({length:16000},(_,i)=>({id:i,text:'x'.repeat(100)}))});let failure;const pending=storage.commit(leader,{image:large,files:[{id:'late',bytes:new Blob(['late'])}]}).catch(error=>{failure=error.code;});await Promise.resolve();await leader.release();await pending;assert(failure==='storage_leader_lost','released owner published or changed its cancellation error');leader=await storage.claim(scope);assert((await storage.recover(leader)).rootRevision===1&&await storage.readFile(leader,'late')===undefined,'released owner advanced Blob checkpoint');
|
||||
// 关闭原始连接、释放 owner 和 adapter,再发起删除数据库;这里不等待删除完成事件。
|
||||
}finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
||||
}
|
||||
|
||||
// 保持旧 DB 连接打开以阻挡升级;同一失败 adapter 保留原错误,旧连接退场后需新 adapter 才能成功。
|
||||
export async function blockedUpgradeCase(assert){
|
||||
// 本场景只需一个仍打开的 v4 数据库,不依赖上一个升级场景留下的数据。
|
||||
const name=`blocked-upgrade-${crypto.randomUUID()}`,scope={accountId:'a',workspaceId:'w',projectId:'p'},read=req=>new Promise((resolve,reject)=>{req.onsuccess=()=>resolve(req.result);req.onerror=()=>reject(req.error);});let raw=await read(indexedDB.open(name,4)),storage=createBrowserStorage({name}),leader;
|
||||
// 重复 claim 都应报告 storage_upgrade_blocked;丢弃的升级请求不能在稍后悄悄升级,关旧连接并新建 adapter 后才升到 5。
|
||||
try{let code;try{await storage.claim(scope);}catch(error){code=error.code;}assert(code==='storage_upgrade_blocked','active old connection did not reject the upgrade');code=undefined;try{await storage.claim(scope);}catch(error){code=error.code;}assert(code==='storage_upgrade_blocked','blocked adapter lost the original failure reason');raw.close();raw=await read(indexedDB.open(name));assert(raw.version===4,'abandoned upgrade changed database version after rejection');raw.close();storage.close();storage=createBrowserStorage({name});leader=await storage.claim(scope);assert(await storage.recover(leader)===null,'fresh adapter could not open after blocked request was retired');raw=await read(indexedDB.open(name));assert(raw.version===5,'fresh upgrade failed after abandoned open');}
|
||||
finally{raw?.close();await leader?.release();storage.close();indexedDB.deleteDatabase(name);}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* 浏览器摘要适配器的协议故障测试:使用可控 Worker 替身检查请求/响应身份,以及启动和运行失败的错误区分。
|
||||
* 不启动真实线程,也不验证浏览器打包;每个用例恢复 globalThis.Worker,避免污染其他测试。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createBrowserDigests} from '../../src/lib/canvas-runtime/browser-digests.ts';
|
||||
|
||||
// Worker 回传错误 id 即使摘要格式正确也必须拒绝;故障 worker 需 terminate,不能把别的任务结果交给当前调用者。
|
||||
test('browser digest adapter rejects mismatched worker identity and terminates the worker',async()=>{
|
||||
const previous=globalThis.Worker;let terminated=0;
|
||||
globalThis.Worker=class{postMessage(value){queueMicrotask(()=>this.onmessage({data:{version:1,id:value.id+1,byteLength:value.bytes.size,sha256:'a'.repeat(64)}}));}terminate(){terminated++;}};
|
||||
globalThis.Worker=class{/* 模拟线程稍后回信,故意把 id 加一;其余字段合法以隔离身份校验。 */ postMessage(value){queueMicrotask(()=>this.onmessage({data:{version:1,id:value.id+1,byteLength:value.bytes.size,sha256:'a'.repeat(64)}}));}/* 计数故障后的终止动作。 */ terminate(){terminated++;}};
|
||||
const pool=createBrowserDigests();try{await assert.rejects(pool.digest(new Blob(['bytes'])),/invalid_digest_response/);assert.equal(terminated,1);}finally{pool.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
|
||||
});
|
||||
// 构造器抛错映射为 worker_unavailable,onerror 映射为 worker_failed;两个路径都不能给调用者返回伪造摘要。
|
||||
test('browser digest adapter exposes worker startup and runtime failures without returning a false hash',async()=>{
|
||||
const previous=globalThis.Worker;let pool;
|
||||
try{globalThis.Worker=class{constructor(){throw Error('denied');}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_unavailable/);pool.close();let terminated=0;globalThis.Worker=class{postMessage(){queueMicrotask(()=>this.onerror({preventDefault(){}}));}terminate(){terminated++;}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_failed/);assert.equal(terminated,1);}finally{pool?.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
|
||||
try{globalThis.Worker=class{/* 在线程创建阶段抛错,尚未进入 postMessage。 */ constructor(){throw Error('denied');}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_unavailable/);pool.close();let terminated=0;globalThis.Worker=class{/* 在线程运行阶段触发 onerror,区别于构造失败。 */ postMessage(){queueMicrotask(()=>this.onerror({preventDefault(){}}));}/* 计数故障后的终止动作。 */ terminate(){terminated++;}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_failed/);assert.equal(terminated,1);}finally{pool?.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
|
||||
});
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
/**
|
||||
* 浏览器迁移控制器与旧画布编辑租约测试。旧编辑视图持 shared lease,迁移要求独占且不等待的 lease,避免新旧写入并行。
|
||||
* 使用内存 localStorage/Web Locks 替身验证协调逻辑,不代表真实多标签 Web Locks 已在本文件执行。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {claimLegacyCanvasEditing,createBrowserCanvasMigration} from '../../src/lib/browser-canvas-migration.ts';
|
||||
import {canvasMigrationStorageKey} from '../../src/lib/canvas-migration.ts';
|
||||
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||
// 确定性 UUID 及账户/workspace 作用域;editing 锁名与迁移持久化键都从此作用域派生。
|
||||
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,scope={accountId:id(1),workspaceId:id(2)},root=id(3),registry=createRegistry(builtinComponents());
|
||||
// 搭建 records、held 锁表及调用记录,controller 控制当前视图生命周期。
|
||||
function fixture(){
|
||||
const records=new Map(),held=new Map(),calls=[];
|
||||
// 最小 localStorage 接口,保存原字符串以检查未来/损坏材料不会被覆盖。
|
||||
const storage={getItem:key=>records.get(key)??null,setItem:(key,value)=>records.set(key,value),removeItem:key=>records.delete(key)};
|
||||
// 异步模拟共享/独占互斥:不可用时调用 work(null),成功期间增加持有计数,finally 总是归还锁。
|
||||
const locks={request(name,options,work){calls.push({name,options});return Promise.resolve().then(async()=>{
|
||||
// shared 只排斥 exclusive,exclusive 排斥任意持有者;这是 fixture 的即时 ifAvailable 模拟,不实现公平等待队列。
|
||||
const state=held.get(name)??{shared:0,exclusive:false},granted=options.mode==='shared'?!state.exclusive:!state.exclusive&&!state.shared;
|
||||
if(!granted)return work(null);
|
||||
if(options.mode==='shared')state.shared++;else state.exclusive=true;held.set(name,state);
|
||||
try{return await work({name,mode:options.mode});}finally{if(options.mode==='shared')state.shared--;else state.exclusive=false;if(!state.shared&&!state.exclusive)held.delete(name);}
|
||||
});}};
|
||||
// 所有租约和迁移实例共享当前 AbortSignal;isCurrentIdentity 可在用例中覆写为过期身份。
|
||||
const controller=new AbortController(),options={scope,registry,locks,storage,signal:controller.signal,isCurrentIdentity:()=>true};
|
||||
return{records,held,calls,controller,options};
|
||||
}
|
||||
// 两个旧编辑器可共享锁,释放一个仍不能迁移;全部释放后 exclusive 迁移检查可进入 idle,所有请求均 ifAvailable。
|
||||
test('editable legacy views share a lease and exclude migration until every editor releases',async()=>{
|
||||
const f=fixture(),one=await claimLegacyCanvasEditing({...f.options,canvasId:root}),two=await claimLegacyCanvasEditing({...f.options,canvasId:root});
|
||||
const migration=createBrowserCanvasMigration(f.options);
|
||||
@@ -24,6 +35,7 @@ test('editable legacy views share a lease and exclude migration until every edit
|
||||
assert.ok(f.calls.every(call=>call.name===canvasMigrationStorageKey(scope)+':editing'&&call.options.ifAvailable===true));
|
||||
assert.deepEqual(f.calls.slice(0,2).map(call=>call.options.mode),['shared','shared']);assert.ok(f.calls.slice(2).every(call=>call.options.mode==='exclusive'));
|
||||
});
|
||||
// pending 迁移只阻止受影响旧 canvas,其他 canvas 可编辑;未来记录版本无法解释时拒绝但原字符串保持。
|
||||
test('pending migration blocks affected old views and corrupt records cannot enable editing',async()=>{
|
||||
const f=fixture(),key=canvasMigrationStorageKey(scope),job={version:1,...scope,rootId:root,id:id(4),phase:'pending',body:JSON.stringify({protocol_version:2,request_id:id(4),expected_versions:{[root]:1}})};
|
||||
f.storage=f.options.storage;f.storage.setItem(key,JSON.stringify(job));
|
||||
@@ -32,6 +44,7 @@ test('pending migration blocks affected old views and corrupt records cannot ena
|
||||
const saved=JSON.stringify({version:2,data:'retain'});f.storage.setItem(key,saved);
|
||||
await assert.rejects(()=>claimLegacyCanvasEditing({...f.options,canvasId:root}),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.held.size,0);
|
||||
});
|
||||
// 视图 abort 或身份失效不能取得迟到编辑授权;已领取和未领取路径都归还锁,重复 release 安全。
|
||||
test('aborted or stale view lifetimes release leases and cannot acquire a late editing grant',async()=>{
|
||||
const f=fixture(),lease=await claimLegacyCanvasEditing({...f.options,canvasId:root});f.controller.abort();await lease.release();assert.equal(f.held.size,0);await lease.release();
|
||||
const g=fixture(),waiting=claimLegacyCanvasEditing({...g.options,canvasId:root});g.controller.abort();await assert.rejects(waiting,{name:'AbortError'});assert.equal(g.held.size,0);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
||||
/** Real Chromium IndexedDB/Web Locks checks, in an isolated temporary browser profile. */
|
||||
/**
|
||||
* 使用隔离 Chrome profile 运行真实 IndexedDB/Web Locks 场景,或以 --package 消费构建后的浏览器 ESM。
|
||||
* 本地 HTTP server 只提供测试页面、源码/发布文件和可控 NDJSON 流;普通模式通过 Node 去除 .ts 类型,不运行应用 bundler。
|
||||
* 此入口内联了一套简化 CDP 请求表,未使用 cdp-client 的连接级超时/故障保护,不能把 Runtime.evaluate 的 timeout 当作所有命令的截止时间。
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -7,18 +11,24 @@ import { spawn } from 'node:child_process';
|
||||
import { stripTypeScriptTypes } from 'node:module';
|
||||
import { setTimeout as pause } from 'node:timers/promises';
|
||||
|
||||
// 从脚本位置定位仓库,随机临时 profile 保证测试不读写日常 Chrome 会话。
|
||||
const root = resolve(import.meta.dirname, '../..'), profile = await mkdtemp(join(tmpdir(), 'endoleap-storage-browser-'));
|
||||
// --package 只影响发布文件服务和页面导入入口;它不会在这里自动构建 .headless-dist。
|
||||
const packageMode=process.argv.includes('--package');
|
||||
// 按 key 保留挂起的响应与读取次数,供页面测试控制共享读取的首块、尾块和失败尾部。
|
||||
const readStreams=new Map();
|
||||
// 只在回环地址提供 fixture;源码读取或类型去除失败时返回 500 供页面检查报告。
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const path = new URL(request.url, 'http://localhost').pathname;
|
||||
// 同一 key 每次读取计数增加,立即发送第一行并保持连接;由独立 release 请求决定何时结束。
|
||||
if(path==='/loader-stream'){
|
||||
const url=new URL(request.url,'http://localhost'),key=url.searchParams.get('key');
|
||||
if(!key){response.writeHead(400).end();return;}
|
||||
const state=readStreams.get(key)??{reads:0};state.reads++;state.response=response;readStreams.set(key,state);
|
||||
response.writeHead(200,{'Content-Type':'application/x-ndjson'});response.write(JSON.stringify({part:0,complete:false})+'\n');return;
|
||||
}
|
||||
// 查找挂起流,可发送合法完成行或非法 JSON 尾部;返回读次数用于核对共享加载没有重复 fetch。
|
||||
if(path==='/loader-release'){
|
||||
const url=new URL(request.url,'http://localhost'),state=readStreams.get(url.searchParams.get('key'));
|
||||
if(!state){response.writeHead(404).end();return;}
|
||||
@@ -26,30 +36,39 @@ const server = createServer(async (request, response) => {
|
||||
response.setHeader('Content-Type','application/json');response.end(JSON.stringify({reads:state.reads}));return;
|
||||
}
|
||||
if (path === '/') { response.setHeader('Content-Type', 'text/html'); response.end('<title>Headless storage checks</title>'); return; }
|
||||
// 包模式从 .headless-dist 读取,其余只允许 src/lib 和 tests/headless;resolve 后再检查目录前缀。
|
||||
const isPackage=packageMode&&path.startsWith('/package/');
|
||||
const filename = isPackage?resolve(root,'.headless-dist',path.slice('/package/'.length)):resolve(root, `.${path}`);
|
||||
if (!(isPackage&&filename.startsWith(`${root}/.headless-dist/`)) && !filename.startsWith(`${root}/src/lib/`) && !filename.startsWith(`${root}/tests/headless/`)) { response.writeHead(404).end(); return; }
|
||||
// 仅对 .ts 去除类型后提供 JavaScript;.mjs 和编译包文件保持读取内容,不进行应用级转换。
|
||||
let source = await readFile(filename, 'utf8');
|
||||
if (filename.endsWith('.ts')) source = stripTypeScriptTypes(source);
|
||||
response.setHeader('Content-Type', 'text/javascript'); response.end(source);
|
||||
} catch (error) { response.writeHead(500).end(String(error)); }
|
||||
});
|
||||
// 先绑定随机本地端口,再用独立 profile 启动 headless Chrome;CHROME_BIN 可覆盖默认安装路径。
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const 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}`, `http://127.0.0.1:${server.address().port}/`], { stdio: 'ignore' });
|
||||
let socket;
|
||||
try {
|
||||
// 最多轮询 100 次 DevToolsActivePort,每次间隔 100 毫秒;未找到端口即失败,不连接用户现有浏览器。
|
||||
let port;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try { port = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); }
|
||||
}
|
||||
if (!port) throw new Error('isolated Chromium did not start');
|
||||
// 从本次 Chrome 的调试服务查找 page,建立 WebSocket 后才发送 CDP 命令。
|
||||
const tabs = await fetch(`http://127.0.0.1:${port}/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; });
|
||||
// pending 仅按 ID 路由 Promise;这里没有每条命令的本地截止定时器。
|
||||
let next = 0; const pending = new Map();
|
||||
// 只处理带 ID 的响应,协议错误拒绝相应 Promise;没有转发无 ID 事件或整连接失败处理。
|
||||
socket.onmessage = event => { const message = JSON.parse(event.data); if (message.id) { const callbacks = pending.get(message.id); pending.delete(message.id); if (message.error) callbacks.reject(new Error(JSON.stringify(message.error))); else callbacks.resolve(message.result); } };
|
||||
// 登记递增 ID 后发送命令,等待对应响应;其行为不同于独立 createCdpCalls 的有界调用器。
|
||||
const call = (method, params) => new Promise((resolve, reject) => { const id = ++next; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); });
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
// 导航到本次 fixture origin,随后最多 100 次检查页面 origin 与 readyState,避免误在空白页执行。
|
||||
await call('Page.navigate', { url: `${origin}/` });
|
||||
let loaded = false;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
@@ -58,10 +77,13 @@ try {
|
||||
await pause(50);
|
||||
}
|
||||
if (!loaded) throw new Error('browser test origin did not finish loading');
|
||||
// 页面动态导入普通存储场景或编译包场景并等待 run;30 秒 timeout 是该 evaluate 的协议参数。
|
||||
const result = await call('Runtime.evaluate', { expression: `import('/tests/headless/${packageMode?'package-browser-cases':'browser-storage-cases'}.mjs').then(module => module.run())`, awaitPromise: true, returnByValue: true, timeout: 30000 });
|
||||
// 页面异常直接使 runner 失败,只有成功返回的场景消息才逐条打印 PASS。
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text);
|
||||
for (const message of result.result.value) console.log(`PASS ${message}`);
|
||||
} finally {
|
||||
// 清理连接并请求 Chrome 退出,等待进程 exit 后再关闭 HTTP server 和删除 profile;此等待没有额外强杀截止时间。
|
||||
socket?.close();
|
||||
const exited = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); });
|
||||
chrome.kill(); await exited;
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
/** Bounded protocol calls for isolated browser fixtures, never product code. */
|
||||
/**
|
||||
* 隔离浏览器 fixture 专用的有界 CDP 请求路由器,不作为产品运行时代码。
|
||||
* 每个正整数 ID 对应一个 Promise 和截止定时器;连接/目标级故障是整个实例的终态。
|
||||
* 本函数不负责建立或关闭 socket,也不提供独立 dispose;外层 runner 持有连接。
|
||||
*/
|
||||
// 接收已连接 socket 和可替换定时器;默认给每个调用设置 30 秒截止定时器,事件由 onEvent 转发。
|
||||
export function createCdpCalls(socket,{timeoutMs=30000,onEvent=()=>{},setTimer=setTimeout,clearTimer=clearTimeout}={}){
|
||||
// pending 是未回应调用的唯一查找表;failure 保留首个致命错误,使未来调用立刻拒绝。
|
||||
let serial=0,failure;const pending=new Map();
|
||||
// 全局失败清除每个截止定时器并拒绝所有 Promise;重复故障不覆盖最初原因。
|
||||
const fail=error=>{failure??=error;for(const row of pending.values()){clearTimer(row.timer);row.reject(failure);}pending.clear();};
|
||||
// 连接关闭和传输错误都不再允许继续发命令。
|
||||
socket.addEventListener('close',()=>fail(Error('CDP connection closed')));
|
||||
socket.addEventListener('error',()=>fail(Error('CDP connection failed')));
|
||||
// 收到消息先解析 JSON;解析或事件回调异常同样会进入全局失败。
|
||||
socket.addEventListener('message',event=>{
|
||||
try{
|
||||
const message=JSON.parse(event.data);
|
||||
// 无请求 ID 的消息是事件;先通知观察者,再把崩溃、脱离或对话框视为目标不可继续使用。
|
||||
if(!message.id){onEvent(message);if(['Inspector.targetCrashed','Inspector.detached','Target.targetCrashed','Page.javascriptDialogOpening'].includes(message.method))fail(Error('CDP target unavailable: '+message.method+' '+JSON.stringify(message.params??{})));return;}
|
||||
// 已超时/完成的响应无对应 row 时忽略;普通协议 error 只拒绝该请求,不使整个连接失效。
|
||||
const row=pending.get(message.id);if(!row)return;pending.delete(message.id);clearTimer(row.timer);message.error?row.reject(Error(JSON.stringify(message.error))):row.resolve(message.result);
|
||||
}catch(error){fail(error);}
|
||||
});
|
||||
// 为新命令分配递增 ID,先登记 pending 与定时器再发送;send 同步抛错也会拒绝全部在途工作。
|
||||
return(method,params={})=>new Promise((resolve,reject)=>{
|
||||
if(failure){reject(failure);return;}
|
||||
const id=++serial,row={resolve,reject,timer:undefined};pending.set(id,row);
|
||||
// 任意一个调用超时都使整个调用器失败,避免测试随后无限挂在另一个依赖命令。
|
||||
row.timer=setTimer(()=>fail(Error(`CDP ${method} timed out after ${timeoutMs}ms`)),timeoutMs);
|
||||
try{socket.send(JSON.stringify({id,method,params}));}catch(error){fail(error);}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
/**
|
||||
* 浏览器测试专用 CDP 调用器的单元测试。用 socket/定时器替身验证请求 ID 路由、截止时间及致命故障,不启动浏览器。
|
||||
* 单个协议命令错误只拒绝对应请求;连接或目标级故障则拒绝整个调用器及未来调用。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createCdpCalls} from './cdp-client.mjs';
|
||||
// 把 socket 事件、已发送命令和定时器保存在 Map/数组,测试可直接投递乱序响应或手动触发超时。
|
||||
function fixture(){const handlers=new Map(),sent=[],timers=new Map();let id=0;const socket={addEventListener:(event,callback)=>handlers.set(event,callback),send:text=>sent.push(JSON.parse(text))},events=[],call=createCdpCalls(socket,{timeoutMs:30,onEvent:event=>events.push(event),setTimer:callback=>{timers.set(++id,callback);return id;},clearTimer:key=>timers.delete(key)});return{call,sent,timers,events,event:(name,data)=>handlers.get(name)({data:JSON.stringify(data)})};}
|
||||
// 两个响应反序返回仍按 ID 完成各自 Promise;无 ID 事件交给 onEvent,成功响应清除对应定时器。
|
||||
test('CDP responses route by request ID, forward events and release deadline timers',async()=>{const f=fixture(),one=f.call('one'),two=f.call('two');f.event('message',{method:'Runtime.event'});f.event('message',{id:2,result:{value:'two'}});f.event('message',{id:1,result:{value:'one'}});assert.deepEqual(await one,{value:'one'});assert.deepEqual(await two,{value:'two'});assert.equal(f.events.length,1);assert.equal(f.timers.size,0);});
|
||||
// 一个调用超时即终止全部 pending 和未来调用;迟到响应被忽略,不产生额外报告读取来掩盖超时。
|
||||
test('a stalled CDP call rejects all pending work and cannot hide behind another unbounded report read',async()=>{const f=fixture(),one=assert.rejects(f.call('Runtime.evaluate'),/timed out/),two=assert.rejects(f.call('Page.captureScreenshot'),/timed out/);[...f.timers.values()][0]();await Promise.all([one,two]);assert.equal(f.timers.size,0);f.event('message',{id:1,result:{late:true}});await assert.rejects(f.call('Runtime.evaluate'),/timed out/);});
|
||||
// 分别注入连接关闭、错误、目标崩溃、脱离和对话框事件;所有情况清空 pending,调用器不会自动接受对话框。
|
||||
test('connection loss and fatal target events reject pending calls without accepting dialogs',async()=>{for(const event of ['close','error','Inspector.targetCrashed','Target.targetCrashed','Inspector.detached','Page.javascriptDialogOpening']){const f=fixture(),result=assert.rejects(f.call('Runtime.evaluate'),/CDP/);event.includes('.')?f.event('message',{method:event}):f.event(event);await result;assert.equal(f.timers.size,0);assert.deepEqual(f.sent.map(row=>row.method),['Runtime.evaluate']);}});
|
||||
// 普通命令错误只拒绝它自己,另一命令仍可成功且两者都清除定时器。
|
||||
test('one protocol command error does not corrupt another request or leak its timer',async()=>{const f=fixture(),one=assert.rejects(f.call('bad'),/invalid/),two=f.call('good');f.event('message',{id:1,error:{message:'invalid'}});f.event('message',{id:2,result:{ok:true}});await one;assert.deepEqual(await two,{ok:true});assert.equal(f.timers.size,0);});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 显式冲突恢复测试:用户选择 keep-local 或 discard-local 后,基于已审阅的远程基线重建条件操作和撤销链。
|
||||
* 旧请求及拒绝证据保留在账本与恢复审计中;结果未知的操作不能靠用户选择直接退休,付费动作不能自动重启。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRegistry,builtinComponents,createDocument,applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,19 +9,56 @@ import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
||||
|
||||
// 共享类型定义和目标身份,保证冲突处理仍执行数据校验及 incarnation 校验。
|
||||
const registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1};
|
||||
// 单元素基线同时包含文字和几何,便于区分局部字段重放与整对象覆盖。
|
||||
const element={...target,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'canvas',data:{title:'a',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}};
|
||||
// 独立的初始项目文档;服务替身和新引擎都从此基线开始。
|
||||
const original=createDocument('project',[{id:'canvas',parent:null}],[element]);
|
||||
// 推进有限轮微任务,供恢复读取进入挂起点;不等待被故意阻塞的完整恢复会话。
|
||||
const tick=async()=>{for(let i=0;i<40;i++)await Promise.resolve();};
|
||||
// 默认拒绝写入并允许外部修改服务基线;存储可注入 quota,支持验证恢复计划先发布后落盘失败的重试。
|
||||
function fixture(){
|
||||
let server=original,version=1,image,leader;const calls=[];
|
||||
const f={reject:true,broken:false,calls,storage:{async claim(scope){if(leader)throw Error('occupied');return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},async recover(){return image&&structuredClone(image);},async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}},port:{compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};},async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const{intent}=JSON.parse(request.body);assert.equal(request.expectedVersions.canvas,version);server=applyChanges(server,intent.changes);version++;return{outcome:'applied',canonical:{requestId:request.id}};},async query(){throw Error('unexpected query');},canonical(){return{document:server,versions:new Map([['canvas',version]])};},async read(){return{document:server,versions:new Map([['canvas',version]])};}},remote(patch){const before=server.elements.get('a');server=applyChanges(server,Object.entries(patch).map(([key,after])=>({kind:'field',target,field:`data.${key}`,before:before.data[key]??null,beforePresent:Object.hasOwn(before.data,key),after,afterPresent:true})));version++;},remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;},setServer(document){server=document;version++;},image:()=>structuredClone(image),async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port});}};
|
||||
const f={reject:true,broken:false,calls,storage:{
|
||||
// 拒绝并行 owner,释放后下一次 open 才能重新接管;这是内存互斥替身。
|
||||
async claim(scope){if(leader)throw Error('occupied');return leader={scope,fence:1,active:true,async release(){leader=undefined;}};},
|
||||
// 返回最近已提交镜像的副本,重开复用相同存储但不共享可变镜像对象。
|
||||
async recover(){return image&&structuredClone(image);},
|
||||
// 故障发生时不推进镜像;成功时校验存储 owner 并返回实际保存修订。
|
||||
async commit(owner,batch){if(f.broken)throw Error('quota');assert.equal(owner,leader);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},
|
||||
// 本组不读取本地文件;空方法仅补齐 StoragePort。
|
||||
async readFile(){},close(){}},port:{
|
||||
// 根据当前 ACK 版本编译条件请求,让恢复后的替代意图采用新基线。
|
||||
compile(intent,requestId,snapshot){return{canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)])),payload:{intent,requestId}};},
|
||||
// 默认给出明确 not-applied;放行后先检查版本再改服务状态。此替身不实现未知结果或请求去重。
|
||||
async submit(request){calls.push(request);if(f.reject)return{outcome:'not-applied',error:'version_conflict'};const{intent}=JSON.parse(request.body);assert.equal(request.expectedVersions.canvas,version);server=applyChanges(server,intent.changes);version++;return{outcome:'applied',canonical:{requestId:request.id}};},
|
||||
// 这里不测试未知结果查询;若恢复误走该路径,立即失败。
|
||||
async query(){throw Error('unexpected query');},
|
||||
// 为已知成功请求提供当前服务模型和版本。
|
||||
canonical(){return{document:server,versions:new Map([['canvas',version]])};},
|
||||
// 恢复会话读取最新完整服务基线,不读取客户端 working。
|
||||
async read(){return{document:server,versions:new Map([['canvas',version]])};}},
|
||||
// 外部写入逐字段保留存在性,模拟其他客户端推进远程版本。
|
||||
remote(patch){const before=server.elements.get('a');server=applyChanges(server,Object.entries(patch).map(([key,after])=>({kind:'field',target,field:`data.${key}`,before:before.data[key]??null,beforePresent:Object.hasOwn(before.data,key),after,afterPresent:true})));version++;},
|
||||
// 外部几何只改变指定轴,恢复时应保留本地未触碰的那些轴。
|
||||
remoteGeometry(patch){const before=server.elements.get('a').geometry;server=applyChanges(server,[{kind:'field',target,field:'geometry',before,after:{...before,...patch}}]);version++;},
|
||||
// 直接替换服务模型并推进版本,用来模拟目标删除等生命周期变化。
|
||||
setServer(document){server=document;version++;},
|
||||
// 提供恢复镜像副本,供审计篡改测试使用。
|
||||
image:()=>structuredClone(image),
|
||||
// 重新装配引擎但保留 fixture 的服务端、回执观测和存储状态。
|
||||
async open(){return openProjectEngine({scope:{accountId:'account',workspaceId:'workspace',projectId:'project'},registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port});}};
|
||||
return f;
|
||||
}
|
||||
// 只收集尚未解决的非上传操作,生成恢复选项时不把独立资源上传当作文档编辑。
|
||||
const pending=engine=>[...engine.getSnapshot().runtime.ledger.operations.values()].filter(record=>record.phase!=='resolved'&&record.intent.operationType!=='asset.upload');
|
||||
// 为每个待处理操作显式指定选择;某些用例覆盖个别选择以验证依赖闭包和动作限制。
|
||||
const choices=(engine,choice='keep-local')=>new Map(pending(engine).map(record=>[record.intent.id,choice]));
|
||||
// 通过真实命令入口修改标题,可传 groupId 检查输入历史分组在恢复后如何拆分或保留。
|
||||
const title=(engine,value,settings)=>engine.editor.dispatch({type:'element.data',target,patch:{title:value}},settings);
|
||||
|
||||
// keep-local 以远程字段前值创建新操作和新请求 key,保留旧拒绝;撤销回到审阅过的远程标题,未触碰文字不回退。
|
||||
test('keep-local freezes a new conditional intent/key, retains the old rejection, and undo restores the reviewed remote value',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote',text:'untouched remote'});
|
||||
const before=engine.getSnapshot(),old=[...before.runtime.ledger.requests.values()][0],session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
||||
@@ -34,6 +75,7 @@ test('keep-local freezes a new conditional intent/key, retains the old rejection
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 丢弃中间一步后重连剩余操作依赖和 undo 链;旧历史及恢复前内容仍保存在审计备份。
|
||||
test('discarding a middle edit preserves the remaining undo chain and archives the old history',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'one');await engine.settle();title(engine,'two');title(engine,'three');await engine.settle();f.remote({title:'remote'});
|
||||
const oldHistory=engine.getSnapshot().history,ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),selected=new Map([[ids[0],'keep-local'],[ids[1],'discard-local'],[ids[2],'keep-local']]),plan=session.plan(selected);
|
||||
@@ -44,6 +86,7 @@ test('discarding a middle edit preserves the remaining undo chain and archives t
|
||||
assert.equal(oldHistory.undo.length,3);assert.equal(engine.getSnapshot().runtime.recoveries.get(plan.id).backup.document.elements[0].data.title,'three');await engine.close();
|
||||
});
|
||||
|
||||
// 同一输入组中的已确认前缀仍可撤销;若服务端改过同字段,则撤销基点改为审阅的远程值。
|
||||
test('typing groups preserve an acknowledged prefix unless the server changed that field',async()=>{
|
||||
for(const remote of [false,true]){
|
||||
const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle();
|
||||
@@ -53,24 +96,28 @@ test('typing groups preserve an acknowledged prefix unless the server changed th
|
||||
}
|
||||
});
|
||||
|
||||
// 只丢弃输入组的 pending 后缀,不删除已确认前缀的撤销能力。
|
||||
test('discarding only a pending group suffix retains applied edits and other fields',async()=>{
|
||||
const f=fixture(),engine=await f.open();f.reject=false;title(engine,'acknowledged',{groupId:'typing'});await engine.settle();f.reject=true;title(engine,'pending',{groupId:'typing'});await engine.settle();
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,1);
|
||||
await session.resolve(plan);f.reject=false;assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'a');await engine.close();
|
||||
});
|
||||
|
||||
// 局部改宽度时保留远程 x 和 rotation;撤销只恢复宽度,不回退远程几何。
|
||||
test('geometry recovery changes only locally touched axes and undo retains remote geometry',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle();f.remoteGeometry({x:300,rotation:45});
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.deepEqual(plan.document.elements.get('a').geometry,{x:300,y:0,width:200,height:100,rotation:45});
|
||||
f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.deepEqual(engine.getSnapshot().document.elements.get('a').geometry,{x:300,y:0,width:100,height:100,rotation:45});await engine.close();
|
||||
});
|
||||
|
||||
// 远程已删除的目标只能丢弃本地修改,keep-local 不得暗中复活旧 incarnation;恢复保留墓碑。
|
||||
test('a deleted target may be discarded but cannot be resurrected by keep-local',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.setServer(applyChanges(original,[{kind:'element',id:'a',before:element,after:null}]));
|
||||
const session=await engine.prepareRecovery();assert.throws(()=>session.plan(choices(engine)),/stale_incarnation/);const plan=session.plan(choices(engine,'discard-local'));
|
||||
assert.equal(plan.history.undo.length,0);await session.resolve(plan);assert.equal(engine.getSnapshot().document.elements.has('a'),false);assert.equal(engine.getSnapshot().document.tombstones.get('a'),1);await engine.close();
|
||||
});
|
||||
|
||||
// 恢复发布后 quota 失败仍保留同一个计划和发送屏障;不能复制计划对象替代原计划,持久化重试成功后只新增一次写入。
|
||||
test('recovery publication failure keeps one plan and blocks sends until the exact cut becomes durable',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
||||
f.reject=false;f.broken=true;await assert.rejects(session.resolve(plan),/quota/);const revision=engine.getSnapshot().rootRevision;assert.equal(f.calls.length,1);
|
||||
@@ -78,11 +125,13 @@ test('recovery publication failure keeps one plan and blocks sends until the exa
|
||||
f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,2);assert.ok(engine.getDurability().durableRootRevision>revision);assert.equal(engine.getSnapshot().runtime.recoveries.size,1);await engine.close();
|
||||
});
|
||||
|
||||
// 漏选任何待处理操作都会拒绝计划;撤销产生的 traversal 也能进入恢复规划,取消会话不提交该计划。
|
||||
test('incomplete choices reject while recorded user/traversal recovery can be planned',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();const session=await engine.prepareRecovery();assert.throws(()=>session.plan(new Map()),/recovery_choices_incomplete/);await session.cancel();
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.ok(undoSession.plan(choices(engine)).history.redo.length===1);await undoSession.cancel();await engine.close();
|
||||
});
|
||||
|
||||
// 只退休依赖链一部分会被拒绝;已 dispatch、结果未知的操作即使补全闭包仍不安全,失败必须保持账本原引用。
|
||||
test('retirement rejects unknown work and incomplete dependency closures without mutation',()=>{
|
||||
const ledger=createLedger(),intent=(id,dependencies=[])=>({id,operationType:'document.change',operationSchemaVersion:1,sequence:id==='a'?1:2,origin:'user',canvasIds:['canvas'],changes:[],dependencies});
|
||||
ledger.add(intent('a'),{});ledger.add(intent('b',['a']),{});const before=ledger.getSnapshot();assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'}]),/recovery_dependency_closure/);assert.equal(ledger.getSnapshot(),before);
|
||||
@@ -90,6 +139,7 @@ test('retirement rejects unknown work and incomplete dependency closures without
|
||||
assert.throws(()=>ledger.retire('recovery',[{operationId:'a',resolution:'discarded'},{operationId:'b',resolution:'discarded'}]),/recovery_operation_not_safe/);assert.equal(ledger.getSnapshot(),frozen);
|
||||
});
|
||||
|
||||
// 旧 owner 的几何不能直接搬到新坐标系;pending run.start 必须显式丢弃并另发新命令,恢复不能自动重启付费动作。
|
||||
test('owner changes reject old geometry; pending paid actions require an explicit discard and never auto-restart',async()=>{
|
||||
const {planConflictRecovery}=await import('../../src/lib/canvas-runtime/conflicts.ts');
|
||||
const f=fixture(),engine=await f.open();engine.editor.dispatch({type:'element.geometry',target,patch:{width:200}});await engine.settle();
|
||||
@@ -100,6 +150,7 @@ test('owner changes reject old geometry; pending paid actions require an explici
|
||||
assert.equal(plan.ledger.operations.get('paid-start').resolution,'discarded');assert.ok(plan.operationIds.every(id=>plan.operations.get(id).operationType==='document.change'));await session.cancel();await engine.close();
|
||||
});
|
||||
|
||||
// 已发布计划即使脱离原会话也可完成持久化屏障;重开校验审计、资源 pin、替代操作链接和备份历史,任一篡改都拒绝。
|
||||
test('recovery audits and replacement links are validated when reopening and the owner can finish a detached durable barrier',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();f.remote({title:'remote'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
||||
f.broken=true;await assert.rejects(session.resolve(plan),/quota/);f.broken=false;f.reject=false;await engine.cancelRecovery();await engine.settle();
|
||||
@@ -114,18 +165,21 @@ test('recovery audits and replacement links are validated when reopening and the
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 远程已满足本地目标值时以 satisfied 退休旧操作,不发送新请求或新增 undo,也不把原拒绝改写成服务端成功。
|
||||
test('a remote value already satisfying the local choice retires the intent without claiming a server write or adding undo',async()=>{
|
||||
const f=fixture(),engine=await f.open();title(engine,'same');await engine.settle();f.remote({title:'same'});const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));
|
||||
assert.equal(plan.operationIds.length,0);assert.equal(plan.history.undo.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan);
|
||||
assert.equal(f.calls.length,1);assert.equal([...engine.getSnapshot().runtime.ledger.requests.values()][0].outcome,'not-applied');assert.equal(decodeRecovery(f.image(),registry).runtime.recoveries.size,1);await engine.close();
|
||||
});
|
||||
|
||||
// 混合输入组丢弃待处理标题后,已确认文字仍可撤销;撤销不得覆盖远程标题。
|
||||
test('discarding a field in a mixed typing group keeps the unrelated acknowledged field undoable',async()=>{
|
||||
const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target,patch:{text:'acknowledged text'}},{groupId:'mixed'});await engine.settle();f.reject=true;title(engine,'pending title',{groupId:'mixed'});await engine.settle();f.remote({title:'remote title'});
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));assert.equal(plan.history.undo.length,1);await session.resolve(plan);f.reject=false;
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'remote title');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'');await engine.close();
|
||||
});
|
||||
|
||||
// 取消挂起读取会 abort 且拒绝迟到结果;即使计划全是 discard-local,提交时仍须重新检查当前身份策略。
|
||||
test('discard-only resolution rechecks current identity and a cancelled read cannot later create a session',async()=>{
|
||||
const f=fixture();let allowed=true,release,signal;const engine=await f.open();title(engine,'local');await engine.settle();
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine,'discard-local'));await session.cancel();
|
||||
@@ -136,6 +190,7 @@ test('discard-only resolution rechecks current identity and a cancelled read can
|
||||
});
|
||||
|
||||
|
||||
// 较早拒绝编辑的 keep/discard 不丢失后来本地抵消的创建删除历史;恢复保留本地墓碑,撤销用新 incarnation,不把抵消操作塞进请求。
|
||||
test('later locally elided history survives keep/discard of an earlier rejected edit and its recovery replay audit',async()=>{
|
||||
for(const keep of [false,true]){
|
||||
const f=fixture();let engine=await f.open();
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 复制快照编解码的协作式预算测试。调度只影响让出时机,不能影响快照字节、墓碑、索引读取或取消语义。
|
||||
* 同时覆盖普通/索引快照及旧 prepared duplicate 源格式;假时钟与工作量上限分别控制耗时和粗时钟场景。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,29 +9,38 @@ import {encodeCopySnapshot,decodeCopySnapshot} from '../../src/lib/canvas-runtim
|
||||
import {encodeIndexedCopySnapshot,openIndexedCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot-index.ts';
|
||||
import {encodeDuplicateSource} from '../../src/lib/canvas-runtime/import-duplicate.ts';
|
||||
import {createCopyBudget} from '../../src/lib/canvas-runtime/copy-budget.ts';
|
||||
// 内置定义和独立 AbortSignal 工厂,避免一个场景的取消状态传入另一个场景。
|
||||
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal;
|
||||
// 包含中文、emoji、转义文本的节点工厂,让字节一致性比较覆盖多字节编码。
|
||||
const node=i=>({id:`n-${i}`,incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:`卡片 ${i}`,text:'emoji 👀\n"quoted"'},geometry:{x:i,y:0,width:100,height:100,rotation:0}});
|
||||
// 512 个节点及一个墓碑组成统一快照,既覆盖批量处理,也覆盖删除身份信息。
|
||||
const document=createDocument('project',[{id:'canvas',parent:null}],Array.from({length:512},(_,i)=>node(i)),new Map([['retired',2]]));
|
||||
// cheap 时钟固定为零,costly 每次前进 10;记录 yield 次数,区分按预算让出与固定条数休眠。
|
||||
function scheduling(slow=false){let yields=0,tick=0;return{options:{now:()=>slow?(++tick)*10:0,yieldTask:async()=>{yields++;}},get yields(){return yields;}};}
|
||||
|
||||
// 普通/索引格式在便宜和昂贵时间切片下编码完全相同;解码保留 512 节点及墓碑,索引格式还读取末项核对内容。
|
||||
test('copy snapshot bytes and decoded records are invariant across cheap and expensive scheduling slices',async()=>{
|
||||
for(const [encode,decode,indexed] of [[encodeCopySnapshot,decodeCopySnapshot,false],[encodeIndexedCopySnapshot,openIndexedCopySnapshot,true]]){
|
||||
const cheap=scheduling(),costly=scheduling(true),a=await encode(document,signal(),cheap.options),b=await encode(document,signal(),costly.options);assert.equal(await a.text(),await b.text());assert.equal(cheap.yields,0,'cheap work slept at a fixed record count');assert.ok(costly.yields>0);const before=costly.yields,snapshot=await decode(b,'project',registry,signal(),costly.options),restored=indexed?snapshot.document:snapshot;assert.equal(restored.elements.size,512);assert.deepEqual([...restored.tombstones],[['retired',2]]);assert.ok(costly.yields>before);if(indexed)assert.deepEqual(await snapshot.readElement('n-511',signal()),document.elements.get('n-511'));else assert.deepEqual([...restored.elements],[...document.elements]);
|
||||
}
|
||||
});
|
||||
// 编码、普通解码及打开索引快照各自在第一次 yield 中取消;必须报 copy_aborted,不能返回可用的半成品,原文档不变。
|
||||
test('abort at a scheduled yield prevents copy encoders and readers from returning a partial snapshot',async()=>{
|
||||
const old=await encodeCopySnapshot(document,signal(),scheduling().options),indexed=await encodeIndexedCopySnapshot(document,signal(),scheduling().options);
|
||||
for(const run of [(s,o)=>encodeCopySnapshot(document,s,o),(s,o)=>encodeIndexedCopySnapshot(document,s,o),(s,o)=>decodeCopySnapshot(old,'project',registry,s,o),(s,o)=>openIndexedCopySnapshot(indexed,'project',registry,s,o)]){const controller=new AbortController();let time=0,yields=0;await assert.rejects(run(controller.signal,{now:()=>time+=10,yieldTask:async()=>{yields++;controller.abort();}}),error=>error.code==='copy_aborted');assert.equal(yields,1);}
|
||||
assert.equal(document.elements.size,512);assert.equal(document.elements.get('n-0').data.title,'卡片 0');
|
||||
});
|
||||
// 时钟不动时仍按调用者累计的工作量上限让出,避免大记录霸占主线程;取消后 step 及预先取消的预算创建都立即拒绝。
|
||||
test('large records consume work budget and coarse clocks still yield without changing copy cancellation errors',async()=>{
|
||||
const controller=new AbortController();let yields=0;const budget=createCopyBudget(controller.signal,{now:()=>0,yieldTask:async()=>{yields++;}});for(let i=0;i<64;i++)await budget.step(256);assert.ok(yields>0,'coarse clock allowed an unbounded byte workload');controller.abort();assert.throws(()=>budget.step(),error=>error.code==='copy_aborted');
|
||||
const aborted=new AbortController();aborted.abort();assert.throws(()=>createCopyBudget(aborted.signal),error=>error.code==='copy_aborted');
|
||||
});
|
||||
// 拒绝非正或非有限 frameBudgetMs;yieldTask 的宿主错误保留原对象,不转换成伪造的复制取消。
|
||||
test('copy budget rejects invalid scheduling and preserves host scheduling failures',async()=>{
|
||||
for(const frameBudgetMs of [0,-1,NaN,Infinity])await assert.rejects(encodeIndexedCopySnapshot(document,signal(),{frameBudgetMs}),error=>error.code==='invalid_copy_budget');
|
||||
const failure=Error('host unavailable');let time=0;await assert.rejects(encodeCopySnapshot(document,signal(),{now:()=>time+=10,yieldTask:async()=>{throw failure;}}),error=>error===failure);
|
||||
});
|
||||
// 旧式 prepared duplicate 源在不同切片下保持相同字节;预算边界取消时拒绝而非返回部分输出。
|
||||
test('legacy prepared duplicate sources preserve exact bytes and abort before returning on a budget boundary',async()=>{
|
||||
let id=0;const plan=planDuplicate(document,registry,[...document.elements.values()].map(({id,incarnation})=>({id,incarnation})),{createId:()=>`copy-${++id}`}),cheap=scheduling(),costly=scheduling(true),a=await encodeDuplicateSource(document,registry,plan,signal(),cheap.options),b=await encodeDuplicateSource(document,registry,plan,signal(),costly.options);assert.equal(await a.text(),await b.text());assert.equal(cheap.yields,0);assert.ok(costly.yields>0);const controller=new AbortController();let time=0;await assert.rejects(encodeDuplicateSource(document,registry,plan,controller.signal,{now:()=>time+=10,yieldTask:async()=>controller.abort()}),error=>error.code==='copy_aborted');
|
||||
});
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
/**
|
||||
* 持久复制源的引用依赖与批次选择测试。映射必须和声明的引用路径使用同一遍历规则,不能仍指向原来的子对象。
|
||||
* 外部保留引用还需校验目标生命周期;循环依赖要作为完整强连通组加入,不能靠拆边绕过依赖或容量。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate,defineComponent} from '../../src/lib/canvas-core/index.ts';
|
||||
import {encodeDuplicateSource,prepareDuplicateImport,assertDuplicateExternal} from '../../src/lib/canvas-runtime/import-duplicate.ts';
|
||||
import {selectImportGroups} from '../../src/lib/canvas-runtime/import-groups.ts';
|
||||
// 可指定类型/归属/数据的第一代节点样本,用于 frame 端口及自定义对象数组引用。
|
||||
const node=(id,owner='P',componentId='text',data={})=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'',...data},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||
// 使用内置复制 schema,frame.ports 中的 nodeId 被识别为元素引用,端口局部 ID 仍是普通数据。
|
||||
const registry=createRegistry(builtinComponents());
|
||||
|
||||
// 复制 frame 的持久源须声明子节点依赖并使用新 nodeId;篡改为旧引用或删掉依赖都在导入准备时拒绝。
|
||||
test('durable copy source includes mapped port dependencies and refuses an old source still targeting original children',async()=>{
|
||||
const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame',{ports:[{nodeId:'A',id:'in',direction:'input',handle:'left'}]}),node('A','S')]);let next=0;
|
||||
const plan=planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=>`new-${++next}`}),source=await encodeDuplicateSource(document,registry,plan,new AbortController().signal),rows=(await source.text()).trim().split('\n').map(row=>JSON.parse(row)),value=rows.find(row=>row.key==='element:F');
|
||||
@@ -16,6 +23,7 @@ test('durable copy source includes mapped port dependencies and refuses an old s
|
||||
const missing=structuredClone(value);missing.dependencies=missing.dependencies.filter(key=>key!=='element:A');assert.throws(()=>prepareDuplicateImport({value:missing},job,key=>stableIds[key],registry),/invalid_duplicate_source/);
|
||||
});
|
||||
|
||||
// 自定义 rows.*.node 外部引用保持原目标并记录 incarnation;目标后来换代,晚期 guard 必须拒绝旧引用。
|
||||
test('object-array external references share the same traversal for mapping and late lifecycle guards',async()=>{
|
||||
const linked=defineComponent({...builtinComponents()[0],id:'linked',copy:{references:[{path:['rows','*','node'],target:'element',external:'preserve'}],omit:[]}}),r=createRegistry([...builtinComponents(),linked]),document=createDocument('P',[{id:'P',parent:null}],[node('C'),node('L','P','linked',{rows:[{node:'C',literal:'C'}]})]);
|
||||
const plan=planDuplicate(document,r,[{id:'L',incarnation:1}],{createId:()=> 'new-L'}),rows=(await(await encodeDuplicateSource(document,r,plan,new AbortController().signal)).text()).trim().split('\n').map(row=>JSON.parse(row)),job={source:{kind:'duplicate-ndjson'},stableIds:{'element:L':'new-L'}};
|
||||
@@ -23,7 +31,9 @@ test('object-array external references share the same traversal for mapping and
|
||||
const changed=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(row=>row.id==='C'?{...row,incarnation:2}:row));assert.throws(()=>assertDuplicateExternal({value:rows[0]},job,changed,r),/copy_external_changed/);
|
||||
});
|
||||
|
||||
// 构造待加入记录的位置描述:依赖键、编码字节数及变化数供 SCC 分组和硬容量检查。
|
||||
const locator=(key,dependencies,byteLength=100)=>({key,dependencies,byteLength,changeCount:1});
|
||||
// A/B 循环还依赖 outside:外部未完成时不选,已完成时整组可超过软 units=1,但字节或 changes 硬上限不足应抛错。
|
||||
test('SCC selection waits for missing external dependencies and enforces hard group capacity',()=>{
|
||||
const pending=[locator('A',['B']),locator('B',['A','outside'])];assert.deepEqual(selectImportGroups(pending,new Set(),{units:1,bytes:1000,changes:100}),[]);
|
||||
assert.deepEqual(selectImportGroups(pending,new Set(['outside']),{units:1,bytes:1000,changes:100}).flat().map(row=>row.key),['A','B']);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 索引复制快照的拓扑/正文分离测试:先读足够规划的结构信息,再按位置读取需要的完整元素。
|
||||
* 拓扑视图不是完整可编辑文档;未知正文错误可以延后到对应记录请求时,但定位、几何与格式边界必须先检查。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate,validateDocument} from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,10 +9,14 @@ import {encodeIndexedCopySnapshot,openIndexedCopySnapshot} from '../../src/lib/c
|
||||
import {prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||
import {copyRegistrySignature,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||||
import {createDuplicatePlanSource} from '../../src/lib/canvas-runtime/duplicate-source.ts';
|
||||
// 构造含私密标记文本和 presentation 扩展的节点,确保索引阶段没有悄悄解码或保留完整正文。
|
||||
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal,node=(id,owner='P',componentId='text')=>({id,incarnation:2,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'unparsed-payload-秘密',extra:{retained:true}},geometry:{x:10,y:20,width:200,height:100,rotation:0}});
|
||||
// 带端口、旋转/缩放子分区、外部入边及墓碑的基线,使懒读取结果可与同步深复制逐项比较。
|
||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{...node('F').data,ports:[{id:'port',nodeId:'A'}],presentation:{unitScale:2,contentScale:.75,other:'not-needed-in-index'}},geometry:{...node('F').geometry,rotation:35}},node('A','S','image'),{id:'edge',incarnation:1,structure:'connection',ownerCanvasId:'P',componentId:'connection',dataVersion:1,data:{retained:true},source:{elementId:'C'},target:{elementId:'A'},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:70,y:80},in:{x:1,y:2},out:{x:3,y:4}}]}}],new Map([['deleted',4]]));
|
||||
// 以 NDJSON 行拆分快照,供定位索引和正文的定点损坏注入,不改变正常编码器。
|
||||
const lines=async bytes=>(await bytes.text()).trimEnd().split('\n');
|
||||
|
||||
// 索引只保留拓扑所需单位信息,不含正文或 ports;按需只读 edge 也能生成正确边,全部记录最终与同步计划一致。
|
||||
test('indexed snapshots read topology before payload, preserve raw records on demand and reproduce exact mapped output',async()=>{
|
||||
const bytes=await encodeIndexedCopySnapshot(document,signal()),snapshot=await openIndexedCopySnapshot(bytes,'project',registry,signal());assert.equal(JSON.stringify([...snapshot.document.elements]).includes('unparsed-payload'),false);assert.equal(snapshot.document.elements.get('F').data.ports,undefined);assert.deepEqual(snapshot.document.elements.get('F').data.presentation,{unitScale:2,contentScale:.75});assert.deepEqual([...snapshot.document.tombstones],[...document.tombstones]);assert.throws(()=>validateDocument(snapshot.document,registry));
|
||||
const loaded=[];let next=0;const state=await prepareDuplicateStream(snapshot.document,registry,[{id:'F',incarnation:2}],{createId:()=>`copy-${++next}`},{sourceToken:'a'.repeat(64),schemaToken:copyRegistrySignature(snapshot.document,registry),topologyOnly:true});assert.equal(loaded.length,0);const source=createDuplicatePlanSource(snapshot.document,registry,state,'indexed',async(id,abort)=>{loaded.push(id);return snapshot.readElement(id,abort);});let reference=0;const expected=planDuplicate(document,registry,[{id:'F',incarnation:2}],{createId:()=>`copy-${++reference}`});
|
||||
@@ -16,19 +24,23 @@ test('indexed snapshots read topology before payload, preserve raw records on de
|
||||
for(let i=0;i<expected.changes.length;i++)assert.deepEqual((await source.read(i,signal())).value.change,expected.changes[i]);for(const[id,record]of document.elements)assert.deepEqual(await snapshot.readElement(id,signal()),record);await assert.rejects(decodeCopySnapshot(bytes,'project',registry,signal()),/invalid_copy_snapshot/);
|
||||
});
|
||||
|
||||
// 损坏尾部 edge 正文不妨碍打开索引及读取 A;请求损坏记录或正文 ID 与索引不符时才明确拒绝。
|
||||
test('index decoding defers corrupt or invalid component content until the corresponding record is requested',async()=>{
|
||||
const rows=await lines(await encodeIndexedCopySnapshot(document,signal())),last=rows.length-1;rows[last]='!'+rows[last].slice(1);const corrupt=new Blob([rows.join('\n')+'\n']),snapshot=await openIndexedCopySnapshot(corrupt,'project',registry,signal());assert.equal((await snapshot.readElement('A',signal())).id,'A');await assert.rejects(snapshot.readElement('edge',signal()),/invalid_copy_snapshot/);
|
||||
const clean=await lines(await encodeIndexedCopySnapshot(document,signal())),i=clean.findIndex(row=>row.startsWith('{"kind":"element","record":{"id":"A"'));clean[i]=clean[i].replace('"id":"A"','"id":"Z"');const mismatch=await openIndexedCopySnapshot(new Blob([clean.join('\n')+'\n']),'project',registry,signal());await assert.rejects(mismatch.readElement('A',signal()),/invalid_copy_snapshot/);
|
||||
});
|
||||
|
||||
// 索引偏移空洞、零长度、非法几何、截断/追加字节、项目不符与各阶段取消都拒绝,不能返回貌似可用的源。
|
||||
test('indexed source rejects locator gaps, geometry corruption, truncation, foreign versions and aborted reads',async()=>{
|
||||
const bytes=await encodeIndexedCopySnapshot(document,signal()),rows=await lines(bytes),index=rows.findIndex(row=>JSON.parse(row).kind==='element-index');
|
||||
for(const change of [row=>({...row,offset:1}),row=>({...row,byteLength:0}),row=>({...row,record:{...row.record,geometry:{...row.record.geometry,width:0}}})]){const bad=[...rows];bad[index]=JSON.stringify(change(JSON.parse(bad[index])));await assert.rejects(openIndexedCopySnapshot(new Blob([bad.join('\n')+'\n']),'project',registry,signal()));}
|
||||
await assert.rejects(openIndexedCopySnapshot(bytes.slice(0,bytes.size-1),'project',registry,signal()));await assert.rejects(openIndexedCopySnapshot(new Blob([await bytes.text(),'x']),'project',registry,signal()));await assert.rejects(openIndexedCopySnapshot(bytes,'foreign',registry,signal()));const controller=new AbortController();controller.abort();await assert.rejects(encodeIndexedCopySnapshot(document,controller.signal),/copy_aborted/);await assert.rejects(openIndexedCopySnapshot(bytes,'project',registry,controller.signal),/copy_aborted/);const snapshot=await openIndexedCopySnapshot(bytes,'project',registry,signal());await assert.rejects(snapshot.readElement('A',controller.signal),/copy_aborted/);
|
||||
});
|
||||
|
||||
// 连续读取 120 条大记录应复用有限原始字节窗口而非每条 slice;再读首项需重新读块,证明旧字节窗口已淘汰。
|
||||
test('bounded raw windows preserve sequential parser batching and evict old bytes without caching decoded content',async()=>{
|
||||
const source=createDocument('project',[{id:'P',parent:null}],Array.from({length:120},(_,i)=>({...node(`record-${i}`),data:{title:`record-${i}`,text:'x'.repeat(3500)}}))),bytes=await encodeIndexedCopySnapshot(source,signal()),reads=[];
|
||||
// 记录实际 Blob.slice 次数,检查连续读记录时有限原始字节窗口的复用和旧窗口淘汰。
|
||||
class ObservedBlob extends Blob{slice(...args){reads.push(args);return super.slice(...args);}}
|
||||
const snapshot=await openIndexedCopySnapshot(new ObservedBlob([bytes]),'project',registry,signal());reads.length=0;
|
||||
for(let i=0;i<120;i++)assert.equal((await snapshot.readElement(`record-${i}`,signal())).data.title,`record-${i}`);assert.ok(reads.length>=5&&reads.length<120/4,`unexpected per-record Blob reads: ${reads.length}`);const count=reads.length;assert.equal((await snapshot.readElement('record-0',signal())).data.text.length,3500);assert.equal(reads.length,count+1,'first window did not leave the bounded cache');
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* 完整复制快照及准备期任务检查点测试。正文、canvas、墓碑和未知字段需完整往返,并与来源项目绑定。
|
||||
* 准备期尚未发布内容,任务不能同时带运行游标或 committedSourceKeys;格式/解释器不匹配必须拒绝。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||
import {encodeCopySnapshot,decodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||||
import {createImportJob,validateImportJob,createImportRunner} from '../../src/lib/canvas-runtime/imports.ts';
|
||||
// 第二代节点、未知嵌套数据与第三代墓碑组成基线,避免只验证最简单创建对象的往返。
|
||||
const registry=createRegistry(builtinComponents()),node={id:'A',incarnation:2,structure:'node',ownerCanvasId:'P',componentId:'text',dataVersion:1,data:{title:'原始',text:'内容',unknown:{preserved:true}},geometry:{x:0,y:0,width:100,height:100,rotation:0}},document=createDocument('project',[{id:'P',parent:null}],[node],new Map([['deleted',3]]));
|
||||
// 每次创建独立未取消信号;取消场景显式提供已 abort 的 controller。
|
||||
const signal=()=>new AbortController().signal;
|
||||
// 完整快照往返保持元素/canvas/墓碑及未知数据,输出元素数据冻结;错误项目 ID 拒绝。
|
||||
test('copy snapshot roundtrip preserves the complete source, identity lifecycles and unknown data',async()=>{
|
||||
const bytes=await encodeCopySnapshot(document,signal()),decoded=await decodeCopySnapshot(bytes,'project',registry,signal());assert.deepEqual([...decoded.elements],[...document.elements]);assert.deepEqual([...decoded.canvases],[...document.canvases]);assert.deepEqual([...decoded.tombstones],[...document.tombstones]);assert.ok(Object.isFrozen(decoded.elements.get('A').data));await assert.rejects(decodeCopySnapshot(bytes,'different-project',registry,signal()),/invalid_copy_snapshot/);
|
||||
});
|
||||
// 空值、错误 JSON、未来版本、未知行、重复记录和取消都不能生成文档;已取消编码也必须立即拒绝。
|
||||
test('snapshot decoding rejects malformed, future, duplicate, invalid and truncated sources without inventing a document',async()=>{
|
||||
for(const text of ['','{}','{bad}',JSON.stringify({kind:'header',version:2,projectId:'project'}),JSON.stringify({kind:'header',version:1,projectId:'project'})+'\n'+JSON.stringify({kind:'other',record:{}})])await assert.rejects(decodeCopySnapshot(new Blob([text]),'project',registry,signal()));
|
||||
const original=await(await encodeCopySnapshot(document,signal())).text(),row=original.trim().split('\n').at(-1);await assert.rejects(decodeCopySnapshot(new Blob([original+row]),'project',registry,signal()),/invalid_copy_snapshot/);
|
||||
const controller=new AbortController();controller.abort();await assert.rejects(encodeCopySnapshot(document,controller.signal),/copy_aborted/);await assert.rejects(decodeCopySnapshot(new Blob([original]),'project',registry,controller.signal),/copy_aborted/);
|
||||
});
|
||||
// 准备检查点需有效选择、解释器版本、唯一已分配 ID 且没有发布进度;不具备对应解释器的 runner 不能运行。
|
||||
test('preparation checkpoints require a matching interpreter, valid selection, unique allocations and no published progress',()=>{
|
||||
const base={...createImportJob({id:'job',projectId:'project',sourceFingerprint:'snapshot',parserVersion:1,source:{kind:'duplicate-snapshot',sha256:'a'.repeat(64),fileId:'file',byteLength:10,targetCanvasId:'P',copy:{sourceRevision:0,roots:[]}}}),status:'preparing',preparation:{version:1,registrySignature:'registry-v1',selection:[{id:'A',incarnation:2}],worldOffset:{x:32,y:32},allocationIds:['new-A'],steps:512}};validateImportJob(base);
|
||||
for(const patch of [{preparation:undefined},{preparation:{...base.preparation,version:5}},{preparation:{...base.preparation,allocationIds:['same','same']}},{preparation:{...base.preparation,selection:[]}},{cursor:1},{committedSourceKeys:['element:A']},{status:'running'},{source:{...base.source,snapshotVersion:3}},{source:{...base.source,snapshotVersion:2}}])assert.throws(()=>validateImportJob({...base,...patch}));
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 从真实引擎持久化序列提取各个崩溃切点,再用匹配的服务端状态重新打开。
|
||||
* 比较文档、history、ledger、请求次数和冻结字节;这是镜像恢复模拟,不会终止真实进程或测试真实磁盘掉电。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,12 +9,18 @@ import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
|
||||
|
||||
// 固定项目作用域,便于服务端版本向量与恢复镜像始终指向同一根 canvas。
|
||||
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000002',account='10000000-0000-4000-8000-000000000003';
|
||||
// 原始文本目标只存在第一代;所有恢复场景从同一内容基线出发。
|
||||
const registry=createRegistry(builtinComponents()),target={id:'a',incarnation:1},original=createDocument(root,[{id:root,parent:null}],[{...target,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:80,rotation:0},data:{title:'Original',text:''}}]);
|
||||
// 用可选本地镜像、服务端文档和历史回执初始化一套独立环境,分别模拟本地与远程已经完成到哪一步。
|
||||
function fixture(image,initialServer=original,initialReceipt){
|
||||
let owner,saved=image&&structuredClone(image),server=initialServer,version=initialReceipt?2:1,now=1000,next=0;
|
||||
// 每次 commit 捕获一个持久化切点;calls 用于区分恢复后的查询与实际写入。
|
||||
const cuts=[],calls=[],timers=new Map(),receipts=new Map(initialReceipt?[[initialReceipt.request_id,structuredClone(initialReceipt)]]:[]);
|
||||
// 从当前服务端状态生成局部规范模型及其版本,作为成功回执的一部分。
|
||||
const model=()=>({documentFormatVersion:1,projectId:root,complete:false,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[root]:version},removedCanvasIds:[]});
|
||||
// 查询只查原请求回执;首次写入应用条件操作并递增版本,重复请求直接返回已有回执。
|
||||
const transport=async input=>{
|
||||
calls.push(input);
|
||||
if(input.method==='GET'){const receipt=receipts.get(input.path.split('/').at(-1));return receipt?{status:200,body:receipt}:{status:404,body:{status:'unknown',request_id:input.path.split('/').at(-1)}};}
|
||||
@@ -18,34 +28,38 @@ function fixture(image,initialServer=original,initialReceipt){
|
||||
const body=JSON.parse(input.body);assert.equal(body.expected_versions[root],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()};receipts.set(input.requestId,receipt);return{status:200,body:receipt};
|
||||
};
|
||||
// 内存 owner 保证当前引擎独占;每次保存克隆镜像,防止后续状态污染选定的崩溃切点。
|
||||
const storage={async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;this.active=false;}};},async recover(){return saved&&structuredClone(saved);},async commit(leader,batch){assert.strictEqual(leader,owner);saved=structuredClone(batch.image);cuts.push(saved);return{durableRootRevision:saved.rootRevision};},async readFile(){},close(){}};
|
||||
// 接入真实 Foundry 文档适配器;假时钟只登记回调,由 drain 手动推进重试。
|
||||
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1]]),storage,persistence:createFoundryPersistence({registry,workspaceId:workspace,rootId:root,transport}),clock:{now:()=>now,setTimer(callback,delay){const id=++next;timers.set(id,{callback,due:now+delay});return id;},clearTimer:id=>timers.delete(id)}};
|
||||
// drain 有界推进收敛和到期重试;若八轮后仍有未解决操作即失败,不以无限等待掩盖恢复停滞。
|
||||
return{cuts,calls,receipts,server:()=>server,open:()=>openProjectEngine(options),async drain(engine){for(let step=0;step<8;step++){await engine.settle();if([...engine.getSnapshot().runtime.ledger.operations.values()].every(operation=>operation.phase==='resolved'))return;now+=100000;for(const[id,timer]of[...timers])if(timer.due<=now){timers.delete(id);timer.callback();}}assert.fail('recovered operations did not settle');}};
|
||||
}
|
||||
|
||||
// 先收集 pending、frozen、dispatch-marked、resolved 四种落盘状态,再用六个子场景分别核对恢复策略和已发生的外部副作用。
|
||||
test('each durable crash cut restores its own document/history/ledger and external side-effect state',async t=>{
|
||||
const producer=fixture(),engine=await producer.open();await engine.persist();const initial=structuredClone(producer.cuts.at(-1));
|
||||
const edited=engine.editor.dispatch({type:'element.data',target,patch:{title:'Changed'}});assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'Changed');assert.ok(engine.getDurability().rootRevision>engine.getDurability().durableRootRevision);
|
||||
await producer.drain(engine);const decoded=producer.cuts.map(image=>({image,state:decodeRecovery(image,registry)})),request=[...engine.getSnapshot().runtime.ledger.requests.values()][0],receipt=producer.receipts.get(request.id),changed=producer.server();
|
||||
const pending=decoded.find(({state})=>state.runtime.ledger.operations.size===1&&state.runtime.ledger.requests.size===0),frozen=decoded.find(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='frozen'),dispatch=decoded.find(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='dispatch-marked'),resolved=decoded.findLast(({state})=>state.runtime.ledger.requests.get(request.id)?.phase==='resolved');
|
||||
assert.ok(pending&&frozen&&dispatch&&resolved,'producer did not expose every required durable transition');await engine.close();
|
||||
// 初始镜像没有尚未落盘的可见编辑;后续每个场景都明确指定应查询/写入的次数及服务端是否已执行。
|
||||
for(const scenario of[
|
||||
{name:'visible edit before durability is absent after crash and was never safe to leave',image:initial,edited:false,posts:0,queries:0},
|
||||
{name:'durable pending intent dispatches once with its original logical identity',image:pending.image,edited:true,posts:1,queries:0},
|
||||
// Recovery conservatively queries this cut too; unknown permits the exact
|
||||
// retry, not a new identity or a claim that no effect could have happened.
|
||||
// frozen 切点也保守查询;unknown 仅允许原请求重试,不能改用新身份或宣称服务未发生副作用。
|
||||
{name:'durable frozen request is reconciled then sends its original bytes only once',image:frozen.image,edited:true,posts:1,queries:1,exact:true},
|
||||
{name:'dispatch-marked before server commit queries unknown then retries the exact request',image:dispatch.image,edited:true,posts:1,queries:1,exact:true},
|
||||
{name:'server commit before local receipt queries without repeating the write',image:dispatch.image,edited:true,posts:0,queries:1,server:changed,receipt,exact:true},
|
||||
{name:'durable receipt and baseline do not replay the acknowledged edit or history',image:resolved.image,edited:true,posts:0,queries:0,server:changed,receipt},
|
||||
])await t.test(scenario.name,async()=>{
|
||||
// The server state is matched to the cut: it is never rewound after a
|
||||
// dispatched effect merely because an older local image was selected.
|
||||
// 本地 dispatch 切点可对应服务执行前或执行后;若服务已经提交,不能因读取旧镜像就把服务状态回退。
|
||||
const f=fixture(scenario.image,scenario.server,scenario.receipt),recovered=await f.open();
|
||||
try{
|
||||
await f.drain(recovered);const state=recovered.getSnapshot();assert.equal(state.document.elements.get('a').data.title,scenario.edited?'Changed':'Original');assert.equal(f.server().elements.get('a').data.title,scenario.edited?'Changed':'Original');assert.equal(state.history.undo.length,scenario.edited?1:0);assert.equal(state.operations.size,scenario.edited?1:0);
|
||||
assert.equal(f.calls.filter(call=>call.method==='POST').length,scenario.posts);assert.equal(f.calls.filter(call=>call.method==='GET').length,scenario.queries);
|
||||
if(scenario.edited)assert.ok(state.runtime.ledger.operations.has(edited.operationIds[0]));
|
||||
// 身份核对覆盖 GET 路径和 POST requestId;实际重发还必须保留同一冻结 body。
|
||||
if(scenario.exact)for(const call of f.calls){assert.equal(call.requestId??call.path.split('/').at(-1),request.id);if(call.method==='POST')assert.equal(call.body,request.body);}
|
||||
assert.equal(recovered.getDurability().rootRevision,recovered.getDurability().durableRootRevision);assert.equal(recovered.sessions.getSnapshot().views.size,0);
|
||||
}finally{await recovered.close();}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* 摘要池的容量与生命周期测试。活动执行数和 FIFO 等待数分别受限;调用者取消不等于底层执行器已经退出。
|
||||
* 以固定格式摘要和手动完成的执行器隔离哈希算法,专门验证调度、关闭及输出校验。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDigestPool} from '../../src/lib/canvas-runtime/digest-pool.ts';
|
||||
// hash 仅作合法输出样本;tick 让执行器的微任务/定时器推进,便于检查当前实际启动次序。
|
||||
const hash='a'.repeat(64),tick=()=>new Promise(resolve=>setTimeout(resolve,0));
|
||||
// 两个执行槽加两个排队名额:第五个拒绝容量,取消排队的 C 后仅 A/B 启动,A 完成才按 FIFO 启动 D;关闭后拒绝新任务。
|
||||
test('digest pool bounds execution and FIFO queue, rejects overload and drops queued cancellation',async()=>{
|
||||
const started=[],release=[];const pool=createDigestPool({concurrency:2,maxQueued:2,execute:async bytes=>{started.push(await bytes.text());return new Promise(resolve=>release.push(()=>resolve(hash)));}}),cancel=new AbortController(),a=pool.digest(new Blob(['A'])),b=pool.digest(new Blob(['B'])),c=pool.digest(new Blob(['C']),cancel.signal),d=pool.digest(new Blob(['D']));const rejected=assert.rejects(c,/import_aborted/);await assert.rejects(pool.digest(new Blob(['overflow'])),/digest_capacity/);cancel.abort();await rejected;await tick();assert.deepEqual(started,['A','B']);release[0]();await a;await tick();assert.deepEqual(started,['A','B','D']);release[1]();release[2]();await Promise.all([b,d]);pool.close();await assert.rejects(pool.digest(new Blob()),/digest_pool_closed/);
|
||||
});
|
||||
// 取消活动 first 后先拒绝调用者,但底层仍挂起时 second 不能启动;first 真正退出后才复用槽位,并忽略其迟到输出。
|
||||
test('active cancellation cannot free a compute slot before its executor stops; late output is ignored',async()=>{
|
||||
let release;const started=[],controller=new AbortController(),pool=createDigestPool({concurrency:1,execute:async bytes=>{started.push(await bytes.text());if(started.length===1)await new Promise(resolve=>release=resolve);return hash;}}),first=pool.digest(new Blob(['first']),controller.signal),rejected=assert.rejects(first,/import_aborted/),second=pool.digest(new Blob(['second']));await tick();controller.abort();await rejected;await tick();assert.deepEqual(started,['first']);release();assert.equal(await second,hash);pool.close();
|
||||
});
|
||||
// close 幂等地取消活动/排队调用并向实际执行器传递 abort;新池中格式错误的 execute 输出必须拒绝。
|
||||
test('closing digest pool aborts active/queued tasks once and malformed executor output is rejected',async()=>{
|
||||
const signals=[],pool=createDigestPool({concurrency:1,execute:(_bytes,signal)=>{signals.push(signal);return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(Error('stopped')),{once:true}));}}),a=pool.digest(new Blob()),b=pool.digest(new Blob()),failures=[assert.rejects(a,/import_aborted/),assert.rejects(b,/import_aborted/)];await tick();pool.close();pool.close();await Promise.all(failures);assert.equal(signals.length,1);assert.equal(signals[0].aborted,true);const invalid=createDigestPool({execute:async()=> 'bad'});await assert.rejects(invalid.digest(new Blob()),/invalid_digest_response/);invalid.close();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* Worker 内部摘要算法选择测试。WebCrypto 可用且未超过单次内存预算时走原生路径,否则使用流式 SHA-256。
|
||||
* 原生读取/计算失败必须保留原错误,不能默默换算法重试而掩盖资源或执行环境问题。
|
||||
*/
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {createHash} from 'node:crypto';
|
||||
import {digestWorkerSource,nativeDigestByteLimit} from '../../src/lib/canvas-runtime/digest-worker-source.ts';
|
||||
|
||||
// 空输入、Unicode 和二进制分别比较原生/流式结果,确认预算内原生路径只调用一次正确的 SHA-256 算法。
|
||||
test('native Worker hashing and streaming fallback produce identical SHA256 for empty, UTF8 and binary sources',async()=>{
|
||||
for(const bytes of [new Blob(),new Blob(['中文 😀']),new Blob([new Uint8Array([0,255,128,10])])]){
|
||||
let calls=0;const native={digest(algorithm,input){calls++;assert.equal(algorithm,'SHA-256');assert.equal(input.byteLength,bytes.size);return crypto.subtle.digest(algorithm,input);}};
|
||||
@@ -8,14 +13,19 @@ test('native Worker hashing and streaming fallback produce identical SHA256 for
|
||||
}
|
||||
});
|
||||
|
||||
// 整块读取失败、原生 digest 抛错或返回错误长度均明确拒绝;不能改走流式路径生成看似成功的结果。
|
||||
test('native read or digest failures stay visible and never silently retry through the streaming fallback',async()=>{
|
||||
// Blob 替身让整块读取失败,同时把 slice 设为陷阱;若代码误走流式兜底会产生另一错误。
|
||||
class BrokenRead extends Blob{arrayBuffer(){return Promise.reject(Error('read failed'));}slice(){throw Error('unexpected fallback');}}
|
||||
await assert.rejects(digestWorkerSource(new BrokenRead(['input'])),/read failed/);
|
||||
const bytes=new Blob(['input']);await assert.rejects(digestWorkerSource(bytes,{digest(){throw Error('native failed');}}),/native failed/);await assert.rejects(digestWorkerSource(bytes,{async digest(){return new ArrayBuffer(1);}}),/invalid_digest_response/);
|
||||
});
|
||||
|
||||
// 超过原生分配预算的输入禁止整块读取及 WebCrypto;仅按最多 1 MiB 块计算,结果与 Node 独立摘要一致。
|
||||
test('inputs above the native allocation budget use bounded chunks and never request a whole buffer',async()=>{
|
||||
// 构造刚超过原生预算的逻辑输入,不实际申请整块大数组;Node createHash 独立计算相同零字节序列。
|
||||
const size=nativeDigestByteLimit+1,hash=createHash('sha256');let largest=0;
|
||||
// 伪 Blob 报告大尺寸,禁止整块 arrayBuffer,只允许 slice 返回有限大小的零字节块并记录峰值。
|
||||
class ChunkOnly extends Blob{get size(){return size;}arrayBuffer(){throw Error('unbounded read');}slice(start=0,end=size){const count=Math.max(0,Math.min(size,end)-start);largest=Math.max(largest,count);return new Blob([new Uint8Array(count)]);}}
|
||||
for(let cursor=0;cursor<size;cursor+=1024*1024)hash.update(new Uint8Array(Math.min(1024*1024,size-cursor)));
|
||||
const digest=await digestWorkerSource(new ChunkOnly(),{digest(){throw Error('native budget exceeded');}});assert.equal(digest,hash.digest('hex'));assert.ok(largest<=1024*1024);
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
/**
|
||||
* 资源显示租约缓存测试。按 owner 和资源键共享进行中的读取/URL,最后租约释放即清理,不保留空闲 URL。
|
||||
* 每个等待者可独立取消;旧读取迟到结果应清理自身,不能替换同键的新代次或破坏其他 owner。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDisplayCache } from '../../src/lib/canvas-runtime/display-cache.ts';
|
||||
// 手动 gate 可返回成功显示源或抛错,让取消和迟到清理时序可控。
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
// 推进事件循环等待 load/清理执行,不做真实磁盘或浏览器图片加载。
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
// 两个租约共享一次 load 和同一 URL,release 幂等;最后释放清理后再 acquire 必须重新 load,关闭清理新源。
|
||||
test('concurrent display leases share one source until the last idempotent release; there is no idle URL retention', async () => {
|
||||
const cache = createDisplayCache(), owner = {}; let loads = 0, disposed = 0;
|
||||
// 可观测显示源工厂:每次真实读取产生新 URL,源 release 计数代表底层资源释放。
|
||||
const load = async () => ({ url: `blob:${++loads}`, release: () => disposed++ });
|
||||
const a = cache.acquire(owner, 'asset', load), b = cache.acquire(owner, 'asset', load);
|
||||
assert.deepEqual(await Promise.all([a.ready, b.ready]), ['blob:1', 'blob:1']); assert.equal(loads, 1);
|
||||
a.release(); a.release(); assert.equal(disposed, 0); b.release(); assert.equal(disposed, 1);
|
||||
const c = cache.acquire(owner, 'asset', load); assert.equal(await c.ready, 'blob:2'); c.release(); cache.close(); assert.equal(disposed, 2);
|
||||
});
|
||||
// 取消一个等待租约只拒绝它自己的 ready,共享 AbortSignal 仍有效;最后一个 reader 释放才 abort 并清理。
|
||||
test('cancelling one waiting lease does not abort a shared load or another reader', async () => {
|
||||
const cache = createDisplayCache(), owner = {}, gate = deferred(); let signal, disposed = 0;
|
||||
// 捕获共享读取的 AbortSignal 并挂起,区分释放单个租约与终止整个底层读取。
|
||||
const load = async value => { signal = value; return gate.promise; };
|
||||
const a = cache.acquire(owner, 'asset', load), b = cache.acquire(owner, 'asset', load); await tick();
|
||||
const cancelled = assert.rejects(a.ready, { code: 'display_released' }); a.release(); await cancelled; assert.equal(signal.aborted, false);
|
||||
gate.resolve({ url: 'blob:shared', release: () => disposed++ }); assert.equal(await b.ready, 'blob:shared'); b.release(); assert.equal(signal.aborted, true); assert.equal(disposed, 1);
|
||||
});
|
||||
// 最后旧租约释放即 abort,随后同键可新建;旧源迟到只释放自身,不能替换/逐出新缓存条目。
|
||||
test('last release aborts a load; a late source cannot replace or evict a newly acquired entry', async () => {
|
||||
const cache = createDisplayCache(), owner = {}, gate = deferred(); let signal, oldDisposed = 0, newDisposed = 0;
|
||||
const old = cache.acquire(owner, 'asset', async value => { signal = value; return gate.promise; }); await tick();
|
||||
@@ -27,12 +38,14 @@ test('last release aborts a load; a late source cannot replace or evict a newly
|
||||
gate.resolve({ url: 'blob:old', release: () => oldDisposed++ }); await tick(); assert.equal(oldDisposed, 1); assert.equal(newDisposed, 0);
|
||||
const peer = cache.acquire(owner, 'asset', async () => { throw Error('unexpected duplicate read'); }); assert.equal(await peer.ready, 'blob:new'); peer.release(); next.release(); assert.equal(newDisposed, 1);
|
||||
});
|
||||
// 读取失败保留原错误码并移除失败条目,新 acquire 可重新读取成功,不永久缓存错误。
|
||||
test('read failure retains its cause and removes the failed cache entry for a fresh retry', async () => {
|
||||
const cache = createDisplayCache(), owner = {};
|
||||
const a = cache.acquire(owner, 'asset', async () => { throw Object.assign(Error('disk_failed'), { code: 'disk_failed' }); });
|
||||
await assert.rejects(a.ready, { code: 'disk_failed' });
|
||||
const b = cache.acquire(owner, 'asset', async () => ({ url: '/remote' })); assert.equal(await b.ready, '/remote'); b.release(); cache.close();
|
||||
});
|
||||
// 同资源 ID 不同 owner 各有源;releaseOwner 只关闭自己的作用域并禁止再次领取,不影响另一个 owner 的共享租约。
|
||||
test('project owner scopes isolate identical IDs and retired owners cannot reacquire', async () => {
|
||||
const cache = createDisplayCache(), a = {}, b = {}; let disposed = 0;
|
||||
const first = cache.acquire(a, 'same-id', async () => ({ url: 'blob:a', release: () => disposed++ }));
|
||||
@@ -41,6 +54,7 @@ test('project owner scopes isolate identical IDs and retired owners cannot reacq
|
||||
assert.throws(() => cache.acquire(a, 'same-id', async () => ({ url: '/forbidden' })), { code: 'display_scope_closed' });
|
||||
const peer = cache.acquire(b, 'same-id', async () => { throw Error('unexpected'); }); assert.equal(await peer.ready, 'blob:b'); peer.release(); second.release(); assert.equal(disposed, 2);
|
||||
});
|
||||
// close 立即拒绝挂起 ready,迟到源仍需释放;关闭后新 acquire 拒绝,多次 close 安全。
|
||||
test('cache close rejects waiting readers immediately and disposes late data without reopening a scope', async () => {
|
||||
const cache = createDisplayCache(), gate = deferred(); let disposed = 0;
|
||||
const lease = cache.acquire({}, 'asset', () => gate.promise); await tick();
|
||||
@@ -48,6 +62,7 @@ test('cache close rejects waiting readers immediately and disposes late data wit
|
||||
gate.resolve({ url: 'blob:late', release: () => disposed++ }); await tick(); assert.equal(disposed, 1);
|
||||
assert.throws(() => cache.acquire({}, 'asset', async () => ({ url: '/late' })), { code: 'display_scope_closed' }); cache.close();
|
||||
});
|
||||
// 空 URL 等非法源需清理已分配资源;某个 release 抛错通过 onError 报告,不能阻止其他 owner 的清理。
|
||||
test('invalid sources clean their allocation and cleanup errors do not interrupt other owners', async () => {
|
||||
const errors = [], cache = createDisplayCache({ onError: error => errors.push(error) }); let disposed = 0;
|
||||
const invalid = cache.acquire({}, 'invalid', async () => ({ url: '', release: () => disposed++ })); await assert.rejects(invalid.ready, { code: 'invalid_display_source' }); assert.equal(disposed, 1);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 分区解散和重新挂接测试。解散只移除 owning canvas,将其直接成员提升到父 canvas,保留入口元素及其连线。
|
||||
* 几何换算必须保留世界姿态,子分区只改父绑定;声明的 canvas 引用跟随重映射,普通字符串不自动替换。
|
||||
* 最后一例用真实 Foundry 协议适配器和内存服务检查单请求、丢 ACK 恢复及跨重开的 undo/redo。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createEditor,createRegistry,builtinComponents,defineComponent,movable,editable,validateDocument,applyChanges,canvasTransform,toWorld,unitScale} from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,26 +10,42 @@ import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
|
||||
import {loadingCommand} from '../../src/lib/canvas-runtime/loading.ts';
|
||||
|
||||
// 统一节点身份、owner 和几何;frame 复用该外壳,单独补充内容缩放及单位缩放。
|
||||
const node=(id,owner='P',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:120,height:80,rotation:5},data:{title:id,text:'kept'}});
|
||||
// 已知 bezier-chain 路径包含锚点和手柄,解散时可以换算;未知路径类型则应原子拒绝。
|
||||
const curve={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:60},in:{x:-5,y:0},out:{x:5,y:0}}]};
|
||||
// 连线用端点引用和 owner 表达图关系,data.label 供检查结构修改不会清空内容。
|
||||
const edge=(id,source,target,owner='P',path=null)=>({id,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:owner,source:{elementId:source},target:{elementId:target},path,data:{label:'keep'}});
|
||||
// 自定义 references 类型只声明 views[*].canvas 为引用,literal 即使同值也不是引用;保留未知扩展字段。
|
||||
const registry=createRegistry([...builtinComponents(),defineComponent({id:'references',dataVersion:1,structure:'node',validate:()=>true,features:[movable(),editable()],copy:{references:[{path:['views','*','canvas'],target:'canvas',external:'preserve'}],omit:[]}})]);
|
||||
// 三层 P→S→T,包含跨层连线、入口连线和显式引用;父 frame 带旋转与缩放以暴露坐标换算错误。
|
||||
const source=()=>createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G',future:'retain'}}],[
|
||||
{...node('F','P','frame'),geometry:{x:100,y:70,width:500,height:400,rotation:33},data:{title:'F',text:'',presentation:{contentScale:2,unitScale:1.2},ports:[{id:'in',nodeId:'A'}]}},
|
||||
{...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75,unitScale:.8}}},node('A','S'),node('B','T'),node('C'),
|
||||
{...node('R','P','references'),data:{views:[{canvas:'S',future:true}],literal:'S'}},edge('incoming','C','F'),edge('cross','C','B'),edge('inside','A','G','S',curve),
|
||||
]);
|
||||
// 目标 S 与其入口 F 的第一代身份必须共同匹配,不能仅凭 canvasId 解散任意分区。
|
||||
const command={type:'canvas.dissolve',canvasId:'S',entry:{id:'F',incarnation:1}};
|
||||
// 浮点姿态用 1e-7 容差比较,避免旋转缩放的舍入误差干扰结构语义检查。
|
||||
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
|
||||
// 把元素中心、大小、角度和单位比例换算到世界坐标,比较解散前后用户实际看到的姿态。
|
||||
function pose(document,id){const element=document.elements.get(id),t=canvasTransform(document,element.ownerCanvasId),g=element.geometry;return{...toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width:g.width*t.scale,height:g.height*t.scale,rotation:(g.rotation+t.rotation)%360,units:unitScale(element.data.presentation)*t.scale};}
|
||||
// 集中验证提升结果:S 消失但入口、元素数和身份保留,直接成员迁移,孙层成员与父扩展信息保留。
|
||||
function checkPromoted(before,after){
|
||||
validateDocument(after,registry);assert.equal(after.canvases.has('S'),false);assert.deepEqual(after.elements.get('F'),before.elements.get('F'));assert.deepEqual(after.elements.get('incoming'),before.elements.get('incoming'));
|
||||
assert.equal(after.elements.size,before.elements.size);assert.equal(after.elements.get('A').ownerCanvasId,'P');assert.equal(after.elements.get('G').ownerCanvasId,'P');assert.equal(after.elements.get('B').ownerCanvasId,'T');assert.deepEqual(after.elements.get('B'),before.elements.get('B'));assert.deepEqual(after.canvases.get('T').parent,{...before.canvases.get('T').parent,canvasId:'P'});
|
||||
|
||||
// 分别比较直接节点、子分区入口和孙层节点的世界姿态,防止只验证 owner 而漏掉嵌套坐标变化。
|
||||
for(const id of['A','G','B']){const a=pose(before,id),b=pose(after,id);for(const key of Object.keys(a))near(a[key],b[key]);assert.equal(after.elements.get(id).incarnation,before.elements.get(id).incarnation);}
|
||||
|
||||
// 内部连线提升到父 canvas 后,路径锚点需换算到同一世界位置;原跨层连线的 owner 本来就是 P。
|
||||
assert.equal(after.elements.get('inside').ownerCanvasId,'P');const a=toWorld(canvasTransform(before,'S'),curve.knots[0].position),b=toWorld(canvasTransform(after,'P'),after.elements.get('inside').path.knots[0].position);near(a.x,b.x);near(a.y,b.y);
|
||||
|
||||
// 仅重写注册表声明的 views.canvas;literal 字符串和 future 扩展值原样保留。
|
||||
assert.equal(after.elements.get('cross').ownerCanvasId,'P');assert.deepEqual(after.elements.get('R').data,{views:[{canvas:'P',future:true}],literal:'S'});
|
||||
}
|
||||
|
||||
// can 只查询能力且不发布;dispatch 一次通知、一个操作和一个 undo 单元,undo 精确还原,redo 再次得到相同世界姿态。
|
||||
test('dissolve promotes direct members once, preserves world poses, nested ownership, entry lines and declared references',()=>{
|
||||
const document=source(),editor=createEditor({document,registry}),before=editor.getSnapshot();let notifications=0;editor.subscribe(()=>notifications++);
|
||||
assert.equal(editor.can(command).enabled,true);assert.strictEqual(editor.getSnapshot(),before);
|
||||
@@ -33,6 +54,7 @@ test('dissolve promotes direct members once, preserves world poses, nested owner
|
||||
assert.equal(editor.history.redo().status,'applied');checkPromoted(document,editor.getSnapshot().document);assert.equal(editor.getSnapshot().history.undo.length,1);
|
||||
});
|
||||
|
||||
// 根 canvas、旧入口身份、错误入口、成员/引用策略拒绝、未知元素和未知路径都不能留下半次提升;Root、query 和订阅次数保持不变。
|
||||
test('dissolve rejects root, stale entry, member policy, unsupported member and opaque curve without partial publication',()=>{
|
||||
const document=source();for(const options of[
|
||||
{command:{...command,canvasId:'P'}},
|
||||
@@ -48,17 +70,20 @@ test('dissolve rejects root, stale entry, member policy, unsupported member and
|
||||
}
|
||||
});
|
||||
|
||||
// 空 canvas 解散只移除绑定,入口元素不删除、不分配替代元素、不产生元素墓碑;undo 恢复原 canvas 集合。
|
||||
test('an empty owning canvas dissolves without deleting its entry or allocating replacement elements',()=>{
|
||||
const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame')]),editor=createEditor({document,registry});
|
||||
assert.equal(editor.dispatch(command).status,'applied');assert.deepEqual([...editor.getSnapshot().document.elements],[...document.elements]);assert.equal(editor.getSnapshot().document.tombstones.size,0);assert.equal(editor.history.undo().status,'applied');assert.deepEqual([...editor.getSnapshot().document.canvases],[...document.canvases]);
|
||||
});
|
||||
|
||||
// reparent 只改 canvas 父入口绑定,不递归改子孙元素 owner;制造父子循环时整体拒绝。
|
||||
test('reparent changes the canvas binding without recursively rewriting descendant owners and cycles remain atomic failures',()=>{
|
||||
const original=source(),editor=createEditor({document:original,registry});assert.equal(editor.dispatch({type:'canvas.reparent',canvasId:'S',parent:{canvasId:'P',entryElementId:'C'}}).status,'applied');
|
||||
const document=editor.getSnapshot().document;assert.deepEqual(document.canvases.get('S').parent,{canvasId:'P',entryElementId:'C'});for(const id of['A','G','B'])assert.equal(document.elements.get(id).ownerCanvasId,original.elements.get(id).ownerCanvasId);assert.equal(document.canvases.get('T').parent.canvasId,'S');validateDocument(document,registry);
|
||||
const before=editor.getSnapshot();assert.equal(editor.dispatch({type:'canvas.reparent',canvasId:'P',parent:{canvasId:'T',entryElementId:'B'}}).status,'rejected');assert.strictEqual(editor.getSnapshot(),before);
|
||||
});
|
||||
|
||||
// 渐进加载时,即使直接成员已到齐也不能忽略尚未加载的引用元素;完整捕获源可解散,旧 v3 加载描述不足以放行。
|
||||
test('dissolve waits for the complete captured source instead of promoting a partially loaded membership',()=>{
|
||||
const full=source(),prefix=createDocument('P',[...full.canvases.values()],[...full.elements.values()].filter(element=>element.id!=='R')),versions=new Map([...full.canvases.keys()].map(id=>[id,1])),state={version:5,sourceToken:'dissolve-source',declaredCanvasIds:[...full.canvases.keys()],sourceCanvases:[...full.canvases.values()],elementOwners:Object.fromEntries([...full.elements.values()].map(element=>[element.id,element.ownerCanvasId])),canvasElementCounts:Object.fromEntries([...full.canvases.keys()].map(id=>[id,[...full.elements.values()].filter(element=>element.ownerCanvasId===id).length])),receivedElements:prefix.elements.size,totalElements:full.elements.size};
|
||||
assert.equal(loadingCommand(command,prefix,registry,state,prefix,{document:prefix,versions}).enabled,false);
|
||||
@@ -66,11 +91,18 @@ test('dissolve waits for the complete captured source instead of promoting a par
|
||||
assert.equal(loadingCommand(command,full,registry,{...state,version:3},full,{document:full,versions}).enabled,false);
|
||||
});
|
||||
|
||||
// 符号 ID 转 UUID 后走真实编译适配器;一次请求覆盖 P/S/T,丢 ACK 只查询原请求,重开保留 undo/redo,最终共三次文档写入。
|
||||
test('dissolve uses one atomic protocol request, recovers a lost ACK by its original identity, then undoes and redoes after reopen',async()=>{
|
||||
const symbolic=source(),ids=Object.fromEntries([...new Set([...symbolic.canvases.keys(),...symbolic.elements.keys()])].map((id,index)=>[id,`10000000-0000-4000-8000-${String(index+1).padStart(12,'0')}`])),inverse=Object.fromEntries(Object.entries(ids).map(([name,id])=>[id,name]));
|
||||
|
||||
// 为协议测试双向重命名所有结构 ID:canvas、入口、owner、端点、端口、声明引用及墓碑;普通内容字符串不替换。
|
||||
const rename=(document,ids)=>{const map=id=>ids[id]??id;return createDocument(map(document.projectId),[...document.canvases.values()].map(canvas=>({...canvas,id:map(canvas.id),parent:canvas.parent?{...canvas.parent,canvasId:map(canvas.parent.canvasId),entryElementId:map(canvas.parent.entryElementId)}:null})),[...document.elements.values()].map(element=>({...element,id:map(element.id),ownerCanvasId:map(element.ownerCanvasId),...(element.structure==='connection'?{source:{...element.source,elementId:map(element.source.elementId)},target:{...element.target,elementId:map(element.target.elementId)}}:{}),data:{...element.data,...(element.data.ports?{ports:element.data.ports.map(port=>({...port,nodeId:map(port.nodeId)}))}:{}),...(element.data.views?{views:element.data.views.map(view=>({...view,canvas:map(view.canvas)}))}:{})}})),new Map([...document.tombstones].map(([id,incarnation])=>[map(id),incarnation])));};
|
||||
const original=rename(symbolic,ids),workspace='20000000-0000-4000-8000-000000000001';let server=original,image,owner,now=1000,lose=true;const versions=new Map([...original.canvases.keys()].map(id=>[id,1])),receipts=new Map(),calls=[];
|
||||
|
||||
// 为参与 canvas 生成局部回执,移除的 S 仍列入 removedCanvasIds 和版本向量。
|
||||
const model=(participants,complete=false)=>({documentFormatVersion:1,projectId:original.projectId,complete,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:participants.filter(id=>!server.canvases.has(id))});
|
||||
|
||||
// Foundry 编译器负责实际请求格式;内存 transport 检查每个参与版本,并在执行后故意丢一次响应。
|
||||
const persistence=createFoundryPersistence({registry,workspaceId:workspace,rootId:ids.P,transport:async input=>{
|
||||
calls.push(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);for(const id of participants)assert.equal(body.expected_versions[id],versions.get(id)??0);
|
||||
@@ -78,7 +110,11 @@ test('dissolve uses one atomic protocol request, recovers a lost ACK by its orig
|
||||
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(lose){lose=false;throw Error('lost dissolve ACK');}return{status:200,body:receipt};
|
||||
}});
|
||||
|
||||
// 保存完整恢复镜像和独占 owner;引擎重开时仍能取到原冻结请求与历史。
|
||||
const storage={async claim(scope){assert.equal(owner,undefined);return owner={scope,active:true,fence:1,async release(){owner=undefined;this.active=false;}};},async recover(){return image&&structuredClone(image);},async commit(leader,batch){assert.equal(leader,owner);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){}};
|
||||
|
||||
// 假时钟不自动执行定时器,重开前主动把 now 前移以触发未知请求的恢复查询。
|
||||
const options={scope:{accountId:'account',workspaceId:workspace,projectId:ids.P},registry,document:original,versions:new Map([...original.canvases.keys()].map(id=>[id,1])),storage,persistence,clock:{now:()=>now,setTimer:()=>0,clearTimer(){}}};let engine=await openProjectEngine(options);
|
||||
try{
|
||||
assert.equal(engine.editor.dispatch({...command,canvasId:ids.S,entry:{id:ids.F,incarnation:1}}).status,'applied');await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.outcome,'unresolved');assert.deepEqual([...request.canvasIds].map(id=>inverse[id]).sort(),['P','S','T']);assert.equal(calls.filter(call=>call.method==='POST').length,1);checkPromoted(symbolic,rename(server,inverse));
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
/**
|
||||
* 可恢复深复制规划器的语义检查点测试。检查点保存遍历游标、阶段及稳定映射,不保存完整节点正文。
|
||||
* 任一阶段序列化/恢复后结果需与同步 planDuplicate 一致,已分配身份不能再分配,旧检查点也不能随后续 step 改变。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
||||
import {createDuplicatePlanner,validateDuplicateCheckpoint} from '../../src/lib/canvas-core/duplicate-checkpoint.ts';
|
||||
import {planDuplicateResumable} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||
// 节点及曲线工厂保留独立身份、owner 和端口,文本标记用于检查检查点没有复制完整正文。
|
||||
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:2,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal-data-not-in-checkpoint'},geometry:{x:10,y:20,width:200,height:100,rotation:0}}),edge=(id,source,target,owner='P')=>({id,incarnation:1,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},data:{},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]}});
|
||||
// 三层分区含内部/跨分区边与上级入边,触发 DFS、分配、映射、节点/边输出等全部阶段。
|
||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),{...node('F','P','frame'),geometry:{...node('F').geometry,rotation:30},data:{title:'F',text:'',presentation:{contentScale:2},ports:[{id:'in',nodeId:'A',direction:'input',handle:'left'}]}},{...node('A','S','image'),data:{title:'A',text:'',assetId:'shared',runId:'not-copied'}},{...node('G','S','frame'),data:{title:'G',text:'',presentation:{contentScale:.75}}},node('B','T'),node('D','T'),edge('incoming','C','F'),edge('deep','C','B'),edge('internal','B','D','T'),edge('across','A','B','S'),edge('outgoing','F','C')]);
|
||||
// 绑定来源/schema token;targets 取真实身份,drain 有界步进到 result,未结束则报错而不返回部分计划。
|
||||
const binding={sourceToken:'source-sha',schemaToken:'schema-v1'},targets=(ids=['F','A'])=>ids.map(id=>({id,incarnation:document.elements.get(id).incarnation})),drain=planner=>{for(let i=0;i<100000;i++)if(planner.step())return planner.result();throw Error('planner did not terminate');};
|
||||
|
||||
// 逐阶段捕获并 JSON 往返检查点,以新文档对象恢复后对照完整同步计划;分配计数一致,原检查点不变且不含标记正文。
|
||||
test('each durable planning phase resumes with the same deep-copy plan and no repeated identity allocation',()=>{
|
||||
// 遍历每个可持久化阶段;emit 与 emit-edges 还要求已输出至少一项,避免只覆盖空阶段入口。
|
||||
const phases=['selection','canvases','members','roots','seed','dfs','edges','allocate-elements','allocate-canvases','mapping','emit','emit-edges','validate','done'];
|
||||
for(const phase of phases){let next=0,planner=createDuplicatePlanner(document,registry,targets(),{createId:()=>`new-${++next}`},binding),checkpoint;
|
||||
for(let i=0;i<10000;i++){const done=planner.step(),state=planner.checkpoint();if(state.phase===phase&&(phase!=='emit'||state.emitted>0)&&(phase!=='emit-edges'||state.emittedEdges>0)){checkpoint=state;break;}if(done)break;}
|
||||
@@ -15,6 +24,7 @@ test('each durable planning phase resumes with the same deep-copy plan and no re
|
||||
}
|
||||
});
|
||||
|
||||
// 在嵌套 DFS 展开中停下,恢复后保留待访问兄弟项及显式选中边;给定完整映射时任何新 ID 分配都会使测试失败。
|
||||
test('nested DFS expansion checkpoints preserve pending siblings, selected edges and explicit stable mappings',()=>{
|
||||
for(const selection of [['F','C','incoming'],['A','internal'],['incoming']]){let reference=0;const selected=targets(selection),expected=planDuplicate(document,registry,selected,{createId:()=>`copy-${++reference}`}),mapping={elements:expected.elementIds,canvases:expected.canvasIds};let planner=createDuplicatePlanner(document,registry,selected,{mapping,createId:()=>{throw Error('allocated despite complete mapping');}},binding),state;
|
||||
for(let i=0;i<10000;i++){const done=planner.step();state=planner.checkpoint();if(state.expanding?.index>=0||done)break;}
|
||||
@@ -22,15 +32,18 @@ test('nested DFS expansion checkpoints preserve pending siblings, selected edges
|
||||
}
|
||||
});
|
||||
|
||||
// 来源/schema 绑定不同、未来版本/阶段、负游标、非法映射或缺失工作项都拒绝;未完成时 result 也不可用。
|
||||
test('checkpoint bindings, shape and source identities reject incompatible state before producing a plan',()=>{
|
||||
const planner=createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},binding),state=planner.checkpoint();for(const patch of [{sourceToken:'different'},{schemaToken:'different'}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,...patch,checkpoint:state}),/binding_mismatch/);
|
||||
for(const patch of [{version:2},{phase:'future'},{cursor:-2},{elementIds:[['F','C']]},{work:[{kind:'element',id:'missing'}]}])assert.throws(()=>createDuplicatePlanner(document,registry,targets(),{createId:()=> 'new'},{...binding,checkpoint:{...state,...patch}}));assert.throws(()=>planner.result(),/copy_plan_not_ready/);
|
||||
});
|
||||
|
||||
// 每片一步并持续保存,在已有至少两个元素 ID 后取消;用保存状态恢复,最终计划及 ID 分配总量仍与同步路径一致。
|
||||
test('cooperative resume checkpoints advance semantic cursors, yield and stop cleanly after cancellation',async()=>{
|
||||
const controller=new AbortController();let saved,ids=0,yields=0;await assert.rejects(planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},binding,{maxStepsPerSlice:1,yieldTask:async()=>{yields++;},checkpoint:async state=>{if(state.elementIds.length>=2){saved=state;controller.abort();}},signal:controller.signal}),/copy_aborted/);assert.ok(yields>0);assert.ok(saved.elementIds.length>=2);const old=ids,plan=await planDuplicateResumable(document,registry,targets(),{createId:()=>`async-${++ids}`},{...binding,checkpoint:saved},{maxStepsPerSlice:2});let reference=0;assert.deepEqual(plan,planDuplicate(document,registry,targets(),{createId:()=>`async-${++reference}`}));assert.equal(ids,reference);assert.ok(ids>=old);
|
||||
});
|
||||
|
||||
// 首次大检查点按预算协作式编码,并发推进 planner 不影响旧检查点/image;字符串编码分片且 UTF-8 计数准确,finally 恢复全局探针。
|
||||
test('a first planning checkpoint encodes cooperatively and is isolated from later planner steps',async()=>{
|
||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const {hasDetachedJsonData}=await import('../../src/lib/canvas-core/json-data.ts');
|
||||
@@ -40,6 +53,7 @@ test('a first planning checkpoint encodes cooperatively and is isolated from lat
|
||||
assert.equal(hasDetachedJsonData(checkpoint),true);
|
||||
JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('checkpoint-'))encoded++;return Reflect.apply(stringify,this,[value,...args]);};
|
||||
try{
|
||||
// 封存检查点尚未完成时继续推进 planner,模拟存储异步执行和后续准备并行;已捕获检查点必须保持旧值。
|
||||
const pending=sealRecoveryImageAsync(17,1,1,checkpoint,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{atFirst??=encoded;yields++;}});
|
||||
for(let i=0;i<20000&&planner.progress().steps<=10;i++)planner.step();
|
||||
assert.ok(planner.progress().steps>checkpoint.steps);
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
/**
|
||||
* 复制规划的协作调度与检查点保存节奏测试。逐步扫描、分配及映射可以让出执行权,但最终计划仍需完全一致。
|
||||
* 检查点保存比任务让出更稀疏,不能让慢 checkpoint I/O 本身强迫下一片立即再保存;取消或错误不发布半份计划。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate,planDuplicateSteps,applyChanges,validateDocument,validateDocumentSteps,validJson} from '../../src/lib/canvas-core/index.ts';
|
||||
import {planDuplicateAsync,prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||
// 使用内置复制定义,调度差异不会改变类型解释。
|
||||
const registry=createRegistry(builtinComponents());
|
||||
// 生成 frame/文本节点;完整数据较简单,测试聚焦大量扫描及映射步骤。
|
||||
const node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'copy'},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||
// 上级 C 指向 frame 的入边,验证协作式规划仍遵守上级入边复制规则。
|
||||
const edge={id:'edge',incarnation:1,ownerCanvasId:'P',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}};
|
||||
// 2000 个子节点加入口、上级节点和入边构成 2003 项源,用于推动跨多个调度切片。
|
||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame'),node('C'),...Array.from({length:2000},(_,i)=>node(`n${i}`,'S')),edge]);
|
||||
// 每轮重新生成确定性的 ID 分配器,使异步与同步计划能深比较,不受全局随机 ID 干扰。
|
||||
const selection=[{id:'F',incarnation:1}],options=()=>{let id=0;return{createId:()=>`new-${++id}`};};
|
||||
|
||||
// 固定时钟仍按每片 64 步让出两百余次;完整结果与同步路径相同,含上级入边、冻结副本及不变源文档。
|
||||
test('cooperative deep planning yields throughout scanning, allocation and remapping and matches the synchronous plan',async()=>{
|
||||
let slices=0;const plan=await planDuplicateAsync(document,registry,selection,options(),{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{slices++;}});
|
||||
assert.ok(slices>200);assert.deepEqual(plan,planDuplicate(document,registry,selection,options()));assert.equal(validJson(plan),true);
|
||||
@@ -16,18 +26,21 @@ test('cooperative deep planning yields throughout scanning, allocation and remap
|
||||
assert.equal(Object.isFrozen(plan.changes[0].after.data),true);assert.equal(document.elements.size,2003);
|
||||
});
|
||||
|
||||
// 默认 task yield 允许真实零延时定时器取消大规划;取消发生在分配前,没有 ID 或内容被发布。
|
||||
test('default task yielding lets actual timers cancel a large copy without publishing content',async()=>{
|
||||
const controller=new AbortController();let ids=0,ran=false;
|
||||
const timer=setTimeout(()=>{ran=true;controller.abort();},0);
|
||||
try{await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`unused-${++ids}`},{signal:controller.signal,maxStepsPerSlice:16}),/copy_aborted/);assert.equal(ran,true);assert.equal(ids,0);assert.equal(document.elements.size,2003);}finally{clearTimeout(timer);}
|
||||
});
|
||||
|
||||
// 已分配部分 ID 后在 yield 中取消,明确拒绝而非返回部分计划,原文档没有任何 new-* 对象。
|
||||
test('cancellation during allocation never returns a partial plan or changes the source',async()=>{
|
||||
const controller=new AbortController();let allocated=0,slices=0;
|
||||
await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`new-${++allocated}`},{signal:controller.signal,now:()=>0,maxStepsPerSlice:32,yieldTask:async()=>{slices++;if(allocated>20)controller.abort();}}),/copy_aborted/);
|
||||
assert.ok(allocated>20&&allocated<2003);assert.ok(slices>1);assert.equal(document.elements.has('new-1'),false);
|
||||
});
|
||||
|
||||
// 规划开始后调用者修改 selection、worldOffset 和实时根,仍使用已捕获的源与选项,不能中途混入新状态。
|
||||
test('planning captures caller inputs and retains the original document when the live root changes',async()=>{
|
||||
const small=createDocument('project',[{id:'P',parent:null}],[node('A')]);let live=small,changed=false;
|
||||
const targets=[{id:'A',incarnation:1}],settings={...options(),worldOffset:{x:40,y:50}};
|
||||
@@ -35,6 +48,7 @@ test('planning captures caller inputs and retains the original document when the
|
||||
assert.equal(live.elements.size,0);assert.equal(result.changes[0].after.geometry.x,40);assert.equal(result.changes[0].after.geometry.y,50);assert.equal(small.elements.size,1);
|
||||
});
|
||||
|
||||
// 即使步数上限很高,时间预算也会让出;宿主调度错误、非法预算和预先取消均保留各自错误。
|
||||
test('time budget forces a task yield even when the step limit is high and scheduler errors propagate',async()=>{
|
||||
let tick=0,yields=0;
|
||||
await planDuplicateAsync(createDocument('p',[{id:'P',parent:null}],[node('A')]),registry,[{id:'A',incarnation:1}],options(),{now:()=>++tick,frameBudgetMs:1,maxStepsPerSlice:99999,yieldTask:async()=>{yields++;}});assert.ok(yields>10);
|
||||
@@ -43,6 +57,7 @@ test('time budget forces a task yield even when the step limit is high and sched
|
||||
const controller=new AbortController();controller.abort();await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{signal:controller.signal}),/copy_aborted/);
|
||||
});
|
||||
|
||||
// 协作式验证和规划不能过滤掉错误记录:多根文档和旧 incarnation 仍以与同步路径一致的错误拒绝。
|
||||
test('cooperative validation and planning retain invalid-input errors rather than skipping failed records',async()=>{
|
||||
const invalid={...document,canvases:new Map([...document.canvases,['bad',{id:'bad',parent:null}]])};
|
||||
assert.throws(()=>validateDocument(invalid,registry),/invalid_roots/);assert.throws(()=>[...validateDocumentSteps(invalid,registry)],/invalid_roots/);
|
||||
@@ -50,6 +65,7 @@ test('cooperative validation and planning retain invalid-input errors rather tha
|
||||
assert.throws(()=>[...planDuplicateSteps(document,registry,[{id:'F',incarnation:9}],options())],/stale_incarnation/);
|
||||
});
|
||||
|
||||
// 源 ID 为 __proto__/constructor 时仍编码为 own JSON 字段,映射对象原型不受影响且序列化往返稳定。
|
||||
test('mapping output preserves special source IDs as own JSON fields without prototype mutation',async()=>{
|
||||
const source=createDocument('special',[{id:'P',parent:null}],[node('__proto__'),node('constructor')]);
|
||||
const plan=await planDuplicateAsync(source,registry,[{id:'__proto__',incarnation:1},{id:'constructor',incarnation:1}],options());
|
||||
@@ -57,16 +73,19 @@ test('mapping output preserves special source IDs as own JSON fields without pro
|
||||
});
|
||||
|
||||
|
||||
// 对比每次保存与间隔保存:最终映射、所有 yield 和阶段覆盖一致,而全量检查点显著减少,最大步距仍受限。
|
||||
test('stream checkpoint cadence reduces full snapshots while preserving every task yield, phase and final mapping',async()=>{
|
||||
const binding={sourceToken:'captured',schemaToken:'schema'},run=async interval=>{let yields=0;const checkpoints=[],state=await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>0,maxStepsPerSlice:64,checkpointIntervalMs:interval,maxStepsPerCheckpoint:2048,yieldTask:async()=>{yields++;},checkpoint:async state=>{checkpoints.push(state);}});return{state,yields,checkpoints};},every=await run(0),paced=await run(50);
|
||||
assert.deepEqual(paced.state,every.state);assert.equal(paced.yields,every.yields);assert.ok(paced.checkpoints.length<every.checkpoints.length/4);const phases=new Set(paced.checkpoints.map(state=>state.phase));for(const state of every.checkpoints)assert.ok(phases.has(state.phase));assert.equal(paced.checkpoints.at(-1).phase,'emit');assert.ok(paced.checkpoints.every(Object.isFrozen));for(let i=1;i<paced.checkpoints.length;i++)assert.ok(paced.checkpoints[i].steps-paced.checkpoints[i-1].steps<=2048+64);assert.equal(document.elements.size,2003);
|
||||
});
|
||||
// 节奏化准备取消后复用已分配 ID;检查点回调模拟耗时 1000,下一次同阶段保存仍从回调完成后重新计时。
|
||||
test('paced preparation resumes allocated IDs exactly and checkpoint I/O time does not force the next snapshot',async()=>{
|
||||
const binding={sourceToken:'captured',schemaToken:'schema'},controller=new AbortController(),allocation=options();let saved;
|
||||
await assert.rejects(prepareDuplicateStream(document,registry,selection,allocation,binding,{signal:controller.signal,now:()=>0,maxStepsPerSlice:64,checkpointIntervalMs:50,maxStepsPerCheckpoint:512,yieldTask:async()=>{},checkpoint:async state=>{if(state.phase==='allocate-elements'&&state.elementIds.length){saved=state;controller.abort();}}}),/copy_aborted/);assert.ok(saved.elementIds.length>0);
|
||||
const resumed=await prepareDuplicateStream(document,registry,selection,allocation,{...binding,checkpoint:saved},{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{}}),complete=await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{}});assert.deepEqual(resumed,complete);assert.deepEqual(resumed.elementIds.slice(0,saved.elementIds.length),saved.elementIds);
|
||||
let time=0,yields=0;const saves=[];await prepareDuplicateStream(document,registry,selection,options(),binding,{now:()=>time,maxStepsPerSlice:64,checkpointIntervalMs:10,yieldTask:async()=>{time++;yields++;},checkpoint:async state=>{const start=time;time+=1000;saves.push({phase:state.phase,start,end:time});}});assert.ok(saves.length<yields/3);for(let i=1;i<saves.length;i++)if(saves[i].phase===saves[i-1].phase)assert.ok(saves[i].start-saves[i-1].end>=10);
|
||||
});
|
||||
// 非法 checkpoint 间隔或步数上限在任何 ID 分配/保存回调前拒绝,不能先产生部分准备成果。
|
||||
test('invalid stream checkpoint cadence rejects before allocation or publication',async()=>{
|
||||
for(const setting of [{checkpointIntervalMs:-1},{checkpointIntervalMs:NaN},{checkpointIntervalMs:Infinity},{maxStepsPerCheckpoint:0},{maxStepsPerCheckpoint:1.5}]){let allocated=0,published=0;await assert.rejects(prepareDuplicateStream(document,registry,selection,{createId:()=>{allocated++;return'new';}},{sourceToken:'captured',schemaToken:'schema'},{...setting,checkpoint:async()=>{published++;}}),/invalid_copy_budget/);assert.equal(allocated,0);assert.equal(published,0);}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 映射已准备好的复制源按记录游标懒输出测试,不要求先生成第二份完整输出 Blob。
|
||||
* 来源摘要、registry 签名和稳定 ID 共同绑定准备状态;单条未知类型可报告记录错误,不破坏先前合法输出。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,21 +9,27 @@ import {prepareDuplicateStream} from '../../src/lib/canvas-runtime/duplicate.ts'
|
||||
import {createDuplicatePlanSource} from '../../src/lib/canvas-runtime/duplicate-source.ts';
|
||||
import {copyRegistrySignature} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||||
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.ts';
|
||||
// 内置定义、可覆盖节点工厂和固定信号,准备源使用一致类型 schema。
|
||||
const registry=createRegistry(builtinComponents()),node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:10,y:20,width:100,height:100,rotation:0}}),signal=new AbortController().signal;
|
||||
// 旋转缩放 frame 包含端口和子节点,并有上级入边,覆盖懒映射的几何及引用一致性。
|
||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('C'),{...node('F','P','frame'),data:{title:'F',text:'',ports:[{id:'p',nodeId:'A'}],presentation:{contentScale:2}},geometry:{...node('F').geometry,rotation:30}},node('A','S'),{id:'incoming',incarnation:1,ownerCanvasId:'P',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'C'},target:{elementId:'A'},data:{},path:{type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:40,y:60},in:{x:0,y:0},out:{x:1,y:1}}]}}]);
|
||||
// 以小切片只完成映射准备,绑定来源摘要与当前 registry 签名,停在尚未 emit 的阶段。
|
||||
async function prepare(source=document){let next=0;return prepareDuplicateStream(source,registry,[{id:'F',incarnation:1}],{createId:()=>`copy-${++next}`},{sourceToken:'a'.repeat(64),schemaToken:copyRegistrySignature(source,registry)},{maxStepsPerSlice:2});}
|
||||
|
||||
// 从末记录倒序读取仍逐项匹配同步计划;序列化后的准备状态可重建同一行,越界结束、取消及负游标分别正确处理。
|
||||
test('lazy copy source seeks mapped records with exact synchronous geometry and reference parity',async()=>{
|
||||
const state=await prepare(),source=createDuplicatePlanSource(document,registry,state,'snapshot');let next=0;const plan=planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=>`copy-${++next}`});assert.equal(state.phase,'emit');assert.equal(state.emitted,0);assert.equal(state.emittedEdges,0);
|
||||
for(let i=plan.changes.length-1;i>=0;i--){const line=await source.read(i,signal);assert.deepEqual(line.value.change,plan.changes[i]);assert.equal(line.nextCursor,i+1);assert.deepEqual(await createDuplicatePlanSource(document,registry,JSON.parse(JSON.stringify(state)),'snapshot').read(i,signal),line);}
|
||||
assert.equal(await source.read(plan.changes.length,signal),null);const aborted=new AbortController();aborted.abort();await assert.rejects(source.read(0,aborted.signal),/import_aborted/);await assert.rejects(source.read(-1,signal),/invalid_import_cursor/);
|
||||
});
|
||||
|
||||
// 准备映射时不必转换未知尾节点;懒源先输出合法项,再为 opaque 记录报告 copy_schema_unavailable,而同步全计划会整体拒绝。
|
||||
test('mapping preparation completes before converting unsupported tail records; source reports per-record failure',async()=>{
|
||||
const sourceDocument=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame'),node('good','S'),node('opaque','S','unknown-widget')]),state=await prepare(sourceDocument),source=createDuplicatePlanSource(sourceDocument,registry,state,'snapshot');
|
||||
const lines=[];for(let i=0;;i++){const line=await source.read(i,signal);if(!line)break;lines.push(line);}assert.ok(lines[0].value);assert.ok(lines.some(line=>line.value?.change.after?.data?.title==='good'));assert.equal(lines.at(-1).error,'copy_schema_unavailable');assert.equal(lines.at(-1).key,'element:opaque');assert.throws(()=>planDuplicate(sourceDocument,registry,[{id:'F',incarnation:1}],{createId:()=>crypto.randomUUID()}),/copy_schema_unavailable/);
|
||||
});
|
||||
|
||||
// 运行 job 校验 snapshot SHA、parser 版本、稳定 ID、合法游标及未输出的 plan;任一绑定被篡改即拒绝。
|
||||
test('persisted record cursor source binds snapshot SHA, mapping and interpreter without a second output Blob',async()=>{
|
||||
const state=await prepare(),stableIds=Object.fromEntries([...state.elementIds.map(([id,mapped])=>[`element:${id}`,mapped]),...state.canvasIds.map(([id,mapped])=>[`canvas:${id}`,mapped])]),job={...createImportJob({id:'job',projectId:'project',sourceFingerprint:'snapshot',parserVersion:2,source:{kind:'duplicate-plan',fileId:'file',byteLength:100,sha256:state.sourceToken,targetCanvasId:'P',plan:state,copy:{sourceRevision:1,roots:state.roots.map(id=>stableIds[`element:${id}`])}}}),status:'running',stableIds};validateImportJob(job);
|
||||
for(const patch of [{parserVersion:1},{cursor:100},{stableIds:{}},{source:{...job.source,sha256:'b'.repeat(64)}},{source:{...job.source,plan:{...state,emitted:1}}}])assert.throws(()=>validateImportJob({...job,...patch}));
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
/**
|
||||
* 核心深复制产品规则测试:父子选择去重、分区树与引用映射、上级入边重接到副本,后代世界偏移只应用一次。
|
||||
* 图片引用复用已有资源,执行任务元数据按 schema 省略;普通字面量和未知可保留字段不能被当 ID 随意替换。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,defineComponent,planDuplicate,applyChanges,validateDocument,canvasTransform,toWorld} from '../../src/lib/canvas-core/index.ts';
|
||||
// 第三代文本节点工厂,副本应分配全新 ID/第一代身份;字面量 A 用来检查是否误当引用重写。
|
||||
const node=(id,owner='P',componentId='text')=>({id,incarnation:3,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal A',presentation:{contentScale:1},future:{keep:true}},geometry:{x:10,y:20,width:200,height:100,rotation:0}});
|
||||
// 带端点控制量和结点的贝塞尔路径,跨缩放坐标系复制时需分别核对位置及局部控制量。
|
||||
const path={type:'bezier-chain',startControl:{x:5,y:6},endControl:{x:-7,y:8},knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]};
|
||||
// 第二代连线引用 source/target 元素及局部 portId;复制时元素引用重映射,portId 不改。
|
||||
const edge=(id,source,target,owner='P',curve=null)=>({id,incarnation:2,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},path:curve,data:{title:id}});
|
||||
// 两层 frame 的 contentScale 与图片任务状态样本,分别验证坐标和资源/执行字段策略。
|
||||
const registry=createRegistry(builtinComponents()),F={...node('F','P','frame'),geometry:{...node().geometry,rotation:30},data:{...node().data,title:'F',presentation:{contentScale:2}}},G={...node('G','S','frame'),data:{...node().data,title:'G',presentation:{contentScale:.75}}},A={...node('A','S','image'),data:{title:'A',text:'A',assetId:'shared',src:'asset:shared',runId:'paid-run',taskId:'task',progress:.5,runStatus:'running',custom:'A'}};
|
||||
// 三层分区同时含入边、深层入边、内部边、跨子区边及出边,明确区分哪些默认跟随被复制子树。
|
||||
const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),F,A,G,node('B','T'),node('D','T'),edge('incoming','C','F','P',path),edge('deep','C','B'),edge('internal','B','D','T',path),edge('across','A','B','S'),edge('outgoing','F','C')]);
|
||||
// 从原文档取 incarnation,防止测试用过期身份绕过核心校验。
|
||||
const target=id=>({id,incarnation:document.elements.get(id).incarnation});
|
||||
// 默认同时选择 F 与已包含的 A,使用确定 ID 和世界偏移;extra 可替换映射及分配器测试恢复一致性。
|
||||
const fixture=(selection=['F','A'],extra={})=>{let next=0;return planDuplicate(document,registry,selection.map(target),{createId:()=>`copy-${++next}`,worldOffset:{x:32,y:48},...extra});};
|
||||
// 以 1e-7 容差比较旋转/缩放后的浮点世界坐标。
|
||||
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
|
||||
|
||||
// F/A 重复选择只复制一棵深树;内部引用和 canvas owner 映射,上级 C 保留并接入新分区,默认不复制出边,逐记录前缀都合法。
|
||||
test('deep copy deduplicates ancestor selection, maps the entire tree and preserves upper incoming sources',()=>{
|
||||
const before=JSON.stringify([...document.elements]),plan=fixture(),after=applyChanges(document,plan.changes);
|
||||
assert.deepEqual(plan.roots,[plan.elementIds.F]);assert.equal(Object.keys(plan.canvasIds).length,2);assert.equal(Object.keys(plan.elementIds).length,9);
|
||||
@@ -22,6 +35,7 @@ test('deep copy deduplicates ancestor selection, maps the entire tree and preser
|
||||
let partial=document;for(const change of plan.changes){partial=applyChanges(partial,[change]);validateDocument(partial,registry);}
|
||||
});
|
||||
|
||||
// 旋转/缩放嵌套下各节点和内部曲线只得到一次世界偏移,后代不重复平移;上级入边控制量按对应坐标规则处理。
|
||||
test('geometry uses one world offset across rotated/scaled ownership and does not offset descendants twice',()=>{
|
||||
const plan=fixture(),after=applyChanges(document,plan.changes);
|
||||
for(const id of ['F','A','G','B','D']){
|
||||
@@ -32,11 +46,13 @@ test('geometry uses one world offset across rotated/scaled ownership and does no
|
||||
const incoming=after.elements.get(plan.elementIds.incoming).path;near(incoming.knots[0].position.x,path.knots[0].position.x+16);near(incoming.knots[0].position.y,path.knots[0].position.y+24);assert.deepEqual(incoming.startControl,path.startControl);
|
||||
});
|
||||
|
||||
// 图片副本保留 assetId/src 和普通字面量,省略 runId/taskId/progress/runStatus,避免把已执行任务当新执行工作。
|
||||
test('image copies share resources and omit execution metadata while preserving ordinary literal values',()=>{
|
||||
const plan=fixture(),data=applyChanges(document,plan.changes).elements.get(plan.elementIds.A).data;
|
||||
assert.equal(data.assetId,'shared');assert.equal(data.src,'asset:shared');assert.equal(data.custom,'A');for(const key of ['runId','taskId','progress','runStatus'])assert.equal(Object.hasOwn(data,key),false);
|
||||
});
|
||||
|
||||
// 完整稳定映射生成相同计划且不调用 createId;碰撞 ID、多余映射范围及过期源 incarnation 都拒绝。
|
||||
test('a supplied stable mapping reproduces the same plan without allocating another identity',()=>{
|
||||
const first=fixture();const second=fixture(['F','A'],{mapping:{elements:first.elementIds,canvases:first.canvasIds},createId:()=>{throw Error('new identity allocated');}});assert.deepEqual(second,first);
|
||||
assert.throws(()=>fixture(['F'],{createId:()=> 'C'}),/duplicate_identity/);
|
||||
@@ -44,11 +60,13 @@ test('a supplied stable mapping reproduces the same plan without allocating anot
|
||||
assert.throws(()=>planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=> 'new'}),/stale_incarnation/);
|
||||
});
|
||||
|
||||
// 同时选择上级 C 和入边时,C 也生成副本,入边指向新 C/新 F,且只创建一次连线。
|
||||
test('selecting the upper source too maps one new source and does not duplicate the incoming edge',()=>{
|
||||
const plan=fixture(['F','A','C','incoming']),after=applyChanges(document,plan.changes),incoming=after.elements.get(plan.elementIds.incoming);
|
||||
assert.equal(incoming.source.elementId,plan.elementIds.C);assert.equal(incoming.target.elementId,plan.elementIds.F);assert.equal(plan.changes.filter(change=>change.kind==='element'&&change.after?.id===incoming.id).length,1);
|
||||
});
|
||||
|
||||
// 自定义声明的列表元素引用和 canvas 引用映射到新 ID;execution 按 omit 删除,literal 不变,未选中的 reject 引用报错。
|
||||
test('declared nested/list references are remapped, resources remain shared and only declared transient fields disappear',()=>{
|
||||
const definition=defineComponent({...builtinComponents()[0],id:'linked',copy:{references:[{path:['links'],target:'element',many:true,external:'reject'},{path:['view'],target:'canvas',external:'reject'}],omit:[['execution']]}}),r=createRegistry([...builtinComponents(),definition]),linked={...node('L'),componentId:'linked',data:{title:'L',text:'A',links:['A','B'],view:'S',execution:{taskId:'task'},literal:'A'}},source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),linked]);let next=0;
|
||||
const plan=planDuplicate(source,r,[target('F'),{id:'L',incarnation:3}],{createId:()=>`ref-${++next}`}),copy=applyChanges(source,plan.changes).elements.get(plan.elementIds.L);
|
||||
@@ -56,6 +74,7 @@ test('declared nested/list references are remapped, resources remain shared and
|
||||
assert.throws(()=>planDuplicate(source,r,[{id:'L',incarnation:3}],{createId:()=>`external-${++next}`}),/copy_external_reference/);
|
||||
});
|
||||
|
||||
// 缺复制 schema、未来数据版本、重叠/危险路径及未知曲线都明确拒绝,不能静默省略或猜测转换。
|
||||
test('missing copy schema, unknown versions, ambiguous paths and opaque curves are explicit failures',()=>{
|
||||
const known=defineComponent({...builtinComponents()[0],id:'opaque',copy:undefined}),r=createRegistry([...builtinComponents(),known]),source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),{...node('U'),componentId:'opaque'}]);
|
||||
assert.throws(()=>planDuplicate(source,r,[{id:'U',incarnation:3}],{createId:()=> 'new'}),/copy_schema_unavailable/);
|
||||
@@ -65,6 +84,7 @@ test('missing copy schema, unknown versions, ambiguous paths and opaque curves a
|
||||
const invalid=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='incoming'?{...element,path:{type:'future'}}:element));assert.throws(()=>planDuplicate(invalid,registry,[target('F')],{createId:(()=>{let n=0;return()=>`unknown-${++n}`;})()}),/unsupported_copy_path/);
|
||||
});
|
||||
|
||||
// frame.ports 对象数组只映射 nodeId,局部端口 ID/handle 及原数据不变;错误数组形状和重叠通配路径都拒绝。
|
||||
test('frame port object arrays map child identities but retain local port IDs, handles and the source data',()=>{
|
||||
const ports=[{id:'in',direction:'input',nodeId:'A',handle:'left'},{id:'out',direction:'output',nodeId:'B',handle:'port:nested'}],source=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(record=>record.id==='F'?{...record,data:{...record.data,ports}}:record));let next=0;
|
||||
const plan=planDuplicate(source,registry,[target('F')],{createId:()=>`port-${++next}`}),data=applyChanges(source,plan.changes).elements.get(plan.elementIds.F).data;
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
/**
|
||||
* 持久化元数据订阅测试。durability 的脏状态、错误和关闭进度独立于业务 Root 发布。
|
||||
* 内存 commit 可注入 quota 故障,观察者异常由错误回调接收,不应破坏其他观察者或保存状态。
|
||||
*/
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||
async function fixture(){let broken=false;const errors=[],engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:'p'},registry:createRegistry(builtinComponents()),document:createDocument('p',[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage:{async claim(scope){return{scope,active:true,fence:1,async release(){this.active=false;}};},async recover(){return null;},async commit(_leader,batch){if(broken)throw{code:22,message:'quota'};return{durableRootRevision:batch.image.rootRevision};},async readFile(){return;},close(){}},persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected query');},canonical(){throw Error('unexpected canonical');}},onError:error=>errors.push(error)});await engine.persist();return{engine,errors,fail:value=>broken=value};}
|
||||
// 建立没有业务网络操作的空文档引擎,先保存初始状态;任何意外的编译、发送、查询都会直接失败。
|
||||
async function fixture(){let broken=false;const errors=[],engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:'p'},registry:createRegistry(builtinComponents()),document:createDocument('p',[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage:{
|
||||
// 最小存储 owner 替身;关闭会释放其 active 标记。
|
||||
async claim(scope){return{scope,active:true,fence:1,async release(){this.active=false;}};},
|
||||
// 无历史镜像,保证每个用例从新的持久化元数据起步。
|
||||
async recover(){return null;},
|
||||
// 开关启用时抛出数字 code 的 quota 对象;否则确认此次镜像实际声明的修订。
|
||||
async commit(_leader,batch){if(broken)throw{code:22,message:'quota'};return{durableRootRevision:batch.image.rootRevision};},
|
||||
// 本组不使用本地文件,不额外产生资源任务。
|
||||
async readFile(){return;},close(){}},
|
||||
// 全部网络入口都故意抛错,保证测试只观察本地元数据而不是悄悄依赖网络同步。
|
||||
persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected query');},canonical(){throw Error('unexpected canonical');}},onError:error=>errors.push(error)});await engine.persist();return{engine,errors,fail:value=>broken=value};}
|
||||
// runtime 修改先通知脏修订,保存后通知对应持久化修订;关闭也通知进度,但完成保存不再增加一次业务 Root。关闭后订阅为空清理函数。
|
||||
test('durability subscribers observe dirty metadata, final durable cut and closure without another business Root',async()=>{const{engine}=await fixture(),states=[];const stop=engine.subscribeDurability(()=>states.push(engine.getDurability()));assert.strictEqual(engine.getDurability(),engine.getDurability());engine.editor.updateRuntime(previous=>({...previous}));const revision=engine.getSnapshot().rootRevision;assert.ok(states.some(state=>state.rootRevision>state.durableRootRevision));await engine.persist();assert.equal(states.at(-1).durableRootRevision,revision);assert.equal(engine.getSnapshot().rootRevision,revision);await engine.close();assert.ok(states.some(state=>state.closing&&!state.closed));assert.equal(states.at(-1).closed,true);stop();const noop=engine.subscribeDurability(()=>assert.fail('closed owner notified'));noop();});
|
||||
// 一个监听器抛错不妨碍其他监听器看到 quota;数字 DOM 风格错误归一成字符串,保存失败保持脏状态,重试成功后清除错误。
|
||||
test('storage failures remain observable and normalize numeric DOM-style codes without listener exceptions changing durability',async()=>{const f=await fixture();const states=[];f.engine.subscribeDurability(()=>{throw Error('observer');});f.engine.subscribeDurability(()=>states.push(f.engine.getDurability()));f.fail(true);f.engine.editor.updateRuntime(previous=>({...previous}));await assert.rejects(f.engine.persist());assert.equal(states.at(-1).error,'quota');assert.equal(typeof states.at(-1).error,'string');assert.ok(states.at(-1).durableRootRevision<states.at(-1).rootRevision);f.fail(false);await f.engine.persist();assert.equal(states.at(-1).error,undefined);assert.equal(states.at(-1).rootRevision,states.at(-1).durableRootRevision);assert.ok(f.errors.some(error=>error.message==='observer'));await f.engine.close();});
|
||||
|
||||
@@ -1,48 +1,69 @@
|
||||
/**
|
||||
* ProjectEngine 的组合状态测试:文档、history、操作账本、ACK 基线由统一发布边界协调。
|
||||
* 使用内存存储和持久化端口,验证保存顺序、未知结果、恢复会话、本地抵消与回执压缩;不等同于真实磁盘或服务端集成。
|
||||
* Root 是不可变业务切点,durability 另行记录已确认保存到哪里;可见修改不等于安全离开。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDocument, applyChanges, builtinComponents, createRegistry } from '../../src/lib/canvas-core/index.ts';
|
||||
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import { encodeRecovery, decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
|
||||
// 内置类型参与全部模型校验,避免测试通过空注册表绕开数据约束。
|
||||
const registry = createRegistry(builtinComponents());
|
||||
// 固定单 canvas、单文本元素的初始模型;后续场景只改变所需字段或生命周期。
|
||||
const original = createDocument('project', [{ id: 'canvas', parent: null }], [{ id: 'a', incarnation: 1, structure: 'node', ownerCanvasId: 'canvas', componentId: 'text', dataVersion: 1, data: { title: 'a', text: '' }, geometry: { x: 0, y: 0, width: 100, height: 100, rotation: 0 } }]);
|
||||
// 存储恢复作用域包含账户、workspace 和 project;目标引用还带 incarnation。
|
||||
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' }, target = { id: 'a', incarnation: 1 };
|
||||
// 用手动完成的 Promise 精确控制网络、编码或写盘何时越过屏障。
|
||||
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
||||
// 推进有限轮微任务;适用于保留在途请求而又要等调度器进入挂起点的测试。
|
||||
const tick = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
||||
// 内存 storage 持有独占 owner 和递增 fence;每次 commit 保存克隆镜像,并要求修订连续或重试同一修订。
|
||||
function storageFixture() {
|
||||
let image = null, owner = null, fence = 0;
|
||||
const saved = [], fixture = {
|
||||
broken: false, saved,
|
||||
// 同一时刻只允许一个 owner;释放后再申请会获得新 fence,供重开测试辨别新存储所有者。
|
||||
async claim(scope) { assert.equal(owner, null); const leader = { scope, fence: ++fence, active: true, async release() { owner = null; leader.active = false; } }; owner = leader; return leader; },
|
||||
// 恢复返回镜像副本,调用方无法修改上一次已保存的对象。
|
||||
async recover() { return image && structuredClone(image); },
|
||||
// 先检查故障和当前 owner,再保存对应 Root;返回的 durableRootRevision 是引擎推进确认边界的证据。
|
||||
async commit(leader, batch) { if (fixture.broken) throw new Error('quota'); assert.equal(owner, leader); assert.equal(leader.active, true); if (image) assert.ok(batch.image.rootRevision === image.rootRevision + 1 || batch.image.rootRevision === image.rootRevision); image = structuredClone(batch.image); saved.push(image); return { durableRootRevision: image.rootRevision }; },
|
||||
async readFile() {}, close() {},
|
||||
image: () => structuredClone(image), owner: () => owner,
|
||||
};
|
||||
return fixture;
|
||||
}
|
||||
// 服务替身按请求 ID 去重,只在首次 submit 应用修改;query 查询既有结果,overrides 可替换任一协议边界。
|
||||
function persistenceFixture(overrides = {}) {
|
||||
let server = original, version = 1;
|
||||
const calls = [], receipts = new Map();
|
||||
const fixture = {
|
||||
calls, receipts,
|
||||
// 把逻辑意图与当前参与版本冻结进 payload;测试编译器不执行真实 Foundry wire 格式压缩。
|
||||
compile(intent, requestId, snapshot) { return { payload: { requestId, intent }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(intent.canvasIds.map(id => [id, snapshot.runtime.versions.get(id)])) }; },
|
||||
// 首次写入产生服务端快照并递增版本;重复同 ID 只返回该回执,不重复修改。
|
||||
async submit(request) {
|
||||
calls.push(['submit', request]);
|
||||
if (!receipts.has(request.id)) { const { intent } = JSON.parse(request.body); server = applyChanges(server, intent.changes); version++; receipts.set(request.id, { document: server, versions: new Map([['canvas', version]]) }); }
|
||||
return { outcome: 'applied', canonical: { receipt: request.id } };
|
||||
},
|
||||
// 记录查询并区分已知成功与 unresolved;没有回执不提供零写入保证。
|
||||
async query(request) { calls.push(['query', request]); return receipts.has(request.id) ? { outcome: 'applied', canonical: { receipt: request.id } } : { outcome: 'unresolved' }; },
|
||||
// 由已保存回执取规范快照;可在个别测试中替换为冲突数据,检查重放失败边界。
|
||||
canonical(request) { return receipts.get(request.id); }, ...overrides,
|
||||
};
|
||||
return fixture;
|
||||
}
|
||||
// 统一装配 engine 并收集异步错误;extra 可覆盖时钟、ID、文档、registry 等默认装配选项。
|
||||
async function open(storage, persistence, extra = {}) {
|
||||
let next = 0; const errors = [];
|
||||
const engine = await openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, persistence, createId: () => `identity-${++next}`, onError: error => errors.push(error), ...extra });
|
||||
return { engine, errors };
|
||||
}
|
||||
|
||||
// 本地命令一次发布文档、历史和账本;首个请求在途时继续移动,ACK 后基于新版本重放移动,最终两次修改都持久化。
|
||||
test('local document/history/ledger publish together; ACK replays edits made while a request is in flight', async () => {
|
||||
const storage = storageFixture(), port = persistenceFixture(), gate = deferred(), realSubmit = port.submit;
|
||||
port.submit = async request => { if (!port.calls.length) await gate.promise; return realSubmit(request); };
|
||||
@@ -67,6 +88,7 @@ test('local document/history/ledger publish together; ACK replays edits made whi
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// detach 只关闭视图,后台请求继续;引擎关闭再打开后恢复历史并查询原未知请求,ID 和冻结字节保持不变。
|
||||
test('closing a view keeps jobs alive; closing/reopening the engine restores history and queries the original unknown request', async () => {
|
||||
const storage = storageFixture(), port = persistenceFixture(), realSubmit = port.submit;
|
||||
port.submit = async request => { await realSubmit(request); return { outcome: 'unresolved' }; };
|
||||
@@ -86,6 +108,7 @@ test('closing a view keeps jobs alive; closing/reopening the engine restores his
|
||||
await recovered.engine.close();
|
||||
});
|
||||
|
||||
// 创建实际已执行但 ACK 丢失,随后删除仍依赖原创建;恢复先查询再发送独立删除,最终服务端只留墓碑而无幽灵元素。
|
||||
test('create applied with a lost ACK followed by delete recovers the original request and leaves no server element',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture(),submit=port.submit;let lost=true,now=1000,next=0;
|
||||
port.submit=async request=>{const status=await submit(request);if(lost){lost=false;return{outcome:'unresolved'};}return status;};
|
||||
@@ -101,6 +124,7 @@ test('create applied with a lost ACK followed by delete recovers the original re
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// quota 失败保留可见编辑但阻止网络;失败 close 保留存储 owner 并封闭编辑入口,存储恢复后重试 close 才释放。
|
||||
test('failed local durability preserves editing, prevents sends, and failed close retains the recovery leader', async () => {
|
||||
const storage = storageFixture(), port = persistenceFixture(), { engine, errors } = await open(storage, port);
|
||||
await engine.persist(); storage.broken = true;
|
||||
@@ -112,6 +136,7 @@ test('failed local durability preserves editing, prevents sends, and failed clos
|
||||
storage.broken = false; await engine.close(); assert.equal(storage.owner(), null);
|
||||
});
|
||||
|
||||
// 已知成功回执与后续几何重放冲突时保留本地内容、原始回执和 waiting 占用,不发送下一请求。
|
||||
test('canonical replay conflict preserves working content and the known receipt without releasing the writer', async () => {
|
||||
const storage = storageFixture(), port = persistenceFixture(), gate = deferred(), realSubmit = port.submit;
|
||||
port.submit = async request => { await gate.promise; return realSubmit(request); };
|
||||
@@ -129,6 +154,7 @@ test('canonical replay conflict preserves working content and the known receipt
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 编码解码恢复只读集合及历史;缺失历史引用和自依赖均被拒绝,另一个镜像副本的修改不改变已恢复内容。
|
||||
test('recovery codec restores immutable maps and rejects broken history/dependency references', async () => {
|
||||
const storage = storageFixture(), port = persistenceFixture(), { engine } = await open(storage, port);
|
||||
engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'encoded' } }); await engine.settle();
|
||||
@@ -143,6 +169,7 @@ test('recovery codec restores immutable maps and rejects broken history/dependen
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 无未决写入时可接纳更新规范基线并保留历史;旧版本刷新被拒绝,不回退新几何。
|
||||
test('a fresh canonical read replaces a clean recovered base, keeps history and refuses older versions', async () => {
|
||||
const storage = storageFixture(), port = persistenceFixture(), first = await open(storage, port);
|
||||
first.engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'local' } }); await first.engine.settle(); await first.engine.close();
|
||||
@@ -156,6 +183,7 @@ test('a fresh canonical read replaces a clean recovered base, keeps history and
|
||||
await reopened.engine.close();
|
||||
});
|
||||
|
||||
// 恢复先等已入场请求完成再读取完整基线;准备期间封闭编辑、生成和刷新,预览不发布,apply 才发布并落盘且不改原历史引用。
|
||||
test('recovery waits for admitted writes, reads a complete base, fences edits and publishes without changing history', {timeout:5000}, async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture(),response=deferred(),realSubmit=port.submit,readGate=deferred(),readEntered=deferred();
|
||||
let reads=0;port.submit=async request=>{await response.promise;return realSubmit(request);};
|
||||
@@ -173,6 +201,7 @@ test('recovery waits for admitted writes, reads a complete base, fences edits an
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 未知回执不能靠全量读跳过确认;prepareRecovery 失败后保留原请求与本地状态,并解除临时编辑屏障。
|
||||
test('unknown receipts prevent a recovery read and preserve the frozen request identity',async()=>{
|
||||
const storage=storageFixture();let reads=0;const port=persistenceFixture({async submit(){return{outcome:'unresolved'};},async read(){reads++;return{document:original,versions:new Map([['canvas',1]])};}});
|
||||
const{engine}=await open(storage,port);engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();
|
||||
@@ -182,6 +211,7 @@ test('unknown receipts prevent a recovery read and preserve the frozen request i
|
||||
assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'allowed'}}).enabled,true);await engine.close();
|
||||
});
|
||||
|
||||
// 冲突预览不覆盖本地内容或历史,直接 apply 被拒绝;重复 cancel 安全,取消后恢复普通编辑。
|
||||
test('recovery conflict previews preserve local data/history and cancel releases the edit fence',async()=>{
|
||||
const remote=applyChanges(original,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);
|
||||
const storage=storageFixture(),port=persistenceFixture({async submit(){return{outcome:'not-applied',error:'version_conflict'};},async read(){return{document:remote,versions:new Map([['canvas',2]])};}});
|
||||
@@ -191,6 +221,7 @@ test('recovery conflict previews preserve local data/history and cancel releases
|
||||
await session.cancel();await session.cancel();assert.equal(engine.editor.can({type:'element.data',target,patch:{title:'more'}}).enabled,true);await engine.close();
|
||||
});
|
||||
|
||||
// 恢复 Root 已发布但保存失败时保留编辑屏障;重试保存同一 Root,不再创建一次恢复修订。
|
||||
test('failed recovery durability retains its fence and retries the same published root',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture({async read(){return{document:original,versions:new Map([['canvas',1]])};}}),{engine}=await open(storage,port);
|
||||
const session=await engine.prepareRecovery();storage.broken=true;await assert.rejects(session.apply(),/quota/);const revision=engine.getSnapshot().rootRevision;
|
||||
@@ -199,6 +230,7 @@ test('failed recovery durability retains its fence and retries the same publishe
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 关闭引擎 abort 恢复读取并释放 owner;端口仍返回迟到结果也必须拒绝,不能创建恢复会话。
|
||||
test('closing the engine aborts a recovery read and rejects a late canonical response',async()=>{
|
||||
const storage=storageFixture(),response=deferred();let signal;
|
||||
const port=persistenceFixture({async read(input){signal=input;return response.promise;}}),{engine}=await open(storage,port);
|
||||
@@ -206,6 +238,7 @@ test('closing the engine aborts a recovery read and rejects a late canonical res
|
||||
response.resolve({document:original,versions:new Map([['canvas',1]])});await assert.rejects(preparing,/engine_closing/);assert.equal(storage.owner(),null);
|
||||
});
|
||||
|
||||
// 恢复读和普通 refresh 都拒绝同版本内容改写;另外直接检查墓碑回退与生命周期变化,不凭版本增加就接受旧生命期。
|
||||
test('complete recovery reads reject same-version rewrites and lifecycle regression before creating a session',async()=>{
|
||||
const remote=applyChanges(original,[{kind:'field',target,field:'data.title',before:'a',after:'remote',beforePresent:true,afterPresent:true}]);
|
||||
const storage=storageFixture(),port=persistenceFixture({async read(){return{document:remote,versions:new Map([['canvas',1]])};}}),{engine}=await open(storage,port);
|
||||
@@ -220,6 +253,7 @@ test('complete recovery reads reject same-version rewrites and lifecycle regress
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 同一事务先创建再删除,basePresence 必须保留最初 absent;本地留下墓碑但无需发送,也不能伪造 ACK 墓碑。
|
||||
test('one transaction captures the first presence baseline even when its created element is later deleted', async () => {
|
||||
const storage = storageFixture(), port = persistenceFixture(), { engine } = await open(storage, port);
|
||||
try {
|
||||
@@ -239,6 +273,7 @@ test('one transaction captures the first presence baseline even when its created
|
||||
});
|
||||
|
||||
|
||||
// 完全未发送的创建删除在本地抵消,仍保留两个历史单元和墓碑;无关 ACK 与重开不丢它,撤销恢复为新 incarnation。
|
||||
test('unsent create/delete retires locally, survives unrelated ACK and reopening, and undo restores a fresh incarnation', async () => {
|
||||
const storage = storageFixture(), port = persistenceFixture(); let next=0, engine;
|
||||
const reopen=async()=>{if(engine)await engine.close();engine=(await open(storage,port,{createId:()=>`elision-${++next}`})).engine;};
|
||||
@@ -260,6 +295,7 @@ test('unsent create/delete retires locally, survives unrelated ACK and reopening
|
||||
});
|
||||
|
||||
|
||||
// 本地抵消证据、工作墓碑和历史内容必须相互匹配;伪造或缺失会拒绝,未来解释版本单独报告不可解释。
|
||||
test('recovery rejects forged or unsupported local completion proofs and missing working lifecycle marks', async () => {
|
||||
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port);
|
||||
try {
|
||||
@@ -277,6 +313,7 @@ test('recovery rejects forged or unsupported local completion proofs and missing
|
||||
});
|
||||
|
||||
|
||||
// 保存失败的本地抵消只在内存成立,不虚构远程回执;重试保存并重开后仍使用原抵消证据且无网络请求。
|
||||
test('failed durability keeps a local elision in memory without inventing a network success, then persists the same proof', async () => {
|
||||
const storage=storageFixture(),port=persistenceFixture();let engine=(await open(storage,port)).engine;
|
||||
try{
|
||||
@@ -290,6 +327,7 @@ test('failed durability keeps a local elision in memory without inventing a netw
|
||||
});
|
||||
|
||||
|
||||
// 反复未发送的 undo/redo 仍可本地抵消并保留历次墓碑;首次真正发布恢复时使用第四代,跳过已取消代次。
|
||||
test('repeated unsent undo/redo pairs remain local and the first published restore skips all canceled incarnations',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture();let next=0,engine=(await open(storage,port,{createId:()=>`repeat-${++next}`})).engine;
|
||||
try{
|
||||
@@ -303,6 +341,7 @@ test('repeated unsent undo/redo pairs remain local and the first published resto
|
||||
});
|
||||
|
||||
|
||||
// 即使端口支持批量编译,也先抵消后面的闭合创建删除;仅发送前面真实编辑,批量编译器若收到闭合生命期就使测试失败。
|
||||
test('batch-capable persistence sends an earlier real edit separately and never freezes the later closed lifetime',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture({compileBatch(){throw Error('closed lifetime entered the wire batch');}}),{engine,errors}=await open(storage,port);
|
||||
try{
|
||||
@@ -312,73 +351,96 @@ test('batch-capable persistence sends an earlier real edit separately and never
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 不主动触发重试的假时钟;定时器仅返回 token,使持久化竞争用例集中观察显式保存和在途网络。
|
||||
const idleClock=()=>({now:()=>0,setTimer:()=>Symbol('idle'),clearTimer(){}});
|
||||
// 八次编辑各保留业务 Root,保存只按确认前缀发布 durableGeneration 回声;派发前镜像必须已含 dispatch-marked 请求和原体。
|
||||
test('a pending write train preserves every business root and publishes one durable-generation echo per captured prefix',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;let sends=0;port.submit=async request=>{sends++;const stored=storage.image();assert.ok(stored.payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));await network.promise;return{outcome:'unresolved'};};try{
|
||||
await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;let held=false,markers=[],through=start.runtime.ledger.durableGeneration;const unsubscribe=engine.editor.subscribe((_,root)=>{if(root.runtime.ledger.durableGeneration>through){markers.push(root.runtime.ledger.durableGeneration);through=root.runtime.ledger.durableGeneration;}});
|
||||
storage.commit=async(...args)=>{if(!held){held=true;entered.resolve();await gate.promise;}return commit(...args);};
|
||||
for(let i=0;i<8;i++)assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'train-'+i}}).status,'applied');
|
||||
|
||||
// 保存队列挂起时记录调用方目标 generation;调度器可能再发布冻结请求,因此还要核对后续捕获前缀的确认回声。
|
||||
const requested=engine.getSnapshot().runtime.ledger.generation,saving=engine.persist();await entered.promise;await tick();const expected=engine.getSnapshot().runtime.ledger.generation,queued=engine.getSnapshot().rootRevision-start.rootRevision;assert.equal(sends,0);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,start.runtime.ledger.durableGeneration);gate.resolve();await saving;unsubscribe();
|
||||
assert.deepEqual(markers,[...new Set([requested,expected])]);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,expected);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);const rows=storage.saved.slice(saved);assert.equal(rows.length,queued+markers.length);assert.deepEqual(rows.map(image=>image.rootRevision),Array.from({length:queued+markers.length},(_,i)=>start.rootRevision+i+1));assert.deepEqual(rows.slice(0,8).map(image=>image.payload.document.elements[0].data.title),Array.from({length:8},(_,i)=>'train-'+i));assert.equal(decodeRecovery(storage.image(),registry).runtime.ledger.durableGeneration,expected);await tick();assert.equal(sends,1);
|
||||
}finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();}
|
||||
});
|
||||
// 连续保存第四次失败时只确认前三个切点,内存仍显示最后编辑;重试从完整未确认后缀继续,修订不得跳号且不能提前发送。
|
||||
test('a later quota failure records only confirmed generation and retries the full continuous suffix',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;let sends=0;port.submit=async request=>{sends++;const stored=storage.image();assert.ok(stored.payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));await network.promise;return{outcome:'unresolved'};};try{
|
||||
await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;let writes=0,broken=true;storage.commit=async(...args)=>{writes++;if(writes===1){entered.resolve();await gate.promise;}if(broken&&writes===4)throw Error('quota-later');return commit(...args);};
|
||||
for(let i=0;i<8;i++)engine.editor.dispatch({type:'element.data',target,patch:{title:'partial-'+i}});const saving=engine.persist();await entered.promise;await tick();const expected=engine.getSnapshot().runtime.ledger.generation,queued=engine.getSnapshot().rootRevision-start.rootRevision;assert.equal(sends,0);gate.resolve();await assert.rejects(saving,/quota-later/);const confirmed=storage.saved.at(-1);assert.equal(storage.saved.length-saved,3);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,confirmed.payload.runtime.ledger.generation);assert.ok(engine.getSnapshot().runtime.ledger.durableGeneration<expected);assert.equal(engine.getDurability().durableRootRevision,confirmed.rootRevision);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'partial-7');assert.equal(sends,0);
|
||||
|
||||
// 失败前缀已被确认,重试只补连续后缀和确认 Root;不能因内存已显示最后编辑就跳过缺失修订。
|
||||
broken=false;await engine.persist();assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,expected);const rows=storage.saved.slice(saved);assert.deepEqual(rows.map(image=>image.rootRevision),Array.from({length:rows.length},(_,i)=>start.rootRevision+i+1));assert.equal(decodeRecovery(storage.image(),registry).document.elements.get('a').data.title,'partial-7');assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);
|
||||
}finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();}
|
||||
});
|
||||
// commit 返回超前修订是无效证据,即使内存存储已经写入也不能推进确认;重试必须提交与原切点完全相同的镜像。
|
||||
test('an invalid receipt cannot advance confirmed generation and the original cut remains retryable',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;let bad=true,held=false,sends=0;port.submit=async()=>{sends++;await network.promise;return{outcome:'unresolved'};};try{
|
||||
await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;storage.commit=async(...args)=>{if(!held){held=true;entered.resolve();await gate.promise;}const receipt=await commit(...args);return bad?{durableRootRevision:receipt.durableRootRevision+1}:receipt;};engine.editor.dispatch({type:'element.data',target,patch:{title:'receipt-test'}});const saving=engine.persist();await entered.promise;await tick();gate.resolve();await assert.rejects(saving,/invalid_durable_receipt/);assert.equal(engine.getDurability().durableRootRevision,start.rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,start.runtime.ledger.durableGeneration);assert.equal(sends,0);const first=storage.saved[saved];bad=false;await engine.persist();const retry=storage.saved[saved+1];assert.deepEqual(retry,first);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,engine.getSnapshot().runtime.ledger.generation);
|
||||
await engine.persist();const start=engine.getSnapshot(),saved=storage.saved.length;storage.commit=async(...args)=>{if(!held){held=true;entered.resolve();await gate.promise;}const receipt=await commit(...args);return bad?{durableRootRevision:receipt.durableRootRevision+1}:receipt;};engine.editor.dispatch({type:'element.data',target,patch:{title:'receipt-test'}});const saving=engine.persist();await entered.promise;await tick();gate.resolve();await assert.rejects(saving,/invalid_durable_receipt/);assert.equal(engine.getDurability().durableRootRevision,start.rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,start.runtime.ledger.durableGeneration);assert.equal(sends,0);
|
||||
// 底层实际写入与上层认可证据分开:坏回执未获认可,下一次必须重试同一镜像,而不是假设可以跳到下个 Root。
|
||||
const first=storage.saved[saved];bad=false;await engine.persist();const retry=storage.saved[saved+1];assert.deepEqual(retry,first);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,engine.getSnapshot().runtime.ledger.generation);
|
||||
}finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();}
|
||||
});
|
||||
// 保存成功后因 owner 失效而无法发布确认时保留已验证回执;恢复 owner 后只补确认 Root,监听器重入 persist 也不能并发写盘或重写旧切点。
|
||||
test('a failed durability publication retains its verified receipt through retry without rewriting saved business cuts',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),network=deferred(),commit=storage.commit;let lose=true;port.submit=async()=>{await network.promise;return{outcome:'unresolved'};};try{
|
||||
await engine.persist();const leader=storage.owner(),saved=storage.saved.length;storage.commit=async(...args)=>{const receipt=await commit(...args);if(lose&&args[1].image.rootRevision===engine.getSnapshot().rootRevision){lose=false;leader.active=false;}return receipt;};engine.editor.dispatch({type:'element.data',target,patch:{title:'publication-retry'}});await assert.rejects(engine.persist(),/storage_leader_lost/);const confirmed=storage.saved.at(-1);assert.equal(engine.getDurability().durableRootRevision,confirmed.rootRevision);assert.ok(confirmed.payload.runtime.ledger.generation>engine.getSnapshot().runtime.ledger.durableGeneration);const count=storage.saved.length;leader.active=true;let activeWrites=0,maxWrites=0;const nested=[];storage.commit=async(...args)=>{activeWrites++;maxWrites=Math.max(maxWrites,activeWrites);try{await tick();return await commit(...args);}finally{activeWrites--;}};const unsubscribe=engine.editor.subscribe((_,root)=>{if(root.runtime.ledger.durableGeneration===confirmed.payload.runtime.ledger.generation)nested.push(engine.persist());});await engine.persist();await Promise.all(nested);unsubscribe();assert.equal(maxWrites,1);assert.equal(storage.saved.length,count+1);assert.equal(storage.saved.at(-1).rootRevision,confirmed.rootRevision+1);assert.equal(engine.getSnapshot().runtime.ledger.durableGeneration,confirmed.payload.runtime.ledger.generation);assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);assert.ok(storage.saved.length>saved);
|
||||
}finally{const leader=storage.owner();if(leader)leader.active=true;network.resolve();storage.commit=commit;await engine.close();}
|
||||
});
|
||||
// 空写入泵刚排空后再产生编辑,新的 persist 调用仍必须等自己的目标 Root,不能复用旧的已完成等待。
|
||||
test('a caller arriving after an empty writer drain still waits for its own newly published Root', {timeout:5000},async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),gate=deferred(),entered=deferred(),network=deferred(),commit=storage.commit;port.submit=async()=>{await network.promise;return{outcome:'unresolved'};};try{
|
||||
await engine.persist();storage.commit=async(...args)=>{entered.resolve();await gate.promise;return commit(...args);};const empty=engine.persist();let edited,required;await new Promise(resolve=>queueMicrotask(()=>{engine.editor.dispatch({type:'element.data',target,patch:{title:'after-empty-writer'}});required=engine.getSnapshot().rootRevision;edited=engine.persist();resolve();}));assert.equal(await Promise.race([entered.promise.then(()=>'write'),edited.then(()=>'returned')]),'write');assert.ok(engine.getDurability().durableRootRevision<required);gate.resolve();await Promise.all([empty,edited]);assert.ok(engine.getDurability().durableRootRevision>=required);assert.equal(storage.image().payload.document.elements[0].data.title,'after-empty-writer');
|
||||
}finally{gate.resolve();network.resolve();storage.commit=commit;await engine.close();}
|
||||
});
|
||||
// 已保存的 dispatch 前缀可以发送,无需等待更晚的本地 Root;但显式 persist 仍等待它捕获的目标修订全部保存。
|
||||
test('a durable dispatch prefix can send while a later local Root is still waiting for storage', {timeout:10000},async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture(),{engine}=await open(storage,port,{clock:idleClock()}),body=deferred(),bodyEntered=deferred(),later=deferred(),laterEntered=deferred(),sent=deferred(),network=deferred(),commit=storage.commit;let bodyCut,after=Infinity,flushed=false;port.submit=async request=>{assert.ok(storage.image().payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));sent.resolve();await network.promise;return{outcome:'unresolved'};};try{
|
||||
await engine.persist();storage.commit=async(...args)=>{const image=args[1].image;if(bodyCut===undefined&&image.payload.runtime.ledger.requests.some(([,row])=>row.phase==='dispatch-marked')){bodyCut=image.rootRevision;bodyEntered.resolve();await body.promise;}if(image.rootRevision>after){laterEntered.resolve();await later.promise;}return commit(...args);};engine.editor.dispatch({type:'element.data',target,patch:{title:'first'}});const saving=engine.persist().then(()=>{flushed=true;});await bodyEntered.promise;after=engine.getSnapshot().rootRevision;engine.editor.dispatch({type:'element.geometry',target,patch:{x:42}});body.resolve();await laterEntered.promise;await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(Error('durable request waited for a later Root')),2000);sent.promise.then(()=>{clearTimeout(timer);resolve();},reject);});assert.equal(flushed,false);assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,42);later.resolve();await saving;assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision);
|
||||
}finally{body.resolve();later.resolve();network.resolve();storage.commit=commit;await engine.close();}
|
||||
});
|
||||
|
||||
// 可选成功回执摘要必须与 ACK、history、ledger 一起发布;每个可恢复切点保持一致,重开不重传且冻结 body 不变。
|
||||
test('an opted-in resolved receipt summary publishes with ACK/history/ledger and reopens without retransmission',async()=>{
|
||||
const storage=storageFixture(),port=persistenceFixture({compactResolvedReceipt(request,receipt,applied){assert.equal(request.phase,'resolved');assert.equal(request.outcome,'applied');assert.equal(receipt.receipt,request.id);assert.equal(applied.document.elements.get('a').data.title,'summary');return{localReceiptVersion:1,id:request.id,versions:[...applied.versions]};}});let{engine}=await open(storage,port);
|
||||
try{engine.editor.dispatch({type:'element.data',target,patch:{title:'summary'}});await engine.settle();const snapshot=engine.getSnapshot(),request=[...snapshot.runtime.ledger.requests.values()][0],body=request.body;assert.equal(request.phase,'resolved');assert.deepEqual(snapshot.runtime.receipts.get(request.id),{localReceiptVersion:1,id:request.id,versions:[['canvas',2]]});assert.equal(snapshot.history.undo.length,1);for(const image of storage.saved){const restored=decodeRecovery(image,registry),saved=restored.runtime.ledger.requests.get(request.id);if(restored.runtime.receipts.get(request.id)?.localReceiptVersion===1){assert.equal(saved.phase,'resolved');assert.equal(restored.document.elements.get('a').data.title,'summary');assert.equal(restored.history.undo.length,1);}if(saved)assert.equal(saved.body,body);}await engine.close();({engine}=await open(storage,port));await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,body);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'summary');assert.equal(port.calls.filter(([kind])=>kind==='submit').length,1);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 摘要准备后最终模型校验失败,必须保留原始成功回执和 waiting 占用;校验恢复后查询修复,不第二次 submit。
|
||||
test('a failed final publication keeps the raw receipt and waiting claim instead of the prepared summary',async()=>{
|
||||
let blocked=false,compactions=0;const custom=createRegistry(builtinComponents().map(definition=>definition.id==='text'?{...definition,validate:data=>typeof data.title==='string'&&typeof data.text==='string'&&(!blocked||data.title!=='local')}:definition)),storage=storageFixture(),port=persistenceFixture({compactResolvedReceipt(request){compactions++;if(compactions===1)blocked=true;return{summary:request.id};}});let{engine}=await open(storage,port,{registry:custom});
|
||||
try{engine.editor.dispatch({type:'element.data',target,patch:{title:'local'}});await engine.settle();const snapshot=engine.getSnapshot(),request=[...snapshot.runtime.ledger.requests.values()][0];assert.equal(compactions,1);assert.equal(request.outcome,'applied');assert.equal(request.phase,'waiting');assert.deepEqual(snapshot.runtime.receipts.get(request.id),{receipt:request.id});assert.equal(snapshot.document.elements.get('a').data.title,'local');assert.equal(snapshot.history.undo.length,1);blocked=false;await engine.close();({engine}=await open(storage,port,{registry:custom}));await engine.retry();await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.deepEqual(engine.getSnapshot().runtime.receipts.get(request.id),{summary:request.id});assert.equal(port.calls.filter(([kind])=>kind==='submit').length,1);assert.ok(port.calls.some(([kind])=>kind==='query'));
|
||||
}finally{blocked=false;await engine.close();}
|
||||
});
|
||||
// 未提供、抛错或返回空值的压缩器不影响合法 ACK;保留原始回执,只有失败压缩器通过错误回调报告。
|
||||
test('receipt compaction is optional and invalid codecs cannot block a valid ACK or discard its raw payload',async()=>{
|
||||
for(const compactResolvedReceipt of [undefined,()=>{throw Error('compact failed');},()=>undefined]){const storage=storageFixture(),port=persistenceFixture(compactResolvedReceipt?{compactResolvedReceipt}:{}),{engine,errors}=await open(storage,port);try{engine.editor.dispatch({type:'element.data',target,patch:{title:'keep raw'}});await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.phase,'resolved');assert.deepEqual(engine.getSnapshot().runtime.receipts.get(request.id),{receipt:request.id});assert.equal(engine.getSnapshot().runtime.conflicts.size,0);assert.equal(errors.length,compactResolvedReceipt?1:0);}finally{await engine.close();}}
|
||||
});
|
||||
|
||||
// 大模型编码在 scheduler.yield 挂起期间继续编辑,首个镜像仍对应捕获的旧 Root;随后镜像按修订连续保存,冻结请求未落盘前不发送。
|
||||
test('encoding yields preserve the captured Root, ordered durable prefix and send barrier', {timeout:15000},async()=>{
|
||||
const previous=Object.getOwnPropertyDescriptor(globalThis,'scheduler'),entered=deferred(),resume=deferred();let hold=false,paused=false,yields=0;
|
||||
Object.defineProperty(globalThis,'scheduler',{configurable:true,value:{async yield(){yields++;if(hold&&!paused){paused=true;entered.resolve();await resume.promise;}}}});
|
||||
let engine;
|
||||
try{
|
||||
|
||||
// 超过编码让出阈值的大模型;每个记录合法且 ID 独立,避免用编码失败替代调度竞争。
|
||||
const rows=Array.from({length:17000},(_,i)=>({...original.elements.get('a'),id:i===0?'a':`encoding-${i}`}));
|
||||
const document=createDocument('project',[{id:'canvas',parent:null}],rows),storage=storageFixture(),port=persistenceFixture({async submit(request){port.calls.push(['submit',request]);const stored=storage.image();assert.ok(stored.payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));return{outcome:'unresolved'};}});
|
||||
({engine}=await open(storage,port,{document,clock:idleClock()}));await engine.persist();
|
||||
const start=engine.getSnapshot().rootRevision,saved=storage.saved.length;hold=true;
|
||||
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'captured-first'}}).status,'applied');
|
||||
|
||||
// 首个 persist 捕获旧 Root 后暂停编码,再允许第二次编辑产生新 Root;两次保存必须各自对应其捕获内容。
|
||||
const captured=engine.getSnapshot(),first=engine.persist();await entered.promise;
|
||||
assert.equal(storage.saved.length,saved);assert.equal(port.calls.length,0);
|
||||
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'edited-during-yield'}}).status,'applied');
|
||||
const second=engine.persist();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'edited-during-yield');
|
||||
resume.resolve();await Promise.all([first,second]);
|
||||
|
||||
// 比较完整首镜像与同步 encodeRecovery(captured),验证让出主线程后没有混入后来编辑;同时检查修订序列无空洞。
|
||||
const cuts=storage.saved.slice(saved);assert.ok(yields>0);
|
||||
assert.deepEqual(cuts.map(cut=>cut.rootRevision),Array.from({length:cuts.length},(_,i)=>start+i+1));
|
||||
assert.deepEqual(cuts[0],encodeRecovery(captured));
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
/**
|
||||
* 画布入口的 canonical-first 选择测试。已迁移项目直接进入 headless,只有明确迁移需求或未部署接口可走旧快照。
|
||||
* 权限失败不能降级成旧数据读取;入口只保留已验证迁移材料,不自动发迁移请求。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
|
||||
import { createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
||||
// UUID 工厂区分 board、workspace、profile,供入口解析和 service 身份验证。
|
||||
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
// 模拟已解析 board,workspaceRef/boardRef 以明确 ID 输入,避免把 slug 解析与模式选择混成同一个断言。
|
||||
const board = { id: id(1), workspace_id: id(2), kind: 'board', title: 'Board', version: 1 };
|
||||
// 当前账户与内置 registry,transport 工厂必须收到该 profile 身份。
|
||||
const profile = { id: id(3) }, registry = createRegistry(builtinComponents());
|
||||
// request 提供 profile/board/旧 canvas,transport 返回注入 canonical 响应;所有路径记录到 calls 检查不应发生的 fallback。
|
||||
function fixture(response) {
|
||||
const calls = [], request = async path => { calls.push(path); if (path === 'profile') return profile; if (path.endsWith('/canvas')) return { version: 1, document: { schema_version: 2, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; return board; };
|
||||
return { calls, options: { request, workspaceRef: board.workspace_id, boardRef: board.id, registry, transport: accountId => { assert.equal(accountId, profile.id); return async input => { calls.push(input.path); return response; }; } } };
|
||||
}
|
||||
|
||||
// canonical 完整模型已可用时只进入 headless,不能再读取旧 canvas 快照制造第二份文档来源。
|
||||
test('canonical-first entry never reads a legacy snapshot for an adopted project', async () => {
|
||||
const f = fixture({ status: 200, body: { model: { documentFormatVersion: 1, projectId: board.id, complete: true, canvases: [{ id: board.id, parent: null }], elements: [], tombstones: {}, versions: { [board.id]: 1 }, removedCanvasIds: [] } } });
|
||||
const result = await loadCanvasEntry(f.options); assert.equal(result.mode, 'headless'); assert.equal(result.model.document.projectId, board.id);
|
||||
assert.equal(f.calls.some(path => path.endsWith('/canvas')), false);
|
||||
});
|
||||
|
||||
// 404 未部署或合法 migration_required 允许读取旧快照;403 权限失败直接报错,绝不退回 legacy。
|
||||
test('explicit migration requirement or missing deployment keeps legacy reads; permission failure does not fall back', async () => {
|
||||
for (const response of [{ status: 404, body: { error: 'not_found' } }, { status: 409, body: { error: 'canonical_migration_required', root_id: board.id, document_format_version: 1, source_versions: { [board.id]: 1 } } }]) {
|
||||
const f = fixture(response); assert.equal((await loadCanvasEntry(f.options)).mode, 'legacy'); assert.equal(f.calls.filter(path => path.endsWith('/canvas')).length, 1);
|
||||
@@ -24,6 +34,7 @@ test('explicit migration requirement or missing deployment keeps legacy reads; p
|
||||
await assert.rejects(() => loadCanvasEntry(f.options), /forbidden/); assert.equal(f.calls.some(path => path.endsWith('/canvas')), false);
|
||||
});
|
||||
|
||||
// legacy 入口保留验证过的 root/source 版本向量及 profile/snapshot,不发 migrate;404 未部署则不提供虚构迁移材料。
|
||||
test('legacy entry retains verified migration root and the full source vector without offering migration for an unavailable API',async()=>{
|
||||
const child=id(4),f=fixture({status:409,body:{error:'canonical_migration_required',root_id:board.id,document_format_version:1,source_versions:{[board.id]:3,[child]:7}}});
|
||||
const result=await loadCanvasEntry(f.options);
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
/** Remote authenticated HTTP/DB integration. Only the model output is a fixture. */
|
||||
/**
|
||||
* 由 Foundry 远程集成入口调用的 AI 场景:HTTP、回执查询和数据库链路真实运行,模型输出使用 fixture。
|
||||
* open、transport、资源端口、故障注入和时钟均由调用方提供;本文件不会独立启动服务或读取登录凭据。
|
||||
* 依次验证丢失受理响应、取消恢复、生成期间的内容冲突,以及受管图片和复制复用资源。
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID,createHash} from 'node:crypto';
|
||||
|
||||
// 组织整套有状态集成场景并返回已通过的检查标记;任一断言失败都进入 finally 关闭引擎。
|
||||
export async function verifyFoundryAI({open,transport,rootId,imageId,assets,calls,tick,readDisplay,advance,loseAdmission,loseAction}){
|
||||
// 使用调用方准备的 workspace 和根 material 定位测试服务,不自行创建项目或切换身份。
|
||||
const path=`workspaces/${process.env.FOUNDRY_TEST_WORKSPACE}/materials/${rootId}/canvas/ai`;
|
||||
// 通过真实 HTTP 操作 fixture 执行屏障和计数器;每次调用有 15 秒超时且要求 200。
|
||||
const control=async action=>{const response=await transport({method:'POST',path:path+'/fixture/'+action,signal:AbortSignal.timeout(15_000)});assert.equal(response.status,200,action+': '+JSON.stringify(response.body));return response.body;};
|
||||
// 从共享调用记录筛选某类 POST,用次数与冻结请求体判断恢复是否意外重复了原动作。
|
||||
const posts=suffix=>calls.filter(call=>call.method==='POST'&&call.path.endsWith(suffix));
|
||||
// 保存当前引擎实例;重开时替换该引用,后续断言读取恢复后的同一项目。
|
||||
let engine=await open();const signal=new AbortController().signal;
|
||||
// 正常关闭释放资源与存储占用,推进调用方时钟后重新打开并等待恢复流程收敛。
|
||||
const reopen=async()=>{await engine.close();advance();engine=await open();await engine.settle();};
|
||||
// 显式 retry 后排空调用方可推进的轮询定时器;最多 100 轮,防止轮询异常令远程检查无限等待。
|
||||
const poll=async()=>{await engine.retry();for(let turns=0;turns<100;turns++){if(!tick())return;await engine.settle();}assert.fail('AI timer drain exceeded its bound');};
|
||||
try{
|
||||
// 先确认测试服务提供文本和图片 fixture 模型;缺少能力即失败,不降级为本地假结果。
|
||||
const catalog=await engine.models(rootId,signal);assert.ok(catalog.models.some(model=>model.id==='fixture-text'));assert.ok(catalog.models.some(model=>model.id==='fixture-image'));
|
||||
// 创建独立文本目标,附带 extension 字段;结果合并必须保留未知扩展及用户修改的几何。
|
||||
const target={id:randomUUID(),incarnation:1};
|
||||
assert.equal(engine.editor.dispatch({type:'element.create',element:{id:target.id,incarnation:1,ownerCanvasId:rootId,componentId:'text',dataVersion:1,structure:'node',data:{title:'Real AI adapter',text:'before generation',extension:{keep:true}},geometry:{x:0,y:0,width:100,height:80,rotation:0}}}).status,'applied');await engine.settle();
|
||||
const input={target,boardId:rootId,modelId:'fixture-text',prompt:'Fixture generation',parameters:{},propagate:false};
|
||||
// 统一断言 startRun 在本地获准并等待受理链收敛;此时并不代表模型已经执行完毕。
|
||||
const begin=async value=>{const result=engine.startRun(value);assert.equal(result.status,'applied',JSON.stringify(result));await engine.settle();return result;};
|
||||
// 通过受理操作 ID 找回对应 RunRecord,避免假设任务 ID 等于操作 ID。
|
||||
const byOperation=id=>[...engine.getSnapshot().runtime.runs.values()].find(run=>run.operationId===id);
|
||||
// 记录本场景开始前的调用基数,末尾核对生成次数和图片复制是否引入额外上传。
|
||||
const initialStarts=posts('/ai/runs').length,initialUploads=posts('/assets').length;
|
||||
|
||||
// A committed admission whose response was lost must be queried after reopen.
|
||||
// 服务端已提交受理后丢弃响应:重开必须查询原请求,保持原 body,不能第二次启动生成。
|
||||
loseAdmission();const first=await begin(input),admission=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.start'&&request.operationIds.includes(first.operationIds[0]));
|
||||
assert.equal(admission.phase,'waiting');assert.equal(posts('/ai/runs').at(-1).response?.httpStatus,202,'must lose a committed admission, not a rejection');const admissionCount=posts('/ai/runs').length;
|
||||
await reopen();assert.equal(posts('/ai/runs').length,admissionCount);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(admission.id).body,admission.body);
|
||||
@@ -26,6 +42,7 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
|
||||
const recovered=engine.getSnapshot().runtime.ledger.requests.get(admission.id);
|
||||
assert.equal(recovered.phase,'resolved','AI admission recovery: '+JSON.stringify({phase:recovered.phase,error:recovered.error,requestId:admission.id,operationId:first.operationIds[0],responses:calls.filter(call=>call.path.endsWith('/ai/runs')||call.path.endsWith('/ai/operations/'+admission.id)).map(call=>({method:call.method,response:call.response}))}));
|
||||
const firstRun=byOperation(first.operationIds[0]);assert.ok(firstRun,'resolved admission must publish a RunRecord');assert.equal(firstRun.status,'queued');
|
||||
// 受理恢复后先移动元素再执行生成;服务结果只能更新文字,必须保留这次移动及扩展数据。
|
||||
assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:77}}).status,'applied');await engine.settle();
|
||||
await control('execute');await poll();
|
||||
const generatedText=engine.getSnapshot().runtime.runs.get(firstRun.id);
|
||||
@@ -33,7 +50,7 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
|
||||
assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'headless fixture output');assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,77);
|
||||
assert.deepEqual(engine.getSnapshot().document.elements.get(target.id).data.extension,{keep:true});
|
||||
|
||||
// Queued cancellation is durable and does not execute the generation transport.
|
||||
// 排队任务取消的响应丢失后查询恢复;执行次数不增加,并排空取消事件游标后才开启下一任务。
|
||||
const second=await begin({...input,prompt:'Fixture cancellation'}),secondRun=byOperation(second.operationIds[0]),beforeCancel=(await control('stats')).executions;
|
||||
loseAction();assert.equal(engine.cancelRun(secondRun.id).status,'applied');await engine.settle();
|
||||
const cancellation=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.cancel'&&request.runId===secondRun.id);assert.equal(cancellation.phase,'waiting');const cancellationCount=posts('/cancel').length;
|
||||
@@ -41,16 +58,19 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
|
||||
await poll();assert.equal(engine.getSnapshot().runtime.runs.get(secondRun.id).hasMore,false,'drain the cancelled run event cursor before another admission');
|
||||
assert.equal((await control('stats')).executions,beforeCancel);
|
||||
|
||||
// Edit while the provider is held, then retain and explicitly apply its output.
|
||||
// 用服务端屏障挂起 provider,期间提交用户文字;结果发生冲突后保留输出,等待用户显式选择应用。
|
||||
const third=await begin({...input,prompt:'Fixture conflict'}),thirdRun=byOperation(third.operationIds[0]);await control('arm');
|
||||
// 并行启动执行并把错误转成结果对象,确保解除屏障后能统一等待和报告,避免未处理的 Promise 拒绝。
|
||||
const executing=control('execute').then(value=>({value}),error=>({error}));
|
||||
try{
|
||||
await control('wait');
|
||||
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'user edit while executing'}}).status,'applied');await engine.settle();
|
||||
// 无论中途断言是否通过,都先解除 provider 屏障,避免测试留下永久挂起的执行。
|
||||
}finally{await control('release');}
|
||||
const completed=await executing;if(completed.error)throw completed.error;await poll();
|
||||
const conflict=engine.getSnapshot().runtime.runs.get(thirdRun.id);assert.equal(conflict.status,'conflict');assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'user edit while executing');
|
||||
const outputId=conflict.outputEvents[0]?.output.output_id;assert.equal(typeof outputId,'string');
|
||||
// 再次模拟成功动作丢响应;先撤销再重开,通过查询确认原应用,随后同步撤销,redo 只恢复已有内容。
|
||||
loseAction();assert.equal(engine.applyRunOutput(conflict.id,target,outputId).status,'applied');await engine.settle();
|
||||
const application=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='run.apply'&&request.runId===conflict.id);assert.equal(application.phase,'waiting');const applyCount=posts('/apply').length;
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();await reopen();
|
||||
@@ -59,16 +79,21 @@ export async function verifyFoundryAI({open,transport,rootId,imageId,assets,call
|
||||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(target.id).data.text,'headless fixture output');
|
||||
assert.equal(posts('/apply').length,applyCount);
|
||||
|
||||
// Managed image results are readable, and copying shares bytes without a run.
|
||||
// 图片结果必须可从资源权威端和显示路径读取;复制元素共享 assetId,不复制任务或重新上传字节。
|
||||
const image=engine.getSnapshot().document.elements.get(imageId),oldAsset=image.data.assetId;
|
||||
const fourth=await begin({target:{id:image.id,incarnation:image.incarnation},boardId:rootId,modelId:'fixture-image',prompt:'Fixture image',parameters:{},propagate:false});
|
||||
const fourthRun=byOperation(fourth.operationIds[0]);await control('execute');await poll();
|
||||
assert.equal(engine.getSnapshot().runtime.runs.get(fourthRun.id).status,'succeeded');const generated=engine.getSnapshot().document.elements.get(imageId);assert.notEqual(generated.data.assetId,oldAsset);
|
||||
// 分别取得资源元数据、原始字节和显示句柄;后续核对 PNG 签名、SHA-256、尺寸和可显示内容。
|
||||
const metadata=await assets.authority.resolve(generated.data.assetId,signal),original=await assets.authority.read(generated.data.assetId,signal),display=await engine.displayResource(generated.data.assetId);
|
||||
// 显示句柄是带引用的资源,即使字节或元数据断言失败也必须 release。
|
||||
try{const bytes=Buffer.from(await original.arrayBuffer());assert.deepEqual([...bytes.subarray(0,8)],[137,80,78,71,13,10,26,10]);assert.equal(createHash('sha256').update(bytes).digest('hex'),metadata.digest);assert.equal(metadata.asset.width,3);assert.equal(metadata.asset.height,2);assert.ok((await readDisplay(display.url)).length>0);}finally{display.release();}
|
||||
// 深复制生成后的图片,等待复制作业完成后检查稳定 ID 映射;复用资源不应增加 RunRecord。
|
||||
const runCount=engine.getSnapshot().runtime.runs.size,copyId=await engine.imports.duplicate([{id:generated.id,incarnation:generated.incarnation}],{viewCanvasId:rootId});await engine.settle();
|
||||
const copy=engine.imports.getJobs().get(copyId);assert.equal(copy.status,'completed');assert.equal(engine.getSnapshot().document.elements.get(copy.stableIds['element:'+generated.id]).data.assetId,generated.data.assetId);
|
||||
assert.equal(engine.getSnapshot().runtime.runs.size,runCount);assert.equal(posts('/ai/runs').length,initialStarts+4);assert.equal(posts('/assets').length,initialUploads);assert.equal((await control('stats')).executions,3);
|
||||
// 只有前面全部断言通过才返回这些标记;它们描述本次远程调用结果,不代表其他入口都已覆盖。
|
||||
return {runtimeAI:true,aiLostAdmission:true,aiCancelReconciled:true,aiConflictApplyUndo:true,aiManagedImage:true,aiCopyNoRun:true,aiExecutionCount:3};
|
||||
// 成功或失败都关闭最新引擎实例,释放本次打开的运行时资源。
|
||||
}finally{await engine.close();}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Invoked by Foundry's isolated PostgreSQL/HTTP integration test on its CI runner.
|
||||
* No mocked model writes: only the recovery store and lost-response fault are local.
|
||||
/**
|
||||
* 由 Foundry CI runner 的隔离 PostgreSQL/HTTP 集成环境调用。文档、迁移和资产写入使用真实服务;本地恢复存储及丢响应是测试替身。
|
||||
* 执行前必须注入隔离服务身份与测试数据;本文件不启动数据库,也不在没有 fixture 时降级为本地服务。
|
||||
* 依次覆盖迁移、未知回执恢复、分区复制/解散、增量加载期间导入与上传,以及跨项目资源采用;AI 场景由环境开关追加。
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
@@ -12,98 +14,163 @@ import {createFoundryRuns} from '../../src/lib/canvas-service/runs.ts';
|
||||
import {createCanvasMigration} from '../../src/lib/canvas-migration.ts';
|
||||
import {verifyFoundryAI} from './foundry-ai.integration.mjs';
|
||||
import {decodeModelStream} from '../../src/lib/canvas-service/model-stream.ts';
|
||||
// 从 runner 环境取得隔离服务及认证参数,仅在进程内供请求使用;缺少任一必需参数直接失败。
|
||||
const {FOUNDRY_HTTP_URL:url,FOUNDRY_TEST_PROJECT:project,FOUNDRY_TEST_ACCOUNT:accountId,FOUNDRY_TEST_WORKSPACE:workspaceId,FOUNDRY_TEST_ROOT:rootId,FOUNDRY_TEST_TOKEN:token}=process.env;
|
||||
assert.ok(url&&project&&accountId&&workspaceId&&rootId&&token,'requires isolated Foundry HTTP fixture');
|
||||
// 装配实际模型校验与项目 API 根路径,calls 只记录测试需要的协议信息。
|
||||
const registry=createRegistry(builtinComponents()),basePath=`${url}/projects/${project}/`,calls=[];
|
||||
// 各故障开关只在相应成功响应已经返回后生效,不把发送前失败伪装成“服务已提交但响应丢失”。
|
||||
let loseNextAck=false,loseNextAssetAck=false,loseNextAIAdmission=false,loseNextAIAction=false;
|
||||
// 只向 fixture 同源地址添加 Bearer 与预期账户头,防止测试资源读取把凭据发送到其他 origin。
|
||||
const authenticatedFetch=async(resource,init={})=>{assert.equal(new URL(resource).origin,new URL(url).origin);const headers=new Headers(init.headers);headers.set('Authorization',`Bearer ${token}`);headers.set('X-Expected-Account',accountId);return fetch(resource,{...init,headers});};
|
||||
// 复用生产 HTTP 传输适配器;测试身份固定,身份变化的校验由单元用例另行覆盖。
|
||||
const rawTransport=createFoundryTransport({basePath,accountId,isCurrentIdentity:()=>true,fetch:authenticatedFetch});
|
||||
const transport=async input=>{const call={method:input.method,path:input.path,requestId:input.requestId,body:input.body};calls.push(call);const result=await rawTransport(input);if(input.path.includes('/canvas/ai/')||input.path.endsWith('/canvas/model/operations'))call.response={httpStatus:result.status,...Object.fromEntries(['protocol_version','request_id','operation_id','action','admission','status','error'].filter(key=>result.body?.[key]!==undefined).map(key=>[key,result.body[key]]))};if(loseNextAIAdmission&&input.method==='POST'&&input.path.endsWith('/ai/runs')&&result.status===202){loseNextAIAdmission=false;throw Error('injected response loss after AI admission');}if(loseNextAIAction&&input.method==='POST'&&/\/(cancel|apply)$/.test(input.path)&&result.status===200){loseNextAIAction=false;throw Error('injected response loss after AI action');}if(loseNextAssetAck&&input.method==='POST'&&input.path.endsWith('/assets')&&result.status>=200&&result.status<300){loseNextAssetAck=false;throw Error('injected upload response loss');}if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}return result;};
|
||||
// 记录请求与有限响应摘要,再按动作类型丢弃一次已成功的响应;未命中开关时返回真实结果。
|
||||
const transport=async input=>{const call={method:input.method,path:input.path,requestId:input.requestId,body:input.body};calls.push(call);const result=await rawTransport(input);if(input.path.includes('/canvas/ai/')||input.path.endsWith('/canvas/model/operations'))call.response={httpStatus:result.status,...Object.fromEntries(['protocol_version','request_id','operation_id','action','admission','status','error'].filter(key=>result.body?.[key]!==undefined).map(key=>[key,result.body[key]]))};
|
||||
// AI 受理仅在 202 时丢响应,确保服务真正创建过任务;重新开关由后续场景显式控制。
|
||||
if(loseNextAIAdmission&&input.method==='POST'&&input.path.endsWith('/ai/runs')&&result.status===202){loseNextAIAdmission=false;throw Error('injected response loss after AI admission');}
|
||||
// 取消或应用只拦截成功 POST;故障发生后本地要通过动作回执恢复,不重跑生成。
|
||||
if(loseNextAIAction&&input.method==='POST'&&/\/(cancel|apply)$/.test(input.path)&&result.status===200){loseNextAIAction=false;throw Error('injected response loss after AI action');}
|
||||
// 上传遇到任意 2xx 后才注入丢响应,不能把真实拒绝当作待确认上传。
|
||||
if(loseNextAssetAck&&input.method==='POST'&&input.path.endsWith('/assets')&&result.status>=200&&result.status<300){loseNextAssetAck=false;throw Error('injected upload response loss');}
|
||||
// 通用写入故障只拦截一次成功 POST,服务端副作用和回执已经存在。
|
||||
if(loseNextAck&&input.method==='POST'&&result.status===200){loseNextAck=false;throw Error('injected response loss after server commit');}
|
||||
// 其余请求完整返回真实传输结果,不修改协议 body 或 stream。
|
||||
return result;};
|
||||
// 原项目操作以 runner 准备的 material 为规范项目作用域;跨项目场景稍后另建 root。
|
||||
const path=`workspaces/${workspaceId}/materials/${rootId}/canvas`;
|
||||
// fixture 初始必须是待迁移旧画布;这保证随后恢复的是迁移写入而不是现成 canonical 模型。
|
||||
const migration=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(migration.status,'migration-required');
|
||||
// 用 Map 模拟迁移本地日志;不在此场景验证浏览器存储或真实跨标签页锁。
|
||||
const migrationRecords=new Map(),migrationStorage={getItem:key=>migrationRecords.get(key)??null,setItem:(key,value)=>migrationRecords.set(key,value),removeItem:key=>migrationRecords.delete(key)};
|
||||
// 迁移协调器使用真实 transport 与随机请求 ID,锁替身直接执行 work,专注幂等迁移协议。
|
||||
const migrationOptions={scope:{accountId,workspaceId},registry,transport,storage:migrationStorage,isCurrentIdentity:()=>true,createId:randomUUID,lock:async work=>work()};
|
||||
// 真实迁移成功后丢响应,本地仍为 pending;新协调器恢复时禁止再分配 ID,只查询原受理。
|
||||
loseNextAck=true;const admission=createCanvasMigration(migrationOptions);assert.equal((await admission.start(rootId,async()=>{})).status,'pending');assert.equal(loseNextAck,false);
|
||||
// 保存原迁移 ID 和 body,并统计 POST 次数;恢复不得重新发送迁移。
|
||||
const frozenMigration=admission.read(),migrationId=frozenMigration.id,migrationBody=frozenMigration.body,migrationPosts=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/migrate')).length;
|
||||
assert.equal((await createCanvasMigration({...migrationOptions,createId:()=>assert.fail('recovery allocated a new migration identity')}).resume()).status,'applied');
|
||||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/migrate')).length,migrationPosts);assert.equal(migrationRecords.size,0);
|
||||
// 直接查询原迁移回执,再以同 ID/body 重试并比较 identity_map,检验真实服务的幂等迁移结果。
|
||||
const migrated=await transport({method:'GET',path:path+'/model/operations/'+migrationId});assert.equal(migrated.status,200);assert.equal(migrated.body.status,'applied');
|
||||
const retried=await transport({method:'POST',path:path+'/model/migrate',body:migrationBody,requestId:migrationId});assert.deepEqual(retried.body.identity_map,migrated.body.identity_map);
|
||||
// 迁移完成后完整读取规范模型;后续引擎以此作为初始 ACK 基线。
|
||||
let initial=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(initial.status,'ready');
|
||||
// 隔离 fixture 必须含 frame 和子 canvas 成员,保证后续有真实嵌套结构可操作。
|
||||
const sourceFrame=[...initial.document.elements.values()].find(element=>element.componentId==='frame'),sourceMember=[...initial.document.elements.values()].find(element=>element.structure==='node'&&element.ownerCanvasId!==rootId);assert.ok(sourceFrame&&sourceMember);
|
||||
// 本地恢复镜像、文件仓库和时钟在引擎关闭后保留,views 不参与持久化。
|
||||
const scope={accountId,workspaceId,projectId:rootId},files=new Map(),errors=[];let image=null,owner,now=0;
|
||||
// 共享资源仓库替身按 authority 与 assetId 定位;此远程消费场景禁止调用仓库收集。
|
||||
const sharedRecords=new Map(),depotPort={async read(_leader,authority,id){return sharedRecords.get(JSON.stringify([authority,id]));},async collect(){throw Error('remote consumer does not collect');}};
|
||||
// commit 同步更新共享资源引用,缺少新字节时继续使用已有字节,供跨项目采用验证。
|
||||
const share=batch=>{for(const write of batch.depot?.writes??[]){const key=JSON.stringify([write.ref.authority,write.ref.asset.id]);sharedRecords.set(key,{ref:write.ref,bytes:write.bytes??sharedRecords.get(key)?.bytes});}};
|
||||
const storage={assetDepot:depotPort,async claim(){assert.equal(owner,undefined);const held={scope,fence:1,active:true,async release(){held.active=false;owner=undefined;}};owner=held;return held;},async recover(){return structuredClone(image);},async commit(held,batch){assert.strictEqual(held,owner);const next=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=next;share(batch);return{durableRootRevision:next.rootRevision};},async readFile(_held,id){return files.get(id);},close(){}};
|
||||
// 项目存储使用独占 owner;commit 克隆 Root,按批次增删本地文件并写共享 depot,readFile 从项目仓库读。
|
||||
const storage={assetDepot:depotPort,
|
||||
// 一个 fixture 项目一次只允许一个持有者;release 使旧 owner 失活并允许后续重开。
|
||||
async claim(){assert.equal(owner,undefined);const held={scope,fence:1,active:true,async release(){held.active=false;owner=undefined;}};owner=held;return held;},
|
||||
// 恢复镜像克隆后交给引擎,避免复用同一个可变 JSON 对象。
|
||||
async recover(){return structuredClone(image);},
|
||||
// 当前 owner 才能提交;文件变化、Root 镜像和 depot 更新在此内存调用内处理,不模拟真实磁盘事务故障。
|
||||
async commit(held,batch){assert.strictEqual(held,owner);const next=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=next;share(batch);return{durableRootRevision:next.rootRevision};},
|
||||
// 项目本地文件读取;跨项目共享字节由另一个 assetDepot 端口提供。
|
||||
async readFile(_held,id){return files.get(id);},close(){}};
|
||||
// 轮询定时器只登记在 Map,不依赖真实等待。
|
||||
let nextTimer=0;const timers=new Map();
|
||||
// 触发当前已到期的定时器,按 due 再按 ID 稳定排序,并返回本轮触发数供 AI 场景有界排空。
|
||||
const tick=()=>{const ready=[...timers].filter(([,timer])=>timer.due<=now).sort((a,b)=>a[1].due-b[1].due||a[0]-b[0]);let fired=0;for(const[id,timer]of ready)if(timers.delete(id)){fired++;timer.callback();}return fired;};
|
||||
// 文档、资产和显示读取使用生产适配器;显示 URL 指向同一真实 API,时钟采用可控定时器。
|
||||
const persistence=createFoundryPersistence({registry,workspaceId,rootId,transport}),assets=createFoundryAssets({workspaceId,project,origin:'https://api.example.com',transport,displayURL:id=>`${basePath}workspaces/${workspaceId}/assets/${encodeURIComponent(id)}?variant=preview`}),clock={now:()=>now,setTimer(callback,delay){const id=++nextTimer;timers.set(id,{callback,due:now+delay});return id;},clearTimer(id){timers.delete(id);}};
|
||||
// 每次重开从同一 scope、存储及服务端口恢复;extra 可为渐进加载场景替换捕获模型和 loading 状态。
|
||||
const open=(extra={})=>openProjectEngine({scope,registry,storage,persistence,assets,document:initial.document,versions:initial.versions,clock,createId:randomUUID,onError:error=>errors.push(error),...extra});
|
||||
// 创建合法文本节点外壳;各场景再替换类型、归属或 data,ID 使用随机 UUID 避免污染既有 fixture 身份。
|
||||
const node=(id,ownerCanvasId=rootId)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
|
||||
// 后续 try/finally 持有最新引擎引用,跨场景重开后最终关闭当前实例。
|
||||
let engine=await open();
|
||||
try{
|
||||
// 文档写入真实成功但客户端未知;重开查询原请求,不新增 POST,完整读取再确认远端内容。
|
||||
loseNextAck=true;assert.equal(engine.editor.dispatch({type:'element.data',target:{id:sourceMember.id,incarnation:1},patch:{text:'committed before lost ACK'}}).status,'applied');await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1);assert.equal(request.phase,'waiting');const sent=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
|
||||
await engine.close();now=10_000;engine=await open();await engine.settle();const restored=engine.getSnapshot().runtime.ledger.requests.get(request.id);assert.equal(restored.body,request.body);assert.equal(restored.phase,'resolved');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+request.id)));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,sent);
|
||||
let live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(sourceMember.id).data.text,'committed before lost ACK');
|
||||
// 同一事务创建父级锚点、两层分区和连线,作为深复制及解散的真实服务样本。
|
||||
const anchor=node(randomUUID()),frame={...node(randomUUID()),componentId:'frame',geometry:{x:100,y:50,width:500,height:400,rotation:30},data:{title:'Nested source',text:'',presentation:{contentScale:2}}},childId=randomUUID(),member=node(randomUUID(),childId),nested={...node(randomUUID(),childId),componentId:'frame'},grandchildId=randomUUID(),leaf=node(randomUUID(),grandchildId),edge={id:randomUUID(),incarnation:1,ownerCanvasId:rootId,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:anchor.id},target:{elementId:frame.id},path:null};
|
||||
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:anchor});tx.dispatch({type:'element.create',element:frame});tx.dispatch({type:'canvas.create',canvas:{id:childId,parent:{canvasId:rootId,entryElementId:frame.id}}});tx.dispatch({type:'element.create',element:member});tx.dispatch({type:'element.create',element:nested});tx.dispatch({type:'canvas.create',canvas:{id:grandchildId,parent:{canvasId:childId,entryElementId:nested.id}}});tx.dispatch({type:'element.create',element:leaf});tx.dispatch({type:'element.create',element:edge});}).status,'applied');await engine.settle();
|
||||
const creation=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1);assert.equal(creation.phase,'resolved','nested partition creation was not confirmed: '+JSON.stringify({phase:creation.phase,outcome:creation.outcome,error:creation.error,response:calls.findLast(call=>call.requestId===creation.id)?.response}));
|
||||
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(member.id).ownerCanvasId,childId);assert.ok(live.document.canvases.has(childId));
|
||||
// 同时选父 frame 与其成员以检查深复制去重;复制后上级锚点应新增一条指向新分区的连线,redo 用新 incarnation。
|
||||
const copiedId=await engine.imports.duplicate([{id:frame.id,incarnation:1},{id:member.id,incarnation:1}],{viewCanvasId:rootId});await engine.settle();const copied=engine.imports.getJobs().get(copiedId);assert.equal(copied.status,'completed');const copiedFrame=copied.stableIds['element:'+frame.id],copiedMember=copied.stableIds['element:'+member.id];assert.ok(copiedFrame&&copiedMember);
|
||||
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.ok(live.document.elements.has(copiedMember));assert.ok([...live.document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId===anchor.id&&element.target.elementId===copiedFrame));assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(copiedFrame).incarnation,2);
|
||||
// Dissolve is an owning-tree edit, not recursive deletion. Lose the successful
|
||||
// real DB response, reopen/query, then restore and repeat the same history unit.
|
||||
// 解散修改 owning tree,不递归删除入口或后代;真实 DB 成功后丢 ACK,再查询恢复并重做同一历史单元。
|
||||
loseNextAck=true;assert.equal(engine.editor.dispatch({type:'canvas.dissolve',canvasId:childId,entry:{id:frame.id,incarnation:1}}).status,'applied');await engine.settle();
|
||||
// 解散已经在数据库完成但本地 waiting;记录请求次数和冻结体,再重开查询,不能发送第二次解散。
|
||||
const dissolveRequest=[...engine.getSnapshot().runtime.ledger.requests.values()].at(-1),dissolveWrites=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;assert.equal(dissolveRequest.phase,'waiting');assert.equal(loseNextAck,false,'dissolve did not receive a successful real service response');
|
||||
await engine.close();now+=10_000;engine=await open();await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(dissolveRequest.id).phase,'resolved');assert.equal(engine.getSnapshot().runtime.ledger.requests.get(dissolveRequest.id).body,dissolveRequest.body);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,dissolveWrites);
|
||||
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.canvases.has(childId),false);assert.equal(live.document.elements.get(member.id).ownerCanvasId,rootId);assert.equal(live.document.elements.get(nested.id).ownerCanvasId,rootId);assert.equal(live.document.canvases.get(grandchildId).parent.canvasId,rootId);assert.equal(live.document.elements.get(leaf.id).ownerCanvasId,grandchildId);assert.deepEqual(live.document.elements.get(leaf.id).geometry,leaf.geometry);assert.equal(live.document.elements.get(frame.id).incarnation,1);assert.deepEqual(live.document.elements.get(edge.id).target,{elementId:frame.id});
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(member.id).ownerCanvasId,childId);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.canvases.has(childId),false);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();
|
||||
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(member.id).ownerCanvasId,childId);assert.equal(live.document.canvases.get(childId).parent.entryElementId,frame.id);assert.equal(live.document.canvases.get(grandchildId).parent.canvasId,childId);assert.equal(live.document.elements.get(leaf.id).ownerCanvasId,grandchildId);
|
||||
// 逐项导入先接受一个合法元素再遇未知 kind,要求 partial 报告且保留已提交来源 key。
|
||||
const importId=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'native',record:node('native','$target')})+'\n'+JSON.stringify({kind:'unsupported'})+'\n']),rootId);await engine.settle();const imported=engine.imports.getJobs().get(importId);assert.equal(imported.status,'partial');assert.equal(imported.committedSourceKeys.length,1);
|
||||
// Put a deterministic sorted tail in a different owner. Every original ID is
|
||||
// lower than these records, so the first real 64-record frame certifies the
|
||||
// root/new child while the original child is provably still incomplete.
|
||||
// 尾部 ID 严格大于全部原 ID,且属于另一 owner;真实 64 项首帧可认证根/新子分区,但原有子分区仍缺成员。
|
||||
// 收集现有元素、canvas 与墓碑 ID,计算严格更大的 70 个尾部 ID,使流排序和缺失归属可预测。
|
||||
const namespace=[...engine.getSnapshot().document.elements.keys(),...engine.getSnapshot().document.canvases.keys(),...engine.getSnapshot().document.tombstones.keys()];
|
||||
const maximum=namespace.reduce((max,id)=>{const value=BigInt('0x'+id.replaceAll('-',''));return value>max?value:max;},0n);assert.ok(maximum+70n<(1n<<128n));
|
||||
const tail=Array.from({length:70},(_,i)=>{const hex=(maximum+BigInt(i+1)).toString(16).padStart(32,'0');return node(`${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`,sourceMember.ownerCanvasId);});
|
||||
assert.equal(engine.editor.transaction(tx=>{for(const element of tail)tx.dispatch({type:'element.create',element});}).status,'applied');await engine.settle();
|
||||
// Consume the real negotiated stream through the production decoder, pausing
|
||||
// after the first nonempty frame. HTTP/DB ACKs may advance before its old tail.
|
||||
// 消费生产解码器的真实协商流,并暂停在首个非空帧;旧捕获尾部到达前,HTTP/DB ACK 可继续推进。
|
||||
await engine.close();image=null;initial=await loadFoundryModel(transport,workspaceId,rootId,registry);
|
||||
// 协商真实 model-stream-v1 并交给生产解码器;preview 提取前缀而不把它冒充完整基线。
|
||||
const streamed=await transport({method:'GET',path:path+'/model',responseFormat:'model-stream-v1'});assert.ok(streamed.stream);const controller=new AbortController(),preview=createModelPreview(registry);let capture,sourceToken,sourceVersions;
|
||||
const stream=decodeModelStream(streamed.stream,rootId,registry,controller.signal);
|
||||
// 读取到第一个非空 receiving 帧即暂停,保留同一迭代器和来源 token/版本供稍后继续消费。
|
||||
while(true){const next=await stream.next();assert.equal(next.done,false);const chunk=next.value;
|
||||
const value=preview.update(chunk.value);if(chunk.value.status==='receiving'){capture=value;sourceToken=chunk.snapshotToken;sourceVersions=chunk.versions;if(chunk.value.receivedElements>0)break;}
|
||||
}
|
||||
assert.ok(capture.sourceCanvases&&capture.elementOwners);assert.ok(capture.document.elements.size<initial.document.elements.size);assert.equal(capture.document.elements.has(tail.at(-1).id),false);
|
||||
// v6 loading 描述包含完整来源树、元素 owner 和成员计数;允许已认证完整 owner 内作业,而未载入尾部仍保持缺失。
|
||||
const loading={version:6,sourceToken,sourceCanvases:capture.sourceCanvases,elementOwners:capture.elementOwners,canvasElementCounts:capture.canvasElementCounts,declaredCanvasIds:capture.sourceCanvases.map(canvas=>canvas.id),receivedElements:capture.receivedElements,totalElements:capture.totalElements};
|
||||
engine=await open({document:capture.document,versions:sourceVersions,loading});assert.equal(engine.editor.getQueries().membersResult(sourceMember.ownerCanvasId).complete,false);assert.equal(engine.imports.canStart(childId).enabled,true);const partialId=engine.imports.startNdjson(new Blob([JSON.stringify({kind:'element',key:'partial-native',record:node('partial-native','$target')})+'\n']),childId);await engine.settle();assert.equal(engine.imports.getJobs().get(partialId).status,'completed');assert.equal(engine.loading.getState().version,6);
|
||||
// 旧流尾尚未到达时深复制已具备闭包的分区;不能为了复制强行等待整个项目,也不能把缺失尾部当作已加载。
|
||||
const partialCopyId=await engine.imports.duplicate([{id:frame.id,incarnation:1}],{viewCanvasId:rootId});await engine.settle();const partialCopy=engine.imports.getJobs().get(partialCopyId);assert.equal(partialCopy.status,'completed');assert.equal(engine.getSnapshot().document.elements.has(tail.at(-1).id),false);const partialFrame=partialCopy.stableIds['element:'+frame.id];assert.ok(partialFrame);
|
||||
// The upload commits on the real server, then its response is deliberately
|
||||
// lost. Reopening must query that upload before either image document sends.
|
||||
// 上传真实提交后丢响应;恢复必须先查询原上传,两个图片文档不能绕过该依赖或重复上传。
|
||||
loseNextAssetAck=true;const imageId=randomUUID();let imageResource;
|
||||
// 将真实 PNG 暂存为资源并立即创建图片;上传已经成功后丢响应,图片文档须等待同一上传回执协调。
|
||||
const imageResult=engine.stageFiles([{name:'live.png',bytes:new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAIAAAASFvFNAAAAEElEQVR4nGP4z8AAQQxwFgBB0gX7h/C5SAAAAABJRU5ErkJggg==','base64')],{type:'image/png'})}],([ref],tx)=>{imageResource=ref;tx.dispatch({type:'element.create',element:{...node(imageId,childId),componentId:'image',data:{title:'Live scoped image',text:'',assetId:ref.id,src:ref.src}}});});assert.equal(imageResult.status,'applied');assert.equal(engine.getSnapshot().document.elements.has(imageId),true);await engine.settle();const upload=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.kind==='asset.upload');assert.ok(upload);assert.equal(loseNextAssetAck,false,'successful upload response was not intercepted');assert.equal(upload.phase,'waiting');const uploadCount=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length;
|
||||
// 复制尚未确认上传的图片仍引用同一资源,重开查询原上传而非再次传字节。
|
||||
const imageCopyId=await engine.imports.duplicate([{id:imageId,incarnation:1}],{viewCanvasId:childId});await engine.settle();const imageCopy=engine.imports.getJobs().get(imageCopyId);assert.equal(imageCopy.status,'completed');const copiedImage=imageCopy.stableIds['element:'+imageId];assert.equal(engine.getSnapshot().document.elements.get(copiedImage).data.assetId,imageResource.id);assert.equal(engine.getSnapshot().document.elements.has(tail.at(-1).id),false);
|
||||
await engine.close();now+=10_000;engine=await open({document:capture.document,versions:sourceVersions,loading});await engine.settle();assert.equal(engine.getSnapshot().runtime.ledger.requests.get(upload.id).phase,'resolved');assert.equal(engine.getSnapshot().runtime.ledger.requests.get(upload.id).body,upload.body);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/assets/operations/'+upload.id)));
|
||||
live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.equal(live.document.elements.get(imageId).data.assetId,imageResource.id);assert.equal(live.document.elements.get(copiedImage).data.assetId,imageResource.id);
|
||||
// 继续消费原迭代器直到 ready 和 EOF,再用原 token/版本完成 loading;旧尾部合并不能覆盖期间新增内容。
|
||||
let terminal;for await(const chunk of stream){preview.update(chunk.value);if(chunk.value.status==='ready')terminal=chunk.value;}assert.ok(terminal,'real source never reached terminal and EOF');
|
||||
assert.equal((await engine.loading.complete(terminal.document,sourceVersions,{sourceToken})).status,'applied');await engine.settle();live=await loadFoundryModel(transport,workspaceId,rootId,registry);assert.ok(live.document.elements.has(engine.imports.getJobs().get(partialId).stableIds['element:partial-native']));assert.ok(live.document.elements.has(partialFrame));assert.ok(live.document.elements.has(tail.at(-1).id));assert.ok([...live.document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId===anchor.id&&element.target.elementId===partialFrame));
|
||||
// 独立读取资源授权元数据及原字节,和本地资源记录、暂存文件比较,不仅检查图片元素的 assetId。
|
||||
const authorized=await assets.authority.resolve(imageResource.id,new AbortController().signal),originalBytes=await assets.authority.read(imageResource.id,new AbortController().signal),record=engine.getSnapshot().runtime.resources.get(imageResource.id);
|
||||
assert.equal(authorized.digest,record.digest);assert.deepEqual(authorized.asset,record.stored);assert.equal(originalBytes.size,record.byteLength);assert.deepEqual(Buffer.from(await originalBytes.arrayBuffer()),Buffer.from(await files.get(imageResource.id).arrayBuffer()));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);
|
||||
// A second canonical root in the same workspace adopts the first root's
|
||||
// authorized identity. Shared bytes remain local; the real server validates
|
||||
// the new document reference independently, with no second upload.
|
||||
// 同 workspace 的第二个 canonical root 采用已有授权资源;共享字节保留,真实服务独立验证新引用,无需第二次上传。
|
||||
await engine.close();
|
||||
// 在同一隔离 workspace 内建立第二个 material 并迁移为规范项目,供资源跨项目采用场景使用。
|
||||
const targetCreated=await transport({method:'POST',path:`workspaces/${workspaceId}/materials`,body:JSON.stringify({kind:'board',title:'Cross-project depot target',content:{}})});assert.equal(targetCreated.status,201);const targetRoot=targetCreated.body.id;
|
||||
const targetMigration=await loadFoundryModel(transport,workspaceId,targetRoot,registry);assert.equal(targetMigration.status,'migration-required');const targetMigrationId=randomUUID();assert.equal((await transport({method:'POST',path:`workspaces/${workspaceId}/materials/${targetRoot}/canvas/model/migrate`,requestId:targetMigrationId,body:JSON.stringify({protocol_version:2,request_id:targetMigrationId,expected_versions:Object.fromEntries(targetMigration.sourceVersions)})})).status,200);
|
||||
const targetInitial=await loadFoundryModel(transport,workspaceId,targetRoot,registry);assert.equal(targetInitial.status,'ready');let targetImage=null,targetOwner,adoptionOnly=true;const targetFiles=new Map();
|
||||
const targetStorage={...storage,async claim(targetScope){assert.equal(targetOwner,undefined);const held={scope:targetScope,fence:1,active:true,async release(){held.active=false;targetOwner=undefined;}};targetOwner=held;return held;},async recover(){return structuredClone(targetImage);},async commit(held,batch){assert.strictEqual(held,targetOwner);if(adoptionOnly)assert.equal((batch.files??[]).length,0,'adoption duplicated project-local bytes');for(const file of batch.files??[]){assert.equal(file.bytes.type.startsWith('image/'),false,'managed image copy duplicated bitmap bytes');targetFiles.set(file.id,file.bytes);}for(const id of batch.deleteFiles??[])targetFiles.delete(id);targetImage=structuredClone(batch.image);share(batch);return{durableRootRevision:targetImage.rootRevision};},async readFile(_held,id){return targetFiles.get(id);}};
|
||||
// 第二项目有独立 owner、镜像和文件表,但共享 depot;采用阶段不允许复制文件,后续也不允许复制托管图片字节。
|
||||
const targetStorage={...storage,
|
||||
// 第二项目有独立占用,不能借用第一个项目的 owner。
|
||||
async claim(targetScope){assert.equal(targetOwner,undefined);const held={scope:targetScope,fence:1,active:true,async release(){held.active=false;targetOwner=undefined;}};targetOwner=held;return held;},
|
||||
// 重开读取第二项目自己的镜像,不覆盖第一个项目的持久化记录。
|
||||
async recover(){return structuredClone(targetImage);},
|
||||
// 采用阶段禁止任何项目文件写入;AI 扩展后允许非图片文件,但图片仍必须复用托管资源。
|
||||
async commit(held,batch){assert.strictEqual(held,targetOwner);if(adoptionOnly)assert.equal((batch.files??[]).length,0,'adoption duplicated project-local bytes');for(const file of batch.files??[]){assert.equal(file.bytes.type.startsWith('image/'),false,'managed image copy duplicated bitmap bytes');targetFiles.set(file.id,file.bytes);}for(const id of batch.deleteFiles??[])targetFiles.delete(id);targetImage=structuredClone(batch.image);share(batch);return{durableRootRevision:targetImage.rootRevision};},
|
||||
// 这里只读第二项目文件表,已有图片原字节必须由共享 depot 解析。
|
||||
async readFile(_held,id){return targetFiles.get(id);}};
|
||||
// 第二引擎的文档与 persistence 改绑新 root,资产权威端口及共享仓库仍属于同一 workspace。
|
||||
const targetOptions={scope:{accountId,workspaceId,projectId:targetRoot},registry,storage:targetStorage,assets,persistence:createFoundryPersistence({registry,workspaceId,rootId:targetRoot,transport}),document:targetInitial.document,versions:targetInitial.versions,clock,createId:randomUUID,onError:error=>errors.push(error)};
|
||||
// 采用已有授权资源后真实服务验证新项目引用;重开从共享字节显示,undo/redo 只改变元素生命期,不再次上传。
|
||||
engine=await openProjectEngine(targetOptions);const adoptedId=randomUUID();assert.equal((await engine.adoptResource(imageResource.id,(handle,tx)=>tx.dispatch({type:'element.create',element:{...node(adoptedId,targetRoot),componentId:'image',data:{title:'Adopted existing image',text:'',assetId:handle.id,src:handle.src}}}))).status,'applied');await engine.settle();assert.equal(targetImage.formatVersion,13);assert.equal((await loadFoundryModel(transport,workspaceId,targetRoot,registry)).document.elements.get(adoptedId).data.assetId,imageResource.id);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);
|
||||
await engine.close();engine=await openProjectEngine(targetOptions);const display=await engine.displayResource(imageResource.id);assert.ok(display.url.startsWith('blob:'));assert.deepEqual(Buffer.from(await(await fetch(display.url)).arrayBuffer()),Buffer.from(await originalBytes.arrayBuffer()));display.release();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal((await loadFoundryModel(transport,workspaceId,targetRoot,registry)).document.elements.get(adoptedId).incarnation,2);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/assets')).length,uploadCount);
|
||||
// 仅 FOUNDRY_TEST_AI_WORKER=1 时运行 AI 扩展;随后允许测试作业写入非图片源文件,但仍检查不重复存图片字节。
|
||||
let aiReport={};if(process.env.FOUNDRY_TEST_AI_WORKER==='1'){
|
||||
await engine.close();adoptionOnly=false;aiReport=await verifyFoundryAI({open:()=>openProjectEngine({...targetOptions,runs:createFoundryRuns({workspaceId,rootId:targetRoot,registry,transport})}),transport,rootId:targetRoot,imageId:adoptedId,assets,calls,tick,readDisplay:async url=>{const response=await authenticatedFetch(url);assert.equal(response.status,200);return Buffer.from(await response.arrayBuffer());},advance:()=>{now+=10_000;},loseAdmission:()=>{loseNextAIAdmission=true;},loseAction:()=>{loseNextAIAction=true;}});
|
||||
}
|
||||
// 容许的错误只有显式注入的丢响应;其他异步错误不能被一并吞掉。
|
||||
assert.ok(errors.every(error=>String(error).includes('injected response loss')),'unexpected engine error: '+errors.map(String).join(','));
|
||||
// 全部断言通过才输出本次集成结果标记;AI 标记只有开关执行该分支时才加入。
|
||||
console.log(JSON.stringify({status:'ok',...aiReport,migrationCoordinator:true,migrationLostAckRecovered:true,migrationRetry:true,realHttp:true,lostAckRecovered:true,atomicPartition:true,deepCopyUpperEdge:true,dissolveCanvas:true,dissolveLostAckRecovered:true,dissolveUndoRedo:true,nativePartialReport:true,loadingImportOldCapture:true,unloadedTailDuringCopy:true,scopedImageLostAck:true,copiedPendingImageSharesUpload:true,authorizedAssetMetadata:true,originalAssetBytes:true,crossProjectAdoption:true,adoptionReopenedBytes:true,requests:calls.length}));
|
||||
}finally{await engine.close();}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
/**
|
||||
* 图片解码池的并发、排队、取消与响应校验测试。尺寸元数据必须合法,调用者取消不能提前释放仍在运行的槽位。
|
||||
* 使用解码执行器及 Worker 替身,不实际解码图片像素或启动浏览器线程。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createImageDecodePool} from '../../src/lib/canvas-runtime/image-decoder.ts';
|
||||
import {createBrowserImageDecoder} from '../../src/lib/canvas-runtime/browser-images.ts';
|
||||
// 手动完成的 Promise 控制解码退出;bytes 的 MIME 只是输入标签,替身执行器直接返回已知尺寸。
|
||||
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};},bytes=new Blob(['image'],{type:'image/png'});
|
||||
// 一个活动加一个等待名额,第三个拒绝;释放屏障后按 FIFO 完成,执行器计数峰值始终为一。
|
||||
test('image decoder bounds real execution slots, preserves FIFO and rejects excess work',async()=>{
|
||||
const gate=deferred(),started=[];let active=0,peak=0;const pool=createImageDecodePool({concurrency:1,maxQueued:1,execute:async file=>{active++;peak=Math.max(peak,active);started.push(await file.text());await gate.promise;active--;return{width:4,height:2};}});try{const first=pool.decode(bytes),second=pool.decode(new Blob(['second'],{type:'image/png'}));await assert.rejects(pool.decode(bytes),/image_decode_capacity/);gate.resolve();assert.deepEqual(await first,{width:4,height:2});assert.deepEqual(await second,{width:4,height:2});assert.deepEqual(started,['image','second']);assert.equal(peak,1);}finally{gate.resolve();pool.close();}
|
||||
});
|
||||
// 活动调用取消后,底层解码尚未结束时新任务仍排队;直到 gate 完成才启动下一次执行。
|
||||
test('cancelling active work does not free its slot before the underlying decoder settles',async()=>{
|
||||
const gate=deferred(),started=deferred(),cancel=new AbortController();let executions=0;const pool=createImageDecodePool({concurrency:1,execute:async()=>{executions++;started.resolve();await gate.promise;return{width:4,height:2};}});try{const first=pool.decode(bytes,cancel.signal),rejected=assert.rejects(first,/image_decode_aborted/);await started.promise;cancel.abort();await rejected;const second=pool.decode(bytes);await Promise.resolve();assert.equal(executions,1);gate.resolve();await second;assert.equal(executions,2);}finally{gate.resolve();pool.close();}
|
||||
});
|
||||
// 零宽尺寸不能成为可用元数据;Worker 返回错误请求 id 也要拒绝并终止线程替身,finally 恢复全局 Worker。
|
||||
test('invalid dimensions and failed workers do not produce usable metadata; worker identity is checked',async()=>{
|
||||
const invalid=createImageDecodePool({execute:async()=>({width:0,height:2})});await assert.rejects(invalid.decode(bytes),/invalid_image_dimensions/);invalid.close();const previous=globalThis.Worker;let terminated=0;globalThis.Worker=class{postMessage(message){queueMicrotask(()=>this.onmessage({data:{version:1,id:message.id+1,byteLength:message.bytes.size,width:1,height:1}}));}terminate(){terminated++;}};const pool=createBrowserImageDecoder();try{await assert.rejects(pool.decode(bytes),/invalid_image_response/);assert.equal(terminated,1);}finally{pool.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
|
||||
const invalid=createImageDecodePool({execute:async()=>({width:0,height:2})});await assert.rejects(invalid.decode(bytes),/invalid_image_dimensions/);invalid.close();const previous=globalThis.Worker;let terminated=0;globalThis.Worker=class{/* 异步回信的尺寸合法,唯独 id 错误,应被视为其他请求的结果。 */ postMessage(message){queueMicrotask(()=>this.onmessage({data:{version:1,id:message.id+1,byteLength:message.bytes.size,width:1,height:1}}));}/* 错误响应必须清理 worker,不能留在池内继续复用。 */ terminate(){terminated++;}};const pool=createBrowserImageDecoder();try{await assert.rejects(pool.decode(bytes),/invalid_image_response/);assert.equal(terminated,1);}finally{pool.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/**
|
||||
* 导入发布批量的自适应控制测试。目标耗时保持不变,根据固定发布成本与每项成本增减批量,而不是固定每批若干项。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createImportBatches} from '../../src/lib/canvas-runtime/import-batches.ts';
|
||||
// 从单项起步,在固定成本占主导且每项便宜时逐渐增至 32 上限;目标耗时仍为 6,不通过抬高预算换吞吐。
|
||||
test('fixed publication cost can grow beyond singleton batches without raising the time budget',()=>{
|
||||
const batches=createImportBatches(6,32);assert.equal(batches.observed,false);assert.equal(batches.limit,1);const sizes=[];
|
||||
for(let i=0;i<20;i++){const n=batches.limit;sizes.push(n);batches.record(n,4+n*.05);}
|
||||
assert.equal(batches.observed,true);assert.equal(batches.limit,32);assert.ok(sizes.includes(2));assert.ok(sizes.every(n=>n<=32));
|
||||
});
|
||||
// 不足一整批的尾项不用于虚增容量;昂贵整批迅速退回一项,后续单项成本降低后再渐进恢复。
|
||||
test('expensive batches back off, then recover as costs fall; partial tails do not inflate the limit',()=>{
|
||||
const batches=createImportBatches(6,32);for(let i=0;i<4;i++)batches.record(batches.limit,1);const full=batches.limit;batches.record(1,1);assert.equal(batches.limit,full);batches.record(full,60);assert.equal(batches.limit,1);batches.record(1,12);assert.equal(batches.limit,1);batches.record(1,2);assert.equal(batches.limit,2);batches.record(2,2);assert.equal(batches.limit,3);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
/**
|
||||
* 文档 parser 5 的渐进源测试:标准头部后可先输出前项,后续尾部仍可挂起、报错或尚未读取。
|
||||
* 记录序号稳定,已就绪项不应被未来扫描阻塞;错误尾部只追加一条终态失败,不撤回合法前缀。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {openStreamingDocumentImportSource} from '../../src/lib/canvas-runtime/import-document-stream.ts';
|
||||
// 为各次读取生成独立信号,测试可单独取消某个挂起读取而不使整个源永久不可用。
|
||||
const signal=()=>new AbortController().signal;
|
||||
// 最小标准 envelope 头部,后续用例在其后追加 elements/tombstones 或打乱字段顺序。
|
||||
const header={documentFormatVersion:1,projectId:'source',canvases:[{id:'root',parent:null}]};
|
||||
// 统一把对象/字符串转成 Blob,也接受可观测 Blob 子类;固定 fingerprint 并允许覆盖调度/字节端口。
|
||||
const open=(bytes,options={})=>openStreamingDocumentImportSource({bytes:bytes instanceof Blob?bytes:new Blob([typeof bytes==='string'?bytes:JSON.stringify(bytes)]),fingerprint:'immutable',signal:signal(),...options});
|
||||
// 手动 gate 和 entered 信号精确控制尾部 slice 返回,不依赖随意延时猜测并发时序。
|
||||
const deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return{promise,resolve};};
|
||||
|
||||
// 首项在尾部 gate 未打开时可读;未来项扫描挂起也不阻塞重读已就绪首项,取消后同序号可重新扫描成功。
|
||||
test('canonical document opens and emits its first record without reading the gated payload tail; aborted scanning can retry the same ordinal',async()=>{
|
||||
const gate=deferred(),entered=deferred(),reads=[];let block=true;
|
||||
// 记录切片,前 32 KiB 立即返回,之后提供可暂停 arrayBuffer;用于证明打开及首项读取不访问挂起尾部。
|
||||
class Source extends Blob{slice(start=0,end=this.size,type){reads.push([start,end]);const part=super.slice(start,end,type);if(start<32768||!block)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
|
||||
const bytes=new Source([JSON.stringify({...header,elements:[{id:'first'},{id:'second',text:'x'.repeat(100000)}],tombstones:{}})]),reader=await open(bytes);
|
||||
assert.equal(reader.parserVersion,5);assert.equal(reads.some(([start])=>start>=32768),false);
|
||||
@@ -16,11 +26,13 @@ test('canonical document opens and emits its first record without reading the ga
|
||||
assert.equal((await reader.read(1,signal())).value.record.id,'second');assert.equal(await reader.read(2,signal()),null);
|
||||
});
|
||||
|
||||
// 子 canvas 优先、元素随后,并发/重复读取和重开都保持相同序号与根 alias;跳过未定义范围的游标拒绝。
|
||||
test('reopening and concurrent reads keep stable canvas-first ordinals and original root aliases',async()=>{
|
||||
const input={...header,canvases:[...header.canvases,{id:'nested',parent:{canvasId:'root',entryElementId:'frame'}}],elements:[{id:'frame'},{id:'member',ownerCanvasId:'nested',data:{text:'图,] \\"'}}],tombstones:{old:2}},reader=await open(input);
|
||||
const rows=await Promise.all([0,1,2,1].map(position=>reader.read(position,signal())));assert.deepEqual(rows.map(row=>row.value.record.id),['nested','frame','member','frame']);assert.equal(rows[2].value.sourceRootId,'root');const reopened=await open(input);assert.deepEqual(await reopened.read(2,signal()),rows[2]);assert.equal(await reader.read(3,signal()),null);await assert.rejects(reader.read(5,signal()),/invalid_import_cursor/);
|
||||
});
|
||||
|
||||
// 非法墓碑、垃圾、重复字段、未知尾字段及尾逗号不撤回合法元素,只追加一条可推进游标的错误后结束。
|
||||
test('bad envelope tails preserve legal records and become one explicit terminal failure',async()=>{
|
||||
for(const suffix of [',"tombstones":{"bad":0}}','} trailing',',"elements":[]}',',"unknown":true}',',}']){
|
||||
const reader=await open(JSON.stringify({...header,elements:[{id:'first'},{id:'last'}]}).slice(0,-1)+suffix);
|
||||
@@ -29,22 +41,27 @@ test('bad envelope tails preserve legal records and become one explicit terminal
|
||||
const trailing=await open(JSON.stringify(header).slice(0,-1)+',"elements":[{"id":"first"},]}');assert.equal((await trailing.read(0,signal())).value.record.id,'first');assert.equal((await trailing.read(1,signal())).error,'invalid_document_import');
|
||||
});
|
||||
|
||||
// 非标准字段顺序沿用索引兼容路径,但仍以 parser 5 暴露;未来格式、循环分区及缺失父 canvas 在打开时拒绝。
|
||||
test('out-of-order headers retain indexed compatibility; invalid headers and cycles are rejected before publishing',async()=>{
|
||||
const reader=await open({elements:[{id:'first'}],...header});assert.equal(reader.parserVersion,5);assert.equal((await reader.read(0,signal())).value.record.id,'first');
|
||||
for(const input of [{...header,documentFormatVersion:2,elements:[]},{...header,canvases:[...header.canvases,{id:'cycle',parent:{canvasId:'cycle',entryElementId:'self'}}],elements:[]},{...header,canvases:[...header.canvases,{id:'lost',parent:{canvasId:'missing',entryElementId:'x'}}],elements:[]}])await assert.rejects(open(input),/invalid_document_import/);
|
||||
});
|
||||
|
||||
// 巨型正文只扫描并报告 record_too_large,后续合法记录仍读出,到末尾正常结束。
|
||||
test('oversized payload is scanned without decoding and later records still import',async()=>{
|
||||
const reader=await open({...header,elements:[{id:'huge',text:'x'.repeat(300000)},{id:'next'}]});assert.equal((await reader.read(0,signal())).error,'record_too_large');assert.equal((await reader.read(1,signal())).value.record.id,'next');assert.equal(await reader.read(2,signal()),null);
|
||||
});
|
||||
|
||||
// 1000 个小记录复用 16 KiB 扫描块,切片数量按文件块数增长而非记录数增长。
|
||||
test('sequential records reuse the source scan chunk rather than reading a new 16 KiB window per row',async()=>{
|
||||
// 只统计完整 16 KiB 扫描窗口请求,检查连续记录共用扫描块,而非把正文小范围读取也计为窗口。
|
||||
let chunks=0;class Counted extends Blob{slice(start=0,end=this.size,...rest){if(end-start===16384)chunks++;return super.slice(start,end,...rest);}}
|
||||
const input={...header,elements:Array.from({length:1000},(_,index)=>({id:`element-${index}`,text:'small'}))},bytes=new Counted([JSON.stringify(input)]),reader=await open(bytes);
|
||||
for(let index=0;index<1000;index++)assert.equal((await reader.read(index,signal())).value.record.id,`element-${index}`);
|
||||
assert.equal(await reader.read(1000,signal()),null);assert.ok(chunks<=Math.ceil(bytes.size/16384)+1,`${chunks} scan reads for ${bytes.size} bytes`);
|
||||
});
|
||||
|
||||
// 首次 await 前捕获 bytes/fingerprint;调用者随后替换 options 字段不能改变正在打开的源及其记录。
|
||||
test('source identity and bytes are captured before the first await, independent of caller option mutation',async()=>{
|
||||
const options={bytes:new Blob([JSON.stringify({...header,elements:[{id:'original'}]})]),fingerprint:'original-file',signal:signal()},opening=openStreamingDocumentImportSource(options);options.bytes=new Blob([JSON.stringify({...header,elements:[{id:'replaced'}]})]);options.fingerprint='replacement-file';const reader=await opening;assert.equal(reader.fingerprint,'original-file');assert.equal((await reader.read(0,signal())).value.record.id,'original');assert.equal(await reader.read(1,signal()),null);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
/**
|
||||
* 完整文档 envelope 的索引导入源测试(parser 4)。头部/拓扑先验证,元素正文按稳定序号读取,原根 canvas 映射到目标根。
|
||||
* 超大单条作为记录失败,不阻止后续合法项;文件尾结构损坏则拒绝打开完整索引源。
|
||||
*/
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {openDocumentImportSource} from '../../src/lib/canvas-runtime/import-document.ts';
|
||||
// 独立信号和 Blob 包装工厂;基线包含两层 canvas、Unicode/分隔符正文及墓碑,检查封装而非简单数组。
|
||||
const signal=()=>new AbortController().signal,source=value=>new Blob([typeof value==='string'?value:JSON.stringify(value)]),document={documentFormatVersion:1,projectId:'source',canvases:[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'frame'}}],elements:[{id:'frame',data:{title:'中文 [,] "'}},{id:'inside',ownerCanvasId:'child'}],tombstones:{old:2}};
|
||||
// 为每次打开绑定固定 fingerprint;重开序号读取应得到同样记录,不依赖上次内存游标。
|
||||
const open=bytes=>openDocumentImportSource({bytes,fingerprint:'original-file',signal:signal()});
|
||||
// 子 canvas 先输出,原根只作 alias;frame/member 以稳定序号读取,重开后同序号结果一致,到末尾返回 null。
|
||||
test('export envelope retains nested canvas records, emits root alias and seeks payloads by stable ordinal',async()=>{const reader=await open(source(document));assert.equal(reader.parserVersion,4);const a=await reader.read(0,signal()),b=await reader.read(1,signal());assert.equal(a.value.kind,'canvas');assert.equal(a.value.record.id,'child');assert.equal(a.value.sourceRootId,'root');assert.deepEqual(b.value.record,document.elements[0]);const reopened=await open(source(document));assert.deepEqual(await reopened.read(2,signal()),await reader.read(2,signal()));assert.equal(await reader.read(3,signal()),null);});
|
||||
// envelope 字段顺序可变,但未来版本、多根、缺失父 canvas、非法墓碑及重复键都不能生成可用源。
|
||||
test('envelope fields may be reordered, while future versions, duplicate keys and broken topology reject',async()=>{const reordered={elements:document.elements,tombstones:{},canvases:document.canvases,projectId:'source',documentFormatVersion:1};assert.equal((await(await open(source(reordered))).read(1,signal())).value.record.id,'frame');for(const bad of [{...document,documentFormatVersion:2},{...document,canvases:[...document.canvases,{id:'other',parent:null}]},{...document,canvases:[{id:'root',parent:null},{id:'child',parent:{canvasId:'missing',entryElementId:'e'}}]},{...document,tombstones:{x:0}},'{"documentFormatVersion":1,"documentFormatVersion":1}'])await assert.rejects(open(source(bad)));});
|
||||
// 超过记录上限时不解码巨型正文并报告 record_too_large,后续记录仍可读;尾部垃圾及预先取消明确拒绝。
|
||||
test('payload errors are per record and large records are never decoded; malformed envelope tails reject',async()=>{const reader=await open(source({...document,elements:[{id:'huge',text:'x'.repeat(300000)},document.elements[1]]}));assert.equal((await reader.read(1,signal())).error,'record_too_large');assert.equal((await reader.read(2,signal())).value.record.id,'inside');await assert.rejects(open(source(JSON.stringify(document)+'garbage')));const controller=new AbortController();controller.abort();await assert.rejects(openDocumentImportSource({bytes:source(document),fingerprint:'x',signal:controller.signal}),/import_aborted/);});
|
||||
import {prepareNativeImport} from '../../src/lib/canvas-runtime/import-native.ts';
|
||||
import {createImportJob} from '../../src/lib/canvas-runtime/imports.ts';
|
||||
import {createRegistry,createDocument} from '../../src/lib/canvas-core/index.ts';
|
||||
// 原根 alias 同时映射 owner 与组件声明的 canvasRef;不能为原根再 reserve 一个多余的新 canvas ID。
|
||||
test('document root aliases also remap component-declared canvas references without reserving a duplicate root',()=>{const registry=createRegistry([{id:'ref',dataVersion:1,structure:'node',features:[],validate:()=>true,copy:{references:[{path:['canvasRef'],target:'canvas',external:'reject'}],omit:[]}}]),target=createDocument('p',[{id:'target',parent:null}],[]),job=createImportJob({id:'job',projectId:'p',parserVersion:4,sourceFingerprint:'file',source:{kind:'native-document',fileId:'file',byteLength:100,targetCanvasId:'target'}}),reserved=[],record={id:'a',incarnation:1,ownerCanvasId:'source',componentId:'ref',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:10,height:10,rotation:0},data:{canvasRef:'source'}},unit=prepareNativeImport({cursor:0,nextCursor:1,key:'record:0',value:{kind:'element',key:'a',record,sourceRootId:'source'}},job,key=>{reserved.push(key);return'new-'+key;},target,registry);assert.equal(unit.changes[0].after.data.canvasRef,'target');assert.equal(unit.changes[0].after.ownerCanvasId,'target');assert.ok(!reserved.includes('canvas:source'));});
|
||||
// 小于固定字节上限的扫描也要按实际耗时让出,yield 内取消后以 import_aborted 拒绝。
|
||||
test('document preparation yields by elapsed time even for files below the byte ceiling',async()=>{const controller=new AbortController();let clock=0,yields=0;await assert.rejects(openDocumentImportSource({bytes:source({...document,elements:[{id:'slow',text:'x'.repeat(2048)}]}),fingerprint:'slow',signal:controller.signal,now:()=>clock+=10,frameBudgetMs:6,yieldTask:async()=>{yields++;controller.abort();}}),/import_aborted/);assert.equal(yields,1);});
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* 导入稳定 ID 表的不可变捕获与增量分配测试。安全冻结表可缓存反向身份集合,外部可变/getter 表必须防御性捕获。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createImportIdentities} from '../../src/lib/canvas-runtime/import-identities.ts';
|
||||
// 万项冻结表反复 reserve 已有键不分配新 ID,也不重复 ownKeys 扫描;clear 后下次捕获才重新扫描。
|
||||
test('preallocated 10000-ID tables keep their identity across records without allocating or rescanning',()=>{
|
||||
let reads=0;const raw=Object.freeze(Object.fromEntries(Array.from({length:10000},(_,i)=>['key'+i,'id'+i]))),ids=new Proxy(raw,{ownKeys(target){reads++;return Reflect.ownKeys(target);}}),capture=createImportIdentities(),allocate=()=>{throw Error('unexpected allocation');};
|
||||
let reads=0;const raw=Object.freeze(Object.fromEntries(Array.from({length:10000},(_,i)=>['key'+i,'id'+i]))),ids=new Proxy(raw,{/* 只观测枚举次数,不改变表内容,用于识别不必要的万项重扫。 */ ownKeys(target){reads++;return Reflect.ownKeys(target);}}),capture=createImportIdentities(),allocate=()=>{throw Error('unexpected allocation');};
|
||||
const first=capture(ids,allocate);assert.equal(first.reserve('key0'),'id0');assert.strictEqual(first.finish(),ids);const checked=reads;for(let i=0;i<10000;i++){const row=capture(ids,allocate);assert.equal(row.reserve('key'+i),'id'+i);assert.ok(row.has('id'+i));assert.strictEqual(row.finish(),ids);}assert.equal(reads,checked);capture.clear();capture(ids,allocate);assert.ok(reads>checked);
|
||||
});
|
||||
// 首次新增键才分离字典及反向集合,finish 结果不受之后 reserve 影响;兄弟捕获独立,重复目标 ID 必须拒绝。
|
||||
test('first allocation detaches the dictionary and reverse set, and a late callback cannot change its published result',()=>{
|
||||
const ids=Object.freeze({old:'old-id'}),capture=createImportIdentities();let next=0;const row=capture(ids,()=>`new-${++next}`);row.reserve('added');const result=row.finish();assert.notStrictEqual(result,ids);row.reserve('late');assert.deepEqual(result,{old:'old-id',added:'new-1'});assert.deepEqual(ids,{old:'old-id'});const sibling=capture(ids,()=> 'new-1');assert.equal(sibling.has('new-1'),false);assert.equal(sibling.reserve('other'),'new-1');assert.throws(()=>capture(ids,()=> 'old-id').reserve('duplicate'),/duplicate_import_identity/);
|
||||
});
|
||||
// 可变字典捕获当时值,不受随后外部改写影响;冻结 getter 仍逐次重新读取,不能仅凭 Object.freeze 复用动态值。
|
||||
test('mutable and frozen getter dictionaries are captured defensively rather than reused',()=>{
|
||||
const capture=createImportIdentities(),mutable={one:'first'},first=capture(mutable,()=> 'new');mutable.one='second';assert.equal(first.reserve('one'),'first');assert.deepEqual(first.finish(),{one:'first'});assert.equal(capture(mutable,()=> 'new').reserve('one'),'second');
|
||||
let current='old',reads=0;const dynamic=Object.freeze({get one(){reads++;return current;}}),one=capture(dynamic,()=> 'new');current='changed';assert.equal(one.reserve('one'),'old');assert.equal(reads,1);assert.equal(capture(dynamic,()=> 'new').reserve('one'),'changed');assert.equal(reads,2);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/**
|
||||
* 导入源全文件 SHA-256 绑定与任务完整性版本测试。新任务在绑定摘要前不能声明已解析游标或稳定映射,旧格式按明确兼容规则读取。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
|
||||
import {createImportJob,validateImportJob} from '../../src/lib/canvas-runtime/imports.ts';
|
||||
// 标准 abc 向量和不同输入区分完整源摘要;预先取消及异步读取返回前取消都拒绝,不接受迟到结果。
|
||||
test('source SHA256 binds complete bytes and cancellation fences both sides of asynchronous reading',async()=>{
|
||||
assert.equal(await importSourceDigest(new Blob(['abc'])),'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');assert.notEqual(await importSourceDigest(new Blob(['abc'])),await importSourceDigest(new Blob(['abd'])));
|
||||
const controller=new AbortController();controller.abort();await assert.rejects(importSourceDigest(new Blob(['abc']),controller.signal),/import_aborted/);let release;const gate=new Promise(resolve=>{release=resolve;}),later=new AbortController(),bytes=new class extends Blob{async arrayBuffer(){await gate;return super.arrayBuffer();}}(['abc']),result=importSourceDigest(bytes,later.signal);later.abort();release();await assert.rejects(result,/import_aborted/);
|
||||
const controller=new AbortController();controller.abort();await assert.rejects(importSourceDigest(new Blob(['abc']),controller.signal),/import_aborted/);let release;const gate=new Promise(resolve=>{release=resolve;}),later=new AbortController(),bytes=new class extends Blob{/* 此替身只覆写整 Blob 的 arrayBuffer;当前摘要走 slice().arrayBuffer(),故 gate 不拦截分块读取。本例断言调用后取消的拒绝,读块挂起场景另由 sha256.test.mjs 覆盖。 */ async arrayBuffer(){await gate;return super.arrayBuffer();}}(['abc']),result=importSourceDigest(bytes,later.signal);later.abort();release();await assert.rejects(result,/import_aborted/);
|
||||
});
|
||||
// 新 job 为 v3/integrityVersion=1,未绑定摘要前拒绝进度,非法摘要/未来完整性解释器拒绝;显式旧格式及已绑定新格式仍可校验。
|
||||
test('new source jobs declare integrity version and reject progress without a bound hash; legacy data remains readable',()=>{
|
||||
const job=createImportJob({id:'i',projectId:'p',sourceFingerprint:'file',parserVersion:1,source:{kind:'native-ndjson',fileId:'file',targetCanvasId:'canvas',byteLength:10}});assert.equal(job.version,3);assert.equal(job.source.integrityVersion,1);validateImportJob(job);
|
||||
assert.throws(()=>validateImportJob({...job,cursor:1}),/import_source_integrity_missing/);assert.throws(()=>validateImportJob({...job,stableIds:{'element:A':'new-A'}}),/import_source_integrity_missing/);assert.throws(()=>validateImportJob({...job,source:{...job.source,sha256:'invalid'}}),/invalid_import_source/);assert.throws(()=>validateImportJob({...job,source:{...job.source,integrityVersion:3}}),/import_source_integrity_interpreter_unavailable/);
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
/**
|
||||
* JSON 数组渐进读取测试。按字节游标扫描 UTF-8 与嵌套结构,可保留已接受前缀并将坏尾部作为显式失败行。
|
||||
* 读取器还需限制单条解码大小,在固定字节工作量或实际耗时达到预算时让出。
|
||||
*/
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {createJsonArrayImportSource} from '../../src/lib/canvas-runtime/import-json-array.ts';
|
||||
// 每次读取提供独立未取消信号,取消用例显式使用自己的 controller。
|
||||
const signal=()=>new AbortController().signal;
|
||||
// 把文本包装成 Blob,绑定 fingerprint,并允许各用例覆盖 chunkBytes、单条上限及调度端口。
|
||||
function source(text,options={}){return createJsonArrayImportSource({bytes:new Blob([text]),fingerprint:'immutable-source',...options});}
|
||||
// 以 nextCursor 顺序排空且断言游标递增,最多 100 行,避免测试在读取器不前进时无限循环。
|
||||
async function all(reader){let cursor=0;const rows=[];for(let i=0;i<100;i++){const row=await reader.read(cursor,signal());if(!row)return rows;assert.ok(row.nextCursor>cursor);rows.push(row);cursor=row.nextCursor;}throw Error('reader did not terminate');}
|
||||
// 每字节分块处理中文、转义引号和字符串中的括号/逗号,结果与原生 JSON 相同;换块宽重开仍能从旧字节游标继续。
|
||||
test('array reader handles UTF8, escaped strings and nested delimiters across one-byte chunks and resumes from the same byte cursor',async()=>{const text=' [ {"title":"图\\\"片,]","nested":[1,{"x":"]"}]},\n {"title":"second"} ] ',reader=source(text,{chunkBytes:1}),rows=await all(reader);assert.deepEqual(rows.map(r=>r.value),JSON.parse(text));assert.ok(rows.every(r=>!r.error));const reopened=source(text,{chunkBytes:3});assert.deepEqual(await reopened.read(rows[1].cursor,signal()),rows[1]);assert.equal(await reopened.read(rows[1].nextCursor,signal()),null);});
|
||||
// 缺失/多余分隔和尾部垃圾保留已接受第一项,随后报告失败;空数组正常,空文件或非法尾逗号明确拒绝。
|
||||
test('structurally malformed tails report failure after the already accepted prefix',async()=>{for(const tail of [',]',',','','] garbage']){const rows=await all(source('[{"id":1},{"id":2}'+tail));assert.deepEqual(rows[0].value,{id:1});assert.ok(rows.at(-1).error,tail);}assert.deepEqual(await all(source('[]')),[]);await assert.rejects(all(source('')),/invalid_json_array/);assert.equal((await all(source('[1,]')))[0].error,'invalid_json_array');});
|
||||
// 超大字符串只扫描不解码,报告单条错误后仍读到后续对象;非法 UTF-8 保留明确错误。
|
||||
test('oversized records skip bounded payload and allow a later valid record; bad UTF8 remains explicit',async()=>{const rows=await all(source(JSON.stringify(['x'.repeat(200),{ok:true}]),{maxRecordBytes:32,chunkBytes:8}));assert.equal(rows[0].error,'record_too_large');assert.deepEqual(rows[1].value,{ok:true});const reader=createJsonArrayImportSource({bytes:new Blob([new Uint8Array([91,34,255,34,93])]),fingerprint:'bad'});assert.equal((await all(reader))[0].error,'invalid_utf8');});
|
||||
// 长扫描在工作量边界让出,yield 内取消不能返回已完成记录;负字节游标拒绝。
|
||||
test('long scans yield and honor abort without publishing a completed record',async()=>{const controller=new AbortController();let yields=0;const reader=source(JSON.stringify(['x'.repeat(40000)]),{now:()=>0,yieldTask:async()=>{yields++;controller.abort();}});await assert.rejects(reader.read(0,controller.signal),/import_aborted/);assert.equal(yields,1);await assert.rejects(reader.read(-1,signal()),/invalid_import_cursor/);});
|
||||
// 即使只扫描 2048 字符,耗时预算先到也应让出,取消错误保持 import_aborted。
|
||||
test('slow array scans yield by elapsed time before the fixed byte ceiling and abort at that boundary',async()=>{const controller=new AbortController();let clock=0,yields=0;const reader=source(JSON.stringify(['x'.repeat(2048)]),{now:()=>clock+=10,frameBudgetMs:6,yieldTask:async()=>{yields++;controller.abort();}});await assert.rejects(reader.read(0,controller.signal),/import_aborted/);assert.equal(yields,1);});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 导入按块读取证明测试。证明只记录成功消费范围涉及的对齐块摘要,不为尚未读到的文件尾部提供真实性保证。
|
||||
* 原始块缓存可淘汰,但预期摘要保留;共享读取、取消、关闭与容量限制不能提前发布半份读取证据。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createImportReadProof,validateImportReadProof,IMPORT_PROOF_BLOCK_BYTES as B} from '../../src/lib/canvas-runtime/import-read-proof.ts';
|
||||
@@ -6,10 +10,14 @@ import {openStreamingDocumentImportSource} from '../../src/lib/canvas-runtime/im
|
||||
import {createNdjsonImportSource} from '../../src/lib/canvas-runtime/imports.ts';
|
||||
import {createJsonArrayImportSource} from '../../src/lib/canvas-runtime/import-json-array.ts';
|
||||
import {fault} from '../../src/lib/canvas-core/values.ts';
|
||||
// 独立读取信号和可控 Promise,分别控制取消者、其他等待者及底层原生读取返回。
|
||||
const signal=()=>new AbortController().signal,deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return{promise,resolve};};
|
||||
// 使用 WebCrypto 独立计算预期块摘要,对照证明中记录的 SHA-256。
|
||||
const sha=async bytes=>Buffer.from(await crypto.subtle.digest('SHA-256',bytes)).toString('hex');
|
||||
// 让出一次真实 timer,使不可取消的底层读在 gate 释放后有机会完成清理。
|
||||
const tick=()=>new Promise(resolve=>setTimeout(resolve,0));
|
||||
|
||||
// 跨两个块的范围返回精确且独立的字节副本,调用者修改不影响重读;证明只含消费过的对齐块,缓存可仅驻留一个块。
|
||||
test('range reads return exact owned bytes and checkpoint only the aligned blocks they consumed',async()=>{
|
||||
const data=Uint8Array.from({length:B*3+19},(_,i)=>i%251),bytes=new Blob([data]),reader=createImportReadProof({bytes,fileId:'f',maxResidentBlocks:1});
|
||||
const value=await reader.port.read(B-5,B+7,signal());assert.deepEqual(value,data.slice(B-5,B+7));value.fill(0);assert.deepEqual(await reader.port.read(B-5,B+7,signal()),data.slice(B-5,B+7));
|
||||
@@ -17,52 +25,61 @@ test('range reads return exact owned bytes and checkpoint only the aligned block
|
||||
assert.deepEqual(validateImportReadProof(JSON.parse(JSON.stringify(proof)),'f',bytes.size),proof);reader.close();assert.equal(reader.stats().residentBlocks,0);
|
||||
});
|
||||
|
||||
// 尾部块读取不可用时,parser 5 仍返回首项;证明仅含第一块,不需要先认证整份文件。
|
||||
test('parser 5 emits its first proof-backed record while the unread tail is unavailable',async()=>{
|
||||
const reads=[];class Gated extends Blob{slice(start=0,end=this.size){reads.push([start,end]);if(start>=B)throw Error('tail unavailable');return super.slice(start,end);}}
|
||||
const reads=[];/* 记录原始块请求,并让首块之后的读取立即失败,检验首项是否提前依赖尾部。 */ class Gated extends Blob{slice(start=0,end=this.size){reads.push([start,end]);if(start>=B)throw Error('tail unavailable');return super.slice(start,end);}}
|
||||
const bytes=new Gated([JSON.stringify({documentFormatVersion:1,projectId:'p',canvases:[{id:'root',parent:null}],elements:[{id:'first'},{id:'later',text:'x'.repeat(B*3)}]})]),reader=createImportReadProof({bytes,fileId:'f'});
|
||||
const source=await openStreamingDocumentImportSource({bytes,readBytes:reader.port,fingerprint:'f',signal:signal()});assert.equal((await source.read(0,signal())).value.record.id,'first');assert.deepEqual(reader.getProof().blocks.map(block=>block.index),[0]);assert.equal(reads.some(([start])=>start>=B),false);reader.close();
|
||||
});
|
||||
|
||||
// NDJSON、数组和乱序文档兼容路径均通过注入的 readBytes 端口读取,并形成实际消费证明。
|
||||
test('NDJSON, array and out-of-order document fallback all use the byte reader',async()=>{
|
||||
const cases=[['{"id":"a"}\n',o=>createNdjsonImportSource(o)],['[{"id":"a"}]',o=>createJsonArrayImportSource(o)],[JSON.stringify({elements:[{id:'a'}],documentFormatVersion:1,projectId:'p',canvases:[{id:'root',parent:null}]}),o=>openStreamingDocumentImportSource({...o,signal:signal()})]];
|
||||
for(const[text,open]of cases){const bytes=new Blob([text]),reader=createImportReadProof({bytes,fileId:'f'}),source=await open({bytes,readBytes:reader.port,fingerprint:'f'}),row=await source.read(0,signal());assert.equal((row.value.record??row.value).id,'a');assert.equal(reader.getProof().blocks.length,1);reader.close();}
|
||||
});
|
||||
|
||||
// 恢复证明只重验已消费的第 0/2 块,变化即拒绝;未消费第 1 块变化不误报已验证,首次读取时才纳入新证明。
|
||||
test('restored proof rejects changed consumed bytes and does not claim to authenticate unread blocks',async()=>{
|
||||
const data=new Uint8Array(B*3).fill(7),first=createImportReadProof({bytes:new Blob([data]),fileId:'f'});await first.port.read(0,1,signal());await first.port.read(B*2,B*2+1,signal());const proof=first.getProof();first.close();
|
||||
const changed=data.slice();changed[B*2]=8;const bad=createImportReadProof({bytes:new Blob([changed]),fileId:'f',proof});await assert.rejects(bad.verify(signal()),/import_source_changed/);assert.strictEqual(bad.getProof().blocks.length,2);bad.close();
|
||||
changed[B*2]=7;changed[B]=8;const unseen=createImportReadProof({bytes:new Blob([changed]),fileId:'f',proof});await unseen.verify(signal());assert.deepEqual(unseen.getProof(),proof);assert.equal((await unseen.port.read(B,B+1,signal()))[0],8);assert.equal(unseen.getProof().blocks.length,3);unseen.close();
|
||||
});
|
||||
|
||||
// 原始字节淘汰不丢预期 hash;已消费块后来篡改时拒绝且不污染旧证明,恢复原字节后可重试。
|
||||
test('eviction does not discard the expected hash; changed rereads fail without poisoning a retry',async()=>{
|
||||
const data=new Uint8Array(B*2).fill(4);let altered=false;class Mutable extends Blob{slice(start,end){const part=data.slice(start,end);if(altered&&start===0)part[0]=9;return new Blob([part]);}}
|
||||
const data=new Uint8Array(B*2).fill(4);let altered=false;/* 人为违反 Blob 字节稳定性;淘汰缓存后重读首块会见到改变,用于校验保存的预期摘要。 */ class Mutable extends Blob{slice(start,end){const part=data.slice(start,end);if(altered&&start===0)part[0]=9;return new Blob([part]);}}
|
||||
const reader=createImportReadProof({bytes:new Mutable([data]),fileId:'f',maxResidentBlocks:1});await reader.port.read(0,1,signal());await reader.port.read(B,B+1,signal());const proof=reader.getProof();altered=true;await assert.rejects(reader.port.read(0,1,signal()),/import_source_changed/);assert.strictEqual(reader.getProof(),proof);altered=false;assert.equal((await reader.port.read(0,1,signal()))[0],4);reader.close();
|
||||
});
|
||||
|
||||
// 两个 waiter 共享同一原生块读取;取消一个不取消另一个,也不提前推进 proof,剩余 waiter 成功后才提交块证据。
|
||||
test('one canceled waiter cannot cancel a shared block or advance its checkpoint',async()=>{
|
||||
const gate=deferred();let reads=0;class Slow extends Blob{slice(start,end){const part=super.slice(start,end);return{async arrayBuffer(){reads++;await gate.promise;return part.arrayBuffer();}};}}
|
||||
const gate=deferred();let reads=0;/* 把底层块读取停在 gate,模拟调用者已取消但不可取消的原生 I/O 尚未返回。 */ class Slow extends Blob{slice(start,end){const part=super.slice(start,end);return{async arrayBuffer(){reads++;await gate.promise;return part.arrayBuffer();}};}}
|
||||
const reader=createImportReadProof({bytes:new Slow([new Uint8Array(B)]),fileId:'f'}),controller=new AbortController(),a=reader.port.read(0,1,controller.signal),b=reader.port.read(1,2,signal());controller.abort();await assert.rejects(a,/import_aborted/);assert.equal(reader.getProof().blocks.length,0);assert.equal(reader.stats().pendingBlocks,1);gate.resolve();await b;assert.equal(reads,1);assert.equal(reader.getProof().blocks.length,1);reader.close();
|
||||
});
|
||||
|
||||
// 跨块范围取消后不发布已读首块的部分证据;close 幂等拒绝新/挂起调用,底层读真正退出后才清空 pending/resident。
|
||||
test('canceled multi-block reads and close never publish partial read evidence',async()=>{
|
||||
const gate=deferred(),entered=deferred();class Slow extends Blob{slice(start,end){const part=super.slice(start,end);if(start===0)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
|
||||
const gate=deferred(),entered=deferred();/* 把底层块读取停在 gate,模拟调用者已取消但不可取消的原生 I/O 尚未返回。 */ class Slow extends Blob{slice(start,end){const part=super.slice(start,end);if(start===0)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
|
||||
const reader=createImportReadProof({bytes:new Slow([new Uint8Array(B*2)]),fileId:'f'}),controller=new AbortController(),read=reader.port.read(0,B+1,controller.signal);await entered.promise;controller.abort();await assert.rejects(read,/import_aborted/);assert.equal(reader.getProof().blocks.length,0);
|
||||
const late=reader.port.read(B,B+1,signal());reader.close();reader.close();await assert.rejects(late,/import_source_closed/);assert.equal(reader.stats().pendingBlocks,1);gate.resolve();await tick();assert.equal(reader.stats().pendingBlocks,0);assert.equal(reader.stats().residentBlocks,0);assert.equal(reader.getProof().blocks.length,0);await assert.rejects(reader.port.read(0,0,signal()),/import_source_closed/);
|
||||
});
|
||||
|
||||
// 四个不可取消的原生读取已占容量,即使调用者全 abort 也不能新开第五个;底层退出后才释放,取消读取不进证明。
|
||||
test('bounded admission retains occupied native reads until settlement instead of opening more slots on abort',async()=>{
|
||||
const gate=deferred();let reads=0;class Slow extends Blob{slice(start,end){const part=super.slice(start,end);return{async arrayBuffer(){reads++;await gate.promise;return part.arrayBuffer();}};}}
|
||||
const gate=deferred();let reads=0;/* 把底层块读取停在 gate,模拟调用者已取消但不可取消的原生 I/O 尚未返回。 */ class Slow extends Blob{slice(start,end){const part=super.slice(start,end);return{async arrayBuffer(){reads++;await gate.promise;return part.arrayBuffer();}};}}
|
||||
const reader=createImportReadProof({bytes:new Slow([new Uint8Array(B*5)]),fileId:'f',maxResidentBlocks:2}),controllers=Array.from({length:4},()=>new AbortController()),pending=controllers.map((controller,i)=>reader.port.read(B*i,B*i+1,controller.signal));
|
||||
await assert.rejects(reader.port.read(B*4,B*4+1,signal()),/import_read_capacity/);assert.equal((await reader.port.read(B*4,B*4,signal())).byteLength,0);controllers.forEach(controller=>controller.abort());await Promise.all(pending.map(task=>assert.rejects(task,/import_aborted/)));assert.equal(reader.stats().activeReads,0);assert.equal(reader.stats().pendingBlocks,4);await assert.rejects(reader.port.read(B*4,B*4+1,signal()),/import_read_capacity/);assert.equal(reads,4);gate.resolve();await tick();assert.ok(reader.stats().residentBlocks<=2);assert.equal(reader.getProof().blocks.length,0);reader.close();
|
||||
});
|
||||
|
||||
// 零长度范围不读块、不新增证明;未来/错身份/错长度/错块宽/额外字段/非法摘要与范围全部拒绝。
|
||||
test('zero-length reads do no I/O and invalid proof, identity and ranges fail closed',async()=>{
|
||||
let reads=0;class Count extends Blob{slice(...args){reads++;return super.slice(...args);}}
|
||||
let reads=0;/* 对 slice 计数,证明零长度读取不触发底层 I/O。 */ class Count extends Blob{slice(...args){reads++;return super.slice(...args);}}
|
||||
const bytes=new Count(['abc']),reader=createImportReadProof({bytes,fileId:'f'});await reader.port.read(1,1,signal());await reader.port.read(3,3,signal());assert.equal(reads,0);assert.equal(reader.getProof().blocks.length,0);const proof=reader.getProof();
|
||||
for(const bad of [{...proof,version:2},{...proof,fileId:'other'},{...proof,byteLength:4},{...proof,blockBytes:1},{...proof,extra:true},{...proof,blocks:[{index:1,sha256:'a'.repeat(64)}]},{...proof,blocks:[{index:0,sha256:'A'.repeat(64)}]},{...proof,blocks:[{index:0,sha256:'a'.repeat(64)},{index:0,sha256:'a'.repeat(64)}]}])assert.throws(()=>createImportReadProof({bytes,fileId:'f',proof:bad}),/import_read_proof/);
|
||||
for(const[start,end]of [[-1,1],[2,1],[0,4],[.5,1]])await assert.rejects(reader.port.read(start,end,signal()),/invalid_import_byte_range/);reader.close();
|
||||
});
|
||||
|
||||
// 字节端口返回过短结果或源长度不符时拒绝;完整性错误不能被包装为 UTF-8 格式错误,修复后同记录可重试。
|
||||
test('byte ports cannot return short bytes, wrong source length, or disguise integrity errors as UTF-8 failures',async()=>{
|
||||
const bytes=new Blob(['abcdef']);await assert.rejects(readImportRange(bytes,{byteLength:6,async read(){return new Uint8Array(1);}},0,2,signal()),/import_source_changed/);await assert.rejects(readImportRange(bytes,{byteLength:7,async read(){throw Error('must not run');}},0,2,signal()),/invalid_import_byte_range/);
|
||||
const document=new Blob([JSON.stringify({documentFormatVersion:1,projectId:'p',canvases:[{id:'root',parent:null}],elements:[{id:'a'}]})]);let rejectDecode=false;const port={byteLength:document.size,async read(start,end){if(rejectDecode&&end-start<100)throw fault('import_source_changed');return new Uint8Array(await document.slice(start,end).arrayBuffer());}};
|
||||
@@ -70,6 +87,7 @@ test('byte ports cannot return short bytes, wrong source length, or disguise int
|
||||
});
|
||||
|
||||
|
||||
// 未来块临时 I/O 失败暂停该次读取,不把序号推进成终态坏尾部;恢复网络后重读同序号获得 b,再正常 EOF。
|
||||
test('transient byte I/O failure does not advance a streaming ordinal into a terminal malformed-tail error',async()=>{
|
||||
const bytes=new Blob([JSON.stringify({documentFormatVersion:1,projectId:'p',canvases:[{id:'root',parent:null}],elements:[{id:'a'},{id:'b',text:'x'.repeat(B*2)}]})]);let fail=true;
|
||||
const readBytes={byteLength:bytes.size,async read(start,end){if(fail&&start>=B)throw Error('offline');return new Uint8Array(await bytes.slice(start,end).arrayBuffer());}};
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* ProjectEngine 的渐进导入/深复制集成测试,串联本地文档、history、ledger、源文件、校验及恢复。
|
||||
* 已发布内容可早于服务端 ACK;分块读取证明支持先显示,完整 SHA 与其持久化 seal 决定何时解除相关网络写入门槛。
|
||||
* 暂停/关闭/取消、端点换代、旧解释器、资源引用及源文件回收各有独立生命周期,不能用重新插入全部内容来恢复。
|
||||
* fixture 使用内存存储和幂等假服务端;测试验证引擎协调,不等同于真实 IDB 事务、网络或渲染帧率验证。
|
||||
*/
|
||||
import {encodeCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot.ts';
|
||||
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
|
||||
import {test} from 'node:test';
|
||||
@@ -5,29 +11,64 @@ import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
// 内置类型、空单 canvas 基线及固定作用域,关闭/重开读取同一个项目的恢复 image。
|
||||
const registry=createRegistry(builtinComponents()),original=createDocument('project',[{id:'canvas',parent:null}],[]),scope={accountId:'account',workspaceId:'workspace',projectId:'project'};
|
||||
// native NDJSON 记录工厂故意携带第八代身份和 runId;导入应生成新身份并去掉执行任务字段。
|
||||
const node=id=>({kind:'element',key:id,record:{id,incarnation:8,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:id,text:'',runId:'must not run'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}});
|
||||
// 对象序列编码为 NDJSON,也允许传入原始坏行以检查部分失败。
|
||||
const file=records=>new Blob([records.map(value=>typeof value==='string'?value:JSON.stringify(value)).join('\n')]);
|
||||
// 手动 gate 与 entered 信号控制服务端 ACK、摘要、源读取或持久化,避免靠固定延时决定事件顺序。
|
||||
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
|
||||
// 准备可重用的源文件/image/服务端文档和回执;broken 在写入前抛配额错误,gate 可挂起服务端提交。
|
||||
function fixture(initial=original){
|
||||
let image=null,leader,next=0,server=initial;const versions=new Map([...initial.canvases.keys()].map(id=>[id,1]));const files=new Map(),requests=[],receipts=new Map(),errors=[];
|
||||
const f={files,requests,errors,gate:null,broken:false,
|
||||
storage:{fileDeletion:'atomic-v1',async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},close(){}},
|
||||
port:{compile(intent,requestId,snapshot){return{payload:{requestId,intent},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){requests.push(request);if(f.gate)await f.gate.promise;const{intent}=JSON.parse(request.body);if(!receipts.has(request.id)){server=applyChanges(server,intent.changes);for(const canvasId of intent.canvasIds)versions.set(canvasId,(versions.get(canvasId)??0)+1);receipts.set(request.id,{document:server,versions:new Map(versions)});}return{outcome:'applied',canonical:{requestId:request.id}};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:{requestId:request.id}}:{outcome:'unresolved'};},canonical(request){return receipts.get(request.id);},async read(){return{document:server,versions:new Map(versions)};}},
|
||||
image:()=>image,server:()=>server,open:(extra={})=>openProjectEngine({scope,registry,document:initial,versions:new Map([...initial.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,createId:()=>`identity-${++next}`,onError:error=>errors.push(error),...extra})};return f;
|
||||
storage:{fileDeletion:'atomic-v1',
|
||||
// 单项目仅一个 leader;release 清空 owner 并撤销活动标记,允许关闭后重新领取。
|
||||
async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},
|
||||
// 克隆上次 image;用例重开应通过恢复 codec 读取它,而非复用旧引擎状态。
|
||||
async recover(){return image&&structuredClone(image);},
|
||||
// 先核对 leader 并检查配额,再保存源文件/删除/克隆 image;此内存实现不模拟真实事务的部分失败回滚。
|
||||
async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},
|
||||
// 按 ID 返回持久化 Blob,且要求当前 leader;用例可替换为挂起读取、损坏字节或 unavailable。
|
||||
async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},
|
||||
// 内存端口无外部句柄;引擎负责归还 leader。
|
||||
close(){}},
|
||||
port:{
|
||||
// 编译携带意图和 requestId 的候选请求,参与 canvas 使用当前 ACK 版本,未建立的新 canvas 基线为零。
|
||||
compile(intent,requestId,snapshot){return{payload:{requestId,intent},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},
|
||||
// 记录请求并可卡在 gate;同 requestId 已有 receipt 时不再次应用,模拟后端幂等,canonical 使用该次保存的基线。
|
||||
async submit(request){requests.push(request);if(f.gate)await f.gate.promise;const{intent}=JSON.parse(request.body);if(!receipts.has(request.id)){server=applyChanges(server,intent.changes);for(const canvasId of intent.canvasIds)versions.set(canvasId,(versions.get(canvasId)??0)+1);receipts.set(request.id,{document:server,versions:new Map(versions)});}return{outcome:'applied',canonical:{requestId:request.id}};},
|
||||
// 按请求 ID 查询执行事实;无保存回执时保持 unresolved,不推定零写入。
|
||||
async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:{requestId:request.id}}:{outcome:'unresolved'};},
|
||||
// 取对应请求保存的 document/versions,防止用当前 working 冒充服务端确认。
|
||||
canonical(request){return receipts.get(request.id);},
|
||||
// 恢复会话读取当前服务端文档及版本表副本,避免外部修改同一个 Map。
|
||||
async read(){return{document:server,versions:new Map(versions)};}},
|
||||
|
||||
// 暴露 fixture 保存的 image 引用,专供旧格式改造和损坏注入;不是产品公开状态接口。
|
||||
image:()=>image,
|
||||
// 只读取得当前假服务端文档,供断言本地可见与远端 ACK 之间的差别。
|
||||
server:()=>server,
|
||||
// 每次以相同源存储/作用域重新装配引擎;递增 ID 分配器和错误列表跨重开保留。
|
||||
open:(extra={})=>openProjectEngine({scope,registry,document:initial,versions:new Map([...initial.canvases.keys()].map(id=>[id,1])),storage:f.storage,persistence:f.port,createId:()=>`identity-${++next}`,onError:error=>errors.push(error),...extra})};return f;
|
||||
}
|
||||
// 先检查当前快照,再订阅到谓词成立或 5 秒超时;两种结束路径都移除订阅,避免泄漏监听器。
|
||||
function until(engine,predicate){if(predicate(engine.getSnapshot()))return Promise.resolve();return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{unsubscribe();reject(Error('import condition timed out'));},5000),unsubscribe=engine.editor.subscribe((_,snapshot)=>{if(predicate(snapshot)){clearTimeout(timer);unsubscribe();resolve();}});});}
|
||||
|
||||
// 阻塞后端提交时三个 native 元素已本地可见且历史合并为一组;新 incarnation=1,runId 被剔除,放行 ACK 后再检查服务端。
|
||||
test('native import adds visible records while its backend writer is still awaiting ACK',async()=>{
|
||||
const f=fixture(),engine=await f.open(),gate=deferred();f.gate=gate;
|
||||
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(f.server().elements.size,0);assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(f.files.size,1);for(const element of engine.getSnapshot().document.elements.values()){assert.equal(element.incarnation,1);assert.equal(element.data.runId,undefined);}gate.resolve();await engine.settle();assert.equal(f.server().elements.size,3);assert.equal(f.errors.length,0);decodeRecovery(f.image(),registry);}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 首项后暂停、编辑已导入元素再重开,继续使用持久化源与原 ID,后续导入不能覆盖手动标题。
|
||||
test('pause/reopen/resume reuses the durable source and IDs without overwriting a user edit',async()=>{
|
||||
const f=fixture();let engine=await f.open();
|
||||
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),first=job.stableIds['element:one'];assert.equal(job.status,'paused');engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'user edit'}});await engine.settle();await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:one'],first);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'user edit');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(f.errors.length,0);decodeRecovery(f.image(),registry);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 导入前缀、手动编辑、导入后缀是三个有序 undo 单元;重开后逐项 undo/redo 仍保留同一编辑与任务完成事实。
|
||||
test('import history on either side of a manual edit remains three ordered undo units after reopening',async()=>{
|
||||
const f=fixture();let engine=await f.open();
|
||||
try{
|
||||
@@ -42,64 +83,76 @@ test('import history on either side of a manual edit remains three ordered undo
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 取消只保留已加入首项;重开后显式 resume 也不把 cancelled 任务当暂停任务继续插入。
|
||||
test('cancel keeps the already added prefix and survives reopening without further insertion',async()=>{
|
||||
const f=fixture();let engine=await f.open();
|
||||
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.cancel(id);await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,1);await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,1);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 首个端点导入后被用户删除,晚到连线不能通过恢复旧源把它复活;剩余独立节点导入,任务 partial。
|
||||
test('deleting an imported endpoint while paused prevents a late edge from reviving it',async()=>{
|
||||
const f=fixture();let engine=await f.open();
|
||||
const edge={kind:'element',key:'edge',record:{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'$target',source:{elementId:'one'},target:{elementId:'two'},path:null,data:{}}};
|
||||
try{const id=engine.imports.startNdjson(file([node('one'),edge,node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const first=engine.imports.getJobs().get(id).stableIds['element:one'];engine.editor.dispatch({type:'element.remove',target:{id:first,incarnation:1}});await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.equal(engine.getSnapshot().document.elements.has(first),false);assert.equal(engine.getSnapshot().document.elements.size,1);assert.ok(job.failures.some(failure=>failure.key==='element:edge'));assert.equal(f.server().elements.has(first),false);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// prepareRecovery 暂停活动导入发布,等待期间 rootRevision 不变;接纳新基线后才继续完成导入。
|
||||
test('canonical recovery holds active imports and releases them after accepting the fresh base',async()=>{
|
||||
const f=fixture(),engine=await f.open();
|
||||
try{const id=engine.imports.startNdjson(file(Array.from({length:20},(_,index)=>node(`n-${index}`))),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length>=1);const session=await engine.prepareRecovery(),cut=engine.getSnapshot().rootRevision;await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,cut);assert.equal((await session.apply()).status,'applied');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,20);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 关闭活动导入保存可继续的 running 检查点,重开自动续跑,首项 ID 不变且无重复节点。
|
||||
test('closing an active import preserves a running checkpoint and reopening resumes without duplicate records',async()=>{
|
||||
const f=fixture();let engine=await f.open();
|
||||
try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);const first=engine.imports.getJobs().get(id).stableIds['element:one'];await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:one'],first);assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 端点删除后 undo 恢复成第二代,旧连线仍不得绑定新身份;记录 import_dependency_changed,保留当前元素。
|
||||
test('a late connection cannot claim an endpoint restored into a new incarnation',async()=>{
|
||||
const f=fixture(),engine=await f.open(),edge={kind:'element',key:'edge',record:{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'$target',source:{elementId:'one'},target:{elementId:'two'},path:null,data:{}}};
|
||||
try{const id=engine.imports.startNdjson(file([node('one'),edge,node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const first=engine.imports.getJobs().get(id).stableIds['element:one'];engine.editor.dispatch({type:'element.remove',target:{id:first,incarnation:1}});await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(first).incarnation,2);await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.equal(engine.getSnapshot().document.elements.get(first).incarnation,2);assert.equal(engine.getSnapshot().document.elements.has(job.stableIds['element:edge']),false);assert.ok(job.failures.some(failure=>failure.code==='import_dependency_changed'));}finally{await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 重开读取源仍挂起时取消 job,随后返回 source unavailable 不能改写 cancelled 或产生无关错误。
|
||||
test('a late unavailable-source result cannot change a job cancelled during reopening',async()=>{
|
||||
const f=fixture();let engine=await f.open();const gate=deferred(),entered=deferred();
|
||||
try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.persist();await engine.close();f.storage.readFile=async()=>{entered.resolve();return gate.promise;};engine=await f.open();await entered.promise;await engine.imports.cancel(id);gate.resolve(undefined);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(f.errors.length,0);}finally{gate.resolve(undefined);await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 普通空恢复 image 可为格式 1,含读取证明后升级到 17;不能仅改 formatVersion 就把证明数据降成旧格式。
|
||||
test('proof-bearing recovery cuts use format 17 and cannot be silently downgraded to a legacy image',async()=>{
|
||||
const f=fixture(),engine=await f.open();
|
||||
try{await engine.persist();assert.equal(f.image().formatVersion,1);const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const image=f.image();assert.equal(image.formatVersion,17);assert.equal(decodeRecovery(image,registry).runtime.imports.get(id).status,'completed');assert.throws(()=>decodeRecovery({...image,formatVersion:1},registry),/unsupported_recovery_version/);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 源文件首次保存挂起时首项已可见,但文件未落盘且任何后端请求不得越过屏障;放行后才完成同步。
|
||||
test('the first valid import record displays while the source write is pending, but no backend request crosses that barrier',async()=>{
|
||||
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),commit=f.storage.commit;f.storage.commit=async(owner,batch)=>{if(batch.files?.length){entered.resolve();await gate.promise;}return commit(owner,batch);};
|
||||
try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await entered.promise;await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length>=1);assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.requests.length,0);assert.equal(f.files.size,0);gate.resolve();await engine.settle();assert.equal(f.files.size,1);assert.equal(f.server().elements.size,2);assert.equal(engine.imports.getJobs().get(id).status,'completed');}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 源文件写失败使任务暂停并保留本地首项/ID,网络未发;修复存储后 resume 唤醒原可靠写入流程。
|
||||
test('resuming after a failed source write preserves IDs and wakes the original durable writer',async()=>{
|
||||
const f=fixture(),engine=await f.open();
|
||||
try{await engine.persist();f.broken=true;const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const first=engine.imports.getJobs().get(id).stableIds['element:one'];assert.equal(f.requests.length,0);assert.equal(engine.getSnapshot().document.elements.size,1);f.broken=false;await engine.imports.resume(id);await engine.settle();await until(engine,()=>f.server().elements.size===2);assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:one'],first);assert.equal(f.files.size,1);assert.equal(new Set(f.requests.map(request=>request.id)).size,f.requests.length);}finally{f.broken=false;await engine.close();}
|
||||
});
|
||||
|
||||
// 深复制样本按组件添加共享图片资源和付费任务字段,区分可复用的资源与不能延续的执行状态。
|
||||
const copyNode=(id,owner='canvas',componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'source',...(componentId==='image'?{assetId:'shared',src:'asset:shared',runId:'paid-run',taskId:'task'}:{})},geometry:{x:0,y:0,width:200,height:100,rotation:0}});
|
||||
// 用一个事务建立上级 C、入口 F、child 成员 A/B 及 C→F 入边,等待同步后作为复制基线。
|
||||
async function seedCopy(engine){
|
||||
const records=[copyNode('C'),copyNode('F','canvas','frame'),copyNode('A','child','image'),copyNode('B','child')];
|
||||
const edge={id:'incoming',incarnation:1,structure:'connection',ownerCanvasId:'canvas',componentId:'connection',dataVersion:1,source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}};
|
||||
const result=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:records[0]});tx.dispatch({type:'element.create',element:records[1]});tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'canvas',entryElementId:'F'}}});for(const element of records.slice(2))tx.dispatch({type:'element.create',element});tx.dispatch({type:'element.create',element:edge});},{label:'Seed copy source'});assert.equal(result.status,'applied');await engine.settle();
|
||||
}
|
||||
|
||||
// 深复制暂停/重开后使用同一源及映射,保留副本上用户编辑、上级入边、共享资源及剔除任务字段的结果。
|
||||
test('deep copy source/mapping recover and preserve upper incoming source, shared assets and user edits',async()=>{
|
||||
const f=fixture();let engine=await f.open();
|
||||
try{
|
||||
@@ -113,6 +166,7 @@ test('deep copy source/mapping recover and preserve upper incoming source, share
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 外部上级 C 删除再恢复成新一代,晚到复制入边报 copy_external_changed;独立子节点副本仍保留。
|
||||
test('late copied edge cannot attach to an external endpoint deleted and restored as a new incarnation',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();
|
||||
assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'C',incarnation:1}}).status,'applied');assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('C').incarnation,2);await engine.settle();await engine.imports.resume(id);await engine.settle();
|
||||
@@ -120,29 +174,35 @@ test('late copied edge cannot attach to an external endpoint deleted and restore
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 复制任务取消后仅保留已发布前缀,重开/resume 不再新增副本。
|
||||
test('copy cancellation retains the published prefix and does not resume on reopening',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const initial=engine.getSnapshot().document.elements.size,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,initial+1);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 尚未准入前的预取消或项目关闭,迟到复制准备结果不能创建 running job;重开后任务表仍为空。
|
||||
test('preparation abort and project close do not admit a late copy job',async()=>{
|
||||
const f=fixture(),engine=await f.open();await seedCopy(engine);const controller=new AbortController();controller.abort();await assert.rejects(engine.imports.duplicate([{id:'F',incarnation:1}],{signal:controller.signal}),/copy_aborted/);assert.equal(engine.imports.getJobs().size,0);
|
||||
const pending=engine.imports.duplicate([{id:'F',incarnation:1}]);const rejected=assert.rejects(pending,/copy_aborted|engine_closing/);await engine.close();await rejected;const reopened=await f.open();try{assert.equal(reopened.imports.getJobs().size,0);}finally{await reopened.close();}
|
||||
});
|
||||
|
||||
// 复制本地完成不等待 ACK;任务显示关联发起 viewCanvasId,而复制元素仍归源规则决定的 child,不能混淆二者。
|
||||
test('copy publishes independently of backend ACK and associates task visibility with its initiating view',async()=>{
|
||||
const f=fixture(),engine=await f.open(),gate=deferred();try{await seedCopy(engine);f.gate=gate;const before=f.server().elements.size,id=await engine.imports.duplicate([{id:'A',incarnation:1}],{viewCanvasId:'canvas'});await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');const job=engine.imports.getJobs().get(id);assert.equal(job.source.targetCanvasId,'canvas');assert.equal(engine.getSnapshot().document.elements.get(job.stableIds['element:A']).ownerCanvasId,'child');assert.equal(f.server().elements.size,before);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,before+1);assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 复制源保存失败时网络无新请求;恢复后保留准备期 source 与稳定身份计划,最终映射数量符合深复制范围。
|
||||
test('copy source write failure gates network and resume preserves the planned identities',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{await seedCopy(engine);const sent=f.requests.length;f.broken=true;const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const before=engine.imports.getJobs().get(id);assert.equal(f.requests.length,sent);f.broken=false;await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(before.source.kind,'duplicate-snapshot');assert.equal(before.preparation.allocationIds.length,0);assert.equal(Object.keys(engine.imports.getJobs().get(id).stableIds).length,5);}finally{f.broken=false;await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 只读策略先拒绝 import/copy 准入,即使选择目标不存在也不先读取/存储源或创建任务。
|
||||
test('read-only policy rejects copy/import admission without storing a source or a running job',async()=>{
|
||||
const f=fixture(),engine=await f.open({policy:()=>({enabled:false,code:'read_only',targets:[]})});try{await assert.rejects(engine.imports.duplicate([{id:'missing',incarnation:1}]),/read_only/);assert.throws(()=>engine.imports.startNdjson(file([node('one')]),'canvas'),/read_only/);assert.equal(engine.imports.getJobs().size,0);await engine.settle();assert.equal(f.files.size,0);assert.equal(f.requests.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 大复制在已分配 ID 的准备期暂停并重开,复用 planner 游标而不重扫前阶段;源后来改名不影响捕获快照中的副本标题。
|
||||
test('preparing copy resumes saved planning cursors and allocated identities without repeating earlier phases',async(t)=>{
|
||||
const f=fixture(),save=f.storage.commit;let durableCuts=0;f.storage.commit=async(...args)=>{durableCuts++;return save(...args);};let engine=await f.open();try{
|
||||
await seedCopy(engine);assert.equal(engine.editor.transaction(tx=>{for(let i=0;i<1000;i++)tx.dispatch({type:'element.create',element:copyNode(`extra-${i}`,'child')});}).status,'applied');await engine.settle();
|
||||
@@ -152,29 +212,35 @@ test('preparing copy resumes saved planning cursors and allocated identities wit
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 任务已准入但仍 preparing 时关闭,保存任务与快照,重开自动完成五个源键。
|
||||
test('closing an admitted preparing copy preserves its task and snapshot for automatic restart',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);assert.equal(engine.imports.getJobs().get(id).status,'preparing');await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).committedSourceKeys.length,5);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 准备期取消持久化后重开仍 cancelled,原文档数量不变,不发布任何迟到副本。
|
||||
test('cancel during preparation is durable and never publishes a copy on reopening',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const size=engine.getSnapshot().document.elements.size,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 准备时使用的 registry.copy 声明改变,重开后明确暂停为 copy_interpreter_changed,不用新规则静默解释旧快照。
|
||||
test('a reopened preparation rejects changed registry copy declarations instead of silently reinterpreting the source',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.imports.pause(id);await engine.close();
|
||||
const changed=createRegistry(builtinComponents().map(component=>component.id==='image'?{...component,copy:{...component.copy,omit:[...component.copy.omit,['customExecution']]}}:component));engine=await f.open({registry:changed});await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'paused');assert.equal(job.error,'copy_interpreter_changed');assert.equal(job.committedSourceKeys.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 恢复会话 hold 期间 preparing 任务不前进,接纳协调后的状态根后才重新启动并完成。
|
||||
test('canonical recovery holds a preparing copy and restarts it only after the coordinated root is adopted',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);const recovery=await engine.prepareRecovery(),cut=engine.getSnapshot().rootRevision;await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,cut);assert.equal(engine.imports.getJobs().get(id).status,'preparing');assert.equal((await recovery.apply()).status,'applied');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// frame 端口引用成员、成员归属 child、child 绑定 frame 形成依赖环,必须一次闭合发布;深复制后端口局部 ID/handle 保留、nodeId 映射,重开与 undo/redo 都可恢复。
|
||||
test('native frame ports and deep copy close ownership/reference cycles atomically, keep local handles and recover every binding',async()=>{
|
||||
const f=fixture();let engine=await f.open();
|
||||
const frame={...node('F'),record:{...node('F').record,componentId:'frame',data:{title:'Frame',text:'',ports:[{id:'input',direction:'input',nodeId:'A',handle:'left'}]}}},child={...node('A'),record:{...node('A').record,ownerCanvasId:'S'}},canvas={kind:'canvas',key:'S',record:{id:'S',parent:{canvasId:'$target',entryElementId:'F'}}};
|
||||
try{
|
||||
// 观察每次发布的 frame 端口是否已经有目标成员,用 0/2 元素数量检测依赖环的半成品是否泄漏。
|
||||
const cuts=[],unsubscribe=engine.editor.subscribe((_,snapshot)=>{const rows=[...snapshot.document.elements.values()],frames=rows.filter(row=>row.componentId==='frame');for(const row of frames){const port=row.data.ports[0];assert.ok(snapshot.document.elements.has(port.nodeId),'published a frame without its port target');assert.notEqual(port.nodeId,'A');}cuts.push(rows.length);});
|
||||
const imported=engine.imports.startNdjson(file([frame,canvas,child]),'canvas');await engine.settle();const first=engine.imports.getJobs().get(imported);assert.equal(first.status,'completed');assert.equal(first.committedSourceKeys.length,3);const F=first.stableIds['element:F'],A=first.stableIds['element:A'];assert.equal(engine.getSnapshot().document.elements.get(F).data.ports[0].nodeId,A);assert.ok(cuts.every(size=>size===0||size===2));unsubscribe();
|
||||
const copied=await engine.imports.duplicate([{id:F,incarnation:1}]);await engine.settle();const job=engine.imports.getJobs().get(copied);assert.equal(job.status,'completed');assert.equal(job.committedSourceKeys.length,3);const F2=job.stableIds[`element:${F}`],A2=job.stableIds[`element:${A}`],port=engine.getSnapshot().document.elements.get(F2).data.ports[0];assert.deepEqual(port,{id:'input',direction:'input',nodeId:A2,handle:'left'});assert.notEqual(A2,A);assert.equal(f.server().elements.size,4);
|
||||
@@ -182,6 +248,7 @@ test('native frame ports and deep copy close ownership/reference cycles atomical
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 源文件保存一直挂起时允许有限本地前缀,未持久化根差达到背压门槛后停止增长;所有网络请求仍为零,放行才完成。
|
||||
test('blocked source persistence admits several local batches then applies bounded backpressure and gates every request',async()=>{
|
||||
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),commit=f.storage.commit;
|
||||
try{
|
||||
@@ -191,14 +258,17 @@ test('blocked source persistence admits several local batches then applies bound
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 复制快照字节同长度篡改也会被摘要绑定识别;恢复前无新副本/网络写,原摘要不被替换成篡改源的身份。
|
||||
test('same-length changes to an admitted copy snapshot are rejected before preparation allocates or publishes content',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const size=engine.getSnapshot().document.elements.size,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),raw=await f.files.get(job.source.fileId).text(),changed=raw.replace('source','tamper');assert.equal(changed.length,raw.length);await engine.close();const sent=f.requests.length;f.files.set(job.source.fileId,new Blob([changed]));engine=await f.open();await engine.imports.resume(id);await engine.settle();const blocked=engine.imports.getJobs().get(id);assert.equal(blocked.status,'paused');assert.equal(blocked.error,'import_source_changed');assert.equal(blocked.committedSourceKeys.length,0);assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal(f.requests.length,sent);assert.equal(blocked.source.sha256,job.source.sha256);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// native 尚未解析的尾部被同长度改写,重开验证拒绝并保留原首项,不允许改源后继续沿用旧任务进度。
|
||||
test('a changed unparsed tail cannot pass native import recovery or alter its preserved prefix',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),raw=await f.files.get(job.source.fileId).text(),changed=raw.replaceAll('three','other');assert.equal(changed.length,raw.length);const first=job.stableIds['element:one'];await engine.close();const sent=f.requests.length;f.files.set(job.source.fileId,new Blob([changed]));engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).error,'import_source_changed');assert.equal(engine.getSnapshot().document.elements.size,1);assert.ok(engine.getSnapshot().document.elements.has(first));assert.equal(f.requests.length,sent);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 摘要读取挂起期间取消或开启恢复会话,迟到结果不能复活 cancelled,也不能在 held root 发布;释放协调后才可继续。
|
||||
test('late source digest cannot revive a cancelled job or publish into a held recovery root',async()=>{
|
||||
for(const cancel of [true,false]){
|
||||
const f=fixture(),engine=await f.open(),entered=deferred(),gate=deferred(),bytes=new class extends Blob{slice(...args){return new class extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}([super.slice(...args)]);}}([JSON.stringify(node('one'))]);
|
||||
@@ -206,62 +276,73 @@ test('late source digest cannot revive a cancelled job or publish into a held re
|
||||
}
|
||||
});
|
||||
|
||||
// 旧未完成源缺乏完整性绑定仍可读取已有检查点,但 resume 明确拒绝补造新身份;原前缀与网络状态保留。
|
||||
test('legacy unfinished sources remain readable but cannot acquire a new content identity silently',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();await engine.close();const image=f.image(),job=image.payload.runtime.imports.find(([key])=>key===id)[1];job.version=2;delete job.source.sha256;delete job.source.integrityVersion;delete job.source.readProof;delete job.source.integrityError;image.formatVersion=2;const sent=f.requests.length;engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).error,'import_source_integrity_unavailable');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.requests.length,sent);decodeRecovery(f.image(),registry);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 把保存的索引源改造为旧完整快照,重算长度/SHA/fingerprint 并去掉 snapshotVersion,使旧准备解释器真正读取旧格式。
|
||||
async function legacyCopySource(f,job,document){const signal=new AbortController().signal,bytes=await encodeCopySnapshot(document,signal);delete job.source.snapshotVersion;job.source.byteLength=bytes.size;job.source.sha256=await importSourceDigest(bytes,signal);job.sourceFingerprint=`local-file:${job.source.fileId}:${bytes.size}`;f.files.set(job.source.fileId,bytes);}
|
||||
|
||||
// 旧 preparation v1 已预留 legacy-frame-id,转换成旧源格式后恢复应沿用该 ID 而不再分配。
|
||||
test('legacy preparation version 1 still replays its reserved identities instead of allocating replacements',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{await seedCopy(engine);const sourceDocument=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();await engine.close();const job=f.image().payload.runtime.imports.find(([key])=>key===id)[1];job.preparation.version=1;job.preparation.steps=1;job.preparation.allocationIds=['legacy-frame-id'];delete job.preparation.planner;await legacyCopySource(f,job,sourceDocument);engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:F'],'legacy-frame-id');assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 复制先转换并发布合法前缀,遇未知尾节点保留 partial;只存原快照而非第二份完整 mapped Blob,重开仍可整体撤销副本。
|
||||
test('copy converts and publishes a prefix before the output tail, preserves it on failure and stores only the original source',async()=>{
|
||||
const source=createDocument('project',[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'F'}}],[copyNode('F','canvas','frame'),...Array.from({length:100},(_,i)=>copyNode(`stream-${i}`,'child')),{...copyNode('opaque','child'),componentId:'unknown-widget'}]),f=fixture(source);let engine=await f.open();
|
||||
try{let prefix;const release=engine.editor.subscribe((_,snapshot)=>{for(const job of snapshot.runtime.imports.values())if(!prefix&&job.committedSourceKeys.length)prefix={cursor:job.cursor,total:job.source.plan.order.length+job.source.plan.edges.length,status:job.status};});const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.settle();release();const job=engine.imports.getJobs().get(id);assert.ok(prefix.cursor<prefix.total,'entire mapped output was converted before the first publication');assert.equal(prefix.status,'running');assert.equal(job.source.kind,'duplicate-plan');assert.equal(job.parserVersion,2);assert.equal(job.source.plan.emitted,0);assert.equal(job.status,'partial');assert.equal(job.committedSourceKeys.length,102);assert.ok(job.failures.some(failure=>failure.key==='element:opaque'&&failure.code==='copy_schema_unavailable'));assert.equal(engine.getSnapshot().document.elements.size,203);assert.equal(f.files.size,1,'copy stored a second complete mapped file');assert.equal(f.server().elements.size,203);assert.equal(f.errors.length,0);await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,203);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,102);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 旧准备版本 2/3 重开仍分别使用 duplicate-ndjson/parser1 与 duplicate-plan/parser2,不能用当前格式偷换解释器。
|
||||
test('legacy preparation versions 2 and 3 retain their source interpreters after reopening',async()=>{
|
||||
for(const version of [2,3]){const f=fixture();let engine=await f.open();try{await seedCopy(engine);const sourceDocument=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();await engine.close();const job=f.image().payload.runtime.imports.find(([key])=>key===id)[1];job.preparation.version=version;await legacyCopySource(f,job,sourceDocument);engine=await f.open();await engine.imports.resume(id);await engine.settle();const done=engine.imports.getJobs().get(id);assert.equal(done.status,'completed');assert.equal(done.source.kind,version===2?'duplicate-ndjson':'duplicate-plan');assert.equal(done.parserVersion,version===2?1:2);assert.equal(f.errors.length,0);}finally{await engine.close();}}
|
||||
});
|
||||
|
||||
// 恢复复制的 16 KiB 源解码挂起时,cancel 保留旧前缀;恢复会话 hold 则阻止后续发布,接纳后再完成。
|
||||
test('late planned-source decoding respects cancellation and canonical recovery holds',async()=>{
|
||||
for(const cancel of [true,false]){
|
||||
const f=fixture();let engine=await f.open();const entered=deferred(),gate=deferred();try{
|
||||
await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id),size=engine.getSnapshot().document.elements.size;await engine.close();
|
||||
class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
|
||||
class DelayedSource extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return end-start===16*1024?new DelayedChunk([part]):part;}}
|
||||
/* arrayBuffer 真正返回前发出 entered 并等待 gate,以制造取消/恢复期间的迟到正文。 */ class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
|
||||
/* 仅拦住 16 KiB 源解码窗口,其他读取保留真实 Blob 行为。 */ class DelayedSource extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return end-start===16*1024?new DelayedChunk([part]):part;}}
|
||||
f.files.set(before.source.fileId,new DelayedSource([f.files.get(before.source.fileId)]));engine=await f.open();const resuming=engine.imports.resume(id);await entered.promise;
|
||||
if(cancel){await engine.imports.cancel(id);gate.resolve();await resuming;await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,size);}else{const preparing=engine.prepareRecovery();gate.resolve();await resuming;const session=await preparing,revision=engine.getSnapshot().rootRevision;await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().document.elements.size,size);assert.equal((await session.apply()).status,'applied');await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');}assert.equal(f.errors.length,0);
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
}
|
||||
});
|
||||
|
||||
// 索引已可用时,晚到大正文块挂起不阻塞前缀副本可见;放行后才读取剩余内容并完成全部副本。
|
||||
test('indexed copy displays a prefix while a later raw payload read is still blocked',async()=>{
|
||||
const source=createDocument('project',[{id:'canvas',parent:null},{id:'child',parent:{canvasId:'canvas',entryElementId:'F'}}],[copyNode('F','canvas','frame'),...Array.from({length:100},(_,i)=>({...copyNode(`lazy-${i}`,'child'),data:{title:`lazy-${i}`,text:'x'.repeat(3000)}}))]),f=fixture(source);let engine=await f.open();const entered=deferred(),gate=deferred();try{
|
||||
const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>!!snapshot.runtime.imports.get(id).source.sha256);await engine.imports.pause(id);await engine.settle();const before=engine.imports.getJobs().get(id);await engine.close();const bytes=f.files.get(before.source.fileId),rows=(await bytes.text()).trimEnd().split('\n');let payload=0;
|
||||
for(const text of rows){const row=JSON.parse(text);payload+=new TextEncoder().encode(text+'\n').byteLength;if(row.kind==='payload')break;}
|
||||
class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
|
||||
class DelayedPayload extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return start>=payload+64*1024&&(start-payload)%(64*1024)===0?new DelayedChunk([part]):part;}}
|
||||
/* arrayBuffer 真正返回前发出 entered 并等待 gate,以制造取消/恢复期间的迟到正文。 */ class DelayedChunk extends Blob{async arrayBuffer(){entered.resolve();await gate.promise;return super.arrayBuffer();}}
|
||||
/* 仅从正文区第二个对齐 64 KiB 窗口起挂起;头部、索引和首个原始正文窗口仍可读。 */ class DelayedPayload extends Blob{slice(start,end,...rest){const part=super.slice(start,end,...rest);return start>=payload+64*1024&&(start-payload)%(64*1024)===0?new DelayedChunk([part]):part;}}
|
||||
f.files.set(before.source.fileId,new DelayedPayload([bytes]));engine=await f.open();await engine.imports.resume(id);await entered.promise;const cut=engine.getSnapshot(),job=cut.runtime.imports.get(id);assert.equal(job.source.snapshotVersion,2);assert.equal(job.status,'running');assert.ok(job.committedSourceKeys.length>0&&job.committedSourceKeys.length<102);assert.ok(cut.document.elements.size>source.elements.size&&cut.document.elements.size<202,'raw tail read gated the entire copy');gate.resolve();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,202);assert.equal(f.errors.length,0);
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// 两个项目共享摘要池,关闭一个只取消它的请求,不关闭池或影响另一个项目;被关闭项目重开后仍可继续。
|
||||
test('closing a project aborts its injected digest without closing another project sharing the pool',async()=>{
|
||||
const {createDigestPool}=await import('../../src/lib/canvas-runtime/digest-pool.ts'),entered=deferred();let first=true;const pool=createDigestPool({concurrency:1,execute:async(bytes,signal)=>{if(first){first=false;entered.resolve();await new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(Error('stopped')),{once:true}));}return importSourceDigest(bytes,signal);}}),a=fixture(),b=fixture();let ea=await a.open({digests:pool}),eb=await b.open({digests:pool});
|
||||
try{const aid=ea.imports.startNdjson(file([node('A')]),'canvas');await entered.promise;const bid=eb.imports.startNdjson(file([node('B')]),'canvas');await ea.close();await eb.settle();assert.equal(eb.imports.getJobs().get(bid).status,'completed');assert.equal(eb.getSnapshot().document.elements.size,1);assert.equal(a.errors.length,0);assert.equal(b.errors.length,0);ea=await a.open();await ea.settle();assert.equal(ea.imports.getJobs().get(aid).status,'completed');assert.equal(ea.getSnapshot().document.elements.size,1);}finally{await ea.close();await eb.close();pool.close();}
|
||||
});
|
||||
|
||||
// 只手动推进回收墙上时间,真实定时器仍服务于调度;用于精确检查源文件退休宽限期。
|
||||
const retentionClock=()=>{let now=1000;return{now:()=>now,advance:ms=>{now+=ms;},setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)};};
|
||||
// 任务本地终态后源文件可标记/宽限期回收,即使文档请求还未 ACK;内容、history 和冻结请求体保持,重开 undo/redo 不依赖已退休源。
|
||||
test('terminal import source collection preserves every imported record, undo unit and pending request across source-v2 reopening',async()=>{
|
||||
const f=fixture(),clock=retentionClock(),gate=deferred();f.gate=gate;let engine=await f.open({clock});try{
|
||||
const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');await engine.persist();const job=engine.imports.getJobs().get(id),fileId=job.source.fileId,document=engine.getSnapshot().document,history=engine.getSnapshot().history;
|
||||
const marked=await engine.collectImportSources({retentionMs:100});assert.deepEqual(marked.markedIds,[fileId]);assert.deepEqual(marked.purgedIds,[]);assert.ok(f.files.has(fileId));assert.equal(f.image().formatVersion,17);clock.advance(100);
|
||||
const result=await engine.collectImportSources({retentionMs:100});assert.deepEqual(result.purgedIds,[fileId]);assert.equal(f.files.has(fileId),false);assert.equal(engine.getSnapshot().document,document);assert.equal(engine.getSnapshot().history,history);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].some(record=>record.phase!=='resolved'));
|
||||
// 保存所有冻结请求的 ID/body;源文件回收不应重写它们,后续重开逐个比较。
|
||||
const frozen=[...engine.getSnapshot().runtime.ledger.requests.values()].map(request=>[request.id,request.body]);decodeRecovery(f.image(),registry);for(const formatVersion of [1,2,3])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/);
|
||||
gate.resolve();await engine.settle();await engine.close();engine=await f.open({clock});await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,2);for(const [id,body]of frozen)assert.equal(engine.getSnapshot().runtime.ledger.requests.get(id).body,body);await assert.rejects(engine.imports.openSource(id),/import_source_retired/);
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,0);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(f.errors.length,0);
|
||||
@@ -269,6 +350,7 @@ test('terminal import source collection preserves every imported record, undo un
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// paused 源仍需保留并可 openSource;partial/cancelled 源可回收,重开后不能借 resume 复活内容或重复失败。
|
||||
test('paused source remains recoverable while partial and cancelled sources become collectible without reviving content',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{
|
||||
const id=engine.imports.startNdjson(file([node('one'),node('two'),node('three')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const source=engine.imports.getJobs().get(id).source;assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);assert.ok(f.files.has(source.fileId));const handle=await engine.imports.openSource(id);assert.equal(await handle.bytes.text(),await file([node('one'),node('two'),node('three')]).text());handle.release();
|
||||
@@ -276,6 +358,7 @@ test('paused source remains recoverable while partial and cancelled sources beco
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 打开终态源前先 pin 并清除 soft marker,异步读取期间不可删;最后 release 后重新开始完整宽限期。
|
||||
test('opening a terminal source pins before I/O, clears its soft marker and restarts the full retention interval',async()=>{
|
||||
const f=fixture(),clock=retentionClock(),engine=await f.open({clock}),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{
|
||||
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId;await engine.collectImportSources({retentionMs:100});clock.advance(200);f.storage.readFile=async(...args)=>{if(args[1]===fileId){entered.resolve();await gate.promise;}return read(...args);};
|
||||
@@ -283,6 +366,7 @@ test('opening a terminal source pins before I/O, clears its soft marker and rest
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 源文件删除落盘失败保留字节与 reservation,阻止 openSource 及非法恢复 running;persist 补成同一 purge cut 后才退休,内容仍可编辑。
|
||||
test('failed source deletion retains byte identity and blocks late reads until the same purge cut becomes durable',async()=>{
|
||||
const f=fixture(),engine=await f.open(),commit=f.storage.commit;let fail=false;try{
|
||||
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('source disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectImportSources({retentionMs:0}),/source disk failure/);assert.ok(f.files.has(fileId));await assert.rejects(engine.imports.openSource(id),/resource_collection_in_progress/);const root=engine.getSnapshot(),job=root.runtime.imports.get(id);assert.equal(engine.editor.updateRuntime(previous=>{const jobs=new Map(previous.imports);jobs.set(id,{...job,status:'running',source:{...job.source,retirement:undefined}});return{...previous,imports:jobs};}).status,'rejected');assert.equal(engine.getSnapshot(),root);
|
||||
@@ -290,6 +374,7 @@ test('failed source deletion retains byte identity and blocks late reads until t
|
||||
}finally{fail=false;await engine.close();}
|
||||
});
|
||||
|
||||
// 旧快照转 NDJSON 时归档 previousFiles,旧代可单独 pin/回收;当前代完成后也可回收,元素引用和历史仍可恢复。
|
||||
test('legacy snapshot conversion records its previous file, then collects both generations without losing copied references',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{
|
||||
await seedCopy(engine);const captured=engine.getSnapshot().document,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await engine.imports.pause(id);await engine.settle();await engine.close();const image=f.image(),job=image.payload.runtime.imports.find(([key])=>key===id)[1],bytes=await encodeCopySnapshot(captured,new AbortController().signal),oldFile=job.source.fileId;f.files.set(oldFile,bytes);job.source.byteLength=bytes.size;job.source.sha256=await importSourceDigest(bytes);job.sourceFingerprint=`local-file:${oldFile}:${bytes.size}`;delete job.source.snapshotVersion;job.preparation={version:1,registrySignature:job.preparation.registrySignature,selection:[{id:'F',incarnation:1}],worldOffset:{x:32,y:32},allocationIds:[],steps:0};decodeRecovery(image,registry);
|
||||
@@ -298,6 +383,7 @@ test('legacy snapshot conversion records its previous file, then collects both g
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// openSource 验证当前文件与绑定摘要;篡改拒绝并释放 pin,读取期间关闭项目则迟到结果报 engine_closing。
|
||||
test('source read handles verify bound bytes and never publish a result after project close',async()=>{
|
||||
const f=fixture(),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{
|
||||
const id=engine.imports.startNdjson(file([node('one')]),'canvas');await engine.settle();const fileId=engine.imports.getJobs().get(id).source.fileId,bytes=f.files.get(fileId),text=await bytes.text();f.files.set(fileId,new Blob([text.replace('one','two')]));await assert.rejects(engine.imports.openSource(id),/import_source_changed/);assert.equal(engine.getResourcePins().pins.has(fileId),false);f.files.set(fileId,bytes);
|
||||
@@ -305,32 +391,41 @@ test('source read handles verify bound bytes and never publish a result after pr
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// JSON 数组任务保存原字节及字节游标,暂停/编辑/重开再续跑保持原 ID 和用户标题,证明型 image 不允许格式降级。
|
||||
test('JSON array import persists original bytes and byte cursor, resumes after reopen, and preserves edits without duplicating IDs',async()=>{const f=fixture();let engine=await f.open();try{const bytes=new Blob([JSON.stringify([node('json-one'),node('json-two'),node('json-three')])]),id=engine.imports.startJsonArray(bytes,'canvas');await until(engine,s=>s.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),first=job.stableIds['element:json-one'];assert.equal(job.parserVersion,3);assert.equal(job.source.kind,'native-json-array');assert.ok(job.cursor>0);assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:13},registry),/unsupported_recovery_version/);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:16},registry),/unsupported_recovery_version/);engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'preserved edit'}});await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'preserved edit');assert.equal(f.server().elements.size,3);assert.equal(f.errors.length,0);}finally{await engine.close();}});
|
||||
// 数组尾逗号使任务 partial,只保留解析器已接受的合法前缀;即使后端 gate 未放行,本地也能展示该前缀和显式错误。
|
||||
test('JSON array malformed tail keeps its visible prefix and an explicit failure while backend waits',async()=>{const f=fixture(),engine=await f.open(),gate=deferred();f.gate=gate;try{const id=engine.imports.startJsonArray(new Blob(['['+JSON.stringify(node('json-first'))+','+JSON.stringify(node('json-bad'))+',]']),'canvas');await until(engine,s=>s.runtime.imports.get(id).status==='partial');const job=engine.imports.getJobs().get(id);assert.equal(job.committedSourceKeys.length,1);assert.equal(job.failures.at(-1).code,'invalid_json_array');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,0);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,1);decodeRecovery(f.image(),registry);}finally{gate.resolve();await engine.close();}});
|
||||
|
||||
// 导出文档导入指定目标根,生成新元素/子 canvas ID,重建 owner 与上级连线;旧源墓碑不移植为目标身份,重开保留四元素。
|
||||
test('exported document imports into a chosen root with fresh IDs, child ownership and upper connections then reopens',async()=>{const f=fixture();let engine=await f.open();try{const frame={...node('frame').record,componentId:'frame',ownerCanvasId:'source-root'},child={...node('child').record,ownerCanvasId:'source-child'},outside={...node('outside').record,ownerCanvasId:'source-root'},edge={id:'incoming',incarnation:1,ownerCanvasId:'source-root',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'outside'},target:{elementId:'frame'},data:{},path:null},input={documentFormatVersion:1,projectId:'source-root',canvases:[{id:'source-root',parent:null},{id:'source-child',parent:{canvasId:'source-root',entryElementId:'frame'}}],elements:[frame,child,outside,edge],tombstones:{old:2}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'completed',JSON.stringify(job.failures));const doc=engine.getSnapshot().document,frameId=job.stableIds['element:frame'],childId=job.stableIds['canvas:source-child'];assert.equal(doc.canvases.size,2);assert.equal(doc.canvases.get(childId).parent.entryElementId,frameId);assert.equal(doc.elements.get(job.stableIds['element:child']).ownerCanvasId,childId);assert.equal(doc.elements.get(job.stableIds['element:incoming']).ownerCanvasId,'canvas');assert.equal(doc.elements.get(job.stableIds['element:incoming']).target.elementId,frameId);assert.equal(doc.tombstones.has('old'),false);assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);await engine.close();engine=await f.open();await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,4);assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(f.errors.length,0);}finally{await engine.close();}});
|
||||
// 无资源授权端口时图片引用报 import_resource_authorization_required,独立文本仍进入文档并同步。
|
||||
test('document resource references report an authorization failure while independent text imports',async()=>{const f=fixture(),engine=await f.open();try{const image={...node('image').record,componentId:'image',ownerCanvasId:'source',data:{title:'image',assetId:'untrusted',src:'https://assets.test/untrusted'}},text={...node('text').record,ownerCanvasId:'source'},input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[image,text],tombstones:{}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.ok(job.failures.some(row=>row.code==='import_resource_authorization_required'));assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,1);}finally{await engine.close();}});
|
||||
// 导出文档使用记录序号恢复,首项后暂停编辑,重开继续不覆盖手动标题或重复元素。
|
||||
test('paused exported document resumes ordinal progress after reopen without overwriting an edited prefix',async()=>{const f=fixture();let engine=await f.open();try{const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:['d-one','d-two','d-three'].map(id=>({...node(id).record,ownerCanvasId:'source'})),tombstones:{}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await until(engine,s=>s.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const job=engine.imports.getJobs().get(id),first=job.stableIds['element:d-one'];assert.equal(job.cursor,1);engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'local edit'}});await engine.settle();await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'paused');await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,3);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'local edit');assert.equal(f.errors.length,0);}finally{await engine.close();}});
|
||||
|
||||
// parser 5 尾部扫描挂起时首项已可编辑;暂停/重开继续同解释器和稳定 ID,保留编辑,错误 image 版本拒绝。
|
||||
test('streaming exported document publishes an editable prefix before the parser tail and resumes parser 5 after reopen',async()=>{
|
||||
const f=fixture(),gate=deferred(),entered=deferred();let block=true,engine=await f.open();
|
||||
class Source extends Blob{slice(start=0,end=this.size,...rest){const part=super.slice(start,end,...rest);if(end-start!==16384||start<32768||!block)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
|
||||
/* 文档扫描从 32 KiB 起的 16 KiB 窗口可挂起;前项仍可先解析和发布。 */ class Source extends Blob{slice(start=0,end=this.size,...rest){const part=super.slice(start,end,...rest);if(end-start!==16384||start<32768||!block)return part;return{async arrayBuffer(){entered.resolve();await gate.promise;return part.arrayBuffer();}};}}
|
||||
try{
|
||||
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[{...node('stream-first').record,ownerCanvasId:'source'},{...node('stream-tail').record,ownerCanvasId:'source',data:{title:'tail',text:'x'.repeat(100000)}}],tombstones:{}},id=engine.imports.startDocument(new Source([JSON.stringify(input)]),'canvas');
|
||||
await entered.promise;await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.includes('element:stream-first'));
|
||||
const job=engine.imports.getJobs().get(id),first=job.stableIds['element:stream-first'];assert.equal(job.parserVersion,5);assert.equal(engine.getSnapshot().document.elements.size,1);
|
||||
assert.equal(engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'edited while tail blocked'}}).status,'applied');
|
||||
// 先请求暂停再释放底层扫描,验证迟到尾部不会绕过暂停;随后持久化 parser/proof 以供重开。
|
||||
const paused=engine.imports.pause(id);block=false;gate.resolve();await paused;await engine.settle();assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:15},registry),/unsupported_recovery_version/);assert.throws(()=>decodeRecovery({...f.image(),formatVersion:18},registry),/unsupported_recovery_version/);
|
||||
await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(first).data.title,'edited while tail blocked');assert.equal(f.server().elements.size,2);assert.equal(f.errors.length,0);
|
||||
}finally{block=false;gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 文档 footer 墓碑非法时已导入元素仍可见并同步;partial 及错误诊断持久化,重开保留该成功前缀。
|
||||
test('bad document footer produces durable partial success rather than discarding imported content',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{
|
||||
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[{...node('legal').record,ownerCanvasId:'source'}],tombstones:{broken:0}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.ok(job.failures.some(row=>row.code==='invalid_document_import'));assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.server().elements.size,1);await engine.close();engine=await f.open();assert.equal(engine.imports.getJobs().get(id).status,'partial');assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 显式构造旧格式 15/parser4/integrity1 检查点,恢复沿用原解释器及 ID,不能暗升成 parser5 或改变 image 格式。
|
||||
test('legacy format 15 parser 4 document jobs keep their original interpreter and stable IDs when resumed',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{
|
||||
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:['legacy-one','legacy-two'].map(id=>({...node(id).record,ownerCanvasId:'source'})),tombstones:{}},id=engine.imports.startDocument(new Blob([JSON.stringify(input)]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle();const first=engine.imports.getJobs().get(id).stableIds['element:legacy-one'];await engine.close();
|
||||
@@ -339,9 +434,10 @@ test('legacy format 15 parser 4 document jobs keep their original interpreter an
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 临时正文 I/O 错误暂停在原 ordinal,eof=false 且无坏记录诊断;恢复端口后继续尾项,保留首项手动编辑。
|
||||
test('temporary document byte I/O pauses without consuming a bad-tail ordinal and resumes with edited prefix intact',async()=>{
|
||||
const f=fixture(),engine=await f.open();let fail=true;
|
||||
class Flaky extends Blob{slice(start=0,end=this.size,...rest){if(fail&&end-start===16384&&start>=16384)throw Error('temporary source I/O');return super.slice(start,end,...rest);}}
|
||||
/* 仅让后续 16 KiB 扫描窗口抛临时 I/O;切换 fail 后同一 Blob 可读,不改变字节身份。 */ class Flaky extends Blob{slice(start=0,end=this.size,...rest){if(fail&&end-start===16384&&start>=16384)throw Error('temporary source I/O');return super.slice(start,end,...rest);}}
|
||||
try{
|
||||
const input={documentFormatVersion:1,projectId:'source',canvases:[{id:'source',parent:null}],elements:[{...node('io-first').record,ownerCanvasId:'source'},{...node('io-tail').record,ownerCanvasId:'source',data:{title:'tail',text:'x'.repeat(100000)}}]},id=engine.imports.startDocument(new Flaky([JSON.stringify(input)]),'canvas');
|
||||
await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const job=engine.imports.getJobs().get(id),first=job.stableIds['element:io-first'];assert.equal(job.error,'import_source_read_failed');assert.equal(job.eof,false);assert.equal(job.cursor,1);assert.equal(job.failures.length,0);assert.equal(job.committedSourceKeys.length,1);
|
||||
@@ -349,52 +445,63 @@ test('temporary document byte I/O pauses without consuming a bad-tail ordinal an
|
||||
}finally{fail=false;await engine.close();}
|
||||
});
|
||||
|
||||
// 完整 SHA 尚未返回时读取证明允许先发布/编辑前缀;即使 persist 本地内容,网络仍不发,待摘要成功后才同步用户编辑。
|
||||
test('native import publishes and edits a proof-backed prefix before full SHA, with every request waiting for its durable seal',async()=>{
|
||||
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({digests:{async digest(bytes,signal){entered.resolve();await gate.promise;return importSourceDigest(bytes,signal);}}});
|
||||
try{const id=engine.imports.startNdjson(file([node('early-one'),node('early-two')]),'canvas');await entered.promise;await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);const job=engine.imports.getJobs().get(id),first=job.stableIds['element:early-one'];assert.equal(job.source.integrityVersion,2);assert.ok(job.source.readProof.blocks.length);assert.equal(job.source.sha256,undefined);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:first,incarnation:1},patch:{title:'edit before hash'}}).status,'applied');await engine.persist();assert.equal(f.requests.length,0);assert.equal(f.server().elements.size,0);assert.equal(f.image().formatVersion,17);decodeRecovery(f.image(),registry);gate.resolve();await engine.settle();assert.ok(engine.imports.getJobs().get(id).source.sha256);assert.equal(f.server().elements.get(first).data.title,'edit before hash');assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 内存中已有完整 SHA 仍不足以放行网络;必须等含该 seal 的恢复 cut 真正落盘,再允许相关文档请求。
|
||||
test('a verified SHA still cannot release pending import writes before the seal cut is durable',async()=>{
|
||||
const f=fixture(),hash=deferred(),save=deferred(),saving=deferred(),commit=f.storage.commit,engine=await f.open({digests:{async digest(bytes,signal){await hash.promise;return importSourceDigest(bytes,signal);}}});
|
||||
try{const id=engine.imports.startNdjson(file([node('seal-one')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length===1);await engine.persist();f.storage.commit=async(owner,batch)=>{if(batch.image.payload.runtime.imports?.some(([key,job])=>key===id&&job.source.sha256)){saving.resolve();await save.promise;}return commit(owner,batch);};hash.resolve();await saving.promise;assert.ok(engine.imports.getJobs().get(id).source.sha256);assert.equal(f.requests.length,0);save.resolve();await engine.settle();assert.equal(f.server().elements.size,1);assert.equal(f.errors.length,0);}finally{hash.resolve();save.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 未封存前 pause/cancel 保留可见前缀、继续挡网络且暂不可回收源;校验后只同步已接受内容,不改变暂停/取消终态。
|
||||
test('pause and cancel retain the unverified prefix and never turn off the source gate early',async()=>{
|
||||
for(const cancel of [false,true]){const f=fixture(),gate=deferred(),engine=await f.open({digests:{async digest(bytes,signal){await gate.promise;return importSourceDigest(bytes,signal);}}});try{const id=engine.imports.startNdjson(file([node('stop-one'),node('stop-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);if(cancel)await engine.imports.cancel(id);else await engine.imports.pause(id);await engine.persist();assert.equal(f.requests.length,0);assert.deepEqual((await engine.collectImportSources({retentionMs:0})).purgedIds,[]);const count=engine.getSnapshot().document.elements.size;gate.resolve();await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,cancel?'cancelled':'paused');assert.equal(f.server().elements.size,count);assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}}
|
||||
});
|
||||
|
||||
// 源验证失败保留本地内容和稳定 ID,阻止发送;修复 digest 端口再 resume,只补验证与剩余导入,不重复身份。
|
||||
test('a source verification failure retains local content, blocks sending and can retry without duplicate identities',async()=>{
|
||||
const f=fixture(),gate=deferred();let fail=true;const engine=await f.open({digests:{async digest(bytes,signal){await gate.promise;if(fail)throw Error('digest unavailable');return importSourceDigest(bytes,signal);}}});
|
||||
try{const id=engine.imports.startNdjson(file([node('retry-one'),node('retry-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);gate.resolve();await engine.settle();const job=engine.imports.getJobs().get(id),ids={...job.stableIds};assert.equal(job.source.integrityError,'import_source_verification_failed');assert.equal(f.requests.length,0);assert.ok(engine.getSnapshot().document.elements.size>0);fail=false;await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');for(const[key,id]of Object.entries(ids))assert.equal(engine.imports.getJobs().get(job.id).stableIds[key],id);assert.equal(f.server().elements.size,2);assert.equal(engine.imports.getJobs().get(id).source.integrityError,undefined);}finally{fail=false;gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 未 seal 的持久化检查点重开保留原前缀/ID,仍挡发送;重新验证并续跑后才同步完整内容。
|
||||
test('an unsealed durable checkpoint reopens with the same prefix and keeps sends blocked until reverified',async()=>{
|
||||
const f=fixture(),gate=deferred(),entered=deferred();let engine=await f.open({digests:{digest(bytes,signal){entered.resolve();return new Promise((resolve,reject)=>{const abort=()=>reject(Error('aborted'));signal.addEventListener('abort',abort,{once:true});gate.promise.then(()=>{signal.removeEventListener('abort',abort);importSourceDigest(bytes,signal).then(resolve,reject);});});}}});
|
||||
try{const id=engine.imports.startNdjson(file([node('reopen-one'),node('reopen-two')]),'canvas');await entered.promise;await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);await engine.imports.pause(id);await engine.persist();const ids={...engine.imports.getJobs().get(id).stableIds};assert.equal(f.image().formatVersion,17);await engine.close();const nextGate=deferred();engine=await f.open({digests:{async digest(bytes,signal){await nextGate.promise;return importSourceDigest(bytes,signal);}}});await engine.persist();assert.equal(f.requests.length,0);assert.deepEqual(engine.imports.getJobs().get(id).stableIds,ids);nextGate.resolve();await engine.imports.resume(id);await engine.settle();assert.equal(f.server().elements.size,2);for(const[key,id]of Object.entries(ids))assert.equal(engine.imports.getJobs().get(engine.imports.getJobs().keys().next().value).stableIds[key],id);}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 恢复会话取消旧 verifier 后,接纳基线需重新启动验证;不能留下无错误但永远不放行网络的静默 hold。
|
||||
test('canonical recovery restarts an aborted unsealed-source verifier instead of leaving a silent send hold',async()=>{
|
||||
const f=fixture(),gate=deferred();let calls=0;const engine=await f.open({digests:{digest(bytes,signal){calls++;return new Promise((resolve,reject)=>{const abort=()=>reject(Error('aborted'));signal.addEventListener('abort',abort,{once:true});gate.promise.then(()=>{signal.removeEventListener('abort',abort);importSourceDigest(bytes,signal).then(resolve,reject);});});}}});
|
||||
try{const id=engine.imports.startNdjson(file([node('hold-one'),node('hold-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);const session=await engine.prepareRecovery();assert.equal((await session.apply()).status,'applied');for(let i=0;i<100&&calls<2;i++)await new Promise(resolve=>setTimeout(resolve,1));assert.ok(calls>=2,'source verifier did not restart after recovery released');assert.equal(f.requests.length,0);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,2);assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 未 seal 源重开先重验已记录块,篡改即阻塞且不再分配 ID;恢复原字节后才允许重试和同步。
|
||||
test('unsealed recovery checks recorded bytes before allocating more IDs or releasing writes',async()=>{
|
||||
const f=fixture(),gate=deferred();let engine=await f.open({digests:{digest(bytes,signal){return new Promise((resolve,reject)=>{signal.addEventListener('abort',()=>reject(Error('aborted')),{once:true});gate.promise.then(()=>importSourceDigest(bytes,signal).then(resolve,reject));});}}});
|
||||
try{const id=engine.imports.startNdjson(file([node('proof-one'),node('proof-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);await engine.imports.pause(id);await engine.persist();const before=engine.imports.getJobs().get(id),ids={...before.stableIds};await engine.close();const bytes=f.files.get(before.source.fileId),text=await bytes.text();f.files.set(before.source.fileId,new Blob([text.replace('proof-two','wrong-two')]));engine=await f.open();await engine.settle();const job=engine.imports.getJobs().get(id);assert.equal(job.source.integrityError,'import_source_changed');assert.equal(f.requests.length,0);assert.deepEqual(job.stableIds,ids);assert.ok(engine.getSnapshot().document.elements.size>0);f.files.set(before.source.fileId,bytes);await engine.imports.resume(id);await engine.settle();assert.equal(f.server().elements.size,2);}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 源摘要、读取证明及完整性版本由引擎拥有,公共 updateRuntime 不能伪造证据或清空 committed 进度;拒绝后 root 不变。
|
||||
test('source evidence is engine-owned and cannot be forged through public runtime updates',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{const id=engine.imports.startNdjson(file([node('owned-proof')]),'canvas');await engine.settle();const before=engine.getSnapshot(),job=engine.imports.getJobs().get(id);for(const source of [{...job.source,sha256:'0'.repeat(64)},{...job.source,readProof:{...job.source.readProof,blocks:[]}},{...job.source,integrityVersion:1,readProof:undefined}]){const result=engine.editor.updateRuntime(runtime=>({...runtime,imports:new Map(runtime.imports).set(id,{...job,source})}));assert.equal(result.status,'rejected');assert.equal(engine.getSnapshot(),before);}const forged=engine.editor.updateRuntime(runtime=>({...runtime,imports:new Map(runtime.imports).set(id,{...job,status:'cancelled',committedSourceKeys:[]})}));assert.equal(forged.status,'rejected');assert.equal(engine.getSnapshot(),before);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 取消后已接受前缀仍需验证:engine.retry 可补完失败校验并同步该前缀,但保持 cancelled 且不重新开始插入。
|
||||
test('retry verifies a cancelled accepted prefix without restarting insertion',async()=>{
|
||||
const f=fixture(),gate=deferred();let fail=true;const engine=await f.open({digests:{async digest(bytes,signal){await gate.promise;if(fail)throw Error('digest unavailable');return importSourceDigest(bytes,signal);}}});try{const id=engine.imports.startNdjson(file([node('retry-cancel-one'),node('retry-cancel-two')]),'canvas');await until(engine,root=>root.runtime.imports.get(id).committedSourceKeys.length>0);await engine.imports.cancel(id);gate.resolve();await engine.settle();const before=engine.imports.getJobs().get(id),count=engine.getSnapshot().document.elements.size;assert.ok(before.source.integrityError);assert.equal(f.requests.length,0);fail=false;await engine.retry();await engine.settle();const after=engine.imports.getJobs().get(id);assert.equal(after.status,'cancelled');assert.equal(after.source.integrityError,undefined);assert.ok(after.source.sha256);assert.deepEqual(after.stableIds,before.stableIds);assert.equal(engine.getSnapshot().document.elements.size,count);assert.equal(f.server().elements.size,count);}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 旧数组格式 14 和旧 streaming document 格式 16 的 integrity1 状态仍按 SHA-first 语义读取,分别保留 parser3/5。
|
||||
test('legacy native array 14 and streaming document 16 retain their SHA-first interpreters',async()=>{
|
||||
for(const format of ['array','document']){const f=fixture();let engine=await f.open();try{const bytes=format==='array'?new Blob([JSON.stringify([node('legacy-native')])]):new Blob([JSON.stringify({documentFormatVersion:1,projectId:'legacy-source',canvases:[{id:'legacy-source',parent:null}],elements:[{...node('legacy-native').record,ownerCanvasId:'legacy-source'}],tombstones:{}})]),id=format==='array'?engine.imports.startJsonArray(bytes,'canvas'):engine.imports.startDocument(bytes,'canvas');await engine.settle();await engine.close();const image=structuredClone(f.image());image.formatVersion=format==='array'?14:16;const job=image.payload.runtime.imports.find(([key])=>key===id)[1];job.source.integrityVersion=1;delete job.source.readProof;delete job.source.integrityError;const decoded=decodeRecovery(image,registry);assert.equal(decoded.runtime.imports.get(id).source.integrityVersion,1);f.storage.recover=async()=>structuredClone(image);engine=await f.open();await engine.settle();assert.equal(engine.imports.getJobs().get(id).parserVersion,format==='array'?3:5);assert.equal(engine.getSnapshot().document.elements.size,1);assert.equal(f.errors.length,0);}finally{await engine.close();}}
|
||||
});
|
||||
|
||||
// 千项真实引擎准备/ready 源在发布监听中异步封存,后续推进不改变捕获字节;准备编码实际让出,重开保留暂停和映射。
|
||||
test('engine preparation encodes in slices and the ready source retains checkpoint bytes and mappings on reopen', {timeout:20000},async()=>{
|
||||
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const rows=Array.from({length:1000},(_,i)=>({...node('live-checkpoint-'+i).record,incarnation:1,ownerCanvasId:'canvas'}));
|
||||
@@ -414,6 +521,7 @@ test('engine preparation encodes in slices and the ready source retains checkpoi
|
||||
const id=await engine.imports.duplicate(rows.map(row=>({id:row.id,incarnation:1})));
|
||||
await until(engine,snapshot=>snapshot.runtime.imports.get(id)?.source?.kind==='duplicate-plan');
|
||||
await engine.imports.pause(id);unsubscribe();
|
||||
// 等待发布监听里启动的封存全部完成,检查准备与源两个阶段都被捕获,且编码期间后续变更未改写旧值。
|
||||
const results=await Promise.all(captured);assert.deepEqual([...seen].sort(),['preparation','source']);
|
||||
for(const result of results){assert.equal(result.error,undefined);if(result.phase==='preparation')assert.ok(result.yields>0,'preparation checkpoint was synchronously normalized');assert.equal(JSON.stringify(result.image.payload),result.before);}
|
||||
const ids=engine.imports.getJobs().get(id).stableIds;gate.resolve();await engine.close();engine=await f.open();
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
/**
|
||||
* 独立导入 runner 的解析、依赖分组、发布和持久化状态机测试。每个已提交前缀可见,坏记录及取消不默认回滚全部导入。
|
||||
* 依赖循环作为强连通组原子提交;稳定 ID、待处理定位、源身份和 generation 支持恢复并隔离迟到异步准备。
|
||||
* fixture 用内存文档与克隆 cut 验证端口调用顺序;引擎/真实存储集成另有测试,不由此模拟持久层事务保证。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,applyChanges,validateDocument,createRegistry,builtinComponents,validJson,fault} from '../../src/lib/canvas-core/index.ts';
|
||||
import {createImportJob,validateImportJob,createNdjsonImportSource,createImportRunner} from '../../src/lib/canvas-runtime/imports.ts';
|
||||
// 内置定义及空单 canvas 文档,供每个 fixture 从相同不可变基线开始。
|
||||
const registry=createRegistry(builtinComponents()),base=createDocument('project',[{id:'canvas',parent:null}],[]);
|
||||
// 手动 Promise 屏障用于暂停源读取、persist 或 beforeCommit,定位取消/恢复的边界。
|
||||
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
|
||||
// 有限推进微任务,检查预算尚未放行时确实没有提前发布内容。
|
||||
const ticks=async()=>{for(let i=0;i<30;i++)await Promise.resolve();};
|
||||
// 把对象或故意损坏的原始行拼成 NDJSON Blob,允许同一输入同时含合法和错误记录。
|
||||
const bytes=records=>new Blob([records.map(record=>typeof record==='string'?record:JSON.stringify(record)).join('\n')]);
|
||||
// 组装源、当前 document/job 状态根与 runner 端口,options 可覆写端口或指定恢复状态。
|
||||
function fixture(records,options={}){
|
||||
const source=options.source??createNdjsonImportSource({bytes:bytes(records),fingerprint:'immutable-source'}),calls=[],errors=[],saved=[],commits=[];let n=0,time=0,root=options.root??{document:base,job:createImportJob({id:'import',projectId:'project',sourceFingerprint:source.fingerprint,parserVersion:1})};
|
||||
// root getter/setter 暴露测试当前状态;真实修改通过 publish/commit,少数用例显式替换它来模拟外部编辑或换代。
|
||||
const f={source,calls,errors,saved,commits,get root(){return root;},set root(value){root=value;},ports:{source,getJob:()=>root.job,createId:()=>`new-${++n}`,now:()=>time,
|
||||
// 以 job.generation 做条件发布,只替换 job 元数据;文档变化必须经 commit 入口。
|
||||
publish(job,expected){assert.equal(root.job.generation,expected);root={...root,job};},
|
||||
// 最小解析转换:校验输入 ID、reserve 稳定 ID、保留 source-key 依赖,再生成节点创建;bad 标记注入非法宽度。
|
||||
prepare(line,job,reserve){time+=2;const input=line.value;if(!input||typeof input!=='object'||!input.id)throw fault('invalid_record');const id=reserve(input.id);return{key:input.id,dependencies:input.dependencies??[],changes:[{kind:'element',id,before:null,after:{id,incarnation:1,structure:'node',ownerCanvasId:'canvas',componentId:'text',dataVersion:1,data:{title:input.title??input.id,text:''},geometry:{x:0,y:0,width:input.bad?-1:100,height:100,rotation:0}}}]};},
|
||||
// 在私有 document 应用整组变化并校验,成功才把 document/job 一起替换;拒绝时原 root 必须不变。
|
||||
commit(units,job,expected){time+=2;assert.equal(root.job.generation,expected);calls.push(units.map(unit=>unit.key));const before=root;try{const document=applyChanges(root.document,units.flatMap(unit=>unit.changes));validateDocument(document,registry);root={document,job};commits.push(root);return{status:'applied',revision:commits.length,operationIds:[]};}catch(error){assert.equal(root,before);return{status:'rejected',code:error.code??'invalid',targets:[]};}},
|
||||
// 校验 job 可序列化且结构合法,再克隆 job 与文档记录为恢复 cut;saved 用于后续重开对照。
|
||||
async persist(){validateImportJob(root.job);assert.equal(validJson(root.job),true);saved.push(structuredClone({job:root.job,document:{projectId:root.document.projectId,canvases:[...root.document.canvases.values()],elements:[...root.document.elements.values()],tombstones:[...root.document.tombstones]}}));},
|
||||
// 错误上报收集到 errors,yieldTask 计数协作调度;调用者 ports 最后覆盖以注入背压和故障。
|
||||
onError:error=>errors.push(error),yieldTask:async()=>{f.yields++;},...options.ports},yields:0};
|
||||
// 所有端口装好后才创建 runner,避免调用未就绪 fixture;返回端口引用供各用例精确注入故障。
|
||||
f.runner=createImportRunner(f.ports);return f;
|
||||
}
|
||||
|
||||
// UTF-8 按 3 字节块解析并以字节游标重读;坏 JSON/超大行只报各自行错误,后续正常行保留,输入 options 改写不换源。
|
||||
test('NDJSON parses bounded UTF-8 chunks, seeks by byte cursor and reports malformed/oversized records without losing the next line',async()=>{
|
||||
const source=createNdjsonImportSource({bytes:bytes([{id:'中文 😀'},'{bad}','x'.repeat(30),{id:'ok'}]),fingerprint:'file-1',maxRecordBytes:24,chunkBytes:3}),signal=new AbortController().signal;
|
||||
let cursor=0;const rows=[];for(;;){const row=await source.read(cursor,signal);if(!row)break;rows.push(row);cursor=row.nextCursor;}
|
||||
@@ -24,6 +42,7 @@ test('NDJSON parses bounded UTF-8 chunks, seeks by byte cursor and reports malfo
|
||||
const input={bytes:bytes([{id:'old'}]),fingerprint:'fixed'},owned=createNdjsonImportSource(input);input.bytes=bytes([{id:'replacement'}]);assert.equal((await owned.read(0,signal)).value.id,'old');
|
||||
});
|
||||
|
||||
// 第二项读取挂起时第一项已可见且已登记 committed key;取消保留该前缀,不能等 EOF 才显示或把已提交内容回滚。
|
||||
test('records become visible before EOF, task yields real scheduling points and cancellation preserves the committed prefix',async()=>{
|
||||
const gate=deferred(),entered=deferred(),raw=bytes([{id:'first'},{id:'second'}]),initial=createNdjsonImportSource({bytes:raw,fingerprint:'immutable-source'});let firstEnd;
|
||||
firstEnd=(await initial.read(0,new AbortController().signal)).nextCursor;
|
||||
@@ -32,19 +51,23 @@ test('records become visible before EOF, task yields real scheduling points and
|
||||
await f.runner.cancel();gate.resolve();await running;await f.runner.settle();assert.equal(f.root.job.status,'cancelled');assert.equal(f.root.document.elements.size,1);assert.deepEqual(f.root.job.committedSourceKeys,['first']);await f.runner.close();
|
||||
});
|
||||
|
||||
// child 先于 parent 时按源键等待;parent 加入后即使被用户改名,随后 child 加入也不能用旧快照覆盖用户编辑。
|
||||
test('a child encountered before its parent waits by source key, then joins without overwriting user edits',async()=>{
|
||||
const f=fixture([{id:'child',dependencies:['parent']},{id:'parent'}]);const real=f.ports.commit;f.ports.commit=(units,next,expected)=>{const result=real(units,next,expected);if(result.status==='applied'&&units.some(unit=>unit.key==='parent')){const id=f.root.job.stableIds.parent,element=f.root.document.elements.get(id);f.root={...f.root,document:applyChanges(f.root.document,[{kind:'field',target:{id,incarnation:1},field:'data.title',before:element.data.title,after:'user edit'}])};}return result;};
|
||||
await f.runner.start();assert.equal(f.root.job.status,'completed');assert.deepEqual(f.root.job.committedSourceKeys,['parent','child']);assert.equal(f.root.document.elements.get(f.root.job.stableIds.parent).data.title,'user edit');assert.equal(f.root.job.pending.length,0);assert.equal(f.errors.length,0);
|
||||
});
|
||||
|
||||
// 多项批次含非法节点时先整批拒绝,再隔离独立坏项;good/tail 仍提交,失败元素不进入文档。
|
||||
test('a rejected multi-record batch publishes no prefix, isolates the invalid record and retains independent content',async()=>{
|
||||
const f=fixture([{id:'good',dependencies:['parent']},{id:'bad',dependencies:['parent'],bad:true},{id:'parent'},{id:'tail'}]);await f.runner.start();assert.equal(f.root.job.status,'partial');assert.ok(f.calls.some(keys=>keys.includes('good')&&keys.includes('bad')));assert.deepEqual(f.root.job.committedSourceKeys,['parent','good','tail']);assert.equal(f.root.document.elements.has(f.root.job.stableIds.bad),false);assert.equal(f.root.job.failures.find(failure=>failure.key==='bad').code,'invalid_geometry');assert.equal(f.errors.length,0);
|
||||
});
|
||||
|
||||
// 前 100 项后发生解析错误并有缺依赖项,仍保留原内容和后来合法项;以 partial 记录两种失败,不插入悬空依赖。
|
||||
test('record 101 failure preserves prior content and later valid records; missing dependencies report partial without dangling insertion',async()=>{
|
||||
const input=Array.from({length:100},(_,index)=>({id:`n-${index}`}));input.push('{bad}',{id:'after'},{id:'edge',dependencies:['missing']});const f=fixture(input);await f.runner.start();assert.equal(f.root.job.status,'partial');assert.equal(f.root.document.elements.size,101);assert.equal(f.root.job.failures.length,2);assert.ok(f.root.job.failures.some(failure=>failure.code==='invalid_json'));assert.ok(f.root.job.failures.some(failure=>failure.code==='unresolved_dependency'));assert.equal(f.root.document.elements.has(f.root.job.stableIds.edge),false);validateImportJob(JSON.parse(JSON.stringify(f.root.job)));
|
||||
});
|
||||
|
||||
// 待处理 locator 已保存后关闭再恢复,必须复用原 ID;已 committed key 对应元素后来被用户删除,也不能重导入复活。
|
||||
test('a durable pending locator resumes with the same IDs; a committed key is not inserted again after deletion',async()=>{
|
||||
const gate=deferred(),entered=deferred(),f=fixture([{id:'one',title:'original'}]);const persist=f.ports.persist;f.ports.persist=async()=>{await persist();if(f.root.job.pending.length&&!f.root.job.committedSourceKeys.length){entered.resolve();await gate.promise;}};
|
||||
const task=f.runner.start();await entered.promise;const cut=f.saved.at(-1),closing=f.runner.close();gate.resolve();await task;await closing;
|
||||
@@ -52,26 +75,31 @@ test('a durable pending locator resumes with the same IDs; a committed key is no
|
||||
const id=resumed.root.job.stableIds.one,element=resumed.root.document.elements.get(id),deleted=applyChanges(resumed.root.document,[{kind:'element',id,before:element,after:null}]),again=fixture([],{source:f.source,root:{document:deleted,job:{...resumed.root.job,status:'paused',eof:false}},ports:{createId(){throw Error('reallocated identity');}}});await again.runner.start();assert.equal(again.root.document.elements.has(id),false);assert.equal(again.calls.length,0);
|
||||
});
|
||||
|
||||
// 同 fingerprint 字节变更和 fingerprint 不同分别报告 source_changed/mismatch,暂停且不提交待处理项。
|
||||
test('changed source bytes or source identity cannot claim a durable pending item',async()=>{
|
||||
const oldSource=createNdjsonImportSource({bytes:bytes([{id:'one',title:'aaa'}]),fingerprint:'immutable-source'}),gate=deferred(),entered=deferred(),f=fixture([],{source:oldSource});const persist=f.ports.persist;f.ports.persist=async()=>{await persist();if(f.root.job.pending.length){entered.resolve();await gate.promise;}};
|
||||
const task=f.runner.start();await entered.promise;const root=f.root,closing=f.runner.close();gate.resolve();await task;await closing;
|
||||
for(const [title,fingerprint,code]of[['bbb','immutable-source','import_source_changed'],['aaa','different-source','import_source_mismatch']]){const source=createNdjsonImportSource({bytes:bytes([{id:'one',title}]),fingerprint}),r=fixture([],{source,root});await r.runner.start();assert.equal(r.root.job.status,'paused');assert.equal(r.root.job.error,code);assert.equal(r.root.document.elements.size,0);assert.equal(r.calls.length,0);}
|
||||
});
|
||||
|
||||
// pending 容量满暂停但保留 locator 和映射;提高容量再开可继续同游标/ID,而不是丢弃或重新编号。
|
||||
test('dependency backpressure pauses with locators intact and a larger budget resumes the same cursor and mapping',async()=>{
|
||||
const f=fixture([{id:'child',dependencies:['parent']},{id:'parent'}],{ports:{maxPending:1}});await f.runner.start();assert.equal(f.root.job.status,'paused');assert.equal(f.root.job.error,'dependency_capacity');assert.equal(f.root.job.pending.length,1);const id=f.root.job.stableIds.child;
|
||||
const r=fixture([],{source:f.source,root:f.root,ports:{maxPending:8,createId:()=> 'parent-id'}});await r.runner.start();assert.equal(r.root.job.status,'completed');assert.equal(r.root.job.stableIds.child,id);assert.equal(r.root.document.elements.size,2);
|
||||
});
|
||||
|
||||
// 未来 job 版本、重复目标 ID 和缺乏结束条件的 completed cut 均在校验阶段拒绝。
|
||||
test('future interpreter versions and malformed job cuts fail before any source read',()=>{
|
||||
const job=createImportJob({id:'i',projectId:'p',sourceFingerprint:'s',parserVersion:1});assert.throws(()=>validateImportJob({...job,version:4}),/import_interpreter_unavailable/);assert.throws(()=>validateImportJob({...job,stableIds:{a:'same',b:'same'}}),/invalid_import_job/);assert.throws(()=>validateImportJob({...job,status:'completed'}),/invalid_import_job/);
|
||||
});
|
||||
|
||||
// 便宜记录首项仍立即发布,随后可合并至最多 16 项;元数据发布次数受控,不能每条都重复发布完整状态。
|
||||
test('fast independent records stage bounded publications and commits while the first record remains immediate',async()=>{
|
||||
const f=fixture(Array.from({length:80},(_,index)=>({id:`batch-${index}`})),{ports:{now:()=>0,maxBatch:16,optimisticLocal:true}}),publish=f.ports.publish;let publications=0;f.ports.publish=(...args)=>{publications++;return publish(...args);};
|
||||
await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,80);assert.deepEqual(f.calls[0],['batch-0']);assert.ok(f.calls.some(group=>group.length>1));assert.ok(f.calls.every(group=>group.length<=16));assert.ok(publications<20,`${publications} metadata publications for 80 records`);assert.equal(f.errors.length,0);validateImportJob(f.root.job);
|
||||
});
|
||||
|
||||
// 第三项预读挂起时前两项已准备内容仍可见,源 read 始终串行;取消保留两项,不要求并发 seek 解锁。
|
||||
test('a stalled speculative read cannot hide the ready staged prefix or require concurrent source seeks',async()=>{
|
||||
const gate=deferred(),entered=deferred(),source=createNdjsonImportSource({bytes:bytes([{id:'first'},{id:'second'},{id:'third'}]),fingerprint:'immutable-source'}),signal=new AbortController().signal,first=await source.read(0,signal),second=await source.read(first.nextCursor,signal);let active=0;
|
||||
const f=fixture([],{source:{...source,async read(cursor,signal){assert.equal(active,0,'concurrent source seeks');active++;try{if(cursor===second.nextCursor){entered.resolve();await gate.promise;}return await source.read(cursor,signal);}finally{active--;}}},ports:{now:()=>0,optimisticLocal:true}});
|
||||
@@ -79,25 +107,30 @@ test('a stalled speculative read cannot hide the ready staged prefix or require
|
||||
for(let i=0;i<100&&f.root.document.elements.size<2;i++)await new Promise(resolve=>setTimeout(resolve,1));assert.equal(f.root.document.elements.size,2);assert.deepEqual(f.root.job.committedSourceKeys,['first','second']);assert.equal(f.root.job.eof,false);await f.runner.cancel();gate.resolve();await running;assert.equal(f.root.document.elements.size,2);assert.equal(f.root.job.status,'cancelled');assert.equal(f.errors.length,0);
|
||||
});
|
||||
|
||||
// 第二项转换后微任务取消,只保留首项;未发布的第二项游标、稳定 ID 和 pending 不能泄漏到 job。
|
||||
test('cancelling during a staged decode discards unpublished cursor and identity changes',async()=>{
|
||||
const f=fixture([{id:'first'},{id:'second'},{id:'third'}],{ports:{now:()=>0,optimisticLocal:true}}),prepare=f.ports.prepare;let cancelled;
|
||||
f.ports.prepare=(line,job,reserve)=>{const unit=prepare(line,job,reserve);if(line.value.id==='second')queueMicrotask(()=>{cancelled=f.runner.cancel();});return unit;};
|
||||
await f.runner.start();await cancelled;assert.equal(f.root.job.status,'cancelled');assert.deepEqual(f.root.job.committedSourceKeys,['first']);assert.equal(f.root.document.elements.size,1);assert.equal(Object.hasOwn(f.root.job.stableIds,'second'),false);assert.equal(f.root.job.pending.length,0);assert.equal(f.errors.length,0);validateImportJob(f.root.job);
|
||||
});
|
||||
|
||||
// 源 I/O 在已准备项之后失败,先发布 ready 前缀再 paused 并报告原错误,不能把已就绪内容藏在错误后。
|
||||
test('a source I/O failure is reported after publishing all already prepared ready records',async()=>{
|
||||
const raw=createNdjsonImportSource({bytes:bytes([{id:'first'},{id:'second'}]),fingerprint:'immutable-source'}),first=await raw.read(0,new AbortController().signal),second=await raw.read(first.nextCursor,new AbortController().signal),f=fixture([],{source:{...raw,async read(cursor,signal){if(cursor===second.nextCursor)throw fault('source_offline');return raw.read(cursor,signal);}},ports:{now:()=>0,optimisticLocal:true}});
|
||||
await f.runner.start();assert.equal(f.root.job.status,'paused');assert.equal(f.root.job.error,'source_offline');assert.deepEqual(f.root.job.committedSourceKeys,['first','second']);assert.equal(f.root.document.elements.size,2);assert.equal(f.root.job.pending.length,0);assert.equal(f.errors.length,1);
|
||||
});
|
||||
|
||||
// A/B/C 循环即使 maxBatch=1 仍作为一个完整原子组提交一次,不能用软批量限制拆开依赖环。
|
||||
test('a reference cycle commits every member in one root even when the adaptive batch budget is one',async()=>{
|
||||
const f=fixture([{id:'A',dependencies:['B']},{id:'B',dependencies:['C']},{id:'C',dependencies:['A']}],{ports:{maxBatch:1}});await f.runner.start();assert.equal(f.root.job.status,'completed');assert.deepEqual(f.calls,[['A','B','C']]);assert.equal(f.commits.length,1);assert.equal(f.root.document.elements.size,3);validateImportJob(f.root.job);
|
||||
});
|
||||
|
||||
// 依赖环一项非法时所有环成员共同失败,任何尝试都含完整环;无关 tail 仍成功,不能单独隔离并发布环成员。
|
||||
test('a rejected reference cycle never isolates or publishes one member; independent content survives',async()=>{
|
||||
const f=fixture([{id:'A',dependencies:['B']},{id:'B',dependencies:['A'],bad:true},{id:'tail'}]);await f.runner.start();assert.equal(f.root.job.status,'partial');assert.deepEqual(f.root.job.committedSourceKeys,['tail']);assert.ok(f.calls.filter(keys=>keys.includes('A')||keys.includes('B')).every(keys=>keys.includes('A')&&keys.includes('B')));assert.deepEqual(f.root.job.failures.map(value=>value.key),['A','B']);assert.equal(f.root.document.elements.size,1);
|
||||
});
|
||||
|
||||
// 整环 locator 保存时文档仍为空;恢复在同一次提交中加入所有成员,不重新分配 ID 或暴露半组。
|
||||
test('a durable pending cycle resumes without reallocating IDs or exposing a partial group',async()=>{
|
||||
const gate=deferred(),entered=deferred(),f=fixture([{id:'A',dependencies:['B']},{id:'B',dependencies:['A']}]);const persist=f.ports.persist;f.ports.persist=async()=>{await persist();if(f.root.job.pending.length===2&&!f.root.job.committedSourceKeys.length){entered.resolve();await gate.promise;}};
|
||||
const running=f.runner.start();await entered.promise;const cut=f.root,closing=f.runner.close();gate.resolve();await running;await closing;assert.equal(cut.document.elements.size,0);
|
||||
@@ -105,16 +138,19 @@ test('a durable pending cycle resumes without reallocating IDs or exposing a par
|
||||
});
|
||||
|
||||
|
||||
// 新 job 为 v3;显式可读的 v1 旧 job 恢复后在首个 published cut 升级为 v3,不能继续发布旧解释器形态。
|
||||
test('new jobs carry interpreter version 3 and a resumed legacy job upgrades in its first published cut',async()=>{
|
||||
const f=fixture([{id:'one'}]);assert.equal(f.root.job.version,3);f.root={...f.root,job:{...f.root.job,version:1}};validateImportJob(f.root.job);const publish=f.ports.publish;f.ports.publish=(job,expected)=>{assert.equal(job.version,3);return publish(job,expected);};await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.root.job.version,3);
|
||||
});
|
||||
|
||||
// 乐观本地已发布两项时,宿主 durability budget 可阻止第三项;到 EOF 后仍先持久化 running 内容,再声明 completed。
|
||||
test('optimistic local checkpoints use host backpressure without declaring completion ahead of persistence',async()=>{
|
||||
const gate=deferred(),entered=deferred(),f=fixture([{id:'one'},{id:'two'},{id:'three'}],{ports:{optimisticLocal:true,maxBatch:1}});let blocked=false;
|
||||
f.ports.awaitDurabilityBudget=async()=>{if(!blocked&&f.root.document.elements.size===2){blocked=true;entered.resolve();await gate.promise;}};
|
||||
const running=f.runner.start();await entered.promise;assert.equal(f.root.document.elements.size,2);assert.equal(f.root.job.status,'running');assert.equal(f.root.job.committedSourceKeys.length,2);gate.resolve();await running;assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,3);assert.ok(f.saved.some(cut=>cut.job.status==='running'&&cut.job.eof&&cut.document.elements.length===3));
|
||||
});
|
||||
|
||||
// 最终内容 cut 或 completed 元数据保存失败均 paused;修复后续跑不重复 committed key 或 ID,仅补完持久化终态。
|
||||
test('both final content and completion-metadata storage failures pause resumably without replaying committed keys',async()=>{
|
||||
for(const phase of ['content','metadata']){
|
||||
const f=fixture([{id:'one'},{id:'two'}],{ports:{optimisticLocal:true,awaitDurabilityBudget(){}}}),persist=f.ports.persist;let broken=true;
|
||||
@@ -123,46 +159,59 @@ test('both final content and completion-metadata storage failures pause resumabl
|
||||
}
|
||||
});
|
||||
|
||||
// 最后持久化屏障挂起时 pause 立即生效;迟到成功不得发布 completed,再次 start 才合法完成且不重复内容提交。
|
||||
test('pausing during the final durability barrier prevents a late completion publication',async()=>{
|
||||
const gate=deferred(),entered=deferred(),f=fixture([{id:'one'}],{ports:{optimisticLocal:true,awaitDurabilityBudget(){}}}),persist=f.ports.persist;
|
||||
f.ports.persist=async()=>{if(f.root.job.eof){entered.resolve();await gate.promise;}return persist();};const running=f.runner.start();await entered.promise;const pausing=f.runner.pause();assert.equal(f.root.job.status,'paused');gate.resolve();await pausing;await running;assert.equal(f.root.job.status,'paused');await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.calls.length,1);
|
||||
});
|
||||
|
||||
// partial 状态保存失败后恢复,只补存已有 unresolved_dependency 诊断,不能重复追加同一失败或内容。
|
||||
test('resuming a failed partial-status write does not duplicate unresolved dependency diagnostics',async()=>{
|
||||
const f=fixture([{id:'good'},{id:'waiting',dependencies:['missing']}],{ports:{optimisticLocal:true,awaitDurabilityBudget(){}}}),persist=f.ports.persist;let broken=true;
|
||||
f.ports.persist=async()=>{if(broken&&f.root.job.status==='partial')throw fault('quota');return persist();};await f.runner.start();assert.equal(f.root.job.status,'paused');assert.equal(f.root.job.failures.length,1);broken=false;await f.runner.start();assert.equal(f.root.job.status,'partial');assert.equal(f.root.job.failures.length,1);assert.equal(f.root.document.elements.size,1);assert.equal(f.calls.length,1);
|
||||
});
|
||||
|
||||
|
||||
// parsed locator 已发布但宿主 parsed 阶段预算未放行,内容根仍为空;背压释放后才提交两项并完成。
|
||||
test('a ready parser publication observes host backpressure before admitting its content root',async()=>{
|
||||
const gate=deferred(),entered=deferred();let blocked=true;const f=fixture([{id:'one'},{id:'two'}],{ports:{optimisticLocal:true,maxBatch:1,awaitDurabilityBudget(stage){if(blocked){assert.equal(stage,'parsed');entered.resolve();return gate.promise;}}}});const running=f.runner.start();await entered.promise;assert.equal(f.root.job.pending.length,1);assert.equal(f.root.job.committedSourceKeys.length,0);assert.equal(f.root.document.elements.size,0);await ticks();assert.equal(f.root.document.elements.size,0,'ready content bypassed host budget');blocked=false;gate.resolve();await running;assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,2);assert.equal(f.errors.length,0);await f.runner.close();
|
||||
});
|
||||
|
||||
// 异步 beforeCommit 挂起时不能提前发布文档或 committed key;期间 cancel 后,迟到 enabled:true 也不得提交。
|
||||
test('async commit preparation cannot publish while waiting and cancellation fences a late successful result',async()=>{const gate=deferred(),entered=deferred(),f=fixture([{id:'image'}],{ports:{async beforeCommit(units,job,signal){assert.equal(f.root.document.elements.size,0);assert.ok(job.pending.length);entered.resolve();await gate.promise;return{enabled:true};}}});const running=f.runner.start();await entered.promise;assert.equal(f.commits.length,0);assert.equal(f.root.job.committedSourceKeys.length,0);await f.runner.cancel();gate.resolve();await running;await f.runner.settle();assert.equal(f.root.job.status,'cancelled');assert.equal(f.commits.length,0);assert.equal(f.root.document.elements.size,0);await f.runner.close();});
|
||||
// 异步准备拒绝 denied 独立项后记录 asset_forbidden,另一个独立项仍发布,拒绝不冒充系统异常。
|
||||
test('async preparation denial isolates independent groups without publishing the denied record',async()=>{const prepared=[],f=fixture([{id:'denied'},{id:'allowed'}],{ports:{async beforeCommit(units){prepared.push(units.map(unit=>unit.key));return units.some(unit=>unit.key==='denied')?{enabled:false,code:'asset_forbidden',targets:[]}:{enabled:true};}}});await f.runner.start();assert.equal(f.root.job.status,'partial');assert.ok(f.root.job.failures.some(row=>row.key==='denied'&&row.code==='asset_forbidden'));assert.equal(f.root.document.elements.size,1);assert.equal([...f.root.document.elements.values()][0].data.title,'allowed');assert.ok(prepared.length>=2);assert.equal(f.errors.length,0);await f.runner.close();});
|
||||
// 异步准备期间 job.generation 外部递增,旧 attempt 返回后不能提交或改进度;新 start 可重新完成。
|
||||
test('a generation change during async preparation prevents the old attempt from committing or changing progress',async()=>{const gate=deferred(),entered=deferred(),f=fixture([{id:'later'}],{ports:{async beforeCommit(){entered.resolve();await gate.promise;return{enabled:true};}}});const running=f.runner.start();await entered.promise;f.root={...f.root,job:{...f.root.job,generation:f.root.job.generation+1}};const generation=f.root.job.generation;gate.resolve();await running;assert.equal(f.commits.length,0);assert.equal(f.root.job.generation,generation);assert.equal(f.root.job.committedSourceKeys.length,0);await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,1);await f.runner.close();});
|
||||
|
||||
// NDJSON 扫描短行时也检查耗时预算,在返回已解码记录前 yield;yield 内取消须以 import_aborted 拒绝。
|
||||
test('NDJSON checks elapsed time before returning a short scanned record and honors cancellation',async()=>{const controller=new AbortController();let clock=0,yields=0;const source=createNdjsonImportSource({bytes:new Blob([JSON.stringify({text:'x'.repeat(2048)})+'\n']),fingerprint:'timed',chunkBytes:256,now:()=>clock+=10,frameBudgetMs:6,yieldTask:async()=>{yields++;controller.abort();}});await assert.rejects(source.read(0,controller.signal),/import_aborted/);assert.equal(yields,1);});
|
||||
|
||||
// 已预分配大量 ID 的恢复 job,在 parsed/committed cut 都保留同一稳定表对象;不得重复分配或为每行复制整张表。
|
||||
test('a resumed preallocated import retains the exact stable-ID table through parsed and committed checkpoints',async()=>{
|
||||
const stableIds=Object.freeze(Object.fromEntries([...Array.from({length:1000},(_,i)=>['reserved-'+i,'unused-'+i]),['one','id-one'],['two','id-two']])),job={...createImportJob({id:'import',projectId:'project',sourceFingerprint:'immutable-source',parserVersion:1}),stableIds},f=fixture([{id:'one'},{id:'two'}],{root:{document:base,job},ports:{createId(){throw Error('preallocated ID was replaced');}}});await f.runner.start();assert.equal(f.root.job.status,'completed');assert.strictEqual(f.root.job.stableIds,stableIds);for(const cut of f.commits)assert.strictEqual(cut.job.stableIds,stableIds);assert.deepEqual(f.root.job.committedSourceKeys,['one','two']);assert.equal(f.errors.length,0);await f.runner.close();
|
||||
});
|
||||
|
||||
// createId 是宿主对象方法,runner 必须保留 this receiver,计数和分配结果由原 ports 对象维护。
|
||||
test('identity allocation keeps the caller port method receiver',async()=>{
|
||||
const f=fixture([{id:'bound'}],{ports:{allocated:0,createId(){return `bound-${++this.allocated}`;}}});await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.root.job.stableIds.bound,'bound-1');assert.equal(f.ports.allocated,1);assert.equal(f.errors.length,0);await f.runner.close();
|
||||
});
|
||||
|
||||
// 每次提交有固定成本时,首项仍立即显示,后续增长批量摊薄成本;昂贵工作后让出,不增加原耗时预算。
|
||||
test('fixed-cost commits amortize ready records while preserving the immediate first record and yielding after expensive work',async()=>{
|
||||
let clock=0;const f=fixture(Array.from({length:80},(_,i)=>({id:`fixed-${i}`})),{ports:{now:()=>clock,frameBudgetMs:6,maxBatch:16,optimisticLocal:true}}),commit=f.ports.commit;f.ports.commit=(units,...rest)=>{clock+=4+units.length*.05;return commit(units,...rest);};await f.runner.start();assert.equal(f.root.job.status,'completed');assert.deepEqual(f.calls[0],['fixed-0']);assert.ok(f.calls.some(group=>group.length>=8));assert.ok(f.calls.length<20);assert.ok(f.yields>0);assert.equal(f.errors.length,0);validateImportJob(f.root.job);
|
||||
});
|
||||
|
||||
// 便宜的 700 条记录可使用最多 128 项上限并超过旧的 32 项批量,全部记录按 FIFO 提交且不丢失。
|
||||
test('a cheap runner can use the engine-sized ceiling without dropping records or changing FIFO order',async()=>{
|
||||
const records=Array.from({length:700},(_,index)=>({id:`wide-${index}`})),f=fixture(records,{ports:{maxBatch:128,now:()=>0}});await f.runner.start();assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,700);assert.ok(f.calls.some(batch=>batch.length>32));assert.ok(f.calls.every(batch=>batch.length<=128));assert.deepEqual(f.calls.flat(),records.map(row=>row.id));assert.equal(f.errors.length,0);await f.runner.close();
|
||||
});
|
||||
// 较大上限不能掩盖单批超时:超过 40 项的模拟提交变贵后下一批必须回退,全部源键仍按序保留。
|
||||
test('the larger ceiling still backs off when a measured commit exceeds the unchanged time budget',async()=>{
|
||||
let clock=0;const records=Array.from({length:500},(_,index)=>({id:`cost-${index}`})),f=fixture(records,{ports:{maxBatch:128,now:()=>clock}}),commit=f.ports.commit;f.ports.commit=(units,...args)=>{clock+=units.length>40?12:1;return commit(units,...args);};await f.runner.start();const sizes=f.calls.map(batch=>batch.length),wide=sizes.findIndex(size=>size>40);assert.ok(wide>=0);assert.ok(sizes[wide+1]<sizes[wide]);assert.equal(f.root.job.status,'completed');assert.equal(f.root.document.elements.size,500);assert.deepEqual(f.calls.flat(),records.map(row=>row.id));assert.equal(f.errors.length,0);await f.runner.close();
|
||||
});
|
||||
|
||||
// 300 条导入形成的 stableIds/bindings 按预算封存,与同步 image 一致且映射数量完整。
|
||||
test('growing import tables yield when encoded while keeping captured IDs and bindings stable',async()=>{
|
||||
const {sealRecoveryImageAsync,sealRecoveryImage}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const f=fixture(Array.from({length:300},(_,i)=>({id:'encode-'+i})));await f.runner.start();
|
||||
@@ -173,6 +222,7 @@ test('growing import tables yield when encoded while keeping captured IDs and bi
|
||||
});
|
||||
|
||||
|
||||
// 300 个尚未等到依赖的 locator 也需协作式编码;无正文发布时仍保存完整待处理依赖,不能为了切片丢项。
|
||||
test('waiting import locators and dependencies encode cooperatively before their endpoints arrive',async()=>{
|
||||
const {sealRecoveryImageAsync,sealRecoveryImage}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const f=fixture(Array.from({length:300},(_,i)=>({id:'waiting-'+i,dependencies:['absent']})));await f.runner.start();
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
/**
|
||||
* 作用域加载协调器测试:多个订阅者可共享同一渐进源,但各自保留导航元数据、取消状态和快照生命周期。
|
||||
* 块需绑定 query/权限/project/snapshot/version/sequence,完整标记后仍须正常 EOF;错误尾部只能保留只读前缀。
|
||||
* 活动并发、排队、单响应字节/块数及校验后缓存分别受限,旧任务迟到清理不能删除新代次。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createScopedLoader, loadQueryKey } from '../../src/lib/canvas-runtime/loader.ts';
|
||||
// 完整查询作用域:账户、workspace、project/canvas、格式、过滤指纹、来源及权限/导航代次;导航代次不阻止共享源。
|
||||
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project', canvasId: 'canvas', viewKey: 'canvas', format: 'v1', queryFingerprint: 'all', source: 'fixture', authEpoch: 1, navigationGeneration: 1 };
|
||||
// 可手动 resolve/reject 的 Promise,用来暂停源迭代,检查取消与新代次的关系。
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
// 让出至 setImmediate,使源执行及清理有机会推进,不模拟实际网络延迟。
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
// 构造绑定查询键的合法加载块,extra 专供逐字段篡改;覆盖信息按序累积,value 使用冻结样本。
|
||||
const chunk = (query, sequence = 0, complete = true, extra = {}) => ({ queryKey: loadQueryKey(query), projectId: 'project', snapshotToken: 'snapshot', serverVersions: { canvas: 3 }, permissionEpoch: query.authEpoch, sequence, coverage: [`part-${sequence}`], complete, byteLength: 2, value: Object.freeze({ part: sequence }), ...extra });
|
||||
|
||||
// 两个导航订阅共用一次渐进读取;取消 A 不取消 B,A 保留取消时前缀且不再更新,B 收齐覆盖并完成。
|
||||
test('subscribers share a progressive read while retaining navigation scopes; cancelling one preserves the other', async () => {
|
||||
const gate = deferred(), updates = []; let calls = 0, signal;
|
||||
const loader = createScopedLoader({ current: () => true, async *load(query, abort) { calls++; signal = abort; yield chunk(query, 0, false); await gate.promise; yield chunk(query, 1); } });
|
||||
@@ -19,41 +29,48 @@ test('subscribers share a progressive read while retaining navigation scopes; ca
|
||||
assert.strictEqual(a.getSnapshot(),stopped);assert.strictEqual(b.getSnapshot(),result);
|
||||
assert.deepEqual(updates, ['queued', 'loading', 'initial-ready']); b.release(); loader.close();
|
||||
});
|
||||
// 尾部失败保留已接受首块为 failed-with-partial;重新 acquire 必须启动新源,不复用失败 inflight。
|
||||
test('tail failure keeps the accepted prefix and a fresh retry replaces the failed inflight entry', async () => {
|
||||
let calls = 0;
|
||||
const loader = createScopedLoader({ current: () => true, async *load(query) { calls++; yield chunk(query, 0, false); if (calls === 1) throw Object.assign(Error('tail_failed'), { code: 'tail_failed' }); yield chunk(query, 1); } });
|
||||
const a = loader.acquire(scope); await assert.rejects(a.ready, { code: 'tail_failed' }); assert.equal(a.getSnapshot().status, 'failed-with-partial'); assert.equal(a.getSnapshot().chunks[0].value.part, 0);
|
||||
const b = loader.acquire(scope); assert.equal((await b.ready).chunks.length, 2); assert.equal(calls, 2); a.release(); b.release(); loader.close();
|
||||
});
|
||||
// 源先给 complete 再抛尾部校验错误,ready 必须拒绝且观察者从未收到 complete 状态。
|
||||
test('a complete marker is not authoritative before iterator EOF; a checksum/trailing failure retains partial status', async () => {
|
||||
const statuses = [], loader = createScopedLoader({ current: () => true, async *load(query) { yield chunk(query); throw Error('trailing_checksum'); } });
|
||||
const handle = loader.acquire(scope, { onUpdate: value => statuses.push(value.status) }); await assert.rejects(handle.ready, /trailing_checksum/);
|
||||
assert.equal(handle.getSnapshot().status, 'failed-with-partial'); assert.equal(statuses.includes('complete'), false); handle.release(); loader.close();
|
||||
});
|
||||
// 尾块换 snapshotToken、版本向量或 project 时拒绝,前缀 chunks/coverage 保留原值,不混入另一快照。
|
||||
test('snapshot token, version vector and project changes reject the tail without mixing it into accepted coverage', async () => {
|
||||
for (const extra of [{ snapshotToken: 'other' }, { serverVersions: { canvas: 4 } }, { projectId: 'other' }]) {
|
||||
const loader = createScopedLoader({ current: () => true, async *load(query) { yield chunk(query, 0, false); yield chunk(query, 1, true, extra); } });
|
||||
const handle = loader.acquire(scope); await assert.rejects(handle.ready); assert.equal(handle.getSnapshot().status, 'failed-with-partial'); assert.equal(handle.getSnapshot().chunks.length, 1); assert.deepEqual(handle.getSnapshot().coverage, ['part-0']); handle.release(); loader.close();
|
||||
}
|
||||
});
|
||||
// 错误 queryKey、权限代次、序号、重复覆盖项、负字节数或非法版本在该块暴露前拒绝,结果为空 failed。
|
||||
test('query, permission, sequence and malformed coverage headers reject before exposing a chunk', async () => {
|
||||
for (const extra of [{ queryKey: 'foreign' }, { permissionEpoch: 2 }, { sequence: 3 }, { coverage: ['x', 'x'] }, { byteLength: -1 }, { serverVersions: { canvas: 0 } }]) {
|
||||
const loader = createScopedLoader({ current: () => true, async *load(query) { yield chunk(query, 0, true, extra); } });
|
||||
const handle = loader.acquire(scope); await assert.rejects(handle.ready, { code: 'invalid_load_chunk' }); assert.equal(handle.getSnapshot().chunks.length, 0); assert.equal(handle.getSnapshot().status, 'failed'); handle.release(); loader.close();
|
||||
}
|
||||
});
|
||||
// EOF 前没有 complete,或 complete 之后又多一块,都不能成为完整结果,已有内容只作为失败前缀保留。
|
||||
test('an incomplete EOF or extra chunk after a complete marker cannot become a complete result', async () => {
|
||||
for (const extra of [false, true]) {
|
||||
const loader = createScopedLoader({ current: () => true, async *load(query) { yield chunk(query, 0, extra); if (extra) yield chunk(query, 1); } });
|
||||
const handle = loader.acquire(scope); await assert.rejects(handle.ready); assert.equal(handle.getSnapshot().status, 'failed-with-partial'); handle.release(); loader.close();
|
||||
}
|
||||
});
|
||||
// 响应字节数或块数上限阻止接受第二块;保留首块计数,不能为满足预算静默丢块还宣称成功。
|
||||
test('response byte and chunk budgets bound retained read copies while preserving earlier chunks', async () => {
|
||||
for (const limits of [{ maxResponseBytes: 3 }, { maxChunks: 1 }]) {
|
||||
const loader = createScopedLoader({ ...limits, current: () => true, async *load(query) { yield chunk(query, 0, false); yield chunk(query, 1); } });
|
||||
const handle = loader.acquire(scope); await assert.rejects(handle.ready, { code: 'load_response_budget' }); assert.equal(handle.getSnapshot().byteLength, 2); assert.equal(handle.getSnapshot().chunks.length, 1); handle.release(); loader.close();
|
||||
}
|
||||
});
|
||||
// 活动任务取消后仍占计算/读取槽位;旧迭代真正退出才启动新代次,其清理不能从共享表移除新任务。
|
||||
test('cancelled active work retains its concurrency slot; its late cleanup cannot delete the newer generation', async () => {
|
||||
const first = deferred(), second = deferred(), entered = []; let calls = 0;
|
||||
const loader = createScopedLoader({ concurrency: 1, maxQueued: 1, current: () => true, async *load(query) { const index = ++calls; entered.push(index); await (index === 1 ? first.promise : second.promise); yield chunk(query); } });
|
||||
@@ -63,30 +80,36 @@ test('cancelled active work retains its concurrency slot; its late cleanup canno
|
||||
first.resolve(); await tick(); assert.equal(calls, 2); const peer = loader.acquire(scope); assert.equal(peer.getSnapshot().generation, b.getSnapshot().generation);
|
||||
second.resolve(); await Promise.all([b.ready, peer.ready]); assert.deepEqual(entered, [1, 2]); b.release(); peer.release(); loader.close();
|
||||
});
|
||||
// 等待中的订阅取消可立即释放排队名额,放弃的 source 不执行;后续查询按空出的队列位置进入。
|
||||
test('queued cancellation frees admission capacity and never executes the abandoned source', async () => {
|
||||
const gate = deferred(), called = [], loader = createScopedLoader({ concurrency: 1, maxQueued: 1, current: () => true, async *load(query) { called.push(query.canvasId); await gate.promise; yield chunk(query); } });
|
||||
const a = loader.acquire(scope), b = loader.acquire({ ...scope, canvasId: 'cancelled' }); const cancelled = assert.rejects(b.ready, { code: 'load_aborted' }); b.release(); await cancelled;
|
||||
const c = loader.acquire({ ...scope, canvasId: 'next' }); gate.resolve(); await Promise.all([a.ready, c.ready]); assert.deepEqual(called, ['canvas', 'next']); a.release(); c.release(); loader.close();
|
||||
});
|
||||
// 已完成读取缓存受字节 LRU、显式 source validation 和权限时间限制;不认可、过期及墙上时钟回退均重新读取。
|
||||
test('read cache requires explicit source validation and is bounded by byte LRU and permission age', async () => {
|
||||
let time = 100, accepted = true; const calls = new Map();
|
||||
const loader = createScopedLoader({ maxCacheEntries: 10, maxCacheBytes: 4, cacheMaxAgeMs: 100, now: () => time, acceptCached: () => accepted, current: () => true,
|
||||
async *load(query) { calls.set(query.canvasId, (calls.get(query.canvasId) ?? 0) + 1); yield chunk(query); } });
|
||||
// 缓存用例统一领取、等待及释放;只有显式 allow-validated 才允许考虑已缓存结果。
|
||||
const read = async (canvasId, cached = false) => { const handle = loader.acquire({ ...scope, canvasId }, { cache: cached ? 'allow-validated' : 'fresh' }); await handle.ready; handle.release(); };
|
||||
await read('a'); await read('b'); await read('a', true); assert.equal(calls.get('a'), 1); await read('c'); await read('b', true); assert.equal(calls.get('b'), 2);
|
||||
accepted = false; await read('b', true); assert.equal(calls.get('b'), 3); accepted = true; time = 201; await read('b', true); assert.equal(calls.get('b'), 4);
|
||||
time = 0; await read('b', true); assert.equal(calls.get('b'), 5); loader.close();
|
||||
});
|
||||
// 相同 canvas 的不同过滤指纹或权限 epoch 不能共享请求,三个查询各自加载。
|
||||
test('different query fingerprints and permission epochs do not share requests', async () => {
|
||||
let calls = 0; const loader = createScopedLoader({ current: () => true, async *load(query) { calls++; yield chunk(query); } });
|
||||
const handles = [scope, { ...scope, queryFingerprint: 'filtered' }, { ...scope, authEpoch: 2 }].map(query => loader.acquire(query));
|
||||
await Promise.all(handles.map(handle => handle.ready)); assert.equal(calls, 3); handles.forEach(handle => handle.release()); loader.close();
|
||||
});
|
||||
// 捕获 scope 只接管声明字段并冻结副本;外部额外对象仍可变,输入 canvas 后改不影响已领取查询。
|
||||
test('scope capture owns only declared metadata without freezing unrelated caller state', async () => {
|
||||
const extra={filter:'mutable'},input={...scope,extra};const loader=createScopedLoader({current:()=>true,async *load(query){yield chunk(query);}});
|
||||
const handle=loader.acquire(input);assert.equal(Object.isFrozen(extra),false);input.canvasId='later';extra.filter='changed';
|
||||
const result=await handle.ready;assert.equal(result.scope.canvasId,'canvas');assert.equal(Object.hasOwn(result.scope,'extra'),false);assert.equal(Object.isFrozen(result.scope),true);handle.release();loader.close();
|
||||
});
|
||||
// 身份失效拒绝迟到块;close 即使源不理 abort 也立即拒绝等待者,迟到完成不能重开 loader。
|
||||
test('identity changes discard late chunks, and close promptly rejects waiting readers even if the source ignores abort', async () => {
|
||||
const gate = deferred(); let current = true;
|
||||
const loader = createScopedLoader({ current: () => current, async *load(query) { await gate.promise; yield chunk(query); } });
|
||||
@@ -94,6 +117,7 @@ test('identity changes discard late chunks, and close promptly rejects waiting r
|
||||
const pending = deferred(), closed = createScopedLoader({ current: () => true, async *load(query) { await pending.promise; yield chunk(query); } });
|
||||
const b = closed.acquire(scope); const stopped = assert.rejects(b.ready, { code: 'loader_closed' }); closed.close(); await stopped; pending.resolve(); await tick(); assert.equal(b.getSnapshot().status, 'cancelled'); assert.throws(() => closed.acquire(scope), { code: 'loader_closed' });
|
||||
});
|
||||
// 观察者回调重入 acquire 不应重通知旧订阅者;新观察者抛错通过专用错误口收集,不使源读取失败。
|
||||
test('observer reentrancy does not re-notify old subscribers or allow observer exceptions to fail a read', async () => {
|
||||
const errors = []; let peer, updates = 0;
|
||||
const loader = createScopedLoader({ current: () => true, onObserverError: error => errors.push(error), async *load(query) { yield chunk(query); } });
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* 部分模型上的可编辑 ProjectEngine 集成测试。loading v1–v6 是加载状态解释器版本,逐步加入范围证书、提前同步、结构/任务/资源准入。
|
||||
* working、原始 loadingRead 与最新 ACK 各有不同用途:后续旧流不能覆盖已确认编辑,未知请求也不能被新完整读当作未执行。
|
||||
* 局部允许的操作仍要验证实际 changes、引用闭包及持久化证据,不能仅按命令名放行;完整接纳后才移除加载边界。
|
||||
* fixture 使用内存存储和 Foundry persistence adapter 加假 transport,验证协调与协议检查,不证明真实服务或浏览器表现。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
@@ -5,26 +11,53 @@ import {createDocument,applyChanges,createRegistry,builtinComponents} from '../.
|
||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import {createFoundryPersistence} from '../../src/lib/canvas-service/foundry.ts';
|
||||
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
// 内置定义与 root/child UUID,满足 Foundry 协议的作用域校验。
|
||||
const registry=createRegistry(builtinComponents()),root='10000000-0000-4000-8000-000000000001',child='10000000-0000-4000-8000-000000000002';
|
||||
// 固定账户、workspace 与项目,使恢复读取命中同一持久化状态。
|
||||
const scope={accountId:'10000000-0000-4000-8000-000000000003',workspaceId:'10000000-0000-4000-8000-000000000004',projectId:root};
|
||||
// 第一代文本节点工厂,可指定 owner,基线正文 original 便于区分本地和远端改动。
|
||||
const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'original'},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
|
||||
// root 的 A/B/F 与 child 的 C,通过共同 root 的边 E 形成加载期引用闭包。
|
||||
const a=node('A'),b=node('B'),frame={...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};
|
||||
// prefix 只含 root/A,full 含五元素和 child;partial engine 不能因为某对象未到就推定其不存在。
|
||||
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],prefix=createDocument(root,[canvases[0]],[a]),full=createDocument(root,canvases,[a,b,frame,c,edge]);
|
||||
// 捕获源版本、loading v1 元数据和 A 的条件身份;后续协议版本在用例中显式补上证书。
|
||||
const versions=new Map([[root,1],[child,1]]),state={version:1,sourceToken:'captured-read',declaredCanvasIds:[root,child],receivedElements:1,totalElements:5},target={id:'A',incarnation:1};
|
||||
// 建立可重开的 image、文件、leader 与假服务端;extra 可覆盖加载状态、初始文档、时钟或资源端口。
|
||||
function fixture(initial=full){
|
||||
let image=null,leader,server=initial;const files=new Map();const serverVersions=new Map([...initial.canvases.keys()].map(id=>[id,1])),calls=[],receipts=new Map(),errors=[];
|
||||
const storage={broken:false,saved:[],async claim(scope){assert.equal(leader,undefined);const held={scope,fence:1,active:true,async release(){leader=undefined;held.active=false;}};leader=held;return held;},async recover(){return structuredClone(image);},async commit(held,batch){assert.strictEqual(held,leader);if(storage.broken)throw Error('quota');image=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);storage.saved.push(image);return{durableRootRevision:image.rootRevision};},async readFile(_leader,id){return files.get(id);},files,close(){},image:()=>structuredClone(image)};
|
||||
// 内存原子文件接口:校验 owner,broken 时在修改前失败,成功保存 image/文件和已保存快照列表。
|
||||
const storage={broken:false,saved:[],
|
||||
// 同一 fixture 只有一个存储 owner,release 清空 leader 并使旧 handle inactive;不模拟跨标签竞选。
|
||||
async claim(scope){assert.equal(leader,undefined);const held={scope,fence:1,active:true,async release(){leader=undefined;held.active=false;}};leader=held;return held;},
|
||||
// 返回 image 克隆,让重开真正走恢复解码,不复用旧引擎对象。
|
||||
async recover(){return structuredClone(image);},
|
||||
// 先验证 owner 和配额,再保存根/文件与删除列表;saved 记录实际提交内容,真实存储回滚不由此内存实现证明。
|
||||
async commit(held,batch){assert.strictEqual(held,leader);if(storage.broken)throw Error('quota');image=structuredClone(batch.image);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);storage.saved.push(image);return{durableRootRevision:image.rootRevision};},
|
||||
// 按 ID 读项目文件,供加载期导入与资源上传使用。
|
||||
async readFile(_leader,id){return files.get(id);},files,
|
||||
// 内存存储没有外部连接需要关闭,leader 生命周期由引擎归还。
|
||||
close(){},
|
||||
// 向故障注入/恢复断言交出克隆,避免检查代码意外改动当前已保存 image。
|
||||
image:()=>structuredClone(image)};
|
||||
// 把指定参与 canvas 的服务端内容编码为 canonical 模型;回执是局部模型,完整读取默认包含所有范围。
|
||||
const model=(participants=[...serverVersions.keys()],complete=true)=>({documentFormatVersion:1,projectId:root,complete,canvases:[...server.canvases.values()].filter(item=>participants.includes(item.id)),elements:[...server.elements.values()].filter(item=>participants.includes(item.ownerCanvasId)),tombstones:Object.fromEntries(server.tombstones),versions:Object.fromEntries(participants.map(id=>[id,serverVersions.get(id)])),removedCanvasIds:participants.filter(id=>!server.canvases.has(id))});
|
||||
// GET 返回完整模型或保存回执;POST 检查 expected_versions,并按 requestId 幂等应用批次、递增参与版本。
|
||||
const transport=async input=>{
|
||||
if(input.method==='GET')return input.path.endsWith('/model')?{status:200,body:{model:model()}}:{status:200,body:receipts.get(input.path.split('/').at(-1))};
|
||||
// 只计文档写请求,先验证每个参与 canvas 的 ACK 基线;已有 receipt 的同 ID 请求不得重复应用 changes。
|
||||
calls.push(input);const body=JSON.parse(input.body),participants=Object.keys(body.expected_versions);for(const id of participants)assert.equal(body.expected_versions[id],serverVersions.get(id)??0);
|
||||
// 假服务先验证版本,再检查同 ID 是否已应用;只覆盖本文件的协调路径,不模拟完整生产幂等协议。
|
||||
if(!receipts.has(input.requestId)){server=applyChanges(server,body.operations.flatMap(item=>item.changes));for(const id of participants)serverVersions.set(id,(serverVersions.get(id)??0)+1);receipts.set(input.requestId,{protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(item=>item.id),model:model(participants,false)});}
|
||||
return{status:200,body:receipts.get(input.requestId)};
|
||||
};
|
||||
// 使用真实 Foundry persistence 编解码,只有底层 transport 与存储被替换为测试实现。
|
||||
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:root,registry,transport});
|
||||
// 暴露服务端推进及版本副本供冲突注入;open 默认从 prefix/捕获版本/初级 loading 状态开始。
|
||||
return{storage,persistence,calls,errors,server:()=>server,advance(changes){server=applyChanges(server,changes);serverVersions.set(root,serverVersions.get(root)+1);},currentVersions:()=>new Map(serverVersions),open:extra=>openProjectEngine({scope,registry,document:prefix,versions,loading:state,storage,persistence,createId:randomUUID,onError:error=>errors.push(error),...extra})};
|
||||
}
|
||||
|
||||
// v1 可对已加载 A 编辑和 undo/redo 并保存,但不冻结请求;重开/接纳前缀不覆盖本地值,full 完成后才同步并保留历史。
|
||||
test('loaded fields edit and undo/redo durably while no request freezes; full source adds tail and sends each edit once',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{
|
||||
assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}}).status,'applied');await engine.settle();
|
||||
@@ -35,6 +68,7 @@ test('loaded fields edit and undo/redo durably while no request freezes; full so
|
||||
assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(engine.getSnapshot().document.elements.size,5);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.server().elements.get('A').geometry.x,25);assert.ok(f.calls.length>0);assert.equal(engine.getSnapshot().history.undo.length,2);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 不完整范围拒绝结构、资源引用、移动和危险 presentation 写;公共入口不能删 loading 标记,stageFiles/copy 也不能绕过。
|
||||
test('incomplete scopes block structure/resources/references and untrusted marker removal without changing the root',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{
|
||||
for(const command of [{type:'element.remove',target},{type:'element.create',element:node('new')},{type:'element.move',target,canvasId:child},{type:'element.data',target,patch:{assetId:'asset'}},{type:'element.data',target,patch:{presentation:{scale:2}}}]){const before=engine.getSnapshot();assert.equal(engine.editor.dispatch(command).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);}
|
||||
@@ -42,6 +76,7 @@ test('incomplete scopes block structure/resources/references and untrusted marke
|
||||
assert.throws(()=>engine.stageFiles([{name:'x',bytes:new Blob(['x'])}],()=>{}),{code:'model_loading'});await assert.rejects(engine.imports.duplicate([target]),{code:'model_loading'});assert.equal(f.calls.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 完整基线接纳必须先持久化才能释放全局发送 hold;配额失败仍无冻结请求,retry 保存后才发送原编辑。
|
||||
test('full baseline must be durable before the hold releases; quota retry never sends from the partial cut',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{
|
||||
engine.editor.dispatch({type:'element.data',target,patch:{title:'saved edit'}});await engine.settle();f.storage.broken=true;
|
||||
@@ -49,6 +84,7 @@ test('full baseline must be durable before the hold releases; quota retry never
|
||||
f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(f.server().elements.get('A').data.title,'saved edit');assert.ok(f.calls.length>0);assert.notEqual(f.storage.image().formatVersion,7);
|
||||
}finally{f.storage.broken=false;await engine.close();}
|
||||
});
|
||||
// 来源 token 不匹配或已捕获前缀改变时拒绝且 root 不变;显式 fresh 完整读取可补同版本新记录,完成后旧 accept 不得降级状态。
|
||||
test('late or changed prefix is rejected; a fresh full read preserves local edits and same-version new records',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{
|
||||
engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const before=engine.getSnapshot();
|
||||
@@ -57,6 +93,7 @@ test('late or changed prefix is rejected; a fresh full read preserves local edit
|
||||
assert.equal((await engine.loading.complete(full,versions,{fresh:true})).status,'applied');await engine.settle();const done=engine.getSnapshot();assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.strictEqual(engine.getSnapshot(),done);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 格式 7 的恢复 image 必须携带合法 loading 标记,降级格式或删标记都拒绝,不能误当普通完整文档打开。
|
||||
test('v7 cannot lose its marker or downgrade, and recovery retains partial edit/history without requests',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();const image=f.storage.image();assert.equal(decodeRecovery(image,registry).runtime.loading.sourceToken,state.sourceToken);
|
||||
for(const formatVersion of [1,2,3,4,5,6])assert.throws(()=>decodeRecovery({...image,formatVersion},registry),{code:'unsupported_recovery_version'});
|
||||
@@ -64,6 +101,7 @@ test('v7 cannot lose its marker or downgrade, and recovery retains partial edit/
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// fresh 全量读与本地字段冲突时保留原 partial 编辑且不发请求;通过用户审阅的 keep-local 计划协调后才结束加载。
|
||||
test('a fresh conflicting completion keeps the original partial edit and resolves through the existing reviewed recovery path',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{
|
||||
const edit=engine.editor.dispatch({type:'element.data',target,patch:{text:'local'}});await engine.settle();
|
||||
@@ -72,22 +110,27 @@ test('a fresh conflicting completion keeps the original partial edit and resolve
|
||||
const session=await engine.prepareRecovery();assert.ok(session.conflict);const plan=session.plan(new Map([[edit.operationIds[0],'keep-local']]));assert.equal((await session.resolve(plan)).status,'applied');await engine.settle();assert.equal(engine.loading.getState(),undefined);assert.equal(f.server().elements.get('A').data.text,'local');assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 重开虽传入 full,持久化 loading 仍需保留;显式 refresh 协调完整基线后保留原历史并同步编辑。
|
||||
test('reopening a partial image with a complete fresh model finishes through refresh without replacing history',async()=>{
|
||||
const f=fixture();let engine=await f.open();try{engine.editor.dispatch({type:'element.data',target,patch:{title:'retained'}});await engine.settle();const history=engine.getSnapshot().history;await engine.close();engine=await f.open({document:full,loading:undefined});assert.ok(engine.loading.getState());assert.equal(engine.refresh(full,versions).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'retained');assert.deepEqual(engine.getSnapshot().history.undo,history.undo);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 命令表面只改标题,恶意 feature handler 实际写声明引用;检查生成 changes 后仍以 model_loading 拒绝整次发布。
|
||||
test('permitted command cannot hide a reference write inside a feature handler',async()=>{
|
||||
const {defineComponent,defineFeature}=await import('../../src/lib/canvas-core/index.ts');
|
||||
// 故意声明一个会把 content.edit 转成引用字段修改的 feature,验证安全门检查最终 changes 而非命令表面 patch。
|
||||
const custom=defineComponent({id:'unsafe-handler',dataVersion:1,structure:'node',validate:()=>true,resourceFields:[],copy:{references:[{path:['link'],target:'element',external:'preserve'}],omit:[]},features:[defineFeature('side-effect',{'content.edit':({element})=>[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.link',before:'old',after:'new',beforePresent:true,afterPresent:true}]})]});
|
||||
const customRegistry=createRegistry([...builtinComponents(),custom]),record={...a,componentId:custom.id,data:{title:'A',link:'old'}},document=createDocument(root,[canvases[0]],[record]),f=fixture(),engine=await f.open({registry:customRegistry,document});
|
||||
try{const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'allowed title'}}).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();}
|
||||
});
|
||||
// 未来 loading 解释器、错误 received 数、空声明范围或伪造 frozen 请求均在恢复校验拒绝。
|
||||
test('future loading interpreters, changed capture counts and forged frozen requests fail closed on recovery',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{await engine.persist();const source=f.storage.image();
|
||||
for(const mutate of [image=>image.payload.runtime.loading.version=2,image=>image.payload.runtime.loading.receivedElements=99,image=>image.payload.runtime.loading.declaredCanvasIds=[],image=>image.payload.runtime.ledger.requests.push(['fake',{}])]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 新完整读带远端标题,本地几何重放不覆盖它;之后 undo 几何也不回滚远端无关字段。
|
||||
test('fresh full read merges unrelated remote fields and geometry undo does not roll them back',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{
|
||||
engine.editor.dispatch({type:'element.geometry',target,patch:{x:25}});await engine.settle();f.advance([{kind:'field',target,field:'data.title',before:'A',after:'remote title',beforePresent:true,afterPresent:true}]);
|
||||
@@ -96,31 +139,38 @@ test('fresh full read merges unrelated remote fields and geometry undo does not
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 身份失效后编辑、历史遍历、前缀接纳和 refresh 都拒绝且保持同一 root,不发送迟到工作。
|
||||
test('identity fencing rejects edits, history traversal and late prefix publication without changing the root',async()=>{
|
||||
const f=fixture();let current=true;const engine=await f.open({isCurrentIdentity:()=>current});try{engine.editor.dispatch({type:'element.data',target,patch:{text:'saved'}});await engine.settle();current=false;const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'late'}}).code,'identity_changed');assert.equal(engine.editor.history.undo().code,'identity_changed');assert.equal(engine.loading.accept(prefix,versions,state).code,'identity_changed');assert.equal(engine.refresh(full,versions).code,'identity_changed');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 多次 loading.resume 共用一次后台完整读取;完成保留本地编辑,之后旧流不能降级,也不再次启动多余读取。
|
||||
test('background full reads are shared, preserve edits and cannot downgrade after completion',async()=>{
|
||||
let release;const gate=new Promise(resolve=>release=resolve);const f=fixture();let reads=0;const persistence={...f.persistence,async read(){reads++;await gate;return{document:f.server(),versions:f.currentVersions()};}},engine=await f.open({persistence});
|
||||
try{engine.editor.dispatch({type:'element.data',target,patch:{text:'background edit'}});await engine.persist();const first=engine.loading.resume(),second=engine.loading.resume();assert.strictEqual(first,second);while(!reads)await new Promise(resolve=>setImmediate(resolve));assert.equal(reads,1);release();assert.equal((await first).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.text,'background edit');assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.equal((await engine.loading.resume()).status,'applied');assert.equal(reads,1);}finally{release();await engine.close();}
|
||||
});
|
||||
// 关闭项目中止正在等待的背景源并保存 partial 编辑;下一 owner 重开仍保留内容且未发送。
|
||||
test('project close aborts a background read and persists its partial edits for the next owner',async()=>{
|
||||
const f=fixture();let aborted=false,started;const reading=new Promise(resolve=>started=resolve);const persistence={...f.persistence,read:signal=>new Promise((resolve,reject)=>{started();signal.addEventListener('abort',()=>{aborted=true;reject(signal.reason);},{once:true});})};let engine=await f.open({persistence});
|
||||
engine.editor.dispatch({type:'element.data',target,patch:{text:'survives source close'}});await engine.persist();const rejected=assert.rejects(engine.loading.resume());await reading;await engine.close();await rejected;assert.equal(aborted,true);assert.equal(f.storage.image().formatVersion,7);engine=await f.open();try{assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'survives source close');assert.equal(f.calls.length,0);}finally{await engine.close();}
|
||||
});
|
||||
// Workspace 并发 openPartial 返回同一 engine;背景同步已完成后,旧前台完整读不替换 root 或制造冲突。
|
||||
test('Workspace opens one partial owner and ignores an older foreground result after background synchronization',async()=>{
|
||||
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture();const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>true,adapters:()=>({persistence:f.persistence})});
|
||||
try{const [first,second]=await Promise.all([workspace.openPartial(prefix,versions,state),workspace.openPartial(prefix,versions,state)]);assert.strictEqual(first,second);first.editor.dispatch({type:'element.data',target,patch:{title:'kept background'}});await first.loading.resume();await first.settle();const before=first.getSnapshot();assert.strictEqual(await workspace.open(full,versions),first);assert.strictEqual(first.getSnapshot(),before);assert.equal(first.getSnapshot().runtime.conflicts.size,0);assert.equal(workspace.getProjects().size,1);}finally{await workspace.close();}
|
||||
});
|
||||
|
||||
// 背景 offline 上报一次并保留 partial cut;身份退役后的 resume 报 identity_changed,不继续向旧观察者上报源错误。
|
||||
test('Workspace reports a background source failure while preserving the pending partial cut and fences retired reports',async()=>{
|
||||
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture(),reports=[];let current=true;const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>current,onError:error=>reports.push(error),adapters:()=>({persistence:{...f.persistence,async read(){throw Error('offline');}}})});
|
||||
try{const engine=await workspace.openPartial(prefix,versions,state);engine.editor.dispatch({type:'element.data',target,patch:{text:'held while offline'}});await engine.persist();await assert.rejects(workspace.resumeLoading(root),/offline/);assert.equal(reports.length,1);assert.ok(engine.loading.getState());assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,7);current=false;await assert.rejects(workspace.resumeLoading(root),/identity_changed/);assert.equal(reports.length,1);}finally{await workspace.close();}
|
||||
});
|
||||
|
||||
// v2 计数证书可证明 child 成员完整但 root/关联边仍不完整;覆盖元数据与内容原子发布,重开保留,最终覆盖完成不虚增文档 revision。
|
||||
test('certified scopes distinguish a complete child from an incomplete parent and publish coverage atomically',async()=>{
|
||||
const loading={...state,version:2,canvasElementCounts:{[root]:4,[child]:1}},f=fixture();let engine=await f.open({loading});
|
||||
try{
|
||||
// 先保存旧查询视图;后续新覆盖发布后,旧视图仍描述原来的 loaded/unloaded 状态。
|
||||
const first=engine.editor.getQueries();assert.equal(first.lookupElement('B').status,'unloaded');assert.equal(first.lookupElement('A').status,'present');assert.equal(first.membersResult(root).complete,false);assert.equal(first.hitNodes(root,{x:50,y:40}).complete,false);
|
||||
const next=createDocument(root,canvases,[a,frame,c]);assert.equal(engine.loading.accept(next,versions,{...loading,receivedElements:3}).status,'applied');
|
||||
const queries=engine.editor.getQueries();assert.equal(queries.membersResult(child).complete,true);assert.deepEqual(queries.membersResult(child).ids,['C']);assert.equal(queries.membersResult(root).complete,false);assert.equal(queries.childCanvasesResult(root).complete,true);assert.equal(queries.incidentResult('C').complete,false);assert.equal(queries.relations.referenceComplete,false);assert.equal(queries.coverage.projectComplete,false);assert.equal(first.lookupElement('C').status,'unloaded');
|
||||
@@ -129,20 +179,24 @@ test('certified scopes distinguish a complete child from an incomplete parent an
|
||||
for(const mutate of [image=>delete image.payload.runtime.loading.canvasElementCounts,image=>image.payload.runtime.loading.version=1,image=>image.payload.runtime.loading.version=99,image=>image.payload.runtime.loading.canvasElementCounts[child]=0]){const image=structuredClone(source);mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
|
||||
await engine.close();engine=await f.open({loading});assert.equal(engine.editor.getQueries().membersResult(child).complete,true);assert.equal(engine.getSnapshot().document.elements.get('C').data.text,'child edit');
|
||||
assert.equal(engine.loading.accept(full,versions,{...loading,receivedElements:5}).status,'applied');const before=engine.getSnapshot(),oldQueries=engine.editor.getQueries();assert.equal(oldQueries.lookupElement('missing').status,'unloaded');assert.equal(oldQueries.membersResult(root).complete,true);
|
||||
// 这里仅覆盖状态完成,文档内容已齐;root 可变化,但文档 revision 不应为元数据变化而虚增。
|
||||
const completion=await engine.loading.complete(full,versions,{sourceToken:loading.sourceToken});assert.equal(completion.status,'applied');assert.equal(completion.revision,before.revision,'coverage-only completion must not invent a document revision');assert.equal(engine.editor.getQueries().lookupElement('missing').status,'absent');assert.equal(engine.editor.getQueries().coverage.projectComplete,true);assert.equal(oldQueries.lookupElement('missing').status,'unloaded');await engine.settle();assert.equal(f.server().elements.get('C').data.text,'child edit');assert.equal(f.storage.image().formatVersion<7,true);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 旧 v1 前缀即使收齐五元素也不能猜测某范围完整;同一读取中临时换成不同 count manifest 必须拒绝。
|
||||
test('legacy prefixes do not infer scope completion; a changed count manifest cannot replace the published root',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{
|
||||
assert.equal(engine.loading.accept(full,versions,{...state,receivedElements:5}).status,'applied');assert.equal(engine.editor.getQueries().membersResult(child).complete,false);
|
||||
const before=engine.getSnapshot();assert.equal(engine.loading.accept(full,versions,{...state,version:2,canvasElementCounts:{[root]:4,[child]:1},receivedElements:5}).code,'loading_source_mismatch');assert.strictEqual(engine.getSnapshot(),before);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 零成员 child 也须其入口依赖已发布才标记 members complete;项目仍加载中,未知 ID 仍是 unloaded。
|
||||
test('zero-member scopes are complete only once their entry dependencies publish',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame]),loading={...state,version:2,totalElements:2,receivedElements:2,canvasElementCounts:{[root]:2,[child]:0}},engine=await f.open({document,loading});
|
||||
try{assert.deepEqual(engine.editor.getQueries().membersResult(child),{ids:[],complete:true});assert.equal(engine.editor.getQueries().lookupElement('not-here').status,'unloaded');}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// v2 允许完整连线 owner 内改 path,即使另一个 canvas 未完成;undo/redo 和格式 8 恢复保留路径,但 owner 不完整时禁用。
|
||||
test('v2 admits path edits in a certified connection owner while another scope is incomplete, preserving undo and v8 recovery',async()=>{
|
||||
const f=fixture(),loading={...state,version:2,receivedElements:5,totalElements:6,canvasElementCounts:{[root]:4,[child]:2}},engine=await f.open({document:full,loading}),path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:50,y:25},in:{x:0,y:0},out:{x:0,y:0}}]};
|
||||
try{
|
||||
@@ -154,6 +208,7 @@ test('v2 admits path edits in a certified connection owner while another scope i
|
||||
const other=fixture(),incomplete=await other.open({document:full,loading:{...loading,canvasElementCounts:{[root]:5,[child]:1}}});try{assert.equal(incomplete.editor.can({type:'connection.path',target:{id:'E',incarnation:1},path}).enabled,false);}finally{await incomplete.close();}
|
||||
});
|
||||
|
||||
// v3 完整 child 的修改可先同步,root 编辑继续等待;原流捕获版本和新 ACK 分开,旧后缀/最终完成不覆盖已确认 child 编辑。
|
||||
test('v3 sends a certified child before the parent tail, retains its ACK through old chunks and completes the original source',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading});
|
||||
try{
|
||||
@@ -165,6 +220,7 @@ test('v3 sends a certified child before the parent tail, retains its ACK through
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'parent waits');assert.equal(f.server().elements.get('C').data.text,'original');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.title,'A');
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// v3 请求丢 ACK 时 fresh 完整接纳先等回执;重开查询原请求而不新发,保留 body/key,并拒绝篡改 loadingRead/旧流内容。
|
||||
test('v3 unknown request reopens with original body/key, queries its receipt and rejects source tampering',async()=>{
|
||||
let time=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},clock={now:()=>time,setTimer:()=>0,clearTimer(){}},persistence={...f.persistence,async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}};
|
||||
let engine=await f.open({document,loading,persistence,clock});try{
|
||||
@@ -177,28 +233,35 @@ test('v3 unknown request reopens with original body/key, queries its receipt and
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 背景完整读取在网络 lane 外等待未知回执;retry 先解决原请求再读 fresh 模型,不能占住网络位置阻止查询。
|
||||
test('background completion waits outside the network lane for a v3 unknown receipt, then reads the fresh full model',async()=>{
|
||||
let time=0,reads=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},clock={now:()=>time,setTimer:()=>0,clearTimer(){}},persistence={...f.persistence,async read(signal){reads++;return f.persistence.read(signal);},async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}},engine=await f.open({document,loading,persistence,clock});
|
||||
try{engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'wait for original receipt'}});await engine.settle();const completion=engine.loading.resume();await new Promise(resolve=>setImmediate(resolve));assert.equal(reads,0);time=1000;await engine.retry();assert.equal((await completion).status,'applied');await engine.settle();assert.equal(reads,1);assert.equal(f.calls.length,1);assert.equal(engine.getSnapshot().document.elements.get('C').data.text,'wait for original receipt');assert.equal(engine.loading.getState(),undefined);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// fresh 背景读期间 hold 新网络准入,但仍保留当时提交的本地编辑;读完成后重放并只发送一次。
|
||||
test('a fresh v3 background read fences new admissions while preserving edits made during the read',async()=>{
|
||||
let release,started;const gate=new Promise(resolve=>release=resolve),reading=new Promise(resolve=>started=resolve),f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading,persistence:{...f.persistence,async read(signal){started();await gate;return f.persistence.read(signal);}}});
|
||||
try{const completion=engine.loading.resume();await reading;engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'edited during fresh read'}});await engine.settle();assert.equal(f.calls.length,0);release();assert.equal((await completion).status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.text,'edited during fresh read');assert.equal(f.calls.length,1);}finally{release();await engine.close();}
|
||||
});
|
||||
|
||||
// v3 范围虽完整,源及编辑尚未落盘时仍不能 freeze;配额恢复后 retry 唤醒同一逻辑操作。
|
||||
test('a v3 certified owner cannot freeze until its source and edit are durably saved; retry wakes the same logical intent',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading});
|
||||
try{f.storage.broken=true;const edit=engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'durability first'}});await assert.rejects(engine.persist(),/quota/);await new Promise(resolve=>setImmediate(resolve));assert.equal(engine.getSnapshot().runtime.ledger.requests.size,0);assert.equal(f.calls.length,0);f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(f.calls.length,1);assert.equal(f.server().elements.get('C').data.text,'durability first');assert.equal(engine.getSnapshot().runtime.ledger.operations.get(edit.operationIds[0]).phase,'resolved');}finally{f.storage.broken=false;await engine.close();}
|
||||
});
|
||||
|
||||
// 关闭回执等待取消订阅、保存未知请求,不额外发完整读取;格式 9 重开仍带原请求证据。
|
||||
test('closing a v3 background receipt wait unsubscribes and saves the unknown request without issuing a fresh read',async()=>{
|
||||
let reads=0;const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),loading={...state,version:3,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1}},engine=await f.open({document,loading,clock:{now:()=>0,setTimer:()=>0,clearTimer(){}},persistence:{...f.persistence,async read(signal){reads++;return f.persistence.read(signal);},async submit(request,signal){await f.persistence.submit(request,signal);throw Error('lost ACK');}}});
|
||||
engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{text:'retain on close'}});await engine.settle();const waiting=assert.rejects(engine.loading.resume());await new Promise(resolve=>setImmediate(resolve));await engine.close();await waiting;assert.equal(reads,0);assert.equal(f.calls.length,1);assert.equal(f.storage.image().formatVersion,9);assert.equal(decodeRecovery(f.storage.image(),registry).runtime.ledger.requests.size,1);
|
||||
});
|
||||
|
||||
// 捕获源全量 ID→owner 清单,区分尚未加载的源 ID 与确实未占用的新 ID。
|
||||
const identities=Object.fromEntries([...full.elements.values()].map(element=>[element.id,element.ownerCanvasId]));
|
||||
// loading v4 在范围计数外携带身份清单,允许完整 owner 内的安全结构修改。
|
||||
const structuralLoading={...state,version:4,receivedElements:3,canvasElementCounts:{[root]:4,[child]:1},elementOwners:identities};
|
||||
// v4 身份清单证明 fresh ID 未占用,允许完整 child 内创建/确认/undo/redo;未加载 B 的 ID 仍禁复用,旧流完成保留第二代新元素。
|
||||
test('v4 creates, acknowledges, undoes and restores a fresh element in a complete owner before an unrelated tail',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('fresh',child);
|
||||
try{
|
||||
@@ -208,6 +271,7 @@ test('v4 creates, acknowledges, undoes and restores a fresh element in a complet
|
||||
assert.equal(engine.loading.accept(full,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('fresh').incarnation,2);assert.equal(engine.getSnapshot().runtime.versions.get(child),4);assert.equal(f.calls.length,3);assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// v4 frame/canvas 一次事务是一组历史,新 canvas 有独立版本;重开 undo/redo 后原流不删除新增范围或回退其版本。
|
||||
test('v4 frame/canvas creation is one atomic history unit and new scope versions recover independently of the old stream',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),newCanvas='10000000-0000-4000-8000-000000000009',newFrame={...node('new-frame',child),componentId:'frame'};let engine=await f.open({document,loading:structuralLoading});
|
||||
try{
|
||||
@@ -217,6 +281,7 @@ test('v4 frame/canvas creation is one atomic history unit and new scope versions
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// v4 原对象删除先等捕获引用闭包完整;本地新生节点/连线可成组创建删除,最终原流到齐后删除 A 也包含 E。
|
||||
test('v4 defers original deletions until the captured reference closure is loaded, while born links and nodes can be removed atomically',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading});
|
||||
try{const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.remove',target}).code,'model_loading');assert.strictEqual(engine.getSnapshot(),before);
|
||||
@@ -224,32 +289,40 @@ test('v4 defers original deletions until the captured reference closure is loade
|
||||
assert.equal(engine.loading.accept(full,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.remove',target}).status,'applied');await engine.settle();assert.equal(f.server().elements.has('A'),false);assert.equal(f.server().elements.has('E'),false);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has('A'),false);assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// v4 普通改名保留源 AI 元数据;缺失/错归属身份清单及旧 image 格式不能伪装恢复。
|
||||
test('v4 identity proof preserves source AI metadata during ordinary edits and rejects forged membership recovery',async()=>{
|
||||
const old={...c,data:{...c.data,ai:{enabled:true}}},f=fixture(createDocument(root,canvases,[a,b,frame,old,edge])),document=createDocument(root,canvases,[a,frame,old]),engine=await f.open({document,loading:structuralLoading});
|
||||
try{assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{title:'still editable'}}).status,'applied');await engine.settle();assert.equal(f.errors.length,0);assert.equal(engine.getSnapshot().document.elements.get('C').data.ai.enabled,true);const saved=f.storage.image();decodeRecovery(saved,registry);for(const mutate of [image=>delete image.payload.runtime.loading.elementOwners,image=>image.payload.runtime.loading.elementOwners.C=root,image=>image.formatVersion=9]){const broken=structuredClone(saved);mutate(broken);assert.throws(()=>decodeRecovery(broken,registry));}}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// v4 完全未发送的创建/删除可抵消且零请求;undo 恢复同 ID 的第二代,后续旧流不使它回到旧生命周期。
|
||||
test('v4 elides an unsent birth/deletion and restores the same ID with a fresh lifecycle before the source tail',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('unsent-birth',child);
|
||||
try{assert.equal(engine.editor.dispatch({type:'element.create',element:created}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:created.id,incarnation:1}}).status,'applied');await engine.settle();assert.equal(f.calls.length,0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).incarnation,2);decodeRecovery(f.storage.image(),registry);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).incarnation,2);assert.equal(f.calls.length,1);}finally{await engine.close();}
|
||||
});
|
||||
// v4 新生节点换 owner 要求源/目标范围均可用;禁止移入未完成 root,允许 child/newCanvas 间移动并保留 undo。
|
||||
test('v4 born-node ownership changes require both scopes and preserve their history while another source owner is incomplete',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),newCanvas='10000000-0000-4000-8000-000000000019',newFrame={...node('move-frame',child),componentId:'frame'},created=node('moving-birth',newCanvas);
|
||||
try{assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:newFrame});tx.dispatch({type:'canvas.create',canvas:{id:newCanvas,parent:{canvasId:child,entryElementId:newFrame.id}}});tx.dispatch({type:'element.create',element:created});}).status,'applied');await engine.settle();assert.equal(engine.editor.can({type:'element.move',target:{id:created.id,incarnation:1},canvasId:root}).enabled,false);assert.equal(engine.editor.dispatch({type:'element.move',target:{id:created.id,incarnation:1},canvasId:child}).status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).ownerCanvasId,child);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get(created.id).ownerCanvasId,newCanvas);assert.equal(engine.loading.getState().version,4);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 源清单已声明 A 但它尚未加载时,即使存在第一代墓碑仍应是 unloaded;加载第二代后才能条件删除并变 absent。
|
||||
test('v4 lookup does not confuse an unloaded restored element with its older tombstone',async()=>{
|
||||
const restored={...a,incarnation:2},dead=new Map([['A',1]]),server=createDocument(root,canvases,[restored,b,frame,c,edge],dead),f=fixture(server),document=createDocument(root,canvases,[frame,c],dead),engine=await f.open({document,loading:{...structuralLoading,receivedElements:2}});
|
||||
try{assert.equal(engine.editor.getQueries().lookupElement('A').status,'unloaded');assert.equal(engine.editor.getQueries().lookupElement('not-in-capture').status,'absent');assert.equal(engine.loading.accept(server,versions,{...structuralLoading,receivedElements:5}).status,'applied');assert.equal(engine.editor.getQueries().lookupElement('A').element.incarnation,2);assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'A',incarnation:2}}).status,'applied');await engine.settle();assert.equal(engine.editor.getQueries().lookupElement('A').status,'absent');}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// fresh 完整读可包含已确认新生对象,但不能在版本未变的不完整源范围凭空加入成员;正确后台读取保留新对象。
|
||||
test('v4 fresh completion accepts acknowledged births but rejects an invented member in an unchanged incomplete source scope',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:structuralLoading}),created=node('fresh-before-background',child);
|
||||
try{engine.editor.dispatch({type:'element.create',element:created});await engine.settle();const malformed=createDocument(root,canvases,[...f.server().elements.values(),node('invented')]);assert.equal((await engine.loading.complete(malformed,f.currentVersions(),{fresh:true})).code,'canonical_version_mismatch');assert.equal(engine.getSnapshot().document.elements.has(created.id),true);assert.equal((await engine.loading.resume()).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(created.id).incarnation,1);assert.equal(engine.getSnapshot().runtime.loadingRead,undefined);assert.equal(f.calls.length,1);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// loading v5 再附源 canvas 树,让导入/复制能校验完整子树及其上级入边范围。
|
||||
const jobLoading={...structuralLoading,version:5,sourceCanvases:canvases};
|
||||
// 把原始元素封装为 native 导入行,测试任务准入与目标 owner 映射。
|
||||
const ndjson=records=>new Blob(records.map(record=>JSON.stringify({kind:'element',key:record.id,record})+'\n'));
|
||||
// v5 只允许导入到完整 child,root 未完整时准入就拒绝;合法任务在其他尾部仍加载时可发布/同步并保存 loadingAdmission。
|
||||
test('v5 native import publishes and synchronizes into a complete owner while another captured owner remains incomplete',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:jobLoading});
|
||||
try{assert.equal(engine.imports.canStart(root).enabled,false);assert.equal(engine.imports.canStart(child).enabled,true);assert.throws(()=>engine.imports.startNdjson(ndjson([node('source')]),root),{code:'loading_scope_incomplete'});assert.equal(engine.imports.getJobs().size,0);assert.equal(f.storage.files.size,0);
|
||||
@@ -257,10 +330,12 @@ test('v5 native import publishes and synchronizes into a complete owner while an
|
||||
assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get(mapped).data.text,'hello');assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// v5 复制涉及的上级范围或声明子分区尚未完整,canDuplicate 和实际 duplicate 都拒绝,不能先分配任务/源文件。
|
||||
test('v5 copy rejects an incomplete ancestor or a missing declared child before allocating task bytes',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:jobLoading});
|
||||
try{const before=engine.getSnapshot(),selection=[{id:'C',incarnation:1}];assert.equal(engine.imports.canDuplicate(selection,{viewCanvasId:child}).enabled,false);await assert.rejects(engine.imports.duplicate(selection,{viewCanvasId:child}),{code:'loading_scope_incomplete'});assert.strictEqual(engine.getSnapshot(),before);assert.equal(engine.imports.getJobs().size,0);assert.equal(f.storage.files.size,0);}finally{await engine.close();}
|
||||
});
|
||||
// v5 无关第三 canvas 可继续加载,只要被复制子树及上级入边范围闭合;暂停/重开保留去重映射与 C 的单副本,不吸入未加载 V。
|
||||
test('v5 deep copy includes upper incoming edges and a closed subtree while unrelated canvas contents are still loading',async()=>{
|
||||
const other='10000000-0000-4000-8000-000000000029',g={...node('G'),componentId:'frame'},incoming={...edge,source:{elementId:'A'},target:{elementId:'F'}},topology=[...canvases,{id:other,parent:{canvasId:root,entryElementId:'G'}}],server=createDocument(root,topology,[a,frame,g,incoming,c,node('U',other),node('V',other)]),document=createDocument(root,topology,[a,frame,g,incoming,c,node('U',other)]),f=fixture(server),serverVersions=new Map([[root,1],[child,1],[other,1]]),loading={version:5,sourceToken:'closed-copy',declaredCanvasIds:[root,child,other],sourceCanvases:topology,receivedElements:6,totalElements:7,canvasElementCounts:{[root]:4,[child]:1,[other]:2},elementOwners:Object.fromEntries([...server.elements.values()].map(element=>[element.id,element.ownerCanvasId]))};
|
||||
// The fixture's initial version vector follows the server's active scopes.
|
||||
@@ -269,6 +344,7 @@ test('v5 deep copy includes upper incoming edges and a closed subtree while unre
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// v5 paused 导入源重开仍用原 ID;loadingAdmission/token/范围/序号必须可验证,完成后 undo 的删除不会被源 full 重新复活。
|
||||
test('v5 paused native source survives reopen, resumes with stable IDs, and undo does not revive after completion',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]);let engine=await f.open({document,loading:jobLoading});
|
||||
try{const id=engine.imports.startNdjson(ndjson([node('paused-source','$target')]),child);await engine.imports.pause(id);await engine.settle();const paused=engine.imports.getJobs().get(id);assert.equal(paused.status,'paused');assert.equal(paused.committedSourceKeys.length,0);assert.equal(f.calls.length,0);assert.equal(f.storage.files.size,1);decodeRecovery(f.storage.image(),registry);for(const mutate of [image=>image.payload.runtime.imports[0][1].source.loadingAdmission.sourceToken='other-cut',image=>image.payload.runtime.imports[0][1].source.loadingAdmission.requiredCanvasIds=[root],image=>image.payload.runtime.imports[0][1].source.loadingAdmission.operationSequence=999,image=>delete image.payload.runtime.loading.sourceCanvases,image=>image.formatVersion=10]){const image=f.storage.image();mutate(image);assert.throws(()=>decodeRecovery(image,registry));}
|
||||
@@ -276,37 +352,56 @@ test('v5 paused native source survives reopen, resumes with stable IDs, and undo
|
||||
await engine.close();engine=await f.open({document,loading:jobLoading});assert.equal(engine.imports.getJobs().get(id).stableIds['element:paused-source'],mapped);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has(mapped),false);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.has(mapped),false);assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// v5 cancelled job 重开/resume 仍取消,源 full 完成只补原始五元素,不生成被取消任务的内容。
|
||||
test('v5 cancelled native job stays cancelled through reopening and final source completion',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]);let engine=await f.open({document,loading:jobLoading});
|
||||
try{const id=engine.imports.startNdjson(ndjson([node('cancelled-source','$target')]),child);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open({document,loading:jobLoading});await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(f.calls.length,0);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.size,5);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
// v5 已知入口但 child 尚未发布时,不可把它解释为空分区并允许深复制,任务表保持空。
|
||||
test('v5 copy cannot interpret a declared but unpublished child as an empty partition',async()=>{
|
||||
const f=fixture(),document=createDocument(root,[canvases[0]],[a,b,frame]),loading={...jobLoading,receivedElements:3,canvasElementCounts:{[root]:3,[child]:2},elementOwners:{A:root,B:root,F:root,C:child,E:child}};
|
||||
const engine=await f.open({document,loading});try{assert.equal(engine.editor.getQueries().membersResult(root).complete,true);assert.equal(engine.getSnapshot().document.canvases.has(child),false);assert.equal(engine.imports.canDuplicate([{id:'F',incarnation:1}],{viewCanvasId:root}).enabled,false);await assert.rejects(engine.imports.duplicate([{id:'F',incarnation:1}],{viewCanvasId:root}),{code:'loading_scope_incomplete'});assert.equal(engine.imports.getJobs().size,0);}finally{await engine.close();}
|
||||
});
|
||||
// 动作可用性先检查目标范围、选择非空/身份新鲜度及策略;不可对 UI 声称可执行而到提交才发现明显缺项。
|
||||
test('import availability rejects stale selection, missing target and policy denial before offering an action',async()=>{
|
||||
const f=fixture();let engine=await f.open({document:full,loading:undefined});try{assert.equal(engine.imports.canStart('missing').code,'canvas_not_loaded');assert.equal(engine.imports.canDuplicate([],{viewCanvasId:root}).code,'empty_copy_selection');assert.equal(engine.imports.canDuplicate([{id:'A',incarnation:2}]).code,'stale_incarnation');}finally{await engine.close();}
|
||||
engine=await f.open({document:full,loading:undefined,policy:()=>({enabled:false,code:'permission_denied'})});try{assert.equal(engine.imports.canStart(root).code,'permission_denied');assert.equal(engine.imports.canDuplicate([target]).code,'permission_denied');}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// loading v6 为完整 owner 中的文件暂存/上传和已有共享资源接纳增加明确准入证据。
|
||||
const assetLoading={...jobLoading,version:6};
|
||||
function assetPort(f){const uploads=[],receipts=new Map();let lose=false;const assets={sourceURL:id=>`https://assets.example/${id}`,displayURL:id=>`/assets/${id}`,async upload(request,bytes){const value=JSON.parse(request.body);assert.equal(f.storage.files.get(value.resource_id).size,bytes.size);assert.ok(f.storage.image().payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.phase==='dispatch-marked'));uploads.push(request);const stored={id:value.resource_id,url:assets.sourceURL(value.resource_id),bytes:bytes.size,width:20,height:10,mime_type:bytes.type};receipts.set(request.id,stored);if(lose)throw Error('lost upload ACK');return{outcome:'applied',canonical:stored};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:receipts.get(request.id)}:{outcome:'unresolved'};},accept:(_request,status)=>status.canonical};return{assets,uploads,lose(){lose=true;}};}
|
||||
// 可记录上传和丢 ACK 的资源端口;上传前核对原 Blob 与 dispatch 证据已保存,成功资产可按原请求查询。
|
||||
function assetPort(f){const uploads=[],receipts=new Map();let lose=false;const assets={sourceURL:id=>`https://assets.example/${id}`,displayURL:id=>`/assets/${id}`,
|
||||
// 上传先核对原 Blob 大小及持久化 dispatch 标记;保存资产回执后 lose 开关可抛错模拟服务成功、客户端丢 ACK。
|
||||
async upload(request,bytes){const value=JSON.parse(request.body);assert.equal(f.storage.files.get(value.resource_id).size,bytes.size);assert.ok(f.storage.image().payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.phase==='dispatch-marked'));uploads.push(request);const stored={id:value.resource_id,url:assets.sourceURL(value.resource_id),bytes:bytes.size,width:20,height:10,mime_type:bytes.type};receipts.set(request.id,stored);if(lose)throw Error('lost upload ACK');return{outcome:'applied',canonical:stored};},
|
||||
// 按原 requestId 查保存的上传结果;未知保持 unresolved,不能用新上传掩盖未知请求。
|
||||
async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:receipts.get(request.id)}:{outcome:'unresolved'};},
|
||||
// 把 canonical 元数据交给引擎做身份验证与依赖解锁,此处不模拟服务端二进制上传本身。
|
||||
accept:(_request,status)=>status.canonical};return{assets,uploads,
|
||||
// 从此让上传回执丢失,用于重开后必须走查询且不重新传字节的场景。
|
||||
lose(){lose=true;}};}
|
||||
// 在 stageFiles 的同步事务内创建图片,返回资源句柄与提交结果,owner 可切换以验证范围拒绝。
|
||||
function stageLoadingImage(engine,owner=child,id='partial-image'){let ref;const result=engine.stageFiles([{name:'picture.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([resource],tx)=>{ref=resource;tx.dispatch({type:'element.create',element:{...node(id,owner),componentId:'image',data:{title:'Picture',text:'',assetId:ref.id,src:ref.src}}});});return{result,ref};}
|
||||
// v6 图片立即显示,完整 child 允许持久化后上传并同步,未完成 root 拒绝;恢复校验准入证据,undo/redo 不重复上传,旧源完成保留资源。
|
||||
test('v6 displays, persists and uploads a scoped image before another owner tail; undo/redo and old completion preserve its resource',async()=>{
|
||||
const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{assert.equal(engine.canStageFiles(child).enabled,true);assert.equal(engine.canStageFiles(root).enabled,false);const before=engine.getSnapshot();assert.equal(stageLoadingImage(engine,root,'denied-image').result.status,'rejected');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.storage.files.size,0);
|
||||
const {result,ref}=stageLoadingImage(engine);assert.equal(result.status,'applied');assert.equal(engine.getSnapshot().document.elements.get('partial-image').data.assetId,ref.id);assert.equal(port.uploads.length,0);await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal(f.storage.image().formatVersion,12);decodeRecovery(f.storage.image(),registry);for(const mutate of [image=>image.payload.runtime.resources[0][1].loadingAdmission.sourceToken='other-source',image=>image.payload.runtime.resources[0][1].loadingAdmission.canvasIds=[root],image=>image.payload.runtime.resources[0][1].loadingAdmission.sourceURL='https://wrong.example/',image=>image.payload.runtime.resources[0][1].loadingAdmission.version=2,image=>{const row=image.payload.runtime.ledger.requests.find(([,row])=>row.kind==='asset.upload')[1];const body=JSON.parse(row.body);body.name='changed';row.body=JSON.stringify(body);},image=>image.formatVersion=11]){const image=f.storage.image();mutate(image);assert.throws(()=>decodeRecovery(image,registry));}assert.equal(engine.loading.getState().version,6);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.has('partial-image'),false);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('partial-image').incarnation,2);assert.equal(port.uploads.length,1);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);assert.equal(engine.getSnapshot().document.elements.get('partial-image').incarnation,2);assert.equal(f.errors.length,0);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// v6 上传服务成功但 ACK 丢失,复制图片可先本地复用待确认资源;重开查询原上传后释放两份图片文档,不重传字节或改 body。
|
||||
test('v6 queries a lost upload ACK after reopening and never resubmits bytes or loses the pending image to the source tail',async()=>{
|
||||
let now=0;const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),clock={now:()=>now,setTimer:()=>0,clearTimer(){}};port.lose();let engine=await f.open({document,loading:assetLoading,assets:port.assets,clock});try{const {ref}=stageLoadingImage(engine);await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.calls.length,0);const request=port.uploads[0];assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');const copyId=await engine.imports.duplicate([{id:'partial-image',incarnation:1}],{viewCanvasId:child});await engine.settle();const copy=engine.imports.getJobs().get(copyId);assert.equal(copy.status,'completed');const copied=copy.stableIds['element:partial-image'];assert.equal(engine.getSnapshot().document.elements.get(copied).data.assetId,ref.id);assert.equal(f.calls.length,0);decodeRecovery(f.storage.image(),registry);await engine.close();now=10_000;engine=await f.open({document,loading:assetLoading,assets:port.assets,clock});await engine.settle();assert.equal(port.uploads.length,1);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,request.body);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'resolved');assert.equal(f.server().elements.get(copied).data.assetId,ref.id);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal((await engine.loading.complete(full,versions,{sourceToken:state.sourceToken})).status,'applied');await engine.settle();assert.equal(port.uploads.length,1);}finally{await engine.close();}
|
||||
});
|
||||
// v6 可替换完整原始 owner 中的图片引用;undo/redo 保留新旧选择且只上传一次,不因其他范围未加载而丢资源。
|
||||
test('v6 replaces an image in a complete original owner and preserves both resource choices through undo/redo',async()=>{
|
||||
const image={...c,componentId:'image',data:{title:'Original image',text:'',assetId:'old-asset',src:'https://assets.example/old-asset'}},server=createDocument(root,canvases,[a,b,frame,image,edge]),f=fixture(server),port=assetPort(f),document=createDocument(root,canvases,[a,frame,image]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{const result=engine.stageFiles([{name:'replacement.png',bytes:new Blob(['replacement'],{type:'image/png'})}],([ref],tx)=>tx.dispatch({type:'element.data',target:{id:'C',incarnation:1},patch:{assetId:ref.id,src:ref.src}}));assert.equal(result.status,'applied');await engine.settle();const replacement=f.server().elements.get('C').data.assetId;assert.notEqual(replacement,'old-asset');assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.assetId,'old-asset');assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('C').data.assetId,replacement);assert.equal(port.uploads.length,1);decodeRecovery(f.storage.image(),registry);assert.equal(f.errors.length,0);}finally{await engine.close();}
|
||||
});
|
||||
// v6 Blob/root 配额失败时本地仍可显示字节,但上传和文档都不发;retry 完成保存后发送一次并保留单一创建历史。
|
||||
test('v6 failed Blob/root persistence keeps the local picture visible and sends neither upload nor document until retry',async()=>{
|
||||
const f=fixture(),port=assetPort(f),document=createDocument(root,canvases,[a,frame,c]),engine=await f.open({document,loading:assetLoading,assets:port.assets});try{await engine.persist();f.storage.broken=true;const {result,ref}=stageLoadingImage(engine);assert.equal(result.status,'applied');const display=await engine.displayResource(ref.id);assert.equal(await(await fetch(display.url)).text(),'image bytes');display.release();await assert.rejects(engine.persist(),/quota/);await new Promise(resolve=>setTimeout(resolve,10));assert.equal(port.uploads.length,0);assert.equal(f.calls.length,0);assert.equal(engine.getSnapshot().document.elements.get('partial-image').data.assetId,ref.id);f.storage.broken=false;await engine.retry();await engine.settle();assert.equal(port.uploads.length,1);assert.equal(f.server().elements.get('partial-image').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{f.storage.broken=false;await engine.close();}
|
||||
});
|
||||
|
||||
// v6 已授权共享图片可接纳到完整 child,不创建上传任务;binding 和准入证据需恢复验证,重开显示/undo/redo 及旧流完成均保持资源身份。
|
||||
test('v6 adopts an authorized shared image into a complete owner without an upload and restores its binding before the unread tail',async()=>{
|
||||
const f=fixture(),document=createDocument(root,canvases,[a,frame,c]),bytes=new Blob(['shared bytes'],{type:'image/png'}),digest=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(n=>n.toString(16).padStart(2,'0')).join(''),id='shared-adopted',authority='https://assets.example';
|
||||
const cache=new Map(),commit=f.storage.commit;f.storage.assetDepot={async read(_leader,_authority,id){return cache.get(id);},async collect(){throw Error('unexpected GC');}};f.storage.commit=async(leader,batch)=>{const result=await commit(leader,batch);for(const write of batch.depot?.writes??[])cache.set(write.ref.asset.id,{ref:write.ref,bytes:write.bytes??cache.get(write.ref.asset.id)?.bytes});return result;};
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
/**
|
||||
* 旧快照到 canonical 身份迁移的持久请求状态机测试。先 drain 旧写、读取最新源版本、保存冻结 body,再派发迁移。
|
||||
* 未知结果保留原 ID/字节,恢复先查询后按原请求重试;只有验证过的终态才可清理恢复材料或放开旧编辑。
|
||||
* 内存存储与假服务端注入响应丢失、版本冲突及配额问题;真实 UI/Web Locks 场景由 renderer 集成探针另测。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||
import {createCanvasMigration,readCanvasMigration,canvasMigrationStorageKey,migrationBlocksCanvas} from '../../src/lib/canvas-migration.ts';
|
||||
|
||||
// 固定 UUID 作用域及新元素身份;源旧 ID 为 a,成功迁移须返回声明映射到新元素的证明。
|
||||
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,root=id(1),child=id(2),scope={accountId:id(3),workspaceId:id(4)},registry=createRegistry(builtinComponents());
|
||||
// 保存迁移字符串、回执、冻结 body 和请求记录,开关区分提交前失败、提交后丢 ACK、明确拒绝和篡改响应。
|
||||
function fixture(){
|
||||
const records=new Map(),receipts=new Map(),bodies=new Map(),calls=[];
|
||||
const f={records,receipts,calls,version:1,canonical:false,current:true,quota:false,loseResponse:false,failBeforeCommit:false,reject:false,alter:undefined};let sequence=100;
|
||||
// 模拟 localStorage;quota 在 setItem 前抛错,removeItem 真正删除恢复记录。
|
||||
const storage={getItem:key=>records.get(key)??null,setItem(key,value){if(f.quota)throw Error('quota');records.set(key,value);},removeItem:key=>records.delete(key)};
|
||||
// 完整 canonical 模型与新身份 id(20),供成功回执的版本、元素类型和 identity_map 交叉校验。
|
||||
const model=version=>({documentFormatVersion:1,projectId:root,complete:true,canvases:[{id:root,parent:null}],elements:[{id:id(20),incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'a',text:'body'},geometry:{x:0,y:0,width:240,height:72,rotation:0}}],versions:{[root]:version},tombstones:{},removedCanvasIds:[]});
|
||||
// GET 分别查迁移需求/完整模型和原回执;POST 先断言已持久化同一 body,再按 requestId 防止重复应用。
|
||||
const transport=async input=>{
|
||||
calls.push(input);
|
||||
if(input.method==='GET'&&input.path.endsWith('/model'))return f.canonical?{status:200,body:{model:model(f.version)}}:{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:f.version}}};
|
||||
@@ -17,10 +27,13 @@ function fixture(){
|
||||
const response=known?{status:200,body:known.body}:{status:404,body:{status:'unknown',request_id:key,error:'operation_receipt_unknown'}};
|
||||
return f.alter?f.alter(response,input):response;
|
||||
}
|
||||
// 网络派发前直接读取保存字符串,对照请求体,证明持久化屏障而非只检查内存中存在请求。
|
||||
assert.equal(JSON.parse(storage.getItem(canvasMigrationStorageKey(scope))).body,input.body,'request must be durable before POST');
|
||||
assert.equal(input.responseFormat,'canvas-v2');
|
||||
// 提交前失败没有服务端回执;恢复只能查询 unknown 后精确重试,不能据传输异常推定 applied。
|
||||
if(f.failBeforeCommit)throw Error('connection lost before commit');
|
||||
const raw=JSON.parse(input.body);assert.equal(raw.request_id,input.requestId);
|
||||
// 已知 key 必须使用相同冻结字节;新 key 根据 reject 开关生成可信零写入拒绝或带映射/完整模型的成功。
|
||||
let response=receipts.get(input.requestId);
|
||||
if(response){assert.equal(bodies.get(input.requestId),input.body,'retry changed frozen bytes');}
|
||||
else{
|
||||
@@ -28,13 +41,17 @@ function fixture(){
|
||||
receipts.set(input.requestId,response);bodies.set(input.requestId,input.body);
|
||||
if(!f.reject){f.version++;f.canonical=true;}
|
||||
}
|
||||
// 此时服务端事实与回执已保存,再模拟 ACK 丢失;客户端看到错误也不能生成另一次迁移。
|
||||
if(f.loseResponse)throw Error('connection lost after commit');
|
||||
return f.alter?f.alter(structuredClone(response),input):response;
|
||||
};
|
||||
// 组装迁移控制器,默认 lock 只执行 work(不证明真实互斥);current、createId 和 signal 可被用例注入。
|
||||
f.storage=storage;f.make=extra=>createCanvasMigration({scope,storage,registry,transport,isCurrentIdentity:()=>f.current,createId:()=>id(++sequence),lock:async work=>work(),...extra});
|
||||
// 从全部调用中过滤 POST,分别检查旧请求精确重试与明确重新准备产生新请求。
|
||||
f.posts=()=>calls.filter(call=>call.method==='POST');return f;
|
||||
}
|
||||
|
||||
// drain 必须在任何读请求前完成,之后重新读源版本;POST 使用 drain 后的版本和预先持久化 requestId,成功后清理记录。
|
||||
test('migration drains first, refreshes source versions and persists exact request identity before dispatch',async()=>{
|
||||
const f=fixture(),migration=f.make();let drained=false;
|
||||
const result=await migration.start(root,async()=>{assert.equal(f.calls.length,0);drained=true;f.version=4;});
|
||||
@@ -43,6 +60,7 @@ test('migration drains first, refreshes source versions and persists exact reque
|
||||
assert.equal(f.posts()[0].requestId,JSON.parse(f.posts()[0].body).request_id);
|
||||
});
|
||||
|
||||
// 服务提交成功但响应丢失,重建控制器先查原 key 即恢复成功;不再次 drain、不分配新 ID,也不发第二个 POST。
|
||||
test('lost committed migration ACK is recovered by original query after reload without flushing or invoking twice',async()=>{
|
||||
const f=fixture();f.loseResponse=true;const first=f.make();
|
||||
assert.equal((await first.start(root,async()=>{})).status,'pending');const job=first.read();
|
||||
@@ -52,6 +70,7 @@ test('lost committed migration ACK is recovered by original query after reload w
|
||||
assert.equal(f.posts().length,1);assert.ok(f.calls.some(call=>call.path.endsWith('/operations/'+job.id)));assert.equal(recovered.read(),undefined);
|
||||
});
|
||||
|
||||
// 提交前断线的 durable 请求先 query unknown,再保留原 ID 和包含额外空白的 body 精确重试,不能重序列化。
|
||||
test('a durable uncommitted request queries unknown then retries only its exact original body and key',async()=>{
|
||||
const f=fixture();f.failBeforeCommit=true;const first=f.make();await first.start(root,async()=>{});
|
||||
const original=first.read(),spaced={...original,body:' \n'+original.body+' '};f.storage.setItem(canvasMigrationStorageKey(scope),JSON.stringify(spaced));f.failBeforeCommit=false;
|
||||
@@ -61,6 +80,7 @@ test('a durable uncommitted request queries unknown then retries only its exact
|
||||
assert.equal(f.calls.at(-2).method,'GET');assert.equal(f.calls.at(-2).body,undefined);
|
||||
});
|
||||
|
||||
// 存储配额或旧 writer drain 失败均不得发迁移;drain 失败也不能留下新恢复任务或触发网络读取。
|
||||
test('storage and legacy-drain failure prevent dispatch and do not silently discard recovery state',async()=>{
|
||||
const f=fixture();f.quota=true;
|
||||
await assert.rejects(()=>f.make().start(root,async()=>{}),/quota/);assert.equal(f.posts().length,0);
|
||||
@@ -69,6 +89,7 @@ test('storage and legacy-drain failure prevent dispatch and do not silently disc
|
||||
assert.equal(f.calls.length,0);assert.equal(f.records.size,0);
|
||||
});
|
||||
|
||||
// 可信 terminal not-applied 才将 phase 置 rejected 并释放编辑 gate;resume 不重发,显式 start 才刷新版本并生成新 key。
|
||||
test('only verified terminal rejection releases the editing gate; a new explicit preparation refreshes and allocates',async()=>{
|
||||
const f=fixture();f.reject=true;const migration=f.make();const result=await migration.start(root,async()=>{});
|
||||
assert.equal(result.status,'rejected');const rejected=migration.read();assert.equal(rejected.phase,'rejected');assert.equal(migrationBlocksCanvas(rejected,root),false);
|
||||
@@ -77,6 +98,7 @@ test('only verified terminal rejection releases the editing gate; a new explicit
|
||||
assert.equal((await migration.start(root,async()=>{})).status,'applied');assert.notEqual(f.posts()[1].requestId,rejected.id);assert.deepEqual(JSON.parse(f.posts()[1].body).expected_versions,{[root]:7});
|
||||
});
|
||||
|
||||
// 裸 403、错 key/源版本/空或错类型/跨 canvas 映射不构成成功证明,保持 pending gate;原回执查询修复后仍只有一次 POST。
|
||||
test('bare rejection and malformed or foreign success retain the frozen request until the original query repairs it',async()=>{
|
||||
for(const alter of [
|
||||
response=>({status:403,body:{error:'forbidden'}}),
|
||||
@@ -92,6 +114,7 @@ test('bare rejection and malformed or foreign success retain the frozen request
|
||||
}
|
||||
});
|
||||
|
||||
// 未来迁移格式及同 workspace 另一 pending 根不能被覆盖;换账户读取不同存储键,不泄漏旧请求。
|
||||
test('future/corrupt migration material and another pending project cannot be overwritten',async()=>{
|
||||
const f=fixture(),key=canvasMigrationStorageKey(scope);f.storage.setItem(key,JSON.stringify({version:2,payload:'retain'}));
|
||||
const saved=f.storage.getItem(key);await assert.rejects(()=>f.make().start(root,async()=>assert.fail()),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.calls.length,0);
|
||||
@@ -100,6 +123,7 @@ test('future/corrupt migration material and another pending project cannot be ov
|
||||
assert.equal(readCanvasMigration(f.storage,{...scope,accountId:id(99)}),undefined);
|
||||
});
|
||||
|
||||
// 响应回来时身份已变或 signal abort,不能清理旧账户恢复材料;回到有效身份后查询原回执,不重复迁移。
|
||||
test('identity changes and aborted lifetimes cannot clear an old account request on a late success',async()=>{
|
||||
const f=fixture();f.alter=response=>{f.current=false;return response;};
|
||||
await assert.rejects(()=>f.make().start(root,async()=>{}),/identity_changed/);assert.equal(f.records.size,1);
|
||||
@@ -108,6 +132,7 @@ test('identity changes and aborted lifetimes cannot clear an old account request
|
||||
await assert.rejects(()=>g.make({signal:controller.signal}).start(root,async()=>{}),{name:'AbortError'});assert.equal(g.records.size,1);assert.equal(g.posts()[0].signal,controller.signal);
|
||||
});
|
||||
|
||||
// 同控制器重入 resume 被 migration_busy 拒绝;完成后新控制器识别已 canonical,只返回 already-migrated 不再 POST。
|
||||
test('reentrant actions cannot create a second in-flight migration and already migrated projects do not POST',async()=>{
|
||||
const f=fixture();let entered,release;const started=new Promise(resolve=>entered=resolve),gate=new Promise(resolve=>release=resolve);
|
||||
const original=f.make();
|
||||
@@ -117,6 +142,7 @@ test('reentrant actions cannot create a second in-flight migration and already m
|
||||
assert.equal((await original.start(root,async()=>{})).status,'already-migrated');assert.equal(f.posts().length,1);
|
||||
});
|
||||
|
||||
// 成功后清理记录失败仍保留可恢复请求,重试查询即可;假存储吞掉 setItem 时以 migration_not_durable 拒绝派发。
|
||||
test('failed result retirement remains recoverable and a storage port that drops writes cannot dispatch',async()=>{
|
||||
const f=fixture(),storage={...f.storage,removeItem(){throw Error('storage unavailable');}};
|
||||
await assert.rejects(()=>f.make({storage}).start(root,async()=>{}),/storage unavailable/);
|
||||
@@ -124,6 +150,7 @@ test('failed result retirement remains recoverable and a storage port that drops
|
||||
const g=fixture();await assert.rejects(()=>g.make({storage:{...g.storage,setItem(){}}}).start(root,async()=>{}),/migration_not_durable/);assert.equal(g.posts().length,0);
|
||||
});
|
||||
|
||||
// 合法空模型允许 identity_map 为空,不应为了满足校验凭空生成不存在元素的映射。
|
||||
test('a valid empty migration has no fabricated identity entries',async()=>{
|
||||
const f=fixture();f.alter=response=>({...response,body:{...response.body,identity_map:[],model:{...response.body.model,elements:[]}}});
|
||||
assert.equal((await f.make().start(root,async()=>{})).status,'applied');assert.equal(f.records.size,0);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Foundry 模型 loader、RuntimeHost 共享读取及画布 entry 注入测试。只读加载不应提前打开 workspace/project 存储。
|
||||
* 并发请求可以共享进行中的读取,但空闲 canonical 快照不自动成为下次打开的权威基线;身份退役隔离迟到预览。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,13 +9,20 @@ import { createRuntimeHost } from '../../src/lib/canvas-runtime/host.ts';
|
||||
import { loadQueryKey } from '../../src/lib/canvas-runtime/loader.ts';
|
||||
import { createFoundryModelLoader } from '../../src/lib/canvas-service/model-loader.ts';
|
||||
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
|
||||
// 确定性 UUID 工厂,满足 service 的输入校验并区分账户、workspace 与根 canvas。
|
||||
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
// 当前身份和 registry;scope 中 projectId 尚未知,由 canonical 模型解析得到真实项目根。
|
||||
const account = id(1), workspace = id(2), root = id(3), registry = createRegistry(builtinComponents());
|
||||
// 完整模型查询绑定权限 epoch、导航代次、格式和来源,防止不同读取用途误共享。
|
||||
const scope = { accountId: account, workspaceId: workspace, projectId: null, canvasId: root, authEpoch: 1, navigationGeneration: 7, format: 'canonical-model-v1', source: 'foundry', viewKey: 'canonical-project', queryFingerprint: 'full-project' };
|
||||
// 手动 gate 控制首次响应及尾部预览返回,精确安排取消/身份退役。
|
||||
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
||||
// 推进一次事件循环,让共享 loader 和观察者执行至可检查的状态。
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
// 生成版本可变的完整 canonical 响应,标题可用于检测是否误用了旧账户结果。
|
||||
const reply = (version = 1, title = account) => ({ status: 200, body: { model: { documentFormatVersion: 1, projectId: root, complete: true, canvases: [{ id: root, parent: null }], elements: [{ id: 'node', incarnation: 1, ownerCanvasId: root, componentId: 'text', dataVersion: 1, structure: 'node', data: { title, text: '' }, geometry: { x: 0, y: 0, width: 100, height: 80, rotation: 0 } }], tombstones: {}, versions: { [root]: version }, removedCanvasIds: [] } } });
|
||||
|
||||
// JSON adapter 只发一个已验证完整块,绑定 query/project、canvas 覆盖和版本,GET 沿用原 AbortSignal。
|
||||
test('Foundry adapter emits one validated complete chunk with bound query, project, coverage and version metadata', async () => {
|
||||
const abort = new AbortController(), calls = [];
|
||||
const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async input => { calls.push(input); return reply(3); } });
|
||||
@@ -20,12 +31,14 @@ test('Foundry adapter emits one validated complete chunk with bound query, proje
|
||||
assert.deepEqual(chunks[0].coverage, [`canvas:${root}`]); assert.equal(chunks[0].serverVersions[root], 3); assert.ok(chunks[0].byteLength > 0); assert.equal(chunks[0].value.document.elements.get('node').data.title, account);
|
||||
assert.strictEqual(calls[0].signal, abort.signal); assert.equal(calls[0].method, 'GET');
|
||||
});
|
||||
// 局部模型、查询 canvas 不在模型及不匹配迁移头均不能冒充初始就绪,必须在 yield 任何块前拒绝。
|
||||
test('partial canonical replies, foreign requested canvases and unbound migration headers cannot masquerade as initial readiness', async () => {
|
||||
for (const [response, expected] of [[{ ...reply(), body: { model: { ...reply().body.model, complete: false } } }, 'incomplete_initial_model'], [reply(), 'project_mismatch'], [{ status: 409, body: { error: 'canonical_migration_required', document_format_version: 1, root_id: root, source_versions: { [root]: 1 } } }, 'project_mismatch']]) {
|
||||
const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async () => response }), query = expected === 'incomplete_initial_model' ? scope : { ...scope, canvasId: id(99) };
|
||||
await assert.rejects(async () => { for await (const _ of port(query, new AbortController().signal)) assert.fail('invalid response yielded a chunk'); }, { code: expected });
|
||||
}
|
||||
});
|
||||
// 同身份/workspace 并发读取共享端口,取消一方不伤另一方;完成释放后下次重新读取新版本,始终不打开 workspace runtime。
|
||||
test('Host shares concurrent model reads per identity/workspace but never reuses an idle authoritative snapshot', async () => {
|
||||
const gate = deferred(), calls = []; let factories = 0, version = 1;
|
||||
const host = createRuntimeHost({ registry, workspace() { assert.fail('readonly load opened a project/storage runtime'); }, modelLoader(accountId, workspaceId, _epoch, current) {
|
||||
@@ -38,6 +51,7 @@ test('Host shares concurrent model reads per identity/workspace but never reuses
|
||||
version = 2; assert.equal((await host.loadModel(account, workspace, root)).versions.get(root), 2); assert.equal(calls.length, 2); assert.equal(host.getWorkspaces().size, 0);
|
||||
} finally { gate.resolve(); await host.close(); }
|
||||
});
|
||||
// 身份退役关闭旧 loader 并 abort 原信号,新账户用新读取;旧响应迟到不能覆盖当前 Host 身份或模型。
|
||||
test('Host retirement aborts the old read and a late response cannot pollute the new account reader', async () => {
|
||||
const gate = deferred(), calls = [];
|
||||
const host = createRuntimeHost({ registry, workspace() { assert.fail('unexpected project open'); }, modelLoader(accountId, workspaceId) {
|
||||
@@ -49,6 +63,7 @@ test('Host retirement aborts the old read and a late response cannot pollute the
|
||||
gate.resolve(); await tick(); assert.equal(host.identity().accountId, id(8)); assert.equal(calls[0].input.signal.aborted, true); assert.equal(calls.length, 2);
|
||||
} finally { gate.resolve(); await host.close(); }
|
||||
});
|
||||
// entry 解析 slug 后传入真实 account/workspace/canvas 与导航上下文,走注入 readModel;canonical 失败不能偷退回旧 canvas 接口。
|
||||
test('entry injection uses resolved IDs and navigation context while preserving canonical-first legacy policy', async () => {
|
||||
const board = { id: root, workspace_id: workspace, slug: 'board', kind: 'board', title: 'Board', version: 1 }, inputs = [], reads = [];
|
||||
const options = { workspaceRef: 'workspace-slug', boardRef: 'board', registry, navigationGeneration: 9, request: async path => { reads.push(path); return path === 'profile' ? { id: account } : board; },
|
||||
@@ -57,10 +72,12 @@ test('entry injection uses resolved IDs and navigation context while preserving
|
||||
await assert.rejects(() => loadCanvasEntry({ ...options, readModel: async () => { throw Object.assign(Error('forbidden'), { code: 'forbidden' }); } }), { code: 'forbidden' });
|
||||
});
|
||||
|
||||
// 两个预览观察者共享一个渐进源,取消一个后它不再收到后缀;另一方完成,Host 未创建任何存储 workspace。
|
||||
test('Host previews share one source but cancelled observers never receive a late prefix or open storage',async()=>{
|
||||
const gate=deferred(),aStates=[],bStates=[];let reads=0;
|
||||
const first=reply().body.model.elements[0],second={...first,id:'second'},canvases=[{id:root,parent:null}],serverVersions={[root]:1};
|
||||
const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened storage');},modelLoader(){return async function*(scope){
|
||||
// 每个原始读取共用 token/版本;part 将状态包装成连续序号块,gate 只阻塞后缀。
|
||||
reads++;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'one-read',serverVersions,permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
|
||||
yield part(0,{status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:2});
|
||||
yield part(1,{status:'receiving',projectId:root,canvases:[],elements:[first],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:1,totalElements:2});
|
||||
@@ -71,9 +88,11 @@ test('Host previews share one source but cancelled observers never receive a lat
|
||||
const abort=new AbortController();const a=host.loadModel(account,workspace,root,{signal:abort.signal,onPreview:value=>aStates.push(value)}),b=host.loadModel(account,workspace,root,{onPreview:value=>bStates.push(value)});
|
||||
try{while(!aStates.some(value=>value.receivedElements===1))await tick();assert.equal(reads,1);assert.equal(aStates.at(-1).document.elements.size,1);const rejected=assert.rejects(a,{code:'load_aborted'});abort.abort();await rejected;const count=aStates.length;gate.resolve();const complete=await b;assert.equal(complete.document.elements.size,2);assert.equal(aStates.length,count);assert.equal(bStates.at(-1).document.elements.size,2);assert.equal(host.getWorkspaces().size,0);}finally{gate.resolve();await host.close();}
|
||||
});
|
||||
// onPreview 抛错经 onObserverError 隔离,不阻止 canonical ready;身份退役后不能继续通知第二次读取的预览。
|
||||
test('preview callback failures do not stop canonical loading and identity retirement fences future previews',async()=>{
|
||||
const gate=deferred(),secondGate=deferred(),errors=[],states=[];let reading=0;
|
||||
const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened a runtime');},onObserverError:error=>errors.push(error),modelLoader(){return async function*(scope){
|
||||
// 两次查询使用不同 gate,分别检查回调异常隔离和身份退役;每次源仍有独立接收/ready 阶段。
|
||||
const pass=++reading;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'read',serverVersions:{[root]:1},permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
|
||||
yield part(0,{status:'receiving',projectId:root,canvases:[{id:root,parent:null}],elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:0});await(pass===1?gate.promise:secondGate.promise);
|
||||
yield part(1,{status:'ready',document:(await import('../../src/lib/canvas-core/document.ts')).createDocument(root,[{id:root,parent:null}]),versions:new Map([[root,1]])},true);
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
/**
|
||||
* 渐进 canonical 模型的只读预览投影测试。接收进度与合法可发布前缀不同:入口/归属/端口/端点依赖未闭合时先等待。
|
||||
* 每次发布需是合法文档,失败保持上次快照并关闭预览;最终完整模型还需与已显示内容一致,不能直接变成可编辑半模型。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRegistry,builtinComponents,createDocument,validateDocument} from '../../src/lib/canvas-core/index.ts';
|
||||
import {createModelPreview} from '../../src/lib/canvas-runtime/model-preview.ts';
|
||||
// 内置类型定义及 root/child 身份;frame 端口与 child 成员形成依赖闭环。
|
||||
const registry=createRegistry(builtinComponents()),root='root',child='child';
|
||||
// 文本节点工厂,可替换 owner,用于构造已就绪节点、等待成员及非法记录。
|
||||
const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}});
|
||||
// F 在 root,端口引用 child 中 A;child 又以 F 为入口,必须闭合后一起显示。
|
||||
const frame={...node('F'),componentId:'frame',data:{title:'F',text:'',ports:[{id:'p',nodeId:'A',direction:'output',handle:'right'}]}};
|
||||
// A 与 B 作为跨分区连线端点;E 归共同 root,不能在任一端点缺失时显示成完整边。
|
||||
const a=node('A',child),b=node('B'),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'B'},path:null};
|
||||
// 源 canvas 树和版本表;预览开始时虽已知 child 关系,入口尚未来到仍不能发布 child。
|
||||
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],versions=new Map([[root,1],[child,1]]);
|
||||
// 空接收头声明总元素数,complete 仍由最终 ready 状态决定,不由 received==total 推定。
|
||||
const head=(total=4)=>({status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions,receivedElements:0,totalElements:total});
|
||||
// 后续 delta 不重复 canvas 头,只带新元素和接收计数,测试增量累积而非整文档替换。
|
||||
const delta=(elements,received,total=4)=>({...head(total),canvases:[],elements,receivedElements:received});
|
||||
|
||||
// 先收 A/E 仍等待,独立 B 可先显示;F 到来才闭合 child/端口/连线并发布合法前缀,旧文档不变,收到全部元素仍不等于 complete。
|
||||
test('partial model waits for entry/owner/port cycles and edge endpoints; publishes one closed legal prefix',()=>{
|
||||
const preview=createModelPreview(registry);const first=preview.update(head());validateDocument(first.document,registry);assert.equal(first.document.canvases.size,1);
|
||||
const second=preview.update(delta([a,edge],2));assert.equal(second.document.elements.size,0);assert.equal(second.waitingElements,2);assert.strictEqual(second.document,first.document);
|
||||
@@ -17,32 +29,38 @@ test('partial model waits for entry/owner/port cycles and edge endpoints; publis
|
||||
const fourth=preview.update(delta([frame],4));validateDocument(fourth.document,registry);assert.equal(fourth.document.elements.size,4);assert.equal(fourth.document.canvases.size,2);assert.equal(fourth.waitingElements,0);assert.equal(fourth.document.elements.get('E').ownerCanvasId,root);
|
||||
assert.equal(first.document.elements.size,0);assert.equal(third.document.elements.size,1);assert.equal(fourth.complete,false);preview.close();assert.throws(()=>preview.update(delta([],4)),{code:'model_preview_closed'});
|
||||
});
|
||||
// 未知组件保持不透明只读候选,不猜测私有引用;已知 frame 声明端口缺目标时必须继续等待。
|
||||
test('declared external reference waits; unsupported component data remains an opaque read-only candidate',()=>{
|
||||
const preview=createModelPreview(registry);preview.update(head(2));const future={...node('future'),componentId:'plugin.future',dataVersion:99,data:{opaque:{ref:'absent',assetId:'secret'}}};
|
||||
const shown=preview.update(delta([future],1,2));assert.deepEqual(shown.document.elements.get('future'),future);assert.equal(registry.resolve(shown.document.elements.get('future')),'unknown-component');
|
||||
const delayed={...node('delayed'),componentId:'frame',data:{title:'delayed',text:'',ports:[{nodeId:'absent'}]}};
|
||||
const next=preview.update(delta([delayed],2,2));assert.equal(next.document.elements.size,1);assert.equal(next.waitingElements,1);
|
||||
});
|
||||
// 同批 good+非法几何/身份/owner 或重复 ID 时整体拒绝,不能先发布 good,也不能替换上一合法快照。
|
||||
test('invalid batches do not replace the last legal prefix or publish a partial batch',()=>{
|
||||
for(const bad of [{...node('bad'),geometry:{...node('bad').geometry,width:-1}},{...node('bad'),incarnation:0},{...node('bad'),ownerCanvasId:'foreign'}]){
|
||||
const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('good'),bad],2,2)));assert.strictEqual(preview.getSnapshot(),first);assert.equal(first.document.elements.size,0);
|
||||
}
|
||||
const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('same'),node('same')],2,2)));assert.strictEqual(preview.getSnapshot(),first);
|
||||
});
|
||||
// 循环/多根 canvas 拓扑和跨 project 的后续输入拒绝,不能混合成同一个预览。
|
||||
test('bad topology and version/source switches cannot silently alter a preview',()=>{
|
||||
for(const topology of [[{id:root,parent:{canvasId:child,entryElementId:'F'}},{id:child,parent:{canvasId:root,entryElementId:'G'}}],[{id:root,parent:null},{id:child,parent:null}]])assert.throws(()=>createModelPreview(registry).update({...head(),canvases:topology}));
|
||||
const preview=createModelPreview(registry);preview.update(head());assert.throws(()=>preview.update({...delta([b],1),projectId:'other'}));
|
||||
});
|
||||
|
||||
// 预览接管元素/头部数据副本,调用者后改不影响已显示内容;最终模型若与前缀不符,报 model_preview_changed。
|
||||
test('owned preview records do not freeze or retain mutable caller payloads; final completion must match',()=>{
|
||||
const preview=createModelPreview(registry),header=structuredClone(head(1)),element=node('one');preview.update(header);const state=preview.update(delta([element],1,1));
|
||||
element.data.text='changed by caller';header.canvases[0].id='changed';assert.equal(state.document.elements.get('one').data.text,'');assert.equal(state.document.canvases.has(root),true);
|
||||
const final=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[element]);assert.throws(()=>preview.update({status:'ready',document:final,versions}),{code:'model_preview_changed'});
|
||||
});
|
||||
// 后续块重复已发布 ID 属于非法输入,并使投影关闭;之后即使给合法新项也不能继续同一预览。
|
||||
test('published identities cannot be repeated in later chunks and invalid input closes the projection',()=>{
|
||||
const preview=createModelPreview(registry);preview.update({...head(2),canvases:[{id:root,parent:null}]});preview.update(delta([node('one')],1,2));assert.throws(()=>preview.update(delta([node('one')],2,2)));assert.throws(()=>preview.update(delta([node('two')],2,2)),{code:'model_preview_closed'});
|
||||
});
|
||||
|
||||
// canvasElementCounts 统计收到记录,不把依赖等待者当已发布;证书先防御复制,单 canvas 数量不符在发布前拒绝。
|
||||
test('scope certificates count received records without treating dependency-blocked members as published',()=>{
|
||||
const preview=createModelPreview(registry);const counts={[root]:3,[child]:1};preview.update({...head(),canvasElementCounts:counts});counts[child]=0;
|
||||
const waiting=preview.update(delta([a,edge],2));assert.equal(waiting.document.canvases.has(child),false);assert.equal(waiting.canvasElementCounts[child],1);assert.equal(waiting.waitingElements,2);
|
||||
@@ -50,6 +68,7 @@ test('scope certificates count received records without treating dependency-bloc
|
||||
const invalid=createModelPreview(registry),first=invalid.update({...head(1),canvasElementCounts:{[root]:0,[child]:1}});assert.throws(()=>invalid.update(delta([b],1,1)),{code:'invalid_model_coverage'});assert.strictEqual(invalid.getSnapshot(),first);
|
||||
});
|
||||
|
||||
// elementOwners 清单包含尚未显示的 ID,按捕获归属校验后续记录;外部改清单无效,未声明 ID 的块不能发布。
|
||||
test('custom loader identity manifests are owned and checked before a receiving batch can publish',()=>{
|
||||
const preview=createModelPreview(registry),elementOwners={F:root,E:root,B:root,A:child};const first=preview.update({...head(),canvasElementCounts:{[root]:3,[child]:1},elementOwners});elementOwners.A=root;assert.equal(first.elementOwners.A,child);assert.deepEqual(first.sourceCanvases,head().canvases);assert.equal(first.document.canvases.has(child),false);assert.ok(Object.isFrozen(first.sourceCanvases));const before=preview.getSnapshot();assert.throws(()=>preview.update(delta([node('not-in-capture')],1)),{code:'invalid_model_identity'});assert.strictEqual(preview.getSnapshot(),before);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Foundry 模型流 framing、完整校验及 transport/loader 协调测试。流可以先交付只读 delta,完整文档必须等终态记录与正常 EOF。
|
||||
* 快照/序号/覆盖/身份清单绑定所有后续记录,错误尾部保留可读前缀但不能打开为权威基线。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,28 +9,39 @@ import {decodeModelStream,modelStreamType} from '../../src/lib/canvas-service/mo
|
||||
import {createFoundryTransport} from '../../src/lib/canvas-service/foundry.ts';
|
||||
import {createFoundryModelLoader} from '../../src/lib/canvas-service/model-loader.ts';
|
||||
import {createScopedLoader} from '../../src/lib/canvas-runtime/loader.ts';
|
||||
// 固定 UUID 作用域,满足 transport 与模型解析的身份约束。
|
||||
const root='10000000-0000-4000-8000-000000000001',workspace='10000000-0000-4000-8000-000000000002',account='10000000-0000-4000-8000-000000000003';
|
||||
// 内置定义和独立信号工厂,避免取消跨用例复用。
|
||||
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal;
|
||||
// 含中文及 emoji 的合法节点样本,验证逐字节传输时 UTF-8 边界处理。
|
||||
const node={id:'a',incarnation:1,ownerCanvasId:root,componentId:'text',dataVersion:1,structure:'node',data:{title:'中文',text:'🙂'},geometry:{x:0,y:0,width:100,height:80,rotation:0}};
|
||||
// 每次新建 header/elements/complete 记录,快照 token 相同、序号连续且声明一个元素;便于逐字段篡改。
|
||||
const records=()=>[{streamVersion:1,snapshotToken:'snapshot-a',sequence:0,kind:'header',documentFormatVersion:1,projectId:root,canvases:[{id:root,parent:null}],versions:{[root]:3},tombstones:{old:2},removedCanvasIds:[],elementCount:1},{streamVersion:1,snapshotToken:'snapshot-a',sequence:1,kind:'elements',elements:[structuredClone(node)]},{streamVersion:1,snapshotToken:'snapshot-a',sequence:2,kind:'complete',elementCount:1}];
|
||||
// 编码为带末尾换行的 UTF-8 NDJSON;byteLength 断言以真实传输字节为准。
|
||||
const encode=rows=>new TextEncoder().encode(rows.map(row=>JSON.stringify(row)).join('\n')+'\n');
|
||||
// 单批字节异步源,不模拟网络切片,逐字节场景使用另一个 split generator。
|
||||
async function* source(rows){yield encode(rows);}
|
||||
// 排空异步迭代器到数组,让尾部或 EOF 校验错误也传播到测试断言。
|
||||
const collect=async iterable=>{const result=[];for await(const item of iterable)result.push(item);return result;};
|
||||
// canonical 完整项目查询作用域,交给通用 scoped loader 检查权限和查询绑定。
|
||||
const scope={accountId:account,workspaceId:workspace,projectId:null,canvasId:root,authEpoch:1,navigationGeneration:1,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry'};
|
||||
|
||||
// 逐字节传入 Unicode/换行,依次输出接收头、元素 delta、最终 ready;正文/墓碑及总 byteLength 完整,记录冻结。
|
||||
test('stream decoder frames split UTF8/newlines, emits deltas and validates one final complete model',async()=>{
|
||||
const bytes=encode(records());async function* split(){for(const byte of bytes)yield Uint8Array.of(byte);}
|
||||
const bytes=encode(records());/* 每批只发一个字节,主动拆开多字节 UTF-8 与记录边界。 */ async function* split(){for(const byte of bytes)yield Uint8Array.of(byte);}
|
||||
const chunks=await collect(decodeModelStream(split(),root,registry,signal()));
|
||||
assert.deepEqual(chunks.map(chunk=>[chunk.sequence,chunk.complete,chunk.value.status]),[[0,false,'receiving'],[1,false,'receiving'],[2,true,'ready']]);
|
||||
assert.equal(chunks[1].value.elements[0].data.text,'🙂');assert.equal(chunks[2].value.document.elements.get('a').data.title,'中文');assert.equal(chunks[2].value.document.tombstones.get('old'),2);assert.equal(chunks.reduce((sum,chunk)=>sum+chunk.byteLength,0),bytes.length);assert.ok(Object.isFrozen(chunks[1].value.elements[0]));
|
||||
});
|
||||
// 快照切换、序号缺口、计数不符、重复 ID、错误 owner/几何/版本/拓扑、缺少结束及缺末换行都不能完成,超大行按流预算拒绝。
|
||||
test('snapshot switches, sequence gaps, duplicate IDs, wrong counts, late records and invalid topology cannot finish',async()=>{
|
||||
const cases=[rows=>rows[1].snapshotToken='other',rows=>rows[1].sequence=2,rows=>rows[0].elementCount=2,rows=>rows[2].elementCount=2,rows=>rows.push({...rows[2],sequence:3}),rows=>rows[1].elements.push(rows[1].elements[0]),rows=>rows[1].elements[0].ownerCanvasId=workspace,rows=>rows[1].elements[0].geometry.width=-1,rows=>rows[0].versions={},rows=>rows[0].canvases[0].parent={canvasId:root,entryElementId:'a'}];
|
||||
for(const mutate of cases){const rows=records();mutate(rows);await assert.rejects(()=>collect(decodeModelStream(source(rows),root,registry,signal())));}
|
||||
for(const rows of [[],records().slice(0,1),records().slice(0,2)])await assert.rejects(()=>collect(decodeModelStream(source(rows),root,registry,signal())));
|
||||
async function* truncated(){yield encode(records()).slice(0,-1);}await assert.rejects(()=>collect(decodeModelStream(truncated(),root,registry,signal())),{code:'incomplete_model_stream'});
|
||||
async function* huge(){yield new Uint8Array(8*1024*1024+1).fill(65);}await assert.rejects(()=>collect(decodeModelStream(huge(),root,registry,signal())),{code:'model_stream_budget'});
|
||||
/* 删除文件末换行,验证终态记录也不能绕过 framing 完整性。 */ async function* truncated(){yield encode(records()).slice(0,-1);}await assert.rejects(()=>collect(decodeModelStream(truncated(),root,registry,signal())),{code:'incomplete_model_stream'});
|
||||
/* 单行超过 8 MiB,没有换行,必须在无限积累前触发预算。 */ async function* huge(){yield new Uint8Array(8*1024*1024+1).fill(65);}await assert.rejects(()=>collect(decodeModelStream(huge(),root,registry,signal())),{code:'model_stream_budget'});
|
||||
});
|
||||
// transport 以 Accept 协商流并交出 ReadableStream;尾部 gate 前 loader 已看到首个元素 delta,放行后才 complete。
|
||||
test('Foundry transport negotiates streaming without buffering; coordinator retains first delta before gated tail',async()=>{
|
||||
let release,opened;const gate=new Promise(resolve=>release=resolve),started=new Promise(resolve=>opened=resolve);let requested;
|
||||
const rows=records(),stream=new ReadableStream({async start(controller){controller.enqueue(encode(rows.slice(0,2)));opened();await gate;controller.enqueue(encode(rows.slice(2)));controller.close();}});
|
||||
@@ -38,29 +53,34 @@ test('Foundry transport negotiates streaming without buffering; coordinator reta
|
||||
release();const done=await handle.ready;assert.equal(done.status,'complete');assert.equal(done.chunks.at(-1).value.document.elements.size,1);
|
||||
}finally{release();handle.release();loader.close();}
|
||||
});
|
||||
// 坏 JSON 尾部使 loader failed-with-partial,保留两个 receiving 块,不能输出权威模型。
|
||||
test('malformed tails retain read deltas without yielding an authoritative model',async()=>{
|
||||
async function* broken(){yield encode(records().slice(0,2));yield new TextEncoder().encode('{broken}\n');}
|
||||
/* 合法头/元素后追加坏 JSON,保留可读前缀并拒绝最终模型。 */ async function* broken(){yield encode(records().slice(0,2));yield new TextEncoder().encode('{broken}\n');}
|
||||
const states=[],loader=createScopedLoader({load:createFoundryModelLoader({workspaceId:workspace,registry,transport:async()=>({status:200,body:null,stream:broken()})}),current:()=>true});const handle=loader.acquire(scope,{onUpdate:value=>states.push(value)});
|
||||
try{await assert.rejects(handle.ready);assert.equal(handle.getSnapshot().status,'failed-with-partial');assert.equal(handle.getSnapshot().chunks.length,2);assert.equal(handle.getSnapshot().chunks.at(-1).value.status,'receiving');}finally{handle.release();loader.close();}
|
||||
});
|
||||
// complete 记录已到但流未结束时 ready 仍未解决;随后垃圾尾行使读取失败,不能先打开基线再反悔。
|
||||
test('terminal record still requires EOF; trailing garbage rejects rather than opening a baseline',async()=>{
|
||||
const gate={},rows=records();let release;gate.promise=new Promise(resolve=>release=resolve);
|
||||
async function* trailing(){yield encode(rows);await gate.promise;yield new TextEncoder().encode('x\n');}
|
||||
/* complete 之后仍阻塞迭代器 EOF,之后追加垃圾,检查 ready 不会过早成功。 */ async function* trailing(){yield encode(rows);await gate.promise;yield new TextEncoder().encode('x\n');}
|
||||
const loader=createScopedLoader({load:createFoundryModelLoader({workspaceId:workspace,registry,transport:async()=>({status:200,body:null,stream:trailing()})}),current:()=>true}),handle=loader.acquire(scope);let ready=false;handle.ready.then(()=>ready=true,()=>{});
|
||||
try{while(handle.getSnapshot().chunks.length<2)await new Promise(resolve=>setImmediate(resolve));assert.equal(ready,false);release();await assert.rejects(handle.ready);assert.equal(handle.getSnapshot().status,'failed-with-partial');}finally{release();handle.release();loader.close();}
|
||||
});
|
||||
// abort 取消阻塞的 body reader;身份失效也拒绝下一次读取并取消底层流,分别核对 cancel 次数。
|
||||
test('stream transport fences changed identity and abort cancels a blocked body reader',async()=>{
|
||||
let current=true,cancelled=0;const abort=new AbortController();const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async()=>new Response(new ReadableStream({cancel(){cancelled++;}}),{headers:{'Content-Type':modelStreamType}})});
|
||||
const reply=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1',signal:abort.signal});const iterator=reply.stream[Symbol.asyncIterator]();const pending=iterator.next();await Promise.resolve();abort.abort();await assert.rejects(pending);assert.equal(cancelled,1);
|
||||
const other=await transport({method:'GET',path:'model',responseFormat:'model-stream-v1'});current=false;await assert.rejects(()=>other.stream[Symbol.asyncIterator]().next(),{code:'identity_changed'});assert.equal(cancelled,2);
|
||||
});
|
||||
|
||||
// 可选 canvasElementCounts 保持旧流兼容;非法清单或后续元素超过单 canvas 声明在发布前拒绝。
|
||||
test('optional snapshot scope counts preserve legacy streams and reject invalid or exceeded coverage before publication',async()=>{
|
||||
const rows=records();rows[0].canvasElementCounts={[root]:1};const seen=await collect(decodeModelStream(source(rows),root,registry,signal()));assert.deepEqual(seen[0].value.canvasElementCounts,{[root]:1});assert.equal(Object.isFrozen(seen[0].value.canvasElementCounts),true);
|
||||
for(const manifest of [null,[],{}, {[root]:-1},{[root]:.5},{[root]:2},{[root]:1,[workspace]:0}]){const broken=records();broken[0].canvasElementCounts=manifest;await assert.rejects(()=>collect(decodeModelStream(source(broken),root,registry,signal())),{code:'invalid_model_coverage'});}
|
||||
const broken=records();broken[0].canvases.push({id:workspace,parent:{canvasId:root,entryElementId:'frame'}});broken[0].versions[workspace]=1;broken[0].canvasElementCounts={[root]:0,[workspace]:1};const iterator=decodeModelStream(source(broken),root,registry,signal())[Symbol.asyncIterator]();assert.equal((await iterator.next()).value.value.receivedElements,0);await assert.rejects(iterator.next(),{code:'invalid_model_coverage'});
|
||||
});
|
||||
|
||||
// elementOwners 声明尚未加载的 ID 与 owner;非法清单或后续未捕获 ID 在发布前拒绝,清单自身冻结。
|
||||
test('identity manifest covers unloaded IDs and binds each received record to its captured owner',async()=>{
|
||||
const rows=records();rows[0].canvasElementCounts={[root]:1};rows[0].elementOwners={a:root};const chunks=await collect(decodeModelStream(source(rows),root,registry,signal()));assert.deepEqual(chunks[0].value.elementOwners,{a:root});assert.ok(Object.isFrozen(chunks[0].value.elementOwners));
|
||||
for(const manifest of [null,[],{}, {other:workspace},{a:root,other:root}]){const broken=structuredClone(rows);broken[0].elementOwners=manifest;await assert.rejects(()=>collect(decodeModelStream(source(broken),root,registry,signal())),{code:'invalid_model_identity'});}
|
||||
|
||||
@@ -1,46 +1,81 @@
|
||||
/**
|
||||
* 本地文件盘点、保留期和回收的引擎测试。是否孤立由所有已知引用及保守覆盖判断,不能仅看资源登记表。
|
||||
* 内存端口声明 atomic-v1 文件删除能力,随镜像保存模拟文件增加/删除;不验证真实文件系统的原子性。
|
||||
* 清理不改变业务文档和历史,退休身份则必须持久保留,防止稍后误用同 ID 的已删除字节。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,applyChanges} from '../../src/lib/canvas-core/index.ts';
|
||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import {decodeRecovery} from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
import {orphanCoverage} from '../../src/lib/canvas-runtime/orphan-files.ts';
|
||||
// 图片目标默认没有 assetId,各场景自行添加引用;文件 Map 可包含尚未登记但仍被元素引用的字节。
|
||||
const registry=createRegistry(builtinComponents()),scope={accountId:'a',workspaceId:'w',projectId:'p'},node=(id,data={})=>({id,incarnation:1,structure:'node',ownerCanvasId:'C',componentId:'image',dataVersion:1,geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...data}}),original=createDocument('p',[{id:'C',parent:null}],[node('A')]);
|
||||
// 用可控 Promise 挂起读字节,模拟显示借用、取消和 Root 变化与回收扫描交错。
|
||||
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
|
||||
// 将文件 Map、业务模型、内存镜像和服务替身组合,保留批次与请求记录用于检查回收是否越界。
|
||||
function fixture(files=new Map(),document=original){
|
||||
let image,owner,next=0,server=document,version=1,now=1000;const batches=[],requests=[],receipts=new Map();
|
||||
const storage={fileDeletion:'atomic-v1',async claim(){assert.equal(owner,undefined);const leader={scope,fence:1,active:true,async release(){owner=undefined;leader.active=false;}};owner=leader;return leader;},async recover(){return image?structuredClone(image):null;},async commit(leader,batch){assert.equal(leader,owner);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);batches.push(batch);return{durableRootRevision:image.rootRevision};},async readFile(leader,id){assert.equal(leader,owner);return files.get(id);},async scanFiles(leader,{expectedRootRevision,after,limit}){assert.equal(leader,owner);if(image.rootRevision!==expectedRootRevision)throw Error('stale_file_inventory');const rows=[...files].sort(([a],[b])=>a<b?-1:a>b?1:0).filter(([id])=>after===undefined||id>after),page=rows.slice(0,limit).map(([id,blob])=>({id,byteLength:blob.size,mimeType:blob.type}));return{files:page,rootRevision:image.rootRevision,...(rows.length>limit?{nextAfter:page.at(-1).id}:{})};},close(){}};
|
||||
const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{C:version}}),async submit(request){requests.push(request);server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['C',++version]])});return{outcome:'applied'};},async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},canonical:request=>receipts.get(request.id)};
|
||||
// commit 在同一替身调用中处理文件和镜像;scanFiles 必须匹配调用者捕获的 rootRevision,并按文件 ID 分页。
|
||||
const storage={fileDeletion:'atomic-v1',
|
||||
// 只允许当前引擎持有文件仓库;释放时同时清除 owner 和 active 标记。
|
||||
async claim(){assert.equal(owner,undefined);const leader={scope,fence:1,active:true,async release(){owner=undefined;leader.active=false;}};owner=leader;return leader;},
|
||||
// 返回克隆恢复镜像,文件字节由独立 files 仓库保留。
|
||||
async recover(){return image?structuredClone(image):null;},
|
||||
// 校验 owner,按批次写入/删除字节后保存镜像;记录整个 batch 供清理断言观察。
|
||||
async commit(leader,batch){assert.equal(leader,owner);for(const file of batch.files??[])files.set(file.id,file.bytes);for(const id of batch.deleteFiles??[])files.delete(id);image=structuredClone(batch.image);batches.push(batch);return{durableRootRevision:image.rootRevision};},
|
||||
// 读取本地 Blob;用例可包装这个方法在真实返回前挂起。
|
||||
async readFile(leader,id){assert.equal(leader,owner);return files.get(id);},
|
||||
// 只在指定持久化修订上盘点;排序后取最多 limit 项,用 nextAfter 表示尚有后页。
|
||||
async scanFiles(leader,{expectedRootRevision,after,limit}){assert.equal(leader,owner);if(image.rootRevision!==expectedRootRevision)throw Error('stale_file_inventory');const rows=[...files].sort(([a],[b])=>a<b?-1:a>b?1:0).filter(([id])=>after===undefined||id>after),page=rows.slice(0,limit).map(([id,blob])=>({id,byteLength:blob.size,mimeType:blob.type}));return{files:page,rootRevision:image.rootRevision,...(rows.length>limit?{nextAfter:page.at(-1).id}:{})};},close(){}};
|
||||
// 普通文档编辑通过文档端口发送并处理规范回执;清理本身不应该产生任何网络文档请求。
|
||||
const port={compile:(intent,id)=>({payload:{intent,id},canvasIds:intent.canvasIds,expectedVersions:{C:version}}),
|
||||
// 应用普通文档修改并保存该请求的规范模型;资源清理场景通过 requests 检查未误入此路径。
|
||||
async submit(request){requests.push(request);server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['C',++version]])});return{outcome:'applied'};},
|
||||
// 只返回此替身保存过的请求结果;没有记录则保持 unresolved。
|
||||
async query(request){return{outcome:receipts.has(request.id)?'applied':'unresolved'};},
|
||||
// 根据请求取 ACK 基线,供无关编辑的正常同步使用。
|
||||
canonical:request=>receipts.get(request.id)};
|
||||
// 复用同一文件仓库与存储镜像以支持重开;advance 只推进保留期墙上时间,网络重试仍使用真实定时器。
|
||||
return{files,batches,requests,storage,advance:ms=>{now+=ms;},image:()=>image,open:()=>openProjectEngine({scope,registry,document,versions:new Map([['C',1]]),storage,persistence:port,createId:()=>`id-${++next}`,clock:{now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)}})};
|
||||
}
|
||||
// 只读盘点按 limit 分页且保持 Root 引用;未登记但被元素引用的文件受保护,只有无引用文件经标记和保留期后删除,文档/history 不变。
|
||||
test('inventory is bounded and read-only; unregistered but referenced files stay protected',async()=>{
|
||||
const doc=createDocument('p',[{id:'C',parent:null}],[node('A',{assetId:'used'})]),f=fixture(new Map([['old',new Blob(['old'])],['used',new Blob(['used'])]]),doc),engine=await f.open();try{await engine.persist();const root=engine.getSnapshot(),first=await engine.inventoryLocalFiles({limit:1});assert.equal(engine.getSnapshot(),root);assert.equal(first.files[0].id,'old');assert.equal(first.files[0].ownership,'unregistered');assert.equal(first.nextAfter,'old');const second=await engine.inventoryLocalFiles({after:first.nextAfter,limit:1});assert.equal(second.files[0].id,'used');assert.ok(second.files[0].pins.some(owner=>owner.startsWith('element:')));assert.equal(second.nextAfter,undefined);assert.equal(f.requests.length,0);
|
||||
const marked=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(marked.markedIds,['old']);assert.deepEqual(marked.purgedIds,[]);assert.equal(f.image().formatVersion,5);f.advance(100);const result=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(result.purgedIds,['old']);assert.equal(f.files.has('old'),false);assert.equal(f.files.has('used'),true);assert.equal(engine.getSnapshot().document,root.document);assert.equal(engine.getSnapshot().history,root.history);assert.equal(f.requests.length,0);decodeRecovery(f.image(),registry);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 退休文件及 v5 镜像重开后仍禁止显示或新增引用,也不能通过清空 orphanFiles 抹去退休证据;无关编辑继续可用,静默降级镜像被拒绝。
|
||||
test('retired orphan identity and v5 recovery survive reopening and reject new references or silent downgrade',async()=>{
|
||||
const f=fixture(new Map([['old',new Blob(['old'])]]));let engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:0});const file=engine.getSnapshot().runtime.orphanFiles.get('old');assert.ok(file.retirement.purgedAt!==undefined);for(const formatVersion of [1,2,3,4])assert.throws(()=>decodeRecovery({...f.image(),formatVersion},registry),/unsupported_recovery_version/);await engine.close();engine=await f.open();await assert.rejects(engine.displayResource('old'),/local_file_retired/);assert.equal(engine.editor.dispatch({type:'element.create',element:node('bad',{assetId:'old'})}).code,'local_file_retired');assert.equal(engine.editor.updateRuntime(previous=>({...previous,orphanFiles:new Map()})).code,'local_file_retired');assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'still editable'}}).status,'applied');await engine.settle();assert.equal(f.files.has('old'),false);
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
// 文件大小与 MIME 相同也可能换了字节;digest 改变必须重置无引用起点,重新等待完整保留期才能删除。
|
||||
test('changed orphan bytes reset the retention interval even when size and MIME stay equal',async()=>{
|
||||
const f=fixture(new Map([['old',new Blob(['aa'])]])),engine=await f.open();try{await engine.collectOrphanFiles({retentionMs:100});const digest=engine.getSnapshot().runtime.orphanFiles.get('old').digest;f.advance(100);f.files.set('old',new Blob(['bb']));const changed=await engine.collectOrphanFiles({retentionMs:100});assert.deepEqual(changed.changedIds,['old']);assert.deepEqual(changed.purgedIds,[]);const record=engine.getSnapshot().runtime.orphanFiles.get('old');assert.notEqual(record.digest,digest);assert.equal(record.retirement.unreferencedAt,1100);f.advance(100);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,['old']);}finally{await engine.close();}
|
||||
});
|
||||
// 显示读取一开始即借用并清除软退休标记,不必等 URL 返回;释放后可重新计时,显式 resourcePins 再次阻止删除。
|
||||
test('display borrowing and explicit owners reset soft markers before collection',async()=>{
|
||||
const f=fixture(new Map([['old',new Blob(['data'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const opening=engine.displayResource('old');await entered.promise;assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);gate.resolve();const handle=await opening;handle.release();await engine.collectOrphanFiles({retentionMs:100});engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map([['export',['old']]])}));assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:0})).purgedIds,[]);}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
// 哈希字节预算耗尽要保留 nextAfter 继续扫描;读字节期间 Root 改变会使验证过期,此轮不能删除任何文件。
|
||||
test('hash budgets preserve pagination progress and a changed Root invalidates read-only verification',async()=>{
|
||||
const f=fixture(new Map([['a',new Blob(['a'])],['b',new Blob(['bb'])],['c',new Blob(['c'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile;try{const first=await engine.collectOrphanFiles({retentionMs:100,maxHashBytes:1});assert.deepEqual(first.markedIds,['a']);assert.equal(first.nextAfter,'a');assert.equal(first.hashBudgetExhausted,true);assert.equal(first.scanFinished,false);const second=await engine.collectOrphanFiles({retentionMs:100,after:first.nextAfter,maxHashBytes:3});assert.deepEqual(second.markedIds,['b','c']);assert.equal(second.scanFinished,true);
|
||||
f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const collecting=engine.collectOrphanFiles({retentionMs:0});await entered.promise;assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'changed while hashing'}}).status,'applied');gate.resolve();await assert.rejects(collecting,/stale_file_inventory/);assert.equal(f.files.size,3);
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
// abort 后迟到字节不得发布盘点结果;删除批次保存失败时保留原清理切点及资源访问屏障,persist 重试完成同一次删除。
|
||||
test('cancelling verification never publishes late byte results and failures retain the original purge cut',async()=>{
|
||||
const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),gate=deferred(),entered=deferred(),read=f.storage.readFile,cancel=new AbortController();let fail=false;const commit=f.storage.commit;try{f.storage.readFile=async(...args)=>{const bytes=await read(...args);entered.resolve();await gate.promise;return bytes;};const task=engine.collectOrphanFiles({retentionMs:0,signal:cancel.signal});await entered.promise;cancel.abort();await assert.rejects(task,/file_inventory_aborted/);gate.resolve();await Promise.resolve();assert.equal(engine.getSnapshot().runtime.orphanFiles?.size??0,0);f.storage.readFile=read;f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk failure');return commit(...args);};fail=true;await assert.rejects(engine.collectOrphanFiles({retentionMs:0}),/disk failure/);assert.ok(f.files.has('old'));await assert.rejects(engine.displayResource('old'),/resource_collection_in_progress/);fail=false;await engine.persist();assert.equal(f.files.has('old'),false);assert.ok(decodeRecovery(f.image(),registry).runtime.orphanFiles.get('old').retirement.purgedAt!==undefined);
|
||||
}finally{fail=false;gate.resolve();await engine.close();}
|
||||
});
|
||||
// 运行任务、恢复审计、未完成导入和复制准入都是不透明生产者;即使已知资源表为空,引用覆盖仍不完整,不能据此回收。
|
||||
test('opaque producers block orphan collection even when the known local resource map is empty',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{const root=engine.getSnapshot(),coverage=runtime=>orphanCoverage({...root,runtime},registry,new Map(),new Map());assert.equal(coverage({...root.runtime,runs:new Map([['run',{id:'run'}]])}).complete,false);assert.ok(coverage({...root.runtime,recoveries:new Map([['audit',{}]])}).blockers.includes('recovery_audits'));assert.ok(coverage({...root.runtime,imports:new Map([['job',{id:'job',status:'paused',source:{fileId:'source'}}]])}).blockers.includes('unfinished_imports'));assert.ok(orphanCoverage(root,registry,new Map(),new Map([['copy-admission',[]]])).blockers.includes('copy_admission'));
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 复制准入在 ImportJob 出现前已构成保护;即使调用带已取消 signal,也保守清除软退休标记,下一次扫描从当前时间重新计时。
|
||||
test('a cancelled copy admission resets orphan retention before any ImportJob is published',async()=>{
|
||||
const f=fixture(new Map([['old',new Blob(['old'])]])),engine=await f.open(),cancel=new AbortController();try{await engine.collectOrphanFiles({retentionMs:100});f.advance(100);cancel.abort();await assert.rejects(engine.imports.duplicate([{id:'A',incarnation:1}],{signal:cancel.signal}),/copy_aborted/);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement,undefined);assert.deepEqual((await engine.collectOrphanFiles({retentionMs:100})).purgedIds,[]);assert.equal(engine.getSnapshot().runtime.orphanFiles.get('old').retirement.unreferencedAt,1100);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
@@ -1,33 +1,55 @@
|
||||
/** Imports compiled ESM directly: no TypeScript stripping or application bundler. */
|
||||
/**
|
||||
* 浏览器直接消费 /package 的编译 ESM:不依赖 TypeScript stripping 或应用 bundler。
|
||||
* 真实测试 module Worker、PNG 解码、SHA-256 及浏览器存储恢复;网络显式阻止,不验证后端写入。
|
||||
*/
|
||||
import {createRegistry,builtinComponents,createDocument} from '/package/modules/canvas-core/index.js';
|
||||
import {openProjectEngine} from '/package/modules/canvas-runtime/index.js';
|
||||
import {createFoundryPersistence} from '/package/modules/canvas-service/index.js';
|
||||
import {createBrowserStorage,createBrowserDigests,createBrowserImageDecoder} from '/package/modules/canvas-runtime/browser.js';
|
||||
// 由浏览器检查入口调用并返回已通过场景的文字;所有断言在实际浏览器能力上执行。
|
||||
export async function run(){
|
||||
// 最小断言在页面端抛错,由外层 runner 读取失败;results 只有对应断言完成后才追加。
|
||||
const assert=(condition,message)=>{if(!condition)throw Error(message);},results=[];
|
||||
// 每次使用随机数据库名,创建生产 storage、digest 和 image 适配器,避免复用其他测试数据库。
|
||||
const name=`headless-package-${crypto.randomUUID()}`,storage=createBrowserStorage({name}),digests=createBrowserDigests(),images=createBrowserImageDecoder();
|
||||
// 独立随机项目和空文档基线;重开时仍使用这组 scope 和同一 storage。
|
||||
const scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:crypto.randomUUID()},registry=createRegistry(builtinComponents()),document=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
|
||||
// 记录 Worker URL 和活跃数;保存原生构造器以便清理时恢复全局对象。
|
||||
const workerUrls=[],NativeWorker=globalThis.Worker;let active=0;
|
||||
globalThis.Worker=class extends NativeWorker{constructor(url,...args){super(url,...args);workerUrls.push(String(url));active++;this.live=true;}terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
|
||||
// 包装真实 Worker 而非模拟消息:构造成功后计数,terminate 只扣减一次,仍调用原生终止方法。
|
||||
globalThis.Worker=class extends NativeWorker{
|
||||
// 先完成原生构造,再记录 URL 与活跃计数;保留正常 Worker 消息和错误行为。
|
||||
constructor(url,...args){super(url,...args);workerUrls.push(String(url));active++;this.live=true;}
|
||||
// 同一实例多次终止只扣减一次计数,避免负数掩盖泄漏;每次仍调用原生 terminate。
|
||||
terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
|
||||
let engine;
|
||||
try{
|
||||
// 用 OffscreenCanvas 生成真实 4×2 PNG,检查编译图片 Worker 的尺寸结果。
|
||||
const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});
|
||||
const dimensions=await images.decode(bytes);assert(dimensions.width===4&&dimensions.height===2,'compiled image Worker dimensions');
|
||||
// 将编译哈希 Worker 结果与浏览器原生 SHA-256 对照,不使用预设摘要跳过实际字节读取。
|
||||
const digest=await digests.digest(bytes),expected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join('');assert(digest===expected,'compiled digest Worker SHA256');
|
||||
// Worker 必须请求发布的 .js 文件,任务结束后活跃数为零;这验证 URL 布局和正常结束时的释放。
|
||||
assert(workerUrls.some(url=>url.endsWith('/image.worker.js'))&&workerUrls.some(url=>url.endsWith('/digest.worker.js')),'Worker URLs did not resolve emitted JS');assert(active===0,'compiled Workers leaked');
|
||||
results.push('compiled image and digest module Workers load emitted JS, decode a real PNG, match native SHA256 and terminate');
|
||||
// 再验证 16 MiB 数据摘要及释放;耗时仅记为本机观察值,没有性能 SLA 断言。
|
||||
const largeBytes=new Blob([new Uint8Array(16*1024*1024).fill(97)]),largeExpected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await largeBytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join(''),started=performance.now(),largeDigest=await digests.digest(largeBytes),digestMs=performance.now()-started;assert(largeDigest===largeExpected&&active===0,'large compiled digest returned wrong bytes or retained its Worker');results.push('16 MiB compiled digest Worker matched SHA256 in '+digestMs.toFixed(1)+' ms (observation, not an SLA)');
|
||||
// Keep networking explicitly held: verify the actual browser durability adapter
|
||||
// independently of an invented backend protocol implementation.
|
||||
// 显式禁止网络派发,单独验证实际浏览器持久化适配器,避免引入虚构后端协议。
|
||||
// 借用真实编译端口但声明不支持任何请求,因此编辑只保存在本地;若意外进入网络就直接失败。
|
||||
const persistence={...createFoundryPersistence({registry,rootId:scope.projectId,workspaceId:scope.workspaceId,transport:async()=>{throw Error('unexpected network');}}),supportsRequest:()=>false};
|
||||
// 以相同 scope 重新装配引擎,触发真实浏览器存储的 owner 申请和镜像恢复。
|
||||
const open=()=>openProjectEngine({scope,registry,document,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:()=>crypto.randomUUID()});
|
||||
engine=await open();
|
||||
// 创建一次文本元素和一个临时视图,前者及历史应恢复,后者必须随旧会话消失。
|
||||
const target={id:crypto.randomUUID(),incarnation:1};
|
||||
assert(engine.editor.dispatch({type:'element.create',element:{...target,ownerCanvasId:scope.projectId,componentId:'text',dataVersion:1,structure:'node',data:{title:'Packed recovery',text:'persisted'},geometry:{x:12,y:24,width:160,height:80,rotation:0}}}).status==='applied','compiled command rejected');
|
||||
engine.sessions.attach('temporary-view');
|
||||
// 等待本地保存后关闭/重开,检查修订不回退、文档和一个 undo 单元保留、views 为空。
|
||||
await engine.settle();const revision=engine.getSnapshot().rootRevision;await engine.close();engine=await open();
|
||||
assert(engine.getSnapshot().rootRevision>=revision,'compiled IndexedDB recovery revision regressed');assert(engine.getSnapshot().document.elements.get(target.id)?.data.text==='persisted','compiled durable document lost');assert(engine.getSnapshot().history.undo.length===1,'compiled durable history lost');assert(engine.sessions.getSnapshot().views.size===0,'transient sessions persisted');await engine.settle();
|
||||
results.push('compiled browser storage and engine claim/release Web Locks and recover an edited document/history from IndexedDB without persisting views');
|
||||
// 仅在全部 Worker 和存储断言通过后返回场景结果。
|
||||
return results;
|
||||
// 清理当前引擎和适配器,恢复原生 Worker,并发起删除测试数据库;这里不等待 deleteDatabase 的完成事件。
|
||||
}finally{await engine?.close();images.close();digests.close();storage.close();globalThis.Worker=NativeWorker;indexedDB.deleteDatabase(name);}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 发布包消费测试入口。先执行 build-headless 生成 .headless-dist,再离线 npm pack 到临时目录,从解包产物验证 Node、类型和浏览器入口。
|
||||
* 执行会触发编译/打包及浏览器检查,不是 node:test 的轻量单测;最后清理临时消费者目录,构建目录仍由构建脚本管理。
|
||||
* 内嵌模板是待生成的独立消费者源码,注释在各模板外说明,避免改变测试输入本身。
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile, mkdir, symlink } from 'node:fs/promises';
|
||||
@@ -5,17 +10,25 @@ import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// 以脚本路径定位仓库根,避免调用者 cwd 不同导致打包错误目录。
|
||||
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..');
|
||||
// 临时目录先解析真实路径,后面的包内路径守卫据此判定 import 是否越出解包根。
|
||||
const temporary=await realpath(await mkdtemp(join(tmpdir(),'endoleap-headless-package-')));
|
||||
// 同步运行子步骤,默认在仓库根且捕获输出;options 可指定消费者 cwd 和独立 npm cache,失败交给外层统一报告。
|
||||
const run=(program,args,options={})=>execFileSync(program,args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','pipe'],...options});
|
||||
try {
|
||||
// 先编译真正发布产物;后面的测试都消费该产物,不能用源文件 TypeScript stripping 冒充包可用。
|
||||
run(process.execPath,['scripts/build-headless.mjs']);
|
||||
// npm pack 禁用生命周期脚本、使用离线模式并写入临时目录;这验证已有构建产物的包装,不触发额外安装。
|
||||
const packed=JSON.parse(run('npm',['pack','--offline','--ignore-scripts','--json','--pack-destination',temporary],{cwd:join(root,'.headless-dist'),env:{...process.env,npm_config_cache:join(temporary,'npm-cache')}}));
|
||||
assert.equal(packed.length,1);
|
||||
// 解包唯一 tarball,后续 packageRoot 代表实际发布文件布局。
|
||||
run('tar',['-xzf',join(temporary,packed[0].filename),'-C',temporary]);
|
||||
const packageRoot=join(temporary,'package');
|
||||
// 读取打包后的 package.json 与 README,检查私有 ESM 包、无直接运行时依赖,以及可选框架 peer 元数据。
|
||||
const metadata=JSON.parse(await readFile(join(packageRoot,'package.json'),'utf8'));
|
||||
const readme=await readFile(join(packageRoot,'README.md'),'utf8');
|
||||
// 逐个检查 README 的相对 .md 链接可在包内读取且非空;外部 URL 不在此处做网络可达性验证。
|
||||
for(const match of readme.matchAll(/\]\(([^)]+\.md)\)/g)){
|
||||
if(/^[a-z]+:/i.test(match[1]))continue;
|
||||
assert.ok((await readFile(join(packageRoot,match[1]),'utf8')).length>0,`packed README link is empty: ${match[1]}`);
|
||||
@@ -23,7 +36,8 @@ try {
|
||||
assert.equal(metadata.private,true);assert.equal(metadata.type,'module');
|
||||
assert.equal(Object.keys(metadata.dependencies??{}).length,0);
|
||||
assert.deepEqual(Object.keys(metadata.peerDependencies??{}).sort(),['@xyflow/react','react','react-dom']);assert.ok(Object.values(metadata.peerDependenciesMeta).every(value=>value.optional===true));
|
||||
// Resolve every import through the packed artifact, never checkout node_modules.
|
||||
// portable 消费者的非内建 import 必须来自解包产物,不能偷偷借用仓库 node_modules。
|
||||
// 生成 ESM resolve 钩子:node: 内建模块放行,其他解析结果必须是 packageRoot 内的 file URL。
|
||||
await writeFile(join(temporary,'guard-loader.mjs'),`
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {sep} from 'node:path';
|
||||
@@ -35,14 +49,17 @@ try {
|
||||
return result;
|
||||
}
|
||||
`);
|
||||
// 生成 Node 启动守卫:注册路径钩子,并将 window/document/navigator/IndexedDB/Worker 读取设为抛错 getter。
|
||||
await writeFile(join(temporary,'guard.mjs'),`
|
||||
import {register} from 'node:module';
|
||||
register('./guard-loader.mjs',import.meta.url);
|
||||
for(const name of ['window','document','navigator','indexedDB','Worker'])Object.defineProperty(globalThis,name,{configurable:true,get(){throw Error('Browser global: '+name);}});
|
||||
`);
|
||||
// 在独立进程运行打包 consumer.mjs;portable、重开和元素数由消费者真实执行结果断言,不从源码文本推断。
|
||||
const report=JSON.parse(run(process.execPath,['--import',join(temporary,'guard.mjs'),join(packageRoot,'consumer.mjs')],{cwd:temporary}));
|
||||
assert.equal(report.status,'ok');assert.equal(report.portableBindings,true);assert.equal(report.reopened,true);assert.equal(report.elements,2);
|
||||
// No DOM library: browser adapter declarations must not leak into portable ports.
|
||||
// 不加载 DOM 类型库,验证 portable 端口的公开声明没有泄漏浏览器专属类型。
|
||||
// 生成不带 DOM 的 TS 消费者:检查 loading v2-v6、导入可用性、部分查询、场景投影/命中和 StoragePort 装配的公开类型。
|
||||
await writeFile(join(packageRoot,'consumer-types.mts'),`
|
||||
import {createRegistry,builtinComponents,createDocument} from '@endoleap/headless-canvas/core';
|
||||
import {openProjectEngine,type StoragePort,type StorageScope,type LoadingState} from '@endoleap/headless-canvas/runtime';
|
||||
@@ -71,13 +88,15 @@ try {
|
||||
return openProjectEngine({scope,storage,registry,document:createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]),versions:new Map([[scope.projectId,1]]),persistence:createFoundryPersistence({registry,transport,rootId:scope.projectId,workspaceId:scope.workspaceId})});
|
||||
}
|
||||
`);
|
||||
// 仅使用 ES2022 与 Node 类型进行 noEmit 检查,防止 portable 端口意外依赖浏览器声明。
|
||||
run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022','--types','node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'consumer-types.mts')],{cwd:temporary});
|
||||
// A separate consumer opts into framework peers. The portable guard above
|
||||
// remains strict and has already proved /bindings does not resolve any peers.
|
||||
// 框架 peers 由第二个消费者显式接入;上面的路径守卫保持严格,/bindings 不能依赖这些 peers。
|
||||
// 另一个显式启用框架的消费者才连接 React/ReactFlow peers,前面的严格 portable 检查已独立完成。
|
||||
await mkdir(join(packageRoot,'node_modules/@xyflow'),{recursive:true});
|
||||
for(const peer of ['react','react-dom','@xyflow/react'])await symlink(await realpath(join(root,'node_modules',peer)),join(packageRoot,'node_modules',peer),'dir');
|
||||
await mkdir(join(packageRoot,'node_modules/@types'),{recursive:true});
|
||||
for(const peer of ['react','react-dom'])await symlink(await realpath(join(root,'node_modules/@types',peer)),join(packageRoot,'node_modules/@types',peer),'dir');
|
||||
// 生成 SSR 消费者:HookView 读取公开 hooks,View 取得 renderer 命令 context;实际渲染并派发一次命令检查绑定。
|
||||
await writeFile(join(packageRoot,'react-consumer.mjs'),`
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';
|
||||
@@ -93,7 +112,9 @@ try {
|
||||
const html=renderToStaticMarkup(React.createElement(CanvasRendererCommandsContext.Provider,{value:commands},React.createElement(renderers.nodeTypes.fixture)));
|
||||
assert.equal(html,'<span>peer renderer</span>');assert.strictEqual(seen,commands);seen.dispatch({type:'element.data',target:{id:'fixture',incarnation:1},patch:{title:'command'}});assert.equal(dispatched,1);console.log('PASS explicit React peer renderer/context consumer');
|
||||
`);
|
||||
// 运行实际解包后的 React 入口,显示该独立消费者输出。
|
||||
const react=run(process.execPath,[join(packageRoot,'react-consumer.mjs')],{cwd:temporary});process.stdout.write(react);
|
||||
// 生成框架 TS 消费者:Hooks 检查选择器、actions 和资源/操作视图,Card/Link 分别验证公开节点及边投影类型。
|
||||
await writeFile(join(packageRoot,'react-consumer-types.mts'),`
|
||||
import {createElement} from 'react';import type {NodeProps,EdgeProps} from '@xyflow/react';
|
||||
import {createReactRenderers,type ReactFlowProjectionNode,type ReactFlowProjectionEdge} from '@endoleap/headless-canvas/react-flow';
|
||||
@@ -105,15 +126,20 @@ try {
|
||||
function Link({data}:EdgeProps<ReactFlowProjectionEdge>){return createElement('span',null,data?.__headlessRecord?.id);}
|
||||
export const adapter=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'card',render:Card},{componentId:'connection',dataVersion:1,structure:'connection',rendererId:'link',render:Link}]);
|
||||
`);
|
||||
// 框架消费者单独启用 DOM 和 React 类型;这不能替代前面不带 DOM 的 portable 检查。
|
||||
run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022,DOM,DOM.Iterable','--types','react,react-dom,node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'react-consumer-types.mts')],{cwd:temporary});
|
||||
|
||||
// 继续执行浏览器包消费场景,直接加载编译 ESM 与 Worker、IndexedDB/Web Locks,独立验证浏览器适配器。
|
||||
const browser=run(process.execPath,['tests/headless/browser-storage.mjs','--package']);
|
||||
process.stdout.write(browser);
|
||||
// 只有所有消费和类型检查完成后才输出整体报告;packedFiles 取自实际 npm pack 结果。
|
||||
console.log(JSON.stringify({relativeDocumentation:true,compiledBrowserAdapters:true,portableBindings:true,explicitReactPeers:true,status:'ok',packedFiles:packed[0].files.length,consumer:report,nodeTypes:true,browserGlobalsBlocked:true,externalRuntimeDependenciesBlocked:true}));
|
||||
// 保留子进程 stdout/stderr 便于定位失败,再抛出原错误使入口失败。
|
||||
} catch(error){
|
||||
if(error.stdout)process.stderr.write(error.stdout);
|
||||
if(error.stderr)process.stderr.write(error.stderr);
|
||||
throw error;
|
||||
} finally {
|
||||
// 成功或失败都删除临时消费者目录;不在这里删除仓库的 .headless-dist。
|
||||
await rm(temporary,{recursive:true,force:true});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
/**
|
||||
* 指针捕获辅助函数的对象替身测试,不分派真实 DOM PointerEvent。
|
||||
* 指针 ID 与捕获目标共同决定归属,旧 lostpointercapture 不能取消新目标或重新捕获后的手势。
|
||||
*/
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from '../../src/lib/canvas-bindings/pointer-capture.ts';
|
||||
// move/up/cancel 检查 pointerId;lostpointercapture 还检查事件 target 与当前捕获状态,空 owner 不匹配。
|
||||
test('pointer move/up/cancel only match their owner and old lost-capture cannot cancel a replacement target',()=>{const capture={},other={},owner={pointerId:7,capture};for(const type of ['pointermove','pointerup','pointercancel']){assert.equal(ownsPointerCapture(owner,{pointerId:8,type}),false);assert.equal(ownsPointerCapture(owner,{pointerId:7,type}),true);}assert.equal(ownsPointerCapture(owner,{pointerId:7,type:'lostpointercapture',target:other}),false);assert.equal(ownsPointerCapture(owner,{pointerId:7,type:'lostpointercapture',target:capture}),true);assert.equal(ownsPointerCapture(null,{pointerId:7}),false);const recaptured={hasPointerCapture:()=>true};assert.equal(ownsPointerCapture({pointerId:7,capture:recaptured},{pointerId:7,type:'lostpointercapture',target:recaptured}),false);});
|
||||
// 成功捕获只释放一次;节点脱离导致的 set/hasPointerCapture 异常作为捕获失败或安全清理处理,不升级为业务错误。
|
||||
test('failed capture and detached release stay transport failures, while a held capture releases once',()=>{let held=false,releases=0;const target={setPointerCapture(){held=true;},hasPointerCapture(){return held;},releasePointerCapture(){held=false;releases++;}};assert.equal(acquirePointerCapture(target,1),true);releasePointerCapture(target,1);releasePointerCapture(target,1);assert.equal(releases,1);assert.equal(acquirePointerCapture({...target,setPointerCapture(){throw Error('detached');}},2),false);assert.doesNotThrow(()=>releasePointerCapture({...target,hasPointerCapture(){throw Error('detached');}},2));});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/** Compile and exercise the actual Next route in a disposable frontend project. */
|
||||
/**
|
||||
* 在临时前端项目中编译实际 Next API route,连接本地 HTTP 上游替身,检查真实代理路径。
|
||||
* 执行本脚本会启动 Next dev 并产生构建文件;不是纯静态检查,也不是远程 Foundry 数据库集成。
|
||||
* 覆盖冻结字节、认证头、资产元数据/上传、AI 协议与事件游标、流式首块,以及路径 allowlist。
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdtemp, mkdir, copyFile, writeFile, symlink, rm } from 'node:fs/promises';
|
||||
@@ -7,11 +11,17 @@ import { tmpdir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { setTimeout as pause } from 'node:timers/promises';
|
||||
|
||||
// 临时目录只复制代理必需文件,received 保存上游实际收到的请求,避免只验证前端 fetch 参数。
|
||||
const source=resolve(import.meta.dirname,'../..'),directory=await mkdtemp(join(tmpdir(),'endoleap-proxy-check-')),received=[];
|
||||
// 测试专用账户和幂等键都是固定假值,不读取真实用户登录凭据。
|
||||
const account='10000000-0000-4000-8000-000000000004',key='90000000-0000-4000-8000-000000000002';
|
||||
// 响应特意保留空白和 extension,要求代理把终态回执原字节透传而非重建 JSON。
|
||||
const responseBytes=' { "protocol_version":2, "request_id":"'+key+'", "status":"not-applied", "terminal":true, "error":"fixture_rejection", "extension":{"retained":true} }\n';
|
||||
// 流尾由测试释放,首块可先到客户端;用来判断代理是否缓冲了整条上游响应。
|
||||
let streamRelease;const streamGate=new Promise(resolve=>streamRelease=resolve);
|
||||
// 仅用于代理传输的最小 NDJSON 片段,不是完整模型解码样本。
|
||||
const streamPrefix='{"streamVersion":1,"kind":"header"}\n',streamTail='{"streamVersion":1,"kind":"complete"}\n';
|
||||
// 上游先收集 body 和 headers;模型流先写前缀再等待,其余路径返回可辨识 JSON 或 409 回执。
|
||||
const upstream=createServer(async(request,response)=>{
|
||||
const chunks=[];for await(const chunk of request)chunks.push(chunk);
|
||||
received.push({path:request.url,body:Buffer.concat(chunks),headers:request.headers});
|
||||
@@ -19,18 +29,25 @@ const upstream=createServer(async(request,response)=>{
|
||||
response.writeHead(request.url.endsWith('/model/operations')?409:200,{'Content-Type':'application/json'});
|
||||
response.end(request.url.endsWith('/model/operations')?responseBytes:'{"id":"fixture"}');
|
||||
});
|
||||
// 系统分配上游监听端口;另一个短暂监听器只为 Next 选端口,释放后再启动 Next,未构成持续端口锁。
|
||||
await new Promise(resolve=>upstream.listen(0,'127.0.0.1',resolve));
|
||||
const reservation=createServer();await new Promise(resolve=>reservation.listen(0,'127.0.0.1',resolve));const port=reservation.address().port;await new Promise(resolve=>reservation.close(resolve));
|
||||
// 保存子进程、有限长度启动日志和 spawn 错误,供 readiness 失败时定位原因。
|
||||
let next,logs='',launchError;
|
||||
try{
|
||||
// 复制实际 route 及它依赖的响应/会话模块;临时页面仅满足 Next 项目结构,不替换被测 route。
|
||||
for(const filename of ['package.json','tsconfig.json','src/app/api/foundry/[...path]/route.ts','src/lib/canvas-response.ts','src/lib/session-cookie.ts']){
|
||||
const target=join(directory,filename);await mkdir(resolve(target,'..'),{recursive:true});await copyFile(join(source,filename),target);
|
||||
}
|
||||
// 复用当前依赖树而不重新安装,临时项目的源码和编译目录与主项目分开。
|
||||
await symlink(join(source,'node_modules'),join(directory,'node_modules'));
|
||||
await writeFile(join(directory,'src/app/layout.tsx'),'export default function Layout({children}:{children:React.ReactNode}){return <html><body>{children}</body></html>}');
|
||||
await writeFile(join(directory,'src/app/page.tsx'),'export default function Page(){return <main>Proxy verification fixture</main>}');
|
||||
// 显式运行 webpack 开发服务器并指向本地上游,detached 便于 finally 按进程组清理。
|
||||
next=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),'dev','--webpack','--hostname','127.0.0.1','--port',String(port)],{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:'fixture'},stdio:['ignore','pipe','pipe']});
|
||||
// 只保留最后 12000 字符日志;启动失败和退出必须被 readiness 循环识别。
|
||||
next.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-12000)});next.on('error',error=>{launchError=error});
|
||||
// 最多 90 次探测,单次请求超时 1.5 秒、失败间隔 250 毫秒;只有实际代理返回 200 才开始断言。
|
||||
let ready=false;
|
||||
for(let attempt=0;attempt<90;attempt++){
|
||||
if(launchError)throw launchError;if(next.exitCode!==null)throw new Error('Next exited during startup:\n'+logs);
|
||||
@@ -38,22 +55,28 @@ try{
|
||||
await pause(250);
|
||||
}
|
||||
if(!ready)throw new Error('Next did not become ready:\n'+logs);
|
||||
// 规范文档操作路径使用固定 workspace/material,后续从它推导资产及 AI 路由。
|
||||
const path='workspaces/10000000-0000-4000-8000-000000000003/materials/10000000-0000-4000-8000-000000000001/canvas/model/operations';
|
||||
// More than the generic 1 MiB proxy cap, below the canonical 5 MiB cap.
|
||||
// 1.1 MB 请求超过通用 1 MiB 上限但低于规范文档专用 5 MiB 上限,专门覆盖该路由的体积策略。
|
||||
const body=' {\n "protocol_version":2,"request_id":"'+key+'","fixture":"'+ 'x'.repeat(1100000)+'"\n} ';
|
||||
// 发送超过通用限制但低于文档专用限制的原始 body,核对上游状态码及终态回执字节不变。
|
||||
const response=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}`,{method:'POST',body,headers:{'Content-Type':'application/json','Idempotency-Key':key,'X-Expected-Account':account,Cookie:'foundry_session=fixture-session',Origin:`http://127.0.0.1:${port}`},signal:AbortSignal.timeout(20000)});
|
||||
assert.equal(response.status,409);assert.equal(await response.text(),responseBytes);
|
||||
// 从上游记录检查 body、幂等键、预期账户,以及 session cookie 转成 Bearer 的实际结果。
|
||||
const request=received.find(item=>item.path.endsWith('/model/operations'));
|
||||
assert.ok(request);assert.equal(request.body.toString(),body);assert.equal(request.headers['idempotency-key'],key);assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers.authorization,'Bearer fixture-session');
|
||||
// 资产回执和元数据是 JSON 路径;元数据还检查身份传递与 no-store,避免误走图片 body 验证。
|
||||
const receiptPath=`workspaces/10000000-0000-4000-8000-000000000003/assets/operations/${key}`;
|
||||
const assetReceipt=await fetch(`http://127.0.0.1:${port}/api/foundry/${receiptPath}`);assert.equal(assetReceipt.status,200);assert.deepEqual(await assetReceipt.json(),{id:'fixture'});
|
||||
const metadata=await fetch(`http://127.0.0.1:${port}/api/foundry/${receiptPath.replace('/operations/','/metadata/')}`,{headers:{'X-Expected-Account':account,Cookie:'foundry_session=fixture-session'}});assert.equal(metadata.status,200);assert.deepEqual(await metadata.json(),{id:'fixture'});assert.match(metadata.headers.get('cache-control'),/no-store/);const metadataRequest=received.find(item=>item.path.includes('/assets/metadata/'));assert.equal(metadataRequest.headers['x-expected-account'],account);assert.equal(metadataRequest.headers.authorization,'Bearer fixture-session');
|
||||
console.log('PASS asset metadata JSON uses the actual authenticated Next proxy without image-body validation or caching');
|
||||
// FormData 自带 boundary,检查代理保留 multipart 字节及 X-Resource-Id,而不自行重新编码文件。
|
||||
const multipart=new FormData();multipart.append('image',new Blob(['immutable file'],{type:'image/png'}),'fixture.png');
|
||||
const assetPath=receiptPath.split('/operations/')[0],resourceId='10000000-0000-4000-8000-000000000006';
|
||||
const uploaded=await fetch(`http://127.0.0.1:${port}/api/foundry/${assetPath}`,{method:'POST',body:multipart,headers:{'X-Resource-Id':resourceId,'Idempotency-Key':key,'X-Expected-Account':account}});await uploaded.text();assert.equal(uploaded.status,200);
|
||||
const upload=received.find(item=>item.path.endsWith('/assets'));assert.equal(upload.headers['x-resource-id'],resourceId);assert.equal(upload.headers['idempotency-key'],key);assert.match(upload.body.toString(),/immutable file/);assert.match(upload.headers['content-type'],/^multipart\/form-data; boundary=/);
|
||||
console.log('PASS asset receipt JSON and multipart resource identity cross the actual Next proxy');
|
||||
// AI 受理同样保留冻结 JSON 空白、幂等键和 If-Match,并协商 canvas v2;查询回执与事件游标单独检查。
|
||||
const aiPath=path.replace('/model/operations','/ai/runs');
|
||||
const frozenAI=' {\n "protocol_version":2,"request_id":"'+key+'","prompt":"保留请求字节" } ';
|
||||
const aiResponse=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}`,{method:'POST',body:frozenAI,headers:{'Content-Type':'application/json','Idempotency-Key':key,'If-Match':'"7"','X-Expected-Account':account,Cookie:'foundry_session=fixture-session'}});
|
||||
@@ -65,15 +88,19 @@ try{
|
||||
const aiEvents=await fetch(`http://127.0.0.1:${port}/api/foundry/${aiPath}/${key}?after_seq=41&limit=100`);await aiEvents.text();assert.equal(aiEvents.status,200);
|
||||
assert.ok(received.some(item=>item.path.endsWith('/ai/runs/'+key+'?limit=100&after_seq=41')));
|
||||
console.log('PASS frozen AI bytes, identity, version header, admission receipt and event cursor cross the actual Next proxy');
|
||||
// 上游尾部仍挂起时读取第一块,随后释放并核对余下字节,验证端到端增量转发。
|
||||
const streamed=await fetch(`http://127.0.0.1:${port}/api/foundry/${path.replace('/operations','')}`,{headers:{Accept:'application/vnd.foundry.canvas-model+ndjson;version=1'}});
|
||||
assert.equal(streamed.headers.get('content-type'),'application/vnd.foundry.canvas-model+ndjson;version=1');
|
||||
const reader=streamed.body.getReader(),first=await reader.read();assert.equal(new TextDecoder().decode(first.value),streamPrefix);streamRelease();let rest='';while(true){const next=await reader.read();if(next.done)break;rest+=new TextDecoder().decode(next.value);}assert.equal(rest,streamTail);reader.releaseLock();
|
||||
console.log('PASS negotiated canonical NDJSON and first chunk cross the actual Next proxy before the gated tail');
|
||||
// 多余路径段应在代理 allowlist 被拒绝,不能发到上游再依赖上游返回错误。
|
||||
const before=received.length,invalid=await fetch(`http://127.0.0.1:${port}/api/foundry/${path}/unexpected/extra`,{signal:AbortSignal.timeout(10000)});await invalid.text();assert.equal(invalid.status,404);assert.equal(received.length,before);
|
||||
console.log('PASS actual Next proxy preserves frozen request bytes/key/account and raw terminal receipt above 1 MiB');
|
||||
console.log('PASS canonical route allowlist rejects unexpected paths without contacting the upstream');
|
||||
}finally{
|
||||
// 清理时先放开挂起流,再终止 Next 进程组;超时未退出则强制结束。
|
||||
streamRelease();
|
||||
if(next?.pid){const exited=new Promise(resolve=>{if(next.exitCode!==null)resolve();else next.once('exit',resolve)});try{process.kill(-next.pid,'SIGTERM')}catch{}await Promise.race([exited,pause(3000)]);if(next.exitCode===null){try{process.kill(-next.pid,'SIGKILL')}catch{}}}
|
||||
// 断开上游残留连接并关闭监听,最后删除本次临时项目目录。
|
||||
upstream.closeAllConnections();await new Promise(resolve=>upstream.close(resolve));await rm(directory,{recursive:true,force:true,maxRetries:5});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
/**
|
||||
* CommitCoordinator 唯一发布边界测试。业务候选 Root 和 prepareRuntime 的结果必须一起校验、一起发布。
|
||||
* 拒绝准备或返回 Promise 都不能留下半更新的文档、history、runtime 或查询索引。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,createEditor} from '../../src/lib/canvas-core/index.ts';
|
||||
// 固定单元素文档供命令、撤销、重做和规范替换共用,以比较发布前后的引用和订阅次数。
|
||||
const registry=createRegistry(builtinComponents()),record={id:'A',incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:'P',data:{title:'A',text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}},document=createDocument('project',[{id:'P',parent:null}],[record]);
|
||||
// 五类合法发布都会先准备 runtime;准备函数随后抛错时,命令、undo、元数据修改和 reconcile 均被拒绝,原 Root 与查询对象不变。
|
||||
test('coordinator prepares runtime atomically for commands, history, metadata and canonical replacement',()=>{
|
||||
let nextId=0,reject=false,calls=0;const editor=createEditor({document,registry,runtime:{prepared:0},createId:()=>`op${++nextId}`,prepareRuntime:(previous,next)=>{calls++;assert.equal(previous,editor.getSnapshot());assert.ok(Object.isFrozen(next));if(reject)throw Error('reserved resource');return Object.freeze({...next.runtime,prepared:previous.runtime.prepared+1});}});
|
||||
|
||||
// 订阅回调同时检查 runtime 准备序号和 query.document,确保订阅者观察到的是同一发布切点。
|
||||
const snapshots=[];editor.subscribe((_,snapshot)=>{assert.equal(snapshot.runtime.prepared,snapshots.length+1);assert.equal(editor.getQueries().document,snapshot.document);snapshots.push(snapshot);});
|
||||
assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'new'}}).status,'applied');assert.equal(editor.history.undo().status,'applied');assert.equal(editor.history.redo().status,'applied');assert.equal(editor.updateRuntime(previous=>({...previous,extra:true})).status,'applied');const source=editor.getSnapshot();assert.equal(editor.reconcile(document,source.runtime,source.rootRevision).status,'applied');assert.equal(calls,5);
|
||||
|
||||
// 先保留 Root 和查询引用,再让各入口触发准备失败;不仅比较字段值,还要求未发布任何新对象。
|
||||
reject=true;const root=editor.getSnapshot(),query=editor.getQueries();for(const operation of [()=>editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'blocked'}}),()=>editor.history.undo(),()=>editor.updateRuntime(()=>({prepared:100})),()=>editor.reconcile(source.document,source.runtime,root.rootRevision)]){assert.equal(operation().status,'rejected');assert.equal(editor.getSnapshot(),root);assert.equal(editor.getQueries(),query);}assert.equal(snapshots.length,5);
|
||||
});
|
||||
// 本地发布要求同步完成,Promise 形式的 prepareRuntime 返回 async_transaction,三个入口都不得改变原 Root。
|
||||
test('an async coordinator cannot publish a partially prepared root',()=>{
|
||||
const editor=createEditor({document,registry,runtime:0,prepareRuntime:()=>Promise.resolve(1)}),root=editor.getSnapshot();assert.equal(editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'blocked'}}).code,'async_transaction');assert.equal(editor.updateRuntime(()=>1).code,'async_transaction');assert.equal(editor.reconcile(document,1,root.rootRevision).code,'async_transaction');assert.equal(editor.getSnapshot(),root);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 供真实浏览器页面调用的 React hooks 场景。组件挂在隐藏容器中,在 StrictMode 下检查订阅路由、手势租约和资源句柄。
|
||||
* engine 与 hooks 使用生产实现,存储、上传和文档服务为内存替身;此处不验证远程授权、数据库或图片解码。
|
||||
* 断言通过页面全局入口返回文字报告,由外层浏览器 runner 收集;不会在组件导入时自动运行全部场景。
|
||||
*/
|
||||
"use client";
|
||||
import {StrictMode,memo,useEffect,useLayoutEffect} from 'react';
|
||||
import {createRoot} from 'react-dom/client';import {flushSync} from 'react-dom';
|
||||
@@ -5,54 +10,93 @@ import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBinding
|
||||
import {createRegistry,builtinComponents,createDocument,applyChanges} from '@/lib/canvas-core/index';
|
||||
import {openProjectEngine} from '@/lib/canvas-runtime/engine';
|
||||
import {equal} from '@/lib/canvas-core/values';
|
||||
// 浏览器断言失败直接抛错,使外层 Runtime.evaluate 得到失败;不把失败写成通过报告。
|
||||
const assert=(value:unknown,message:string)=>{if(!value)throw Error(message);};
|
||||
// 短暂等待浏览器计时器;until 最多轮询 1000 次,观察 React effect/订阅结果,而非假设 flush 后同步可读。
|
||||
const tick=()=>new Promise(resolve=>setTimeout(resolve,5));async function until(check:()=>boolean){for(let i=0;i<1000;i++){if(check())return;await tick();}throw Error('hook observation timed out');}
|
||||
// 装配两个节点和两个 view;partial 模式只发布 a,但完整来源仍含 b,供 unloaded→present/absent 检查。
|
||||
async function fixture(partial=false){
|
||||
// 合法文本节点外壳;版本、身份和 owner 固定,序列号另用于命令/请求 ID。
|
||||
const node=(id:string)=>({id,incarnation:1,structure:'node' as const,componentId:'text',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:100,height:80,rotation:0},data:{title:id,text:''}});let server=createDocument(partial?'canvas':'hooks',[{id:'canvas',parent:null}],[node('a'),node('b')]),version=1,serial=0,image:any=null,fail=false,readGate:Promise<void>|undefined;
|
||||
// 分别保存本地 Blob、文档回执及异步错误,read 次数用于验证资源 hooks 共享读取。
|
||||
const files=new Map<string,Blob>(),receipts=new Map(),errors:unknown[]=[];let reads=0;
|
||||
// 内存存储可注入 quota,readFile 可被 Promise 屏障延迟;不实现真实浏览器锁或 IndexedDB。
|
||||
const storage={async claim(scope:any){return{scope,fence:1,active:true,async release(){this.active=false;}};},async recover(){return image;},async commit(_leader:any,batch:any){if(fail)throw Error('quota');for(const file of batch.files??[])files.set(file.id,file.bytes);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(_leader:any,id:string){reads++;if(readGate)await readGate;return files.get(id);},close(){}};
|
||||
// 上传替身返回固定 2×1 元数据与资源 URL,不验证图片字节;查询保持 unresolved,accept 直接取 canonical。
|
||||
const assets={sourceURL:(id:string)=>`https://assets.test/${id}`,displayURL:(id:string)=>`/assets/${id}`,async upload(request:any,bytes:Blob){const payload=JSON.parse(request.body);return{outcome:'applied' as const,canonical:{id:payload.resource_id,url:assets.sourceURL(payload.resource_id),bytes:bytes.size,mime_type:bytes.type,width:2,height:1}};},async query(){return{outcome:'unresolved' as const};},accept:(_request:any,status:any)=>status.canonical};
|
||||
// 文档替身应用真实 changes 并递增版本,把成功模型放在回执表;不实现真实 HTTP 或请求去重。
|
||||
const persistence={compile(intent:any,id:string,snapshot:any){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request:any){server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['canvas',++version]])});return{outcome:'applied' as const};},async query(){return{outcome:'unresolved' as const};},canonical:(request:any)=>receipts.get(request.id)};
|
||||
// 装配真实引擎与 loading 状态,显式 attach v/w 后等待本地初始工作收敛。
|
||||
const engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:partial?'canvas':'hooks'},document:partial?createDocument('canvas',[{id:'canvas',parent:null}],[node('a')]):server,...(partial?{loading:{version:1 as const,sourceToken:'hook-partial',declaredCanvasIds:['canvas'],receivedElements:1,totalElements:2}}:{}),versions:new Map([['canvas',1]]),registry:createRegistry(builtinComponents()),storage,assets,persistence,createId:()=>`hook-${++serial}`,onError:error=>errors.push(error)});const detachA=engine.sessions.attach('v','canvas'),detachB=engine.sessions.attach('w','canvas');await engine.settle();
|
||||
// 向场景暴露保存故障、读取屏障与读次数;detach 只清理 fixture 自己附加的两个会话。
|
||||
return{engine,node,files,errors,full:server,get reads(){return reads;},fail(value:boolean){fail=value;},gate(value?:Promise<void>){readGate=value;},detach(){detachA();detachB();}};
|
||||
}
|
||||
// 创建隐藏 React root 并串行运行场景;保留组件读到的最新 hook 结果和渲染/选择器计数供断言。
|
||||
async function run(){
|
||||
const f=await fixture(),engine=f.engine,container=document.createElement('div');container.style.display='none';document.body.append(container);const root=createRoot(container),reports:string[]=[],counts={title:0,element:0,mode:0,gesture:0,titleOnly:0};let titleOnly:unknown,titleSelections=0;let title:any,element:any,actions:any,mode:any,gesture:any,operation:any,status:any,assetA:any,assetB:any;
|
||||
// 全局选择器只提取 a 的标题,equal 比较结果对象,检查无关 Root 发布不引发重渲染。
|
||||
const Title=memo(function Title(){title=useCanvas(snapshot=>({title:snapshot.document.elements.get('a')?.data.title}),equal);counts.title++;return null;});
|
||||
// 完整元素 hook 固定到 a 的第一代身份,稍后用于验证删除恢复后状态变为 stale。
|
||||
const Element=memo(function Element(){element=useElement({id:'a',incarnation:1});counts.element++;return null;});
|
||||
// 计数元素选择器实际执行次数,区分“跳过无关目标”与“目标变化但结果相等不渲染”。
|
||||
const selectTitle=(view:ElementView)=>{titleSelections++;return view.status==='present'?view.element.data.title:view.status;};
|
||||
// 只订阅目标标题字段;同元素其他字段变化允许重新选择,但不能增加组件渲染次数。
|
||||
const ElementTitle=memo(function ElementTitle(){titleOnly=useElement({id:'a',incarnation:1},selectTitle);counts.titleOnly++;return null;});
|
||||
// 捕获绑定到目标身份的命令 facade,检查稳定引用、历史选项和过期身份拒绝。
|
||||
const Actions=memo(function Actions(){actions=useActions({id:'a',incarnation:1});return null;});
|
||||
// 观察 v 视图的 mode,另一个 view 的 mode 更新不能使它重渲染。
|
||||
const Mode=memo(function Mode(){mode=useMode({id:'a',incarnation:1});counts.mode++;return null;});
|
||||
// 捕获目标绑定手势控制器;beginOnLayout 在替换目标的 layout effect 中立即开启新手势,以暴露旧 cleanup 误取消问题。
|
||||
const Gesture=memo(function Gesture({targetId='a',beginOnLayout=false}:{targetId?:string;beginOnLayout?:boolean}){const binding=useGestureBindings({id:targetId,incarnation:1});gesture=binding;counts.gesture++;useLayoutEffect(()=>{if(beginOnLayout){assert(binding.begin().enabled,'replacement gesture did not begin');assert(binding.update({x:88}).enabled,'replacement gesture did not preview');}},[targetId,beginOnLayout]);return null;});
|
||||
// 按逻辑操作 ID 观察操作及其请求;最初 missing 应没有记录,rename 后改绑真实 ID。
|
||||
const Operation=memo(function Operation({id}:{id:string}){operation=useOperation(id);return null;});
|
||||
// 独立观察同步、持久化错误和关闭状态,不靠业务文档再次变化触发观察。
|
||||
const Status=memo(function Status(){status=useSyncStatus();return null;});
|
||||
// 两个 slot 可同时借用同一资源,分别保存 hook 结果以检查共享 URL 及引用计数释放。
|
||||
const Asset=memo(function Asset({id,slot}:{id:string;slot:'a'|'b'}){const value=useAsset(id);if(slot==='a')assetA=value;else assetB=value;return null;});
|
||||
// 每次以 StrictMode 和 CanvasProvider 同步替换子树;owner 可替换成另一个 engine,随后等待 effects。
|
||||
const render=async(children:any,owner=engine)=>{flushSync(()=>root.render(<StrictMode><CanvasProvider engine={owner} viewId="v">{children}</CanvasProvider></StrictMode>));await tick();};
|
||||
try{
|
||||
// 先等 synced 再记录动态基线;StrictMode 可能重复渲染,因此断言增量而非写死初始次数。
|
||||
await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id="missing"/><Status/></>);await until(()=>status?.status==='synced');const baseline={...counts},originalActions=actions,initialTitleSelections=titleSelections;
|
||||
// 改无关 b:窄订阅计数和 action 引用不变,元素标题选择器也不执行。
|
||||
engine.editor.dispatch({type:'element.data',target:{id:'b',incarnation:1},patch:{title:'unrelated'}});await engine.settle();await tick();assert(equal(baseline,counts),'unrelated element/ACK rerendered narrow hooks');assert(actions===originalActions,'action facade identity drifted');assert(titleSelections===initialTitleSelections,'unrelated edit/ACK evaluated the element selector');
|
||||
// 改 a 的 text 会执行其标题选择器,但标题没变时不得重渲染 ElementTitle。
|
||||
const titleRenders=counts.titleOnly;engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'other field'}});await engine.settle();await tick();assert(titleSelections>initialTitleSelections&&counts.titleOnly===titleRenders&&titleOnly==='a','field selector did not isolate title from another field on the same target');reports.push('element ID routing skips unrelated selector evaluation; field selectors observe their target without rerendering for other fields');
|
||||
// 经 actions.rename 验证命令历史 label/groupId、canonical 观察及 operation/request 状态。
|
||||
const renamed=actions.rename('renamed',{label:'Hook rename',groupId:'hook-rename'});await engine.settle();const renamedHistory=engine.getSnapshot().history.entries.get(engine.getSnapshot().history.undo.at(-1)!);assert(renamedHistory?.label==='Hook rename'&&renamedHistory.groupId==='hook-rename','action facade dropped command history options');await until(()=>title.title==='renamed'&&titleOnly==='renamed');assert(element.status==='present'&&element.element.data.title==='renamed','element did not observe canonical rename');await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);assert(operation?.operation.phase==='resolved'&&operation.request?.phase==='resolved','operation hook did not include its request');reports.push('selectors suppress unrelated element and ACK renders; bound actions share validated history/ledger and operation/request observation');
|
||||
// 手势预览只改变交互状态,不改业务几何、revision 或 history;取消不留撤销记录,mode 仍按 view 隔离。
|
||||
const history=engine.getSnapshot().history.undo.length,revision=engine.getSnapshot().revision;assert(gesture.begin().enabled&&gesture.update({x:42}).enabled,'gesture did not acquire/update');await until(()=>gesture.active&&gesture.geometry.x===42);assert(engine.getSnapshot().document.elements.get('a')!.structure==='node'&&(engine.getSnapshot().document.elements.get('a') as any).geometry.x===0,'preview mutated committed geometry');assert(engine.getSnapshot().revision===revision,'preview created an edit');gesture.cancel();await until(()=>!gesture.active);assert(engine.getSnapshot().history.undo.length===history,'canceled preview changed history');assert(mode.setMode('editing').enabled,'view mode rejected');await until(()=>mode.mode==='editing');const modeRenders=counts.mode;engine.sessions.mode('w','a','resize');await tick();assert(counts.mode===modeRenders,'another view changed this view mode');
|
||||
// 第二次手势提交只新增一个历史单元,租约释放后旧 commit 不再可写。
|
||||
assert(gesture.begin().enabled,'second gesture did not acquire');gesture.update({x:55});assert(gesture.commit().status==='applied','gesture did not commit');await engine.settle();assert(engine.getSnapshot().history.undo.length===history+1,'gesture did not create exactly one history entry');assert(gesture.commit().status==='rejected','released gesture lease remained writable');reports.push('gesture preview/cancel/commit owns a token and one history unit; modes stay scoped to their view');
|
||||
// 切换 hook 目标后立即在 layout effect 开新租约;旧控制器的 cancel/commit 和 cleanup 都不能碰新目标。
|
||||
const previousGesture=gesture,beforeOwnerSwap=engine.getSnapshot();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture targetId="b" beginOnLayout/><Operation id={renamed.operationIds[0]}/><Status/></>);const replacementToken=engine.sessions.getSnapshot().gesture?.token;assert(!!replacementToken&&engine.sessions.getSnapshot().gesture?.targets[0].id==='b','old target cleanup canceled the new layout gesture');assert(previousGesture.commit().status==='rejected','old controls committed the replacement gesture');previousGesture.cancel();assert(engine.sessions.getSnapshot().gesture?.token===replacementToken,'old controls canceled the replacement gesture');assert(engine.getSnapshot()===beforeOwnerSwap,'owner switch or obsolete callback published document/history work');gesture.cancel();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);reports.push('gesture controller replacement owns an independent lease; old cleanup, cancel and commit cannot touch a new target gesture');
|
||||
|
||||
// 先检查只有 durability 完成也能清除 dirty,再注入 quota 验证 blocked/dirty,重试成功才恢复 synced。
|
||||
engine.editor.updateRuntime(previous=>({...previous}));await engine.persist();await tick();await until(()=>status.status==='synced'&&!status.dirty);assert(!status.dirty,'durable-only metadata completion was not observable');f.fail(true);actions.rename('unsaved');await until(()=>status.status==='blocked'&&status.error==='quota');assert(status.dirty,'failed persistence appeared durable');f.fail(false);await engine.retry();await engine.settle();await until(()=>status.status==='synced'&&!status.dirty);reports.push('sync status observes durable-only completion and quota failure/retry without claiming in-memory edits are synced');
|
||||
// 暂存任意图片类型字节只用于资源引用测试;不是 PNG 解码验证。
|
||||
let resource='';engine.stageFiles([{name:'image.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([ref],tx)=>{resource=ref.id;tx.dispatch({type:'element.create',element:{...f.node('image'),componentId:'image',data:{title:'image',text:'',assetId:ref.id,src:ref.src}}});});await engine.settle();const reads=f.reads;
|
||||
// 两个 hooks 必须共用一次读取和一个 URL;移除一个不影响另一个,最后一个卸载后 URL 才失效。
|
||||
await render(<><Asset id={resource} slot="a"/><Asset id={resource} slot="b"/><Status/></>);await until(()=>assetA?.status==='ready'&&assetB?.status==='ready');assert(assetA.url===assetB.url&&f.reads===reads+1,'two resource hooks did not share one display read/URL');const url=assetA.url;assert(await(await fetch(url)).text()==='image bytes','asset hook did not expose original bytes');await render(<><Asset id={resource} slot="a"/><Status/></>);assert(await(await fetch(url)).text()==='image bytes','unmounting one reader revoked the other');await render(<Status/>);let revoked=false;try{await fetch(url);}catch{revoked=true;}assert(revoked,'last resource reader did not release its URL');reports.push('resource hooks share a URL, retain remaining readers and release the last handle on unmount');
|
||||
// 读取尚未返回时卸载 hook,随后才放行;迟到资源结果应释放显示 pin,不能泄漏借用。
|
||||
let release!:()=>void;f.gate(new Promise<void>(resolve=>release=resolve));const before=f.reads;await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>f.reads>before);await render(<Status/>);release();f.gate();await tick();assert(!engine.getResourcePins().pins.get(resource)?.has('display'),'late resource load leaked a display pin');
|
||||
// 删除并撤销恢复元素后 incarnation 已变化;旧 facade 必须拒绝写入,不能自动改绑同 ID 的新对象。
|
||||
await render(<><Element/><Actions/><Gesture/><Status/></>);const staleActions=actions;engine.editor.dispatch({type:'element.remove',target:{id:'a',incarnation:1}});await engine.settle();engine.editor.history.undo();await engine.settle();await until(()=>element.status==='stale');const snapshot=engine.getSnapshot();assert(staleActions.rename('stale').status==='rejected'&&engine.getSnapshot()===snapshot,'old incarnation facade edited a restored element');reports.push('late resource loads release their pins and stale action targets cannot retarget restored incarnations');
|
||||
// 关闭 engine 时 mounted hooks 也要观察到 closed/error 并释放资源。
|
||||
await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>assetA.status==='ready');await engine.close();await until(()=>status.status==='closed'&&assetA.status==='error');reports.push('engine close is observable and releases mounted resource handles without owning a second project');
|
||||
// 换 Provider 所属引擎后,资源字节和命令目标都必须来自新 owner,不能修改旧引擎快照。
|
||||
const other=await fixture();try{
|
||||
let otherResource='';other.engine.stageFiles([{name:'other.png',bytes:new Blob(['other identity bytes'],{type:'image/png'})}],([ref],tx)=>{otherResource=ref.id;tx.dispatch({type:'element.create',element:{...other.node('other-image'),componentId:'image',data:{title:'other',text:'',assetId:ref.id,src:ref.src}}});});await other.engine.settle();
|
||||
const oldSnapshot=engine.getSnapshot();await render(<><Asset id={otherResource} slot="a"/><Actions/><Status/></>,other.engine);await until(()=>assetA.status==='ready');assert(await(await fetch(assetA.url)).text()==='other identity bytes','Provider change reused another engine resource handle');assert(actions.rename('second owner').status==='applied','new owner action did not dispatch');await other.engine.settle();assert(other.engine.getSnapshot().document.elements.get('a')!.data.title==='second owner'&&engine.getSnapshot()===oldSnapshot,'Provider change routed commands to the old owner');reports.push('Provider owner changes replace resource handles and action targets without writing to the previous engine');
|
||||
}finally{await other.engine.close();}
|
||||
// 部分来源完成前 b 和未知 ID 都是 unloaded;loading.complete 后才能分别判断 present 与 absent。
|
||||
const partial=await fixture(true);try{
|
||||
let b:any,missing:any;function Lookup(){b=useElement('b');missing=useElement('not-in-source');return null;}
|
||||
await render(<><Lookup/><Status/></>,partial.engine);assert(b.status==='unloaded'&&missing.status==='unloaded','partial lookup guessed absence');const completed=await partial.engine.loading.complete(partial.full,new Map([['canvas',1]]),{sourceToken:'hook-partial'});assert(completed.status==='applied','hook partial fixture did not complete');await partial.engine.settle();await until(()=>b.status==='present'&&missing.status==='absent');reports.push('element hooks preserve unloaded versus absent until source completion and then expose the newly loaded element');
|
||||
}finally{await partial.engine.close();}
|
||||
// 只有所有场景通过才返回报告;finally 卸载 root、移除隐藏容器、恢复故障开关并清理 fixture。
|
||||
return reports;
|
||||
}finally{flushSync(()=>root.unmount());container.remove();f.fail(false);f.detach();await engine.close();}
|
||||
}
|
||||
// effect 仅注册页面测试入口;清理时先比较函数身份,防止旧挂载删除新实例注册的入口。
|
||||
export function HookChecks(){useEffect(()=>{const host=globalThis as typeof globalThis&{__runCanvasHookChecks?:()=>ReturnType<typeof run>};host.__runCanvasHookChecks=run;return()=>{if(host.__runCanvasHookChecks===run)delete host.__runCanvasHookChecks;};},[]);return null;}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* 渐进复制尚未完成时的真实浏览器原生输入探针,由 renderer 集成 runner 注入 CDP/求值/等待端口。
|
||||
* 在原卡片上双击、输入中文组合及连续字符,跨早/中/尾发布阶段检查同一 textarea 的 DOM、焦点与呈现。
|
||||
* evaluate 模板字符串是在页面执行的独立脚本,保持其原字节;这里只在调用边界解释内部回调与读数。
|
||||
*/
|
||||
/** Native input while a real progressive copy is still publishing. */
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// 导出给 runner:count 是原始节点规模;返回每次 input 的时间/内容/焦点样本,供外层报告或性能门槛使用。
|
||||
export async function editDuringCopy({call,evaluate,wait,count}){
|
||||
// 至少 1000 节点才启用并发探针,先等待首帧出现且复制还未完成,保证输入发生在真实渐进发布期间。
|
||||
assert.ok(count>=1000,'concurrent input probe requires at least 1000 source nodes');
|
||||
await wait('window.__canvasPerformance.read().firstPresentedFrameMs!==null','copy did not present before native input');
|
||||
assert.equal(await evaluate('window.__canvasPerformance.read().localCompleteMs===null'),true,'copy finished before input probe');
|
||||
// Copied cards overlap originals. Find an actually exposed point; never send a
|
||||
// synthetic double-click through the copied card to the original underneath.
|
||||
// 页面脚本先找原始 perf-0 文本标签,再按屏幕网格调用 elementFromPoint,寻找未被副本遮住的原生命中点。
|
||||
const point=await evaluate(`(()=>{
|
||||
const label=document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]');
|
||||
if(!label)throw Error('original text label is missing');
|
||||
@@ -16,9 +24,11 @@ export async function editDuringCopy({call,evaluate,wait,count}){
|
||||
if(label.contains(document.elementFromPoint(x,y)))return{x,y};
|
||||
throw Error('original text has no exposed native input point');
|
||||
})()`);
|
||||
// 通过 CDP 发送真实双击按下/释放,再等待原 textarea 进入可写焦点,不能用合成事件穿透副本。
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:2});
|
||||
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:2});
|
||||
await wait(`document.activeElement===document.querySelector('.react-flow__node[data-id="perf-0"] textarea')&&!document.activeElement.readOnly`,'original text did not enter editing during copy');
|
||||
// 安装 input 监听器:每次记录输入值、阶段及已提交数量,用两次 RAF 后的时间近似观察到后续呈现机会。
|
||||
await evaluate(`(()=>{
|
||||
const element=document.activeElement,samples=[];
|
||||
const input=()=>{
|
||||
@@ -36,6 +46,7 @@ export async function editDuringCopy({call,evaluate,wait,count}){
|
||||
return Number(status.match(/·\\s*(\\d+)\\s*项/)?.[1]??0);
|
||||
},dispose(){element.removeEventListener('input',input);}};
|
||||
})()`);
|
||||
// 先启动中文输入法组合再提交文本,随后分次插入 A/B/C;每次都等对应样本进入后续帧。
|
||||
await call('Input.imeSetComposition',{text:'复',selectionStart:1,selectionEnd:1});
|
||||
await call('Input.insertText',{text:'复制期间输入'});
|
||||
for(const text of [' A',' B',' C']){
|
||||
@@ -44,6 +55,7 @@ export async function editDuringCopy({call,evaluate,wait,count}){
|
||||
}
|
||||
// Keep the same native editor alive through later publication and ACK work.
|
||||
// Small scenarios can finish during the early burst; the scale gate uses 10k.
|
||||
// 万级场景还在 50%/90% 进度注入 M/T,保持同一编辑器不重开;测量 CDP round trip 与样本帧延迟是不同读数。
|
||||
if(count>=10000)for(const [phase,fraction,text] of [['middle',.5,' M'],['tail',.9,' T']]){
|
||||
await wait(`window.__copyInputProbe.progress()>=${Math.floor((count+3)*fraction)}`,'copy did not reach '+phase+' publication');
|
||||
assert.equal(await evaluate('window.__canvasPerformance.read().localCompleteMs===null'),true,'copy completed before '+phase+' input');
|
||||
@@ -53,15 +65,18 @@ export async function editDuringCopy({call,evaluate,wait,count}){
|
||||
await wait(`window.__copyInputProbe.samples.at(-1)?.phase===${JSON.stringify(phase)}&&window.__copyInputProbe.samples.at(-1)?.frameAt!==undefined`,phase+' input did not reach a frame');
|
||||
await evaluate(`window.__copyInputProbe.samples.at(-1).dispatchRoundTripMs=${dispatchRoundTripMs};true`);
|
||||
}
|
||||
// 最后页面脚本读取样本与焦点状态,移除监听器、blur 并删除探针全局变量;断言使用清理前捕获的结果。
|
||||
const result=await evaluate(`(()=>{
|
||||
const probe=window.__copyInputProbe,result={samples:probe.samples,value:probe.element.value,retained:probe.element.isConnected&&document.activeElement===probe.element};
|
||||
probe.dispose();probe.element.blur();delete window.__copyInputProbe;return result;
|
||||
})()`);
|
||||
// 检查同一原生输入节点/焦点留存及完整文本后缀,复制过程不能重建编辑 DOM 或覆盖用户输入。
|
||||
assert.equal(result.retained,true,'copy discarded native input DOM or focus');
|
||||
assert.ok(result.value.endsWith(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'native composition/typing was overwritten');
|
||||
assert.ok(result.samples.some(sample=>sample.copyActive),'all native input happened after copy completion');
|
||||
if(count>=10000)for(const phase of ['middle','tail'])assert.ok(result.samples.some(sample=>sample.phase===phase&&sample.copyActive),phase+' input was not observed before completion');
|
||||
assert.ok(result.samples.every(sample=>sample.retained),'copy interrupted an input presentation frame');
|
||||
// 每个样本挂载节点数仍少于 100,证明此探针未因原生编辑挂载全部离屏节点;这不是通用 FPS 保证。
|
||||
assert.ok(result.samples.every(sample=>sample.mountedNodes<100),'native editing mounted offscreen nodes during copy');
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 浏览器渲染集成专用插件:模型定义与 ReactFlow renderer 一起通过 CanvasRuntimeExtension 注入。
|
||||
* 页面全局计数和 editing facade 仅用于测试观察,数据编辑仍经过生产命令/事务入口。
|
||||
*/
|
||||
"use client";
|
||||
import {useScopedNodeEditing} from '@/components/canvas/node-editing';
|
||||
import { Handle, Position, BaseEdge, getBezierPath, type NodeProps, type EdgeProps } from '@xyflow/react';
|
||||
@@ -5,15 +9,20 @@ import { defineComponent, editable, movable } from '@/lib/canvas-core/index';
|
||||
import type {ReactFlowProjectionNode as ProjectionNode,ReactFlowProjectionEdge as ProjectionEdge} from '@/lib/canvas-bindings/react-flow';
|
||||
import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-flow';
|
||||
import { useCanvasBinding,useElement, useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow';
|
||||
// 按元素 ID 计数真实 renderer 调用,供外层检查无关元素是否重渲染,不以 DOM 数量代替渲染次数。
|
||||
function rendered(id:string){const host=globalThis as typeof globalThis&{__fixtureRenders?:Record<string,number>};const counts=host.__fixtureRenders??={};counts[id]=(counts[id]??0)+1;}
|
||||
// 通过 useElement 取得当前原记录,捕获有 scoped target 与无绑定两种旧 editing facade,交给外层验证桥接隔离。
|
||||
function Meter({data}:NodeProps<ProjectionNode>){
|
||||
const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!;rendered(record.id);const legacy=useScopedNodeEditing(record.id,data.__headlessTarget),unbound=useScopedNodeEditing(record.id),binding=useCanvasBinding();const host=globalThis as typeof globalThis&{__fixtureEngine?:typeof binding.engine;__fixtureLegacy?:Record<string,typeof legacy>;__fixtureUnbound?:Record<string,typeof legacy>};host.__fixtureEngine=binding.engine;(host.__fixtureLegacy??={})[record.id]=legacy;(host.__fixtureUnbound??={})[record.id]=unbound;
|
||||
// Increase 按钮阻止点击冒泡,并在一个事务里修改 metric 与 raw,要求只产生一个历史单元;自定义 Handle ID 供连线端口测试。
|
||||
return <div data-renderer-fixture="meter" style={{width:'100%',height:'100%',background:'white',border:'1px solid #ded7ce',padding:16}}>
|
||||
<span>Metric {String(record.data.metric)}</span><button className="nodrag" aria-label={`Increase ${record.id}`} onClick={event=>{event.stopPropagation();commands.transaction(tx=>{tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{metric:Number(record.data.metric)+1}});tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{raw:{retained:true,edited:true}}});},{label:'Increase metric'});}}>Increase</button>
|
||||
<Handle type="source" id="custom-out" position={Position.Right}/><Handle type="source" id="custom-in" position={Position.Left}/>
|
||||
</div>;
|
||||
}
|
||||
// 自定义边记录渲染次数,使用 ReactFlow Bezier path 绘制;标题从原 connection data 读取 metric。
|
||||
function MeterEdge(props:EdgeProps<ProjectionEdge>){rendered(props.id);const [path]=getBezierPath(props);return <g data-renderer-fixture="metric-edge"><title>Metric {String(props.data?.__headlessRecord?.data.metric)}</title><BaseEdge id={props.id} path={path}/></g>;}
|
||||
// 两种模型分别校验 metric,节点安装编辑/移动能力;renderer 键精确匹配 componentId、dataVersion 与 structure。
|
||||
export const fixtureExtension:CanvasRuntimeExtension={
|
||||
components:[defineComponent({id:'fixture:meter',dataVersion:1,structure:'node',validate:data=>Number.isSafeInteger(data.metric),features:[editable(),movable()],copy:{references:[],omit:[]}}),defineComponent({id:'fixture:metric-edge',dataVersion:1,structure:'connection',validate:data=>typeof data.metric==='number',features:[],copy:{references:[],omit:[]}})],
|
||||
renderers:[{componentId:'fixture:meter',dataVersion:1,structure:'node',rendererId:'fixture-meter',render:Meter},{componentId:'fixture:metric-edge',dataVersion:1,structure:'connection',rendererId:'fixture-metric-edge',render:MeterEdge}],
|
||||
|
||||
@@ -1,42 +1,61 @@
|
||||
/**
|
||||
* 旧画布迁移的浏览器集成 fixture 与 CDP 验证流程,由 renderer runner 提供页面、输入、轮询和假服务端。
|
||||
* 五份独立旧画布分别验证脏写排空、其他视图共享锁、未知请求精确恢复、已有草稿及 pending 期间新增草稿。
|
||||
* 这是可执行探针定义;Node 单元测试不会自动执行这些浏览器步骤,也不把控制台 PASS 文本当作已运行证据。
|
||||
* evaluate/until 的页面脚本字符串保持原字节,在调用边界解释其中的查找、注入和状态检查。
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {createDocument} from '../../src/lib/canvas-core/index.ts';
|
||||
// 创建可安装到测试 HTTP server 的路由处理器;复用外部 boards/documents/versions/model,迁移成功后让主 runner 提供 canonical 路由。
|
||||
export function createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}){
|
||||
// 五个 fixture 各有旧节点、版本、迁移回执与故障开关;loss before/after 区分提交前失败与提交后丢响应。
|
||||
const states=Array.from({length:5},(_,index)=>{
|
||||
const root=id(7000+index*10),element=id(7001+index*10);
|
||||
boards.push({id:root,workspace_id:workspace,kind:'board',version:1,title:'Legacy upgrade '+index});
|
||||
return{root,element,version:1,ready:false,loss:undefined,holdPatch:false,patchWait:undefined,releasePatch:undefined,receipts:new Map(),keys:new Map(),patches:0,posts:[],document:{schema_version:2,nodes:[{id:'old-card',type:'plainText',position:{x:500,y:200},width:240,height:100,data:{kind:'plain-text',title:'Old card',text:'Legacy content'}}],edges:[],viewport:{x:0,y:0,zoom:1}}};
|
||||
});
|
||||
// handle 只处理匹配 fixture 根的旧 canvas/迁移路由,返回 true 表示已响应,其余交给外层服务。
|
||||
return{states,async handle(request,path,reply,bytes){
|
||||
const state=states.find(value=>path.includes('/materials/'+value.root+'/canvas'));if(!state)return false;
|
||||
const base=`/materials/${state.root}/canvas`,raw=bytes.length?JSON.parse(bytes.toString()):undefined;
|
||||
// 未迁移时 canonical model 返回带 source_versions 的明确 migration_required;此 header 才能开启升级入口。
|
||||
if(path.endsWith(base+'/model')&&!state.ready){reply(409,{error:'canonical_migration_required',root_id:state.root,document_format_version:1,source_versions:{[state.root]:state.version}});return true;}
|
||||
// 旧快照 GET 返回当前 document;PATCH 校验账户和 If-Match,并禁止 ready 后旧 writer 再写入。
|
||||
if(path.endsWith(base)){
|
||||
if(request.method==='PATCH'){
|
||||
assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['if-match'],`"${state.version}"`);assert.equal(state.ready,false,'retired snapshot writer sent after migration');
|
||||
state.document=raw.document;state.version++;state.patches++;
|
||||
// 可把旧 PATCH 的响应停在 gate,让 UI 升级必须等待脏 writer 收到确认,而不是仅等本地文本更新。
|
||||
if(state.holdPatch){state.patchWait=new Promise(resolve=>state.releasePatch=resolve);await state.patchWait;}
|
||||
reply(200,{version:state.version});
|
||||
}else reply(200,{version:state.version,document:state.document});return true;
|
||||
}
|
||||
// 提供旧画布的最小能力与空 run 列表,保持 UI 能正常打开,避免无关加载缺口干扰迁移。
|
||||
if(path.endsWith(base+'/capabilities')){reply(200,{subgraphs:{member_positions:true}});return true;}
|
||||
if(path.endsWith(base+'/ai/runs')&&request.method==='GET'){reply(200,{runs:[]});return true;}
|
||||
// 按原 key 查询已保存回执,未命中回 unknown;恢复测试靠这条路径消除执行不确定性。
|
||||
if(path.includes(base+'/model/operations/')&&request.method==='GET'){
|
||||
const key=path.split('/').at(-1),receipt=state.receipts.get(key);reply(receipt?200:404,receipt??{status:'unknown',request_id:key});return true;
|
||||
}
|
||||
// 迁移 POST 校验账户/幂等头并记录原始字符串;明确失败、精确重试与成功转换分别处理。
|
||||
if(path.endsWith(base+'/model/migrate')){
|
||||
assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],raw.request_id);
|
||||
state.posts.push({id:raw.request_id,body:bytes.toString()});
|
||||
// 模拟尚未应用就返回 503,不能因此生成成功回执;恢复时必须再查 unknown 并精确重试。
|
||||
if(state.loss==='before'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;}
|
||||
// 已有请求校验完全相同字节;新请求版本冲突返回 terminal not-applied,否则只执行一次身份转换。
|
||||
let receipt=state.receipts.get(raw.request_id);
|
||||
if(receipt){assert.equal(state.keys.get(raw.request_id),bytes.toString());}
|
||||
else if(raw.expected_versions[state.root]!==state.version){
|
||||
receipt={protocol_version:2,request_id:raw.request_id,status:'not-applied',terminal:true,error:'migration_version_conflict'};
|
||||
}else{
|
||||
// 读取最后一次已确认的旧节点内容,分配 fixture 的新 canonical ID,增加版本并退休旧 writer。
|
||||
const old=state.document.nodes[0];state.version++;state.ready=true;versions.set(state.root,state.version);
|
||||
documents.set(state.root,createDocument(state.root,[{id:state.root,parent:null}],[{id:state.element,incarnation:1,ownerCanvasId:state.root,componentId:'plain-text',dataVersion:1,structure:'node',data:{title:old.data.title,text:old.data.text},geometry:{...old.position,width:old.width,height:old.height,rotation:0}}]));
|
||||
receipt={protocol_version:2,request_id:raw.request_id,status:'applied',source_versions:raw.expected_versions,identity_map:[{sourceCanvasId:state.root,sourceId:old.id,structure:'node',elementId:state.element}],model:model(documents.get(state.root))};
|
||||
}
|
||||
// 先保存请求事实和字节,再处理 loss after;即使客户端收到 503,后端已经可查询成功。
|
||||
state.receipts.set(raw.request_id,receipt);state.keys.set(raw.request_id,bytes.toString());
|
||||
if(state.loss==='after'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;}
|
||||
reply(receipt.status==='applied'?200:412,receipt);return true;
|
||||
@@ -45,31 +64,43 @@ export function createRendererMigrationFixture({id,account,workspace,boards,docu
|
||||
}};
|
||||
}
|
||||
|
||||
// 执行实际页面输入/重载/多视图场景,参数均由外部 runner 注入;本函数不自己启动浏览器或应用。
|
||||
export async function verifyRendererMigration({fixture,call,evaluate,until,port,workspace,account,pause}){
|
||||
// 统一构造测试本机路由,后续在五个独立画布之间跳转。
|
||||
const route=root=>`http://127.0.0.1:${port}/dashboard/${workspace}/${root}`;
|
||||
// 返回页面求值表达式,在“画布升级”区域按精确文字找按钮;JSON.stringify 保证文字安全嵌入脚本。
|
||||
const button=label=>`[...document.querySelectorAll('[aria-label="画布升级"] button')].find(button=>button.textContent.trim()===${JSON.stringify(label)})`;
|
||||
// 先在页面取已启用按钮中心,再用 CDP 发送真实鼠标按下/释放;不是调用 DOM click 的合成事件。
|
||||
async function click(label){const point=await evaluate(`(()=>{const button=${button(label)};if(!button||button.disabled)throw Error('missing enabled upgrade button');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);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});}
|
||||
// 旧编辑器可用条件同时检查升级入口和画布非 inert,避免只凭按钮出现就认为旧写入已放开。
|
||||
const editable=()=>`!!${button('升级画布')}&&!document.querySelector('.react-flow').parentElement.inert`;
|
||||
// 账户/workspace 级持久化迁移键,稍后比较保存的冻结 body 与服务实际收到字节。
|
||||
const storageKey=`endoleap_canvas_migration_v1:${account}:${workspace}`;
|
||||
// 五份状态按下方场景依次使用,互不污染版本、回执和旧 PATCH 数量。
|
||||
const[a,b,c,d,e]=fixture.states;
|
||||
await until(editable(),'legacy editor did not claim an editing lease');
|
||||
// 场景 A:先找原旧卡片可编辑位置并用 elementFromPoint 验证没被遮挡,再真实双击/输入形成脏稿。
|
||||
const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="old-card"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+r.width*.4,y:r.y+Math.min(25,r.height*.3)}})()`);
|
||||
assert.equal(await evaluate(`document.elementFromPoint(${point.x},${point.y})?.closest('.react-flow__node')?.getAttribute('data-id')`),'old-card','legacy edit point is covered by another surface');
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:2});
|
||||
await until(`document.activeElement?.tagName==='TEXTAREA'&&!document.activeElement.readOnly`,'legacy text did not enter editing');
|
||||
await call('Input.insertText',{text:' migration edit'});const edited=await evaluate('document.activeElement.value');
|
||||
// 暂停旧 PATCH 响应并让随后迁移丢 ACK,检查迁移没有越过旧写入确认屏障且已包含最新正文。
|
||||
a.holdPatch=true;a.loss='after';await click('升级画布');
|
||||
for(let i=0;i<100&&!a.releasePatch;i++)await pause(20);assert.ok(a.releasePatch,'upgrade did not drain its dirty legacy writer');assert.equal(a.posts.length,0,'migration crossed an unacknowledged legacy PATCH');assert.equal(a.document.nodes[0].data.text,edited);
|
||||
a.holdPatch=false;a.releasePatch();
|
||||
await until(`!!${button('继续查询升级')}`,'lost migration acknowledgement did not leave a resumable UI');
|
||||
assert.equal(a.posts.length,1);assert.equal(await evaluate(`document.querySelector('.react-flow').parentElement.inert`),true);
|
||||
// 未知期间旧画布应 inert;保存 body 必须与实际 POST 一致,并输出该 UI 状态截图供测试产物审查。
|
||||
const frozen=await evaluate(`JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)}))`);assert.equal(frozen.body,a.posts[0].body);
|
||||
await writeFile('/private/tmp/headless-migration-unknown.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
|
||||
// 刷新页面后自动查询原迁移并进入 canonical,检查只迁移一次、旧 PATCH 只一次,已编辑正文保留。
|
||||
await call('Page.reload');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${a.element}"]')&&!localStorage.getItem(${JSON.stringify(storageKey)})`,'canonical reload did not reconcile its saved migration');
|
||||
assert.equal(a.posts.length,1);assert.equal(a.patches,1);assert.equal(a.version,3);assert.equal(JSON.parse(a.posts[0].body).expected_versions[a.root],2);
|
||||
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${a.element}"] textarea').value`),edited);
|
||||
console.log('PASS legacy dirty writer drains before identity migration; lost ACK reload queries once and retires snapshot writes');
|
||||
|
||||
// 场景 B:隐藏同源 iframe 再开同一旧画布,验证实际 Web Locks 共享租约阻止独占迁移;移除 peer 后才能成功。
|
||||
await call('Page.navigate',{url:route(b.root)});await until(editable(),'second legacy editor did not open');
|
||||
await evaluate(`(()=>{const frame=document.createElement('iframe');frame.id='migration-peer';frame.src=location.href;frame.style.display='none';document.body.append(frame);})()`);
|
||||
await until(`(()=>{const doc=document.getElementById('migration-peer')?.contentDocument;return !!doc?.querySelector('[aria-label="画布升级"] button')&&!doc.querySelector('.react-flow')?.parentElement.inert;})()`,'another legacy view did not acquire its shared lease');
|
||||
@@ -79,6 +110,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
|
||||
await click('升级画布');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${b.element}"]')`,'upgrade did not open the canonical editor after the peer closed');assert.equal(b.posts.length,1);
|
||||
console.log('PASS actual Web Locks exclude identity migration while another legacy view is editable');
|
||||
|
||||
// 场景 C:迁移提交前失败再发生远端版本变化,重载仍保持原请求 gate;精确重试零写入拒绝后才允许显式重新准备。
|
||||
await call('Page.navigate',{url:route(c.root)});await until(editable(),'third legacy editor did not open');c.loss='before';await click('升级画布');await until(`!!${button('继续查询升级')}`,'uncommitted request did not remain resumable');
|
||||
const original=c.posts[0];c.version++;c.document.nodes[0].data.text='Remote edit before recovery';
|
||||
await call('Page.reload');await until(`!!${button('继续查询升级')}&&document.querySelector('.react-flow').parentElement.inert`,'reload enabled old writes before resolving the frozen migration');await pause(1000);assert.equal(c.patches,0);
|
||||
@@ -90,6 +122,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
|
||||
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${c.element}"] textarea').value`),'Remote edit before recovery');
|
||||
console.log('PASS legacy reload remains frozen until exact retry resolves; known rejection requires explicit fresh preparation');
|
||||
|
||||
// 场景 D:已有不同本地恢复草稿,升级必须先展示并等待显式草稿选择,不能在恢复选择前 PATCH 或迁移。
|
||||
await call('Page.navigate',{url:route(d.root)});await until(editable(),'draft conflict fixture did not open');
|
||||
const draft=structuredClone(d.document);draft.nodes[0].data.text='Draft from another closed editor';
|
||||
await evaluate(`localStorage.setItem(${JSON.stringify(`endoleap_canvas_v1:${workspace}:${d.root}:recovery`)},${JSON.stringify(JSON.stringify({document:draft,baseVersion:1,dirty:true}))})`);
|
||||
@@ -103,6 +136,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
|
||||
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${d.element}"] textarea').value`),draft.nodes[0].data.text);
|
||||
console.log('PASS a different cached legacy draft blocks migration until explicit recovery and survives the handoff');
|
||||
|
||||
// 场景 E:迁移结果未知期间出现恢复草稿,先保留草稿但不弹 modal 阻塞查询;明确拒绝后才允许恢复草稿和新准备。
|
||||
await call('Page.navigate',{url:route(e.root)});await until(editable(),'pending draft fixture did not open');
|
||||
e.loss='before';await click('升级画布');await until(`!!${button('继续查询升级')}`,'pending draft fixture did not retain the original upgrade');
|
||||
const retained=structuredClone(e.document);retained.nodes[0].data.text='Retained while migration is unknown';
|
||||
@@ -114,6 +148,7 @@ export async function verifyRendererMigration({fixture,call,evaluate,until,port,
|
||||
assert.equal(await evaluate(`JSON.parse(localStorage.getItem(${JSON.stringify(recoveryKey)})).document.nodes[0].data.text`),retained.nodes[0].data.text);assert.equal(e.patches,0);
|
||||
await click('继续查询升级');await until(`!!document.querySelector('dialog[open]')&&!document.querySelector('.react-flow').parentElement.inert`,'terminal rejection did not make the retained draft recoverable');
|
||||
assert.equal(e.posts.length,2);assert.deepEqual(e.posts[1],e.posts[0]);
|
||||
// 用户明确恢复保留稿后,再点击重新准备产生新迁移 key;新 canonical 编辑器应显示该草稿正文。
|
||||
const retainedPoint=await evaluate(`(()=>{const button=[...document.querySelectorAll('dialog button')].find(button=>button.textContent==='恢复本地草稿');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...retainedPoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...retainedPoint,button:'left',buttons:0,clickCount:1});
|
||||
await until(`!document.querySelector('dialog[open]')`,'retained draft choice did not close recovery');
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
/** Actual GraphPage/React Flow + IndexedDB probe; fixture HTTP server, not production service. */
|
||||
/**
|
||||
* 实际 GraphPage/ReactFlow 页面与 IndexedDB 的输入、保留和深复制探针,HTTP 后端由 fixture 提供。
|
||||
* 调用者注入 CDP、页面 evaluate、服务文档及报告路径;本文件不独立启动浏览器或构建项目。
|
||||
* 将场景断言与耗时/堆采样分别报告:计时是本次环境的观测,不构成通用 SLA;内嵌页面脚本保持测试输入原字节。
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import {editDuringCopy} from './renderer-copy-input.mjs';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {setTimeout as pause} from 'node:timers/promises';
|
||||
import {canvasTransform,toWorld} from '../../src/lib/canvas-core/coordinates.ts';
|
||||
|
||||
// 依次验证原生编辑、离屏输入保留、宿主面板、portal 和大分区复制,再写报告与成功截图。
|
||||
export async function measureRenderer({call,evaluate,documents,calls=[],root,partition,count,exceptions,reportPath,browserLogs=()=>'',baseline,buildMode='development',bundler='webpack',buildMs}){
|
||||
// 保存测量墙上起点、堆样本及复制状态;target 用于离屏裁剪断言,不是最终服务端元素总数。
|
||||
const target=2*count+3,started=Date.now(),heapSamples=[];let copying=false,nextHeap=0;
|
||||
// 每次条件轮询有四分钟墙上截止并检查页面异常;复制中可每十秒取堆与回执保留快照,写 .heap.json。单次 await 仍依赖注入调用器返回。
|
||||
const wait=async(expression,label)=>{const deadline=Date.now()+240000;while(Date.now()<deadline){if(exceptions.length)throw Error(exceptions.join('\n'));if(copying&&process.env.HEADLESS_PERFORMANCE_HEAP==='1'&&Date.now()>=nextHeap){const heap=await call('Runtime.getHeapUsage'),retention=await evaluate('window.__fixtureReceiptRetention');heapSamples.push({wallMs:Date.now()-started,serverElements:documents.get(root)?.elements.size,heap,retention});nextHeap=Date.now()+10000;await writeFile(reportPath+'.heap.json',JSON.stringify({baseline,heapSamples},null,2));}if(await evaluate(expression))return;await pause(100);}throw Error(label);};
|
||||
let report,duringCopyInput;
|
||||
// 环境开关决定是否在复制期间额外输入;默认仅测复制,报告 scenario 据此区分。
|
||||
const concurrentInput=process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1';
|
||||
try{
|
||||
// 等真实 frame 与至少两个节点挂载;未选择时不应保留空节点工具栏订阅。
|
||||
await wait(`!!document.querySelector('.react-flow__node[data-id="perf-frame"]')&&document.querySelectorAll('.react-flow__node').length>=2`,'initial large canvas did not render');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node-toolbar").length'),0,'unselected nodes retained empty toolbar subscriptions');
|
||||
// 读取页面 performance.now,是相对页面时间原点的就绪观测,不是函数 Date.now 起点的独立渲染耗时。
|
||||
const initialReadyMs=await evaluate('performance.now()');console.log('PERF initial scene ready '+Math.round(initialReadyMs)+' ms');
|
||||
// Use native input: synthetic focus alone misses the editing-mode transition.
|
||||
// 使用原生输入进入编辑;仅脚本 focus 不会覆盖真实编辑模式切换。
|
||||
await wait(`!!document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]')`,'first visible text card missing');
|
||||
// 记录双击前后 DOM 数量,用于断言进入单卡编辑不会把离屏全集挂载。
|
||||
const beforeEditing=await evaluate('document.querySelectorAll(".react-flow__node").length');
|
||||
const editPoint=await evaluate(`(()=>{const rect=document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:rect.x+60,y:rect.y+30};})()`);
|
||||
// 经 CDP 发送原生双击,随后验证 activeElement 为可写 textarea,避免只用脚本 focus 绕过编辑模式。
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...editPoint,button:'left',buttons:1,clickCount:2});
|
||||
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...editPoint,button:'left',buttons:0,clickCount:2});
|
||||
await wait(`document.activeElement===document.querySelector('.react-flow__node[data-id="perf-0"] textarea')&&!document.activeElement.readOnly`,'native text editing did not start');
|
||||
await evaluate('window.__performanceEditor=document.activeElement;new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
const duringEditing=await evaluate('document.querySelectorAll(".react-flow__node").length');
|
||||
if(count>=100)assert.ok(duringEditing<=beforeEditing+10,'editing one visible card mounted the offscreen canvas');
|
||||
// 输入后同一 DOM、焦点和文本都要保留,再开启 IME 并将目标平移到屏幕外。
|
||||
await call('Input.insertText',{text:'Native editing remains responsive'});
|
||||
assert.equal(await evaluate('window.__performanceEditor.isConnected&&document.activeElement===window.__performanceEditor&&window.__performanceEditor.value.includes("Native editing remains responsive")'),true,'native input lost its DOM, focus or value');
|
||||
await evaluate('window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector(".react-flow__viewport")).transform);true');
|
||||
// 先建立组合输入再平移;目标确实离屏且仍保留输入 DOM,正常规模下不能因保留一个 editor 挂载全集。
|
||||
await call('Input.imeSetComposition',{text:'保',selectionStart:1,selectionEnd:1});
|
||||
await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`);
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
@@ -34,6 +48,7 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
|
||||
assert.equal(await evaluate('(()=>{const r=window.__performanceEditor.getBoundingClientRect();return r.right<0||r.bottom<0||r.left>innerWidth||r.top>innerHeight;})()'),true,'IME probe did not move its owner offscreen');
|
||||
const offscreenEditing=await evaluate('document.querySelectorAll(".react-flow__node").length');
|
||||
if(count>=100)assert.ok(offscreenEditing<=10,'retaining one offscreen editor mounted the complete canvas');
|
||||
// 离屏提交组合文本后恢复 viewport 并 blur,等同步完成再开始复制计时,排除上一轮未保存输入干扰。
|
||||
await call('Input.insertText',{text:'保留输入'});
|
||||
assert.equal(await evaluate('window.__performanceEditor.value.endsWith("保留输入")'),true,'offscreen IME commit lost content');
|
||||
await evaluate(`(()=>{const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__editViewport;})()`);
|
||||
@@ -41,6 +56,7 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
|
||||
await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'native input did not synchronize before copy measurement');
|
||||
const inputCheck={beforeEditing,duringEditing,offscreenEditing};
|
||||
console.log('PERF native editing '+JSON.stringify(inputCheck));
|
||||
// 记录 AI 任务 POST 基数;修改面板设置或复制只能改配置/文档,不应启动生成。
|
||||
const runStartsBefore=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length;
|
||||
await wait(`!!document.querySelector('[aria-label="AI 创作"]')&&!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI panel action did not become available');
|
||||
await evaluate(`document.querySelector('[aria-label="AI 创作"]').click()`);
|
||||
@@ -48,24 +64,29 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
|
||||
await evaluate(`(()=>{window.__performancePanel=document.querySelector('[aria-label="AI 指令"]');window.__performancePanel.focus();window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);})()`);
|
||||
await call('Input.insertText',{text:'Stable host panel draft'});
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
// 宿主 AI 面板与节点编辑不同:面板持有草稿不应额外保留所有节点。
|
||||
inputCheck.panelFocusedNodes=await evaluate('document.querySelectorAll(".react-flow__node").length');
|
||||
if(count>=100)assert.ok(inputCheck.panelFocusedNodes<=beforeEditing+10,'host-panel focus mounted the complete canvas');
|
||||
await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`);
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
// 平移后面板 DOM、焦点和草稿都保留,但画布离屏节点应归零。
|
||||
assert.equal(await evaluate('window.__performancePanel.isConnected&&document.activeElement===window.__performancePanel&&window.__performancePanel.value==="Stable host panel draft"'),true,'independent panel lost its focus or draft during pan');
|
||||
inputCheck.offscreenPanel=await evaluate('document.querySelectorAll(".react-flow__node").length');
|
||||
assert.equal(inputCheck.offscreenPanel,0,'host-panel input pinned unrelated offscreen nodes');
|
||||
await evaluate('window.__performancePanel.blur();new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'AI settings did not save before copy');
|
||||
// blur 后从 fixture 服务文档核对配置已经保存,再恢复视口并关闭面板。
|
||||
assert.equal(documents.get(root).elements.get('perf-0').data.ai?.prompt,'Stable host panel draft','host-panel blur lost saved settings');
|
||||
await evaluate(`(()=>{document.querySelector('[aria-label="关闭 AI 工具"]').click();const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__performancePanel;delete window.__editViewport;})()`);
|
||||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,runStartsBefore,'editing settings started a generation task');
|
||||
|
||||
// 通过 elementFromPoint 搜索真正空白 pane 坐标并原生点击,清空之前编辑选择,避免污染 frame 工具栏计数。
|
||||
const panePoint=await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect();for(let y=rect.y+5;y<rect.bottom;y+=20)for(let x=rect.x+5;x<rect.right;x+=20)if(document.elementFromPoint(x,y)===pane)return{x,y};throw Error('no empty native pane point');})()`);
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...panePoint,button:'left',buttons:1,clickCount:1});
|
||||
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...panePoint,button:'left',buttons:0,clickCount:1});
|
||||
await wait('document.querySelectorAll(".react-flow__node-toolbar").length===0','native edit selection did not clear before frame selection');
|
||||
|
||||
// 选择 frame 后只允许一个工具栏,portal 标题编辑作为新的离屏保留 owner。
|
||||
await evaluate(`document.querySelector('.react-flow__node[data-id="perf-frame"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
|
||||
await wait(`!!document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'partition selection did not enable deep copy');
|
||||
await evaluate(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))`);
|
||||
@@ -76,13 +97,16 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
|
||||
await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`);
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
assert.equal(await evaluate('window.__performancePortal.isConnected&&document.activeElement===window.__performancePortal&&window.__performancePortal.value==="Retained portal title"'),true,'portal title draft was lost during offscreen pan');
|
||||
// 分区标题 portal 离屏时只保留必要 owner;blur 后应释放全部离屏节点,回到原视口再确认标题保存及重挂载。
|
||||
inputCheck.offscreenPortal=await evaluate('document.querySelectorAll(".react-flow__node").length');
|
||||
if(count>=100)assert.ok(inputCheck.offscreenPortal<=10,'one portal draft mounted the complete canvas');
|
||||
await evaluate('window.__performancePortal.blur();true');
|
||||
await wait('document.querySelectorAll(".react-flow__node").length===0','released portal retained offscreen owners');
|
||||
await evaluate(`(()=>{const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__editViewport;delete window.__performancePortal;})()`);
|
||||
await wait(`document.querySelector('[aria-label="分区名称"]')?.value==='Retained portal title'&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'portal title did not save and remount');
|
||||
// 捕获复制开始前的原文,用来证明并发输入不进入已经捕获的副本。
|
||||
const sourceText=documents.get(root).elements.get('perf-0').data.text;
|
||||
// 页面脚本以 rAF 采样新 DOM、进度完成、节点峰值、帧间隔,并在支持时观察 longtask;tick 反复采样,read 汇总,stop 取消并断开 observer。
|
||||
await evaluate(`(()=>{
|
||||
const originals=new Set(['perf-source','perf-frame',...Array.from({length:${count}},(_,i)=>'perf-'+i)]),start=performance.now(),tasks=[],gaps=[];
|
||||
let raf,last=start,sawPreparing=false,firstDOM=null,firstFrame=null,localComplete=null,localCompleteFrame=null,prepared=null,maxNodes=0;
|
||||
@@ -93,11 +117,15 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
|
||||
window.__canvasPerformance={read(){const sorted=[...gaps].sort((a,b)=>a-b),p95=sorted[Math.max(0,Math.ceil(sorted.length*.95)-1)]??0;return{frameGapP95Ms:p95,elapsedMs:performance.now()-start,firstDOMMs:firstDOM,firstPresentedFrameMs:firstFrame,localCompleteMs:localComplete,localCompleteFrameMs:localCompleteFrame,prepareMs:prepared,maxMountedNodes:maxNodes,nodeCount:document.querySelectorAll('.react-flow__node').length,longTasksSupported:supported,longTaskCount:tasks.length,maxLongTaskMs:Math.max(0,...tasks.map(task=>task.duration)),totalLongTaskMs:tasks.reduce((total,task)=>total+task.duration,0),maxFrameGapMs:Math.max(0,...gaps),longTasks:tasks};},stop(){cancelAnimationFrame(raf);if(observer){tasks.push(...observer.takeRecords().map(entry=>({start:entry.startTime-start,duration:entry.duration})));observer.disconnect();}return this.read();}};
|
||||
document.querySelector('[aria-label="深复制选中内容"]').click();
|
||||
})()`);
|
||||
// 页面脚本安装采样后点击复制;Node 侧此后才启用复制期间的可选堆采样。
|
||||
copying=true;console.log('PERF copy requested');
|
||||
// 可选探针通过原生输入继续编辑源卡,校验完整文字增量;副本随后仍须保留复制起点文本。
|
||||
if(concurrentInput){duringCopyInput=await editDuringCopy({call,evaluate,wait,count});assert.equal(duringCopyInput.value,sourceText+(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'concurrent typing lost pre-existing source text');console.log('PERF concurrent input '+JSON.stringify(duringCopyInput));}
|
||||
await wait(`document.querySelectorAll('.react-flow__node').length>0&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'large copy did not finish rendering and synchronization');
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
// 完成进度与已同步后再等两帧,要求 renderer 测量出现合批;统计批量大小而非只计渲染总数。
|
||||
const internalsBatches=await evaluate('window.__fixtureInternalsBatches??[]');assert.ok(internalsBatches.some(size=>size>1),'mounted nodes did not share a renderer measurement batch');
|
||||
// 停止页面采样并验证服务元素数、复制 canvas 和上级连线。firstPresentedFrame 是检测新 DOM 后的下一次 rAF 近似值,不是 compositor 上屏证明。
|
||||
const measurements=await evaluate('window.__canvasPerformance.stop()'),document=documents.get(root),copiedCanvas=[...document.canvases.values()].find(canvas=>canvas.parent&&canvas.id!==partition);
|
||||
assert.equal(document.elements.size,2*count+5);assert.ok(copiedCanvas);assert.ok([...document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId==='perf-source'&&element.target.elementId===copiedCanvas.parent.entryElementId));assert.ok(document.elements.has('perf-incoming'));assert.ok(measurements.firstPresentedFrameMs!==null&&measurements.localCompleteFrameMs!==null);if(count>=100)assert.ok(measurements.nodeCount<target,'offscreen DOM was not culled');assert.ok(await evaluate(`document.querySelector('[aria-label="画布导入"]').textContent.includes('${count+3} 项')`),'copy progress did not include all source records');
|
||||
if(concurrentInput){
|
||||
@@ -105,17 +133,26 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par
|
||||
assert.equal(copiedFirst?.data.text,sourceText,'copy snapshot absorbed a later original edit');
|
||||
assert.equal(document.elements.get('perf-0').data.text,duringCopyInput.value,'ACK overwrote concurrent original text');
|
||||
}
|
||||
// 再次核对整个配置/复制场景没有启动 AI 任务;生成计费不属于深复制的副作用。
|
||||
const runStartsDuringScenario=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length-runStartsBefore;
|
||||
assert.equal(runStartsDuringScenario,0,'deep copy or settings unexpectedly started generation');
|
||||
// 离屏源仍需保留两条可见分区连线,随后把最后一个副本节点平移到屏幕内,检查实际挂载。
|
||||
const visibleEdges=await evaluate('document.querySelectorAll(".react-flow__edge").length');assert.equal(visibleEdges,2,'frame connections did not render for the offscreen source');
|
||||
// Completion is a model/progress check. Verify an offscreen copied card actually mounts after panning.
|
||||
// 进度完成只证明模型/作业状态;还要平移后看到副本卡片实际挂载。
|
||||
// 按原节点局部位置找到复制后的末项,而非只依赖进度或服务记录宣称渲染完成。
|
||||
const originalLast=document.elements.get(`perf-${count-1}`),copiedLast=[...document.elements.values()].find(element=>element.structure==='node'&&element.ownerCanvasId===copiedCanvas.id&&element.geometry.x===originalLast.geometry.x&&element.geometry.y===originalLast.geometry.y);assert.ok(copiedLast);
|
||||
// 从副本 owner 的局部坐标换算世界中心,给页面 viewport 平移定位使用。
|
||||
const world=toWorld(canvasTransform(document,copiedLast.ownerCanvasId),{x:copiedLast.geometry.x+copiedLast.geometry.width/2,y:copiedLast.geometry.y+copiedLast.geometry.height/2});
|
||||
await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect(),matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(${world.x}*matrix.a+matrix.e-rect.width/2)/.5,deltaY:(${world.y}*matrix.a+matrix.f-rect.height/2)/.5,bubbles:true,cancelable:true}));})()`);
|
||||
await wait(`!!document.querySelector('.react-flow__node[data-id="${copiedLast.id}"] textarea')`,'offscreen copied card did not mount after viewport pan');
|
||||
// 记录平移后的实际挂载数量,和之前的可见连线数一起写入报告。
|
||||
const viewportCheck={visibleEdgesBeforePan:visibleEdges,copiedId:copiedLast.id,mountedAfterPan:await evaluate('document.querySelectorAll(".react-flow__node").length')};
|
||||
// 报告同时保存输入断言上下文、浏览器/构建模式、采样、fixture 文档计数与测量批次,便于跨环境比较。
|
||||
report={inputCheck,duringCopyInput,runStartsDuringScenario,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',mode:`Next ${buildMode} ${bundler}; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks`,count,initialReadyMs,buildMs,internals:{batches:internalsBatches.length,maxBatch:Math.max(0,...internalsBatches),totalRequests:internalsBatches.reduce((sum,size)=>sum+size,0)},measurements,viewportCheck,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started};
|
||||
// 仅成功路径截图,输出到报告同名 PNG;截图是场景结束视口,不是每个计时点的屏幕证据。
|
||||
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile(reportPath.endsWith('.json')?reportPath.slice(0,-5)+'.png':reportPath+'.png',Buffer.from(screenshot.data,'base64'));
|
||||
// 失败也尽力停止页面采样并写诊断报告,然后重抛;不能把部分观测输出当作场景通过。
|
||||
}catch(error){let measurements;try{measurements=await evaluate('window.__canvasPerformance?.stop()');}catch{}report={duringCopyInput,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',count,buildMode,buildMs,error:error.message,measurements,serverElements:documents.get(root)?.elements.size,serverCanvases:documents.get(root)?.canvases.size,wallMs:Date.now()-started};await writeFile(reportPath,JSON.stringify(report,null,2));throw error;}
|
||||
// 成功报告落盘后打印紧凑摘要,长任务明细仍保存在 JSON 文件。
|
||||
await writeFile(reportPath,JSON.stringify(report,null,2));console.log('MEASURE '+JSON.stringify({...report,measurements:{...report.measurements,longTasks:undefined}}));console.log('REPORT '+reportPath);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
/**
|
||||
* 渲染定义目录与 React 组装的契约测试。模型 registry 定义数据与能力,renderer catalog 定义宿主如何显示,两者按类型/版本/结构匹配。
|
||||
* 这里检查描述捕获、投影记录与函数身份,不挂载真实 ReactFlow 页面。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRendererCatalog,builtinRendererDefinitions} from '../../src/lib/canvas-bindings/renderers.ts';
|
||||
import {createReactRenderers} from '../../src/lib/canvas-bindings/react-renderers.ts';
|
||||
import {createDocument,createRegistry,builtinComponents,defineComponent,movable,createEditor} from '../../src/lib/canvas-core/index.ts';
|
||||
import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts';
|
||||
// 自定义计量节点的精确渲染键,不允许用同一映射自动兼容未来版本或不同 structure。
|
||||
const descriptor={componentId:'plugin:meter',dataVersion:1,structure:'node',rendererId:'meter'};
|
||||
// 模型独立声明 metric 校验和移动能力;同一个模型可由多个宿主使用不同渲染器。
|
||||
const definition=defineComponent({id:'plugin:meter',dataVersion:1,structure:'node',validate:data=>typeof data.metric==='number',features:[movable()]}),registry=createRegistry([...builtinComponents(),definition]);
|
||||
// 保留不透明标题和看似内部字段的数据,用来核对投影注入的可信原记录不会被用户 data 冒充。
|
||||
const node={id:'meter',incarnation:1,ownerCanvasId:'root',structure:'node',componentId:'plugin:meter',dataVersion:1,data:{metric:7,title:{opaque:true},__headlessRecord:{untrusted:true}},geometry:{x:0,y:0,width:200,height:100,rotation:0}};
|
||||
// 目录只捕获不可变元数据,不持有 React render 值;输入描述后续变化无效,未来版本/不同结构不匹配,重复或保留 ID 被拒绝。
|
||||
test('renderer catalog captures exact version/structure mappings without React values or mutable descriptors',()=>{
|
||||
const input={...descriptor,render(){}};const catalog=createRendererCatalog([input]);input.rendererId='changed';assert.equal(catalog.resolve(node).rendererId,'meter');assert.equal(catalog.resolve({...node,dataVersion:2}),undefined);assert.equal(catalog.resolve({...node,structure:'connection'}),undefined);assert.equal(catalog.definitions[0].render,undefined);assert.ok(Object.isFrozen(catalog.definitions[0]));assert.throws(()=>createRendererCatalog([descriptor,descriptor]),/duplicate_renderer_definition/);assert.throws(()=>createRendererCatalog([{...descriptor,rendererId:'unsupported'}]),/invalid_renderer_definition/);
|
||||
});
|
||||
// 组装仅在同一个 render 实现时共享别名包装;拒绝空 renderer、ID 冲突与模型契约不符,getter 只读取一次并保留 memo 对象。
|
||||
test('React assembly preserves aliases only for the same component and rejects invalid renderers or model contracts',()=>{
|
||||
const render=()=>null,assembly=createReactRenderers([{...descriptor,render}],registry);assert.equal(assembly.nodeTypes.meter.type,render);assert.ok(Object.isFrozen(assembly.nodeTypes));assert.equal(assembly.edgeTypes.meter,undefined);
|
||||
assert.throws(()=>createReactRenderers([{...descriptor,render:null}]),/invalid_react_renderer/);assert.throws(()=>createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render:()=>null}]),/renderer_id_conflict/);assert.throws(()=>createReactRenderers([{...descriptor,dataVersion:2,render}],registry),/renderer_model_mismatch/);
|
||||
let reads=0;const getter=createReactRenderers([{...descriptor,get render(){reads++;return render;}}]);assert.equal(reads,1);assert.equal(getter.definitions[0].render,render);assert.equal(getter.nodeTypes.meter,assembly.nodeTypes.meter);
|
||||
const alias=createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render}]);assert.equal(alias.nodeTypes.meter,assembly.nodeTypes.meter);const memo={$$typeof:Symbol.for('react.memo'),type:render};assert.equal(createReactRenderers([{...descriptor,render:memo}]).nodeTypes.meter,memo);
|
||||
});
|
||||
// 自定义节点与边投影携带原记录和 transform,未知 data/path 不丢;未安装映射或未来 schema 走 unsupported。
|
||||
test('installed node and edge renderers receive original records and transforms while absent schemas still fall back',()=>{
|
||||
const customEdge=defineComponent({id:'plugin:edge',dataVersion:1,structure:'connection',validate:()=>true,features:[]}),r=createRegistry([...builtinComponents(),definition,customEdge]),other={...node,id:'second',geometry:{...node.geometry,x:300}},edge={id:'edge',incarnation:1,structure:'connection',componentId:'plugin:edge',dataVersion:1,ownerCanvasId:'root',source:{elementId:'meter',portId:'custom-out'},target:{elementId:'second',portId:'custom-in'},path:{type:'custom',retained:true},data:{path:{own:true},metric:99}},source=createDocument('project',[{id:'root',parent:null}],[node,other,edge]),catalog=createRendererCatalog([...builtinRendererDefinitions,descriptor,{...descriptor,componentId:'plugin:edge',structure:'connection',rendererId:'meter-edge'}]),p=projectCanvas(source,'root',r,new Set(),()=>undefined,null,catalog);
|
||||
assert.equal(p.nodes[0].type,'meter');assert.equal(p.nodes[0].data.__headlessFallback,undefined);assert.equal(p.nodes[0].data.__headlessRecord,source.elements.get('meter'));assert.deepEqual(p.nodes[0].data.__headlessRecord.data.title,{opaque:true});assert.equal(p.nodes[0].data.__headlessTransform.scale,1);assert.equal(p.edges[0].type,'meter-edge');assert.equal(p.edges[0].data.__headlessRecord,source.elements.get('edge'));assert.deepEqual(p.edges[0].data.__headlessRecord.path,{type:'custom',retained:true});
|
||||
const missing=projectCanvas(source,'root',r,new Set(),()=>undefined);assert.equal(missing.nodes[0].type,'unsupported');assert.equal(missing.edges[0].type,'unsupported');const future=createDocument('project',[{id:'root',parent:null}],[{...node,dataVersion:2}]);assert.equal(projectCanvas(future,'root',r,new Set(),()=>undefined,null,catalog).nodes[0].type,'unsupported');
|
||||
});
|
||||
// 加载前预览必须显式提供 preview,实现与正式 renderer 独立捕获;非法 preview 或不一致别名被拒绝。
|
||||
test('pre-engine preview renderers are explicit and captured independently from engine-bound renderers',()=>{
|
||||
const render=()=>null,preview=()=>null,assembly=createReactRenderers([{...descriptor,render,preview}]);assert.equal(assembly.nodeTypes.meter.type,render);assert.equal(assembly.previewNodeTypes.meter.type,preview);assert.equal(assembly.definitions[0].preview,preview);assert.ok(Object.isFrozen(assembly.previewNodeTypes));assert.equal(createReactRenderers([{...descriptor,render}]).previewNodeTypes.meter,undefined);assert.throws(()=>createReactRenderers([{...descriptor,render,preview:0}]),/invalid_react_preview/);assert.throws(()=>createReactRenderers([{...descriptor,render,preview},{...descriptor,componentId:'other',render}]),/renderer_id_conflict/);
|
||||
});
|
||||
|
||||
// 两个宿主对同一 component 选择不同 rendererId,但共享同一文档记录与移动结果,命令仍只产生一个历史单元。
|
||||
test('independent host renderers for one component share model data and capability behavior',()=>{
|
||||
const editor=createEditor({registry,document:createDocument('project',[{id:'root',parent:null}],[node])}),one=createReactRenderers([{...descriptor,rendererId:'host-one',render:()=>null}],registry),two=createReactRenderers([{...descriptor,rendererId:'host-two',render:()=>null}],registry);
|
||||
assert.equal(editor.dispatch({type:'element.geometry',target:{id:node.id,incarnation:node.incarnation},patch:{x:42}}).status,'applied');
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
/**
|
||||
* 运行任务轮询器的暂停与终止测试。hold 只阻止下一次轮询,允许已发出的响应更新并保存游标。
|
||||
* 可控 Promise 与假时钟分别控制响应和后续调度;这里不连接网络或真实存储。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRunTracker } from '../../src/lib/canvas-runtime/runs.ts';
|
||||
|
||||
// 推进有限轮微任务,让轮询链执行;不会推进假时钟或主动触发未来的定时器。
|
||||
const tick = async () => { for (let index=0;index<30;index++)await Promise.resolve(); };
|
||||
// 由测试决定首个轮询何时返回,用来把 hold 放在请求已发出但响应尚未完成的位置。
|
||||
const deferred = () => { let resolve;const promise=new Promise(yes=>{resolve=yes;});return{promise,resolve}; };
|
||||
// 搭建一个运行中的任务:首轮响应可挂起,后续响应直接完成,保存记录用于核对落盘游标。
|
||||
function fixture() {
|
||||
let now=0,next=0;const timers=new Map(),runs=new Map([['run',{id:'run',status:'running',cursor:0,hasMore:true,attempt:0}]]),calls=[],saved=[],response=deferred();
|
||||
const tracker=createRunTracker({clock:{now:()=>now,setTimer(callback,delay){const id=++next;timers.set(id,{callback,due:now+delay});return id;},clearTimer:id=>timers.delete(id)},getRuns:()=>runs,async persist(){saved.push(structuredClone([...runs]));},async poll(run){calls.push(run);return calls.length===1?response.promise:{status:'succeeded',events:[],next:2,hasMore:false,outputs:[],steps:[]};},accept(run,page){runs.set(run.id,{...runs.get(run.id),status:page.status,cursor:page.next,hasMore:page.hasMore});},update(id,reduce){runs.set(id,reduce(runs.get(id)));},onError(error){throw error;}});
|
||||
return{tracker,runs,calls,saved,response,timers,async advance(ms){now+=ms;for(const[id,timer]of[...timers])if(timer.due<=now){timers.delete(id);timer.callback();}await tick();}};
|
||||
}
|
||||
|
||||
// 两个 hold 共同阻止新轮询;在途响应仍保存 cursor=1,重复释放同一 hold 无效,最后释放后从该游标继续。
|
||||
test('run holds preserve a completed cursor and notifications without launching another poll',async()=>{
|
||||
const f=fixture();f.tracker.notify(['run']);await f.advance(0);assert.equal(f.calls.length,1);
|
||||
const releaseA=f.tracker.hold(),releaseB=f.tracker.hold();
|
||||
@@ -21,6 +29,7 @@ test('run holds preserve a completed cursor and notifications without launching
|
||||
f.tracker.stop();
|
||||
});
|
||||
|
||||
// stop 是终态:即使先前有待处理通知,随后释放 hold 也不能恢复定时器或请求,关闭后不再允许申请 hold。
|
||||
test('releasing a run hold after stop cannot restart a poll',async()=>{
|
||||
const f=fixture(),release=f.tracker.hold();f.tracker.notify(['run']);f.tracker.stop();release();await f.advance(10000);
|
||||
assert.equal(f.calls.length,0);assert.equal(f.timers.size,0);assert.throws(()=>f.tracker.hold(),/run_tracker_closed/);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 运行任务从受理、轮询到取消及显式应用结果的引擎测试。真实 engine 与 Foundry 适配器连接内存服务替身。
|
||||
* 账本记录一次请求的执行证据,RunRecord 记录持续执行和事件游标,history 只记录可撤销的内容修改。
|
||||
* 这里验证客户端协议与恢复逻辑;服务端数据库和实际模型执行由单独的远程集成用例覆盖。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDocument, applyChanges, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -6,23 +11,37 @@ import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
import { createFoundryPersistence, createFoundryRuns, createFoundryTransport, mergeFoundryRunContext } from '../../src/lib/canvas-service/index.ts';
|
||||
import { createNetworkGate } from '../../src/lib/canvas-runtime/network.ts';
|
||||
|
||||
// 固定作用域和目标身份;请求 ID 使用 UUID 形式,元素身份由 id 与 incarnation 共同确定。
|
||||
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,root=id(1),child=id(2),workspace=id(3),account=id(4),target={id:'a',incarnation:1};
|
||||
// 使用内置类型和完整文本几何,避免以不合法的模型绕过结果校验。
|
||||
const registry=createRegistry(builtinComponents()),node=(name,owner=root)=>({id:name,incarnation:1,structure:'node',ownerCanvasId:owner,componentId:'text',dataVersion:1,data:{title:name,text:'original'},geometry:{x:0,y:0,width:260,height:200,rotation:0}});
|
||||
// 根 canvas 包含目标和分区入口,子 canvas 中保留未知插件数据,用来检查局部回执不会清掉无关内容。
|
||||
const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'f'}}],[node('a'),{...node('f'),componentId:'frame'}, {...node('future',child),componentId:'plugin.future',dataVersion:9,data:{opaque:{keep:true}}}]);
|
||||
// 所有默认生成请求针对同一元素;各场景显式替换参数,便于辨别第二次调用和原请求恢复。
|
||||
const runInput={target,boardId:root,modelId:'text-model',prompt:'Generate',parameters:{},propagate:false};
|
||||
// 返回手动完成的 Promise,把测试停在文档写入、任务受理或轮询返回的边界。
|
||||
const defer=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};};
|
||||
// 仅推进有限轮微任务,适用于故意保持网络挂起、不能等待整个 engine.settle 的阶段。
|
||||
const tick=async()=>{for(let i=0;i<100;i++)await Promise.resolve();};
|
||||
// 假时钟保存到期任务;advance 最多执行 100 个到期回调并推进微任务,避免真实等待。
|
||||
function clockFixture(){let now=1000,next=0;const timers=new Map();return{now:()=>now,setTimer(fn,delay){const id=++next;timers.set(id,{fn,due:now+delay});return id;},clearTimer(id){timers.delete(id);},async advance(ms){now+=ms;for(let count=0;count<100;count++){const ready=[...timers].find(([,value])=>value.due<=now);if(!ready)break;timers.delete(ready[0]);ready[1].fn();await tick();}await tick();},timers};}
|
||||
// 内存存储模拟独占 owner、恢复镜像和 quota 故障;复制镜像避免后续内存修改偷改已保存数据。
|
||||
function storageFixture(){let image,owner;const storage={broken:false,saved:[],async claim(scope){assert.equal(owner,undefined);owner={scope,active:true,fence:1,async release(){owner.active=false;owner=undefined;}};return owner;},async recover(){return image&&structuredClone(image);},async commit(leader,batch){assert.equal(leader,owner);if(storage.broken)throw new Error('quota');image=structuredClone(batch.image);storage.saved.push(image);return{durableRootRevision:image.rootRevision};},async readFile(){},close(){},image:()=>structuredClone(image)};return storage;}
|
||||
// 组合内存服务、真实适配器、存储及可控时钟;请求回执按 requestId 保存,响应丢失不撤销已发生的服务端修改。
|
||||
function fixture(){
|
||||
let document=original,versions=new Map([[root,1],[child,7]]),next=100,runCounter=500,postGate,docGate;
|
||||
const storage=storageFixture(),clock=clockFixture(),calls=[],receipts=new Map(),runs=new Map(),errors=[];
|
||||
// 只返回指定 canvas 的成员和版本,模拟普通局部回执;墓碑仍来自服务端文档。
|
||||
const chunk=board=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[document.canvases.get(board)],elements:[...document.elements.values()].filter(element=>element.ownerCanvasId===board),tombstones:Object.fromEntries(document.tombstones),versions:{[board]:versions.get(board)}});
|
||||
// 组合多个参与 canvas 的局部上下文,版本只包含这些 canvas;不是整份项目替换。
|
||||
const partial=ids=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:ids.map(id=>document.canvases.get(id)),elements:[...document.elements.values()].filter(element=>ids.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(ids.map(id=>[id,versions.get(id)]))});
|
||||
// 将所有 canvas 的局部数据标记为完整上下文,供跨 owner 结果协调使用。
|
||||
const context=()=>({...partial([...document.canvases.keys()]),complete:true});
|
||||
// 记录每次请求并按路径分派;未识别路径立即报错,防止测试意外走未实现的服务分支。
|
||||
const transport=async request=>{
|
||||
calls.push(request);
|
||||
if(request.method==='GET'&&request.path.endsWith('/model'))return{status:200,body:{model:context()}};
|
||||
// 文档写入可被屏障挂起;首次请求检查参与版本,成功才修改文档并递增版本,重复请求读取原回执。
|
||||
if(request.path.endsWith('/model/operations')){
|
||||
if(docGate)await docGate.promise;
|
||||
const input=JSON.parse(request.body);let body=receipts.get(request.requestId);
|
||||
@@ -33,6 +52,7 @@ function fixture(){
|
||||
}
|
||||
return{status:body.status==='applied'?200:412,body};
|
||||
}
|
||||
// 取消只改变任务控制状态;应用输出还检查元素当前 owner 和版本,写入文本后生成可查询的成功回执。
|
||||
if(request.method==='POST'&&/\/ai\/runs\/[^/]+\/(cancel|apply)$/.test(request.path)){
|
||||
const action=request.path.endsWith('/cancel')?'run.cancel':'run.apply',runId=request.path.split('/').at(-2),run=runs.get(runId),input=JSON.parse(request.body);let body=receipts.get(request.requestId);
|
||||
if(!body){
|
||||
@@ -50,6 +70,7 @@ function fixture(){
|
||||
if(action==='run.cancel'&&f.loseCancel||action==='run.apply'&&f.loseApply)return{status:503,body:{error:'response_lost'}};
|
||||
return{status:200,body};
|
||||
}
|
||||
// 受理与执行分开:受理写入 AI 配置并冻结计划,但不生成文本;零写入拒绝和成功都留下可查询证据。
|
||||
if(request.method==='POST'&&request.path.endsWith('/ai/runs')){
|
||||
const input=JSON.parse(request.body);let body=receipts.get(request.requestId);
|
||||
if(!body){
|
||||
@@ -66,7 +87,9 @@ function fixture(){
|
||||
if(f.loseAdmission)return{status:503,body:{error:'response_lost'}};
|
||||
return{status:202,body};
|
||||
}
|
||||
// 原 requestId 查询是响应丢失后的恢复依据;没有保存回执只能返回 unknown,不能推定未执行。
|
||||
if(request.path.includes('/ai/operations/')){const receipt=receipts.get(request.path.split('/').at(-1));return receipt?{status:200,body:receipt}:{status:404,body:{protocol_version:2,request_id:request.path.split('/').at(-1),status:'unknown'}};}
|
||||
// 按 after_seq 分页读取事件;先捕获响应再等待 pollGate,能重现取消确认之后才返回的旧轮询。
|
||||
if(request.path.includes('/ai/runs/')){
|
||||
const url=new URL(request.path,'https://fixture.test'),runId=url.pathname.split('/').at(-1),run=runs.get(runId),after=Number(url.searchParams.get('after_seq'));
|
||||
const events=run.events.filter(event=>event.seq>after).slice(0,f.pageSize??100);
|
||||
@@ -74,10 +97,14 @@ function fixture(){
|
||||
}
|
||||
throw new Error('Unexpected '+request.path);
|
||||
};
|
||||
// 暴露故障开关与观测结果;open 每次建立新引擎,但复用同一内存服务和已落盘镜像。
|
||||
const f={storage,clock,calls,runs,errors,receipts,transport,loseAdmission:false,
|
||||
holdAdmission(){postGate=defer();return postGate;},holdDocument(){docGate=defer();return docGate;},
|
||||
// 真实 runs 端口的 accept 可注入解释失败:即使已保存成功回执,也必须等能正确解释后才发布本地状态。
|
||||
async open(){return openProjectEngine({scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map([[root,1],[child,7]]),storage,clock,createId:()=>id(++next),persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport}),runs:(()=>{const port=createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport});return{...port,accept(...args){if(f.failAdmissionCodec)throw Error('codec_temporarily_unavailable');return port.accept(...args);},acceptAction(...args){if(f.failActionCodec)throw Error('codec_temporarily_unavailable');return port.acceptAction(...args);}};})(),onError:error=>errors.push(error)});},
|
||||
// 模拟其他客户端只改变元素归属,同时推进旧、新 owner 的版本;目标 id 与 incarnation 不变。
|
||||
move(owner){const element=document.elements.get('a');document=applyChanges(document,[{kind:'field',target,field:'ownerCanvasId',before:element.ownerCanvasId,after:owner}]);versions.set(element.ownerCanvasId,versions.get(element.ownerCanvasId)+1);versions.set(owner,versions.get(owner)+1);},
|
||||
// 完成最新任务:普通结果写入当前 owner,冲突结果只保留输出;两者都追加输出、结果和完成事件。
|
||||
finish(text='generated',conflict=false){
|
||||
const [runId,run]=[...runs].at(-1),element=document.elements.get('a'),owner=element.ownerCanvasId;
|
||||
if(!conflict){document=applyChanges(document,[{kind:'field',target,field:'data.text',before:element.data.text,after:text,beforePresent:true,afterPresent:true}]);versions.set(owner,versions.get(owner)+1);}
|
||||
@@ -89,6 +116,7 @@ function fixture(){
|
||||
};return f;
|
||||
}
|
||||
|
||||
// 先前编辑完成后才受理,受理确认后放行后续移动;生成执行期间目标仍 busy,但文档 writer 不再被任务长期占用。分页游标和输出可恢复。
|
||||
test('AI admission orders prior and later edits, persists its job, and does not hold the writer for execution',async()=>{
|
||||
const f=fixture(),engine=await f.open(),gate=f.holdAdmission();const detach=engine.sessions.attach('view');
|
||||
engine.editor.dispatch({type:'element.data',target,patch:{title:'before click'}});
|
||||
@@ -114,6 +142,7 @@ test('AI admission orders prior and later edits, persists its job, and does not
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 服务已受理但响应丢失时重开引擎,查询原请求恢复同一任务和请求体;POST 次数保持一次,随后正常接收结果。
|
||||
test('lost AI admission response recovers its original request and run without a second invocation',async()=>{
|
||||
const f=fixture();f.loseAdmission=true;const engine=await f.open();engine.startRun(runInput);await engine.settle();
|
||||
assert.equal(engine.getSnapshot().runtime.runs.size,0);const frozen=[...engine.getSnapshot().runtime.ledger.requests.values()][0];await engine.close();
|
||||
@@ -124,6 +153,7 @@ test('lost AI admission response recovers its original request and run without a
|
||||
f.finish();await f.clock.advance(1000);await recovered.settle();assert.equal(recovered.getSnapshot().document.elements.get('a').data.text,'generated');await recovered.close();
|
||||
});
|
||||
|
||||
// 事件先等待未决文档回执,再暴露内容冲突;保留本地文字、生成输出和未消费页面,重开后也不能丢失它们。
|
||||
test('run event waits for outstanding receipts and retains a content conflict, output and page across recovery',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();
|
||||
const gate=f.holdDocument();engine.editor.dispatch({type:'element.data',target,patch:{text:'local edit'}});await tick();f.finish();
|
||||
@@ -135,6 +165,7 @@ test('run event waits for outstanding receipts and retains a content conflict, o
|
||||
const recovered=await f.open();assert.equal(recovered.getSnapshot().runtime.runs.get(run.id).pendingPage.next,4);assert.equal(recovered.getSnapshot().document.elements.get('a').data.text,'local edit');await recovered.close();
|
||||
});
|
||||
|
||||
// 先明确解决旧版本文档请求,再刷新规范基线;后到的旧事件不得回退新文字或重放后的本地几何。
|
||||
test('older run event cannot rewind a later canonical document receipt',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.finish();
|
||||
engine.editor.dispatch({type:'element.geometry',target,patch:{x:500}});await engine.settle();
|
||||
@@ -145,6 +176,7 @@ test('older run event cannot rewind a later canonical document receipt',async()=
|
||||
assert.equal([...engine.getSnapshot().runtime.runs.values()][0].cursor,4);await engine.close();
|
||||
});
|
||||
|
||||
// 本地镜像保存失败时不发受理请求;恢复存储后可继续,关闭期间迟到的轮询结果不得改变根修订或文档。
|
||||
test('run durability failure prevents admission and close fences late poll responses',async()=>{
|
||||
const f=fixture(),engine=await f.open();await engine.persist();f.storage.broken=true;engine.startRun(runInput);await tick();assert.equal(f.calls.length,0);
|
||||
f.storage.broken=false;await engine.retry();await f.clock.advance(0);await f.clock.advance(1000);await engine.settle();assert.equal(f.runs.size,1);
|
||||
@@ -153,6 +185,7 @@ test('run durability failure prevents admission and close fences late poll respo
|
||||
assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'original');
|
||||
});
|
||||
|
||||
// 读、写和轮询共用容量为二的网络门;排队请求被 abort 后退出,剩余请求完成时活跃数归零。
|
||||
test('transport gate bounds combined run/read/write capacity and drops aborted waiters',async()=>{
|
||||
const gate=createNetworkGate(2),release=defer(),controller=new AbortController();let active=0,max=0;
|
||||
const execute=signal=>gate.run(signal,async()=>{max=Math.max(max,++active);await release.promise;active--;});
|
||||
@@ -161,6 +194,7 @@ test('transport gate bounds combined run/read/write capacity and drops aborted w
|
||||
});
|
||||
|
||||
|
||||
// 篡改受理响应的 incarnation 后保留成功证据但暂不发布任务或 AI 配置;查询原回执修复,无需第二次 POST。
|
||||
test('malformed admission identity keeps its receipt and claims until the original query repairs it',async()=>{
|
||||
const f=fixture(),real=f.transport;
|
||||
const runs=createFoundryRuns({workspaceId:workspace,rootId:root,registry,transport:async request=>{const response=await real(request);return request.method==='POST'?{...response,body:{...response.body,target_incarnation:99}}:response;}});
|
||||
@@ -170,6 +204,7 @@ test('malformed admission identity keeps its receipt and claims until the origin
|
||||
await engine.retry();await f.clock.advance(0);await engine.settle();assert.equal(engine.getSnapshot().runtime.runs.size,1);assert.equal(f.calls.filter(call=>call.method==='POST').length,1);await engine.close();
|
||||
});
|
||||
|
||||
// 外来 incarnation 的事件不能覆盖当前内容;恢复镜像中篡改目标代次或请求 owner 同样必须被拒绝。
|
||||
test('foreign event identity and corrupted persisted run bindings cannot overwrite the working document',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.finish();
|
||||
const remote=[...f.runs.values()][0];remote.events[2].target_incarnation=99;
|
||||
@@ -182,6 +217,7 @@ test('foreign event identity and corrupted persisted run bindings cannot overwri
|
||||
});
|
||||
|
||||
|
||||
// 即使事件声明更高 canvas 版本,也不能用旧 incarnation 的结果覆盖已经恢复成第二代的同 ID 元素。
|
||||
test('even a higher-version malformed event cannot restore an old incarnation',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.finish();
|
||||
const server=f.server(),records=[...server.document.elements.values()].map(element=>element.id==='a'?{...element,incarnation:2,data:{...element.data,text:'restored content'}}:element),restored=createDocument(root,[...server.document.canvases.values()],records,new Map([['a',1]])),nextVersion=server.versions.get(root)+1;
|
||||
@@ -191,6 +227,7 @@ test('even a higher-version malformed event cannot restore an old incarnation',a
|
||||
});
|
||||
|
||||
|
||||
// 受理拒绝的响应丢失后先保持排序阻塞;查到绑定原请求的 terminal 零写入证据才放行后续编辑,篡改该证据会使镜像校验失败。
|
||||
test('queryable zero-effect admission rejection releases ordering without discarding later editing',async()=>{
|
||||
const f=fixture();f.rejectAdmission=true;f.loseAdmission=true;const engine=await f.open();const started=engine.startRun(runInput);engine.editor.dispatch({type:'element.geometry',target,patch:{x:700}});await engine.settle();
|
||||
assert.equal(f.server().document.elements.get('a').geometry.x,0);await engine.close();f.loseAdmission=false;
|
||||
@@ -199,6 +236,7 @@ test('queryable zero-effect admission rejection releases ordering without discar
|
||||
assert.equal(f.server().document.elements.get('a').geometry.x,700);assert.equal(f.calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1);assert.equal(recovered.getSnapshot().history.undo.length,1);const decoded=decodeRecovery(f.storage.image(),registry);assert.equal(decoded.runtime.ledger.operations.get(started.operationIds[0]).resolution,'not-applied');const corrupt=f.storage.image();corrupt.payload.runtime.receipts[0][1].terminal=false;assert.throws(()=>decodeRecovery(corrupt,registry),/corrupt_recovery_image/);await recovered.close();
|
||||
});
|
||||
|
||||
// 取消成功但响应丢失,重开后只查询原动作并恢复 cancelled;取消不创建内容历史,也不声明 canvas 写入参与者。
|
||||
test('durable cancellation survives response loss and does not create content history',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0];f.loseCancel=true;
|
||||
const cancelled=engine.cancelRun(run.id);assert.equal(cancelled.status,'applied');await engine.settle();assert.equal(f.runs.get(run.id).status,'cancelled');assert.equal(engine.getSnapshot().history.undo.length,0);
|
||||
@@ -206,6 +244,7 @@ test('durable cancellation survives response loss and does not create content hi
|
||||
const recovered=await f.open();await f.clock.advance(5000);await recovered.settle();assert.equal(recovered.getSnapshot().runtime.ledger.operations.get(cancelled.operationIds[0]).phase,'resolved');assert.equal(recovered.getSnapshot().runtime.runs.get(run.id).status,'cancelled');assert.equal(f.calls.filter(call=>call.method==='POST'&&call.path.endsWith('/cancel')).length,1);assert.ok(f.calls.some(call=>call.path.endsWith('/ai/operations/'+request.id)));await recovered.close();
|
||||
});
|
||||
|
||||
// 显式应用立即形成可撤销内容修改;结果未知时撤销仍保留,恢复后先确认原应用再同步逆向编辑,redo 不重新生成或重复 apply。
|
||||
test('explicit output application is immediate and undoable while unknown; recovery and redo never regenerate',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.finish('chosen output',true);await f.clock.advance(0);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0];assert.equal(run.status,'conflict');
|
||||
f.loseApply=true;const applied=engine.applyRunOutput(run.id,target,id(999));assert.equal(applied.status,'applied');assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'chosen output');assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(engine.getSnapshot().operations.get(applied.operationIds[0]).operationType,'run.apply');await engine.settle();
|
||||
@@ -215,6 +254,7 @@ test('explicit output application is immediate and undoable while unknown; recov
|
||||
});
|
||||
|
||||
|
||||
// 取消确认推进 controlEpoch;此前捕获的旧轮询不能回退 cancelled,也不能提前推进游标,下一轮新响应才消费取消事件。
|
||||
test('a poll captured before cancellation cannot regress the acknowledged control state',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0];f.pollGate=defer();await f.clock.advance(0);await tick();
|
||||
engine.cancelRun(run.id);await tick();assert.equal(engine.getSnapshot().runtime.runs.get(run.id).status,'cancelled');assert.equal(engine.getSnapshot().runtime.runs.get(run.id).controlEpoch,1);
|
||||
@@ -222,6 +262,7 @@ test('a poll captured before cancellation cannot regress the acknowledged contro
|
||||
await f.clock.advance(1);await engine.settle();assert.equal(engine.getSnapshot().runtime.runs.get(run.id).status,'cancelled');assert.equal(engine.getSnapshot().runtime.runs.get(run.id).cursor,2);await engine.close();
|
||||
});
|
||||
|
||||
// 分别检查 start、cancel、apply 的零写入证据绑定;字段不匹配或只有 HTTP 错误时仍未知,权限错误仅增加 blocked。
|
||||
test('AI action rejection proof must match request, operation, action and run; bare errors remain unknown',async()=>{
|
||||
const requestId=id(8800),operationId=id(8801),runId=id(8802),signal=new AbortController().signal;
|
||||
for(const kind of ['run.start','run.cancel','run.apply']){
|
||||
@@ -236,29 +277,34 @@ test('AI action rejection proof must match request, operation, action and run; b
|
||||
});
|
||||
|
||||
|
||||
// 任务结果跟随同 incarnation 的外部 owner 迁移;文档中仍只有一个元素,原计划归属不改,自动结果不重复写入用户历史。
|
||||
test('a completed run follows an external owner-only move and publishes one global element without duplicate history',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.move(child);f.finish('moved output');await f.clock.advance(0);await engine.settle();
|
||||
const snapshot=engine.getSnapshot(),run=[...snapshot.runtime.runs.values()][0];assert.equal(snapshot.document.elements.get('a').ownerCanvasId,child);assert.equal(snapshot.document.elements.get('a').data.text,'moved output');assert.equal([...snapshot.document.elements.values()].filter(element=>element.id==='a').length,1);assert.equal(snapshot.document.elements.get('future').data.opaque.keep,true);assert.equal(snapshot.history.undo.length,0);assert.equal(run.boardId,root);assert.equal(run.plan.steps[0].board_id,root);assert.equal(run.cursor,4);
|
||||
assert.equal(decodeRecovery(f.storage.image(),registry).document.elements.get('a').ownerCanvasId,child);await engine.close();
|
||||
});
|
||||
|
||||
// 迁移上下文等待所有受影响 canvas 的未决回执;协调完成后保留无关分区入口的本地标题。
|
||||
test('a relocated context waits for receipts in every affected canvas and preserves an unrelated pending title',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const gate=f.holdDocument();engine.editor.dispatch({type:'element.data',target:{id:'f',incarnation:1},patch:{title:'local title'}});await tick();f.move(child);f.finish();await f.clock.advance(0);await tick();
|
||||
assert.equal([...engine.getSnapshot().runtime.runs.values()][0].error,'run_waiting_for_receipts');assert.equal(engine.getSnapshot().document.elements.get('a').ownerCanvasId,root);gate.resolve();await engine.settle();await f.clock.advance(1000);await engine.settle();
|
||||
assert.equal(engine.getSnapshot().document.elements.get('a').ownerCanvasId,child);assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'generated');assert.equal(engine.getSnapshot().document.elements.get('f').data.title,'local title');await engine.close();
|
||||
});
|
||||
|
||||
// 坐标基于旧 owner 的未决移动不能直接套入新 canvas;保留本地几何和待处理页,并报告 ownership_conflict。
|
||||
test('a pending geometry edit cannot silently reuse the coordinates of an old owner',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const gate=f.holdDocument();engine.editor.dispatch({type:'element.geometry',target,patch:{x:200}});await tick();f.move(child);f.finish();await f.clock.advance(0);gate.resolve();await engine.settle();await f.clock.advance(1000);await engine.settle();
|
||||
const run=[...engine.getSnapshot().runtime.runs.values()][0];assert.equal(run.error,'ownership_conflict');assert.equal(run.blocked,true);assert.ok(run.pendingPage);assert.equal(engine.getSnapshot().document.elements.get('a').ownerCanvasId,root);assert.equal(engine.getSnapshot().document.elements.get('a').geometry.x,200);await engine.close();
|
||||
});
|
||||
|
||||
// 冲突输出先接纳新的 owner 上下文;显式应用的 payload 和撤销写入当前子 canvas,任务路径仍使用原根作用域。
|
||||
test('a moved content conflict adopts its owner context and explicit application/undo uses that current canvas',async()=>{
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();f.move(child);f.finish('chosen moved output',true);await f.clock.advance(0);await engine.settle();const run=[...engine.getSnapshot().runtime.runs.values()][0];assert.equal(run.status,'conflict');assert.equal(engine.getSnapshot().document.elements.get('a').ownerCanvasId,child);
|
||||
assert.equal(engine.applyRunOutput(run.id,target,id(999)).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('a').data.text,'chosen moved output');const apply=f.calls.find(call=>call.path.endsWith('/apply'));assert.equal(JSON.parse(apply.body).target_canvas_id,child);assert.ok(apply.path.includes('/materials/'+root+'/'));
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().document.elements.get('a').ownerCanvasId,child);assert.equal(f.server().document.elements.get('a').data.text,'original');await engine.close();
|
||||
});
|
||||
|
||||
// 完整上下文按各 canvas 版本协调:保留第三个 canvas 的更新 ACK,同时拒绝同版本数据被改写。
|
||||
test('complete run context retains later unrelated canvas ACKs and rejects changed data at the same version',()=>{
|
||||
const third=id(22),canvases=[...original.canvases.values(),{id:third,parent:{canvasId:root,entryElementId:'g'}}],frame={...node('g'),componentId:'frame'},moved={...node('a',child),data:{title:'a',text:'before'}},sibling={...node('sibling',third),data:{title:'newer title',text:''}},elements=[moved,original.elements.get('f'),original.elements.get('future'),frame,sibling],document=createDocument(root,canvases,elements),snapshot={document,runtime:{acknowledged:document,versions:new Map([[root,3],[child,8],[third,10]])}};
|
||||
const model={documentFormatVersion:1,projectId:root,complete:true,removedCanvasIds:[],canvases,elements:elements.map(element=>element.id==='a'?{...element,data:{...element.data,text:'generated'}}:element.id==='sibling'?{...element,data:{title:'old title',text:''}}:element),tombstones:{},versions:{[root]:3,[child]:9,[third]:9}};
|
||||
@@ -266,6 +312,7 @@ test('complete run context retains later unrelated canvas ACKs and rejects chang
|
||||
const invalid=structuredClone(model);invalid.elements.find(element=>element.id==='f').data.title='same-version change';assert.throws(()=>mergeFoundryRunContext(invalid,snapshot,registry,root),/same_version_context_conflict/);
|
||||
});
|
||||
|
||||
// 成功受理回执已保存但解释失败时,恢复会话承认原请求并重放后续本地配置;恢复 RunRecord 和已完成输出,保持单次调用及原始请求体。
|
||||
test('a saved successful AI admission recovers its RunRecord and later local configuration without another invocation',async()=>{
|
||||
const f=fixture();f.failAdmissionCodec=true;const engine=await f.open();engine.startRun(runInput);await engine.settle();
|
||||
const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.outcome,'applied');assert.equal(request.phase,'waiting');assert.equal(engine.getSnapshot().runtime.runs.size,0);
|
||||
@@ -277,6 +324,7 @@ test('a saved successful AI admission recovers its RunRecord and later local con
|
||||
await engine.close();const restored=await f.open();assert.equal(restored.getSnapshot().runtime.runs.size,1);await restored.close();
|
||||
});
|
||||
|
||||
// 分别恢复已成功但尚未解释的取消和应用回执;恢复控制状态且不重发动作,应用后的本地编辑按当前 owner 同步。
|
||||
test('saved successful cancel and apply receipts recover controls without sending the action again',async()=>{
|
||||
for(const action of ['cancel','apply']){
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const runId=[...engine.getSnapshot().runtime.runs.keys()][0];
|
||||
@@ -293,6 +341,7 @@ test('saved successful cancel and apply receipts recover controls without sendin
|
||||
}
|
||||
});
|
||||
|
||||
// 实际 HTTP 传输适配器对三类动作都协商 canvas v2;POST 保留冻结体和幂等键,查询 GET 无请求体,取消不带 If-Match。
|
||||
test('AI admission, cancel and apply negotiate canvas v2 through the shared HTTP transport',async()=>{
|
||||
for(const action of ['start','cancel','apply']){
|
||||
const f=fixture(),engine=await f.open();
|
||||
@@ -314,6 +363,7 @@ test('AI admission, cancel and apply negotiate canvas v2 through the shared HTTP
|
||||
}
|
||||
});
|
||||
|
||||
// 成功历史证据也必须绑定原请求、操作、任务、目标和版本;冻结计划可省略无关 canvas,但不能引入外来 canvas 或篡改参与版本。
|
||||
test('historical AI success proofs bind immutable request, operation, run, target, version and frozen plan',async()=>{
|
||||
for(const action of ['start','cancel','apply']){
|
||||
const f=fixture(),engine=await f.open();engine.startRun(runInput);await engine.settle();const runId=[...engine.getSnapshot().runtime.runs.keys()][0];
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
/**
|
||||
* 页面专用原生 ReactFlow selection 观察组件。默认不安装监听器,由外层测试 enable 后才挂载 Listener。
|
||||
* 此 fixture 观察 ReactFlow 自己的选择事件和 nodeLookup,不代替业务文档或 headless session 的事实源。
|
||||
*/
|
||||
"use client";
|
||||
import {useCallback,useEffect,useState} from 'react';
|
||||
import {useOnSelectionChange,useStoreApi} from '@xyflow/react';
|
||||
// 把回调中的节点/边对象收窄成 ID 列表,避免把整个 renderer 对象留在测试事件日志。
|
||||
type Event={nodes:string[];edges:string[]};
|
||||
// 暴露启用、原生选择命令和当前节点选择查询,供浏览器脚本与 headless 选择行为对照。
|
||||
type Control={enable(value:boolean):void;select(ids:string[]):void;selected():string[]};
|
||||
// 仅在测试 Window 上声明控制接口与事件日志,不向生产 editor 注入状态。
|
||||
declare global{interface Window{__fixtureNativeSelection?:Control;__fixtureNativeSelectionEvents?:Event[];}}
|
||||
// 稳定 useCallback 记录 ID,再注册 useOnSelectionChange;组件卸载由 hook 解除观察。
|
||||
function Listener(){
|
||||
const onChange=useCallback((value:{nodes:{id:string}[];edges:{id:string}[]})=>{window.__fixtureNativeSelectionEvents?.push({nodes:value.nodes.map(node=>node.id),edges:value.edges.map(edge=>edge.id)});},[]);
|
||||
useOnSelectionChange({onChange});return null;
|
||||
}
|
||||
/** Isolated page fixture only; no observer is installed in performance builds. */
|
||||
/** 仅在隔离测试页面使用,性能构建不安装此观察器。 */
|
||||
// 持有 ReactFlow store 和启用开关;effect 注册控制对象,render 按开关决定是否安装监听器。
|
||||
export function NativeSelectionFixture(){
|
||||
const store=useStoreApi(),[enabled,setEnabled]=useState(false);
|
||||
useEffect(()=>{
|
||||
// select 先清除再添加原生选中节点,selected 从 nodeLookup 查询;日志独立保存已收到的事件。
|
||||
const control:Control={enable:setEnabled,select(ids){store.getState().resetSelectedElements();store.getState().addSelectedNodes(ids);},selected(){return [...store.getState().nodeLookup.values()].filter(node=>node.selected).map(node=>node.id);}};
|
||||
window.__fixtureNativeSelection=control;window.__fixtureNativeSelectionEvents??=[];
|
||||
// 只移除本 effect 注册的同一控制对象,旧 cleanup 不删除新挂载的控制接口;事件日志不在此清空。
|
||||
return()=>{if(window.__fixtureNativeSelection===control)delete window.__fixtureNativeSelection;};
|
||||
},[store]);
|
||||
return enabled?<Listener/>:null;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Foundry 文档适配器的编译、回执和传输契约测试。真实适配器连接可控 transport/fetch 替身,不访问远程 HTTP 或数据库。
|
||||
* 区分协议版本、文档格式、服务版本向量与元素 incarnation;HTTP 成功、未知结果和明确零写入必须有各自证据。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
@@ -5,22 +9,34 @@ import { createDocument, createRegistry, builtinComponents, applyChanges } from
|
||||
import { createLedger } from '../../src/lib/canvas-runtime/ledger.ts';
|
||||
import { createFoundryPersistence, createFoundryTransport, loadFoundryModel } from '../../src/lib/canvas-service/foundry.ts';
|
||||
|
||||
// 固定账户/workspace/根和子 canvas,便于检查局部回执覆盖范围与身份头。
|
||||
const root='10000000-0000-4000-8000-000000000001',child='10000000-0000-4000-8000-000000000002',workspace='10000000-0000-4000-8000-000000000003',account='10000000-0000-4000-8000-000000000004';
|
||||
// 逻辑操作 ID 与冻结请求 ID 分开:批次可覆盖多个逻辑操作,但查询和重发仍使用原 requestId。
|
||||
const operationId='90000000-0000-4000-8000-000000000001',requestId='90000000-0000-4000-8000-000000000002';
|
||||
// 用内置类型校验已知记录,未知插件记录仍需不透明保留。
|
||||
const registry=createRegistry(builtinComponents());
|
||||
// 构造带完整几何与第一代身份的文字节点,默认属于根 canvas。
|
||||
const node=(id,owner=root)=>({id,incarnation:1,ownerCanvasId:owner,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:'preserved'},geometry:{x:0,y:0,width:260,height:200,rotation:0}});
|
||||
// 子 canvas 中放入未来插件数据和额外 extension,验证局部合并不丢失无关记录。
|
||||
const a=node('a'),f={...node('f'),componentId:'frame'},future={...node('future',child),componentId:'plugin.future',dataVersion:9,data:{custom:{keep:[1,'x']}},extension:{retained:true}};
|
||||
// frame f 是子 canvas 的入口,建立文档归属树而非仅两个独立列表。
|
||||
const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'f'}}];
|
||||
// 初始业务文档保留原始标题,供 ACK 合并后的结果对比。
|
||||
const document=createDocument(root,canvases,[a,f,future]);
|
||||
// ACK 基线版本为根 3、子 7;历史和账本为空,后续冻结从这一确定基线读取参与版本。
|
||||
const snapshot={revision:1,rootRevision:1,document,history:{undo:[],redo:[],entries:new Map()},operations:new Map(),runtime:{acknowledged:document,versions:new Map([[root,3],[child,7]]),ledger:createLedger().getSnapshot(),receipts:new Map(),conflicts:new Map()}};
|
||||
// 单一条件字段修改,带 before/after 和存在性;编译器只负责转成增量协议,不发送整份文档。
|
||||
const intent={id:operationId,operationType:'document.change',operationSchemaVersion:1,sequence:1,origin:'user',canvasIds:[root],dependencies:[],changes:[{kind:'field',target:{id:'a',incarnation:1},field:'data.title',before:'a',after:'renamed',beforePresent:true,afterPresent:true}]};
|
||||
// complete 模型用于初始/恢复读取;partial 模型只覆盖根并将版本推进到 4,子内容不在该回执内。
|
||||
const chunk=(complete=true)=>({documentFormatVersion:1,projectId:root,canvases:complete?canvases:[canvases[0]],elements:complete?[a,f,future]:[{...a,data:{...a.data,title:'renamed'}},f],tombstones:{},versions:complete?{[root]:3,[child]:7}:{[root]:4},complete,removedCanvasIds:[]});
|
||||
// 建立适配器、编译意图并由真实 ledger 冻结请求;默认 transport 返回单操作成功和根 canvas 局部模型。
|
||||
function setup(transport=async()=>({status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)}})){
|
||||
const port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport});
|
||||
const compiled=port.compile(intent,requestId,snapshot),ledger=createLedger();ledger.add(intent,{a:'exists'});
|
||||
const request=ledger.freeze({id:requestId,operationIds:[operationId],...compiled});return{port,request,compiled};
|
||||
}
|
||||
|
||||
// 初始读显式返回 migration-required;从子入口读取仍得到根项目并持有独立模型,未知数据保留,局部模型不能冒充完整初始读。
|
||||
test('initial model read is explicit about migration/coverage and owns opaque records',async()=>{
|
||||
const calls=[];
|
||||
const required=await loadFoundryModel(async request=>{calls.push(request);return{status:409,body:{error:'canonical_migration_required',document_format_version:1,root_id:root,source_versions:{[root]:1}}}},workspace,root,registry);
|
||||
@@ -32,10 +48,16 @@ test('initial model read is explicit about migration/coverage and owns opaque re
|
||||
await assert.rejects(()=>loadFoundryModel(async()=>({status:200,body:{model:chunk(false)}}),workspace,root,registry),/incomplete_initial_model/);
|
||||
});
|
||||
|
||||
// 冻结协议 2 的条件 changes 并与 JSON 样例比较;带额外空白的原 body 按字节发送,query 为无 body 的原 ID GET。
|
||||
test('compiler freezes protocol 2 conditional changes and transports the exact original bytes',async()=>{
|
||||
const calls=[],{port,request,compiled}=setup(async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)}}});
|
||||
assert.equal(compiled.protocolVersion,2);assert.equal(request.protocolVersion,2);
|
||||
const body=JSON.parse(request.body);assert.deepEqual(body.operations,[{id:operationId,changes:intent.changes}]);assert.equal(Object.hasOwn(body,'document'),false);
|
||||
// 严格 JSON 夹具的旁注:protocol_version=2 是请求协议,不是组件 dataVersion 或文档格式版本。
|
||||
// request_id 使用本测试冻结请求 ID;expected_versions 只含参与根 canvas 的已确认版本 3。
|
||||
// operations[0].id 是逻辑操作 ID;target.incarnation=1 防止相同元素 ID 的新生命期接收旧修改。
|
||||
// changes 是 data.title 从 a 到 renamed 的条件修改;beforePresent/afterPresent 显式区分缺失字段和有值字段。
|
||||
// 深比较覆盖整个编译请求形状;这个静态样例本身不证明真实后端已接受或持久化请求。
|
||||
assert.deepEqual(body,JSON.parse(await readFile(new URL('./fixtures/foundry-model-operation-v2.json',import.meta.url),'utf8')));
|
||||
const originalBytes=' \n'+request.body+' ',frozen={...request,body:originalBytes};
|
||||
assert.equal((await port.submit(frozen,new AbortController().signal)).outcome,'applied');
|
||||
@@ -43,6 +65,7 @@ test('compiler freezes protocol 2 conditional changes and transports the exact o
|
||||
await port.query(frozen,new AbortController().signal);assert.equal(calls[1].method,'GET');assert.equal(calls[1].body,undefined);assert.ok(calls[1].path.endsWith('/'+requestId));
|
||||
});
|
||||
|
||||
// 只有原 requestId 匹配的 terminal not-applied 是零写入;普通冲突/权限错误保持未知并阻塞,404 unknown 也不证明未执行。
|
||||
test('only explicit identity-matched terminal proof is zero-write; bare 4xx stays unresolved and blocked',async()=>{
|
||||
for(const [response,outcome,blocked]of[
|
||||
[{status:409,body:{protocol_version:2,request_id:requestId,status:'not-applied',terminal:true,error:'version_conflict'}},'not-applied',false],
|
||||
@@ -55,6 +78,7 @@ test('only explicit identity-matched terminal proof is zero-write; bare 4xx stay
|
||||
}
|
||||
});
|
||||
|
||||
// 局部 ACK 只替换参与根 canvas,保留未来子记录与版本 7;额外版本覆盖或冒用其他 owner 的记录均拒绝,输入 ACK 不变。
|
||||
test('partial canonical receipts replace participants and retain unrelated future components and versions',async()=>{
|
||||
const{port,request}=setup();const status=await port.submit(request,new AbortController().signal),proposal=port.canonical(request,status,snapshot);
|
||||
assert.equal(proposal.document.elements.get('a').data.title,'renamed');assert.deepEqual(proposal.document.elements.get('future'),future);assert.equal(proposal.versions.get(child),7);assert.equal(proposal.versions.get(root),4);
|
||||
@@ -65,6 +89,7 @@ test('partial canonical receipts replace participants and retain unrelated futur
|
||||
assert.equal(snapshot.runtime.acknowledged.elements.get('a').data.title,'a');
|
||||
});
|
||||
|
||||
// 缺失逻辑 operation 覆盖的成功响应保持 unresolved;冻结元数据与 body 不一致时直接阻止发送。
|
||||
test('missing logical coverage and altered frozen metadata do not masquerade as successful requests',async()=>{
|
||||
const calls=[],{port,request}=setup(async input=>{calls.push(input);return{status:200,body:{protocol_version:2,request_id:requestId,status:'applied',operation_ids:[],model:chunk(false)}}});
|
||||
const incomplete=await port.submit(request,new AbortController().signal);assert.equal(incomplete.outcome,'unresolved');assert.equal(incomplete.blocked,true);assert.equal(incomplete.error,'receipt_coverage_mismatch');
|
||||
@@ -72,6 +97,7 @@ test('missing logical coverage and altered frozen metadata do not masquerade as
|
||||
assert.equal(corrupt.blocked,true);assert.equal(calls.length,1);assert.equal(corrupt.error,'frozen_request_contract_mismatch');
|
||||
});
|
||||
|
||||
// 新 canvas 的 absent 版本显式使用 0,已有根仍为 3;这不改变协议版本,也不是元素或文档格式版本。
|
||||
test('new canvases carry an explicit absent version without confusing format/protocol/document clocks',()=>{
|
||||
const canvas='10000000-0000-4000-8000-000000000005',{port}=setup();
|
||||
const create={...intent,canvasIds:[root,canvas],changes:[{kind:'canvas',id:canvas,before:null,after:{id:canvas,parent:{canvasId:root,entryElementId:'f'}}}]};
|
||||
@@ -79,6 +105,7 @@ test('new canvases carry an explicit absent version without confusing format/pro
|
||||
const ledger=createLedger();ledger.add(create,{});assert.equal(ledger.freeze({id:requestId,operationIds:[operationId],...compiled}).expectedVersions[canvas],0);
|
||||
});
|
||||
|
||||
// fetch 前后都检查当前身份;冻结 body、幂等键和预期账户头原样传递,身份已变时不发请求,途中变化也拒绝响应。
|
||||
test('browser transport fences identity changes and never rebuilds a frozen body',async()=>{
|
||||
let current=true;const calls=[];
|
||||
const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>current,fetch:async(url,init)=>{calls.push([url,init]);return new Response('{"status":"unknown"}',{status:404,headers:{'Content-Type':'application/json'}})}});
|
||||
@@ -90,6 +117,7 @@ test('browser transport fences identity changes and never rebuilds a frozen body
|
||||
});
|
||||
|
||||
|
||||
// expectedVersion 编码成不可变 If-Match;零、负数、非整数、NaN 和不安全整数在进入 fetch 前被拒绝。
|
||||
test('shared service transport binds the immutable AI admission version header',async()=>{
|
||||
const calls=[];
|
||||
const transport=createFoundryTransport({accountId:account,isCurrentIdentity:()=>true,fetch:async(url,input)=>{calls.push({url,input});return new Response('{}',{status:200,headers:{'Content-Type':'application/json'}})}});
|
||||
@@ -99,6 +127,7 @@ test('shared service transport binds the immutable AI admission version header',
|
||||
assert.equal(calls.length,1);
|
||||
});
|
||||
|
||||
// 恢复读取只接受绑定项目的完整规范模型,转发同一 AbortSignal;迁移需求、局部模型、其他项目及权限错误均拒绝。
|
||||
test('recovery reader requires a complete canonical model for its bound project and forwards cancellation',async()=>{
|
||||
const controller=new AbortController(),calls=[];
|
||||
const{port}=setup(async request=>{calls.push(request);return{status:200,body:{model:chunk()}};});
|
||||
@@ -112,6 +141,7 @@ test('recovery reader requires a complete canonical model for its bound project
|
||||
]){const{port:invalid}=setup(async()=>response);await assert.rejects(invalid.read(controller.signal),error);}
|
||||
});
|
||||
|
||||
// 历史成功证明绑定冻结契约,允许后来更高版本,但同版本分歧、落后读取、身份/覆盖篡改和墓碑回退均拒绝。
|
||||
test('historical applied proof validates frozen identity and coverage, retains newer reads and rejects same-version divergence',async()=>{
|
||||
const{port,request}=setup(),status=await port.submit(request,new AbortController().signal),proof=port.verifyApplied(request,status.canonical),proposal=port.canonical(request,status,snapshot);
|
||||
proof.assertCanonical(proposal);assert.equal(proof.versions.get(root),4);assert.equal(proof.versions.set,undefined);
|
||||
@@ -126,6 +156,7 @@ test('historical applied proof validates frozen identity and coverage, retains n
|
||||
assert.throws(()=>lifecycle.assertCanonical({document:changed,versions:new Map([[root,5],[child,7]])}),/stale_canonical_lifecycle/);
|
||||
});
|
||||
|
||||
// 批量编译保留操作次序、每个逻辑 ID 和全部参与版本;回执遗漏任何操作均不算成功,乱序或重复逻辑 ID 无法编译。
|
||||
test('ordered batch freezes every logical identity, conditional change and participant version, requiring complete receipt coverage', async () => {
|
||||
const secondId='90000000-0000-4000-8000-000000000003';
|
||||
const second={...intent,id:secondId,sequence:2,dependencies:[intent.id],changes:[{...intent.changes[0],before:'renamed',after:'final'}]};
|
||||
@@ -141,6 +172,7 @@ test('ordered batch freezes every logical identity, conditional change and parti
|
||||
});
|
||||
|
||||
|
||||
// 只在有账本压缩依据时减少 wire changes;最终文档仍到 final,原意图中的中间值不变,回执仍覆盖所有逻辑 ID。
|
||||
test('Foundry compiles the proven projection without changing logical intents or receipt coverage',async()=>{
|
||||
const middle='temporary '.repeat(100),secondId='90000000-0000-4000-8000-000000000003';
|
||||
const first={...intent,changes:[{...intent.changes[0],after:middle}]},second={...intent,id:secondId,sequence:2,dependencies:[first.id],changes:[{...intent.changes[0],before:middle,after:'final'}]},ledger=createLedger();
|
||||
@@ -153,9 +185,11 @@ test('Foundry compiles the proven projection without changing logical intents or
|
||||
assert.deepEqual(wire.map(op=>op.id),request.operationIds);assert.equal((await port.submit(request,new AbortController().signal)).outcome,'applied');
|
||||
});
|
||||
|
||||
// 摘要为显式可选策略,仅 resolved/applied 请求可压缩;摘要保留执行元数据但不是原始成功证明,未来摘要版本单独拒绝。
|
||||
test('Foundry receipt summaries are opt-in, retain execution metadata and cannot masquerade as raw recovery proofs',()=>{
|
||||
const{request,port:rawPort}=setup();assert.equal(rawPort.compactResolvedReceipt,undefined);const port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:async()=>{},resolvedReceiptPolicy:'summary'}),receipt={protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)},applied=port.canonical(request,{outcome:'applied',canonical:receipt},snapshot),resolved={...request,phase:'resolved',outcome:'applied'},summary=port.compactResolvedReceipt(resolved,receipt,applied);assert.strictEqual(port.compactResolvedReceipt(request,receipt,applied),receipt);assert.deepEqual(summary,{localReceiptFormat:'foundry.reconciled-document',localReceiptVersion:1,protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],projectId:root,versions:{[root]:4}});assert.equal(Object.hasOwn(summary,'model'),false);assert.equal(receipt.model.elements.length,2);assert.throws(()=>port.verifyApplied(request,summary),/receipt_already_reconciled/);assert.throws(()=>port.canonical(request,{outcome:'applied',canonical:summary},snapshot),/receipt_already_reconciled/);assert.throws(()=>port.verifyApplied(request,{...summary,localReceiptVersion:2}),/receipt_summary_interpreter_unavailable/);assert.deepEqual([...port.verifyApplied(request,receipt).versions],[[root,4]]);
|
||||
});
|
||||
// 遇到未知扩展、未来协议/文档或额外字段保留原回执引用;run.apply 不走文档摘要策略,非法策略配置立即失败。
|
||||
test('summary policy preserves opaque, future and unfamiliar receipt data unchanged',()=>{
|
||||
const{request}=setup(),port=createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:async()=>{},resolvedReceiptPolicy:'summary'}),raw={protocol_version:2,request_id:requestId,status:'applied',operation_ids:[operationId],model:chunk(false)},applied=port.canonical(request,{outcome:'applied',canonical:raw},snapshot),cases=[{...raw,extension:{keep:true}},{...raw,protocol_version:3},{...raw,model:{...raw.model,documentFormatVersion:2}},{...raw,model:{...raw.model,futureField:'keep'}},{...raw,model:{...raw.model,elements:[...raw.model.elements,{...future,id:'opaque',ownerCanvasId:root}]}},{...raw,model:{...raw.model,elements:[{...a,extension:{keep:true}},f]}}];for(const value of cases)assert.strictEqual(port.compactResolvedReceipt({...request,phase:'resolved',outcome:'applied'},value,applied),value);assert.strictEqual(port.compactResolvedReceipt({...request,kind:'run.apply'},raw,applied),raw);assert.throws(()=>createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport:async()=>{},resolvedReceiptPolicy:'invalid'}),/invalid_receipt_policy/);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
/**
|
||||
* ViewSession 的身份、工具、导航和释放边界测试。交互预览属于会话,不能借切换工具或清理旧视图修改业务文档。
|
||||
* 多个 view 共享 editor,但 mode 和 tool 各自保存;手势取消与失效 mode 清理必须作为一次最终状态通知。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts';
|
||||
// 构造带固定 incarnation 和完整几何的节点;owner 和 component 可按分区或类型场景替换。
|
||||
const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||
// 根 canvas 中有两个文本目标、连线与子分区入口;v、w 两个视图用于检查会话间隔离及过期 detach。
|
||||
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],[node('F','root','frame'),node('A'),node('B'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor),detach=sessions.attach('v');sessions.attach('w');return{editor,sessions,detach};}
|
||||
// 同 ID 原子替换为第二代元素后,旧 resize mode 不得继承到新身份。
|
||||
test('an atomic restored incarnation cannot inherit the old element editing mode',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','resize');const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.has('A'),false);});
|
||||
// 手势所属视图切到 pan 时一次发布新工具和取消后的预览;pan 不接受新变换或连线手势,业务 Root 保持原引用。
|
||||
test('switching tools clears the owned preview atomically and blocks fresh transform/connection gestures in pan',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot();const observed=[];sessions.subscribe(()=>observed.push(sessions.getSnapshot()));sessions.tool('v','pan');assert.equal(observed.length,1);assert.equal(observed[0].views.get('v').tool,'pan');assert.equal(observed[0].gesture,null);assert.equal(observed[0].lastCancelReason,'tool_changed');assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'tool_not_select');assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).code,'tool_not_select');assert.strictEqual(editor.getSnapshot(),before);});
|
||||
// 同名视图重新 attach 后,旧的 detach 回调再次调用也不能移除新实例。
|
||||
test('old detach cleanup cannot remove a freshly attached view with the same ID',()=>{const{sessions,detach}=fixture();detach();sessions.attach('v');detach();assert.equal(sessions.getSnapshot().views.has('v'),true);});
|
||||
|
||||
// 普通文字修改保留两个视图的 mode,owner 迁移则清空;不存在的元素不能进入 mode,清理其空 mode 是无变化操作。
|
||||
test('mode lifetime follows identity and placement while ordinary content edits preserve it',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','editing');sessions.mode('w','A','resize');editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'new content'}});assert.equal(sessions.getSnapshot().views.get('v').modes.get('A'),'editing');assert.equal(sessions.getSnapshot().views.get('w').modes.get('A'),'resize');assert.equal(editor.dispatch({type:'element.move',target:{id:'A',incarnation:1},canvasId:'child'}).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.size,0);assert.equal(sessions.getSnapshot().views.get('w').modes.size,0);assert.throws(()=>sessions.mode('v','missing','editing'),/element_missing/);const before=sessions.getSnapshot();sessions.mode('v','missing',null);assert.strictEqual(sessions.getSnapshot(),before);});
|
||||
// 目标换代时只通知一次,同时清除 mode 和变换预览,并报告 target_changed。
|
||||
test('target replacement publishes mode invalidation and gesture cancellation together',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','resize');sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('v').modes.has('A'),false);assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'target_changed');});
|
||||
// 其他视图切工具不影响当前手势,重复选同工具不发布;未知工具被拒绝,连线手势也受所属视图工具约束。
|
||||
test('another view tool change preserves the gesture, and same-tool selection is idempotent',()=>{const{sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;sessions.tool('w','pan');assert.strictEqual(sessions.getSnapshot().gesture,gesture);const before=sessions.getSnapshot();sessions.tool('v','select');assert.strictEqual(sessions.getSnapshot(),before);assert.throws(()=>sessions.tool('v','unknown'),/invalid_tool/);assert.strictEqual(sessions.getSnapshot(),before);sessions.cancel();sessions.beginConnection('w',{id:'edge',incarnation:1});assert.equal(sessions.getSnapshot().connectionGesture,null);sessions.tool('w','select');assert.equal(sessions.beginConnection('w',{id:'edge',incarnation:1}).enabled,true);sessions.tool('w','pan');assert.equal(sessions.getSnapshot().connectionGesture,null);});
|
||||
// 导航和 detach 都只暴露最终会话状态;不能出现目标 canvas 已换但旧手势仍在的中间通知。
|
||||
test('canvas navigation and detach notify only a final session, never a new target with the old gesture',()=>{const{sessions,detach}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);let observed=[];const stop=sessions.subscribe(()=>observed.push(sessions.getSnapshot()));sessions.canvas('v','child');assert.equal(observed.length,1);assert.equal(observed[0].views.get('v').canvasId,'child');assert.equal(observed[0].gesture,null);sessions.canvas('v','root');sessions.begin('v',[{id:'A',incarnation:1}]);observed=[];detach();assert.equal(observed.length,1);assert.equal(observed[0].views.has('v'),false);assert.equal(observed[0].gesture,null);stop();});
|
||||
// dispose 先封闭入口再通知清理,因此监听器重入不能创建手势;反复 dispose 不再通知或留下视图。
|
||||
test('dispose fences reentrant admission and cannot leave a gesture or accept repeated cleanup',()=>{const{sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);let attempt,calls=0;sessions.subscribe(()=>{calls++;attempt=sessions.begin('v',[{id:'A',incarnation:1}]);});sessions.dispose();assert.equal(attempt.enabled,false);assert.equal(attempt.code,'view_detached');assert.equal(calls,1);assert.equal(sessions.getSnapshot().views.size,0);assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().connectionGesture,null);sessions.dispose();assert.equal(calls,1);});
|
||||
|
||||
// 类型或 dataVersion 改变都会使原 mode 失效;非字符串 mode 被拒绝且不替换会话快照。
|
||||
test('component/version changes invalidate modes while invalid tags never enter a view',()=>{for(const patch of [{componentId:'plain-text'},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.mode('v','A','editing');const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.has('A'),false);const snapshot=sessions.getSnapshot();assert.throws(()=>sessions.mode('v','B',{}),/invalid_mode/);assert.strictEqual(sessions.getSnapshot(),snapshot);}});
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
/**
|
||||
* 增量 SHA-256 与分块导入摘要测试,使用 Node crypto 的独立实现作为结果参照。
|
||||
* 覆盖填充边界、不同 update 块宽、重复最终化读取,以及异步读块/让出时取消的迟到结果隔离。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHash,randomBytes} from 'node:crypto';
|
||||
import {createSha256} from '../../src/lib/canvas-runtime/sha256.ts';
|
||||
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
|
||||
// 用 Node createHash 计算参照摘要;既不复用待测算法,也不以自身输出作为预期值。
|
||||
const expected=bytes=>createHash('sha256').update(bytes).digest('hex');
|
||||
// 空值、标准字符串、百万个 a 及长度 0–259 的随机输入,分别按六种块宽更新;hex 可重复读取,但最终化后禁止 update。
|
||||
test('incremental SHA-256 matches known vectors, every padding boundary and varied chunk splits',()=>{
|
||||
for(const bytes of [Buffer.alloc(0),Buffer.from('abc'),Buffer.from('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'),Buffer.alloc(1000000,97),...Array.from({length:260},(_,n)=>randomBytes(n))]){
|
||||
for(const chunk of [1,7,63,64,65,8192]){const hash=createSha256();for(let offset=0;offset<bytes.length;offset+=chunk)hash.update(bytes.subarray(offset,offset+chunk));assert.equal(hash.hex(),expected(bytes),`${bytes.length}/${chunk}`);assert.equal(hash.hex(),expected(bytes));assert.throws(()=>hash.update(new Uint8Array()),/digest_finalized/);}
|
||||
}
|
||||
});
|
||||
// 1 MiB+13 字节按 64 KiB 读取为 17 块并让出 16 次;摘要匹配 Node,且没有整块源 Blob 读取。
|
||||
test('source digest reads bounded slices, yields and never materializes the entire Blob',async()=>{
|
||||
const data=randomBytes(1024*1024+13),reads=[];let yields=0;
|
||||
// 禁止对整个源 Blob 调 arrayBuffer,slice 记录请求块长并交给真实 Blob 读字节。
|
||||
class SlicedBlob extends Blob{arrayBuffer(){throw Error('whole Blob read');}slice(start,end){reads.push(end-start);return super.slice(start,end);}}
|
||||
assert.equal(await importSourceDigest(new SlicedBlob([data]),undefined,{chunkBytes:65536,yieldTask:async()=>{yields++;}}),expected(data));assert.equal(reads.length,17);assert.ok(reads.every(size=>size<=65536));assert.equal(yields,16);
|
||||
});
|
||||
// 分别在读块挂起时 abort 和 yield 回调内 abort;迟到块及调度返回后都不能完成摘要,统一拒绝 import_aborted。
|
||||
test('source digest cancellation fences a late chunk and a scheduler yield',async()=>{
|
||||
let release,entered;const gate=new Promise(resolve=>release=resolve),ready=new Promise(resolve=>entered=resolve),controller=new AbortController();class Chunk extends Blob{async arrayBuffer(){entered();await gate;return super.arrayBuffer();}}class Source extends Blob{slice(...args){return new Chunk([super.slice(...args)]);}}
|
||||
let release,entered;const gate=new Promise(resolve=>release=resolve),ready=new Promise(resolve=>entered=resolve),controller=new AbortController();/* 读出的分块再套一层 gate,保证 abort 发生在 arrayBuffer 真正返回之前。 */ class Chunk extends Blob{async arrayBuffer(){entered();await gate;return super.arrayBuffer();}}/* 保留真实 slice 字节,但让每一块都经过上述可暂停读取。 */ class Source extends Blob{slice(...args){return new Chunk([super.slice(...args)]);}}
|
||||
const pending=importSourceDigest(new Source(['abc']),controller.signal);await ready;controller.abort();release();await assert.rejects(pending,/import_aborted/);const second=new AbortController();await assert.rejects(importSourceDigest(new Blob(['abcdef']),second.signal,{chunkBytes:2,yieldTask:async()=>second.abort()}),/import_aborted/);
|
||||
});
|
||||
|
||||
@@ -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$/);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user