Advance M7 workflows and release operations
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-15 17:43:53 -04:00
parent 17ab961485
commit 7c16b279ae
103 changed files with 8064 additions and 429 deletions

102
.gitea/workflows/m6-ci.yml Normal file
View File

@@ -0,0 +1,102 @@
name: M6 deployable RC
on:
push:
pull_request:
workflow_dispatch:
env:
FORCE_COLOR: "0"
NO_COLOR: "1"
jobs:
quick:
runs-on: [self-hosted, linux, x64, blender-web]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.19.2
cache: npm
cache-dependency-path: web/package-lock.json
- name: Run quick lane
run: npm --prefix web run ci:quick
- name: Validate quick report
run: npm --prefix web run test:ci-report -- release/ci-reports/quick.json
- name: Upload quick report
if: always()
uses: actions/upload-artifact@v4
with:
name: m6-quick-report
path: release/ci-reports/
retention-days: 7
chromium:
needs: quick
runs-on: [self-hosted, linux, x64, blender-web]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.19.2
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install browser driver from lockfile
run: |
npm --prefix web ci --ignore-scripts
npm --prefix web exec -- playwright install chromium
- name: Run Chromium lane
run: npm --prefix web run ci:chromium
- name: Validate Chromium report
run: npm --prefix web run test:ci-report -- release/ci-reports/chromium.json
- name: Upload Chromium report
if: always()
uses: actions/upload-artifact@v4
with:
name: m6-chromium-report
path: release/ci-reports/
retention-days: 14
release:
needs: chromium
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
runs-on: [self-hosted, linux, x64, blender-web-release]
env:
BLENDER_BIN: ${{ vars.BLENDER_BIN }}
BLENDER_ARCHIVE_SHA256: ${{ vars.BLENDER_ARCHIVE_SHA256 }}
VDB_RESOURCE_ROOT: ${{ vars.VDB_RESOURCE_ROOT }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.19.2
cache: npm
cache-dependency-path: web/package-lock.json
- name: Cache pinned Emscripten toolchain data
uses: actions/cache@v4
with:
path: .emcache
key: emscripten-${{ runner.os }}-3.1.69-${{ hashFiles('tools/web/emscripten-env.sh', 'web/package-lock.json') }}
- name: Install browser driver from lockfile
run: |
npm --prefix web ci --ignore-scripts
npm --prefix web exec -- playwright install chromium
- name: Run release lane
run: npm --prefix web run ci:release
- name: Validate release report and hashes
run: npm --prefix web run test:ci-report -- release/ci-reports/release.json
- name: Upload release report and archives
if: always()
uses: actions/upload-artifact@v4
with:
name: m6-release-report
path: |
release/ci-reports/
release/archive-reports/
release/operations-reports/
release/SHA256SUMS.txt
release/RC_MANIFEST.json
release/RC_MANIFEST.json.sha256
release/blender-web-offline.tar.gz
release/blender-web-corresponding-source.tar.gz
retention-days: 30

View File

@@ -32,6 +32,8 @@ npm --prefix web run test:browser
See `docs/CURRENT_EXECUTION_PLAN.md` for the current task order,
`docs/PROJECT_STATUS_AND_NEXT_WORK.md` for the implemented scope, and
`docs/BLENDER_5_2_WEB_FEATURE_PARITY.md` for the long-term Blender parity ledger.
The atomic full-product work breakdown is in
`docs/BLENDER_5_2_FULL_PARITY_WBS.md`.
The release contract and explicit non-goals are defined in
`docs/WEB_BLENDER_MODELER_V1_SCOPE.md`.

View File

@@ -0,0 +1,518 @@
# Blender 5.2 全功能对标工作分解
更新时间2026-08-15
## 1. 目标与事实边界
本文把“对标 Blender 全部功能”定义为:以仓库内 `blender-5.2.0` 的 RNA、operator、
data-block、modifier、node、editor、文件格式和运行时后端为冻结基线逐项给出
`LOCAL_EXACT``LOCAL_BOUNDED``SERVER``EXCLUDED` 结论,并为结论保存可复验的正例、
阻断例和数据保真证据。
当前 `Web Blender Modeler V1` 已达到其有界发布范围,但不等于完整 Blender。机器账本中
N-015 至 N-026 的 12 个 family 全部仍为 `parityStatus=BLOCKED`。因此以下任务默认均为未完成;
只有 `docs/status/parity-ledger.json` 和对应专项证据同时更新后才允许改变状态。
短周期唯一领取顺序仍以 `docs/CURRENT_EXECUTION_PLAN.md` 为准。本文负责完整产品范围和细粒度
工作包,不取代当前执行队列。
## 2. 原子任务完成定义
每个能力切片必须依次通过以下原子门;不能用后一个门反推前一个门完成:
| 门 | 单一产物 | 退出条件 |
| --- | --- | --- |
| G0 Inventory | 一条机器可读 inventory 记录 | Blender 5.2 名称、源码锚点、依赖和分类均存在 |
| G1 Contract | 一个版本化协议 | 字段、上限、稳定 ID、错误码和 revision 规则可纯校验 |
| G2 Reader | 一个 Blender Main reader | 真实 fixture 可读;缺失、损坏和 linked 输入有稳定结果 |
| G3 Evaluator | 一个求值切片 | desktop 与 WASM 的语义或误差 golden 通过 |
| G4 Writer | 一个 Main transaction | 只产生一个 undo step失败不改变 revision |
| G5 Persistence | 一个保存重开闭环 | save、Worker restart、reopen 后语义和稳定 ID 一致 |
| G6 Delta | 一个增量协议 | base/next revision、乱序、重复和 stale 输入均有测试 |
| G7 UI | 一个可操作入口 | context、selection、取消、键盘路径和错误显示完整 |
| G8 Viewport | 一个渲染闭环 | 主线程与 Offscreen 资源生命周期、非空像素和 device loss 通过 |
| G9 Interop | 一个导入/导出结论 | round-trip 或机器可读 loss/block report 通过 |
| G10 Quality | 一个发布证据 | 性能、OOM、quota、恶意输入、取消和泄漏门通过 |
一个原子任务只允许交付一个门。新增白名单项时,必须为该项新建自己的 G2-G10 证据,不能只
扩大数组后复用其他项的成功记录。
## 3. 阶段与最小里程碑
| 阶段 | 最小里程碑 | 前置 | 退出条件 |
| --- | --- | --- | --- |
| S0 可部署运行时 | M6.1-M6.7 | V1 RC | single/pthread、HTTP、归档、CI、运维逐项通过 |
| S1 核心项目体验 | M7 | S0 | 30 分钟 edit/save/reopen soak 和恢复通过 |
| S2 全量清单冻结 | F00 | S1 | Blender 5.2 inventory 生成器输出稳定且零未分类项 |
| S3 数据与场景核心 | F01-F03 | S2 | Main/ID/Scene/Object/Collection/View Layer 闭环 |
| S4 几何创作 | F04-F08 | S3 | Mesh、非 Mesh、Sculpt、Paint、Grease Pencil 分域通过 |
| S5 程序化与材质 | F09-F11 | S3 | Modifier、Geometry Nodes、Shader/Texture 分域通过 |
| S6 动画与绑定 | F12-F13 | S3 | Animation、Rigging、Constraint、Driver、NLA 分域通过 |
| S7 模拟与渲染 | F14-F16 | S5、S6 | Physics、Render、Compositor 的 local/server 边界通过 |
| S8 媒体与跟踪 | F17-F18 | S3 | VSE/Audio、Tracking/Mask 闭环 |
| S9 资产与互操作 | F19-F20 | S3 | Library/Asset 与逐格式 round-trip/loss report 通过 |
| S10 编辑器与平台 | F21-F23 | S4-S9 | editor、keymap、scripting、安全、跨浏览器分类完成 |
| S11 全域发布审计 | F24 | S0-S10 | inventory 零缺口;每项均有正例或稳定阻断证据 |
每个 F 域内部再按 `inventory -> read -> evaluate -> write -> persist -> UI -> quality` 形成独立小
里程碑。跨域依赖只允许引用已经通过的前置门。
## 4. F00 全量清单与状态生成
- [ ] `F00-01` 从 RNA 生成全部 data-block type 清单。
- [ ] `F00-02` 从 operator registry 生成 operator、poll context 和参数清单。
- [ ] `F00-03` 从 modifier enum 生成全部 modifier type 清单。
- [ ] `F00-04` 从 node registries 生成 Geometry、Shader、Compositor node 清单。
- [ ] `F00-05` 从 constraint registry 生成 Object/Bone constraint 清单。
- [ ] `F00-06` 从 editor space types 生成 editor、region 和 mode 清单。
- [ ] `F00-07` 从 import/export registrations 生成格式和扩展清单。
- [ ] `F00-08` 从 render/physics feature flags 生成后端能力清单。
- [ ] `F00-09` 为每条清单记录 Blender 源文件、符号和版本。
- [ ] `F00-10` 为每条清单分配稳定 parity ID重跑不得漂移。
- [ ] `F00-11` 把现有 N-001 至 N-026 证据映射到 parity ID。
- [ ] `F00-12` 拒绝同一 parity ID 同时声明不兼容的 release class。
- [ ] `F00-13` CI 检查新增 Blender 项目必须先分类才能合并。
- [ ] `F00-14` 生成按 family、状态、后端和依赖聚合的差距报告。
- [ ] `F00-15` 固定 Blender 5.2 inventory hash作为全域发布输入。
里程碑 F00重复生成结果 byte-for-byte 一致,未分类计数为 0任何汇总状态均可追溯到单项。
## 5. F01 文件、Main、ID 与恢复
- [ ] `F01-01` 盘点全部可持久化 ID type 和 embedded ID。
- [ ] `F01-02` 为每个 ID type 建立最小合法 `.blend` fixture。
- [ ] `F01-03` 为 library-linked、missing、override 三种所有权建立 fixture。
- [ ] `F01-04` 逐 ID type 验证 wasm32 DNA 对齐和指针重建。
- [ ] `F01-05` 逐 ID type 验证 Main 加载、释放和 100 次重复无增长。
- [ ] `F01-06` 固定 stable ID 在 rename、duplicate、append 后的规则。
- [ ] `F01-07` 固定 ID add/remove/remap 的单事务 revision 规则。
- [ ] `F01-08` 验证 undo/redo 不产生悬挂 ID 引用。
- [ ] `F01-09` 验证保存取消保持旧 `.blend` hash。
- [ ] `F01-10` 验证 OOM 保持旧 Main 和旧 OPFS revision。
- [ ] `F01-11` 验证 staging、journal、manifest 三个崩溃点恢复。
- [ ] `F01-12` 验证跨 Worker 重启的 command replay 去重。
- [ ] `F01-13` 建立 schema migration 向前、向后和拒绝矩阵。
- [ ] `F01-14` 建立 autosave、manual save、save-as 的互斥状态机。
- [ ] `F01-15` 建立未知新 DNA 字段的数据保留测试。
里程碑 F01所有已分类 ID 都能精确读取并安全释放;允许写入的 ID 完成原子保存重开,其余保持
数据不丢失并返回稳定阻断。
## 6. F02 Scene、Object、Collection 与 View Layer
- [ ] `F02-01` 完整读取 Scene、ViewLayer、LayerCollection 和 Collection 层级。
- [ ] `F02-02` 完整读取 Object 类型、实例、显示、选择和可见性状态。
- [ ] `F02-03` 验证 local/world/parent inverse/delta transform 组合。
- [ ] `F02-04` 验证 parent 类型 Object/Bone/Vertex/Vertex3。
- [ ] `F02-05` 实现 View Layer create/remove/rename Main transaction。
- [ ] `F02-06` 实现 Collection link/unlink/move Main transaction。
- [ ] `F02-07` 实现 Object link/unlink/duplicate/delete Main transaction。
- [ ] `F02-08` 实现 parent/unparent/clear inverse Main transaction。
- [ ] `F02-09` 实现 transform、apply transform 和 origin 独立 transaction。
- [ ] `F02-10` 实现 instance collection 和 real-user 计数安全门。
- [ ] `F02-11` 验证 dependency cycle 和跨 Scene 引用阻断。
- [ ] `F02-12` 同步 View3D、Outliner、Properties 的 selection/context revision。
- [ ] `F02-13` 验证 local view、hide、exclude、holdout 和 indirect-only 映射。
- [ ] `F02-14` 保存重开后比较层级、可见性和 stable ID。
- [ ] `F02-15` 建立 100k Object/Collection 的分块与性能门。
## 7. F03 Depsgraph、Animation Frame 与 Delta
- [ ] `F03-01` 为全部 ID type 记录 depsgraph component/operation 支持状态。
- [ ] `F03-02` 验证 graph build、tagged update、frame change 和 graph free。
- [ ] `F03-03` 验证对象 transform dependency ordering。
- [ ] `F03-04` 验证 modifier、constraint、driver、parent 跨对象依赖边。
- [ ] `F03-05` 验证 dependency cycle 报告包含稳定对象和 operation ID。
- [ ] `F03-06` 固定每种 Main edit 的最小 recalc tag。
- [ ] `F03-07` 拒绝 stale evaluation 覆盖新 revision。
- [ ] `F03-08` 固定 frame seek 取消和迟到结果丢弃规则。
- [ ] `F03-09` 对 snapshot 与 delta 结果建立等价性测试。
- [ ] `F03-10` 对 add/remove/remap 建立 delta 顺序测试。
- [ ] `F03-11` 对 mesh byte range 建立重叠、越界和合并测试。
- [ ] `F03-12` 对资源 delta 建立 hash、生命周期和引用计数测试。
- [ ] `F03-13` 主线程与 Offscreen 消费同一 revision 序列。
- [ ] `F03-14` 100 次 frame/update 循环无 Main、Worker、GPU 持续增长。
## 8. F04 Mesh 建模与 UV
- [ ] `F04-01` 冻结 Object/Edit Mode mesh operator 白名单。
- [ ] `F04-02` 完成 vertex/edge/face select、history 和 active element。
- [ ] `F04-03` 完成 box/circle/lasso/select-linked/select-similar。
- [ ] `F04-04` 完成 primitive add 的逐 primitive fixture。
- [ ] `F04-05` 完成 merge、split、separate、join 的逐操作闭环。
- [ ] `F04-06` 完成 extrude vertex/edge/face/region 的逐模式闭环。
- [ ] `F04-07` 完成 inset individual/region 的逐参数闭环。
- [ ] `F04-08` 完成 bevel vertex/edge 的 segment/profile/clamp 闭环。
- [ ] `F04-09` 完成 loop cut、slide 和 edge ring selection。
- [ ] `F04-10` 完成 knife、bisect 和 intersect 的独立闭环。
- [ ] `F04-11` 完成 rip、spin、screw 和 bridge edge loops。
- [ ] `F04-12` 完成 fill、grid fill、beautify 和 triangulate/quads。
- [ ] `F04-13` 完成 dissolve vertex/edge/face/limited dissolve。
- [ ] `F04-14` 完成 normals recalc/flip/split/merge by distance。
- [ ] `F04-15` 完成 crease、bevel weight、sharp、seam 属性编辑。
- [ ] `F04-16` 完成 vertex group、color attribute 和 generic attribute 编辑。
- [ ] `F04-17` 完成 UV seam、unwrap、smart project、cube/cylinder/sphere project。
- [ ] `F04-18` 完成 UV select、stitch、weld、pin、align、pack islands。
- [ ] `F04-19` 完成 multi-UV map、active/render UV 和 UDIM tile 规则。
- [ ] `F04-20` 每个 operator 建立 non-manifold、degenerate 和预算负例。
- [ ] `F04-21` 每个 operator 建立 Main/undo/save/reopen/desktop golden。
- [ ] `F04-22` 建立 1M vertex edit 的取消、内存和 delta 性能门。
## 9. F05 Curve、Surface、Text、Metaball 与现代 Curves
- [ ] `F05-01` 完成 Curve Poly/Bezier/NURBS 全字段 reader。
- [ ] `F05-02` 完成 Surface U/V order、knot、endpoint、cyclic reader。
- [ ] `F05-03` 完成 Text body、textbox、paragraph、style 和 VFont reader。
- [ ] `F05-04` 完成 Metaball family、element、threshold 和 update reader。
- [ ] `F05-05` 完成 Curves point/curve domain 与全部基础属性 reader。
- [ ] `F05-06` 完成 PointCloud position/radius/custom attributes reader。
- [ ] `F05-07` 完成 spline/control point/handle create、remove、reorder。
- [ ] `F05-08` 完成 spline type convert、cyclic、tilt、radius 和 weight 编辑。
- [ ] `F05-09` 完成 Surface U/V topology 和 rational weight 编辑。
- [ ] `F05-10` 完成 Text body、style range、textbox 和 layout 编辑。
- [ ] `F05-11` 完成外部字体校验、OPFS 绑定、替换和缺失恢复。
- [ ] `F05-12` 完成 Metaball element add/remove/transform 编辑。
- [ ] `F05-13` 完成 Curves selection、comb、length 和 radius 有界工具。
- [ ] `F05-14` 验证 legacy/evaluated mesh 与 desktop tessellation golden。
- [ ] `F05-15` 验证主线程/Offscreen handle raycast、gizmo 和释放。
- [ ] `F05-16` 完成 GLB/USD 映射或逐类型 loss report。
## 10. F06 Volume、Hair 与大规模稀疏数据
- [ ] `F06-01` 读取 Volume grid、transform、class、type 和 metadata。
- [ ] `F06-02` 固定 OpenVDB 到 NanoVDB converter build identity。
- [ ] `F06-03` 校验每个 grid 和 chunk 的 offset、length、hash。
- [ ] `F06-04` 完成 HTTP range、retry、resume 和 If-Range。
- [ ] `F06-05` 完成 OPFS bundle 原子绑定和 Worker 重发现。
- [ ] `F06-06` 定义 GPU page-fault feedback 和 overflow 行为。
- [ ] `F06-07` 完成 page request 合并、取消、hash 和 stale revision 门。
- [ ] `F06-08` 完成 resident page table、pin、LRU 和确定性 OOM。
- [ ] `F06-09` 完成主线程 WebGPU 自动缺页重绘。
- [ ] `F06-10` 完成 Offscreen WebGPU 相同缺页序列。
- [ ] `F06-11` 完成 density/temperature/flame/color 有限材质映射。
- [ ] `F06-12` 完成 device loss 后 page table 有界恢复。
- [ ] `F06-13` 完成 Hair curves、root binding 和 surface dependency reader。
- [ ] `F06-14` 完成 64 MiB/1 GiB sparse bundle 预算和取消门。
- [ ] `F06-15` 完成 Volume Main + asset + viewport 联合保存重开。
- [ ] `F06-16` 完成 desktop/主线程/Offscreen 三视角 golden。
## 11. F07 Sculpt、Vertex Paint、Weight Paint 与 Texture Paint
- [ ] `F07-01` 冻结 PBVH 类型、brush、falloff 和 symmetry inventory。
- [ ] `F07-02` 接入真实 PBVH node 查询,移除 proxy-success。
- [ ] `F07-03` 接入深度/遮挡可见性采样。
- [ ] `F07-04` 完成 Draw、Smooth、Inflate、Grab 各自 desktop golden。
- [ ] `F07-05` 完成 Mask draw/clear/invert 和 Face Set 操作。
- [ ] `F07-06` 完成 symmetry、radial symmetry 和 texture mask 规则。
- [ ] `F07-07` 一个 pointer session 只提交一个 Main undo step。
- [ ] `F07-08` 完成 Vertex Paint blend、falloff、selection mask。
- [ ] `F07-09` 完成 Weight Paint normalize、limit、mirror、clean。
- [ ] `F07-10` 完成 multi-paint、auto-normalize 和 bone selection 联动。
- [ ] `F07-11` 完成 Texture Paint UV hit、seam bleed 和 clone source。
- [ ] `F07-12` 完成 packed/UDIM dirty tile 原子事务。
- [ ] `F07-13` 完成 4K/8K texture upload、LRU、quota 和 OOM。
- [ ] `F07-14` 完成笔/压力输入与鼠标输入确定性 fixture。
- [ ] `F07-15` 每种 paint 模式完成 undo/save/reopen 和 Worker restart。
## 12. F08 Grease Pencil
- [ ] `F08-01` 完成 Layer/Group/Frame/Drawing/Stroke/Point 全字段 reader。
- [ ] `F08-02` 完成 layer/group create/remove/reorder transaction。
- [ ] `F08-03` 完成 frame insert/remove/duplicate/move transaction。
- [ ] `F08-04` 完成 draw/erase stroke 单 pointer transaction。
- [ ] `F08-05` 完成 point/stroke/segment select、marquee、lasso。
- [ ] `F08-06` 完成 point radius/opacity/color/material/cyclic 编辑。
- [ ] `F08-07` 完成 onion skin 和 multi-frame preview。
- [ ] `F08-08` 完成 2D canvas 与 3D viewport selection revision 同步。
- [ ] `F08-09` 完成 Dope Sheet frame exposure 编辑。
- [ ] `F08-10` 逐个接入 Grease Pencil modifier reader/evaluator。
- [ ] `F08-11` 完成主线程/Offscreen stroke tessellation golden。
- [ ] `F08-12` 完成 save/reopen、GLB/USD loss 和大 drawing 性能门。
## 13. F09 Modifier 与 Geometry Processing
- [ ] `F09-01` 将 Blender 5.2 modifier 0-86 每项映射到 parity ID。
- [ ] `F09-02` 每项读取 name、UID、stack order、mode 和 target。
- [ ] `F09-03` 每项建立 unsupported preservation fixture。
- [ ] `F09-04` 逐项开放 Generate modifier evaluator 和 golden。
- [ ] `F09-05` 逐项开放 Deform modifier evaluator 和 golden。
- [ ] `F09-06` 逐项开放 Physics modifier cache/evaluator 和 golden。
- [ ] `F09-07` 逐项开放 Grease Pencil modifier evaluator 和 golden。
- [ ] `F09-08` 逐项实现参数 Main edit、undo、save/reopen。
- [ ] `F09-09` 逐项实现 apply/copy/move/remove transaction。
- [ ] `F09-10` 验证 object/collection/texture target 缺失和 cycle。
- [ ] `F09-11` 验证 viewport/render/edit/cage 开关组合。
- [ ] `F09-12` 验证 stack reorder 与 depsgraph recalc 最小化。
- [ ] `F09-13` Decimate 三模式完成全部 delimiter/weight/symmetry 组合。
- [ ] `F09-14` Subdivision CPU/OpenSubdiv/WebGPU 路径分别声明。
- [ ] `F09-15` 1M/10M topology 的取消、内存、Worker restart 门。
## 14. F10 Geometry Nodes 与 Simulation Zone
- [ ] `F10-01` 完成 node/group/interface/socket/link/stable ID reader。
- [ ] `F10-02` 生成全部 Geometry Node type inventory。
- [ ] `F10-03` 为 unsupported node 保留原图并阻断求值。
- [ ] `F10-04` 逐 node 建立最小 desktop fixture。
- [ ] `F10-05` 逐 node 实现 socket 默认值和 link type 校验。
- [ ] `F10-06` 逐 node 接入 Blender lazy-function evaluator。
- [ ] `F10-07` 逐 node 比较 geometry、attribute、instance 输出。
- [ ] `F10-08` 固定 field context、domain adaptation 和匿名属性寿命。
- [ ] `F10-09` 固定 group recursion、dependency cycle 和深度预算。
- [ ] `F10-10` 实现 node add/remove/move/link/unlink 单 transaction。
- [ ] `F10-11` 实现 interface socket add/remove/reorder transaction。
- [ ] `F10-12` Simulation Zone cache 绑定 graph/source/revision hash。
- [ ] `F10-13` 完成 cache cancel、LRU、corruption、restart 和 OOM。
- [ ] `F10-14` 完成 node editor UI、selection、search 和错误定位。
- [ ] `F10-15` 恶意大图、深链、宽 fan-out 和资源引用预算门。
## 15. F11 Material、Shader Nodes、Texture 与 Color Management
- [ ] `F11-01` 完成 Material slot、surface/volume/displacement 全字段 reader。
- [ ] `F11-02` 生成全部 Shader Node type inventory。
- [ ] `F11-03` 逐 node 建立 socket/link/default desktop fixture。
- [ ] `F11-04` 逐 node 实现 Main add/remove/link/parameter transaction。
- [ ] `F11-05` 完成 Principled BSDF 全输入和版本语义映射。
- [ ] `F11-06` 完成 Texture Coordinate/Mapping/Image/Environment 节点。
- [ ] `F11-07` 完成 Normal/Bump/Displacement/Tangent 节点。
- [ ] `F11-08` 完成 Math/Vector Math/Mix/Color Ramp 节点。
- [ ] `F11-09` 固定 unsupported graph preservation 和 compile block。
- [ ] `F11-10` shader key 绑定 graph、asset、color space 和 backend。
- [ ] `F11-11` compile failure 保留上一份可用 pipeline。
- [ ] `F11-12` 完成 image source、packed、UDIM、sequence 和 generated 生命周期。
- [ ] `F11-13` 完成 sRGB/linear/non-color/scene-linear 转换 golden。
- [ ] `F11-14` 完成 OCIO view/display/look/exposure 能力矩阵。
- [ ] `F11-15` 完成 viewport PBR 与 Blender reference 像素误差门。
## 16. F12 Animation、Driver、NLA 与 Timeline
- [ ] `F12-01` 完成 Action/Slot/FCurve/Group/Channel 全字段 reader。
- [ ] `F12-02` 完成 keyframe insert/delete/move/duplicate transaction。
- [ ] `F12-03` 完成 constant/linear/bezier interpolation。
- [ ] `F12-04` 完成 handle type、tangent、easing 和 extrapolation。
- [ ] `F12-05` 完成 modifier、sampled point 和 cyclic FCurve reader。
- [ ] `F12-06` 完成 Dope Sheet channel/filter/selection 同步。
- [ ] `F12-07` 完成 Graph Editor key/handle/curve editing。
- [ ] `F12-08` 完成 marker、preview range、playback 和 audio sync 状态。
- [ ] `F12-09` 完成 driver variable/target/expression 安全 reader。
- [ ] `F12-10` 只对白名单 driver expression 开放本地求值。
- [ ] `F12-11` 完成 NLA track/strip/action/time mapping 只读求值。
- [ ] `F12-12` 完成 Clip/Transition/Meta/Sound strip 逐类型编辑。
- [ ] `F12-13` 完成 blend/repeat/reverse/animated time 逐参数 golden。
- [ ] `F12-14` 完成 NLA Editor selection、move、trim 和 mute/solo。
- [ ] `F12-15` 完成 animation GLB/USD 映射和 loss report。
## 17. F13 Armature、Pose、Constraint 与 Shape Key
- [ ] `F13-01` 完成 Armature/Bone/EditBone/PoseBone 全字段 reader。
- [ ] `F13-02` 完成 rest/pose/parent/connected/inherit scale 组合 golden。
- [ ] `F13-03` 完成 edit bone add/remove/subdivide/extrude/parent transaction。
- [ ] `F13-04` 完成 pose transform、clear、copy/paste 和 pose library transaction。
- [ ] `F13-05` 生成全部 Object/Bone Constraint inventory。
- [ ] `F13-06` 逐 constraint 读取参数、target、space 和 influence。
- [ ] `F13-07` 逐 constraint 建立 desktop/WASM frame golden。
- [ ] `F13-08` 逐 constraint 实现 add/remove/reorder/parameter transaction。
- [ ] `F13-09` 完成 IK chain、pole、limits 和 dependency cycle。
- [ ] `F13-10` 完成 vertex group weight、bind matrix 和 armature modifier。
- [ ] `F13-11` 完成 Shape Key relative/absolute、value 和 slider 编辑。
- [ ] `F13-12` 完成 Shape Key topology change 阻断或精确 remap。
- [ ] `F13-13` 完成 skin/shape simplification 误差预算和稳定阻断。
- [ ] `F13-14` 完成 Three.js skeleton/morph 和双视口像素门。
- [ ] `F13-15` 完成 GLB skin/morph/animation desktop reimport 比较。
## 18. F14 Physics、Particles 与 Simulation Cache
- [ ] `F14-01` 冻结 Rigid Body/World/Constraint inventory。
- [ ] `F14-02` 冻结 Cloth/Collision/Soft Body inventory。
- [ ] `F14-03` 冻结 Fluid Domain/Flow/Effector inventory。
- [ ] `F14-04` 冻结 Dynamic Paint Canvas/Brush inventory。
- [ ] `F14-05` 冻结 Particle/Hair/Force Field inventory。
- [ ] `F14-06` 逐 family 完成 Main settings/dependency reader。
- [ ] `F14-07` 逐 family 完成 settings edit/undo/save/reopen。
- [ ] `F14-08` 逐 family 定义 local solver、desktop bake 或 server 分类。
- [ ] `F14-09` 逐 family 定义 cache manifest 和 frame binary schema。
- [ ] `F14-10` 完成 source/settings/build/frame/hash 绑定。
- [ ] `F14-11` 完成 exact frame seek、range read 和 stale frame gate。
- [ ] `F14-12` 完成 bake start/progress/cancel/commit 状态机。
- [ ] `F14-13` 完成 cache LRU、quota、corruption 和 Worker restart。
- [ ] `F14-14` 每 family 完成 100 帧 desktop/browser golden。
- [ ] `F14-15` 每 family 完成 OOM、timeout、device loss 和 server fault 门。
## 19. F15 Camera、Lighting、Render 与 Output
- [ ] `F15-01` 完成 Camera lens/sensor/shift/clip/DOF/panorama reader。
- [ ] `F15-02` 完成 Point/Sun/Spot/Area light 全字段 reader。
- [ ] `F15-03` 完成 World surface/color/strength/environment reader。
- [ ] `F15-04` 完成 Scene render、film、format、color management reader。
- [ ] `F15-05` 每个字段完成 Main edit/undo/save/reopen。
- [ ] `F15-06` 完成 Three/WebGPU camera、light 和 shadow 映射。
- [ ] `F15-07` 固定灯数、shadow atlas、probe 和 texture 预算。
- [ ] `F15-08` 冻结 Workbench feature matrix 和 pixel golden。
- [ ] `F15-09` 冻结 Eevee feature matrix 和 pixel golden。
- [ ] `F15-10` 将 Cycles device/backend 明确分类为 server。
- [ ] `F15-11` 完成 render job submit/progress/cancel/result hash。
- [ ] `F15-12` 完成 Freestyle capability 和 server result binding。
- [ ] `F15-13` 完成 Render Result/pass/layer metadata 和查看器。
- [ ] `F15-14` 完成 image output format/color/alpha 能力矩阵。
- [ ] `F15-15` 完成 device loss、OOM、timeout 和错误报告。
## 20. F16 Compositor
- [ ] `F16-01` 生成全部 Compositor Node inventory。
- [ ] `F16-02` 完成 graph/socket/link/default/resource reader。
- [ ] `F16-03` 逐 node 建立 desktop input/output fixture。
- [ ] `F16-04` 逐 node 实现 CPU 或 WebGPU executor。
- [ ] `F16-05` 逐 node 实现 Main parameter/link transaction。
- [ ] `F16-06` unsupported graph 保留并提交 server 或阻断。
- [ ] `F16-07` 完成 Render Layer/Image/Movie Clip/Mask 资源绑定。
- [ ] `F16-08` 完成 Viewer/Composite/File Output 目标边界。
- [ ] `F16-09` 完成 tile、frame cache、LRU 和 revision key。
- [ ] `F16-10` 完成 worker cancellation 和迟到 tile 丢弃。
- [ ] `F16-11` 完成 HDR、alpha、color-space 像素 golden。
- [ ] `F16-12` 完成大图、恶意图、cycle、OOM 和 device loss 门。
## 21. F17 Video Sequencer 与 Audio
- [ ] `F17-01` 完成 Scene/Movie/Image/Sound/Effect/Meta strip reader。
- [ ] `F17-02` 完成 channel/range/offset/speed/transform/crop reader。
- [ ] `F17-03` 生成 effect、transition 和 modifier inventory。
- [ ] `F17-04` 完成 add/remove/move/trim/split transaction。
- [ ] `F17-05` 完成 meta group/ungroup 和 dependency cycle。
- [ ] `F17-06` 逐 effect/transition 建立 frame golden。
- [ ] `F17-07` 运行时探测 WebCodecs/HTMLMedia codec 能力。
- [ ] `F17-08` 完成 proxy、waveform、thumbnail content-addressed cache。
- [ ] `F17-09` 完成精确 seek、scrub、取消和 stale decode gate。
- [ ] `F17-10` 完成 audio mix、mute/solo、volume/pan 和 A/V sync。
- [ ] `F17-11` 完成 AudioContext suspend/resume 和无设备恢复。
- [ ] `F17-12` 最终编码按 codec 分类 local 或 server。
- [ ] `F17-13` 完成 1M frame timeline 和长媒体性能门。
- [ ] `F17-14` 完成损坏媒体、路径沙箱、网络中断和 quota 门。
## 22. F18 Movie Tracking 与 Mask
- [ ] `F18-01` 完成 MovieClip/Proxy/TrackingSettings reader。
- [ ] `F18-02` 完成 Track/Marker/PlaneTrack/Object reader。
- [ ] `F18-03` 完成 Mask/Layer/Spline/Point/Handle reader。
- [ ] `F18-04` 完成 marker add/remove/move/track transaction。
- [ ] `F18-05` 完成 mask point/handle/spline/layer transaction。
- [ ] `F18-06` 完成 selection、marquee、lasso 和锁定过滤。
- [ ] `F18-07` 本地 tracking 算法逐项 capability probe。
- [ ] `F18-08` camera solve、stabilize 和 planar track 分类 local/server。
- [ ] `F18-09` 完成 server solve submit/cancel/result hash。
- [ ] `F18-10` 完成 Clip/Mask editor overlay 和 frame sync。
- [ ] `F18-11` 完成 compositor/scene resource binding。
- [ ] `F18-12` 完成 desktop solve/error golden、保存重开和媒体故障门。
## 23. F19 Asset、Library、Link 与 Override
- [ ] `F19-01` 完成 Asset metadata/tag/catalog/preview reader。
- [ ] `F19-02` 完成 catalog schema migration 和稳定 catalog ID。
- [ ] `F19-03` 完成 content-addressed preview 生成、缓存和释放。
- [ ] `F19-04` 完成 append 单 data-block Main transaction。
- [ ] `F19-05` 完成 link read-only ownership 和 reload transaction。
- [ ] `F19-06` 完成 relocate、missing library 和 path sandbox。
- [ ] `F19-07` 完成 library dependency cycle 检测。
- [ ] `F19-08` 完成 override create/resync/reset 操作矩阵。
- [ ] `F19-09` 完成跨项目 stable ID 和引用失效规则。
- [ ] `F19-10` 完成 Asset Browser search/filter/sort/drag context。
- [ ] `F19-11` 完成 OPFS index rebuild 和损坏条目隔离。
- [ ] `F19-12` 完成大 catalog、quota、Worker restart 和 malicious metadata 门。
## 24. F20 Import、Export 与交换格式
- [ ] `F20-01` 为 glTF/GLB 建立字段级 import/export 矩阵。
- [ ] `F20-02` 为 OBJ/MTL 建立字段级 import/export 矩阵。
- [ ] `F20-03` 为 STL 建立字段级 import/export 矩阵。
- [ ] `F20-04` 为 PLY 建立字段级 import/export 矩阵。
- [ ] `F20-05` 为 USD/USDZ 建立字段级 import/export 矩阵。
- [ ] `F20-06` 为 Alembic 建立字段级 import/export 矩阵。
- [ ] `F20-07` 为 FBX 和其他注册格式给出 local/server/excluded 分类。
- [ ] `F20-08` 每种格式完成最小 import -> save -> reopen。
- [ ] `F20-09` 每种格式完成 export -> desktop reimport。
- [ ] `F20-10` 每种格式比较 hierarchy、geometry、material、animation。
- [ ] `F20-11` 每种格式输出机器可读 warning/loss/block report。
- [ ] `F20-12` 完成 external URI、path traversal 和 sandbox。
- [ ] `F20-13` 完成 archive entry 数、压缩比、总字节和路径预算。
- [ ] `F20-14` 完成大文件 streaming、取消、OOM 和临时文件清理。
- [ ] `F20-15` 固定 desktop importer/exporter build identity。
## 25. F21 Editors、Workspace、Keymap 与交互
- [ ] `F21-01` 生成全部 SpaceType/Region/Mode inventory。
- [ ] `F21-02` 完成 View3D/Outliner/Properties 共享 context store。
- [ ] `F21-03` 完成 UV/Image Editor 只读面,再逐 writer 开放编辑。
- [ ] `F21-04` 完成 Shader/Geometry/Compositor Node Editor 共用 graph 基础层。
- [ ] `F21-05` 完成 Dope Sheet/Graph/NLA/Timeline 共用 animation context。
- [ ] `F21-06` 完成 Spreadsheet 几何和属性分页读取。
- [ ] `F21-07` 完成 Text/Console 的只读与默认拒绝边界。
- [ ] `F21-08` 完成 workspace/area/region split/join/layout persistence。
- [ ] `F21-09` workspace 保存不得改变 Blender scene revision。
- [ ] `F21-10` operator search 只从 capability registry 生成。
- [ ] `F21-11` keymap 按 editor/modal/tool context 确定性解析。
- [ ] `F21-12` 完成 menu/context menu/popup/modal/Escape 规则。
- [ ] `F21-13` 完成鼠标、触控、笔、HiDPI、IME 和非 US 键盘 fixture。
- [ ] `F21-14` 完成键盘全路径、焦点、ARIA 和对比度门。
- [ ] `F21-15` editor dispose 后零 DOM listener、Worker request 和 GPU 泄漏。
## 26. F22 Python、Extension 与安全执行边界
- [ ] `F22-01` 保持 Python/Text autorun/driver/add-on 默认拒绝。
- [ ] `F22-02` 完成 script/add-on manifest 只读解析和大小预算。
- [ ] `F22-03` 定义签名者、权限、撤销、版本和审计模型。
- [ ] `F22-04` 定义浏览器 sandbox 的 DOM/network/OPFS/Worker 权限。
- [ ] `F22-05` 完成 CPU、内存、时间和输出预算。
- [ ] `F22-06` 完成 requestId replay、审计 hash chain 和篡改拒绝。
- [ ] `F22-07` server Blender job 使用一次性目录和只读源。
- [ ] `F22-08` server job 固定网络策略、资源限制和超时。
- [ ] `F22-09` server result 绑定 source/settings/build/output hash。
- [ ] `F22-10` 完成恶意脚本、依赖混淆和逃逸 fixture。
- [ ] `F22-11` 完成 CSP禁止 inline script、eval 和未声明 origin。
- [ ] `F22-12` 完成 extension install/update/remove 的签名和回滚策略。
## 27. F23 平台、偏好、国际化与跨浏览器
- [ ] `F23-01` 冻结 Chromium single/pthread 平台能力报告。
- [ ] `F23-02` 完成 Firefox OPFS/WebGL2/WebGPU/Worker/isolation probe。
- [ ] `F23-03` 完成 Firefox P0 主线程路径。
- [ ] `F23-04` 完成 Firefox Offscreen 路径。
- [ ] `F23-05` 完成 WebKit OPFS/WebGL2/WebGPU/Worker/isolation probe。
- [ ] `F23-06` 完成 WebKit P0 主线程路径。
- [ ] `F23-07` 完成 WebKit Offscreen 路径。
- [ ] `F23-08` 按浏览器/设备档位固定内存和 GPU 预算。
- [ ] `F23-09` 完成 user preferences schema、migration 和项目无关 persistence。
- [ ] `F23-10` 完成 locale 切换、数字/日期格式和翻译 fallback。
- [ ] `F23-11` 完成 theme、font scale、reduced motion 和高对比偏好。
- [ ] `F23-12` native window/file watcher/CUDA/Metal/HIP/OptiX 明确 EXCLUDED。
- [ ] `F23-13` 每个新增浏览器加入 quick/P0/full 三层 CI。
- [ ] `F23-14` 1280x720、1440x900、4K 和窄屏无重叠截图门。
## 28. F24 性能、故障、安全、交付与全域审计
- [ ] `F24-01` 固定 100k/1M/10M geometry 基准环境和预算。
- [ ] `F24-02` 固定 4K/8K/UDIM texture 基准环境和预算。
- [ ] `F24-03` 固定 100/1000/1M frame animation/media 基准。
- [ ] `F24-04` 固定 simulation/volume cache 容量和 seek 基准。
- [ ] `F24-05` 覆盖 WASM/Main、OPFS、GPU、VDB 四类 OOM。
- [ ] `F24-06` 覆盖 Worker crash、device loss、network interruption、quota。
- [ ] `F24-07` 覆盖 blend/image/font/media/archive/node/script fuzz corpus。
- [ ] `F24-08` fuzz crash 修复后自动固化为最小回归 fixture。
- [ ] `F24-09` quick lane 执行 type/lint/unit/status/schema。
- [ ] `F24-10` Chromium lane 执行 P0、双视口和 fault smoke。
- [ ] `F24-11` release lane 执行 full E2E、性能、VDB、acceptance、offline。
- [ ] `F24-12` CI 报告绑定 commit、lockfile、inventory、ledger、engine、archive hash。
- [ ] `F24-13` binary/source archive 在两个空临时目录独立复验。
- [ ] `F24-14` 完成 SBOM、license、source offer 和供应链审计。
- [ ] `F24-15` 每个 parity ID 都有 LOCAL 正例、SERVER 闭环或 EXCLUDED 保真证据。
- [ ] `F24-16` inventory 未分类、隐式 loss、proxy-success 三项计数均为 0。
- [ ] `F24-17` 独立机器复验全部构件和关键证据 hash。
- [ ] `F24-18` 发布说明逐项列出支持、受限、server 和 excluded 能力。
里程碑 F24不能由聚合 release gate 反推 family 完成。只有 inventory 的每个单项都达到自身
退出条件N-015 至 N-026 才能逐项从 `BLOCKED` 改为 `COMPLETE`
## 29. 当前领取点
截至 2026-08-15M6 已完成 35/71single/pthread 选择与回退、真实 HTTP MIME/range、
releaseId/hash 升级负例和缓存合同均有专项证据。HTML、manifest、stable engine 未变化时均返回
`304`,换代后均返回 `200` 且 ETag 变化production build 的 1 个入口 JS、1 个 CSS、3 个
Worker 均使用 8 位内容 hash 文件名并返回 `immutable`。当前唯一下一任务为 `M6-09A`:从
binary archive 解包后使用合同服务器启动,不读取工作区文件。

View File

@@ -1,6 +1,6 @@
# Web Blender V1 当前执行计划
更新时间2026-08-14
更新时间2026-08-15
## 1. 交付目标
@@ -110,21 +110,25 @@ React 工作区和编辑器
| 交付 | 离线 binary/source archive、SBOM、SHA-256 可复现 | 正式部署模板、持续发布流水线、跨机器复验待做 | 本地 V1 RC 已收口 |
2026-08-15 重新执行 `typecheck``lint`、14 个 Node tests、status consistency、release
evidence checker 和 `git diff --check`,均退出 0。acceptance runner 解析 12 个 family 的
50 条声明,去重为 49 条命令并全部通过,命令总耗时 454,106 ms清单 SHA-256 为
`74c01764987c3dce0601a20cdf494f8076102f6d54262a1d13df953abee96160`。官方 Blender 5.2.0
evidence checker 和 `git diff --check`,均退出 0。M6-03 后 acceptance runner 再次解析 12 个
family 的 50 条声明,去重为 49 条命令并全部通过,命令总耗时 480,169 ms清单 SHA-256 为
`d5fb0a7b2df92b076f8b05ccd68903d60e3352b9423b0c1404065f6cbcbdd45c`。官方 Blender 5.2.0
Linux archive SHA-256 为 `96f6c181a30f4950607839dc84d42a354b250d8a0231b098b59b7bc69c351c48`
运行时 `USD=True`desktop USD roundtrip 已通过。
最终 release evidence 生成时间为 `2026-08-15T04:43:09.832Z`17 条 record 均为 exit code 0、
非空输出和非空构件 hash总命令耗时 553,427 ms。`v1-user-loop` record 已绑定上述 acceptance
第一次重跑误用了本地 `USD=False` Blender结果为 48/49该失败证据未标 READY。修正为上述
官方 runtime 后完整重跑 49 条,而不是手改失败记录。当前 release evidence 生成时间为
`2026-08-15T05:37:25.215Z`17 条 record 均为 exit code 0、非空输出和非空构件 hash
总命令耗时 553,577 ms。`v1-user-loop` record 已绑定上述 acceptance
清单 hash。
### 4.4 当前阻断与决策顺序
1. `P0`V1 本地 RC 已无阻断;下一领取点为 `M6-01` 部署响应头合同
2. `P1`当前离线包已在本机连续两次可复现,还缺独立目录/独立机器复验和真实 HTTP 部署门。
3. `P1`pthread/single-thread 的生产选择、隔离头、fallback 和缓存升级合同尚未形成发布证据
1. `P0`V1 可部署 RC 已完成 M6 71/71M7 已完成 7/18下一领取点为 `M7-08` Worker 崩溃恢复
2. `P1`binary/source archive 已在独立目录复验quick/Chromium/release 三条
CI lane 与 RC manifest 已联合校验;发布/限制/恢复和运维合同均已进入离线包
3. `P1`pthread/single-thread、真实 HTTP、缓存升级、归档离线、OPFS 断网和空目录
deploy/upgrade/rollback 均已有可复验证据M7 开始核心项目体验硬化。
4. `P2`:完整 Blender 功能域继续受 VDB demand paging、PBVH、Geometry Nodes evaluator、
通用 Shader、完整编辑器/渲染/媒体和安全脚本边界阻断。
@@ -146,9 +150,19 @@ loss、恶意 blend、zip bomb、离线包和 V1 用户闭环均已在最终 M5
| M3 long media | 长时间线索引、seek、取消与重启证据 | 已完成,持续回归 | `performance.longMedia=true` |
| M4 OOM | 四类确定性内存故障与恢复证据 | 已完成,持续回归 | `faults.oom=true` |
| M5 V1 RC | 可复现离线候选包、SBOM、校验和 | 已完成,持续回归 | release gate `READY`、49/49 acceptance |
| M6 可部署 RC | 双 WASM 运行时选择、真实 HTTP、离线包、CI、运维合同 | 已完成71/71 | single/pthread 可解释,真实部署与归档 P0 通过 |
| M7 核心体验硬化 | 项目状态机、恢复、存储、输入和可访问性 | 进行中7/18 | 30 分钟编辑/保存/重开 soak 通过 |
| M8 VDB 自动分页 | GPU 缺页反馈、range/OPFS、LRU、双视口恢复 | 未开始 | 64 MiB sparse bundle 与三视角 golden 通过 |
| M9 非 Mesh/GP/Paint | 字体、Curve、Grease Pencil、Paint 增量闭环 | 未开始 | 每个新增 writer 独立通过 Main/undo/save/golden |
| M10 GN/Shader/NLA/Simulation | 白名单求值、cache、编译与阻断 | 未开始 | 四域分别通过 desktop/WASM/fault 门 |
| M11 Render/Compositor/Media | 灯光、渲染、合成、媒体执行边界 | 未开始 | 本地白名单与 server 边界均可审计 |
| M12 Asset/IO/Editors | 资产、格式、编辑器和上下文工作流 | 未开始 | 每个格式/editor 有独立 round-trip 或稳定阻断 |
| M13 Scripting/Security | 脚本默认拒绝、服务端隔离、CSP、供应链 | 未开始 | 恶意输入矩阵和 release 安全门通过 |
| M14 跨浏览器/设备 | Firefox、WebKit、触控、笔、HiDPI、IME | 未开始 | 新浏览器进入 quick/P0/full CI 后才声明支持 |
| M15 全域审计 | Blender 5.2 全域差距和下一发布 | 未开始 | 逐 family 审计,不由聚合 release gate 反推完成 |
任务严格按 M1 -> M2 -> M3 -> M4 -> M5 领取。里程碑内部允许先写纯校验任务,但不得在
前一里程碑失败时把后一里程碑标为完成。
M0 至 M5 已完成并转入持续回归;当前严格领取 M6。里程碑内部允许先写纯校验任务,但不得在
前一里程碑失败时把后一里程碑标为完成M7 至 M15 不得绕过 M6 发布合同扩功能
## 6. M0 范围、状态和 P0 闭环
@@ -448,9 +462,9 @@ M5 按 `PREP -> STATIC -> ACCEPT -> LOOP -> PERF -> FAULT -> VDB -> PACKAGE -> A
- [x] `M5-ACCEPT-19` 使用 SHA-256 校验的官方 USD-enabled Blender 5.2 重跑 desktop USD
roundtrip归档 hash 和 executable hash 均写入 acceptance evidence。
- [x] `M5-ACCEPT-20` 运行完整 acceptance runner50 declarations、49 unique、49 passed、
0 failed总命令耗时 454,106 ms。
0 failedM6-03 后复验总命令耗时 480,169 ms。
- [x] `M5-ACCEPT-21` 将 acceptance evidence SHA-256
`74c01764987c3dce0601a20cdf494f8076102f6d54262a1d13df953abee96160` 绑定到最终
`d5fb0a7b2df92b076f8b05ccd68903d60e3352b9423b0c1404065f6cbcbdd45c` 绑定到最终
`v1-user-loop` release record。
### M5.3 P0 用户闭环和 Chromium 回归
@@ -525,7 +539,7 @@ M5 按 `PREP -> STATIC -> ACCEPT -> LOOP -> PERF -> FAULT -> VDB -> PACKAGE -> A
- [x] `M5-AUDIT-03` 确认 12 个 family 仍为 `parityStatus=BLOCKED`
- [x] `M5-AUDIT-04` 确认 12 个 V1 required slice 均为 `releaseStatus=READY`
- [x] `M5-AUDIT-05` 运行完整 `release:evidence`,用本轮命令输出和构件 hash 替换旧 records
当前生成时间 `2026-08-15T04:43:09.832Z`17 条 record总命令耗时 553,427 ms。
当前生成时间 `2026-08-15T05:37:25.215Z`17 条 record总命令耗时 553,577 ms。
- [x] `M5-AUDIT-06` 检查 17 条 evidence record 均为 exit code 0、非空输出和非空构件 hash。
- [x] `M5-AUDIT-07` 检查 evidence 的 ledger SHA-256 与当前文件完全一致。
- [x] `M5-AUDIT-08` 运行 `test:release-evidence`,结果 `READY``missing=[]`
@@ -541,7 +555,7 @@ SBOM、状态和 evidence SHA-256 相互一致。
M5 最终 archive SHA-256binary
`f2903a75026a0a28615aeb5f4834a3859af83022c82bc50e28437dcbed68e549`source
`9a9c1b505c68a618de842be7c248f628b104b11b80e8a40d111f62540fcba327`。下一任务:`M6-01`
`9a9c1b505c68a618de842be7c248f628b104b11b80e8a40d111f62540fcba327`当前下一任务:`M7-08`
## 12. V1 后原子里程碑
@@ -551,40 +565,157 @@ M5 最终 archive SHA-256binary
### M6 可部署 RC
M6 固定按 `runtime -> HTTP -> cache/offline -> archive -> CI -> operations` 串行推进。每个
任务只验证一个行为;上一小里程碑未通过时,不把后一小里程碑的准备工作记为完成。
#### M6.1 single-thread 无隔离启动
- [x] `M6-01` 新建机器可读部署合同,固定 MIME、缓存、COOP、COEP、CORP 和 Range 响应头;
`node tools/web/check-deployment-contract.mjs`、typecheck、lint 和 production build 通过。
- [x] `M6-02` 复用 `CapabilityGateResult` 新增纯函数 `gateWasmThreadingCapability`;仅当
`crossOriginIsolated``SharedArrayBuffer``Worker` 同时可用时放行 pthread 引擎,
缺失项逐条返回稳定结构single-thread 初始化路径不调用该门。
- [ ] `M6-03` 验证 single-thread 引擎在无隔离环境启动
- [ ] `M6-04` 验证 pthread 引擎只在隔离和 SharedArrayBuffer 可用时启动。
- [ ] `M6-05` 验证 pthread 初始化失败回退 single-thread且不重复打开项目
- [ ] `M6-06` engine JS/WASM、Worker、CSS 和 fixture 固定正确 MIME。
- [ ] `M6-07` 为 WASM 与大资产验证 byte-range 206、`Content-Range` 和稳定 ETag
- [ ] `M6-08` 验证版本更新后旧 HTML 不加载新旧混合 hash 资源。
- [ ] `M6-09` 验证完全断网冷启动离线包,不访问 CDN 或外部字体
- [ ] `M6-10` 验证断网重开 OPFS 项目、编辑、保存和 GLB 导出
- [ ] `M6-11` 在全新临时目录解包 binary archive 并运行 browser smoke。
- [ ] `M6-12` 在全新临时目录解包 source archive 并核对 source offer/SBOM
- [ ] `M6-13` 建立 CI quick lanetypecheck、lint、unit、status、evidence schema。
- [ ] `M6-14` 建立 CI Chromium laneP0 用户闭环、双视口和 fault smoke。
- [ ] `M6-15` 建立 CI release lane全 E2E、性能、离线复现和 acceptance evidence。
- [ ] `M6-16` 所有 CI lane 输出机器可读 JSON 和构件 SHA-256。
- [ ] `M6-17` 写部署/升级/回滚 runbook命令在空目录实测。
- [ ] `M6-18` 生成 RC 版本号、变更说明、已知限制和恢复说明。
- [x] `M6-03A` 增加仅供测试使用的无隔离 Vite profile默认 dev/preview 仍强制部署合同头
- [x] `M6-03B` 在独立 Chromium context 断言文档响应没有 COOP/COEP/CORP
`crossOriginIsolated=false``SharedArrayBuffer` 不可用、`Worker` 可用
- [x] `M6-03C` 断言当前 engine manifest 为 `memory.shared=false`,网络请求不包含 pthread
JS/WASM/worker 资产
- [x] `M6-03D` 在上述环境等待 single-thread WebEngine 返回 `ready`,同时证明 pthread gate
`BLOCKED`,从行为上保证 single-thread 不受该门阻断
- [x] `M6-03E` 使用真实 `basic_scene.blend` 完成 open 和一个 Main edit证明不是只加载 UI shell
M6.1 退出条件:专项命令一次启动独立无隔离服务器,以上五项同进程通过,退出码为 0
#### M6.2 pthread 选择与回退
- [x] `M6-04A` 扩展 engine manifest schema显式声明 single/pthread variant、JS、WASM、
pthread worker、shared memory 和每个文件 SHA-256v2 纯校验器固定 256 至 32768 pages、
variant 资源隔离和稳定错误路径21 个专项正负例断言通过。生产 manifest/安装路径保持 v1
无隔离 single-thread 专项回归通过。
- [x] `M6-04B` 安装脚本从两个独立构建目录复制 variant 资产到显式 single/pthread URL
生成绑定四个物理文件 SHA-256 的生产 schema v2 manifest同目录和缺失资产负例不会改写目标
Node 专项、manifest/local dependency、build 和无隔离 Chromium 回归通过。旧 single Worker 入口
只作为 `M6-05` fallback 接线前的完整 Main 兼容路径保留。
- [x] `M6-04C` 新增纯函数选择 engine variant输入仅为 manifest、用户策略和平台能力
frozen input 重复调用结果一致,未知策略稳定拒绝。
- [x] `M6-04D` `AUTO` 在完整 pthread gate `READY` 时选择 pthread隔离、SharedArrayBuffer 或
Worker 任一缺失时选择 single五组能力矩阵通过。
- [x] `M6-04E` `PTHREAD_REQUIRED` 在任一能力缺失时返回 `selectedVariant=null` 和原样 M6-02
结构,不向调用方暴露可请求的 pthread URL。
- [x] `M6-04F` 隔离 Chromium 实际加载 pthread JS/WASM校验 manifest hash、
`SharedArrayBuffer`、pool worker 二次 JS 请求和 native handle 创建/释放。
- [x] `M6-04G` 同一 manifest 在无隔离 Chromium 由 AUTO 实际加载 single JS/WASM确认非共享
内存、无 pthread 请求,并继续用完整 single Main 打开和编辑真实 `.blend`
- [x] `M6-05A` 为 pthread factory 增加仅测试可用的确定性初始化失败注入点。
- [x] `M6-05B` `AUTO` 下 pthread 初始化失败只回退一次 single不形成重试循环。
- [x] `M6-05C` fallback 前终止已创建的 pthread worker、timer 和 pending request。
- [x] `M6-05D` fallback 过程中未打开项目时只初始化;已有待打开项目时只执行一次 open。
- [x] `M6-05E` fallback 结果公开 selected/attempted/fallbackReasonUI 不显示 pthread READY。
M6.2 退出条件isolated pthread、unisolated single、forced failure fallback 三个专项测试均通过,
每种路径的资产请求、Worker 数和 open 次数可断言。
#### M6.3 HTTP MIME 与 byte range
- [x] `M6-06A` 从 deployment contract 生成测试用扩展名到 MIME 的唯一映射,不复制常量。
- [x] `M6-06B` 对 HTML、JS、CSS、WASM、JSON、PNG、WAV、BLEND、NVDB 分别执行 GET。
- [x] `M6-06C` 对 engine JS/WASM、pthread worker 和 fixture 分别执行 HEAD断言状态与
`Content-Type`,且 HEAD 无响应体。
- [x] `M6-06D` MIME 错误时测试必须报告 URL、expected、actual不能只返回布尔失败。
- [x] `M6-07A` 对 WASM、BLEND、NVDB 各执行一个闭区间 range断言 `206` 和精确字节。
- [x] `M6-07B` 分别验证 open-ended 与 suffix range若合同不支持则稳定返回 `416`
- [x] `M6-07C` malformed、倒序、越界和 multi-range 分别返回 `416`,不得返回截断的 `200`
- [x] `M6-07D` `206` 同时具有 `Accept-Ranges``Content-Range``Content-Length` 和强 ETag。
- [x] `M6-07E` 相同内容跨请求 ETag 稳定;内容变化后 ETag 必须变化。
- [x] `M6-07F` `If-Range` 匹配时返回对应 `206`,不匹配时返回完整 `200`
- [x] `M6-07G` 中断后从精确 offset 续传并校验合并后的 SHA-256不复用旧 revision 字节。
M6.3 退出条件:同一真实 HTTP server 同时通过 MIME 和 range 正负例矩阵。
#### M6.4 缓存、升级与离线运行
- [x] `M6-08A` HTML 和 engine manifest 固定 `no-cache`,每次启动都能重新验证版本。
- [x] `M6-08B` content-hashed JS/CSS/Worker 固定 immutable稳定 engine URL 必须重新验证 hash。
- [x] `M6-08C` manifest 为每个 variant 绑定同一 releaseId禁止跨 release 混用 JS/WASM/worker。
- [x] `M6-08D` 模拟发布切换:旧 HTML 只加载旧 manifest 全套或明确刷新,不加载混合版本。
- [x] `M6-08E` hash 不匹配时保持旧项目未打开并返回稳定错误,不进入 fallback 的错误 variant。
`npm --prefix web run test:deployment-cache` 从 production build 临时副本启动合同服务器:`/`
`/index.html`、manifest 和 4 个 stable engine 资源均为 `no-cache`;实际构建的入口 JS、CSS 和
3 个 Worker 共 5 项均具有 Vite 内容 hash 且为 immutable。HTML、manifest、stable engine 对相同
ETag 返回无 body `304`,换代后对旧 ETag 返回完整 `200` 和新 ETag临时换代不改写 `web/dist`
- [x] `M6-09A` 从 binary archive 解包后使用合同服务器启动,不读取工作区文件。
- [x] `M6-09B` 新浏览器 profile 冷启动时拦截所有非 loopback 请求,断言请求数为 0。
- [x] `M6-09C` 断开外网但保留本地合同服务器,完成 manifest、WASM、字体和基础 UI 冷启动。
- [x] `M6-10A` 在线创建 OPFS 项目、编辑并原子保存,记录 revision、blend hash 和 stable IDs。
- [x] `M6-10B` 重建 Worker 并切换到外网断开状态,从 OPFS 重开同一 revision。
- [x] `M6-10C` 离线重开后继续 Main edit、undo/redo 和保存revision 单调增加。
- [x] `M6-10D` 离线导出 GLB 并严格回读,且无非 loopback 请求。
M6.4 退出条件版本切换负例、archive 冷启动和 OPFS 离线闭环分别有独立命令与报告。
#### M6.5 归档独立复验
- [x] `M6-11A` 使用 `mktemp -d` 解包 binary archive禁止引用仓库 `web/dist`
- [x] `M6-11B` 校验 `SHA256SUMS.txt`、deployment contract、license、SBOM 和 binary manifest。
- [x] `M6-11C` 从临时目录启动合同服务器并运行 browser smoke退出后释放端口和临时 profile。
- [x] `M6-12A` 在另一个空临时目录解包 corresponding source archive。
- [x] `M6-12B` 校验 source offer 指向的版本、许可证、lockfile、CMake 与 Web 源码全部存在。
- [x] `M6-12C` 扫描 source archive拒绝 build、dist、release、cache、trace、绝对开发路径和凭据。
- [x] `M6-12D` 从 source archive 执行 lockfile install、typecheck、build并比对可重现清单。
M6.5 退出条件binary/source 两个归档均在独立目录复验,报告包含 archive 与关键文件 SHA-256。
#### M6.6 CI 分层与机器报告
- [x] `M6-13A` 确定仓库实际 CI provider、runner OS、Node/Chrome/Blender/Emscripten 版本矩阵。
- [x] `M6-13B` quick lane 从 lockfile 安装,执行 typecheck、lint、unit、status 和 evidence schema。
- [x] `M6-13C` quick lane 缓存只以 lockfile/toolchain key 命中,不缓存 release evidence。
- [x] `M6-14A` Chromium lane 执行 P0 user loop 和主线程/Offscreen 双视口 smoke。
- [x] `M6-14B` Chromium lane 执行 network、device-loss、OOM、quota 与 malicious-input fault smoke。
- [x] `M6-14C` Chromium lane 每个 browser server 使用独立动态端口,失败后也必须清理。
- [x] `M6-15A` release lane 顺序执行全 E2E、性能矩阵、VDB、acceptance 和离线复现。
- [x] `M6-15B` release lane 只在受保护 ref 或显式 dispatch 运行,不由普通 quick push 重复触发。
- [x] `M6-15C` 任一 release 子命令失败时证据状态为失败,不能上传旧 READY JSON。
- [x] `M6-16A` 三条 lane 均输出 schema-versioned JSON命令、exitCode、duration、environment。
- [x] `M6-16B` 报告绑定 commit、lockfile、ledger、engine、archive 和测试构件 SHA-256。
- [x] `M6-16C` 上传前运行 schema 与 hash checker报告和构件采用固定 retention。
2026-08-15 完整 release lane 的 19/19 子命令通过,总命令耗时
1,245,732 msfull E2E 126/126V1 acceptance 49/49。quick 6/6、Chromium 9/9 和
release 19/19 报告由 `check-ci-report.mjs` 联合校验,同时绑定 commit
`17ab961485fe2ea1574c5f0b7d3ef9608a2bf424`。失败注入返回 exit code 17 并仅写入
`FAILED` 报告。当前 binary/source archive SHA-256 分别为
`ca09540e8f5c0157bb85d62c98aeb3291ea195a29585bc8e05afb308747a8ff5`
`95b3a747425081a205b1966c004a08da4cb37aab7ba0a649ff076713f7010f74`
M6.6 退出条件fresh runner 上 quick/Chromium/release 三条 lane 通过,失败注入不会保留成功证据。
#### M6.7 运维与 RC 冻结
- [x] `M6-17A` 部署 runbook 从空目录安装 binary archive并验证 HTTPS/loopback 前置条件。
- [x] `M6-17B` 升级 runbook 覆盖新旧 manifest、cache、OPFS schema 和 Worker 版本切换。
- [x] `M6-17C` 回滚 runbook 保留项目数据,只回滚 app/engine releaseId并验证旧版可读策略。
- [x] `M6-17D` 故障诊断表覆盖 MIME、range、isolation、hash、quota、Worker 和 GPU 错误。
- [x] `M6-17E` 在一个全新临时目录逐条实跑部署、升级、回滚命令并记录输出。
- [x] `M6-18A` 固定 RC semver、commit、releaseId、binary/source/SBOM SHA-256。
- [x] `M6-18B` 变更说明只列已验证 V1 能力,并链接明确 excluded/server/blocked 清单。
- [x] `M6-18C` 已知限制包含 Chromium-only、single/pthread 选择、存储和 GPU 预算。
- [x] `M6-18D` 发布恢复说明包含项目备份、升级失败回滚和证据校验命令。
- [x] `M6-18E` 最终运行 quick、Chromium、release lane所有结果绑定同一 RC commit。
M6 退出条件:离线包和一个真实 HTTP 部署均通过同一 P0 流程single/pthread 选择可解释;
CI 可从 lockfile 和对应源码重现当前 binary/source hash。
### M7 核心项目体验硬化
- [ ] `M7-01` 记录 import/open/save/save-as/export 五个用户动作的状态机。
- [ ] `M7-02` 对重复 open、并发 save 和 close-during-save 建立互斥规则。
- [ ] `M7-03` 大文件导入显示真实字节进度并支持取消。
- [ ] `M7-04` 取消 import 后释放 input buffer、native handle 和 staging 文件。
- [ ] `M7-05` save 中断时旧 revision 和旧 hash 保持不变。
- [ ] `M7-06` dirty state 只由成功 Main transaction 改变。
- [ ] `M7-07` undo 回到保存 revision 时 dirty state 清除。
- [x] `M7-01` 记录 import/open/save/save-as/export 五个用户动作的状态机。
- [x] `M7-02` 对重复 open、并发 save 和 close-during-save 建立互斥规则。
- [x] `M7-03` 大文件导入显示真实字节进度并支持取消。
- [x] `M7-04` 取消 import 后释放 input buffer、native handle 和 staging 文件。
- [x] `M7-05` save 中断时旧 revision 和旧 hash 保持不变。
- [x] `M7-06` dirty state 只由成功 Main transaction 改变。
- [x] `M7-07` undo 回到保存 revision 时 dirty state 清除。
- [ ] `M7-08` Worker 崩溃时展示可恢复错误,不清空当前项目列表。
- [ ] `M7-09` 重启 Worker 后恢复 selection、frame、workspace 和项目 revision。
- [ ] `M7-10` recent projects 对缺失/损坏 OPFS 条目执行隔离和修复提示。

View File

@@ -20,9 +20,24 @@ SQLite WASM 和 Bitbybit/OCCT 均不在当前依赖范围内。Three.js、WASM
运行时资源全部使用项目本地文件,不依赖 CDN。
`Web Blender Modeler V1` 的本地 RC 已收口ledger 的 50 条 acceptance 声明去重为 49 条
命令并全部通过122/122 Chromium E2E 通过17 条 release record 为 `READY` 且 0 missing。
命令并全部通过126/126 Chromium full E2E 通过17 条 release record 为 `READY` 且 0 missing。
这不改变完整 Blender 5.2 对标结论N-015 至 N-026 仍为 12/12 `parityStatus=BLOCKED`
### 1.1 2026-08-15 重新盘点快照
| 状态轴 | 已完成 | 尚未完成 | 判断 |
| --- | --- | --- | --- |
| V1 release family | 12/12 `READY` | 0 | V1 已声明功能闭环完成 |
| Blender 5.2 全域 parity | 0/12 complete | 12/12 `BLOCKED` | 不是完整 Blender Web 移植 |
| slice 台账 | 177 completed | 60 blocked | blocked 项均留在 M8-M15 长期路线 |
| acceptance | 50 declarations / 49 unique passed | 0 failed | 本地 V1 RC 证据完整 |
| M6 可部署 RC | 71/71 原子任务 | 0 | 可部署 RC 已冻结 |
| M7 核心体验硬化 | 7/18 原子任务 | 11 | 当前严格领取 M7 项目体验工作 |
当前优先级不是扩 Blender 全域功能。single/pthread、真实 HTTP、缓存升级、离线闭环、
独立归档复验、运维 runbook、RC 文档和最终三条 CI lane 均已通过;现在进入 M7 核心项目
体验硬化。`M7-01``M7-07` 已通过,唯一下一任务为 `M7-08`
## 2. 已完成并有测试覆盖的能力
| 能力 | 当前证据 | 状态 |
@@ -222,16 +237,33 @@ demand paging、大 bundle 和发布 golden。
`docs/status/v1-acceptance-evidence.json` 逐条覆盖 12 个 family 的 50 条声明、49 个唯一命令,
结果为 49 passed、0 failed其 SHA-256
`74c01764987c3dce0601a20cdf494f8076102f6d54262a1d13df953abee96160` 已绑定到最终
`d5fb0a7b2df92b076f8b05ccd68903d60e3352b9423b0c1404065f6cbcbdd45c` 已绑定到最终
`v1-user-loop` release record。desktop USD 使用经发布 SHA-256 校验的官方 Blender 5.2.0 LTS
runtime `USD=True`最终离线 binary/source archive SHA-256 分别为
`f2903a75026a0a28615aeb5f4834a3859af83022c82bc50e28437dcbed68e549`
`9a9c1b505c68a618de842be7c248f628b104b11b80e8a40d111f62540fcba327`
runtime `USD=True`当前 M6 离线 binary/source archive SHA-256 分别为
`ca09540e8f5c0157bb85d62c98aeb3291ea195a29585bc8e05afb308747a8ff5`
`95b3a747425081a205b1966c004a08da4cb37aab7ba0a649ff076713f7010f74`。release lane 19/19
子命令通过,总命令耗时 1,245,732 msquick/Chromium/release 报告联合校验同一 commit。
`M6-01` 已新增机器可读部署合同并让 Vite/离线包生成器复用该事实源。`M6-02` 已新增纯
pthread 能力门`crossOriginIsolated``SharedArrayBuffer``Worker` 任一缺失均返回稳定的
`PLATFORM_CAPABILITY_UNAVAILABLE`,且 single-thread 初始化路径不调用该门。下一领取点是
`CURRENT_EXECUTION_PLAN.md``M6-03`:验证 single-thread 引擎在无隔离环境启动。
pthread 能力门`M6-03A-E` 已在没有 COOP/COEP/CORP 的独立 Chromium server 验证
`crossOriginIsolated=false``SharedArrayBuffer` 不可用且 pthread gate `BLOCKED` 时,
single-thread 引擎仍能加载真实 WASM、打开 `basic_scene.blend` 并执行 Main edit。`M6-04A`
已冻结 schema v2 和纯校验器single/pthread 各自声明 JS、WASM、pthread worker、SHA-256 与
16 MiB 至 2 GiB 页级内存边界。`M6-04B` 已从 `build_web-single``build_web-pthread`
安装显式 variant 资产,生产 manifest 已切换到 schema v2安装专项覆盖逐字节复制、四项 hash、
同目录和缺失资产不改写,且无隔离 single-thread 真实 Main 编辑回归通过。旧 single Worker 入口
暂时保留到选择器接线。`M6-04C-G` 已完成纯选择、AUTO/required 能力矩阵和双环境实机:
隔离 Chromium 加载 pthread shared memory 与 pool worker无隔离 Chromium 只加载显式 single
variant并继续完成真实 Main 编辑。`M6-05A-E` 已验证确定性 pthread 失败只回退一次、完整清理
且 pending project 只 open 一次。`M6-06A``M6-07G` 已由同一真实 HTTP server 一次通过 9 种
MIME、4 个 HEAD、WASM/BLEND/NVDB range、6 类 `416`、强 ETag、If-Range 和断流续传/换代拒绝。
`M6-08C-E` 已将生产双 variant manifest 绑定同一 `releaseId`:版本不一致只返回
`REFRESH_REQUIRED` 且 initialize/open 均为 0真实 Chromium 篡改 pthread WASM 后返回稳定
`ENGINE_VARIANT_INTEGRITY_FAILED`,不请求 single、不 fallback、不 open。`M6-08A/B` 已用实际
production build 验证 HTML/manifest/stable engine `no-cache`、5 个 hashed JS/CSS/Worker
`immutable`,以及未换代 `304`、换代后旧 ETag 得到完整 `200` 的重验证行为。M6 最终
quick 7/7、Chromium 9/9、release 25/25 与 RC manifest 联合通过M7 已完成 7/18
下一领取点是 `M7-08`
## 6. 当前验收命令
@@ -244,6 +276,7 @@ node tools/web/run-web-engine-smoke.mjs
npm --prefix web run typecheck
npm --prefix web run lint
npm --prefix web test
npm --prefix web run test:engine-manifest-v2
npm --prefix web run test:golden
npm --prefix web run test:frame-evaluation
npm --prefix web run test:pose-constraint-goldens
@@ -276,6 +309,9 @@ BLENDER_BIN=/path/to/usd-enabled/blender \
npm --prefix web run test:v1-acceptance-evidence
npm --prefix web run test:v1-acceptance-release-binding
node tools/web/check-deployment-contract.mjs
npm --prefix web run test:deployment-http
npm --prefix web run test:deployment-cache
WEB_TEST_PORT=5202 npm --prefix web exec -- playwright test --config web/playwright.single-thread.config.ts
WEB_TEST_PORT=5193 npm --prefix web run test:e2e # 5173 被占用时指定可用端口
WEB_TEST_PORT=5193 npm --prefix web run test:e2e -- -g "N-015|non-mesh"
```

View File

@@ -0,0 +1,20 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://blender-web.local/schemas/ci-lane-report-v1.json",
"title": "Web Blender CI lane report",
"type": "object",
"required": ["schemaVersion", "lane", "status", "generatedAt", "commit", "environment", "bindings", "records", "retentionDays"],
"properties": {
"schemaVersion": { "const": 1 },
"lane": { "enum": ["quick", "chromium", "release"] },
"status": { "enum": ["READY", "FAILED"] },
"generatedAt": { "type": "string", "format": "date-time" },
"commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" },
"environment": { "type": "object" },
"bindings": { "type": "object" },
"records": { "type": "array", "minItems": 1 },
"retentionDays": { "enum": [7, 14, 30] }
},
"additionalProperties": false
}

View File

@@ -2,7 +2,7 @@
"schemaVersion": 4,
"source": "docs/status/parity-ledger.json",
"sourceSha256": "1e16518a25c6b6bed4ab7f605ea1ad5babe529f2842c858b20ae6daae30bd478",
"generatedAt": "2026-08-15T04:43:09.832Z",
"generatedAt": "2026-08-15T05:37:25.215Z",
"families": [
{
"id": "N-015",
@@ -699,19 +699,6 @@
"379dcc11dc7de0dfec4745763bddf3cc415a5f5dbe48500fefae2f7508095bb5"
]
},
{
"id": "v1-user-loop",
"fields": [],
"command": "npm --prefix web run test:v1-user-loop && npm --prefix web run test:v1-acceptance-evidence",
"exitCode": 0,
"durationMs": 5366,
"output": "> blender-web-editor@0.1.0 test:v1-user-loop\n> playwright test --config playwright.config.ts tests/e2e/v1-user-loop.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/v1-user-loop.spec.ts:6:1 completes the V1 import-edit-save-restart-reopen-GLB loop (2.7s)\n\n 1 passed (4.2s)\n\n> blender-web-editor@0.1.0 test:v1-acceptance-evidence\n> node ../tools/web/check-v1-acceptance-evidence.mjs\n\nv1-acceptance-evidence-ok families=12 declarations=50 unique=49 sha256=74c01764987c3dce0601a20cdf494f8076102f6d54262a1d13df953abee96160\n\n[WebServer] (node:2314858) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:2314870) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
"artifactSha256": [
"3defbaa426195254fcaefaddabf9b128002a94def03aa255513c33eaf4ac0926",
"5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8",
"74c01764987c3dce0601a20cdf494f8076102f6d54262a1d13df953abee96160"
]
},
{
"id": "chromium",
"fields": [
@@ -917,6 +904,19 @@
"9a9c1b505c68a618de842be7c248f628b104b11b80e8a40d111f62540fcba327",
"288b0bb969e670b458059adf8897f87bafc76a87a479c8aa0a305905accf2de9"
]
},
{
"id": "v1-user-loop",
"fields": [],
"command": "npm --prefix web run test:v1-user-loop && npm --prefix web run test:v1-acceptance-evidence",
"exitCode": 0,
"durationMs": 5516,
"output": "> blender-web-editor@0.1.0 test:v1-user-loop\n> playwright test --config playwright.config.ts tests/e2e/v1-user-loop.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/v1-user-loop.spec.ts:6:1 completes the V1 import-edit-save-restart-reopen-GLB loop (2.8s)\n\n 1 passed (4.4s)\n\n> blender-web-editor@0.1.0 test:v1-acceptance-evidence\n> node ../tools/web/check-v1-acceptance-evidence.mjs\n\nv1-acceptance-evidence-ok families=12 declarations=50 unique=49 sha256=d5fb0a7b2df92b076f8b05ccd68903d60e3352b9423b0c1404065f6cbcbdd45c\n\n[WebServer] (node:2396656) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:2396668) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
"artifactSha256": [
"3defbaa426195254fcaefaddabf9b128002a94def03aa255513c33eaf4ac0926",
"5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8",
"d5fb0a7b2df92b076f8b05ccd68903d60e3352b9423b0c1404065f6cbcbdd45c"
]
}
]
}

View File

@@ -3,14 +3,14 @@
"source": "docs/status/parity-ledger.json",
"sourceSha256": "1e16518a25c6b6bed4ab7f605ea1ad5babe529f2842c858b20ae6daae30bd478",
"package": "web/package.json",
"packageSha256": "4259c8c085fe94f5b109416230482d0791e6d9b47559546be42415bb91a4c224",
"packageSha256": "a7ee993997c8d62effcdd3bb994f16656424e6a5e976c3d3b4e2951bd4109710",
"toolSourceSha256": {
"tools/web/v1-acceptance-lib.mjs": "48005c8fc9d97955513e104ee1b8950cab2c3520c166eff4358e10bd7318f535",
"tools/web/check-v1-acceptance-coverage.mjs": "6448fcc673fde091cff4a299d77889570d3104bd0cfabe5434f45ba0b46de432",
"tools/web/run-v1-acceptance.mjs": "f12d04f7d74450f1973eb88aa58355a225019c6b9906aedc746c005a1958f1a5",
"tools/web/check-v1-acceptance-coverage.mjs": "42b713f9b622851328266584e3a8ba2231e0393141ba4a8ae091a97337d9e03e",
"tools/web/run-v1-acceptance.mjs": "7e87893e42c6c848c2bc487340712ba4ec151e63e49b2bbc76e6a0c51d839da3",
"tools/web/check-v1-acceptance-evidence.mjs": "af1a3029a1148e8e5d346d66f3a58f7063b73fee8dd95e6e41ed0504ed56dfea"
},
"generatedAt": "2026-08-15T04:33:26.940Z",
"generatedAt": "2026-08-15T13:31:45.207Z",
"environment": {
"node": "v20.19.2",
"npm": "9.2.0",
@@ -35,9 +35,9 @@
],
"command": "npm --prefix web run test:nonmesh-roundtrip",
"exitCode": 0,
"durationMs": 1486,
"outputSha256": "f0f98be0be03fab64d04f9e7a3d696161dc3c9fb5729c09a41f34cc401e61822",
"output": "> blender-web-editor@0.1.0 test:nonmesh-roundtrip\n> node ../tools/web/check-nonmesh-roundtrip.mjs\n\nnonmesh-roundtrip-ok types=CURVE,SURFACE,FONT,METABALL,VOLUME multispline-create-delete-bulk-handle-cyclic-surface-2d-topology-font-links-volume-properties=passed undo-redo=passed save-reopen=passed\n\nweb-depsgraph-stage=runtime-init-begin\nweb-depsgraph-stage=runtime-init-end\nweb-depsgraph-stage=blend-read-end\nweb-depsgraph-stage=view-layer-sync-end\nweb-depsgraph-data scene=0xaf0538 master=0xa18618 nodetree=0 world=0 collections=0\nweb-depsgraph-stage=global-main-swap-end\nweb-depsgraph-stage=graph-new-end\nweb-depsgraph-stage=graph-build-end\nweb-depsgraph-stage=frame-change-end\nweb-depsgraph-stage=graph-free-end\nweb-depsgraph-stage=global-main-restore-end\nweb-depsgraph-stage=blend-free-end"
"durationMs": 1996,
"outputSha256": "828a1171ff5444aba7bddaa0f2f47e1004881e0595b856b23b1e4eb46aa09c01",
"output": "> blender-web-editor@0.1.0-rc.1 test:nonmesh-roundtrip\n> node ../tools/web/check-nonmesh-roundtrip.mjs\n\nnonmesh-roundtrip-ok types=CURVE,SURFACE,FONT,METABALL,VOLUME multispline-create-delete-bulk-handle-cyclic-surface-2d-topology-font-links-volume-properties=passed undo-redo=passed save-reopen=passed\n\nweb-depsgraph-stage=runtime-init-begin\nweb-depsgraph-stage=runtime-init-end\nweb-depsgraph-stage=blend-read-end\nweb-depsgraph-stage=view-layer-sync-end\nweb-depsgraph-data scene=0xaf0538 master=0xa18618 nodetree=0 world=0 collections=0\nweb-depsgraph-stage=global-main-swap-end\nweb-depsgraph-stage=graph-new-end\nweb-depsgraph-stage=graph-build-end\nweb-depsgraph-stage=frame-change-end\nweb-depsgraph-stage=graph-free-end\nweb-depsgraph-stage=global-main-restore-end\nweb-depsgraph-stage=blend-free-end"
},
{
"token": "web:test:nonmesh-desktop-golden",
@@ -46,9 +46,9 @@
],
"command": "npm --prefix web run test:nonmesh-desktop-golden",
"exitCode": 0,
"durationMs": 686,
"outputSha256": "e5339560846d774c039f493679a373b37b35ea7bfa68f1dc2a019188ecc91d45",
"output": "> blender-web-editor@0.1.0 test:nonmesh-desktop-golden\n> node ../tools/web/check-nonmesh-desktop-golden.mjs\n\nnonmesh-desktop-golden-ok fixture=nonmesh_scene.blend geometries=4\n\nweb-depsgraph-stage=runtime-init-begin\nweb-depsgraph-stage=runtime-init-end\nweb-depsgraph-stage=blend-read-end\nweb-depsgraph-stage=view-layer-sync-end\nweb-depsgraph-data scene=0xa71220 master=0xacb470 nodetree=0 world=0 collections=0\nweb-depsgraph-stage=global-main-swap-end\nweb-depsgraph-stage=graph-new-end\nweb-depsgraph-stage=graph-build-end\nweb-depsgraph-stage=frame-change-end\nweb-depsgraph-stage=graph-free-end\nweb-depsgraph-stage=global-main-restore-end\nweb-depsgraph-stage=blend-free-end"
"durationMs": 920,
"outputSha256": "54cfd9cdec5a7885b22768bce6652410fc3709da1f4c943bcfa6655637435215",
"output": "> blender-web-editor@0.1.0-rc.1 test:nonmesh-desktop-golden\n> node ../tools/web/check-nonmesh-desktop-golden.mjs\n\nnonmesh-desktop-golden-ok fixture=nonmesh_scene.blend geometries=4\n\nweb-depsgraph-stage=runtime-init-begin\nweb-depsgraph-stage=runtime-init-end\nweb-depsgraph-stage=blend-read-end\nweb-depsgraph-stage=view-layer-sync-end\nweb-depsgraph-data scene=0xa71220 master=0xacb470 nodetree=0 world=0 collections=0\nweb-depsgraph-stage=global-main-swap-end\nweb-depsgraph-stage=graph-new-end\nweb-depsgraph-stage=graph-build-end\nweb-depsgraph-stage=frame-change-end\nweb-depsgraph-stage=graph-free-end\nweb-depsgraph-stage=global-main-restore-end\nweb-depsgraph-stage=blend-free-end"
},
{
"token": "web:test:nonmesh-glb-blender-roundtrip",
@@ -57,9 +57,9 @@
],
"command": "npm --prefix web run test:nonmesh-glb-blender-roundtrip",
"exitCode": 0,
"durationMs": 1668,
"outputSha256": "8627de96cfe96c0c7490cfe54fa59a680f787d5d3dcdadacc20e79a2c20f72c7",
"output": "> blender-web-editor@0.1.0 test:nonmesh-glb-blender-roundtrip\n> node ../tools/web/check-nonmesh-glb-blender-roundtrip.mjs\n\nnonmesh-glb-blender-roundtrip-ok bytes=53172 objects=7 points=1 lines=3 triangles=3\n\nweb-depsgraph-stage=runtime-init-begin\nweb-depsgraph-stage=runtime-init-end\nweb-depsgraph-stage=blend-read-end\nweb-depsgraph-stage=view-layer-sync-end\nweb-depsgraph-data scene=0xa71260 master=0xacb460 nodetree=0 world=0 collections=0\nweb-depsgraph-stage=global-main-swap-end\nweb-depsgraph-stage=graph-new-end\nweb-depsgraph-stage=graph-build-end\nweb-depsgraph-stage=frame-change-end\nweb-depsgraph-stage=graph-free-end\nweb-depsgraph-stage=global-main-restore-end\nweb-depsgraph-stage=blend-free-end"
"durationMs": 2200,
"outputSha256": "c9ba8f68095690f4235b19da1645416f912fc0a7716f11f75c2ee8a63f300319",
"output": "> blender-web-editor@0.1.0-rc.1 test:nonmesh-glb-blender-roundtrip\n> node ../tools/web/check-nonmesh-glb-blender-roundtrip.mjs\n\nnonmesh-glb-blender-roundtrip-ok bytes=53172 objects=7 points=1 lines=3 triangles=3\n\nweb-depsgraph-stage=runtime-init-begin\nweb-depsgraph-stage=runtime-init-end\nweb-depsgraph-stage=blend-read-end\nweb-depsgraph-stage=view-layer-sync-end\nweb-depsgraph-data scene=0xa71260 master=0xacb460 nodetree=0 world=0 collections=0\nweb-depsgraph-stage=global-main-swap-end\nweb-depsgraph-stage=graph-new-end\nweb-depsgraph-stage=graph-build-end\nweb-depsgraph-stage=frame-change-end\nweb-depsgraph-stage=graph-free-end\nweb-depsgraph-stage=global-main-restore-end\nweb-depsgraph-stage=blend-free-end"
},
{
"token": "web:test:nonmesh-usd-serialization",
@@ -68,9 +68,9 @@
],
"command": "npm --prefix web run test:nonmesh-usd-serialization",
"exitCode": 0,
"durationMs": 2031,
"outputSha256": "b80732c965d85c19cfb3c3a629777bacfdc1a920abcc384d436831a64f6e3101",
"output": "> blender-web-editor@0.1.0 test:nonmesh-usd-serialization\n> USD_DESKTOP_REQUIRED=0 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs\n\nnonmesh-usd-blender-roundtrip-ok bytes=65405 objects=7 points=1 basisCurves=3 meshes=3\n\nweb-depsgraph-stage=runtime-init-begin\nweb-depsgraph-stage=runtime-init-end\nweb-depsgraph-stage=blend-read-end\nweb-depsgraph-stage=view-layer-sync-end\nweb-depsgraph-data scene=0xa71260 master=0xacb460 nodetree=0 world=0 collections=0\nweb-depsgraph-stage=global-main-swap-end\nweb-depsgraph-stage=graph-new-end\nweb-depsgraph-stage=graph-build-end\nweb-depsgraph-stage=frame-change-end\nweb-depsgraph-stage=graph-free-end\nweb-depsgraph-stage=global-main-restore-end\nweb-depsgraph-stage=blend-free-end"
"durationMs": 2532,
"outputSha256": "7a76fabed285b2fbd3b528fae02dd5696327b5068fd6909e504666d5022b80c2",
"output": "> blender-web-editor@0.1.0-rc.1 test:nonmesh-usd-serialization\n> USD_DESKTOP_REQUIRED=0 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs\n\nnonmesh-usd-blender-roundtrip-ok bytes=65405 objects=7 points=1 basisCurves=3 meshes=3\n\nweb-depsgraph-stage=runtime-init-begin\nweb-depsgraph-stage=runtime-init-end\nweb-depsgraph-stage=blend-read-end\nweb-depsgraph-stage=view-layer-sync-end\nweb-depsgraph-data scene=0xa71260 master=0xacb460 nodetree=0 world=0 collections=0\nweb-depsgraph-stage=global-main-swap-end\nweb-depsgraph-stage=graph-new-end\nweb-depsgraph-stage=graph-build-end\nweb-depsgraph-stage=frame-change-end\nweb-depsgraph-stage=graph-free-end\nweb-depsgraph-stage=global-main-restore-end\nweb-depsgraph-stage=blend-free-end"
},
{
"token": "web:test:nonmesh-usd-blender-roundtrip",
@@ -79,9 +79,9 @@
],
"command": "npm --prefix web run test:nonmesh-usd-blender-roundtrip",
"exitCode": 0,
"durationMs": 2033,
"outputSha256": "ce0580b3b5d047a1c3c5d2d1a338c6c001b5d9a69c5d22ed35322bd353fb16af",
"output": "> blender-web-editor@0.1.0 test:nonmesh-usd-blender-roundtrip\n> USD_DESKTOP_REQUIRED=1 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs\n\nnonmesh-usd-blender-roundtrip-ok bytes=65405 objects=7 points=1 basisCurves=3 meshes=3\n\nweb-depsgraph-stage=runtime-init-begin\nweb-depsgraph-stage=runtime-init-end\nweb-depsgraph-stage=blend-read-end\nweb-depsgraph-stage=view-layer-sync-end\nweb-depsgraph-data scene=0xa71260 master=0xacb460 nodetree=0 world=0 collections=0\nweb-depsgraph-stage=global-main-swap-end\nweb-depsgraph-stage=graph-new-end\nweb-depsgraph-stage=graph-build-end\nweb-depsgraph-stage=frame-change-end\nweb-depsgraph-stage=graph-free-end\nweb-depsgraph-stage=global-main-restore-end\nweb-depsgraph-stage=blend-free-end"
"durationMs": 2526,
"outputSha256": "0d4e89b5d022962e4effc43c53385b825c21217dd14850e36f6db7e20bd3511a",
"output": "> blender-web-editor@0.1.0-rc.1 test:nonmesh-usd-blender-roundtrip\n> USD_DESKTOP_REQUIRED=1 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs\n\nnonmesh-usd-blender-roundtrip-ok bytes=65405 objects=7 points=1 basisCurves=3 meshes=3\n\nweb-depsgraph-stage=runtime-init-begin\nweb-depsgraph-stage=runtime-init-end\nweb-depsgraph-stage=blend-read-end\nweb-depsgraph-stage=view-layer-sync-end\nweb-depsgraph-data scene=0xa71260 master=0xacb460 nodetree=0 world=0 collections=0\nweb-depsgraph-stage=global-main-swap-end\nweb-depsgraph-stage=graph-new-end\nweb-depsgraph-stage=graph-build-end\nweb-depsgraph-stage=frame-change-end\nweb-depsgraph-stage=graph-free-end\nweb-depsgraph-stage=global-main-restore-end\nweb-depsgraph-stage=blend-free-end"
},
{
"token": "web:test:nonmesh-binary",
@@ -90,9 +90,9 @@
],
"command": "npm --prefix web run test:nonmesh-binary",
"exitCode": 0,
"durationMs": 4132,
"outputSha256": "0b7d02b2b5ba6c772edf687e2270ae8672b9a16be61e3793f23a2987278413e3",
"output": "> blender-web-editor@0.1.0 test:nonmesh-binary\n> playwright test --config playwright.config.ts -g \"one-million-point binary transfer gate\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:402:1 enforces the N-015 one-million-point binary transfer gate (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4623,
"outputSha256": "53f1182423f3cdcb5d0ba00f5827ba1d5ed5f880151ef5d746ae3893020ed771",
"output": "> blender-web-editor@0.1.0-rc.1 test:nonmesh-binary\n> playwright test --config playwright.config.ts -g \"one-million-point binary transfer gate\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:402:1 enforces the N-015 one-million-point binary transfer gate (1.6s)\n\n 1 passed (3.7s)"
},
{
"token": "web:test:selection-history",
@@ -101,9 +101,9 @@
],
"command": "npm --prefix web run test:selection-history",
"exitCode": 0,
"durationMs": 4046,
"outputSha256": "b130771034a5d42212e4df33b6738159b73c7353bf985233393c506e8a274cb1",
"output": "> blender-web-editor@0.1.0 test:selection-history\n> playwright test --config playwright.config.ts -g \"N-015 selection history\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1060:1 keeps N-015 selection history bounded and rejects stale raycast hits (1.5s)\n\n 1 passed (3.2s)"
"durationMs": 4642,
"outputSha256": "9b8035591b1b4f7f665688d9190d7191d12a5cd6d204fcb271d46375817cb3a2",
"output": "> blender-web-editor@0.1.0-rc.1 test:selection-history\n> playwright test --config playwright.config.ts -g \"N-015 selection history\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1060:1 keeps N-015 selection history bounded and rejects stale raycast hits (1.7s)\n\n 1 passed (3.7s)"
},
{
"token": "web:test:nonmesh-interaction",
@@ -112,9 +112,9 @@
],
"command": "npm --prefix web run test:nonmesh-interaction",
"exitCode": 0,
"durationMs": 4296,
"outputSha256": "7c96e5f69ac5c5a567bb2345fbd8b0793a80d54d8b87ef4a55b7c8cdb223e231",
"output": "> blender-web-editor@0.1.0 test:nonmesh-interaction\n> playwright test --config playwright.config.ts -g \"N-015 curve gizmo interaction\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1081:1 validates the N-015 curve gizmo interaction transaction boundary (1.7s)\n\n 1 passed (3.5s)"
"durationMs": 4538,
"outputSha256": "0794b7d8acb9c24a8de963a9a76fbbf2919228cc637d79c2b101a8280698df63",
"output": "> blender-web-editor@0.1.0-rc.1 test:nonmesh-interaction\n> playwright test --config playwright.config.ts -g \"N-015 curve gizmo interaction\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1081:1 validates the N-015 curve gizmo interaction transaction boundary (1.8s)\n\n 1 passed (3.7s)"
},
{
"token": "web:test:vdb",
@@ -123,9 +123,9 @@
],
"command": "npm --prefix web run test:vdb",
"exitCode": 0,
"durationMs": 4025,
"outputSha256": "1aa70cf5c66b18abf4f2214c67834377c7c50b46a207e84fc50de89dbeb1c1cc",
"output": "> blender-web-editor@0.1.0 test:vdb\n> playwright test --config playwright.config.ts -g \"VDB conversion boundary\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1156:1 validates the VDB conversion boundary and NanoVDB streaming contract (1.5s)\n\n 1 passed (3.2s)"
"durationMs": 4103,
"outputSha256": "974bedb7c4fa970d79b6140d1527b39de4c99f063c9c848684d064efcdc8723b",
"output": "> blender-web-editor@0.1.0-rc.1 test:vdb\n> playwright test --config playwright.config.ts -g \"VDB conversion boundary\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1156:1 validates the VDB conversion boundary and NanoVDB streaming contract (1.4s)\n\n 1 passed (3.3s)"
},
{
"token": "web:test:vdb-availability",
@@ -134,9 +134,9 @@
],
"command": "npm --prefix web run test:vdb-availability",
"exitCode": 0,
"durationMs": 389,
"outputSha256": "09d74a026344f6f10405b879be73c1bdf060ef1eb1d11b4aa2c1ab60ff256d49",
"output": "> blender-web-editor@0.1.0 test:vdb-availability\n> node ../tools/web/check-vdb-availability.mjs\n\nvdb-availability-ok core=READY release=BLOCKED browser-openvdb=disabled desktop=ready server=ready opfs=ready webgpu-core=ready main-roundtrip=ready viewport=blocked advanced-material=blocked resources=9"
"durationMs": 370,
"outputSha256": "3d1fce4839232bd9c5c7dc42ea2e462be9ade0866d1296cfc5897afadd6b39fc",
"output": "> blender-web-editor@0.1.0-rc.1 test:vdb-availability\n> node ../tools/web/check-vdb-availability.mjs\n\nvdb-availability-ok core=READY release=BLOCKED browser-openvdb=disabled desktop=ready server=ready opfs=ready webgpu-core=ready main-roundtrip=ready viewport=blocked advanced-material=blocked resources=9"
},
{
"token": "web:test:vdb-native",
@@ -145,9 +145,9 @@
],
"command": "npm --prefix web run test:vdb-native",
"exitCode": 0,
"durationMs": 1259,
"outputSha256": "1a431ee0076697fa70cfc3230f3400b85dd45cf668a645c2a83276f615e241a4",
"output": "> blender-web-editor@0.1.0 test:vdb-native\n> node ../tools/web/check-vdb-native-pipeline.mjs\n\nvdb-native-pipeline-ok resources=14 converter=openvdb13-nanovdb32 conversion-deterministic=1 semantic-deterministic=1 official-deterministic=1 malformed=blocked browser-openvdb=off"
"durationMs": 1341,
"outputSha256": "52abc08ddc600e3c00842cb2619b3c4b66b113e8bd83d96db910ac1b95d5d012",
"output": "> blender-web-editor@0.1.0-rc.1 test:vdb-native\n> node ../tools/web/check-vdb-native-pipeline.mjs\n\nvdb-native-pipeline-ok resources=14 converter=openvdb13-nanovdb32 conversion-deterministic=1 semantic-deterministic=1 official-deterministic=1 malformed=blocked browser-openvdb=off"
},
{
"token": "web:test:vdb-server",
@@ -156,9 +156,9 @@
],
"command": "npm --prefix web run test:vdb-server",
"exitCode": 0,
"durationMs": 648,
"outputSha256": "5ed0afb34328b5da0ec5702504cb1178999da5e11953129caec407d2c81c0d43",
"output": "> blender-web-editor@0.1.0 test:vdb-server\n> node ../tools/web/check-vdb-server-job.mjs\n\nvdb-server-job-ok job=vdb-b59105490496a2fee0dda646 bytes=2563944 idempotent=1 signed=1 isolated=1 cancelled=1 atomic=1 desktop-server-hash=equal"
"durationMs": 691,
"outputSha256": "d958e3ed39b5660b5e8d66d7171b651e97f0e87c1c7b9769709478925a91907f",
"output": "> blender-web-editor@0.1.0-rc.1 test:vdb-server\n> node ../tools/web/check-vdb-server-job.mjs\n\nvdb-server-job-ok job=vdb-b59105490496a2fee0dda646 bytes=2563944 idempotent=1 signed=1 isolated=1 cancelled=1 atomic=1 desktop-server-hash=equal"
},
{
"token": "web:test:vdb-opfs",
@@ -167,9 +167,9 @@
],
"command": "npm --prefix web run test:vdb-opfs",
"exitCode": 0,
"durationMs": 5297,
"outputSha256": "cabf007e624b204168d00a5f292c33fbdc560e8584a609c11f7286d16e26b6e9",
"output": "> blender-web-editor@0.1.0 test:vdb-opfs\n> playwright test --config playwright.config.ts -g \"hash-bound NanoVDB project through OPFS\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1206:1 commits and reopens a hash-bound NanoVDB project through OPFS (2.7s)\n\n 1 passed (4.5s)"
"durationMs": 5672,
"outputSha256": "976954270c006f573cd70022ab4cd5c500ff9d4ff895aabde4d1eb8cc1895430",
"output": "> blender-web-editor@0.1.0-rc.1 test:vdb-opfs\n> playwright test --config playwright.config.ts -g \"hash-bound NanoVDB project through OPFS\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1206:1 commits and reopens a hash-bound NanoVDB project through OPFS (2.9s)\n\n 1 passed (4.8s)"
},
{
"token": "web:test:vdb-webgpu",
@@ -178,9 +178,9 @@
],
"command": "npm --prefix web run test:vdb-webgpu",
"exitCode": 0,
"durationMs": 62192,
"outputSha256": "3885ee85fd05995c04f1a790fa134f1bdbf39503dc6a00e471fc99a77dbccf6c",
"output": "> blender-web-editor@0.1.0 test:vdb-webgpu\n> playwright test --config playwright.config.ts -g \"real NanoVDB Float32 tree with WebGPU\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1232:1 samples and integrates a real NanoVDB Float32 tree with WebGPU (59.6s)\n\n 1 passed (1.0m)"
"durationMs": 64026,
"outputSha256": "d7d13bf8104f9fef4a92836d37b839fc4ec24135ad6424e7d36eddcf7b45c070",
"output": "> blender-web-editor@0.1.0-rc.1 test:vdb-webgpu\n> playwright test --config playwright.config.ts -g \"real NanoVDB Float32 tree with WebGPU\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1232:1 samples and integrates a real NanoVDB Float32 tree with WebGPU (1.0m)\n\n 1 passed (1.1m)"
},
{
"token": "web:test:vdb-viewport",
@@ -189,9 +189,9 @@
],
"command": "npm --prefix web run test:vdb-viewport",
"exitCode": 0,
"durationMs": 116435,
"outputSha256": "eb54044817e597de7a1978fe2b8cf9d97324a2aeb0cd4ab1574a4385401f7211",
"output": "> blender-web-editor@0.1.0 test:vdb-viewport\n> playwright test --config playwright.config.ts -g \"both production viewport backends\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1264:1 renders a real NanoVDB volume through both production viewport backends (1.9m)\n\n 1 passed (1.9m)"
"durationMs": 122887,
"outputSha256": "215608ae74264a438f7e93caa25758eff946f429de284021b40d202ba78756a5",
"output": "> blender-web-editor@0.1.0-rc.1 test:vdb-viewport\n> playwright test --config playwright.config.ts -g \"both production viewport backends\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1264:1 renders a real NanoVDB volume through both production viewport backends (2.0m)\n\n 1 passed (2.0m)"
},
{
"token": "web:test:vdb-faults",
@@ -201,9 +201,9 @@
],
"command": "npm --prefix web run test:vdb-faults",
"exitCode": 0,
"durationMs": 5724,
"outputSha256": "ba420837572b94192e0dd81660674ef5cc406d77e675ad1322d705749fe785cb",
"output": "> blender-web-editor@0.1.0 test:vdb-faults\n> playwright test --config playwright.config.ts -g \"NanoVDB paging from network, Worker and WebGPU device faults\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1344:1 recovers NanoVDB paging from network, Worker and WebGPU device faults (3.2s)\n\n 1 passed (4.9s)"
"durationMs": 6038,
"outputSha256": "6ab0e4445b5c80adb967c1e573f12339ee9e5bae3b5585ff42218a266df6d28c",
"output": "> blender-web-editor@0.1.0-rc.1 test:vdb-faults\n> playwright test --config playwright.config.ts -g \"NanoVDB paging from network, Worker and WebGPU device faults\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1344:1 recovers NanoVDB paging from network, Worker and WebGPU device faults (3.4s)\n\n 1 passed (5.2s)"
},
{
"token": "web:e2e:N-015|non-mesh",
@@ -212,9 +212,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-015|non-mesh\"",
"exitCode": 0,
"durationMs": 17945,
"outputSha256": "dabeaa176c79b4688db7d0c11faac4026ca2ea98994d582ff8006eb76a4a533d",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-015|non-mesh\n\n\nRunning 10 tests using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:295:1 reads bounded non-mesh data blocks and previews supported geometry (2.3s)\n ✓ 2 tests/e2e/smoke.spec.ts:341:1 reports structured N-015 gates for previewable and resource-backed data (1.2s)\n ✓ 3 tests/e2e/smoke.spec.ts:402:1 enforces the N-015 one-million-point binary transfer gate (946ms)\n ✓ 4 tests/e2e/smoke.spec.ts:417:1 rejects malformed N-015 binary chunks, integer overflow and budget overflow (919ms)\n ✓ 5 tests/e2e/smoke.spec.ts:1060:1 keeps N-015 selection history bounded and rejects stale raycast hits (912ms)\n ✓ 6 tests/e2e/smoke.spec.ts:1081:1 validates the N-015 curve gizmo interaction transaction boundary (1.2s)\n ✓ 7 tests/e2e/smoke.spec.ts:1098:40 previews an N-015 Curve handle drag and commits Main once in main-thread Chromium (3.0s)\n ✓ 8 tests/e2e/smoke.spec.ts:1098:40 previews an N-015 Curve handle drag and commits Main once in OffscreenCanvas Chromium (2.2s)\n ✓ 9 tests/e2e/smoke.spec.ts:1435:1 returns real Blender evaluations for legacy non-mesh geometry (1.4s)\n ✓ 10 tests/e2e/smoke.spec.ts:1467:1 keeps N-015 non-mesh previews in the OffscreenCanvas renderer (1.4s)\n\n 10 passed (17.1s)"
"durationMs": 20310,
"outputSha256": "4c6c6c6b742c24240b0ea458175e2054ef5cc0357c02f06310cea664fa7d57dc",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-015|non-mesh\n\n\nRunning 10 tests using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:295:1 reads bounded non-mesh data blocks and previews supported geometry (2.6s)\n ✓ 2 tests/e2e/smoke.spec.ts:341:1 reports structured N-015 gates for previewable and resource-backed data (1.3s)\n ✓ 3 tests/e2e/smoke.spec.ts:402:1 enforces the N-015 one-million-point binary transfer gate (1.1s)\n ✓ 4 tests/e2e/smoke.spec.ts:417:1 rejects malformed N-015 binary chunks, integer overflow and budget overflow (947ms)\n ✓ 5 tests/e2e/smoke.spec.ts:1060:1 keeps N-015 selection history bounded and rejects stale raycast hits (974ms)\n ✓ 6 tests/e2e/smoke.spec.ts:1081:1 validates the N-015 curve gizmo interaction transaction boundary (1.4s)\n ✓ 7 tests/e2e/smoke.spec.ts:1098:40 previews an N-015 Curve handle drag and commits Main once in main-thread Chromium (3.4s)\n ✓ 8 tests/e2e/smoke.spec.ts:1098:40 previews an N-015 Curve handle drag and commits Main once in OffscreenCanvas Chromium (2.5s)\n ✓ 9 tests/e2e/smoke.spec.ts:1435:1 returns real Blender evaluations for legacy non-mesh geometry (1.6s)\n ✓ 10 tests/e2e/smoke.spec.ts:1467:1 keeps N-015 non-mesh previews in the OffscreenCanvas renderer (1.6s)\n\n 10 passed (19.5s)"
},
{
"token": "web:e2e:real OPFS quota",
@@ -223,9 +223,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"real OPFS quota\"",
"exitCode": 0,
"durationMs": 4220,
"outputSha256": "3b5bbe03b09487bee7d7d21821833cdd863c7e6ccc77a62329a1c83ea8a69526",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g real OPFS quota\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1595:1 enforces a real OPFS quota without replacing the committed project (1.6s)\n\n 1 passed (3.4s)"
"durationMs": 4212,
"outputSha256": "9ca11848c3935b40798325d6e54d68ec0e7c0d8eb880bb16ecba04a7ca85d3cc",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g real OPFS quota\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1595:1 enforces a real OPFS quota without replacing the committed project (1.5s)\n\n 1 passed (3.4s)"
},
{
"token": "web:test:grease-pencil",
@@ -234,9 +234,9 @@
],
"command": "npm --prefix web run test:grease-pencil",
"exitCode": 0,
"durationMs": 702,
"outputSha256": "54038fad56465ca7ac6826cf69854c227c3812ce375031af549f2771861c9473",
"output": "> blender-web-editor@0.1.0 test:grease-pencil\n> node ../tools/web/check-grease-pencil-roundtrip.mjs\n\ngrease-pencil-roundtrip-ok layer-frame-stroke=passed undo-redo=passed save-reopen=passed"
"durationMs": 776,
"outputSha256": "c5419926826128e17eed9837fd237b2467eee712143f7f3f1c7eecc243fe6775",
"output": "> blender-web-editor@0.1.0-rc.1 test:grease-pencil\n> node ../tools/web/check-grease-pencil-roundtrip.mjs\n\ngrease-pencil-roundtrip-ok layer-frame-stroke=passed undo-redo=passed save-reopen=passed"
},
{
"token": "web:test:grease-pencil-editor",
@@ -245,9 +245,9 @@
],
"command": "npm --prefix web run test:grease-pencil-editor",
"exitCode": 0,
"durationMs": 4097,
"outputSha256": "c4109eb01c634356551cf19e26a3a3859f612e7511adfbeb25f092e9591e77bc",
"output": "> blender-web-editor@0.1.0 test:grease-pencil-editor\n> playwright test --config playwright.config.ts -g \"N-016 Grease Pencil editor context\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:510:1 validates the N-016 Grease Pencil editor context transaction boundary (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4194,
"outputSha256": "71cfe010c8140d96ece7bddc359805a6dbcb54213224e78d40db436b9aef6605",
"output": "> blender-web-editor@0.1.0-rc.1 test:grease-pencil-editor\n> playwright test --config playwright.config.ts -g \"N-016 Grease Pencil editor context\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:510:1 validates the N-016 Grease Pencil editor context transaction boundary (1.5s)\n\n 1 passed (3.3s)"
},
{
"token": "web:e2e:N-016 Grease Pencil",
@@ -256,9 +256,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-016 Grease Pencil\"",
"exitCode": 0,
"durationMs": 19150,
"outputSha256": "613880a4c3506a4671fa7283c37890d85b37b10d899af99a5202625130ccf463",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-016 Grease Pencil\n\n\nRunning 12 tests using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:437:1 enforces the N-016 Grease Pencil layer/frame/drawing/stroke/point schema budget (1.4s)\n ✓ 2 tests/e2e/smoke.spec.ts:453:1 renders a bounded previous/next N-016 Grease Pencil onion-skin preview (872ms)\n ✓ 3 tests/e2e/smoke.spec.ts:485:3 renders the N-016 Grease Pencil current-frame strokes in main-thread Chromium (1.3s)\n ✓ 4 tests/e2e/smoke.spec.ts:485:3 renders the N-016 Grease Pencil current-frame strokes in OffscreenCanvas Chromium (1.2s)\n ✓ 5 tests/e2e/smoke.spec.ts:510:1 validates the N-016 Grease Pencil editor context transaction boundary (917ms)\n ✓ 6 tests/e2e/smoke.spec.ts:526:1 raycasts and highlights N-016 Grease Pencil points with stable drawing identity (1.3s)\n ✓ 7 tests/e2e/smoke.spec.ts:541:40 previews N-016 Grease Pencil points and commits Main once in main-thread Chromium (1.8s)\n ✓ 8 tests/e2e/smoke.spec.ts:541:40 previews N-016 Grease Pencil points and commits Main once in OffscreenCanvas Chromium (1.5s)\n ✓ 9 tests/e2e/smoke.spec.ts:565:1 edits N-016 Grease Pencil layers and frames from the bounded editor panel (1.6s)\n ✓ 10 tests/e2e/smoke.spec.ts:580:1 navigates real N-016 Grease Pencil drawing frames in the bounded Dope Sheet (1.6s)\n ✓ 11 tests/e2e/smoke.spec.ts:595:1 moves an N-016 Grease Pencil point through one revision-bound Main transaction (1.5s)\n ✓ 12 tests/e2e/smoke.spec.ts:606:1 reopens an edited N-016 Grease Pencil drawing after WebEngine Worker restart (1.5s)\n\n 12 passed (18.3s)"
"durationMs": 21136,
"outputSha256": "feeb4c14236ca3766be2bcefb3bd7567074bc91940935c2731cdcd3e4a8b8f03",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-016 Grease Pencil\n\n\nRunning 12 tests using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:437:1 enforces the N-016 Grease Pencil layer/frame/drawing/stroke/point schema budget (1.5s)\n ✓ 2 tests/e2e/smoke.spec.ts:453:1 renders a bounded previous/next N-016 Grease Pencil onion-skin preview (914ms)\n ✓ 3 tests/e2e/smoke.spec.ts:485:3 renders the N-016 Grease Pencil current-frame strokes in main-thread Chromium (1.4s)\n ✓ 4 tests/e2e/smoke.spec.ts:485:3 renders the N-016 Grease Pencil current-frame strokes in OffscreenCanvas Chromium (1.4s)\n ✓ 5 tests/e2e/smoke.spec.ts:510:1 validates the N-016 Grease Pencil editor context transaction boundary (974ms)\n ✓ 6 tests/e2e/smoke.spec.ts:526:1 raycasts and highlights N-016 Grease Pencil points with stable drawing identity (1.4s)\n ✓ 7 tests/e2e/smoke.spec.ts:541:40 previews N-016 Grease Pencil points and commits Main once in main-thread Chromium (2.0s)\n ✓ 8 tests/e2e/smoke.spec.ts:541:40 previews N-016 Grease Pencil points and commits Main once in OffscreenCanvas Chromium (1.7s)\n ✓ 9 tests/e2e/smoke.spec.ts:565:1 edits N-016 Grease Pencil layers and frames from the bounded editor panel (1.7s)\n ✓ 10 tests/e2e/smoke.spec.ts:580:1 navigates real N-016 Grease Pencil drawing frames in the bounded Dope Sheet (1.8s)\n ✓ 11 tests/e2e/smoke.spec.ts:595:1 moves an N-016 Grease Pencil point through one revision-bound Main transaction (1.6s)\n ✓ 12 tests/e2e/smoke.spec.ts:606:1 reopens an edited N-016 Grease Pencil drawing after WebEngine Worker restart (1.5s)\n\n 12 passed (20.1s)"
},
{
"token": "web:test:paint-roundtrip",
@@ -267,9 +267,9 @@
],
"command": "npm --prefix web run test:paint-roundtrip",
"exitCode": 0,
"durationMs": 757,
"outputSha256": "80170948f7a18d6e0fa314c0a3ddc660abb67b1fc018f66a944d080516523fc0",
"output": "> blender-web-editor@0.1.0 test:paint-roundtrip\n> node ../tools/web/check-paint-roundtrip.mjs\n\npaint-roundtrip-ok vertex-color=passed vertex-weight-normalize=passed mirror-gate=passed undo-redo=passed save-reopen=passed"
"durationMs": 785,
"outputSha256": "d43258df5975ef194ba7c45c3758c8cac3e2ef89256510e4c1fedbc27f2b5b25",
"output": "> blender-web-editor@0.1.0-rc.1 test:paint-roundtrip\n> node ../tools/web/check-paint-roundtrip.mjs\n\npaint-roundtrip-ok vertex-color=passed vertex-weight-normalize=passed mirror-gate=passed undo-redo=passed save-reopen=passed"
},
{
"token": "web:e2e:N-017 paint",
@@ -278,9 +278,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-017 paint\"",
"exitCode": 0,
"durationMs": 4194,
"outputSha256": "b2b4157a480bfa0ddc1d3d5031baec6b784e7917b1b6781072791724d96fe655",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-017 paint\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:648:1 enforces the N-017 paint stroke, bounded brush and UDIM patch budgets (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4154,
"outputSha256": "204d84caabd73ba9be657483115ed1c2f681acf34ece9500ed5c0d5861c8fb7a",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-017 paint\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:648:1 enforces the N-017 paint stroke, bounded brush and UDIM patch budgets (1.4s)\n\n 1 passed (3.3s)"
},
{
"token": "web:e2e:N-018 physics",
@@ -289,9 +289,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-018 physics\"",
"exitCode": 0,
"durationMs": 4083,
"outputSha256": "67606d91993447632a709a163b3ca2a39634ffe343d523327f6a2084a5b253d1",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-018 physics\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:746:1 validates the N-018 physics capability and cache manifests without claiming solvers (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4129,
"outputSha256": "cfc04a1e75861cfd87fd3b58ed016a9d2a85c854866fcfc314e97fbfe591d2fa",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-018 physics\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:746:1 validates the N-018 physics capability and cache manifests without claiming solvers (1.4s)\n\n 1 passed (3.3s)"
},
{
"token": "web:test:simulation-cache",
@@ -300,9 +300,9 @@
],
"command": "npm --prefix web run test:simulation-cache",
"exitCode": 0,
"durationMs": 4157,
"outputSha256": "995c1e31bb89e4124999b1e60d7cb0ac09cd5276f07e15f7affb796624a177c6",
"output": "> blender-web-editor@0.1.0 test:simulation-cache\n> playwright test --config playwright.config.ts -g \"Simulation caches\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1631:1 persists and revalidates content-addressed Simulation caches across Worker restarts (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4312,
"outputSha256": "62ebe648848c8feb9f768825b87175e9c6ba374ee93fa43e9e089525e2d62a87",
"output": "> blender-web-editor@0.1.0-rc.1 test:simulation-cache\n> playwright test --config playwright.config.ts -g \"Simulation caches\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1631:1 persists and revalidates content-addressed Simulation caches across Worker restarts (1.6s)\n\n 1 passed (3.4s)"
},
{
"token": "web:test:physics-main-reader",
@@ -311,9 +311,9 @@
],
"command": "npm --prefix web run test:physics-main-reader",
"exitCode": 0,
"durationMs": 733,
"outputSha256": "40b88699ab714c717a59461345880235d337e833b614056b13d43594f797fa03",
"output": "> blender-web-editor@0.1.0 test:physics-main-reader\n> node ../tools/web/check-physics-main-reader.mjs\n\nphysics-main-reader-ok families=CLOTH,SOFT_BODY dependencies=COLLISION settings-sha256=passed save-reopen=passed playback=BLOCKED solver=BLOCKED"
"durationMs": 771,
"outputSha256": "db1e7ed03da623f091a436357d0fdc025fa358e13040aa98c8658b7b307c080c",
"output": "> blender-web-editor@0.1.0-rc.1 test:physics-main-reader\n> node ../tools/web/check-physics-main-reader.mjs\n\nphysics-main-reader-ok families=CLOTH,SOFT_BODY dependencies=COLLISION settings-sha256=passed save-reopen=passed playback=BLOCKED solver=BLOCKED"
},
{
"token": "web:test:lighting-roundtrip",
@@ -322,9 +322,9 @@
],
"command": "npm --prefix web run test:lighting-roundtrip",
"exitCode": 0,
"durationMs": 717,
"outputSha256": "14be8ff1c568f56cdbdb4fe9b0cd091f2014e024a049d1b11aa6a800d5d76e02",
"output": "> blender-web-editor@0.1.0 test:lighting-roundtrip\n> node ../tools/web/check-lighting-roundtrip.mjs\n\nlighting-roundtrip-ok camera-writer-dof=passed light-shadow-exposure=passed world-mist=passed color-management-reader=passed white-balance-gate=passed undo-redo=passed save-reopen=passed"
"durationMs": 775,
"outputSha256": "c9ddac34f9376983f0c67309583d0cb4a946d94f87421ffe1c9d6c3b39dbaba0",
"output": "> blender-web-editor@0.1.0-rc.1 test:lighting-roundtrip\n> node ../tools/web/check-lighting-roundtrip.mjs\n\nlighting-roundtrip-ok camera-writer-dof=passed light-shadow-exposure=passed world-mist=passed color-management-reader=passed white-balance-gate=passed undo-redo=passed save-reopen=passed"
},
{
"token": "web:e2e:N-019 Scene exposure",
@@ -333,9 +333,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-019 Scene exposure\"",
"exitCode": 0,
"durationMs": 4550,
"outputSha256": "55f18e2d99876fafdb02d050c9a712cca9e3d618b90d018160d9387244279420",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-019 Scene exposure\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:772:1 maps N-019 Scene exposure and light shadow metadata without using legacy World exposure (2.0s)\n\n 1 passed (3.7s)"
"durationMs": 4815,
"outputSha256": "282a7bd253cb7748b37e69df903c4d6c0b3420222a0228b1aad4f2af863ae9da",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-019 Scene exposure\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:772:1 maps N-019 Scene exposure and light shadow metadata without using legacy World exposure (2.1s)\n\n 1 passed (3.9s)"
},
{
"token": "web:test:compositor-main-reader",
@@ -344,9 +344,9 @@
],
"command": "npm --prefix web run test:compositor-main-reader",
"exitCode": 0,
"durationMs": 692,
"outputSha256": "abcaddcb533bf00bfd0a0aa151e351e03615c3ed3a8b8e0b8d7a26ca7c0e25d6",
"output": "> blender-web-editor@0.1.0 test:compositor-main-reader\n> node ../tools/web/check-compositor-main-reader.mjs\n\ncompositor-main-reader-ok graph-structure=passed exposure-invert-parameters=passed socket-links=passed unsupported-preserved=passed"
"durationMs": 772,
"outputSha256": "8a4e76fcac535266cf3d4ac3166a2465334ed066c30a6bad5821c10f488e1e3b",
"output": "> blender-web-editor@0.1.0-rc.1 test:compositor-main-reader\n> node ../tools/web/check-compositor-main-reader.mjs\n\ncompositor-main-reader-ok graph-structure=passed exposure-invert-parameters=passed socket-links=passed unsupported-preserved=passed"
},
{
"token": "web:e2e:N-020 CPU compositor",
@@ -355,9 +355,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-020 CPU compositor\"",
"exitCode": 0,
"durationMs": 4177,
"outputSha256": "af1cc149bed90ef32bc0c85c705ccecdd96208299fafd90a73b6fa8896f102ef",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-020 CPU compositor\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:834:1 executes the bounded N-020 CPU compositor and preserves unsupported nodes as gates (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4109,
"outputSha256": "dc95f8f62abd9d6662f2a919d6aa82ce8154e4f014d2fd2290db78aec5987381",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-020 CPU compositor\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:834:1 executes the bounded N-020 CPU compositor and preserves unsupported nodes as gates (1.4s)\n\n 1 passed (3.3s)"
},
{
"token": "web:test:sequencer-main-reader",
@@ -366,9 +366,9 @@
],
"command": "npm --prefix web run test:sequencer-main-reader",
"exitCode": 0,
"durationMs": 680,
"outputSha256": "5b798b9549d5ab530f123b91fdd8d1347b49277c294725d77ba37421a4b1b739",
"output": "> blender-web-editor@0.1.0 test:sequencer-main-reader\n> node ../tools/web/check-sequencer-main-reader.mjs\n\nsequencer-main-reader-ok media-paths=passed still-image-range=passed fps=passed effect-dependencies=passed"
"durationMs": 730,
"outputSha256": "3cfbd29640e5da4df0505732f2d6f963663cb67414d5ee97eebe8a7ffbbbeea8",
"output": "> blender-web-editor@0.1.0-rc.1 test:sequencer-main-reader\n> node ../tools/web/check-sequencer-main-reader.mjs\n\nsequencer-main-reader-ok media-paths=passed still-image-range=passed fps=passed effect-dependencies=passed"
},
{
"token": "web:e2e:N-021 sequencer",
@@ -377,9 +377,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-021 sequencer\"",
"exitCode": 0,
"durationMs": 4076,
"outputSha256": "3aa8c2ae171d05a17169afe602dbbf3be7ee541bbe2f24c60e06b21754f779d4",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-021 sequencer\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:879:1 validates N-021 sequencer strips, deterministic edits, sandbox paths and codec gates (1.4s)\n\n 1 passed (3.2s)"
"durationMs": 4157,
"outputSha256": "4f9551827b9a72bc512d7ceba8ca569e4a8623ff767da93bc79c901451c94233",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-021 sequencer\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:879:1 validates N-021 sequencer strips, deterministic edits, sandbox paths and codec gates (1.4s)\n\n 1 passed (3.3s)"
},
{
"token": "web:test:mask-main-reader",
@@ -388,9 +388,9 @@
],
"command": "npm --prefix web run test:mask-main-reader",
"exitCode": 0,
"durationMs": 718,
"outputSha256": "ebf3ada68dda29abe2008343900b2ffad6d41e9750a3e9204c7b70674bbee692",
"output": "> blender-web-editor@0.1.0 test:mask-main-reader\n> node ../tools/web/check-mask-main-reader.mjs\n\nmask-main-reader-ok layers-splines=passed handles-feather=passed locked-editable-selection=passed"
"durationMs": 743,
"outputSha256": "5fab1e2b7cdae27caa97ebbaf2b9c0f72a84cb44897b856ecbfd4d5162712027",
"output": "> blender-web-editor@0.1.0-rc.1 test:mask-main-reader\n> node ../tools/web/check-mask-main-reader.mjs\n\nmask-main-reader-ok layers-splines=passed handles-feather=passed locked-editable-selection=passed"
},
{
"token": "web:e2e:N-022 tracking",
@@ -399,9 +399,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-022 tracking\"",
"exitCode": 0,
"durationMs": 4098,
"outputSha256": "1858048af0a5d9c8926a4aea37e8acf62e0ef6988f0c1e6dd4ce0c28ab4a8870",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-022 tracking\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:922:1 validates N-022 tracking markers, masks, resource bindings and solve gates (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4096,
"outputSha256": "67420ec6e4a28f27f2346a7e6db835d2ca8bd281cd789371458b3d17a5693e2d",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-022 tracking\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:922:1 validates N-022 tracking markers, masks, resource bindings and solve gates (1.4s)\n\n 1 passed (3.3s)"
},
{
"token": "web:test:library-main-reader",
@@ -410,9 +410,9 @@
],
"command": "npm --prefix web run test:library-main-reader",
"exitCode": 0,
"durationMs": 712,
"outputSha256": "fa46d8ff58c3f7d52cc6b4744e47db2c80827462557fdbbec3fe504b67c356cf",
"output": "> blender-web-editor@0.1.0 test:library-main-reader\n> node ../tools/web/check-library-main-reader.mjs\n\nreports | Read library: 'resources/image_resource_library.blend', '//resources/image_resource_library.blend', parent '<direct>'\n | \u001b[2;37msource/blender/blenkernel/intern/report.cc:51 BKE_report_log\u001b[0m\nreports | \u001b[1;33mWARNING\u001b[0m Unable to open 'resources/image_resource_library.blend': No such file or directory\n | \u001b[2;37msource/blender/blenkernel/intern/report.cc:54 BKE_report_log\u001b[0m\nreports | Cannot find lib 'resources/image_resource_library.blend'\n | \u001b[2;37msource/blender/blenkernel/intern/report.cc:51 BKE_report_log\u001b[0m\nlibrary-main-reader-ok relative-path=passed readonly=passed dependency-inventory=passed"
"durationMs": 753,
"outputSha256": "30d5e1d42dd164832e6dea514753d5e72251927249c5019e87dd79b6c8eaf392",
"output": "> blender-web-editor@0.1.0-rc.1 test:library-main-reader\n> node ../tools/web/check-library-main-reader.mjs\n\nreports | Read library: 'resources/image_resource_library.blend', '//resources/image_resource_library.blend', parent '<direct>'\n | \u001b[2;37msource/blender/blenkernel/intern/report.cc:51 BKE_report_log\u001b[0m\nreports | \u001b[1;33mWARNING\u001b[0m Unable to open 'resources/image_resource_library.blend': No such file or directory\n | \u001b[2;37msource/blender/blenkernel/intern/report.cc:54 BKE_report_log\u001b[0m\nreports | Cannot find lib 'resources/image_resource_library.blend'\n | \u001b[2;37msource/blender/blenkernel/intern/report.cc:51 BKE_report_log\u001b[0m\nlibrary-main-reader-ok relative-path=passed readonly=passed dependency-inventory=passed"
},
{
"token": "web:e2e:N-023 asset",
@@ -421,9 +421,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-023 asset\"",
"exitCode": 0,
"durationMs": 4172,
"outputSha256": "e8d063ee602df3b253284024afc2dfe18ea7c12de31f0fa902ceffa9036c5f7d",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-023 asset\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:969:1 validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4191,
"outputSha256": "eb7f5ba2385cdf3279dab8ede7a792d238a01f3155e8d0e08e69d4e5e606cf94",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-023 asset\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:969:1 validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.3s)"
},
{
"token": "web:test:editor-main-reader",
@@ -432,9 +432,9 @@
],
"command": "npm --prefix web run test:editor-main-reader",
"exitCode": 0,
"durationMs": 691,
"outputSha256": "814ab7c0fe0c737a9cbafe6f8fe59564dcb94f9244ac4af1e7b60b9d675e815b",
"output": "> blender-web-editor@0.1.0 test:editor-main-reader\n> node ../tools/web/check-editor-main-reader.mjs\n\neditor-main-reader-ok workspaces=9 areas=36 regions=167"
"durationMs": 750,
"outputSha256": "534c21e20c5abe806079a385cacc0136c73a12f95600a66d52deef019509b8ba",
"output": "> blender-web-editor@0.1.0-rc.1 test:editor-main-reader\n> node ../tools/web/check-editor-main-reader.mjs\n\neditor-main-reader-ok workspaces=9 areas=36 regions=167"
},
{
"token": "web:e2e:N-024 editor",
@@ -443,9 +443,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-024 editor\"",
"exitCode": 0,
"durationMs": 4084,
"outputSha256": "f36025cb33bfa95624cd2c0db5aaa948ef13c768a75cbcefbc67f41feaf3e48e",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-024 editor\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:997:1 validates N-024 editor context, selection sync, layout budgets and workflow gates (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4193,
"outputSha256": "79f7ba8129518e6d2819cc2e4dde8348b108ad61b5eaeb93e751cb0568bd522a",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-024 editor\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:997:1 validates N-024 editor context, selection sync, layout budgets and workflow gates (1.5s)\n\n 1 passed (3.4s)"
},
{
"token": "web:test:script-main-reader",
@@ -454,9 +454,9 @@
],
"command": "npm --prefix web run test:script-main-reader",
"exitCode": 0,
"durationMs": 700,
"outputSha256": "5d06bf4178fb048dcfc30fa197812632c84e2c2266e328b325c941290c971581",
"output": "> blender-web-editor@0.1.0 test:script-main-reader\n> node ../tools/web/check-script-main-reader.mjs\n\nscript-main-reader-ok full-source=passed sha256=passed autorun-default-deny=passed"
"durationMs": 779,
"outputSha256": "e73ad4daab4992a2e86e5e91a81d5e830c7d4604122e021da8fac36f90b37316",
"output": "> blender-web-editor@0.1.0-rc.1 test:script-main-reader\n> node ../tools/web/check-script-main-reader.mjs\n\nscript-main-reader-ok full-source=passed sha256=passed autorun-default-deny=passed"
},
{
"token": "web:test:scripting-isolation",
@@ -465,9 +465,9 @@
],
"command": "npm --prefix web run test:scripting-isolation",
"exitCode": 0,
"durationMs": 863,
"outputSha256": "d124b300b4531fae73575afdec53baff2e0656207a1b9f96969f8761e277cd25",
"output": "> blender-web-editor@0.1.0 test:scripting-isolation\n> node ../tools/web/check-scripting-isolation.mjs\n\nscripting-isolation-ok status=BLOCKED audit=DENY approved-key=sandbox-unavailable server=unavailable execution=disabled"
"durationMs": 894,
"outputSha256": "6469327e0bb1c241dc7b0c0d184ca45cbe258f56638a245663c215eaa2ab91bf",
"output": "> blender-web-editor@0.1.0-rc.1 test:scripting-isolation\n> node ../tools/web/check-scripting-isolation.mjs\n\nscripting-isolation-ok status=BLOCKED audit=DENY approved-key=sandbox-unavailable server=unavailable execution=disabled"
},
{
"token": "web:e2e:N-025 script",
@@ -476,9 +476,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-025 script\"",
"exitCode": 0,
"durationMs": 4072,
"outputSha256": "1d4a3ba828574a63106cceb1b809be66bb60d167a7c9f870c297683021954a5d",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-025 script\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1017:1 validates N-025 script policy, signatures, budgets and platform gates (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4125,
"outputSha256": "dc5463f44e788eed8372e05f0c9cfcaff9ffa31b0badb3f881c768f2dd6c6805",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-025 script\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1017:1 validates N-025 script policy, signatures, budgets and platform gates (1.4s)\n\n 1 passed (3.3s)"
},
{
"token": "web:e2e:N-026 release",
@@ -487,9 +487,9 @@
],
"command": "npm --prefix web run test:e2e -- --workers=1 -g \"N-026 release\"",
"exitCode": 0,
"durationMs": 4106,
"outputSha256": "eeffeacdf49d91e2fbaf754723d147796dedbd25721d46a3931684a9ac32fe91",
"output": "> blender-web-editor@0.1.0 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-026 release\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1040:1 keeps N-026 release manifest deterministic and blocks missing evidence (1.5s)\n\n 1 passed (3.3s)"
"durationMs": 4146,
"outputSha256": "b9262c32aa2e913165d94cce4611a9422bcb3d66f5626317a7403c7972963e2a",
"output": "> blender-web-editor@0.1.0-rc.1 test:e2e\n> playwright test --config playwright.config.ts --workers=1 -g N-026 release\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:1040:1 keeps N-026 release manifest deterministic and blocks missing evidence (1.4s)\n\n 1 passed (3.3s)"
},
{
"token": "web:test:v1-user-loop",
@@ -498,9 +498,9 @@
],
"command": "npm --prefix web run test:v1-user-loop",
"exitCode": 0,
"durationMs": 5032,
"outputSha256": "81703418e4a01edfa77fa5ca7251a37d49fe9732e0e98d8a90182162014e3a94",
"output": "> blender-web-editor@0.1.0 test:v1-user-loop\n> playwright test --config playwright.config.ts tests/e2e/v1-user-loop.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/v1-user-loop.spec.ts:6:1 completes the V1 import-edit-save-restart-reopen-GLB loop (2.7s)\n\n 1 passed (4.2s)"
"durationMs": 5555,
"outputSha256": "82cbcac6791c98fa4f0effa80ab5de4f170cfc65b0883c06bf447ea45008146a",
"output": "> blender-web-editor@0.1.0-rc.1 test:v1-user-loop\n> playwright test --config playwright.config.ts tests/e2e/v1-user-loop.spec.ts\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/v1-user-loop.spec.ts:6:1 completes the V1 import-edit-save-restart-reopen-GLB loop (3.0s)\n\n 1 passed (4.7s)"
},
{
"token": "web:test:release-evidence",
@@ -509,9 +509,9 @@
],
"command": "npm --prefix web run test:release-evidence",
"exitCode": 0,
"durationMs": 842,
"outputSha256": "12b1eec0e7653d11ae97fdfbecafeb0a71cab66ea5f343d9992826ce4a4fa9d0",
"output": "> blender-web-editor@0.1.0 test:release-evidence\n> node ../tools/web/check-release-evidence.mjs\n\nrelease-evidence-check-ok records=17 missing=0 status=READY"
"durationMs": 1184,
"outputSha256": "fd4d31581227c482d4f7e165cbf83a313a667a6551a069c982c086d1b90fbaec",
"output": "> blender-web-editor@0.1.0-rc.1 test:release-evidence\n> node ../tools/web/check-release-evidence.mjs\n\nrelease-evidence-check-ok records=17 missing=0 status=READY"
},
{
"token": "web:test:browser",
@@ -520,9 +520,9 @@
],
"command": "npm --prefix web run test:browser",
"exitCode": 0,
"durationMs": 6933,
"outputSha256": "beec8f6ae62d79680a1b3d5a13cc0ed75c13ed2620ce0da2c1b8db4a77a5ad13",
"output": "> blender-web-editor@0.1.0 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] tests/e2e/cross-browser.spec.ts:6:1 boots the offline engine, renders SceneIR and performs a Main edit (2.6s)\n ✓ 2 [chromium] tests/e2e/cross-browser.spec.ts:32:1 keeps content-addressed asset recovery available in Chromium (973ms)\n ✓ 3 [chromium] tests/e2e/cross-browser.spec.ts:51:1 keeps the committed project after quota failure and Worker restart in Chromium (994ms)\n\n 3 passed (6.1s)"
"durationMs": 9223,
"outputSha256": "c31e3eb770f85aff43c5da17b2cb819b5a4d4bb69dfc85a9cb2319a24f7bfc12",
"output": "> blender-web-editor@0.1.0-rc.1 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] tests/e2e/cross-browser.spec.ts:6:1 boots the offline engine, renders SceneIR and performs a Main edit (3.4s)\n ✓ 2 [chromium] tests/e2e/cross-browser.spec.ts:32:1 keeps content-addressed asset recovery available in Chromium (1.6s)\n ✓ 3 [chromium] tests/e2e/cross-browser.spec.ts:51:1 keeps the committed project after quota failure and Worker restart in Chromium (1.3s)\n\n 3 passed (8.1s)"
},
{
"token": "web:test:release-package",
@@ -531,9 +531,9 @@
],
"command": "npm --prefix web run test:release-package",
"exitCode": 0,
"durationMs": 5691,
"outputSha256": "e44e4a3d2b6698b700be7ed6deda864ecba337e467d341cf7dd6b43aaee8f846",
"output": "> blender-web-editor@0.1.0 test:release-package\n> npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 54 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.46 kB │ gzip: 0.30 kB\ndist/assets/storage.worker-COUVNftM.js 36.81 kB\ndist/assets/web-engine.worker-CWkf48pG.js 245.76 kB\ndist/assets/viewport-render.worker-DZmZ4IRD.js 601.32 kB\ndist/assets/web_engine-CvVHxFlQ.wasm 15,048.04 kB │ gzip: 3,562.32 kB\ndist/assets/index-QI0bg0OR.css 11.41 kB │ gzip: 3.18 kB\ndist/assets/index-Cv6ZsBTp.js 973.17 kB │ gzip: 263.15 kB\n\n✓ built in 584ms\n\n> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=aa50a5b98ebb51c98c6eced2ec32dff96b8df6d90e10cc101ce9e8eb44695060\nrelease-package-ok files=10 bytes=32096983\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit."
"durationMs": 7028,
"outputSha256": "d087f0a9f20a7027eccb94c7a9cc6b0b67924377510582a91755629debc8de0e",
"output": "> blender-web-editor@0.1.0-rc.1 test:release-package\n> npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs\n\n\n> blender-web-editor@0.1.0-rc.1 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 54 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.46 kB │ gzip: 0.30 kB\ndist/assets/storage.worker-COUVNftM.js 36.81 kB\ndist/assets/web-engine.worker-CWkf48pG.js 245.76 kB\ndist/assets/viewport-render.worker-DZmZ4IRD.js 601.32 kB\ndist/assets/web_engine-CvVHxFlQ.wasm 15,048.04 kB │ gzip: 3,562.32 kB\ndist/assets/index-QI0bg0OR.css 11.41 kB │ gzip: 3.18 kB\ndist/assets/index-YnUA2xia.js 978.22 kB │ gzip: 264.63 kB\n\n✓ built in 877ms\n\n> blender-web-editor@0.1.0-rc.1 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=7d0fb22790c44bada5f01e5ebc12f6050e1c0436a53f58dceb2e979c17bb593a\nrelease-package-ok files=14 bytes=32896131\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit."
},
{
"token": "web:release:offline",
@@ -542,9 +542,9 @@
],
"command": "npm --prefix web run release:offline",
"exitCode": 0,
"durationMs": 36973,
"outputSha256": "e7202bf7f4d4a7fb171ad29073d92f1846951e5785f91dea2f23ea65121603ae",
"output": "> blender-web-editor@0.1.0 release:offline\n> npm run build && node ../tools/web/check-offline-reproducibility.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 54 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.46 kB │ gzip: 0.30 kB\ndist/assets/storage.worker-COUVNftM.js 36.81 kB\ndist/assets/web-engine.worker-CWkf48pG.js 245.76 kB\ndist/assets/viewport-render.worker-DZmZ4IRD.js 601.32 kB\ndist/assets/web_engine-CvVHxFlQ.wasm 15,048.04 kB │ gzip: 3,562.32 kB\ndist/assets/index-QI0bg0OR.css 11.41 kB │ gzip: 3.18 kB\ndist/assets/index-Cv6ZsBTp.js 973.17 kB │ gzip: 263.15 kB\n\n✓ built in 584ms\nsbom-ok packages=150 sha256=aa50a5b98ebb51c98c6eced2ec32dff96b8df6d90e10cc101ce9e8eb44695060\noffline-release-ok binary=7585557 source=205827355 sha256=f2903a75026a0a28615aeb5f4834a3859af83022c82bc50e28437dcbed68e549\nsbom-ok packages=150 sha256=aa50a5b98ebb51c98c6eced2ec32dff96b8df6d90e10cc101ce9e8eb44695060\noffline-release-ok binary=7585557 source=205827355 sha256=f2903a75026a0a28615aeb5f4834a3859af83022c82bc50e28437dcbed68e549\noffline-reproducibility-ok binary=f2903a75026a0a28615aeb5f4834a3859af83022c82bc50e28437dcbed68e549 source=8324d6353954ca782d84cbfd26f18a2a03fada17128d4f32c13a29e9c8aa50cc\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit."
"durationMs": 45192,
"outputSha256": "0b59a36f3df2b4450493bfa7770e30b853cd97abdd761bef494e35ee738607e1",
"output": "> blender-web-editor@0.1.0-rc.1 release:offline\n> npm run build && node ../tools/web/check-offline-reproducibility.mjs\n\n\n> blender-web-editor@0.1.0-rc.1 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 54 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.46 kB │ gzip: 0.30 kB\ndist/assets/storage.worker-COUVNftM.js 36.81 kB\ndist/assets/web-engine.worker-CWkf48pG.js 245.76 kB\ndist/assets/viewport-render.worker-DZmZ4IRD.js 601.32 kB\ndist/assets/web_engine-CvVHxFlQ.wasm 15,048.04 kB │ gzip: 3,562.32 kB\ndist/assets/index-QI0bg0OR.css 11.41 kB │ gzip: 3.18 kB\ndist/assets/index-YnUA2xia.js 978.22 kB │ gzip: 264.63 kB\n\n✓ built in 880ms\nsbom-ok packages=150 sha256=7d0fb22790c44bada5f01e5ebc12f6050e1c0436a53f58dceb2e979c17bb593a\noffline-release-ok binary=7841801 source=206996277 sha256=8af6c782b8e4b31020219597b821160b7634439749c455fd31d9d919676e2e2e\nsbom-ok packages=150 sha256=7d0fb22790c44bada5f01e5ebc12f6050e1c0436a53f58dceb2e979c17bb593a\noffline-release-ok binary=7841801 source=206996277 sha256=8af6c782b8e4b31020219597b821160b7634439749c455fd31d9d919676e2e2e\noffline-reproducibility-ok binary=8af6c782b8e4b31020219597b821160b7634439749c455fd31d9d919676e2e2e source=ee09b3e43e715b3d5497573bbf63a449cbea146150d56c08c6506afd5fce2871\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit."
},
{
"token": "web:test:release-performance",
@@ -553,9 +553,9 @@
],
"command": "npm --prefix web run test:release-performance",
"exitCode": 0,
"durationMs": 82653,
"outputSha256": "ac3c6ae6138f9434cf9c627d626694f08c24715b903cbfae3f1b5ce58f4faf3e",
"output": "> blender-web-editor@0.1.0 test:release-performance\n> node ../tools/web/check-release-performance.mjs\n\nrelease-performance-ok [{\"target\":100000,\"ratio\":0.9,\"inputTriangles\":100000,\"outputTriangles\":89999,\"elapsedMs\":81667,\"peakHeapBytes\":67108864,\"releasedPointerCount\":10,\"recoveryAllocation\":true},{\"target\":1000000,\"ratio\":1,\"inputTriangles\":1000000,\"outputTriangles\":1000000,\"elapsedMs\":507,\"peakHeapBytes\":346554368,\"releasedPointerCount\":10,\"recoveryAllocation\":true}]\n\nHeap resize call from 67108864 to 80543744 took 0.1827109999867389 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.1000789999961853 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.05835200000728946 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 0.03199099999619648 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.2570010000054026 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.3920889999862993 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.357361000002129 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.3448809999972582 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.3298830000130692 msecs. Success: true"
"durationMs": 89284,
"outputSha256": "e76cf7de997e507dbab0242f1cde2e6b291a9f99d63ef2ca2450332da16d9ea3",
"output": "> blender-web-editor@0.1.0-rc.1 test:release-performance\n> node ../tools/web/check-release-performance.mjs\n\nrelease-performance-ok [{\"target\":100000,\"ratio\":0.9,\"inputTriangles\":100000,\"outputTriangles\":89999,\"elapsedMs\":88241,\"peakHeapBytes\":67108864,\"releasedPointerCount\":10,\"recoveryAllocation\":true},{\"target\":1000000,\"ratio\":1,\"inputTriangles\":1000000,\"outputTriangles\":1000000,\"elapsedMs\":535,\"peakHeapBytes\":346554368,\"releasedPointerCount\":10,\"recoveryAllocation\":true}]\n\nHeap resize call from 67108864 to 80543744 took 0.2181620000046678 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.09495199999946635 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.04383199999574572 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 0.05545200000051409 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.4360670000023674 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4820880000042962 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.6095040000072913 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.3340389999939362 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.456514999998035 msecs. Success: true"
},
{
"token": "web:test:malicious-blends",
@@ -564,9 +564,9 @@
],
"command": "npm --prefix web run test:malicious-blends",
"exitCode": 0,
"durationMs": 489,
"outputSha256": "cbac9faec5d96aa047fb40c0e21a3d06cb3eb741d943ddd55ab4188a9cc328c0",
"output": "> blender-web-editor@0.1.0 test:malicious-blends\n> node ../tools/web/check-malicious-blends.mjs\n\nmalicious-blends-ok rejected=5"
"durationMs": 507,
"outputSha256": "acd3b170723a0b560590b327a3eea3a119df2c246e46c6d849a5ccb3e8766d74",
"output": "> blender-web-editor@0.1.0-rc.1 test:malicious-blends\n> node ../tools/web/check-malicious-blends.mjs\n\nmalicious-blends-ok rejected=5"
}
]
}

47
docs/web/CI.md Normal file
View File

@@ -0,0 +1,47 @@
# M6 CI contract
The repository remote is Gitea, so the authoritative workflow is
`.gitea/workflows/m6-ci.yml`. `main` is the protected release ref. Pull requests and ordinary
pushes run quick and Chromium lanes; the release lane runs only for a `main` push or an explicit
`workflow_dispatch`.
## Pinned runner matrix
| Component | Pinned value | Lane |
| --- | --- | --- |
| Runner | self-hosted Debian 13 x86_64, labels `blender-web` / `blender-web-release` | all |
| Node.js | 20.19.2 | all |
| npm | lockfile-compatible npm 9 | all |
| Playwright | 1.62.1 | Chromium, release |
| Chromium | Playwright Chromium 149.0.7827.55 or `CHROME_PATH` override | Chromium, release |
| Blender | official Blender 5.2.0 LTS with USD enabled, configured by `BLENDER_BIN` | release |
| Emscripten | 3.1.69 | release |
`BLENDER_ARCHIVE_SHA256` must identify the official Blender archive used by acceptance. VDB jobs
use `VDB_RESOURCE_ROOT`; when it is absent, tools resolve `resource-library/blender-web-vdb`
under the runner home directory.
## Lane contents
`npm --prefix web run ci:quick` installs from `package-lock.json`, then runs typecheck, lint, Node
tests, status consistency and release-evidence schema checks.
`npm --prefix web run ci:chromium` runs the P0 user loop (including main-thread and Offscreen
viewports), release browser smoke, network interruption, device loss, OOM, real OPFS quota,
malicious blend and archive gates. Every browser command receives a newly allocated loopback port;
Playwright owns and cleans its server process.
`npm --prefix web run ci:release` runs full E2E, the complete performance and VDB matrices, V1
acceptance, reproducible offline packaging, archive cold boot, and binary/source independent
verification.
Each lane writes `release/ci-reports/<lane>.json`, a sidecar SHA-256 and one immutable log per
command. Reports use `docs/status/ci-lane-report.schema.json` and bind the commit, lockfile, parity
ledger, engine manifest, SBOM, available archives and every command log. A failed subcommand writes
an atomic `FAILED` report before the lane exits nonzero; it cannot reuse an older READY report.
The npm cache key is derived from `web/package-lock.json`. The Emscripten cache additionally binds
version 3.1.69 and `tools/web/emscripten-env.sh`. Release evidence, reports and archives are never
cache inputs. Artifact retention is fixed at 7 days for quick, 14 days for Chromium and 30 days for
release; the report checker runs before upload.

View File

@@ -13,6 +13,173 @@ Production uses HTTPS. Loopback development may use `http://127.0.0.1`; `file://
Runtime assets are same-origin. A reverse proxy must not strip the isolation, range, cache, MIME or
ETag headers.
## Empty-directory install runbook
The release delivery contains `blender-web-offline.tar.gz`,
`blender-web-corresponding-source.tar.gz` and `SHA256SUMS.txt`. Install as an unprivileged deployment
user on a Linux host with GNU `tar`, `sha256sum`, `awk`, `mktemp`, `ln` and `mv`. Start with an empty
install root; do not extract directly over a running release.
Set explicit paths. `BLENDER_WEB_INSTALL_ROOT` must be a new or dedicated directory, never `/`, a
home directory or a shared workspace.
```bash
BLENDER_WEB_DELIVERY=/absolute/path/to/delivery
BLENDER_WEB_INSTALL_ROOT=/srv/blender-web
BLENDER_WEB_ARCHIVE="$BLENDER_WEB_DELIVERY/blender-web-offline.tar.gz"
BLENDER_WEB_SUMS="$BLENDER_WEB_DELIVERY/SHA256SUMS.txt"
test -f "$BLENDER_WEB_ARCHIVE"
test -f "$BLENDER_WEB_SUMS"
mkdir -p "$BLENDER_WEB_INSTALL_ROOT/releases"
BLENDER_WEB_EXPECTED_SHA256="$(awk '$2 == "blender-web-offline.tar.gz" { print $1 }' "$BLENDER_WEB_SUMS")"
BLENDER_WEB_ACTUAL_SHA256="$(sha256sum "$BLENDER_WEB_ARCHIVE" | awk '{ print $1 }')"
test -n "$BLENDER_WEB_EXPECTED_SHA256"
test "$BLENDER_WEB_ACTUAL_SHA256" = "$BLENDER_WEB_EXPECTED_SHA256"
tar -tzf "$BLENDER_WEB_ARCHIVE"
BLENDER_WEB_STAGE="$(mktemp -d "$BLENDER_WEB_INSTALL_ROOT/.install.XXXXXX")"
tar --no-same-owner --no-same-permissions -xzf "$BLENDER_WEB_ARCHIVE" -C "$BLENDER_WEB_STAGE"
test -f "$BLENDER_WEB_STAGE/blender-web-offline/app/index.html"
test -f "$BLENDER_WEB_STAGE/blender-web-offline/deployment-contract.json"
BLENDER_WEB_RELEASE_DIR="$BLENDER_WEB_INSTALL_ROOT/releases/$BLENDER_WEB_ACTUAL_SHA256"
test ! -e "$BLENDER_WEB_RELEASE_DIR"
mv "$BLENDER_WEB_STAGE/blender-web-offline" "$BLENDER_WEB_RELEASE_DIR"
rmdir "$BLENDER_WEB_STAGE"
ln -s "releases/$BLENDER_WEB_ACTUAL_SHA256" "$BLENDER_WEB_INSTALL_ROOT/.current.next"
mv -Tf "$BLENDER_WEB_INSTALL_ROOT/.current.next" "$BLENDER_WEB_INSTALL_ROOT/current"
test -f "$BLENDER_WEB_INSTALL_ROOT/current/app/index.html"
```
Before extraction, operators must inspect the `tar -tzf` output and reject absolute paths, `..`
components, links and entries outside `blender-web-offline/`. The automated M6 gate performs these
checks before it extracts anything. The content SHA-256 is the immutable release directory name;
`current` is the only mutable pointer. The server document root is
`$BLENDER_WEB_INSTALL_ROOT/current/app`, while `deployment-contract.json` remains beside `app/`.
## Transport preflight
Choose and record one origin before starting the server:
- Production: `https://<public-host>[:port]`. TLS terminates at the compatible server or reverse
proxy. Plain HTTP on a public DNS name, LAN address, `0.0.0.0` or container hostname is rejected.
- Local verification only: `http://127.0.0.1:<port>`. Do not publish the loopback-only server by
changing its bind address.
- `file://`, origins with embedded credentials and deployments below a URL path prefix are
unsupported. The application and every runtime asset must share one origin.
An HTTPS reverse proxy must forward requests to the document root without rewriting asset paths and
must preserve every header in `responseHeaders.allResponses`. A loopback check is not evidence that a
public plain-HTTP deployment is supported.
After the server starts, replace `BLENDER_WEB_ORIGIN` with the recorded HTTPS or loopback origin and
run:
```bash
curl --fail --silent --show-error --head "$BLENDER_WEB_ORIGIN/"
curl --fail --silent --show-error --head "$BLENDER_WEB_ORIGIN/engine-manifest.json"
curl --fail --silent --show-error \
--header 'Range: bytes=0-15' \
"$BLENDER_WEB_ORIGIN/vendor/blender/single/web_engine.wasm" >/dev/null
```
The first two responses must contain the three isolation headers, the contract cache policy, a
strong ETag and the exact MIME type. The range request must return `206` with `Accept-Ranges`,
`Content-Range`, `Content-Length` and the same strong ETag validator. Open the recorded origin in a
new Chromium profile and require `window.isSecureContext === true`,
`window.crossOriginIsolated === true`, the manifest to be verified and the selected engine to be
ready before directing users to the release.
## Upgrade runbook
Keep the public origin unchanged. IndexedDB and OPFS are origin-bound; changing scheme, host or port
creates a different storage boundary and is not an upgrade. Do not clear site data, browser profiles,
OPFS, IndexedDB or the old release directory during an upgrade.
1. Announce a maintenance window. Require active edits and saves to finish, then have users close or
reload existing tabs. A document and all of its content-hashed Workers must come from one release.
2. Install the new archive into a new content SHA-256 release directory by repeating the validation
and staging steps above, but do not change `current` yet.
3. Compare `current/release-metadata.json` with the new `release-metadata.json`. Record the old and new
product versions, engine release IDs, IndexedDB schema versions, OPFS project manifest versions,
entry assets and Worker assets. Reject a lower storage schema during upgrade. An OPFS schema change
requires an explicitly documented migration and recovery test; V1 currently declares schema `1`.
4. Start the new directory on a separate loopback-only validation port. Run the header, MIME, range,
manifest hash and browser cold-start gates before changing public traffic.
5. Export or download a backup of each critical project before the first forward-only storage
migration. The current IndexedDB schema is `6`; `onupgradeneeded` applies migrations atomically when
the new StorageWorker starts. A failed transaction must leave the old database version intact.
6. Stop admitting new sessions, then create `.current.next` pointing to the complete new release and
atomically rename it over `current`. Never switch `app/`, `engine-manifest.json`, stable engine files
or Worker files separately.
7. Revalidate `/`, `/engine-manifest.json`, one engine range, `window.isSecureContext`,
`window.crossOriginIsolated`, the selected engine and Storage schema. Reopen a saved OPFS project,
compare its revision and blend SHA-256, perform one edit/save, and only then end maintenance.
HTML, `engine-manifest.json` and stable engine URLs remain `no-cache`; a release switch therefore
revalidates them. Content-hashed entry and Worker URLs remain immutable. A manifest `releaseId`
mismatch requires a full refresh and must not initialize an engine or open a project. If any preflight
or post-switch check fails, leave or restore `current` to the old complete release; do not delete the
new directory until diagnostics are captured, and do not delete the old directory until the rollback
window ends.
## Rollback runbook
Rollback changes only the `current` app/engine release pointer. It never deletes or rewrites browser
site data. Keep the public origin unchanged and retain both release directories plus the pre-upgrade
project exports throughout the rollback window.
1. Stop new sessions and require active saves to finish. Record the current project revision and blend
SHA-256, the active `current` target, and both releases' `release-metadata.json` files.
2. Compare the browser's already-opened IndexedDB schema and OPFS project manifest schema with the
target old release. A target with the same IndexedDB schema and the same OPFS schema is directly
readable. Re-run its loopback cold-start and project-reopen checks before switching.
3. If the browser database has migrated above the old release's IndexedDB schema, in-place rollback is
`BLOCKED`: opening the older database version would fail. Do not delete or recreate the database.
Deploy a forward-compatible repair release that retains the current storage schema, or restore the
pre-upgrade project export under a separate recovery origin/profile supported by the old release.
4. If the OPFS project manifest schema differs, in-place rollback is also `BLOCKED` unless that exact
reverse reader has its own tested migration. V1 does not claim reverse OPFS migrations.
5. For a compatible target, create `.current.next` pointing to the complete old content-hash directory
and atomically rename it over `current`. Never copy individual HTML, manifest, engine or Worker files.
6. Revalidate transport, cache, manifest and engine identity. Reopen the same project and require its
revision and blend SHA-256 to match the pre-rollback values before allowing one edit/save.
If post-switch validation fails, atomically restore `current` to the newer release. Capture diagnostics
before removing a failed target. Browser data, the newer release and project exports remain untouched.
## Failure diagnostics
The machine-readable source is `operations-diagnostics.json`. Capture the active release metadata,
origin, failing URL, response headers, stable error code and project revision before recovery.
| Domain | Primary signal | Confirm | Recovery and data boundary |
| --- | --- | --- | --- |
| MIME | `DEPLOYMENT_MIME_MISMATCH` or streaming/module refusal | HEAD the URL; compare `Content-Type` with the contract | Fix the MIME map and repeat HEAD. Do not clear project storage. |
| Range | `DEPLOYMENT_RANGE_INVALID`, `416`, failed resume | Request `bytes=0-15`; inspect `206`, range headers, ETag and If-Range | Fix byte ranges; discard only partial bytes whose ETag changed. Keep verified OPFS bytes. |
| Isolation | `PLATFORM_CAPABILITY_UNAVAILABLE`, no SharedArrayBuffer | Verify HTTPS or `127.0.0.1`, all three isolation headers and both browser flags | Restore headers/same-origin assets or use the gated single variant. Do not open through a blocked pthread path. |
| Hash | `ENGINE_VARIANT_INTEGRITY_FAILED` | Re-fetch no-cache manifest; hash JS/WASM/Worker and compare one `releaseId` | Restore one complete release and reload. No fallback or project open is allowed on mismatch. |
| Quota | `STORAGE_QUOTA` or `QuotaExceededError` | Record storage estimate and compare committed revision/SHA-256 | Export the project and remove only selected disposable caches. Never clear all site data. |
| Worker | `WORKER_TERMINATED` or Worker load failure | HEAD the hashed Worker; compare MIME/cache/path with release metadata | Reload one release, restart Worker and reopen the verified revision. Reject late results. |
| GPU | `GPU_DEVICE_LOST` or a GPU budget code | Record loss reason, limits/resident bytes and unchanged project revision | Release/recreate viewport resources or reduce budget. Never rewrite Main/OPFS for GPU recovery. |
Run `npm --prefix web run test:operations-diagnostics` to validate all seven entries against the
deployment contract and the packaged documentation.
## Fresh-directory rehearsal
Before freezing an RC, run `npm --prefix web run test:operations-rehearsal`. It creates one new
temporary root and executes delivery checksum/path validation, initial install, loopback HTTP
preflight, project seeding, complete-release upgrade, HTTP revalidation, compatible rollback and a
final HTTP/project-integrity check in that order. It never uses an existing install directory.
The machine report is `release/operations-reports/rehearsal.json`. Every record contains the runbook
command, exit code, duration and observed output. `READY` requires all records to pass, the same
project revision and SHA-256 before/after both switches, both release directories to remain present,
and cleanup of the temporary rehearsal root after the report is assembled.
## Cache policy
- `/` and `/index.html`: `Cache-Control: no-cache` so a deployment is revalidated.
@@ -21,6 +188,11 @@ ETag headers.
- `/vendor/blender/*`: `Cache-Control: no-cache`; these stable URLs are verified by the engine manifest.
- Other paths: `Cache-Control: no-cache` until a more specific content-addressed rule exists.
Responses carry a strong content SHA-256 ETag. A matching `If-None-Match` returns `304` with no body;
after HTML, manifest or a stable engine asset changes, the old validator returns the new complete response
with `200` and a different ETag. Immutable `/assets/*` names must include the Vite content hash and are
never reused for different bytes.
The server must use the MIME map in the JSON contract. In particular, WebAssembly is
`application/wasm`, JavaScript is `text/javascript; charset=utf-8`, and NanoVDB is
`application/x-nanovdb`.
@@ -32,5 +204,9 @@ The server must use the MIME map in the JSON contract. In particular, WebAssembl
unsatisfied range returns `416`. An `If-Range` mismatch ignores the range and returns the complete
resource with status `200`; partial bytes from a different revision must never be combined.
Run `node tools/web/check-deployment-contract.mjs` for the static contract gate. Production HTTP
behavior is a separate M6 acceptance gate and must exercise an actual server before release.
Run `node tools/web/check-deployment-contract.mjs` for the static contract gate and
`npm --prefix web run test:deployment-runbook` for the fresh-directory install and transport
preflight gate. Run `npm --prefix web run test:upgrade-runbook` for the two-release manifest, cache,
storage schema, Worker and atomic-switch gate, and `npm --prefix web run test:rollback-runbook` for
compatible and blocked-old-reader rollback paths. Production HTTP behavior is a separate M6
acceptance gate and must exercise an actual server before release.

View File

@@ -0,0 +1,68 @@
# Web Blender Modeler V1 Known Limitations
These limits apply to `0.1.0-rc.1`. A successful bounded gate does not imply complete Blender 5.2
parity or support beyond the declared operation, fixture, resource budget and browser configuration.
See [V1_SCOPE.md](V1_SCOPE.md) and [parity-ledger.json](parity-ledger.json) for the authoritative
capability boundary.
## Browser and deployment
- Chromium is the only V1 browser baseline. Firefox, WebKit, mobile browsers, touch, pen, IME and
non-Chromium accessibility compatibility are not release claims.
- Production requires HTTPS. `http://127.0.0.1` is allowed for local verification; public plain HTTP
and `file://` are unsupported. All app, Worker and engine assets must remain same-origin.
- The base viewport uses WebGL2. OffscreenCanvas is a second tested production path. WebGPU is used
only for the declared bounded NanoVDB path and is subject to adapter availability and limits.
## Single-thread and pthread selection
- `AUTO` selects pthread only when `crossOriginIsolated`, `SharedArrayBuffer` and Worker capability
gates are all ready. Otherwise it selects the single-thread variant and reports the pthread gate.
- A pthread initialization failure under `AUTO` is cleaned up before one single-thread attempt. A
resource request failure outside this path remains an error.
- A JS, WASM or pthread Worker SHA-256 mismatch is fatal
(`ENGINE_VARIANT_INTEGRITY_FAILED`): there is no integrity fallback and no project is opened.
- `PTHREAD_REQUIRED` blocks when its platform gate is not ready. `SINGLE_REQUIRED` never attempts
pthread. A mixed app/manifest `releaseId` returns `REFRESH_REQUIRED` before engine initialization or
project open; a full reload is required.
- Both variants declare 256 initial WebAssembly pages (16 MiB) and a 32,768-page ceiling (2 GiB).
This is a manifest ceiling, not a memory reservation or availability guarantee; Chromium, the OS or
the device can reject growth earlier.
## Project storage
- OPFS and IndexedDB are bound to the exact scheme, host and port. Moving between origins does not
migrate projects. Clearing site data or a browser profile deletes locally stored projects and caches.
- Chromium and the operating system control quota. V1 reserves no minimum capacity and cannot infer
that `navigator.storage.estimate()` free space will remain available through a save.
- Atomic save preserves the last committed revision on quota failure, but the new revision is not
durable until save succeeds. Export critical projects before upgrades and before deleting caches.
- IndexedDB schema migration is forward-only. OPFS project manifest schema has no general reverse
migration. An older app is blocked when it cannot read the current schema.
## CPU, WASM and GPU budgets
- The RC gates cover 100k, 1M and 10M geometry fixtures; they are not an unlimited scene-size claim.
Imports and operations can block earlier when their own vertex, element, transfer, time or memory
budgets are exceeded.
- A texture asset is limited by protocol to 64 MiB, 16,384 pixels per dimension and 256 requested
assets per scene. The actual WebGL `MAX_TEXTURE_SIZE`, address space and GPU memory may be lower.
The release gate exercised 4K and 8K textures, not every format or 16K residency combination.
- VDB input is bounded to 512 MiB, a NanoVDB bundle to 1 GiB, each chunk to 16 MiB and declared GPU
residency to at most 512 MiB. Actual `maxStorageBufferBindingSize` and `maxBufferSize` may be lower.
- NanoVDB pages are explicit and LRU-bounded. Automatic viewport page-fault feedback, depth-composed
production demand paging and an unbounded multi-grid material system are not included in V1.
- GPU device loss or budget failure releases and recreates viewport resources where supported; it does
not modify Blender Main or OPFS project bytes. A reduced scene or budget may still be required.
## Blender feature scope
Complete PBVH sculpt/paint, arbitrary Geometry Nodes and Shader evaluation, full physics solvers,
Cycles, final video encoding, Python/Text autorun, add-ons and native GPU backends are either bounded,
server-only or excluded. The exact family slices and stable blockers are in
[parity-ledger.json](parity-ledger.json); unsupported data must be preserved or explicitly blocked,
not reported as executed.
Use [DEPLOYMENT.md](DEPLOYMENT.md) for installation and rollback requirements, and
[operations-diagnostics.json](operations-diagnostics.json) for stable failure signals and data-safe
recovery boundaries.

60
docs/web/RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,60 @@
# Web Blender Modeler V1 0.1.0-rc.1 Release Notes
Release candidate identity: `web-blender-0.1.0-rc.1`, engine
`blender-wasm-0.1.0-rc.1`.
This candidate is a Chromium-only browser modeler built around a bounded Blender 5.2 Main/WebEngine
subset. It is not a browser port of the complete Blender desktop application. The normative product
boundary and capability definitions are in [V1_SCOPE.md](V1_SCOPE.md); the machine-readable family
status and exact required/blocked slices are in [parity-ledger.json](parity-ledger.json).
## Verified V1 capabilities
- Import `.blend`, edit the declared Object, Mesh and Principled material subset, undo, redo, save
atomically, restart the Storage and WebEngine Workers, reopen, and export a semantically checked GLB.
- Preserve stable object, mesh and material identities across save/reopen and verify both the
main-thread WebGL and OffscreenCanvas Worker production viewports with non-empty pixels.
- Select integrity-checked single-thread or pthread WebEngine artifacts from one versioned engine
manifest. The pthread variant is used only when the isolated platform prerequisites pass.
- Store projects, snapshots and bounded assets in origin-bound OPFS/IndexedDB, including tested
interruption, Worker restart, quota failure and recovery behavior.
- Exercise bounded 100k, 1M and 10M geometry, 4K/8K textures, 600-frame simulation cache and
one-million-frame media indexing gates.
- Exercise deterministic WASM, OPFS, GPU and NanoVDB out-of-memory recovery, WebGL device loss,
network interruption, malformed `.blend` input and malicious archive rejection.
- Deliver deterministic offline binary and corresponding-source archives with SHA-256 checksums,
SPDX SBOM, licenses, source offer, deployment contract and operations diagnostics.
- Reproduce quick, Chromium and release CI lanes with lockfile, source, engine, archive and per-command
log hash bindings.
## Capability boundaries
`LOCAL_EXACT` and `LOCAL_BOUNDED` mean only the fixtures, operations and budgets declared in
[V1_SCOPE.md](V1_SCOPE.md) and [parity-ledger.json](parity-ledger.json). Unsupported operations must
remain blocked or preserved; the release does not infer support from a readable Blender data block.
`SERVER` covers validated request/result boundaries for work such as Cycles rendering, complex physics
bakes and final video encoding. This offline candidate does not bundle or configure those services and
does not claim local-equivalent execution.
`EXCLUDED` includes arbitrary Python/Text autorun, add-on installation, native desktop windows and
native CUDA, Metal, HIP or OptiX backends. The UI must not report those operations as successful.
All 12 V1 family release slices are `READY`, while all 12 complete Blender 5.2 family parity states are
`BLOCKED`. The exact blocked and excluded slice names remain authoritative in
[parity-ledger.json](parity-ledger.json); this release note does not convert them to completed work.
## Delivery evidence
Verify `SHA256SUMS.txt`, then verify `RC_MANIFEST.json.sha256` and `RC_MANIFEST.json` before deployment.
The manifest binds the RC version, base commit, engine release ID, package lock, ledger, embedded release
metadata, SBOM, binary/source archives and operations reports.
Read [Known limitations](KNOWN_LIMITATIONS.md) before deployment, especially the browser, engine
variant, origin-bound storage, quota, WASM and GPU budget boundaries.
Use [Release recovery](RELEASE_RECOVERY.md) to verify delivery evidence, export project backups and
handle pre-switch failures, compatible rollback or blocked schema downgrade.
Deployment requirements and operator commands are in [DEPLOYMENT.md](DEPLOYMENT.md). Stable error
signals and recovery boundaries are in [operations-diagnostics.json](operations-diagnostics.json).

View File

@@ -0,0 +1,105 @@
# Web Blender Modeler V1 Release Recovery
Use this procedure for `0.1.0-rc.1` upgrades and failed deployments. The complete installation,
upgrade and rollback contract remains [DEPLOYMENT.md](DEPLOYMENT.md). Never clear Chromium site data,
delete a browser profile, overwrite a release directory or downgrade a storage schema as a recovery
shortcut.
## Verify the delivery
The delivery directory must contain `blender-web-offline.tar.gz`,
`blender-web-corresponding-source.tar.gz`, `SHA256SUMS.txt`, `RC_MANIFEST.json` and
`RC_MANIFEST.json.sha256`. Verify bytes before extracting:
```bash
cd /absolute/path/to/delivery
sha256sum --check SHA256SUMS.txt
sha256sum --check RC_MANIFEST.json.sha256
```
Both commands must report `OK`. Check that the manifest identity is
`web-blender-0.1.0-rc.1`, its commit and `engineReleaseId` match the release record, and its binary,
source and SBOM hashes match the delivered files. In a corresponding source checkout, independently
run:
```bash
npm --prefix web ci --ignore-scripts
npm --prefix web run test:rc-manifest
npm --prefix web run test:binary-archive
npm --prefix web run test:source-archive
node tools/web/check-ci-report.mjs release/ci-reports/quick.json \
release/ci-reports/chromium.json release/ci-reports/release.json
```
Do not deploy when any command fails or when a report is not `READY` and bound to the manifest commit,
lockfile, ledger, engine and archive hashes.
## Back up projects before upgrade
1. Record the exact public origin, including scheme, host and port. Upgrades must retain that origin
because IndexedDB and OPFS data are origin-bound.
2. For every critical project, finish active edits and use the top-bar **Save Project** action. Wait
for the committed/saved state and retain the downloaded `blender-web.blend` outside the browser
profile and outside the deployment install root.
3. Record the project's displayed SceneIR revision and calculate the external backup hash with
`sha256sum blender-web.blend`. Reopen the downloaded file with the current release and confirm the
expected objects before entering the maintenance window.
4. Record `readlink current`, the old binary archive SHA-256 and both old/new
`release-metadata.json` files. Keep the old content-hash release directory, new staged directory,
external project backups and browser profile through the rollback window.
A backup is not complete merely because OPFS still contains a project. The external `.blend`, revision,
SHA-256, origin and active release identity form the recovery record.
## Upgrade failure before the switch
If checksum, archive path, header, MIME, range, engine hash, Worker or cold-start preflight fails before
`current` changes, leave `current` on the old release. Remove only the incomplete staging directory
after diagnostics are captured. Do not change IndexedDB, OPFS, browser caches or the old release.
Repeat the delivery checks and the loopback preflight from [DEPLOYMENT.md](DEPLOYMENT.md). A failed
candidate remains uninstalled until its binary hash and all preflight evidence pass.
## Upgrade failure after the switch
Stop new sessions and require active saves to finish. Capture the active origin, `current` target,
both release metadata files, failing URL/headers, error code, project revision and project SHA-256.
Compare the browser's opened IndexedDB and OPFS schema versions with the old target:
- If the IndexedDB versions match and the OPFS project manifest versions match, validate the complete
old release on a loopback port. Atomically repoint `current` to that old content-hash directory using
the rollback commands in [DEPLOYMENT.md](DEPLOYMENT.md). Revalidate transport and engine identity,
reopen the same project, and require the recorded revision and blend SHA-256 before allowing an edit.
- If the old IndexedDB schema is lower, rollback is `BLOCKED` with
`INDEXEDDB_SCHEMA_DOWNGRADE_UNSUPPORTED`. Do not open, delete or recreate the database with the old app.
- If the OPFS schema differs without an exact tested reverse reader, rollback is `BLOCKED` with
`OPFS_REVERSE_MIGRATION_UNDECLARED`. V1 does not claim a general reverse OPFS migration.
For a blocked rollback, keep or restore the newer compatible app release. Deploy a forward-compatible
repair using the current storage schemas, or start the old release on a separate recovery origin/profile
and import an external pre-upgrade `.blend` backup. Never point the old app at newer incompatible site
data.
## Restore and prove project integrity
After a compatible rollback or repair:
1. Open the recorded origin in a new Chromium tab and confirm the expected release/engine identity.
2. Use **Recover Project** for unchanged compatible origin storage. If that fails or a separate recovery
origin is required, use **Open .blend** and select the external backup.
3. Confirm the recorded objects and revision, use **Save Project**, then hash the new downloaded
`blender-web.blend`. The hash must equal the recorded backup unless a deliberate post-recovery edit
was made.
4. Keep the failed release, logs, diagnostics and external backup until the recovery record contains the
final origin, release ID, revision and SHA-256.
The automated rehearsal command is:
```bash
npm --prefix web run test:operations-rehearsal
```
It verifies delivery, fresh install, HTTP preflight, project revision/SHA-256 preservation, upgrade,
compatible rollback and cleanup in one new temporary root. It does not replace real project exports or
authorize a schema downgrade.

View File

@@ -0,0 +1,61 @@
# Engine Manifest Schema v2
`M6-04A` 冻结双 variant 的数据合同和纯校验器。`M6-04B` 已将生产
`web/app/public/engine-manifest.json` 切换到 schema v2并从两个独立构建目录把资产安装到
`/vendor/blender/single/``/vendor/blender/pthread/`。纯策略选择和双环境实机加载已由
`M6-04C-G` 完成;旧 single Worker 入口继续作为 `M6-05` fallback 接线前的完整 Main 兼容路径。
## 顶层合同
- `schemaVersion` 固定为 `2``protocolVersion` 固定为 `1``engine` 固定为
`blender-wasm`
- `releaseId` 是整个 manifest 的原子发布身份single/pthread 及其 JS、WASM、worker 摘要
必须作为同一份 manifest 一起切换,调用方不得从另一 release 补资源。
- `variants` 必须恰好包含一个 `single` 和一个 `pthread`,不能有第三种或重复 variant。
- single 与 pthread 的 JS URL 必须不同WASM URL 也必须不同,避免通过通用路径隐含构建类型。
## 资源合同
每项资源都必须声明 `fileName`、同源根路径 `url` 和小写 64 位十六进制 SHA-256
| variant | 必需资源 | 禁止资源 |
| --- | --- | --- |
| `single` | `resources.js``resources.wasm` | `resources.pthreadWorker` |
| `pthread` | `resources.js``resources.wasm``resources.pthreadWorker` | 无 |
JS 与 pthread worker 文件名必须以 `.js` 结尾WASM 文件名必须以 `.wasm` 结尾URL 的最后一段
必须与 `fileName` 完全一致。资源 URL 不允许远程 origin、query、fragment、反斜线或路径穿越。
`pthreadWorker` 表示 worker 加载角色,不强制是第四个物理文件:当前 Emscripten pthread 输出会
让 worker 重新加载同一个 pthread ES module因此该角色可以与 `resources.js` 使用相同的
`fileName``url` 和 SHA-256同一 URL 不允许声明不同摘要。worker 不得引用 single 的 JS。
若后续工具链产生独立 worker 文件,则三个字段必须描述该文件。
## 内存合同
WASM page 固定为 65,536 bytes。每个 variant 都独立声明 `initialPages``maximumPages`
`shared`
- `initialPages` 范围为 256 至 32,768 pages即 16 MiB 至 2 GiB。
- `maximumPages` 不得小于 `initialPages`,且不得超过 32,768 pages2 GiB
- single 必须为 `shared=false`pthread 必须为 `shared=true`
权威 TypeScript 类型和无 I/O 校验器位于 `web/protocol/manifest.ts`;结构化正例位于
`tests/golden/M6-04A/engine-manifest-v2.json`。校验器只接收输入并返回深层新对象或抛出带稳定
`code`/`path``WebEngineManifestValidationError`,不读取文件、不请求网络,也不选择运行时
variant。
安装命令为 `bash tools/web/install-web-engine-assets.sh`。默认读取 `build_web-single`
`build_web-pthread`,先在临时目录生成并复核 manifest再安装四个物理文件。专项命令
`npm --prefix web run test:engine-variant-install` 同时覆盖正常复制、同一构建目录拒绝、缺失资产
拒绝和失败不改写目标 manifest。
纯选择器位于 `web/protocol/engine-variant.ts`,策略为 `AUTO``SINGLE_REQUIRED`
`PTHREAD_REQUIRED`。它不读取 DOM、网络、存储或 Worker调用方必须显式传入已校验 manifest
和三项线程能力。`PTHREAD_REQUIRED` 被阻断时不返回 variant。专项命令为
`npm --prefix web run test:engine-variant-selection``npm --prefix web run test:pthread-engine`
`npm --prefix web run test:single-thread-unisolated`
升级入口为 `bootstrapWebEngineRelease`。旧 HTML 绑定的 expected release 与重新验证后的 manifest
不一致时只返回 `REFRESH_REQUIRED`,不会选择或初始化 variant也不会打开待处理项目。资源
SHA-256 不一致统一返回 `ENGINE_VARIANT_INTEGRITY_FAILED`;该错误不可进入 pthread -> single
fallback。`npm --prefix web run test:engine-upgrade-safety` 同时覆盖纯策略和真实 Chromium 篡改响应。

View File

@@ -0,0 +1,69 @@
{
"schemaVersion": 1,
"product": "Web Blender Modeler V1",
"entries": [
{
"id": "OPS-MIME",
"domain": "mime",
"signals": ["DEPLOYMENT_MIME_MISMATCH", "WebAssembly.instantiateStreaming failed", "module script refused"],
"checks": ["HEAD the failing URL and record Content-Type", "compare the extension with deployment-contract.json mimeTypes"],
"expected": [".wasm application/wasm", ".js text/javascript; charset=utf-8", ".json application/json; charset=utf-8"],
"recovery": ["fix the server MIME map", "restart or reload proxy configuration", "repeat HEAD before refreshing Chromium"],
"dataSafety": "Do not clear OPFS or IndexedDB; MIME failures occur before project bytes need mutation."
},
{
"id": "OPS-RANGE",
"domain": "range",
"signals": ["DEPLOYMENT_RANGE_INVALID", "HTTP 416", "partial resource resume failed"],
"checks": ["GET the resource with Range: bytes=0-15", "record status, Accept-Ranges, Content-Range, Content-Length and ETag", "repeat with matching and mismatching If-Range"],
"expected": ["valid single range returns 206", "invalid or unsatisfied range returns 416", "If-Range mismatch returns full 200"],
"recovery": ["enable single byte ranges without compression rewriting", "preserve strong ETag validators", "discard partial bytes from a different ETag before retry"],
"dataSafety": "Never append bytes across different ETags; retain the last verified OPFS asset and project commit."
},
{
"id": "OPS-ISOLATION",
"domain": "isolation",
"signals": ["PLATFORM_CAPABILITY_UNAVAILABLE", "crossOriginIsolated false", "SharedArrayBuffer unavailable"],
"checks": ["confirm HTTPS or explicit http://127.0.0.1 loopback", "HEAD the document and error routes", "evaluate window.isSecureContext and window.crossOriginIsolated"],
"expected": ["Cross-Origin-Opener-Policy same-origin", "Cross-Origin-Embedder-Policy require-corp", "Cross-Origin-Resource-Policy same-origin"],
"recovery": ["restore the three headers on every response", "remove cross-origin runtime assets", "use the single variant only when the pthread capability gate is BLOCKED"],
"dataSafety": "Do not open a pending project through a blocked pthread path; correcting headers does not require storage deletion."
},
{
"id": "OPS-HASH",
"domain": "hash",
"signals": ["ENGINE_VARIANT_INTEGRITY_FAILED", "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", "manifest rejected"],
"checks": ["fetch engine-manifest.json with no-cache", "hash the exact JS, WASM and pthread Worker response bytes", "compare releaseId and every declared SHA-256"],
"expected": ["one releaseId binds both variants", "resource SHA-256 equals the manifest", "integrity failure performs no fallback and no project open"],
"recovery": ["restore one complete immutable release", "purge only the corrupt HTTP cache entry", "reload the document and revalidate the manifest"],
"dataSafety": "Keep the project unopened until all resource hashes pass; never repair integrity by editing project data."
},
{
"id": "OPS-QUOTA",
"domain": "quota",
"signals": ["STORAGE_QUOTA", "QuotaExceededError", "OPFS staging write failed"],
"checks": ["record navigator.storage.estimate usage and quota", "compare the committed project revision and SHA-256", "inspect staging and cache categories without deleting the committed blend"],
"expected": ["old committed revision remains readable", "failed staging is not promoted", "Worker restart reports the same committed SHA-256"],
"recovery": ["export the committed project", "remove explicitly selected disposable caches", "request persistent storage or free origin quota before retry"],
"dataSafety": "Never delete scene.blend, its verified manifest or all site data as a quota recovery shortcut."
},
{
"id": "OPS-WORKER",
"domain": "worker",
"signals": ["WORKER_TERMINATED", "Worker script load failed", "pending request aborted"],
"checks": ["HEAD the content-hashed Worker URL", "confirm JavaScript MIME and immutable cache", "compare Worker assets with release-metadata.json", "capture pending request and resource counters"],
"expected": ["all Workers belong to the current release", "terminated Worker publishes no late result", "restart rediscovers the committed project"],
"recovery": ["reload to one complete current release", "restart the affected Worker", "reopen the last verified project revision"],
"dataSafety": "Do not promote late Worker output or remove OPFS while recovering a Worker."
},
{
"id": "OPS-GPU",
"domain": "gpu",
"signals": ["GPU_DEVICE_LOST", "GPU_TEXTURE_BUDGET_EXCEEDED", "NANOVDB_GPU_BUDGET_EXCEEDED"],
"checks": ["record WebGL context loss or WebGPU device.lost reason", "record adapter limits and requested resident bytes", "confirm CPU, WASM and OPFS project revision did not change"],
"expected": ["GPU resources release once", "bounded recovery recreates the viewport", "persistent project SHA-256 remains stable"],
"recovery": ["reduce texture or NanoVDB resident budget", "recreate the viewport after device recovery", "reload the current release if bounded recovery fails"],
"dataSafety": "GPU recovery may discard viewport resources only; it must not rewrite Main or the committed project."
}
]
}

View File

@@ -3,7 +3,7 @@
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "blender-web-editor-sbom",
"documentNamespace": "https://blender-web.local/spdx/e62e3ed568907b9c7d85050e4f2b6bd874d425cca768a0201c3c57cdd9ad9926",
"documentNamespace": "https://blender-web.local/spdx/cf7beefe71131e5d51b45ffa79b7b906b21decc743fc7eafd7cf6791996dea37",
"creationInfo": {
"created": "1970-01-01T00:00:00Z",
"creators": [
@@ -3283,7 +3283,7 @@
{
"SPDXID": "SPDXRef-Package-blender-web-editor",
"name": "blender-web-editor",
"versionInfo": "0.1.0",
"versionInfo": "0.1.0-rc.1",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "NOASSERTION",
@@ -3292,7 +3292,7 @@
"checksums": [
{
"algorithm": "SHA256",
"checksumValue": "e7b16da68885fe43f32ab7b3eeb2c037b4fd8bf54fe6d5e77049aa51e294b1fb"
"checksumValue": "87af8e7d5eb36537541cf941699868a010c1fcea305daa3ce5385453e8fa4557"
}
]
},

View File

@@ -0,0 +1,54 @@
{
"schemaVersion": 2,
"protocolVersion": 1,
"releaseId": "blender-wasm-0.1.0",
"engineVersion": "blender-wasm-0.1.0",
"engine": "blender-wasm",
"variants": [
{
"id": "single",
"memory": {
"initialPages": 256,
"maximumPages": 32768,
"shared": false
},
"resources": {
"js": {
"fileName": "web_engine.js",
"url": "/vendor/blender/single/web_engine.js",
"sha256": "1111111111111111111111111111111111111111111111111111111111111111"
},
"wasm": {
"fileName": "web_engine.wasm",
"url": "/vendor/blender/single/web_engine.wasm",
"sha256": "2222222222222222222222222222222222222222222222222222222222222222"
}
}
},
{
"id": "pthread",
"memory": {
"initialPages": 256,
"maximumPages": 32768,
"shared": true
},
"resources": {
"js": {
"fileName": "web_engine.js",
"url": "/vendor/blender/pthread/web_engine.js",
"sha256": "3333333333333333333333333333333333333333333333333333333333333333"
},
"wasm": {
"fileName": "web_engine.wasm",
"url": "/vendor/blender/pthread/web_engine.wasm",
"sha256": "4444444444444444444444444444444444444444444444444444444444444444"
},
"pthreadWorker": {
"fileName": "web_engine.js",
"url": "/vendor/blender/pthread/web_engine.js",
"sha256": "3333333333333333333333333333333333333333333333333333333333333333"
}
}
}
]
}

View File

@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
createDeploymentHttpServer,
listenDeploymentHttpServer,
loadDeploymentContract,
} from "./deployment-http-server.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
function option(name, fallback) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
}
function assertSafeArchiveEntries(archive) {
const entries = execFileSync("tar", ["-tzf", archive], { encoding: "utf8" })
.split("\n")
.filter(Boolean);
assert.ok(entries.length > 0, "binary archive is empty");
for (const entry of entries) {
assert.ok(!entry.startsWith("/"), `binary archive contains an absolute path: ${entry}`);
assert.ok(!entry.split("/").includes(".."), `binary archive contains path traversal: ${entry}`);
assert.ok(entry === "blender-web-offline/" || entry.startsWith("blender-web-offline/"), `unexpected binary archive root: ${entry}`);
}
}
const archive = path.resolve(option("--archive", path.join(repoRoot, "release/blender-web-offline.tar.gz")));
const port = Number.parseInt(option("--port", process.env.WEB_TEST_PORT ?? "5189"), 10);
assert.ok(fs.statSync(archive).isFile(), `binary archive is missing: ${archive}`);
assert.ok(Number.isSafeInteger(port) && port > 0 && port < 65536, `invalid deployment port: ${port}`);
assertSafeArchiveEntries(archive);
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-archive-server-"));
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", archive, "-C", workspace]);
const bundleRoot = path.join(workspace, "blender-web-offline");
const staticRoot = path.join(bundleRoot, "app");
assert.ok(fs.statSync(path.join(staticRoot, "index.html")).isFile(), "extracted binary archive has no app/index.html");
assert.ok(!staticRoot.startsWith(`${repoRoot}${path.sep}`), "archive server must not serve a workspace directory");
const contract = loadDeploymentContract(path.join(bundleRoot, "deployment-contract.json"));
const server = createDeploymentHttpServer({ root: staticRoot, contract });
let closing = false;
async function close(exitCode = 0) {
if (closing) return;
closing = true;
await new Promise((resolve) => server.close(() => resolve()));
fs.rmSync(workspace, { recursive: true, force: true });
process.exit(exitCode);
}
process.once("SIGINT", () => void close(0));
process.once("SIGTERM", () => void close(0));
process.once("uncaughtException", (error) => {
process.stderr.write(`${error.stack ?? error}\n`);
void close(1);
});
process.once("unhandledRejection", (error) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
void close(1);
});
const origin = await listenDeploymentHttpServer(server, { port });
process.stdout.write(`archive-deployment-server ${origin} root=${staticRoot} archive=${archive}\n`);

View File

@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const releaseRoot = path.join(repoRoot, "release");
const archive = path.resolve(process.env.M6_BINARY_ARCHIVE ?? path.join(releaseRoot, "blender-web-offline.tar.gz"));
const sourceArchive = path.resolve(process.env.M6_SOURCE_ARCHIVE ?? path.join(releaseRoot, "blender-web-corresponding-source.tar.gz"));
const sumsPath = path.join(releaseRoot, "SHA256SUMS.txt");
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-binary-archive-"));
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
function walk(directory, base = directory) {
const files = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...walk(absolute, base));
else files.push(path.relative(base, absolute).replaceAll(path.sep, "/"));
}
return files;
}
function safeEntries(file) {
const verbose = execFileSync("tar", ["-tvzf", file], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
for (const line of verbose.split("\n").filter(Boolean)) {
assert.ok(["-", "d"].includes(line[0]), `binary archive contains a non-regular entry: ${line}`);
}
const entries = execFileSync("tar", ["-tzf", file], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }).split("\n").filter(Boolean);
assert.ok(entries.length > 0, "binary archive is empty");
for (const entry of entries) {
assert.ok(!entry.startsWith("/") && !entry.split("/").includes(".."), `unsafe binary archive path: ${entry}`);
assert.ok(entry === "blender-web-offline/" || entry.startsWith("blender-web-offline/"), `unexpected binary archive root: ${entry}`);
}
}
try {
assert.ok(fs.statSync(archive).isFile(), `binary archive is missing: ${archive}`);
assert.ok(fs.statSync(sourceArchive).isFile(), `source archive is missing: ${sourceArchive}`);
assert.ok(fs.statSync(sumsPath).isFile(), `release checksum file is missing: ${sumsPath}`);
execFileSync("sha256sum", ["-c", path.basename(sumsPath)], { cwd: releaseRoot, stdio: "pipe" });
safeEntries(archive);
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", archive, "-C", workspace]);
const bundle = path.join(workspace, "blender-web-offline");
assert.ok(!fs.realpathSync(bundle).startsWith(`${repoRoot}${path.sep}`), "binary archive was not extracted independently");
const required = [
"COPYING",
"DEPLOYMENT.md",
"SOURCE_OFFER.txt",
"deployment-contract.json",
"manifest.json",
"KNOWN_LIMITATIONS.md",
"operations-diagnostics.json",
"parity-ledger.json",
"RELEASE_NOTES.md",
"RELEASE_RECOVERY.md",
"release-metadata.json",
"sbom.spdx.json",
"third-party-notices.json",
"V1_SCOPE.md",
"app/index.html",
"app/engine-manifest.json",
];
for (const relative of required) assert.ok(fs.statSync(path.join(bundle, relative)).isFile(), `binary archive omits ${relative}`);
const manifest = JSON.parse(fs.readFileSync(path.join(bundle, "manifest.json"), "utf8"));
assert.equal(manifest.schemaVersion, 1, "binary manifest schema drifted");
assert.ok(Array.isArray(manifest.files) && manifest.files.length > 0, "binary manifest has no files");
const actualFiles = walk(bundle).filter((relative) => relative !== "manifest.json");
assert.deepEqual(manifest.files.map((entry) => entry.path), actualFiles, "binary manifest file list drifted");
for (const entry of manifest.files) {
const file = path.join(bundle, entry.path);
assert.equal(fs.statSync(file).size, entry.bytes, `binary manifest byte length drifted: ${entry.path}`);
assert.equal(sha256(file), entry.sha256, `binary manifest SHA-256 drifted: ${entry.path}`);
}
const contract = JSON.parse(fs.readFileSync(path.join(bundle, "deployment-contract.json"), "utf8"));
assert.equal(contract.schemaVersion, 1);
assert.equal(contract.transport.production, "https");
assert.equal(contract.transport.localDevelopment, "http://127.0.0.1");
assert.equal(contract.transport.fileProtocolSupported, false);
assert.deepEqual(contract.methods, ["GET", "HEAD"]);
const sbom = JSON.parse(fs.readFileSync(path.join(bundle, "sbom.spdx.json"), "utf8"));
assert.equal(sbom.spdxVersion, "SPDX-2.3");
assert.ok(sbom.packages.length > 0, "binary SBOM has no packages");
const engine = JSON.parse(fs.readFileSync(path.join(bundle, "app/engine-manifest.json"), "utf8"));
assert.equal(engine.schemaVersion, 2);
assert.equal(new Set(engine.variants.map((variant) => variant.id)).size, 2);
for (const variant of engine.variants) {
for (const resource of Object.values(variant.resources)) {
const file = path.join(bundle, "app", new URL(resource.url, "http://archive.local").pathname.slice(1));
assert.equal(sha256(file), resource.sha256, `${variant.id} ${resource.url} hash drifted`);
}
}
const releaseMetadata = JSON.parse(fs.readFileSync(path.join(bundle, "release-metadata.json"), "utf8"));
assert.equal(releaseMetadata.schemaVersion, 1);
assert.equal(releaseMetadata.engineReleaseId, engine.releaseId);
assert.equal(releaseMetadata.storage.indexedDbSchemaVersion, 6);
assert.equal(releaseMetadata.storage.opfsProjectManifestSchemaVersion, 1);
assert.equal(releaseMetadata.storage.migrationDirection, "forward-only");
assert.equal(releaseMetadata.storage.originBound, true);
assert.ok(releaseMetadata.app.workerAssets.length >= 3);
for (const relative of [...releaseMetadata.app.workerAssets, ...releaseMetadata.app.entryAssets]) {
assert.ok(fs.statSync(path.join(bundle, "app", relative)).isFile(), `release metadata asset is missing: ${relative}`);
}
const diagnostics = JSON.parse(fs.readFileSync(path.join(bundle, "operations-diagnostics.json"), "utf8"));
assert.deepEqual(diagnostics.entries.map((entry) => entry.domain).sort(), ["gpu", "hash", "isolation", "mime", "quota", "range", "worker"]);
const releaseNotes = fs.readFileSync(path.join(bundle, "RELEASE_NOTES.md"), "utf8");
assert.match(releaseNotes, /All 12 V1 family release slices are `READY`/);
assert.match(releaseNotes, /all 12 complete Blender 5\.2 family parity states are\s+`BLOCKED`/);
assert.match(releaseNotes, /\[Known limitations\]\(KNOWN_LIMITATIONS\.md\)/);
assert.match(releaseNotes, /\[Release recovery\]\(RELEASE_RECOVERY\.md\)/);
assert.match(fs.readFileSync(path.join(bundle, "KNOWN_LIMITATIONS.md"), "utf8"), /32,768-page ceiling \(2 GiB\)/);
assert.match(fs.readFileSync(path.join(bundle, "RELEASE_RECOVERY.md"), "utf8"), /sha256sum --check RC_MANIFEST\.json\.sha256/);
const report = {
schemaVersion: 1,
task: "M6-11",
status: "READY",
archive: { path: path.basename(archive), bytes: fs.statSync(archive).size, sha256: sha256(archive) },
correspondingSource: { path: path.basename(sourceArchive), bytes: fs.statSync(sourceArchive).size, sha256: sha256(sourceArchive) },
extractedRootOutsideWorkspace: true,
filesVerified: manifest.files.length,
keyFiles: Object.fromEntries(required.map((relative) => [relative, sha256(path.join(bundle, relative))])),
};
const reportRoot = path.join(releaseRoot, "archive-reports");
fs.mkdirSync(reportRoot, { recursive: true });
fs.writeFileSync(path.join(reportRoot, "binary.json"), `${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`binary-archive-ok files=${report.filesVerified} sha256=${report.archive.sha256}\n`);
}
finally {
fs.rmSync(workspace, { recursive: true, force: true });
}

View File

@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "blender-ci-failure-"));
const reportPath = path.join(temporary, "quick-failure.json");
try {
const result = spawnSync(process.execPath, [new URL("./run-ci-lane.mjs", import.meta.url).pathname, "quick", "--self-test-failure"], {
encoding: "utf8",
env: { ...process.env, CI_REPORT_PATH: reportPath },
});
assert.notEqual(result.status, 0, "injected lane failure returned success");
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
assert.equal(report.status, "FAILED");
assert.equal(report.records.length, 1);
assert.equal(report.records[0].id, "injected-failure");
assert.equal(report.records[0].exitCode, 17);
assert.ok(fs.existsSync(`${reportPath}.sha256`));
process.stdout.write("ci-failure-report-ok status=FAILED exitCode=17 staleReady=false\n");
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const reportRoot = path.join(repoRoot, "release/ci-reports");
const requested = process.argv.slice(2);
const paths = requested.length > 0
? requested.map((value) => path.isAbsolute(value) ? value : path.resolve(repoRoot, value))
: ["quick", "chromium", "release"].map((lane) => path.join(reportRoot, `${lane}.json`)).filter((file) => fs.existsSync(file));
assert.ok(paths.length > 0, "no CI lane reports were provided or found");
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
const commonBindings = ["packageJson", "lockfile", "ledger", "engineManifest", "releaseNotes", "knownLimitations", "releaseRecovery", "sbom"];
const releaseBindings = ["binaryArchive", "sourceArchive", "sha256Sums", "rcManifest"];
const expectedRecordIds = {
quick: ["lockfile-install", "typecheck", "lint", "unit", "status", "evidence-schema", "rc-docs"],
chromium: ["lockfile-install", "p0-user-loop", "offline-browser-smoke", "network-interruption", "device-loss", "oom-recovery", "opfs-quota", "malicious-blend", "malicious-archive"],
release: [
"lockfile-install", "full-e2e", "performance-100k-1m", "performance-10m",
"performance-texture-4k", "performance-texture-8k", "performance-simulation",
"performance-long-media", "vdb-native", "vdb-server", "vdb-opfs", "vdb-webgpu",
"vdb-viewport", "vdb-faults", "v1-acceptance", "offline-reproducibility",
"archive-browser", "binary-archive", "source-archive", "deployment-runbook",
"upgrade-runbook", "rollback-runbook", "operations-diagnostics", "operations-rehearsal",
"rc-manifest",
],
};
const reports = [];
for (const reportPath of paths) {
const bytes = fs.readFileSync(reportPath);
const report = JSON.parse(bytes);
assert.equal(report.schemaVersion, 1, `${reportPath} schemaVersion drifted`);
assert.ok(["quick", "chromium", "release"].includes(report.lane), `${reportPath} lane is invalid`);
assert.equal(report.status, "READY", `${reportPath} is not READY`);
assert.match(report.commit, /^[a-f0-9]{40}$/);
assert.ok(!Number.isNaN(Date.parse(report.generatedAt)), `${reportPath} generatedAt is invalid`);
assert.equal(report.retentionDays, report.lane === "quick" ? 7 : report.lane === "chromium" ? 14 : 30);
for (const name of ["os", "node", "npm", "chrome", "blender", "emscripten"]) assert.equal(typeof report.environment[name], "string", `${reportPath} omits environment.${name}`);
for (const name of [...commonBindings, ...releaseBindings]) {
const binding = report.bindings[name];
assert.ok(binding && typeof binding.path === "string", `${reportPath} omits binding ${name}`);
if (binding.sha256 !== null) {
assert.match(binding.sha256, /^[a-f0-9]{64}$/);
const file = path.join(repoRoot, binding.path);
assert.equal(sha256(fs.readFileSync(file)), binding.sha256, `${reportPath} binding drifted: ${name}`);
}
}
if (report.lane === "release") {
for (const name of releaseBindings) assert.match(report.bindings[name].sha256, /^[a-f0-9]{64}$/);
}
else {
for (const name of releaseBindings) assert.equal(report.bindings[name].sha256, null);
}
assert.ok(Array.isArray(report.records) && report.records.length > 0, `${reportPath} has no command records`);
assert.deepEqual(report.records.map((record) => record.id), expectedRecordIds[report.lane], `${reportPath} command coverage drifted`);
for (const record of report.records) {
assert.equal(record.exitCode, 0, `${reportPath} command failed: ${record.id}`);
assert.ok(Number.isSafeInteger(record.durationMs) && record.durationMs >= 0);
assert.match(record.output.sha256, /^[a-f0-9]{64}$/);
const artifact = path.join(repoRoot, record.output.path);
assert.equal(fs.statSync(artifact).size, record.output.bytes, `${record.id} output length drifted`);
assert.equal(sha256(fs.readFileSync(artifact)), record.output.sha256, `${record.id} output hash drifted`);
}
const sidecar = fs.readFileSync(`${reportPath}.sha256`, "utf8").trim().split(/\s+/)[0];
assert.equal(sidecar, sha256(bytes), `${reportPath} sidecar hash drifted`);
reports.push(report);
}
if (reports.length > 1) {
assert.equal(new Set(reports.map((report) => report.commit)).size, 1, "CI lane reports do not bind one commit");
for (const binding of commonBindings) {
assert.equal(new Set(reports.map((report) => report.bindings[binding].sha256)).size, 1, `CI lane ${binding} bindings differ`);
}
}
if (reports.length === 3) {
assert.deepEqual(new Set(reports.map((report) => report.lane)), new Set(["quick", "chromium", "release"]));
const releaseReport = reports.find((report) => report.lane === "release");
const rcManifest = JSON.parse(fs.readFileSync(path.join(repoRoot, releaseReport.bindings.rcManifest.path), "utf8"));
assert.equal(rcManifest.gitCommit, releaseReport.commit, "RC manifest and CI reports bind different commits");
assert.equal(rcManifest.bindings.packageJsonSha256, releaseReport.bindings.packageJson.sha256);
assert.equal(rcManifest.bindings.packageLockSha256, releaseReport.bindings.lockfile.sha256);
assert.equal(rcManifest.bindings.ledgerSha256, releaseReport.bindings.ledger.sha256);
assert.equal(rcManifest.bindings.engineManifestSha256, releaseReport.bindings.engineManifest.sha256);
assert.equal(rcManifest.artifacts.sbom.sha256, releaseReport.bindings.sbom.sha256);
assert.equal(rcManifest.artifacts.binaryArchive.sha256, releaseReport.bindings.binaryArchive.sha256);
assert.equal(rcManifest.artifacts.sourceArchive.sha256, releaseReport.bindings.sourceArchive.sha256);
assert.equal(rcManifest.bindings.sha256SumsSha256, releaseReport.bindings.sha256Sums.sha256);
}
process.stdout.write(`ci-report-ok lanes=${reports.map((report) => report.lane).join(",")} commit=${reports[0].commit}\n`);

View File

@@ -0,0 +1,161 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
createDeploymentHttpServer,
listenDeploymentHttpServer,
loadDeploymentContract,
} from "./deployment-http-server.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourceDist = path.join(repoRoot, "web/dist");
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-deployment-cache-"));
const root = path.join(workspace, "dist");
const contract = loadDeploymentContract();
let server;
function sha256(bytes) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
function route(id) {
const value = contract.responseHeaders.routes.find((candidate) => candidate.id === id);
assert.ok(value, `deployment contract route is missing: ${id}`);
return value;
}
async function responseBytes(response) {
return Buffer.from(await response.arrayBuffer());
}
async function expectCache(origin, url, expected) {
const response = await fetch(`${origin}${url}`, { cache: "no-store" });
assert.equal(response.status, 200, `cache probe failed url=${url} status=${response.status}`);
assert.equal(
response.headers.get("cache-control"),
expected,
`Cache-Control mismatch url=${url}`,
);
assert.match(response.headers.get("etag"), /^"sha256-[a-f0-9]{64}"$/);
return response;
}
function contentHashedAssets() {
const assetRoot = path.join(root, "assets");
const names = fs.readdirSync(assetRoot).sort();
const cases = [
["entry JS", names.filter((name) => name.endsWith(".js") && !name.includes(".worker-"))],
["CSS", names.filter((name) => name.endsWith(".css"))],
["Worker", names.filter((name) => name.includes(".worker-") && name.endsWith(".js"))],
];
for (const [role, roleNames] of cases) {
assert.ok(roleNames.length > 0, `production build has no content-hashed ${role} asset`);
for (const name of roleNames) {
assert.match(name, /-[A-Za-z0-9_-]{8}\.(?:js|css)$/, `${role} asset is not content hashed: ${name}`);
}
}
return cases.flatMap(([, namesForRole]) => namesForRole).map((name) => `/assets/${name}`);
}
try {
assert.ok(fs.statSync(path.join(sourceDist, "index.html")).size > 0, "web/dist is missing; run the production build first");
fs.cpSync(sourceDist, root, { recursive: true });
const manifestPath = path.join(root, "engine-manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.equal(manifest.schemaVersion, 2);
assert.match(manifest.releaseId, /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/);
server = createDeploymentHttpServer({ root, contract });
const origin = await listenDeploymentHttpServer(server);
const noCache = route("entry-document").cacheControl;
const manifestNoCache = route("runtime-manifest").cacheControl;
const immutable = route("content-hashed-assets").cacheControl;
const engineNoCache = route("stable-engine-assets").cacheControl;
const entry = await expectCache(origin, "/", noCache);
const entryEtag = entry.headers.get("etag");
assert.deepEqual(await responseBytes(entry), fs.readFileSync(path.join(root, "index.html")));
await expectCache(origin, "/index.html", noCache);
const unchangedEntry = await fetch(`${origin}/`, { headers: { "If-None-Match": entryEtag } });
assert.equal(unchangedEntry.status, 304);
assert.equal(unchangedEntry.headers.get("cache-control"), noCache);
const nextEntryBytes = Buffer.concat([
fs.readFileSync(path.join(root, "index.html")),
Buffer.from("\n<!-- next release -->\n"),
]);
fs.writeFileSync(path.join(root, "index.html"), nextEntryBytes);
const changedEntry = await fetch(`${origin}/`, { headers: { "If-None-Match": entryEtag } });
assert.equal(changedEntry.status, 200);
assert.equal(changedEntry.headers.get("cache-control"), noCache);
assert.notEqual(changedEntry.headers.get("etag"), entryEtag);
assert.deepEqual(await responseBytes(changedEntry), nextEntryBytes);
const firstManifest = await expectCache(origin, "/engine-manifest.json", manifestNoCache);
const firstManifestEtag = firstManifest.headers.get("etag");
assert.deepEqual(await responseBytes(firstManifest), fs.readFileSync(manifestPath));
const unchangedManifest = await fetch(`${origin}/engine-manifest.json`, {
headers: { "If-None-Match": firstManifestEtag },
});
assert.equal(unchangedManifest.status, 304);
assert.equal(unchangedManifest.headers.get("cache-control"), manifestNoCache);
assert.equal((await unchangedManifest.arrayBuffer()).byteLength, 0);
const assetUrls = contentHashedAssets();
for (const url of assetUrls) {
const response = await expectCache(origin, url, immutable);
const bytes = await responseBytes(response);
assert.equal(response.headers.get("etag"), `"sha256-${sha256(bytes)}"`);
}
const uniqueResources = [...new Map(
manifest.variants.flatMap((variant) => Object.values(variant.resources))
.map((resource) => [resource.url, resource]),
).values()];
for (const resource of uniqueResources) {
const response = await expectCache(origin, resource.url, engineNoCache);
assert.equal(sha256(await responseBytes(response)), resource.sha256);
}
const nextManifest = { ...manifest, releaseId: `${manifest.releaseId}.next` };
fs.writeFileSync(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`);
const changedManifest = await fetch(`${origin}/engine-manifest.json`, {
headers: { "If-None-Match": firstManifestEtag },
});
assert.equal(changedManifest.status, 200);
assert.equal(changedManifest.headers.get("cache-control"), manifestNoCache);
assert.notEqual(changedManifest.headers.get("etag"), firstManifestEtag);
assert.equal((await changedManifest.json()).releaseId, nextManifest.releaseId);
const stableResource = uniqueResources.find((resource) => resource.url.endsWith(".js"));
assert.ok(stableResource, "engine manifest has no stable JS resource");
const stablePath = path.join(root, stableResource.url.replace(/^\/+/, ""));
const stableFirst = await fetch(`${origin}${stableResource.url}`, { cache: "no-store" });
const stableFirstEtag = stableFirst.headers.get("etag");
const stableUnchanged = await fetch(`${origin}${stableResource.url}`, {
headers: { "If-None-Match": stableFirstEtag },
});
assert.equal(stableUnchanged.status, 304);
assert.equal(stableUnchanged.headers.get("cache-control"), engineNoCache);
const replacement = Buffer.from("export default () => { throw new Error('next release'); };\n");
fs.writeFileSync(stablePath, replacement);
const stableChanged = await fetch(`${origin}${stableResource.url}`, {
headers: { "If-None-Match": stableFirstEtag },
});
assert.equal(stableChanged.status, 200);
assert.equal(stableChanged.headers.get("cache-control"), engineNoCache);
assert.notEqual(stableChanged.headers.get("etag"), stableFirstEtag);
assert.equal(sha256(await responseBytes(stableChanged)), sha256(replacement));
assert.notEqual(sha256(replacement), stableResource.sha256);
process.stdout.write(
`deployment-cache-ok noCache=3 immutable=${assetUrls.length} stableEngine=${uniqueResources.length} revalidation=304/200\n`,
);
}
finally {
if (server?.listening) await new Promise((resolve) => server.close(resolve));
fs.rmSync(workspace, { recursive: true, force: true });
}

View File

@@ -53,7 +53,10 @@ assert.deepEqual(contract.rangeRequests.requiredSatisfiedHeaders, ["Accept-Range
assert.equal(contract.rangeRequests.ifRangeMismatch, "return-full-200");
for (const value of Object.values(contract.responseHeaders.allResponses)) assert.ok(guide.includes(`\`${value}\``));
for (const status of ["206", "416", "200"]) assert.ok(guide.includes(`\`${status}\``));
for (const status of ["206", "416", "304", "200"]) assert.ok(guide.includes(`\`${status}\``));
for (const route of contract.responseHeaders.routes) assert.ok(guide.includes(route.cacheControl));
for (const section of ["Empty-directory install runbook", "Transport preflight", "Upgrade runbook", "Rollback runbook", "Failure diagnostics", "Fresh-directory rehearsal"]) assert.ok(guide.includes(`## ${section}`));
for (const token of ["BLENDER_WEB_INSTALL_ROOT", "window.isSecureContext === true", "window.crossOriginIsolated === true"]) assert.ok(guide.includes(token));
assert.match(vite, /deployment-contract\.json/);
assert.match(vite, /responseHeaders\.allResponses/);
assert.match(releaseCreator, /deployment-contract\.json/);

View File

@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import {
createDeploymentHttpServer,
listenDeploymentHttpServer,
loadDeploymentContract,
} from "./deployment-http-server.mjs";
const contract = loadDeploymentContract();
const root = fs.mkdtempSync(path.join(os.tmpdir(), "blender-deployment-http-"));
function write(relative, bytes) {
const filePath = path.join(root, relative);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, bytes);
return Buffer.from(bytes);
}
function sha256(bytes) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
function deterministicBytes(size, seed) {
const bytes = Buffer.alloc(size);
for (let index = 0; index < bytes.length; index += 1) bytes[index] = (index * 31 + seed) & 0xff;
return bytes;
}
const fixtures = new Map([
["/index.html", write("index.html", "<!doctype html><title>Web Blender</title>")],
["/assets/app.js", write("assets/app.js", "export const app = true;\n")],
["/assets/app.css", write("assets/app.css", "body { color: black; }\n")],
["/vendor/blender/single/web_engine.js", write("vendor/blender/single/web_engine.js", "export default () => {};\n")],
["/vendor/blender/single/web_engine.wasm", write("vendor/blender/single/web_engine.wasm", deterministicBytes(1024, 7))],
["/vendor/blender/pthread/web_engine.js", write("vendor/blender/pthread/web_engine.js", "export default () => {}; // pthread\n")],
["/engine-manifest.json", write("engine-manifest.json", "{\"schemaVersion\":2}\n")],
["/media/frame.png", write("media/frame.png", Buffer.from([0x89, 0x50, 0x4e, 0x47]))],
["/media/silence.wav", write("media/silence.wav", Buffer.from("RIFF0000WAVE"))],
["/fixtures/basic.blend", write("fixtures/basic.blend", deterministicBytes(4096, 11))],
["/volumes/smoke.nvdb", write("volumes/smoke.nvdb", deterministicBytes(1024 * 1024, 19))],
]);
const server = createDeploymentHttpServer({ root, contract });
let origin;
async function body(response) {
return Buffer.from(await response.arrayBuffer());
}
async function expectMime(url, expected, method = "GET") {
const response = await fetch(`${origin}${url}`, { method });
const actual = response.headers.get("content-type");
assert.equal(actual, expected, `MIME mismatch url=${url} expected=${expected} actual=${actual}`);
assert.equal(response.status, 200, `HTTP status mismatch url=${url} expected=200 actual=${response.status}`);
return response;
}
function expectedMime(url) {
const extension = path.extname(new URL(url, "http://deployment.local").pathname);
const mime = contract.mimeTypes[extension];
assert.ok(mime, `deployment contract has no MIME for url=${url}`);
return mime;
}
async function expectRange(url, header, expectedStart, expectedEnd) {
const source = fixtures.get(url);
const response = await fetch(`${origin}${url}`, { headers: { Range: header } });
assert.equal(response.status, contract.rangeRequests.satisfiedStatus, `range status url=${url} range=${header}`);
const bytes = await body(response);
assert.deepEqual(bytes, source.subarray(expectedStart, expectedEnd + 1), `range bytes url=${url} range=${header}`);
for (const name of contract.rangeRequests.requiredSatisfiedHeaders) {
assert.ok(response.headers.has(name), `missing ${name} url=${url} range=${header}`);
}
assert.equal(response.headers.get("content-range"), `bytes ${expectedStart}-${expectedEnd}/${source.length}`);
assert.equal(Number(response.headers.get("content-length")), expectedEnd - expectedStart + 1);
assert.match(response.headers.get("etag"), /^"sha256-[a-f0-9]{64}"$/);
return response;
}
async function interruptedPrefix(url) {
return new Promise((resolve, reject) => {
const chunks = [];
const request = http.get(`${origin}${url}`, (response) => {
response.once("data", (chunk) => {
chunks.push(chunk);
response.destroy();
resolve({ bytes: Buffer.concat(chunks), etag: response.headers.etag });
});
response.on("error", (error) => {
if (error.code !== "ECONNRESET") reject(error);
});
});
request.on("error", reject);
});
}
try {
origin = await listenDeploymentHttpServer(server);
const mimeCases = [
["/index.html", ".html"],
["/assets/app.js", ".js"],
["/assets/app.css", ".css"],
["/vendor/blender/single/web_engine.wasm", ".wasm"],
["/engine-manifest.json", ".json"],
["/media/frame.png", ".png"],
["/media/silence.wav", ".wav"],
["/fixtures/basic.blend", ".blend"],
["/volumes/smoke.nvdb", ".nvdb"],
];
for (const [url, extension] of mimeCases) {
assert.equal(expectedMime(url), contract.mimeTypes[extension]);
const response = await expectMime(url, contract.mimeTypes[extension]);
assert.deepEqual(await body(response), fixtures.get(url));
}
for (const url of [
"/vendor/blender/single/web_engine.js",
"/vendor/blender/single/web_engine.wasm",
"/vendor/blender/pthread/web_engine.js",
"/fixtures/basic.blend",
]) {
const response = await expectMime(url, expectedMime(url), "HEAD");
assert.equal((await response.arrayBuffer()).byteLength, 0, `HEAD returned a body url=${url}`);
assert.equal(Number(response.headers.get("content-length")), fixtures.get(url).length);
}
await expectRange("/vendor/blender/single/web_engine.wasm", "bytes=100-199", 100, 199);
await expectRange("/fixtures/basic.blend", "bytes=200-399", 200, 399);
await expectRange("/volumes/smoke.nvdb", "bytes=300-599", 300, 599);
await expectRange("/fixtures/basic.blend", "bytes=400-", 400, fixtures.get("/fixtures/basic.blend").length - 1);
await expectRange("/fixtures/basic.blend", "bytes=-128", fixtures.get("/fixtures/basic.blend").length - 128, fixtures.get("/fixtures/basic.blend").length - 1);
for (const range of ["items=0-1", "bytes=", "bytes=20-10", "bytes=0-999999", "bytes=0-1,4-5", "bytes=-0"]) {
const response = await fetch(`${origin}/fixtures/basic.blend`, { headers: { Range: range } });
assert.equal(response.status, contract.rangeRequests.unsatisfiedStatus, `invalid range accepted range=${range}`);
assert.equal((await response.arrayBuffer()).byteLength, 0);
assert.equal(response.headers.get("content-range"), `bytes */${fixtures.get("/fixtures/basic.blend").length}`);
}
const etagFirst = (await fetch(`${origin}/fixtures/basic.blend`)).headers.get("etag");
const etagSecond = (await fetch(`${origin}/fixtures/basic.blend`)).headers.get("etag");
assert.equal(etagFirst, etagSecond);
const matchingIfRange = await fetch(`${origin}/fixtures/basic.blend`, {
headers: { Range: "bytes=0-31", "If-Range": etagFirst },
});
assert.equal(matchingIfRange.status, 206);
const mismatchingIfRange = await fetch(`${origin}/fixtures/basic.blend`, {
headers: { Range: "bytes=0-31", "If-Range": '"sha256-old-revision"' },
});
assert.equal(mismatchingIfRange.status, 200);
assert.deepEqual(await body(mismatchingIfRange), fixtures.get("/fixtures/basic.blend"));
const interrupted = await interruptedPrefix("/volumes/smoke.nvdb");
assert.ok(interrupted.bytes.length > 0 && interrupted.bytes.length < fixtures.get("/volumes/smoke.nvdb").length);
const resumed = await fetch(`${origin}/volumes/smoke.nvdb`, {
headers: {
Range: `bytes=${interrupted.bytes.length}-`,
"If-Range": interrupted.etag,
},
});
assert.equal(resumed.status, 206);
const merged = Buffer.concat([interrupted.bytes, await body(resumed)]);
assert.equal(sha256(merged), sha256(fixtures.get("/volumes/smoke.nvdb")));
const replacement = deterministicBytes(fixtures.get("/fixtures/basic.blend").length, 23);
fs.writeFileSync(path.join(root, "fixtures/basic.blend"), replacement);
const changed = await fetch(`${origin}/fixtures/basic.blend`, {
headers: { Range: "bytes=32-", "If-Range": etagFirst },
});
assert.equal(changed.status, 200);
assert.notEqual(changed.headers.get("etag"), etagFirst);
assert.deepEqual(await body(changed), replacement);
process.stdout.write("deployment-http-ok mime=9 head=4 ranges=5 invalidRanges=6 resumeSha256=verified\n");
}
finally {
if (server.listening) await new Promise((resolve) => server.close(resolve));
fs.rmSync(root, { recursive: true, force: true });
}

View File

@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
createDeploymentHttpServer,
listenDeploymentHttpServer,
} from "./deployment-http-server.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const releaseRoot = path.join(repoRoot, "release");
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
const binaryArchive = path.join(releaseRoot, "blender-web-offline.tar.gz");
const sumsFile = path.join(releaseRoot, "SHA256SUMS.txt");
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-deployment-runbook-"));
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
function checksumEntries(value) {
return Object.fromEntries(value.trim().split("\n").map((line) => {
const match = line.match(/^([a-f0-9]{64}) ([^/]+)$/);
assert.ok(match, `invalid SHA256SUMS entry: ${line}`);
return [match[2], match[1]];
}));
}
function assertSafeArchive(file) {
const verbose = execFileSync("tar", ["-tvzf", file], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
for (const line of verbose.split("\n").filter(Boolean)) {
assert.ok(["-", "d"].includes(line[0]), `deployment archive contains a link or special entry: ${line}`);
}
const entries = execFileSync("tar", ["-tzf", file], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })
.split("\n")
.filter(Boolean);
assert.ok(entries.length > 0, "deployment archive is empty");
for (const entry of entries) {
assert.ok(!entry.startsWith("/"), `deployment archive contains an absolute path: ${entry}`);
assert.ok(!entry.split("/").includes(".."), `deployment archive contains path traversal: ${entry}`);
assert.ok(entry === "blender-web-offline/" || entry.startsWith("blender-web-offline/"), `unexpected deployment archive root: ${entry}`);
}
}
function classifyOrigin(value, contract) {
let origin;
try {
origin = new URL(value);
}
catch {
return { status: "BLOCKED", reason: "INVALID_ORIGIN" };
}
if (origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) {
return { status: "BLOCKED", reason: "UNSUPPORTED_ORIGIN_SHAPE" };
}
if (origin.protocol === `${contract.transport.production}:`) return { status: "READY", mode: "production-https" };
const local = new URL(contract.transport.localDevelopment);
if (origin.protocol === local.protocol && origin.hostname === local.hostname) {
return { status: "READY", mode: "loopback-http" };
}
return { status: "BLOCKED", reason: "HTTPS_OR_EXPLICIT_LOOPBACK_REQUIRED" };
}
function assertHeader(response, name, expected) {
assert.equal(response.headers.get(name), expected, `${name} drifted at ${response.url}`);
}
let server;
try {
const delivery = path.join(workspace, "delivery");
const installRoot = path.join(workspace, "install");
fs.mkdirSync(delivery);
fs.mkdirSync(installRoot);
for (const file of [binaryArchive, sourceArchive, sumsFile]) {
assert.ok(fs.statSync(file).isFile(), `release delivery is missing ${path.basename(file)}`);
fs.copyFileSync(file, path.join(delivery, path.basename(file)));
}
const deliveredArchive = path.join(delivery, path.basename(binaryArchive));
const sums = checksumEntries(fs.readFileSync(path.join(delivery, path.basename(sumsFile)), "utf8"));
assert.equal(sha256(deliveredArchive), sums[path.basename(binaryArchive)], "binary delivery checksum drifted");
assert.equal(sha256(path.join(delivery, path.basename(sourceArchive))), sums[path.basename(sourceArchive)], "source delivery checksum drifted");
assertSafeArchive(deliveredArchive);
const releases = path.join(installRoot, "releases");
fs.mkdirSync(releases);
const staging = fs.mkdtempSync(path.join(installRoot, ".install."));
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", deliveredArchive, "-C", staging]);
const stagedBundle = path.join(staging, "blender-web-offline");
const archiveSha256 = sha256(deliveredArchive);
const installedRelease = path.join(releases, archiveSha256);
assert.ok(!fs.existsSync(installedRelease), "fresh install unexpectedly contains a release");
fs.renameSync(stagedBundle, installedRelease);
fs.rmdirSync(staging);
const pendingLink = path.join(installRoot, ".current.next");
fs.symlinkSync(path.join("releases", archiveSha256), pendingLink);
fs.renameSync(pendingLink, path.join(installRoot, "current"));
assert.equal(fs.realpathSync(path.join(installRoot, "current")), installedRelease);
assert.ok(!installedRelease.startsWith(`${repoRoot}${path.sep}`), "runbook installed inside the source workspace");
const bundleRoot = path.join(installRoot, "current");
const appRoot = path.join(bundleRoot, "app");
const contract = JSON.parse(fs.readFileSync(path.join(bundleRoot, "deployment-contract.json"), "utf8"));
const engine = JSON.parse(fs.readFileSync(path.join(appRoot, "engine-manifest.json"), "utf8"));
assert.equal(classifyOrigin("https://blender.example", contract).mode, "production-https");
assert.equal(classifyOrigin("https://blender.example:8443", contract).mode, "production-https");
assert.equal(classifyOrigin("http://127.0.0.1:8080", contract).mode, "loopback-http");
for (const blocked of [
"file:///tmp/blender/index.html",
"http://localhost:8080",
"http://0.0.0.0:8080",
"http://192.168.1.10:8080",
"https://user:secret@blender.example",
"https://blender.example/subpath",
]) assert.equal(classifyOrigin(blocked, contract).status, "BLOCKED", `${blocked} must be blocked`);
server = createDeploymentHttpServer({ root: appRoot, contract });
const origin = await listenDeploymentHttpServer(server);
assert.equal(classifyOrigin(origin, contract).mode, "loopback-http");
const indexResponse = await fetch(`${origin}/`, { method: "HEAD" });
assert.equal(indexResponse.status, 200);
for (const [name, value] of Object.entries(contract.responseHeaders.allResponses)) assertHeader(indexResponse, name, value);
assertHeader(indexResponse, "cache-control", "no-cache");
assertHeader(indexResponse, "content-type", contract.mimeTypes[".html"]);
assert.match(indexResponse.headers.get("etag") ?? "", /^"sha256-[a-f0-9]{64}"$/);
const wasmPath = new URL(engine.variants.find((variant) => variant.id === "single").resources.wasm.url, origin);
const rangeResponse = await fetch(wasmPath, { headers: { Range: "bytes=0-15" } });
assert.equal(rangeResponse.status, contract.rangeRequests.satisfiedStatus);
assert.equal((await rangeResponse.arrayBuffer()).byteLength, 16);
assertHeader(rangeResponse, "accept-ranges", "bytes");
assert.match(rangeResponse.headers.get("content-range") ?? "", /^bytes 0-15\/\d+$/);
const report = {
schemaVersion: 1,
task: "M6-17A",
status: "READY",
archive: { bytes: fs.statSync(deliveredArchive).size, sha256: archiveSha256 },
engineReleaseId: engine.releaseId,
install: {
emptyRoot: true,
outsideSourceWorkspace: true,
immutableReleaseDirectory: path.relative(installRoot, installedRelease),
currentTarget: fs.readlinkSync(path.join(installRoot, "current")),
},
transport: {
productionHttps: "READY",
loopbackHttp: "READY",
publicPlainHttp: "BLOCKED",
fileProtocol: "BLOCKED",
sameOriginRuntimeAssets: contract.transport.sameOriginRuntimeAssets,
},
installedServer: { status: indexResponse.status, rangeStatus: rangeResponse.status, isolationHeaders: contract.responseHeaders.allResponses },
};
const reportRoot = path.join(releaseRoot, "operations-reports");
fs.mkdirSync(reportRoot, { recursive: true });
fs.writeFileSync(path.join(reportRoot, "deploy.json"), `${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`deployment-runbook-ok release=${engine.releaseId} archive=${archiveSha256} install=empty https=ready loopback=ready public-http=blocked\n`);
}
finally {
if (server) await new Promise((resolve) => server.close(resolve));
fs.rmSync(workspace, { recursive: true, force: true });
}

View File

@@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const installer = path.join(repoRoot, "tools/web/install-web-engine-assets.sh");
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "engine-variant-install-test-"));
function sha256(bytes) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
function writeBuild(directory, label, includeWasm = true) {
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, "web_engine.js"), `export default ${JSON.stringify(label)};\n`);
if (includeWasm) fs.writeFileSync(path.join(directory, "web_engine.wasm"), Buffer.from(`wasm:${label}`));
}
function writeLegacyManifest(publicRoot) {
fs.mkdirSync(publicRoot, { recursive: true });
const manifestPath = path.join(publicRoot, "engine-manifest.json");
fs.writeFileSync(manifestPath, `${JSON.stringify({
schemaVersion: 1,
protocolVersion: 1,
engineVersion: "blender-wasm-install-test",
engine: "blender-wasm",
memory: { initialPages: 256, maximumPages: 32768, shared: false },
wasm: [],
}, null, 2)}\n`);
return manifestPath;
}
function runInstaller(singleBuild, pthreadBuild, publicRoot) {
return spawnSync("bash", [installer], {
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
WEB_ENGINE_SINGLE_BUILD_DIR: singleBuild,
WEB_ENGINE_PTHREAD_BUILD_DIR: pthreadBuild,
WEB_ENGINE_PUBLIC_ROOT: publicRoot,
},
});
}
try {
const positive = path.join(workspace, "positive");
const singleBuild = path.join(positive, "build-single");
const pthreadBuild = path.join(positive, "build-pthread");
const publicRoot = path.join(positive, "public");
writeBuild(singleBuild, "single-output");
writeBuild(pthreadBuild, "pthread-output");
writeLegacyManifest(publicRoot);
fs.mkdirSync(path.join(publicRoot, "vendor/blender"), { recursive: true });
fs.writeFileSync(path.join(publicRoot, "vendor/blender/web_engine.js"), "legacy-loader\n");
const installed = runInstaller(singleBuild, pthreadBuild, publicRoot);
assert.equal(installed.status, 0, installed.stderr || installed.stdout);
const manifest = JSON.parse(fs.readFileSync(path.join(publicRoot, "engine-manifest.json"), "utf8"));
assert.equal(manifest.schemaVersion, 2);
assert.equal(manifest.releaseId, "blender-wasm-install-test");
assert.deepEqual(manifest.variants.map((variant) => variant.id), ["single", "pthread"]);
for (const [index, buildDirectory] of [[0, singleBuild], [1, pthreadBuild]]) {
const variant = manifest.variants[index];
for (const role of ["js", "wasm"]) {
const resource = variant.resources[role];
const source = fs.readFileSync(path.join(buildDirectory, resource.fileName));
const target = fs.readFileSync(path.join(publicRoot, resource.url));
assert.deepEqual(target, source);
assert.equal(resource.sha256, sha256(source));
}
}
assert.equal(fs.readFileSync(path.join(publicRoot, "vendor/blender/web_engine.js"), "utf8"), "legacy-loader\n");
assert.deepEqual(manifest.variants[1].resources.pthreadWorker, manifest.variants[1].resources.js);
const sameBuildRoot = path.join(workspace, "same-build-negative");
const sameBuild = path.join(sameBuildRoot, "build");
const samePublic = path.join(sameBuildRoot, "public");
writeBuild(sameBuild, "same-output");
const sameManifest = writeLegacyManifest(samePublic);
const beforeSame = fs.readFileSync(sameManifest);
const sameResult = runInstaller(sameBuild, sameBuild, samePublic);
assert.notEqual(sameResult.status, 0);
assert.match(sameResult.stderr, /independent build directories/);
assert.deepEqual(fs.readFileSync(sameManifest), beforeSame);
assert.equal(fs.existsSync(path.join(samePublic, "vendor/blender/single")), false);
const missingRoot = path.join(workspace, "missing-asset-negative");
const completeBuild = path.join(missingRoot, "build-single");
const incompleteBuild = path.join(missingRoot, "build-pthread");
const missingPublic = path.join(missingRoot, "public");
writeBuild(completeBuild, "complete-output");
writeBuild(incompleteBuild, "incomplete-output", false);
const missingManifest = writeLegacyManifest(missingPublic);
const beforeMissing = fs.readFileSync(missingManifest);
const missingResult = runInstaller(completeBuild, incompleteBuild, missingPublic);
assert.notEqual(missingResult.status, 0);
assert.deepEqual(fs.readFileSync(missingManifest), beforeMissing);
assert.equal(fs.existsSync(path.join(missingPublic, "vendor/blender/single")), false);
process.stdout.write("engine-variant-install-ok variants=2 negativeCases=2\n");
}
finally {
fs.rmSync(workspace, { recursive: true, force: true });
}

View File

@@ -33,6 +33,10 @@ test -s "${web_dir}/app/src/vendor/blender/web_engine.js"
test -s "${web_dir}/app/src/vendor/blender/web_engine.wasm"
test -s "${web_dir}/app/public/vendor/blender/web_engine.js"
test -s "${web_dir}/app/public/vendor/blender/web_engine.wasm"
test -s "${web_dir}/app/public/vendor/blender/single/web_engine.js"
test -s "${web_dir}/app/public/vendor/blender/single/web_engine.wasm"
test -s "${web_dir}/app/public/vendor/blender/pthread/web_engine.js"
test -s "${web_dir}/app/public/vendor/blender/pthread/web_engine.wasm"
cmp "${web_dir}/app/src/vendor/blender/web_engine.js" "${web_dir}/app/public/vendor/blender/web_engine.js"
cmp "${web_dir}/app/src/vendor/blender/web_engine.wasm" "${web_dir}/app/public/vendor/blender/web_engine.wasm"
test -s "${repo_root}/blender-5.2.0/extern/zlib/CMakeLists.txt"

View File

@@ -6,12 +6,20 @@ const [manifestPath, publicRoot] = process.argv.slice(2);
if (!manifestPath || !publicRoot) throw new Error("usage: check-manifest-assets.mjs MANIFEST PUBLIC_ROOT");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
for (const resource of manifest.wasm ?? []) {
if (manifest.schemaVersion === 2 &&
(typeof manifest.releaseId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/.test(manifest.releaseId))) {
throw new Error("schema v2 engine manifest has no valid releaseId");
}
const resources = manifest.schemaVersion === 2
? manifest.variants.flatMap((variant) => Object.values(variant.resources))
: manifest.wasm ?? [];
const uniqueResources = [...new Map(resources.map((resource) => [resource.url, resource])).values()];
for (const resource of uniqueResources) {
const relativePath = resource.url.replace(/^\/+/, "");
const filePath = path.join(publicRoot, relativePath);
const actual = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
if (actual !== String(resource.sha256).toLowerCase()) {
throw new Error(`manifest hash mismatch: ${resource.id} expected=${resource.sha256} actual=${actual}`);
throw new Error(`manifest hash mismatch: ${resource.url} expected=${resource.sha256} actual=${actual}`);
}
}
console.log(`manifest-assets-ok resources=${(manifest.wasm ?? []).length}`);
console.log(`manifest-assets-ok resources=${uniqueResources.length}`);

View File

@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourcePath = path.join(repoRoot, "docs/web/operations-diagnostics.json");
const diagnostics = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
const contract = JSON.parse(fs.readFileSync(path.join(repoRoot, "docs/web/deployment-contract.json"), "utf8"));
const guide = fs.readFileSync(path.join(repoRoot, "docs/web/DEPLOYMENT.md"), "utf8");
const requiredDomains = ["mime", "range", "isolation", "hash", "quota", "worker", "gpu"];
assert.equal(diagnostics.schemaVersion, 1);
assert.equal(diagnostics.product, contract.product);
assert.deepEqual(diagnostics.entries.map((entry) => entry.domain).sort(), [...requiredDomains].sort());
assert.equal(new Set(diagnostics.entries.map((entry) => entry.id)).size, requiredDomains.length);
const allSignals = diagnostics.entries.flatMap((entry) => entry.signals);
assert.equal(new Set(allSignals).size, allSignals.length, "diagnostic signals must be unambiguous");
for (const entry of diagnostics.entries) {
assert.match(entry.id, /^OPS-[A-Z]+$/);
for (const name of ["signals", "checks", "expected", "recovery"]) {
assert.ok(Array.isArray(entry[name]) && entry[name].length > 0, `${entry.id} omits ${name}`);
assert.ok(entry[name].every((value) => typeof value === "string" && value.length > 0), `${entry.id} has an empty ${name}`);
}
assert.ok(typeof entry.dataSafety === "string" && entry.dataSafety.length > 0, `${entry.id} omits dataSafety`);
assert.ok(guide.toLowerCase().includes(`| ${entry.domain}`), `${entry.id} is absent from DEPLOYMENT.md`);
}
const byDomain = Object.fromEntries(diagnostics.entries.map((entry) => [entry.domain, entry]));
for (const mime of [contract.mimeTypes[".wasm"], contract.mimeTypes[".js"], contract.mimeTypes[".json"]]) {
assert.ok(byDomain.mime.expected.some((value) => value.includes(mime)), `MIME diagnostics omit ${mime}`);
}
for (const status of [contract.rangeRequests.satisfiedStatus, contract.rangeRequests.unsatisfiedStatus, 200]) {
assert.ok(byDomain.range.expected.some((value) => value.includes(String(status))), `range diagnostics omit ${status}`);
}
for (const [name, value] of Object.entries(contract.responseHeaders.allResponses)) {
assert.ok(byDomain.isolation.expected.some((item) => item.includes(name) && item.includes(value)), `isolation diagnostics omit ${name}`);
}
assert.ok(byDomain.hash.expected.some((value) => value.includes("no fallback") && value.includes("no project open")));
assert.ok(byDomain.quota.dataSafety.includes("Never delete"));
assert.ok(byDomain.worker.dataSafety.includes("late Worker output"));
assert.ok(byDomain.gpu.dataSafety.includes("must not rewrite"));
const report = {
schemaVersion: 1,
task: "M6-17D",
status: "READY",
sourceSha256: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
domains: requiredDomains,
entries: diagnostics.entries.length,
signals: allSignals.length,
};
const reportRoot = path.join(repoRoot, "release/operations-reports");
fs.mkdirSync(reportRoot, { recursive: true });
fs.writeFileSync(path.join(reportRoot, "diagnostics.json"), `${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`operations-diagnostics-ok domains=${requiredDomains.join(",")} signals=${allSignals.length} source=${report.sourceSha256}\n`);

View File

@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const limitationsPath = path.join(repoRoot, "docs/web/KNOWN_LIMITATIONS.md");
const limitations = fs.readFileSync(limitationsPath, "utf8");
const notes = fs.readFileSync(path.join(repoRoot, "docs/web/RELEASE_NOTES.md"), "utf8");
const engine = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
assert.ok(notes.includes("[Known limitations](KNOWN_LIMITATIONS.md)"), "release notes do not link known limitations");
for (const variant of engine.variants) {
assert.equal(variant.memory.initialPages, 256);
assert.equal(variant.memory.maximumPages, 32_768);
}
for (const required of [
"Chromium is the only V1 browser baseline",
"crossOriginIsolated",
"SharedArrayBuffer",
"PTHREAD_REQUIRED",
"SINGLE_REQUIRED",
"ENGINE_VARIANT_INTEGRITY_FAILED",
"REFRESH_REQUIRED",
"256 initial WebAssembly pages (16 MiB)",
"32,768-page ceiling (2 GiB)",
"bound to the exact scheme, host and port",
"reserves no minimum capacity",
"64 MiB",
"16,384 pixels per dimension",
"256 requested",
"VDB input is bounded to 512 MiB",
"NanoVDB bundle to 1 GiB",
"residency to at most 512 MiB",
"Automatic viewport page-fault feedback",
]) {
assert.ok(limitations.includes(required), `known limitations omit: ${required}`);
}
const renderAssets = fs.readFileSync(path.join(repoRoot, "web/protocol/render-assets.ts"), "utf8");
assert.match(renderAssets, /MAX_GPU_TEXTURE_BYTES = 64 \* 1024 \* 1024/);
assert.match(renderAssets, /MAX_GPU_TEXTURE_DIMENSION = 16_384/);
assert.match(renderAssets, /MAX_GPU_TEXTURE_ASSETS = 256/);
const volume = fs.readFileSync(path.join(repoRoot, "web/protocol/volume-vdb.ts"), "utf8");
assert.match(volume, /VDB_MAX_RESOURCE_BYTES = 512 \* 1024 \* 1024/);
assert.match(volume, /NANOVDB_MAX_BUNDLE_BYTES = 1024 \* 1024 \* 1024/);
assert.match(volume, /NANOVDB_MAX_GPU_RESIDENT_BYTES = 512 \* 1024 \* 1024/);
for (const link of ["V1_SCOPE.md", "parity-ledger.json", "DEPLOYMENT.md", "operations-diagnostics.json"]) {
assert.ok(limitations.includes(`](${link})`), `known limitations omit link: ${link}`);
}
process.stdout.write(`rc-known-limitations-ok variants=${engine.variants.length} wasmMaximumPages=${engine.variants[0].memory.maximumPages}\n`);

View File

@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const manifestPath = path.join(repoRoot, "release/RC_MANIFEST.json");
const manifestBytes = fs.readFileSync(manifestPath);
const manifest = JSON.parse(manifestBytes);
const sha256Bytes = (value) => crypto.createHash("sha256").update(value).digest("hex");
const sha256File = (file) => sha256Bytes(fs.readFileSync(file));
const archiveEntry = (archive, entry) => execFileSync("tar", ["-xOf", archive, entry], { maxBuffer: 32 * 1024 * 1024 });
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package.json"), "utf8"));
const lockfile = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package-lock.json"), "utf8"));
const engine = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
const binaryArchive = path.join(repoRoot, manifest.artifacts.binaryArchive.path);
const sourceArchive = path.join(repoRoot, manifest.artifacts.sourceArchive.path);
const sbomPath = path.join(repoRoot, manifest.artifacts.sbom.path);
const sumsPath = path.join(repoRoot, "release/SHA256SUMS.txt");
assert.equal(manifest.schemaVersion, 1);
assert.match(manifest.semver, /^\d+\.\d+\.\d+-rc\.\d+$/);
assert.equal(manifest.rcId, `web-blender-${manifest.semver}`);
assert.equal(packageJson.version, manifest.semver);
assert.equal(lockfile.version, manifest.semver);
assert.equal(lockfile.packages[""].version, manifest.semver);
assert.equal(engine.releaseId, manifest.engineReleaseId);
assert.equal(engine.engineVersion, manifest.engineReleaseId);
assert.equal(manifest.engineReleaseId, `blender-wasm-${manifest.semver}`);
assert.equal(manifest.gitCommit, execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim());
assert.equal(manifest.sourceBinding.mode, "corresponding-source-archive");
assert.equal(typeof manifest.sourceBinding.worktreeDirty, "boolean");
for (const entry of Object.values(manifest.artifacts)) {
const file = path.join(repoRoot, entry.path);
assert.equal(fs.statSync(file).size, entry.bytes, `${entry.path} byte length drifted`);
assert.equal(sha256File(file), entry.sha256, `${entry.path} SHA-256 drifted`);
}
const embeddedPackageJson = archiveEntry(sourceArchive, "web/package.json");
const embeddedPackageLock = archiveEntry(sourceArchive, "web/package-lock.json");
const embeddedEngineManifest = archiveEntry(binaryArchive, "blender-web-offline/app/engine-manifest.json");
const embeddedReleaseMetadata = archiveEntry(binaryArchive, "blender-web-offline/release-metadata.json");
const embeddedSbom = archiveEntry(binaryArchive, "blender-web-offline/sbom.spdx.json");
assert.deepEqual(embeddedPackageJson, fs.readFileSync(path.join(repoRoot, "web/package.json")));
assert.deepEqual(embeddedPackageLock, fs.readFileSync(path.join(repoRoot, "web/package-lock.json")));
assert.deepEqual(embeddedEngineManifest, fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json")));
assert.deepEqual(embeddedSbom, fs.readFileSync(sbomPath));
const releaseMetadata = JSON.parse(embeddedReleaseMetadata.toString("utf8"));
const sourceManifest = JSON.parse(archiveEntry(sourceArchive, "SOURCE_MANIFEST.json").toString("utf8"));
assert.equal(releaseMetadata.productVersion, manifest.semver);
assert.equal(releaseMetadata.engineReleaseId, manifest.engineReleaseId);
assert.equal(sourceManifest.version, manifest.semver);
assert.equal(sourceManifest.engineReleaseId, manifest.engineReleaseId);
assert.equal(manifest.bindings.packageJsonSha256, sha256File(path.join(repoRoot, "web/package.json")));
assert.equal(manifest.bindings.packageLockSha256, sha256File(path.join(repoRoot, "web/package-lock.json")));
assert.equal(manifest.bindings.ledgerSha256, sha256File(path.join(repoRoot, "docs/status/parity-ledger.json")));
assert.equal(manifest.bindings.engineManifestSha256, sha256File(path.join(repoRoot, "web/app/public/engine-manifest.json")));
assert.equal(manifest.bindings.embeddedReleaseMetadataSha256, sha256Bytes(embeddedReleaseMetadata));
assert.equal(manifest.bindings.sha256SumsSha256, sha256File(sumsPath));
assert.deepEqual(Object.keys(manifest.operations), ["deploy", "upgrade", "rollback", "diagnostics", "rehearsal"]);
for (const entry of Object.values(manifest.operations)) {
const file = path.join(repoRoot, entry.path);
assert.equal(fs.statSync(file).size, entry.bytes);
assert.equal(sha256File(file), entry.sha256);
}
const manifestSha256 = sha256Bytes(manifestBytes);
assert.equal(fs.readFileSync(`${manifestPath}.sha256`, "utf8"), `${manifestSha256} RC_MANIFEST.json\n`);
const checksumLines = fs.readFileSync(sumsPath, "utf8").trim().split("\n");
assert.deepEqual(checksumLines, [
`${manifest.artifacts.binaryArchive.sha256} blender-web-offline.tar.gz`,
`${manifest.artifacts.sourceArchive.sha256} blender-web-corresponding-source.tar.gz`,
]);
process.stdout.write(`rc-manifest-check-ok id=${manifest.rcId} commit=${manifest.gitCommit} artifacts=3 operations=${Object.keys(manifest.operations).length}\n`);

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const notesPath = path.join(repoRoot, "docs/web/RELEASE_NOTES.md");
const notes = fs.readFileSync(notesPath, "utf8");
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package.json"), "utf8"));
const engine = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
const ledger = JSON.parse(fs.readFileSync(path.join(repoRoot, "docs/status/parity-ledger.json"), "utf8"));
assert.match(notes, new RegExp(`^# Web Blender Modeler V1 ${packageJson.version.replaceAll(".", "\\.")} Release Notes`, "m"));
assert.match(notes, new RegExp(`engine\\s+\`${engine.releaseId.replaceAll(".", "\\.")}\``));
for (const capability of ["\.blend", "undo", "redo", "OffscreenCanvas", "single-thread", "pthread", "OPFS", "10M geometry", "8K textures", "SBOM"]) {
assert.match(notes, new RegExp(capability, "i"), `release notes omit verified capability: ${capability}`);
}
for (const boundary of ["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "EXCLUDED", "Chromium-only", "not a browser port"]) {
assert.ok(notes.includes(boundary), `release notes omit capability boundary: ${boundary}`);
}
const releaseReady = ledger.families.filter((family) => family.releaseStatus === "READY").length;
const parityBlocked = ledger.families.filter((family) => family.parityStatus === "BLOCKED").length;
assert.equal(releaseReady, 12);
assert.equal(parityBlocked, 12);
assert.ok(notes.includes(`All ${releaseReady} V1 family release slices are \`READY\``));
assert.match(notes, new RegExp(`all ${parityBlocked} complete Blender 5\\.2 family parity states are\\s+\`BLOCKED\``));
const links = [...notes.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)].map((match) => match[1]);
assert.ok(links.length >= 5, "release notes do not link the capability and operations sources");
for (const link of links) {
assert.doesNotMatch(link, /^(?:https?:|\/)/, `release notes contain an external or absolute link: ${link}`);
const source = link === "V1_SCOPE.md"
? path.join(repoRoot, "docs/WEB_BLENDER_MODELER_V1_SCOPE.md")
: link === "parity-ledger.json"
? path.join(repoRoot, "docs/status/parity-ledger.json")
: path.join(repoRoot, "docs/web", link);
assert.ok(fs.statSync(source).isFile(), `release note link is unresolved: ${link}`);
}
process.stdout.write(`rc-release-notes-ok version=${packageJson.version} ready=${releaseReady} parityBlocked=${parityBlocked} links=${links.length}\n`);

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const recovery = fs.readFileSync(path.join(repoRoot, "docs/web/RELEASE_RECOVERY.md"), "utf8");
const notes = fs.readFileSync(path.join(repoRoot, "docs/web/RELEASE_NOTES.md"), "utf8");
const app = fs.readFileSync(path.join(repoRoot, "web/app/src/app/App.tsx"), "utf8");
const deployment = fs.readFileSync(path.join(repoRoot, "docs/web/DEPLOYMENT.md"), "utf8");
assert.ok(notes.includes("[Release recovery](RELEASE_RECOVERY.md)"), "release notes do not link release recovery");
for (const required of [
"sha256sum --check SHA256SUMS.txt",
"sha256sum --check RC_MANIFEST.json.sha256",
"test:rc-manifest",
"test:binary-archive",
"test:source-archive",
"check-ci-report.mjs",
"Save Project",
"blender-web.blend",
"SceneIR revision",
"readlink current",
"failure before the switch",
"failure after the switch",
"INDEXEDDB_SCHEMA_DOWNGRADE_UNSUPPORTED",
"OPFS_REVERSE_MIGRATION_UNDECLARED",
"Recover Project",
"Open .blend",
"test:operations-rehearsal",
]) {
assert.ok(recovery.toLowerCase().includes(required.toLowerCase()), `release recovery omits: ${required}`);
}
assert.match(app, /anchor\.download = "blender-web\.blend"/);
assert.match(app, /await storage\.saveProject\(projectIdRef\.current, revision, data\.slice\(0\)\)/);
assert.match(deployment, /Rollback changes only the `current` app\/engine release pointer/);
assert.match(deployment, /in-place rollback is\s+`BLOCKED`/);
for (const link of ["DEPLOYMENT.md"]) assert.ok(recovery.includes(`](${link})`));
process.stdout.write("rc-release-recovery-ok checksums=2 archives=2 schemaBlocks=2 projectBackup=downloaded-blend\n");

View File

@@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const archive = path.join(repoRoot, "release/blender-web-offline.tar.gz");
const reportRoot = path.join(repoRoot, "release/operations-reports");
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-rollback-runbook-"));
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function writeMetadata(release, metadata) {
fs.writeFileSync(path.join(release, "release-metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
}
function switchCurrent(installRoot, releaseName) {
const pending = path.join(installRoot, ".current.next");
fs.rmSync(pending, { force: true });
fs.symlinkSync(path.join("releases", releaseName), pending);
fs.renameSync(pending, path.join(installRoot, "current"));
}
function rollbackCompatibility(openedStorage, target) {
if (target.storage.indexedDbSchemaVersion < openedStorage.indexedDbSchemaVersion) {
return { status: "BLOCKED", reason: "INDEXEDDB_SCHEMA_DOWNGRADE_UNSUPPORTED" };
}
if (target.storage.opfsProjectManifestSchemaVersion !== openedStorage.opfsProjectManifestSchemaVersion) {
return { status: "BLOCKED", reason: "OPFS_REVERSE_MIGRATION_UNDECLARED" };
}
return { status: "READY", reason: null };
}
try {
const installRoot = path.join(workspace, "install");
const releases = path.join(installRoot, "releases");
const originStorage = path.join(workspace, "origin-storage", "projects", "project-a");
fs.mkdirSync(releases, { recursive: true });
fs.mkdirSync(originStorage, { recursive: true });
const extracted = path.join(workspace, "extracted");
fs.mkdirSync(extracted);
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", archive, "-C", extracted]);
const currentRelease = path.join(releases, "current-release");
fs.renameSync(path.join(extracted, "blender-web-offline"), currentRelease);
const currentMetadata = readJson(path.join(currentRelease, "release-metadata.json"));
const compatibleOld = path.join(releases, "compatible-old-release");
fs.cpSync(currentRelease, compatibleOld, { recursive: true });
const compatibleMetadata = structuredClone(currentMetadata);
compatibleMetadata.productVersion = "0.0.9-compatible";
compatibleMetadata.engineReleaseId = `${currentMetadata.engineReleaseId}-compatible-old`;
writeMetadata(compatibleOld, compatibleMetadata);
const incompatibleOld = path.join(releases, "incompatible-old-release");
fs.cpSync(currentRelease, incompatibleOld, { recursive: true });
const incompatibleMetadata = structuredClone(currentMetadata);
incompatibleMetadata.productVersion = "0.0.8-incompatible";
incompatibleMetadata.engineReleaseId = `${currentMetadata.engineReleaseId}-incompatible-old`;
incompatibleMetadata.storage.indexedDbSchemaVersion = Math.max(1, currentMetadata.storage.indexedDbSchemaVersion - 1);
writeMetadata(incompatibleOld, incompatibleMetadata);
switchCurrent(installRoot, path.basename(currentRelease));
const blend = Buffer.from("M6-17C persistent project content");
const project = {
schemaVersion: currentMetadata.storage.opfsProjectManifestSchemaVersion,
revision: 17,
bytes: blend.byteLength,
sha256: sha256(blend),
};
fs.writeFileSync(path.join(originStorage, "scene.blend"), blend);
fs.writeFileSync(path.join(originStorage, "scene.blend.meta.json"), `${JSON.stringify(project)}\n`);
const projectBytesBefore = fs.readFileSync(path.join(originStorage, "scene.blend"));
const manifestBefore = fs.readFileSync(path.join(originStorage, "scene.blend.meta.json"));
const currentBefore = fs.realpathSync(path.join(installRoot, "current"));
const openedStorage = {
indexedDbSchemaVersion: currentMetadata.storage.indexedDbSchemaVersion,
opfsProjectManifestSchemaVersion: currentMetadata.storage.opfsProjectManifestSchemaVersion,
};
const blocked = rollbackCompatibility(openedStorage, incompatibleMetadata);
assert.deepEqual(blocked, { status: "BLOCKED", reason: "INDEXEDDB_SCHEMA_DOWNGRADE_UNSUPPORTED" });
assert.equal(fs.realpathSync(path.join(installRoot, "current")), currentBefore, "blocked rollback changed current");
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend")), projectBytesBefore);
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend.meta.json")), manifestBefore);
const compatible = rollbackCompatibility(openedStorage, compatibleMetadata);
assert.deepEqual(compatible, { status: "READY", reason: null });
switchCurrent(installRoot, path.basename(compatibleOld));
assert.equal(fs.realpathSync(path.join(installRoot, "current")), compatibleOld);
assert.equal(readJson(path.join(installRoot, "current", "release-metadata.json")).engineReleaseId, compatibleMetadata.engineReleaseId);
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend")), projectBytesBefore);
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend.meta.json")), manifestBefore);
const reopened = readJson(path.join(originStorage, "scene.blend.meta.json"));
assert.equal(reopened.revision, project.revision);
assert.equal(reopened.sha256, sha256(fs.readFileSync(path.join(originStorage, "scene.blend"))));
assert.ok(fs.statSync(currentRelease).isDirectory(), "newer release was removed during rollback");
const opfsBlockedTarget = structuredClone(compatibleMetadata);
opfsBlockedTarget.storage.opfsProjectManifestSchemaVersion += 1;
assert.deepEqual(rollbackCompatibility(openedStorage, opfsBlockedTarget), {
status: "BLOCKED",
reason: "OPFS_REVERSE_MIGRATION_UNDECLARED",
});
const report = {
schemaVersion: 1,
task: "M6-17C",
status: "READY",
activeBefore: currentMetadata.engineReleaseId,
activeAfter: compatibleMetadata.engineReleaseId,
compatibleRollback: compatible,
blockedOldReader: blocked,
blockedOpfsMismatch: "OPFS_REVERSE_MIGRATION_UNDECLARED",
project: {
revisionBefore: project.revision,
revisionAfter: reopened.revision,
sha256Before: project.sha256,
sha256After: reopened.sha256,
preserved: true,
},
releases: { newerRetained: true, targetSwitchedAtomically: true },
};
fs.mkdirSync(reportRoot, { recursive: true });
fs.writeFileSync(path.join(reportRoot, "rollback.json"), `${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`rollback-runbook-ok compatible=ready idb-downgrade=blocked opfs-mismatch=blocked revision=${project.revision} project=${project.sha256}\n`);
}
finally {
fs.rmSync(workspace, { recursive: true, force: true });
}

View File

@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const releaseRoot = path.join(repoRoot, "release");
const sourceArchive = path.resolve(process.env.M6_SOURCE_ARCHIVE ?? path.join(releaseRoot, "blender-web-corresponding-source.tar.gz"));
const binaryArchive = path.resolve(process.env.M6_BINARY_ARCHIVE ?? path.join(releaseRoot, "blender-web-offline.tar.gz"));
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-source-archive-"));
const sourceRoot = path.join(workspace, "source");
const binaryRoot = path.join(workspace, "binary");
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
function archiveEntries(archive) {
const entries = execFileSync("tar", ["-tzf", archive], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }).split("\n").filter(Boolean);
assert.ok(entries.length > 0, `${path.basename(archive)} is empty`);
for (const entry of entries) {
assert.ok(!entry.startsWith("/") && !entry.split("/").includes(".."), `unsafe archive path: ${entry}`);
}
return entries;
}
function run(command, args, cwd) {
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
maxBuffer: 32 * 1024 * 1024,
});
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
assert.equal(result.status, 0, `${command} ${args.join(" ")} failed\n${output.slice(-8000)}`);
return output;
}
function firstPartyTextFiles(root) {
const files = [];
const visit = (directory) => {
if (!fs.existsSync(directory)) return;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
const relative = path.relative(root, absolute).replaceAll(path.sep, "/");
if (relative.startsWith("web/app/public/vendor/") || relative.startsWith("web/app/src/vendor/")) continue;
if (entry.isDirectory()) visit(absolute);
else if (entry.isFile() && fs.statSync(absolute).size <= 2 * 1024 * 1024 && /\.(?:c|cc|cpp|h|hh|hpp|js|mjs|json|md|sh|ts|tsx|txt|yml|yaml)$/.test(entry.name)) files.push(absolute);
}
};
for (const relative of ["web", "tools/web", "docs/web"]) visit(path.join(root, relative));
for (const relative of ["docs/PROJECT_STATUS_AND_NEXT_WORK.md", "WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md"]) {
const file = path.join(root, relative);
if (fs.existsSync(file)) files.push(file);
}
return files;
}
function distManifest(root) {
const files = [];
const visit = (directory, base) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) visit(absolute, base);
else files.push({ path: path.relative(base, absolute).replaceAll(path.sep, "/"), bytes: fs.statSync(absolute).size, sha256: sha256(absolute) });
}
};
visit(root, root);
return files;
}
try {
assert.ok(fs.statSync(sourceArchive).isFile(), `source archive is missing: ${sourceArchive}`);
assert.ok(fs.statSync(binaryArchive).isFile(), `binary archive is missing: ${binaryArchive}`);
const entries = archiveEntries(sourceArchive);
const forbidden = entries.filter((entry) =>
/(^|\/)(?:node_modules|dist|test-results|playwright-report|__pycache__)(?:\/|$)/.test(entry) ||
/\.pyc$/.test(entry) ||
/^(?:release|build_[^/]*|\.emcache)(?:\/|$)/.test(entry),
);
assert.deepEqual(forbidden, [], `source archive contains generated output: ${forbidden.join(", ")}`);
fs.mkdirSync(sourceRoot);
fs.mkdirSync(binaryRoot);
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", sourceArchive, "-C", sourceRoot]);
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", binaryArchive, "-C", binaryRoot]);
assert.ok(!fs.realpathSync(sourceRoot).startsWith(`${repoRoot}${path.sep}`), "source archive was not extracted independently");
const required = [
"SOURCE_MANIFEST.json",
"blender-5.2.0/COPYING",
"blender-5.2.0/CMakeLists.txt",
"blender-5.2.0/source/blender/web_engine/CMakeLists.txt",
"web/package.json",
"web/package-lock.json",
"web/app/vite.config.ts",
"web/app/src/main.tsx",
"web/protocol/manifest.ts",
"tools/web/create-offline-release.mjs",
];
for (const relative of required) assert.ok(fs.lstatSync(path.join(sourceRoot, relative)), `source archive omits ${relative}`);
const sourceOffer = fs.readFileSync(path.join(binaryRoot, "blender-web-offline/SOURCE_OFFER.txt"), "utf8");
assert.match(sourceOffer, /blender-web-corresponding-source\.tar\.gz/);
assert.match(sourceOffer, /Blender 5\.2/);
const manifestPath = path.join(sourceRoot, "SOURCE_MANIFEST.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.equal(manifest.schemaVersion, 1);
assert.equal(manifest.product, "Web Blender Modeler V1");
assert.ok(Array.isArray(manifest.files) && manifest.files.length > 10_000, "source manifest is incomplete");
const actualPaths = entries.filter((entry) => !entry.endsWith("/") && entry !== "SOURCE_MANIFEST.json").sort();
assert.deepEqual(manifest.files.map((entry) => entry.path).sort(), actualPaths, "source manifest path list drifted");
for (const entry of manifest.files) {
const file = path.join(sourceRoot, entry.path);
if (entry.type === "symlink") {
assert.equal(fs.readlinkSync(file), entry.target, `source symlink target drifted: ${entry.path}`);
}
else {
assert.equal(fs.statSync(file).size, entry.bytes, `source byte length drifted: ${entry.path}`);
assert.equal(sha256(file), entry.sha256, `source SHA-256 drifted: ${entry.path}`);
}
}
const absoluteDevelopmentPath = /(?:\/home\/[A-Za-z0-9._-]+\/|\/Users\/[A-Za-z0-9._-]+\/|[A-Za-z]:\\Users\\)/;
const credential = /(?:-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})/;
for (const file of firstPartyTextFiles(sourceRoot)) {
const text = fs.readFileSync(file, "utf8");
assert.doesNotMatch(text, absoluteDevelopmentPath, `source archive contains an absolute development path: ${path.relative(sourceRoot, file)}`);
assert.doesNotMatch(text, credential, `source archive contains a credential signature: ${path.relative(sourceRoot, file)}`);
}
const installOutput = run("npm", ["ci", "--ignore-scripts"], path.join(sourceRoot, "web"));
const typecheckOutput = run("npm", ["run", "typecheck"], path.join(sourceRoot, "web"));
const buildOutput = run("npm", ["run", "build"], path.join(sourceRoot, "web"));
const rebuilt = distManifest(path.join(sourceRoot, "web/dist"));
const packaged = distManifest(path.join(binaryRoot, "blender-web-offline/app"));
assert.deepEqual(rebuilt, packaged, "source archive build does not reproduce the binary app manifest");
const report = {
schemaVersion: 1,
task: "M6-12",
status: "READY",
archive: { path: path.basename(sourceArchive), bytes: fs.statSync(sourceArchive).size, sha256: sha256(sourceArchive) },
binaryArchiveSha256: sha256(binaryArchive),
extractedRootOutsideWorkspace: true,
filesVerified: manifest.files.length,
buildFilesVerified: rebuilt.length,
commands: [
{ command: "npm ci --ignore-scripts", outputSha256: crypto.createHash("sha256").update(installOutput).digest("hex") },
{ command: "npm run typecheck", outputSha256: crypto.createHash("sha256").update(typecheckOutput).digest("hex") },
{ command: "npm run build", outputSha256: crypto.createHash("sha256").update(buildOutput).digest("hex") },
],
keyFiles: Object.fromEntries(required.map((relative) => [relative, sha256(path.join(sourceRoot, relative))])),
rebuiltManifestSha256: crypto.createHash("sha256").update(JSON.stringify(rebuilt)).digest("hex"),
};
const reportRoot = path.join(releaseRoot, "archive-reports");
fs.mkdirSync(reportRoot, { recursive: true });
fs.writeFileSync(path.join(reportRoot, "source.json"), `${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`source-archive-ok files=${report.filesVerified} buildFiles=${report.buildFilesVerified} sha256=${report.archive.sha256}\n`);
}
finally {
fs.rmSync(workspace, { recursive: true, force: true });
}

View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const archive = path.join(repoRoot, "release/blender-web-offline.tar.gz");
const reportRoot = path.join(repoRoot, "release/operations-reports");
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-upgrade-runbook-"));
const sha256Bytes = (value) => crypto.createHash("sha256").update(value).digest("hex");
const sha256File = (file) => sha256Bytes(fs.readFileSync(file));
function readMetadata(release) {
return JSON.parse(fs.readFileSync(path.join(release, "release-metadata.json"), "utf8"));
}
function switchCurrent(installRoot, relativeTarget) {
const pending = path.join(installRoot, ".current.next");
fs.rmSync(pending, { force: true });
fs.symlinkSync(relativeTarget, pending);
fs.renameSync(pending, path.join(installRoot, "current"));
}
try {
const installRoot = path.join(workspace, "install");
const releases = path.join(installRoot, "releases");
const originStorage = path.join(workspace, "origin-storage");
fs.mkdirSync(releases, { recursive: true });
fs.mkdirSync(originStorage);
const extracted = path.join(workspace, "extracted");
fs.mkdirSync(extracted);
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", archive, "-C", extracted]);
const newRelease = path.join(releases, sha256File(archive));
fs.renameSync(path.join(extracted, "blender-web-offline"), newRelease);
const newMetadata = readMetadata(newRelease);
const oldRelease = path.join(releases, "synthetic-previous-release");
fs.cpSync(newRelease, oldRelease, { recursive: true });
const oldMetadata = structuredClone(newMetadata);
oldMetadata.productVersion = "0.0.9";
oldMetadata.engineReleaseId = `${newMetadata.engineReleaseId}-previous`;
oldMetadata.storage.indexedDbSchemaVersion = Math.max(1, newMetadata.storage.indexedDbSchemaVersion - 1);
const oldWorker = oldMetadata.app.workerAssets[0];
const previousWorker = oldWorker.replace(/\.worker-([A-Za-z0-9_-]+)\.js$/, ".worker-previous.js");
assert.notEqual(previousWorker, oldWorker, "Worker asset does not have a content-hashed name");
fs.copyFileSync(path.join(oldRelease, "app", oldWorker), path.join(oldRelease, "app", previousWorker));
oldMetadata.app.workerAssets = [previousWorker, ...oldMetadata.app.workerAssets.slice(1)];
fs.writeFileSync(path.join(oldRelease, "release-metadata.json"), `${JSON.stringify(oldMetadata, null, 2)}\n`);
switchCurrent(installRoot, path.join("releases", path.basename(oldRelease)));
const projectBytes = Buffer.from("M6-17B persistent OPFS project bytes");
const projectFile = path.join(originStorage, "scene.blend");
fs.writeFileSync(projectFile, projectBytes);
const projectBefore = sha256File(projectFile);
assert.notEqual(oldMetadata.engineReleaseId, newMetadata.engineReleaseId, "upgrade does not change engine release identity");
assert.ok(newMetadata.storage.indexedDbSchemaVersion >= oldMetadata.storage.indexedDbSchemaVersion, "upgrade would downgrade IndexedDB schema");
assert.equal(newMetadata.storage.opfsProjectManifestSchemaVersion, oldMetadata.storage.opfsProjectManifestSchemaVersion, "upgrade changes OPFS schema without a migration");
assert.notDeepEqual(newMetadata.app.workerAssets, oldMetadata.app.workerAssets, "upgrade does not exercise Worker identity switching");
assert.equal(newMetadata.app.documentCache, "no-cache");
assert.equal(newMetadata.app.manifestCache, "no-cache");
assert.equal(newMetadata.app.hashedAssetCache, "public, max-age=31536000, immutable");
const oldTarget = fs.realpathSync(path.join(installRoot, "current"));
const failedPending = path.join(installRoot, ".current.next");
fs.symlinkSync(path.join("releases", "missing-release"), failedPending);
assert.throws(() => fs.realpathSync(failedPending), /ENOENT/);
fs.rmSync(failedPending);
assert.equal(fs.realpathSync(path.join(installRoot, "current")), oldTarget, "failed preflight changed current");
assert.equal(sha256File(projectFile), projectBefore, "failed preflight changed project data");
switchCurrent(installRoot, path.join("releases", path.basename(newRelease)));
assert.equal(fs.realpathSync(path.join(installRoot, "current")), newRelease);
assert.deepEqual(readMetadata(path.join(installRoot, "current")), newMetadata);
assert.equal(sha256File(projectFile), projectBefore, "release switch changed origin storage");
for (const worker of newMetadata.app.workerAssets) {
assert.ok(fs.statSync(path.join(newRelease, "app", worker)).isFile(), `new Worker is missing: ${worker}`);
}
assert.ok(fs.statSync(path.join(oldRelease, "app", previousWorker)).isFile(), "old Worker was removed inside the rollback window");
const report = {
schemaVersion: 1,
task: "M6-17B",
status: "READY",
old: {
productVersion: oldMetadata.productVersion,
engineReleaseId: oldMetadata.engineReleaseId,
indexedDbSchemaVersion: oldMetadata.storage.indexedDbSchemaVersion,
opfsProjectManifestSchemaVersion: oldMetadata.storage.opfsProjectManifestSchemaVersion,
workerAssets: oldMetadata.app.workerAssets,
},
next: {
productVersion: newMetadata.productVersion,
engineReleaseId: newMetadata.engineReleaseId,
indexedDbSchemaVersion: newMetadata.storage.indexedDbSchemaVersion,
opfsProjectManifestSchemaVersion: newMetadata.storage.opfsProjectManifestSchemaVersion,
workerAssets: newMetadata.app.workerAssets,
},
cache: {
document: newMetadata.app.documentCache,
manifest: newMetadata.app.manifestCache,
hashedAssets: newMetadata.app.hashedAssetCache,
},
switch: { failedPreflightKeptCurrent: true, atomicCurrentSwitch: true, oldReleaseRetained: true },
project: { sha256Before: projectBefore, sha256After: sha256File(projectFile), preserved: true },
};
fs.mkdirSync(reportRoot, { recursive: true });
fs.writeFileSync(path.join(reportRoot, "upgrade.json"), `${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`upgrade-runbook-ok old=${oldMetadata.engineReleaseId} next=${newMetadata.engineReleaseId} idb=${oldMetadata.storage.indexedDbSchemaVersion}->${newMetadata.storage.indexedDbSchemaVersion} opfs=${newMetadata.storage.opfsProjectManifestSchemaVersion} workers=switched project=preserved\n`);
}
finally {
fs.rmSync(workspace, { recursive: true, force: true });
}

View File

@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const root = path.resolve(new URL("../..", import.meta.url).pathname);
@@ -13,7 +14,11 @@ assert.match(protocol, /validateNanoVDBBundleManifest/, "NanoVDB bundle validati
assert.match(protocol, /gateNanoVDBPipeline/, "NanoVDB stage capability gates are missing");
assert.doesNotMatch(protocol, /export async function decodeVDBResource/, "Browser raw OpenVDB decode entry must not be restored");
const roots = [path.join(root, "resource-library"), path.join(root, "tests"), "/home/mes123456/resource-library"];
const roots = [
path.join(root, "resource-library"),
path.join(root, "tests"),
path.resolve(process.env.VDB_RESOURCE_ROOT ?? path.join(os.homedir(), "resource-library/blender-web-vdb")),
];
const vdbFiles = [];
function scan(directory) {
if (!fs.existsSync(directory)) return;

View File

@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb");
const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? path.join(os.homedir(), "resource-library/blender-web-vdb"));
const converter = path.join(root, "build_vdb_tools", "vdb_to_nanovdb");
const generator = path.join(root, "build_vdb_tools", "vdb_fixture_generator");
const source = path.join(resourceRoot, "generated", "generated-smoke.vdb");

View File

@@ -10,7 +10,8 @@ import { VDBJobService, createVDBJobHttpServer } from "../vdb/server/vdb-job-ser
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const converter = path.join(root, "build_vdb_tools/vdb_to_nanovdb");
const source = "/home/mes123456/resource-library/blender-web-vdb/generated/generated-smoke.vdb";
const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? path.join(os.homedir(), "resource-library/blender-web-vdb"));
const source = path.join(resourceRoot, "generated/generated-smoke.vdb");
const secret = "vdb-server-test-signing-key-000000000000000000000000";
assert.ok(fs.existsSync(converter), "native converter is missing");
assert.ok(fs.existsSync(source), "real OpenVDB fixture is missing");

View File

@@ -17,6 +17,12 @@ fs.copyFileSync(path.join(root, "docs/web/third-party-notices.json"), path.join(
fs.copyFileSync(path.join(root, "docs/web/sbom.spdx.json"), path.join(bundle, "sbom.spdx.json"));
fs.copyFileSync(path.join(root, "docs/web/deployment-contract.json"), path.join(bundle, "deployment-contract.json"));
fs.copyFileSync(path.join(root, "docs/web/DEPLOYMENT.md"), path.join(bundle, "DEPLOYMENT.md"));
fs.copyFileSync(path.join(root, "docs/web/operations-diagnostics.json"), path.join(bundle, "operations-diagnostics.json"));
fs.copyFileSync(path.join(root, "docs/web/RELEASE_NOTES.md"), path.join(bundle, "RELEASE_NOTES.md"));
fs.copyFileSync(path.join(root, "docs/web/KNOWN_LIMITATIONS.md"), path.join(bundle, "KNOWN_LIMITATIONS.md"));
fs.copyFileSync(path.join(root, "docs/web/RELEASE_RECOVERY.md"), path.join(bundle, "RELEASE_RECOVERY.md"));
fs.copyFileSync(path.join(root, "docs/WEB_BLENDER_MODELER_V1_SCOPE.md"), path.join(bundle, "V1_SCOPE.md"));
fs.copyFileSync(path.join(root, "docs/status/parity-ledger.json"), path.join(bundle, "parity-ledger.json"));
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/opensubdiv-source/LICENSE.txt"), path.join(bundle, "LICENSE-OpenSubdiv.txt"));
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/gmp-source/COPYING.LESSERv3"), path.join(bundle, "LICENSE-GMP-LGPLv3.txt"));
fs.writeFileSync(path.join(bundle, "README.txt"), [
@@ -24,6 +30,7 @@ fs.writeFileSync(path.join(bundle, "README.txt"), [
"",
"Serve app/ from a static HTTP server that implements deployment-contract.json.",
"See DEPLOYMENT.md for the required isolation, cache, MIME, ETag and byte-range behavior.",
"See RELEASE_NOTES.md, KNOWN_LIMITATIONS.md, RELEASE_RECOVERY.md and V1_SCOPE.md for release boundaries and recovery.",
"The application has no runtime CDN dependency.",
"Opening index.html directly is unsupported because browsers restrict module Workers and WASM under file://.",
"See SOURCE_OFFER.txt and third-party-notices.json for licensing and corresponding source.",
@@ -37,6 +44,38 @@ fs.writeFileSync(path.join(bundle, "SOURCE_OFFER.txt"), [
"",
].join("\n"));
function exportedInteger(file, name) {
const source = fs.readFileSync(path.join(root, file), "utf8");
const match = source.match(new RegExp(`export const ${name} = (\\d+);`));
if (!match) throw new Error(`${file} does not export ${name} as an integer literal`);
return Number(match[1]);
}
const packageJson = JSON.parse(fs.readFileSync(path.join(root, "web/package.json"), "utf8"));
const engineManifest = JSON.parse(fs.readFileSync(path.join(root, "web/app/public/engine-manifest.json"), "utf8"));
const builtFiles = walk(path.join(root, "web/dist"));
const workerAssets = builtFiles.filter((relative) => /^assets\/.+\.worker-[A-Za-z0-9_-]+\.js$/.test(relative));
if (workerAssets.length === 0) throw new Error("production build has no content-hashed Worker assets");
fs.writeFileSync(path.join(bundle, "release-metadata.json"), `${JSON.stringify({
schemaVersion: 1,
product: "Web Blender Modeler V1",
productVersion: packageJson.version,
engineReleaseId: engineManifest.releaseId,
storage: {
indexedDbSchemaVersion: exportedInteger("web/app/src/storage/migrations.ts", "STORAGE_SCHEMA_VERSION"),
opfsProjectManifestSchemaVersion: exportedInteger("web/app/src/storage/opfs-files.ts", "OPFS_PROJECT_SCHEMA_VERSION"),
migrationDirection: "forward-only",
originBound: true,
},
app: {
workerAssets,
entryAssets: builtFiles.filter((relative) => /^assets\/index-[A-Za-z0-9_-]+\.(?:js|css)$/.test(relative)),
documentCache: "no-cache",
manifestCache: "no-cache",
hashedAssetCache: "public, max-age=31536000, immutable",
},
}, null, 2)}\n`);
function sha256(file) {
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
@@ -53,18 +92,18 @@ function walk(directory, base = directory) {
const manifest = walk(bundle).map((relative) => ({ path: relative, bytes: fs.statSync(path.join(bundle, relative)).size, sha256: sha256(path.join(bundle, relative)) }));
fs.writeFileSync(path.join(bundle, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, files: manifest }, null, 2)}\n`);
function deterministicArchive(output, cwd, entries) {
function deterministicArchive(output, cwd, entries, options = []) {
const tarPath = output.replace(/\.gz$/, "");
fs.rmSync(output, { force: true });
fs.rmSync(tarPath, { force: true });
execFileSync("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-cf", tarPath, "-C", cwd, ...entries]);
execFileSync("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", ...options, "-cf", tarPath, "-C", cwd, ...entries]);
execFileSync("gzip", ["-n", "-f", tarPath]);
}
const binaryArchive = path.join(releaseRoot, "blender-web-offline.tar.gz");
deterministicArchive(binaryArchive, releaseRoot, ["blender-web-offline"]);
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
deterministicArchive(sourceArchive, root, [
const sourceEntries = [
"blender-5.2.0",
"web/app",
"web/protocol",
@@ -74,12 +113,58 @@ deterministicArchive(sourceArchive, root, [
"web/eslint.config.js",
"web/playwright.config.ts",
"web/playwright.release.config.ts",
"web/playwright.archive.config.ts",
"web/playwright.single-thread.config.ts",
"tools/web",
"docs/web",
"docs/status",
"docs/PROJECT_STATUS_AND_NEXT_WORK.md",
"docs/WEB_BLENDER_MODELER_V1_SCOPE.md",
"docs/status/parity-ledger.json",
"WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md",
]);
];
function sourceManifestFiles(entries) {
const files = [];
const visit = (absolute, relative) => {
const stat = fs.lstatSync(absolute);
if (stat.isDirectory()) {
if (path.basename(absolute) === "__pycache__") return;
for (const name of fs.readdirSync(absolute).sort((left, right) => left.localeCompare(right))) {
visit(path.join(absolute, name), path.posix.join(relative, name));
}
return;
}
if (relative.endsWith(".pyc")) return;
if (stat.isSymbolicLink()) {
files.push({ path: relative, type: "symlink", target: fs.readlinkSync(absolute) });
return;
}
files.push({ path: relative, type: "file", bytes: stat.size, sha256: sha256(absolute) });
};
for (const entry of entries) visit(path.join(root, entry), entry);
return files;
}
const sourceManifestPath = path.join(root, "SOURCE_MANIFEST.json");
if (fs.existsSync(sourceManifestPath)) throw new Error(`refusing to replace existing ${sourceManifestPath}`);
const sourceManifest = {
schemaVersion: 1,
product: "Web Blender Modeler V1",
version: packageJson.version,
engineReleaseId: engineManifest.releaseId,
files: sourceManifestFiles(sourceEntries),
};
fs.writeFileSync(sourceManifestPath, `${JSON.stringify(sourceManifest, null, 2)}\n`);
try {
deterministicArchive(sourceArchive, root, [...sourceEntries, "SOURCE_MANIFEST.json"], [
"--exclude=*/__pycache__",
"--exclude=*/__pycache__/*",
"--exclude=*.pyc",
]);
}
finally {
fs.rmSync(sourceManifestPath, { force: true });
}
const sums = [binaryArchive, sourceArchive].map((file) => `${sha256(file)} ${path.basename(file)}`).join("\n") + "\n";
fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), sums);
process.stdout.write(`offline-release-ok binary=${fs.statSync(binaryArchive).size} source=${fs.statSync(sourceArchive).size} sha256=${sha256(binaryArchive)}\n`);

View File

@@ -0,0 +1,98 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const releaseRoot = path.join(repoRoot, "release");
const sha256Bytes = (value) => crypto.createHash("sha256").update(value).digest("hex");
const sha256 = (file) => sha256Bytes(fs.readFileSync(file));
const archiveEntry = (archive, entry) => execFileSync("tar", ["-xOf", archive, entry], { maxBuffer: 32 * 1024 * 1024 });
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package.json"), "utf8"));
const engineManifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
const binaryArchive = path.join(releaseRoot, "blender-web-offline.tar.gz");
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
const sbom = path.join(repoRoot, "docs/web/sbom.spdx.json");
const sumsPath = path.join(releaseRoot, "SHA256SUMS.txt");
const packageJsonPath = path.join(repoRoot, "web/package.json");
const packageLockPath = path.join(repoRoot, "web/package-lock.json");
const engineManifestPath = path.join(repoRoot, "web/app/public/engine-manifest.json");
const embeddedPackageJsonBytes = archiveEntry(sourceArchive, "web/package.json");
const embeddedPackageLockBytes = archiveEntry(sourceArchive, "web/package-lock.json");
const embeddedEngineManifestBytes = archiveEntry(binaryArchive, "blender-web-offline/app/engine-manifest.json");
const embeddedMetadataBytes = archiveEntry(binaryArchive, "blender-web-offline/release-metadata.json");
const embeddedSbomBytes = archiveEntry(binaryArchive, "blender-web-offline/sbom.spdx.json");
const sourceManifest = JSON.parse(archiveEntry(sourceArchive, "SOURCE_MANIFEST.json").toString("utf8"));
const embeddedMetadata = JSON.parse(embeddedMetadataBytes.toString("utf8"));
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim();
const worktreeDirty = execFileSync("git", ["status", "--porcelain", "--untracked-files=all"], {
cwd: repoRoot,
encoding: "utf8",
}).trim().length > 0;
if (!/^\d+\.\d+\.\d+-rc\.\d+$/.test(packageJson.version)) throw new Error("package version is not an RC semver");
if (engineManifest.releaseId !== `blender-wasm-${packageJson.version}`) throw new Error("engine releaseId does not match RC semver");
if (embeddedMetadata.productVersion !== packageJson.version || embeddedMetadata.engineReleaseId !== engineManifest.releaseId) {
throw new Error("binary release metadata does not match the frozen RC identity");
}
if (sourceManifest.version !== packageJson.version || sourceManifest.engineReleaseId !== engineManifest.releaseId) {
throw new Error("source manifest does not match the frozen RC identity");
}
for (const [label, actual, expected] of [
["source package.json", embeddedPackageJsonBytes, fs.readFileSync(packageJsonPath)],
["source package-lock.json", embeddedPackageLockBytes, fs.readFileSync(packageLockPath)],
["binary engine manifest", embeddedEngineManifestBytes, fs.readFileSync(engineManifestPath)],
["binary SBOM", embeddedSbomBytes, fs.readFileSync(sbom)],
]) {
if (!actual.equals(expected)) throw new Error(`${label} does not match the frozen workspace bytes`);
}
const checksumEntries = fs.readFileSync(sumsPath, "utf8").trim().split("\n").map((line) => line.match(/^([a-f0-9]{64}) ([^/]+)$/));
if (checksumEntries.some((entry) => entry === null) || checksumEntries.length !== 2) {
throw new Error("SHA256SUMS.txt must contain exactly two canonical archive entries");
}
const checksums = new Map(checksumEntries.map((entry) => [entry[2], entry[1]]));
for (const file of [binaryArchive, sourceArchive]) {
if (checksums.get(path.basename(file)) !== sha256(file)) throw new Error(`SHA256SUMS.txt does not match ${path.basename(file)}`);
}
const artifact = (file, relative) => ({
path: relative,
bytes: fs.statSync(file).size,
sha256: sha256(file),
});
const operations = ["deploy", "upgrade", "rollback", "diagnostics", "rehearsal"]
.map((name) => [name, path.join(releaseRoot, "operations-reports", `${name}.json`)]);
for (const [name, file] of operations) {
if (!fs.existsSync(file)) throw new Error(`RC operations report is missing: ${name}`);
}
const manifest = {
schemaVersion: 1,
rcId: `web-blender-${packageJson.version}`,
semver: packageJson.version,
gitCommit: commit,
sourceBinding: {
mode: "corresponding-source-archive",
worktreeDirty,
note: "The source archive SHA-256 binds the candidate bytes independently of the base commit.",
},
engineReleaseId: engineManifest.releaseId,
artifacts: {
binaryArchive: artifact(binaryArchive, "release/blender-web-offline.tar.gz"),
sourceArchive: artifact(sourceArchive, "release/blender-web-corresponding-source.tar.gz"),
sbom: artifact(sbom, "docs/web/sbom.spdx.json"),
},
bindings: {
packageJsonSha256: sha256(packageJsonPath),
packageLockSha256: sha256(packageLockPath),
ledgerSha256: sha256(path.join(repoRoot, "docs/status/parity-ledger.json")),
engineManifestSha256: sha256(engineManifestPath),
embeddedReleaseMetadataSha256: sha256Bytes(embeddedMetadataBytes),
sha256SumsSha256: sha256(sumsPath),
},
operations: Object.fromEntries(operations.map(([name, file]) => [name, artifact(file, path.relative(repoRoot, file).replaceAll(path.sep, "/"))])),
};
const output = path.join(releaseRoot, "RC_MANIFEST.json");
fs.writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`);
fs.writeFileSync(`${output}.sha256`, `${sha256(output)} ${path.basename(output)}\n`);
process.stdout.write(`rc-manifest-ok id=${manifest.rcId} commit=${commit} binary=${manifest.artifacts.binaryArchive.sha256} source=${manifest.artifacts.sourceArchive.sha256} sbom=${manifest.artifacts.sbom.sha256}\n`);

View File

@@ -0,0 +1,166 @@
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export function loadDeploymentContract(
contractPath = path.join(repoRoot, "docs/web/deployment-contract.json"),
) {
return JSON.parse(fs.readFileSync(contractPath, "utf8"));
}
function cacheControl(pathname, routes) {
for (const route of routes) {
for (const pattern of route.patterns) {
if (pattern === "*" || pattern === pathname ||
(pattern.endsWith("*") && pathname.startsWith(pattern.slice(0, -1)))) {
return route.cacheControl;
}
}
}
return "no-cache";
}
function resolvedFile(root, pathname) {
let decoded;
try {
decoded = decodeURIComponent(pathname);
}
catch {
return null;
}
if (decoded.includes("\0")) return null;
const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
const filePath = path.resolve(root, relative);
const rootPrefix = `${path.resolve(root)}${path.sep}`;
if (!filePath.startsWith(rootPrefix)) return null;
return filePath;
}
function strongEtag(filePath) {
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
return `"sha256-${hash}"`;
}
function matchesIfNoneMatch(value, etag) {
if (typeof value !== "string") return false;
return value === "*" || value.split(",").some((candidate) => candidate.trim() === etag);
}
function parseRange(value, size) {
if (typeof value !== "string" || !value.startsWith("bytes=") || value.includes(",")) return null;
const match = value.match(/^bytes=(\d*)-(\d*)$/);
if (!match || (!match[1] && !match[2])) return null;
if (!match[1]) {
const suffixLength = Number(match[2]);
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0 || suffixLength > size) return null;
return { start: size - suffixLength, end: size - 1 };
}
const start = Number(match[1]);
const end = match[2] ? Number(match[2]) : size - 1;
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) ||
start < 0 || start >= size || end < start || end >= size) return null;
return { start, end };
}
function responseHeaders(contract, pathname, filePath, stat) {
const extension = path.extname(filePath).toLowerCase();
const mime = contract.mimeTypes[extension];
if (!mime) return null;
return {
...contract.responseHeaders.allResponses,
"Cache-Control": cacheControl(pathname, contract.responseHeaders.routes),
"Content-Type": mime,
"Content-Length": String(stat.size),
};
}
export function createDeploymentHttpServer({ root, contract = loadDeploymentContract() }) {
const staticRoot = path.resolve(root);
return http.createServer((request, response) => {
const requestUrl = new URL(request.url ?? "/", "http://deployment.local");
const pathname = requestUrl.pathname;
const commonHeaders = { ...contract.responseHeaders.allResponses, "Cache-Control": "no-cache" };
if (!contract.methods.includes(request.method)) {
response.writeHead(405, { ...commonHeaders, Allow: contract.methods.join(", "), "Content-Length": "0" });
response.end();
return;
}
const filePath = resolvedFile(staticRoot, pathname);
if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
response.writeHead(404, { ...commonHeaders, "Content-Length": "0" });
response.end();
return;
}
const stat = fs.statSync(filePath);
const headers = responseHeaders(contract, pathname, filePath, stat);
if (!headers) {
response.writeHead(415, { ...commonHeaders, "Content-Length": "0" });
response.end();
return;
}
const extension = path.extname(filePath).toLowerCase();
const rangeEnabled = contract.rangeRequests.extensions.includes(extension);
const etag = strongEtag(filePath);
headers.ETag = etag;
if (rangeEnabled) headers["Accept-Ranges"] = contract.rangeRequests.unit;
if (matchesIfNoneMatch(request.headers["if-none-match"], etag)) {
response.writeHead(304, { ...headers, "Content-Length": "0" });
response.end();
return;
}
const requestedRange = request.headers.range;
const ifRangeMatches = !request.headers["if-range"] || request.headers["if-range"] === etag;
if (requestedRange && rangeEnabled && ifRangeMatches) {
const range = parseRange(requestedRange, stat.size);
if (!range) {
response.writeHead(contract.rangeRequests.unsatisfiedStatus, {
...headers,
"Content-Range": `bytes */${stat.size}`,
"Content-Length": "0",
});
response.end();
return;
}
const length = range.end - range.start + 1;
response.writeHead(contract.rangeRequests.satisfiedStatus, {
...headers,
"Content-Range": `bytes ${range.start}-${range.end}/${stat.size}`,
"Content-Length": String(length),
});
if (request.method === "HEAD") response.end();
else fs.createReadStream(filePath, { start: range.start, end: range.end }).pipe(response);
return;
}
response.writeHead(200, headers);
if (request.method === "HEAD") response.end();
else fs.createReadStream(filePath).pipe(response);
});
}
export async function listenDeploymentHttpServer(server, { host = "127.0.0.1", port = 0 } = {}) {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("deployment server did not bind a TCP port");
return `http://${host}:${address.port}`;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const root = path.resolve(process.argv[2] ?? path.join(repoRoot, "web/dist"));
const port = Number.parseInt(process.env.BLENDER_WEB_HTTP_PORT ?? "8080", 10);
const server = createDeploymentHttpServer({ root });
const origin = await listenDeploymentHttpServer(server, { port });
process.stdout.write(`deployment-http-server ${origin} root=${root}\n`);
}

View File

@@ -2,18 +2,51 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source_dir="${repo_root}/build_web_blender6/bin"
target_dir="${repo_root}/web/app/public/vendor/blender"
source_vendor_dir="${repo_root}/web/app/src/vendor/blender"
mkdir -p "${target_dir}"
mkdir -p "${source_vendor_dir}"
test -s "${source_dir}/web_engine.js" && test -s "${source_dir}/web_engine.wasm"
cp "${source_dir}/web_engine.js" "${target_dir}/web_engine.js"
cp "${source_dir}/web_engine.wasm" "${target_dir}/web_engine.wasm"
cp "${source_dir}/web_engine.js" "${source_vendor_dir}/web_engine.js"
cp "${source_dir}/web_engine.wasm" "${source_vendor_dir}/web_engine.wasm"
wasm_hash="$(sha256sum "${target_dir}/web_engine.wasm" | cut -d ' ' -f 1)"
single_build_dir="${WEB_ENGINE_SINGLE_BUILD_DIR:-${repo_root}/build_web-single}"
pthread_build_dir="${WEB_ENGINE_PTHREAD_BUILD_DIR:-${repo_root}/build_web-pthread}"
public_root="${WEB_ENGINE_PUBLIC_ROOT:-${repo_root}/web/app/public}"
manifest_path="${WEB_ENGINE_MANIFEST_PATH:-${public_root}/engine-manifest.json}"
if [[ "$(realpath -m "${single_build_dir}")" == "$(realpath -m "${pthread_build_dir}")" ]]; then
echo "single and pthread variants must come from independent build directories" >&2
exit 1
fi
for build_dir in "${single_build_dir}" "${pthread_build_dir}"; do
test -s "${build_dir}/web_engine.js"
test -s "${build_dir}/web_engine.wasm"
done
test -s "${manifest_path}"
staging_dir="$(mktemp -d "${TMPDIR:-/tmp}/blender-web-engine-install.XXXXXX")"
trap 'rm -rf "${staging_dir}"' EXIT
for variant in single pthread; do
mkdir -p "${staging_dir}/vendor/blender/${variant}"
done
cp "${single_build_dir}/web_engine.js" "${staging_dir}/vendor/blender/single/web_engine.js"
cp "${single_build_dir}/web_engine.wasm" "${staging_dir}/vendor/blender/single/web_engine.wasm"
cp "${pthread_build_dir}/web_engine.js" "${staging_dir}/vendor/blender/pthread/web_engine.js"
cp "${pthread_build_dir}/web_engine.wasm" "${staging_dir}/vendor/blender/pthread/web_engine.wasm"
cp "${manifest_path}" "${staging_dir}/engine-manifest.json"
node "${repo_root}/tools/web/update-engine-manifest.mjs" \
"${repo_root}/web/app/public/engine-manifest.json" \
"${wasm_hash}"
sha256sum "${target_dir}/web_engine.js" "${target_dir}/web_engine.wasm"
"${staging_dir}/engine-manifest.json" \
"${staging_dir}/vendor/blender/single" \
"${staging_dir}/vendor/blender/pthread"
node "${repo_root}/tools/web/check-manifest-assets.mjs" \
"${staging_dir}/engine-manifest.json" "${staging_dir}"
for variant in single pthread; do
mkdir -p "${public_root}/vendor/blender/${variant}"
install -m 0644 "${staging_dir}/vendor/blender/${variant}/web_engine.js" \
"${public_root}/vendor/blender/${variant}/web_engine.js"
install -m 0644 "${staging_dir}/vendor/blender/${variant}/web_engine.wasm" \
"${public_root}/vendor/blender/${variant}/web_engine.wasm"
done
install -m 0644 "${staging_dir}/engine-manifest.json" "${manifest_path}"
sha256sum \
"${public_root}/vendor/blender/single/web_engine.js" \
"${public_root}/vendor/blender/single/web_engine.wasm" \
"${public_root}/vendor/blender/pthread/web_engine.js" \
"${public_root}/vendor/blender/pthread/web_engine.wasm"

186
tools/web/run-ci-lane.mjs Normal file
View File

@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const releaseRoot = path.join(repoRoot, "release");
const lane = process.argv[2];
const selfTestFailure = process.argv.includes("--self-test-failure");
assert.ok(["quick", "chromium", "release"].includes(lane), "usage: run-ci-lane.mjs <quick|chromium|release>");
const laneCommands = {
quick: [
["lockfile-install", "npm", ["--prefix", "web", "ci", "--ignore-scripts"]],
["typecheck", "npm", ["--prefix", "web", "run", "typecheck"]],
["lint", "npm", ["--prefix", "web", "run", "lint"]],
["unit", "npm", ["--prefix", "web", "test"]],
["status", "npm", ["--prefix", "web", "run", "test:status-consistency"]],
["evidence-schema", "npm", ["--prefix", "web", "run", "test:release-evidence"]],
["rc-docs", "npm", ["--prefix", "web", "run", "test:rc-docs"]],
],
chromium: [
["lockfile-install", "npm", ["--prefix", "web", "ci", "--ignore-scripts"]],
["p0-user-loop", "npm", ["--prefix", "web", "run", "test:v1-user-loop"], "browser"],
["offline-browser-smoke", "npm", ["--prefix", "web", "run", "test:browser"], "browser"],
["network-interruption", "npm", ["--prefix", "web", "run", "test:network-interruption"], "browser"],
["device-loss", "npm", ["--prefix", "web", "run", "test:device-loss"], "browser"],
["oom-recovery", "npm", ["--prefix", "web", "run", "test:oom-recovery"], "browser"],
["opfs-quota", "npm", ["--prefix", "web", "run", "test:storage-quota"], "browser"],
["malicious-blend", "npm", ["--prefix", "web", "run", "test:malicious-blends"]],
["malicious-archive", "npm", ["--prefix", "web", "run", "test:asset-library"], "browser"],
],
release: [
["lockfile-install", "npm", ["--prefix", "web", "ci", "--ignore-scripts"]],
["full-e2e", "npm", ["--prefix", "web", "run", "test:e2e", "--", "--workers=1"], "browser"],
["performance-100k-1m", "npm", ["--prefix", "web", "run", "test:release-performance"]],
["performance-10m", "npm", ["--prefix", "web", "run", "test:geometry-10m-performance"], "browser"],
["performance-texture-4k", "npm", ["--prefix", "web", "run", "test:texture-4k-performance"], "browser"],
["performance-texture-8k", "npm", ["--prefix", "web", "run", "test:texture-8k-performance"], "browser"],
["performance-simulation", "npm", ["--prefix", "web", "run", "test:simulation-cache-performance"], "browser"],
["performance-long-media", "npm", ["--prefix", "web", "run", "test:long-media-performance"], "browser"],
["vdb-native", "npm", ["--prefix", "web", "run", "test:vdb-native"]],
["vdb-server", "npm", ["--prefix", "web", "run", "test:vdb-server"]],
["vdb-opfs", "npm", ["--prefix", "web", "run", "test:vdb-opfs"], "browser"],
["vdb-webgpu", "npm", ["--prefix", "web", "run", "test:vdb-webgpu"], "browser"],
["vdb-viewport", "npm", ["--prefix", "web", "run", "test:vdb-viewport"], "browser"],
["vdb-faults", "npm", ["--prefix", "web", "run", "test:vdb-faults"], "browser"],
["v1-acceptance", "npm", ["--prefix", "web", "run", "release:v1-acceptance"], "acceptance"],
["offline-reproducibility", "npm", ["--prefix", "web", "run", "release:offline"]],
["archive-browser", "npm", ["--prefix", "web", "run", "test:archive-offline"], "browser"],
["binary-archive", "npm", ["--prefix", "web", "run", "test:binary-archive"]],
["source-archive", "npm", ["--prefix", "web", "run", "test:source-archive"]],
["deployment-runbook", "npm", ["--prefix", "web", "run", "test:deployment-runbook"]],
["upgrade-runbook", "npm", ["--prefix", "web", "run", "test:upgrade-runbook"]],
["rollback-runbook", "npm", ["--prefix", "web", "run", "test:rollback-runbook"]],
["operations-diagnostics", "npm", ["--prefix", "web", "run", "test:operations-diagnostics"]],
["operations-rehearsal", "npm", ["--prefix", "web", "run", "test:operations-rehearsal"]],
["rc-manifest", "npm", ["--prefix", "web", "run", "release:rc-manifest"]],
],
};
const commands = selfTestFailure
? [["injected-failure", process.execPath, ["-e", "process.stderr.write('injected CI lane failure\\n'); process.exit(17)"]]]
: laneCommands[lane];
const reportPath = path.resolve(process.env.CI_REPORT_PATH ?? path.join(releaseRoot, "ci-reports", `${lane}.json`));
const logRoot = path.join(path.dirname(reportPath), `${path.basename(reportPath, ".json")}-logs`);
fs.mkdirSync(logRoot, { recursive: true });
const digest = (value) => crypto.createHash("sha256").update(value).digest("hex");
const fileDigest = (file) => fs.existsSync(file) && fs.statSync(file).isFile() ? digest(fs.readFileSync(file)) : null;
function capture(command, args) {
const result = spawnSync(command, args, { cwd: repoRoot, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }, maxBuffer: 8 * 1024 * 1024 });
return result.status === 0 ? `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() : "unavailable";
}
async function freePortRange(length = 1) {
for (let attempt = 0; attempt < 100; attempt++) {
const base = 20_000 + Math.floor(Math.random() * (40_000 - length));
const servers = [];
try {
for (let offset = 0; offset < length; offset++) {
const server = net.createServer();
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(base + offset, "127.0.0.1", resolve);
});
servers.push(server);
}
return base;
}
catch {
// Try another contiguous range.
}
finally {
await Promise.all(servers.map((server) => new Promise((resolve) => server.close(resolve))));
}
}
throw new Error(`unable to allocate ${length} loopback port(s)`);
}
function artifactBindings() {
const files = {
packageJson: "web/package.json",
lockfile: "web/package-lock.json",
ledger: "docs/status/parity-ledger.json",
engineManifest: "web/app/public/engine-manifest.json",
releaseNotes: "docs/web/RELEASE_NOTES.md",
knownLimitations: "docs/web/KNOWN_LIMITATIONS.md",
releaseRecovery: "docs/web/RELEASE_RECOVERY.md",
sbom: "docs/web/sbom.spdx.json",
binaryArchive: "release/blender-web-offline.tar.gz",
sourceArchive: "release/blender-web-corresponding-source.tar.gz",
sha256Sums: "release/SHA256SUMS.txt",
rcManifest: "release/RC_MANIFEST.json",
};
return Object.fromEntries(Object.entries(files).map(([name, relative]) => {
const releaseOnly = ["binaryArchive", "sourceArchive", "sha256Sums", "rcManifest"].includes(name);
return [name, { path: relative, sha256: releaseOnly && lane !== "release" ? null : fileDigest(path.join(repoRoot, relative)) }];
}));
}
const environment = {
os: `${os.platform()} ${os.release()} ${os.arch()}`,
node: process.version,
npm: capture("npm", ["--version"]),
chrome: capture(process.env.CHROME_PATH ?? "google-chrome", ["--version"]),
blender: capture(process.env.BLENDER_BIN ?? "blender", ["--version"]).split("\n")[0],
emscripten: capture("bash", ["-lc", "source tools/web/emscripten-env.sh >/dev/null 2>&1 && emcc --version | head -1"]),
};
const commit = capture("git", ["rev-parse", "HEAD"]);
const records = [];
for (const [index, definition] of commands.entries()) {
const [id, command, args, portMode] = definition;
const env = { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" };
let port;
if (portMode === "browser") {
port = await freePortRange();
env.WEB_TEST_PORT = String(port);
}
else if (portMode === "acceptance") {
port = await freePortRange(80);
env.WEB_ACCEPTANCE_PORT = String(port);
}
const started = Date.now();
const result = spawnSync(command, args, { cwd: repoRoot, encoding: "utf8", env, maxBuffer: 64 * 1024 * 1024 });
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
const logPath = path.join(logRoot, `${String(index + 1).padStart(2, "0")}-${id}.log`);
fs.writeFileSync(logPath, `${output}\n`);
const record = {
id,
command: [command, ...args].join(" "),
exitCode: result.status ?? 1,
durationMs: Date.now() - started,
...(port ? { loopbackPort: port } : {}),
output: { path: path.relative(repoRoot, logPath).replaceAll(path.sep, "/"), bytes: fs.statSync(logPath).size, sha256: fileDigest(logPath) },
};
records.push(record);
process.stdout.write(`[${lane} ${index + 1}/${commands.length}] ${id} exit=${record.exitCode} durationMs=${record.durationMs}${port ? ` port=${port}` : ""}\n`);
if (record.exitCode !== 0) break;
}
const status = records.length === commands.length && records.every((record) => record.exitCode === 0) ? "READY" : "FAILED";
const report = {
schemaVersion: 1,
lane,
status,
generatedAt: new Date().toISOString(),
commit,
environment,
bindings: artifactBindings(),
records,
retentionDays: lane === "quick" ? 7 : lane === "chromium" ? 14 : 30,
};
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
const temporaryReport = `${reportPath}.tmp-${process.pid}`;
fs.writeFileSync(temporaryReport, `${JSON.stringify(report, null, 2)}\n`);
fs.renameSync(temporaryReport, reportPath);
fs.writeFileSync(`${reportPath}.sha256`, `${fileDigest(reportPath)} ${path.basename(reportPath)}\n`);
process.stdout.write(`ci-lane-report lane=${lane} status=${status} records=${records.length} path=${reportPath}\n`);
if (status !== "READY") process.exitCode = 1;

View File

@@ -0,0 +1,191 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
createDeploymentHttpServer,
listenDeploymentHttpServer,
} from "./deployment-http-server.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const releaseRoot = path.join(repoRoot, "release");
const archive = path.join(releaseRoot, "blender-web-offline.tar.gz");
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
const sumsFile = path.join(releaseRoot, "SHA256SUMS.txt");
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-operations-rehearsal-"));
assert.equal(path.dirname(workspace), os.tmpdir());
assert.ok(path.basename(workspace).startsWith("blender-operations-rehearsal-"));
const records = [];
let failure = null;
let server = null;
let projectBefore = null;
let projectAfter = null;
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
const fileSha256 = (file) => sha256(fs.readFileSync(file));
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
async function run(id, command, action) {
const started = Date.now();
try {
const output = await action();
records.push({ id, command, exitCode: 0, durationMs: Date.now() - started, output });
return output;
}
catch (error) {
records.push({
id,
command,
exitCode: 1,
durationMs: Date.now() - started,
output: { error: error instanceof Error ? error.message : String(error) },
});
throw error;
}
}
function switchCurrent(installRoot, releaseName) {
const pending = path.join(installRoot, ".current.next");
fs.rmSync(pending, { force: true });
fs.symlinkSync(path.join("releases", releaseName), pending);
fs.renameSync(pending, path.join(installRoot, "current"));
}
async function validateInstalledHttp(installRoot) {
const bundle = path.join(installRoot, "current");
const contract = readJson(path.join(bundle, "deployment-contract.json"));
const engine = readJson(path.join(bundle, "app/engine-manifest.json"));
server = createDeploymentHttpServer({ root: path.join(bundle, "app"), contract });
const origin = await listenDeploymentHttpServer(server);
try {
const head = await fetch(`${origin}/`, { method: "HEAD" });
assert.equal(head.status, 200);
for (const [name, value] of Object.entries(contract.responseHeaders.allResponses)) {
assert.equal(head.headers.get(name), value);
}
const single = engine.variants.find((variant) => variant.id === "single");
const range = await fetch(new URL(single.resources.wasm.url, origin), { headers: { Range: "bytes=0-15" } });
assert.equal(range.status, 206);
assert.equal((await range.arrayBuffer()).byteLength, 16);
return { originMode: "loopback-http", headStatus: head.status, rangeStatus: range.status, releaseId: engine.releaseId };
}
finally {
await new Promise((resolve) => server.close(resolve));
server = null;
}
}
try {
const delivery = path.join(workspace, "delivery");
const installRoot = path.join(workspace, "install");
const originStorage = path.join(workspace, "origin-storage", "projects", "rehearsal-project");
fs.mkdirSync(delivery);
fs.mkdirSync(installRoot);
await run("delivery-check", "sha256sum -c SHA256SUMS.txt && tar -tzf blender-web-offline.tar.gz", async () => {
for (const file of [archive, sourceArchive, sumsFile]) fs.copyFileSync(file, path.join(delivery, path.basename(file)));
const entries = Object.fromEntries(fs.readFileSync(path.join(delivery, "SHA256SUMS.txt"), "utf8").trim().split("\n").map((line) => {
const match = line.match(/^([a-f0-9]{64}) ([^/]+)$/);
assert.ok(match, `invalid checksum entry: ${line}`);
return [match[2], match[1]];
}));
assert.equal(fileSha256(path.join(delivery, path.basename(archive))), entries[path.basename(archive)]);
assert.equal(fileSha256(path.join(delivery, path.basename(sourceArchive))), entries[path.basename(sourceArchive)]);
const archiveEntries = execFileSync("tar", ["-tzf", path.join(delivery, path.basename(archive))], { encoding: "utf8" }).split("\n").filter(Boolean);
assert.ok(archiveEntries.length > 0);
for (const entry of archiveEntries) {
assert.ok(!entry.startsWith("/") && !entry.split("/").includes(".."));
assert.ok(entry === "blender-web-offline/" || entry.startsWith("blender-web-offline/"));
}
return { binarySha256: entries[path.basename(archive)], sourceSha256: entries[path.basename(sourceArchive)], archiveEntries: archiveEntries.length };
});
const installed = await run("deploy-install", "tar --no-same-owner --no-same-permissions -xzf ARCHIVE -C STAGE && mv STAGE RELEASE && mv -Tf .current.next current", async () => {
const releases = path.join(installRoot, "releases");
fs.mkdirSync(releases);
const staging = fs.mkdtempSync(path.join(installRoot, ".install."));
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", path.join(delivery, path.basename(archive)), "-C", staging]);
const releaseName = fileSha256(path.join(delivery, path.basename(archive)));
fs.renameSync(path.join(staging, "blender-web-offline"), path.join(releases, releaseName));
fs.rmdirSync(staging);
switchCurrent(installRoot, releaseName);
return { releaseName, currentTarget: fs.readlinkSync(path.join(installRoot, "current")), emptyInstallRoot: true };
});
await run("deploy-http", "HEAD / && GET Range:bytes=0-15 single/web_engine.wasm", () => validateInstalledHttp(installRoot));
await run("project-seed", "persist project revision and SHA-256 outside release directories", async () => {
fs.mkdirSync(originStorage, { recursive: true });
const bytes = Buffer.from("M6-17E rehearsal project bytes");
const manifest = { schemaVersion: 1, revision: 23, bytes: bytes.length, sha256: sha256(bytes) };
fs.writeFileSync(path.join(originStorage, "scene.blend"), bytes);
fs.writeFileSync(path.join(originStorage, "scene.blend.meta.json"), `${JSON.stringify(manifest)}\n`);
projectBefore = manifest;
return manifest;
});
const upgraded = await run("upgrade-switch", "stage NEXT_RELEASE && mv -Tf .current.next current", async () => {
const oldRelease = path.join(installRoot, "releases", installed.releaseName);
const nextName = `${installed.releaseName}-next`;
const nextRelease = path.join(installRoot, "releases", nextName);
fs.cpSync(oldRelease, nextRelease, { recursive: true });
const metadataPath = path.join(nextRelease, "release-metadata.json");
const metadata = readJson(metadataPath);
metadata.productVersion = `${metadata.productVersion}-rehearsal-next`;
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
switchCurrent(installRoot, nextName);
assert.equal(fileSha256(path.join(originStorage, "scene.blend")), projectBefore.sha256);
return { from: installed.releaseName, to: nextName, oldReleaseRetained: fs.existsSync(oldRelease) };
});
await run("upgrade-http", "HEAD / && GET Range:bytes=0-15 after upgrade", () => validateInstalledHttp(installRoot));
await run("rollback-switch", "preflight schemas && mv -Tf .current.next current", async () => {
const activeMetadata = readJson(path.join(installRoot, "current", "release-metadata.json"));
const targetMetadata = readJson(path.join(installRoot, "releases", installed.releaseName, "release-metadata.json"));
assert.equal(activeMetadata.storage.indexedDbSchemaVersion, targetMetadata.storage.indexedDbSchemaVersion);
assert.equal(activeMetadata.storage.opfsProjectManifestSchemaVersion, targetMetadata.storage.opfsProjectManifestSchemaVersion);
switchCurrent(installRoot, installed.releaseName);
projectAfter = readJson(path.join(originStorage, "scene.blend.meta.json"));
assert.deepEqual(projectAfter, projectBefore);
assert.equal(fileSha256(path.join(originStorage, "scene.blend")), projectBefore.sha256);
return { from: upgraded.to, to: installed.releaseName, projectRevision: projectAfter.revision, projectSha256: projectAfter.sha256 };
});
await run("rollback-http", "HEAD / && GET Range:bytes=0-15 after rollback", () => validateInstalledHttp(installRoot));
}
catch (error) {
failure = error;
}
finally {
if (server) await new Promise((resolve) => server.close(resolve));
fs.rmSync(workspace, { recursive: true, force: true });
}
const report = {
schemaVersion: 1,
task: "M6-17E",
status: failure ? "FAILED" : "READY",
generatedAt: new Date().toISOString(),
freshTemporaryRoot: true,
oneContinuousWorkspace: true,
workspaceCleaned: !fs.existsSync(workspace),
project: projectBefore && projectAfter ? {
revisionBefore: projectBefore.revision,
revisionAfter: projectAfter.revision,
sha256Before: projectBefore.sha256,
sha256After: projectAfter.sha256,
preserved: projectBefore.revision === projectAfter.revision && projectBefore.sha256 === projectAfter.sha256,
} : null,
records,
};
const reportRoot = path.join(releaseRoot, "operations-reports");
fs.mkdirSync(reportRoot, { recursive: true });
const reportPath = path.join(reportRoot, "rehearsal.json");
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
fs.writeFileSync(`${reportPath}.sha256`, `${fileSha256(reportPath)} ${path.basename(reportPath)}\n`);
process.stdout.write(`operations-rehearsal status=${report.status} records=${records.length} project=${report.project?.preserved === true ? "preserved" : "unverified"} cleanup=${report.workspaceCleaned}\n`);
if (failure) throw failure;

View File

@@ -31,7 +31,7 @@ if (blenderArchiveSha256 && !/^[a-f0-9]{64}$/.test(blenderArchiveSha256)) {
const environment = {
node: process.version,
npm: capture("npm", ["--version"]).split(/\r?\n/)[0],
chromium: capture(process.env.CHROME_PATH ?? "/home/mes123456/.local/bin/google-chrome", ["--version"]).split(/\r?\n/)[0],
chromium: capture(process.env.CHROME_PATH ?? "google-chrome", ["--version"]).split(/\r?\n/)[0],
blenderBin: process.env.BLENDER_BIN ? "BLENDER_BIN" : blenderBin,
blenderExecutableSha256: fs.existsSync(blenderBin) ? sha256(fs.readFileSync(blenderBin)) : "unavailable",
blenderArchiveSha256: blenderArchiveSha256 || "unavailable",

View File

@@ -1,12 +1,51 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const [manifestPath, wasmHash] = process.argv.slice(2);
if (!manifestPath || !/^[a-f0-9]{64}$/.test(wasmHash ?? "")) {
throw new Error("usage: update-engine-manifest.mjs <manifest.json> <wasm-sha256>");
const [manifestPath, singleDirectory, pthreadDirectory] = process.argv.slice(2);
if (!manifestPath || !singleDirectory || !pthreadDirectory) {
throw new Error(
"usage: update-engine-manifest.mjs <manifest.json> <single-directory> <pthread-directory>",
);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const resource = manifest.wasm?.find((entry) => entry.id === "web-engine-bootstrap");
if (!resource) throw new Error("web-engine-bootstrap resource is missing from the engine manifest");
resource.sha256 = wasmHash;
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
if (typeof manifest.engineVersion !== "string" || manifest.engineVersion.length === 0) {
throw new Error("engineVersion is missing from the existing engine manifest");
}
function resource(directory, variant, fileName) {
const filePath = path.join(directory, fileName);
const bytes = fs.readFileSync(filePath);
if (bytes.byteLength === 0) throw new Error(`${variant} ${fileName} is empty`);
return {
fileName,
url: `/vendor/blender/${variant}/${fileName}`,
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
};
}
const singleJs = resource(singleDirectory, "single", "web_engine.js");
const singleWasm = resource(singleDirectory, "single", "web_engine.wasm");
const pthreadJs = resource(pthreadDirectory, "pthread", "web_engine.js");
const pthreadWasm = resource(pthreadDirectory, "pthread", "web_engine.wasm");
const updated = {
schemaVersion: 2,
protocolVersion: 1,
releaseId: typeof manifest.releaseId === "string" ? manifest.releaseId : manifest.engineVersion,
engineVersion: manifest.engineVersion,
engine: "blender-wasm",
variants: [
{
id: "single",
memory: { initialPages: 256, maximumPages: 32768, shared: false },
resources: { js: singleJs, wasm: singleWasm },
},
{
id: "pthread",
memory: { initialPages: 256, maximumPages: 32768, shared: true },
resources: { js: pthreadJs, wasm: pthreadWasm, pthreadWorker: pthreadJs },
},
],
};
fs.writeFileSync(manifestPath, `${JSON.stringify(updated, null, 2)}\n`);

View File

@@ -1,20 +1,54 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"protocolVersion": 1,
"engineVersion": "blender-wasm-0.1.0",
"releaseId": "blender-wasm-0.1.0-rc.1",
"engineVersion": "blender-wasm-0.1.0-rc.1",
"engine": "blender-wasm",
"memory": {
"initialPages": 256,
"maximumPages": 32768,
"shared": false
},
"wasm": [
"variants": [
{
"id": "web-engine-bootstrap",
"fileName": "web_engine.wasm",
"url": "/vendor/blender/web_engine.wasm",
"sha256": "5d87a9ea57bd7a1888f16a5f4306e1c6d3a1bdfcf832c9485fe8518aac528fd8",
"required": true
"id": "single",
"memory": {
"initialPages": 256,
"maximumPages": 32768,
"shared": false
},
"resources": {
"js": {
"fileName": "web_engine.js",
"url": "/vendor/blender/single/web_engine.js",
"sha256": "eedb8cedeb2190fbece91d58cd5dd568356caf42191517aff2085911177157ca"
},
"wasm": {
"fileName": "web_engine.wasm",
"url": "/vendor/blender/single/web_engine.wasm",
"sha256": "f079e2221b501eee7f0b8e3290c22f37210ba44ee7a392c7fa817fde16b9ee5e"
}
}
},
{
"id": "pthread",
"memory": {
"initialPages": 256,
"maximumPages": 32768,
"shared": true
},
"resources": {
"js": {
"fileName": "web_engine.js",
"url": "/vendor/blender/pthread/web_engine.js",
"sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837"
},
"wasm": {
"fileName": "web_engine.wasm",
"url": "/vendor/blender/pthread/web_engine.wasm",
"sha256": "a6bed7b410b15f0d5aca229d1e926bff861e3ff16f0d3d6abc1bc73bcaf5fc0b"
},
"pthreadWorker": {
"fileName": "web_engine.js",
"url": "/vendor/blender/pthread/web_engine.js",
"sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837"
}
}
}
]
}

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { WebEngineClient } from "../engine-client/WebEngineClient";
import { loadWebEngineManifest, verifyWasmResource } from "../../../protocol/manifest";
import type { ProgressEvent } from "../../../protocol/progress";
@@ -32,6 +33,11 @@ import {
} from "../volume/nanovdb-viewport";
import { composePaintColorPatch, composePaintWeightPatch } from "../../../protocol/paint";
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import { createInitialUserActionStates, reduceUserActionStates, type UserActionIdentity, type UserActionKind } from "../../../protocol/user-action-state";
import { acquireProjectAction, createProjectActionMutexState, releaseProjectAction, type ProjectActionConflict, type ProjectActionIdentity } from "../../../protocol/project-action-mutex";
import { DEFAULT_FILE_READ_YIELD_BYTES, FileByteReadError, readFileBytes, type FileReadPhase } from "../../../protocol/file-byte-reader";
import { advanceSaveTransaction, beginSaveTransaction, commitSaveTransaction, createSaveTransactionState, failSaveTransaction, type SaveTransactionState } from "../../../protocol/save-transaction";
import { acceptHistoryTransaction, acceptMainSave, acceptMainTransaction, createDirtyState, recoverDirtyState } from "../../../protocol/dirty-state";
import "./app-shell.css";
function errorMessage(error: unknown): string {
@@ -48,6 +54,26 @@ async function sha256Hex(data: ArrayBuffer): Promise<string> {
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
}
const LAST_PROJECT_STORAGE_KEY = "blender-web:last-project-id";
function loadLastProjectId(): string {
try {
return localStorage.getItem(LAST_PROJECT_STORAGE_KEY) || "untitled";
}
catch {
return "untitled";
}
}
function rememberLastProjectId(projectId: string): void {
try {
localStorage.setItem(LAST_PROJECT_STORAGE_KEY, projectId);
}
catch {
// Project recovery still works in the current session when localStorage is unavailable.
}
}
interface AreaProps {
className?: string;
editor: EditorType;
@@ -366,7 +392,7 @@ function Outliner({ snapshot, onSelect, onToggleVisibility }: {
{collection.objectIds.map((objectId) => {
const node = nodeById.get(objectId);
if (!node) return null;
return <div key={node.id} role="button" tabIndex={0} onClick={(event) => onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey); }} className={`tree-row child${node.id === snapshot?.activeObjectId ? " selected" : ""}`}><span>·</span><span className={`tree-icon ${node.type.toLowerCase()}`}></span><span>{node.name}</span><button type="button" className="tree-action" aria-label={`${node.visible ? "隐藏" : "显示"} ${node.name}`} onClick={(event) => { event.stopPropagation(); onToggleVisibility(node.id, !node.visible); }}>{node.visible ? "◉" : "○"}</button></div>;
return <div key={node.id} role="button" tabIndex={0} data-node-id={node.id} data-data-id={node.dataId ?? ""} onClick={(event) => onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey); }} className={`tree-row child${node.id === snapshot?.activeObjectId ? " selected" : ""}`}><span>·</span><span className={`tree-icon ${node.type.toLowerCase()}`}></span><span>{node.name}</span><button type="button" className="tree-action" aria-label={`${node.visible ? "隐藏" : "显示"} ${node.name}`} onClick={(event) => { event.stopPropagation(); onToggleVisibility(node.id, !node.visible); }}>{node.visible ? "◉" : "○"}</button></div>;
})}
</div>
))}
@@ -645,7 +671,7 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
export function App() {
const [uiState, setUIState] = useState(createDefaultWebWorkspaceState);
const [frame, setFrame] = useState(1);
const [saved, setSaved] = useState(true);
const [dirtyState, setDirtyState] = useState(() => createDirtyState());
const [engineStatus, setEngineStatus] = useState("WASM: starting");
const [wasmStatus, setWasmStatus] = useState("WASM ABI: starting");
const [storageStatus, setStorageStatus] = useState("Storage: starting");
@@ -660,15 +686,65 @@ export function App() {
const [preview, setPreview] = useState<{ snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] } | null>(null);
const [lodLevels, setLodLevels] = useState<Record<string, WebEngineLODLevelResult[]> | null>(null);
const [openProgress, setOpenProgress] = useState<ProgressEvent | null>(null);
const [openReadEvidence, setOpenReadEvidence] = useState<{ actionId: string | null; phase: FileReadPhase | "IDLE"; bytesRead: number; totalBytes: number; progressEvents: number }>({ actionId: null, phase: "IDLE", bytesRead: 0, totalBytes: 0, progressEvents: 0 });
const [openCleanupEvidence, setOpenCleanupEvidence] = useState({ readerLiveReaders: 0, readerInputBytes: 0, readerStagingFiles: 0, engineActiveRequests: 0, engineInputBytes: 0, engineNativeHandles: 0, engineStagingFiles: 0, lastStage: "IDLE" });
const [saveTransaction, setSaveTransaction] = useState<SaveTransactionState>(() => createSaveTransactionState({ revision: 0, sha256: null }));
const [userActions, dispatchUserAction] = useReducer(reduceUserActionStates, createInitialUserActionStates());
const [projectActionConflict, setProjectActionConflict] = useState<ProjectActionConflict | null>(null);
const webClientRef = useRef<WebEngineClient | null>(null);
const storageClientRef = useRef<StorageClient | null>(null);
const autosaveRef = useRef<AutosaveScheduler | null>(null);
const commandCountRef = useRef(0);
const projectIdRef = useRef("untitled");
const projectIdRef = useRef(loadLastProjectId());
const projectRevisionRef = useRef(0);
const committedProjectRevisionRef = useRef(0);
const committedProjectHashRef = useRef<string | null>(null);
const userActionSequenceRef = useRef(0);
const projectActionMutexRef = useRef(createProjectActionMutexState());
const openAbortControllerRef = useRef<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const workspace = uiState.context.workspaceId;
const saved = !dirtyState.dirty;
const workspaceLabel = useMemo(() => `${workspace} Workspace`, [workspace]);
const dispatchUI = (command: UICommand) => setUIState((state) => reduceUICommand(state, command));
const nextUserActionIdentity = <Kind extends UserActionKind>(kind: Kind): UserActionIdentity & { kind: Kind } => (
{ kind, actionId: `${kind}:${++userActionSequenceRef.current}` }
);
const startUserAction = (identity: UserActionIdentity): void => {
dispatchUserAction({ type: "START", identity });
};
const beginUserAction = (kind: UserActionKind): UserActionIdentity => {
const identity = nextUserActionIdentity(kind);
startUserAction(identity);
return identity;
};
const succeedUserAction = (identity: UserActionIdentity): void => {
dispatchUserAction({ type: "SUCCEED", identity });
};
const failUserAction = (identity: UserActionIdentity, errorCode: string): void => {
dispatchUserAction({ type: "FAIL", identity, errorCode });
};
const cancelUserAction = (identity: UserActionIdentity, errorCode: string): void => {
dispatchUserAction({ type: "CANCEL", identity, errorCode });
};
const acquireProjectActionLock = (identity: ProjectActionIdentity, reportConflict = true): boolean => {
const result = acquireProjectAction(projectActionMutexRef.current, identity);
if (!result.granted) {
if (reportConflict) {
setProjectActionConflict(result.conflict);
setEngineStatus(`Action: blocked (${result.conflict.code}: ${result.conflict.reason})`);
}
return false;
}
projectActionMutexRef.current = result.state;
setProjectActionConflict(null);
return true;
};
const releaseProjectActionLock = (identity: ProjectActionIdentity): void => {
const result = releaseProjectAction(projectActionMutexRef.current, identity);
if (result.released) projectActionMutexRef.current = result.state;
else setEngineStatus(`Action: lock failure (${result.errorCode})`);
};
const selectObject = (id: string, additive = false): void => {
setSelectedObjectIds((current) => {
if (!additive) return new Set([id]);
@@ -767,11 +843,11 @@ export function App() {
// Cache invalidation is best effort and never blocks an engine command.
}
};
const applyEditCommand = async (command: WebEngineEditCommand): Promise<void> => {
const applyEditCommand = async (command: WebEngineEditCommand): Promise<boolean> => {
const client = webClientRef.current;
if (!client || !snapshot) {
if (command.type === "setFrame") setFrame(command.frame);
return;
return false;
}
try {
if (command.type === "previewDecimateMesh") {
@@ -781,9 +857,11 @@ export function App() {
setEngineStatus(result.simplify
? `Engine: Preview ${result.simplify.originalTriangleCount} -> ${result.simplify.outputTriangleCount} triangles`
: "Engine: Preview ready");
return;
return true;
}
const result = await client.applyCommand(command);
const logicalRevision = Math.max(projectRevisionRef.current + 1, result.snapshot.revision);
projectRevisionRef.current = logicalRevision;
setPreview(null);
setLodLevels(null);
setSnapshot(result.snapshot);
@@ -794,7 +872,26 @@ export function App() {
if (command.type === "meshEdit" || command.type === "separateMeshFaces" || command.type === "joinObjects") {
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set() }));
}
setSaved(false);
if (command.type === "undo" || command.type === "redo") {
let matchesCommittedContent = false;
try {
const historyBlend = await client.saveBlend();
matchesCommittedContent = await sha256Hex(historyBlend) === committedProjectHashRef.current;
}
catch {
// A history serialization failure is conservative: the project remains dirty.
}
setDirtyState((current) => {
const accepted = acceptHistoryTransaction(current, logicalRevision, matchesCommittedContent);
return accepted.ok ? accepted.state : current;
});
}
else {
setDirtyState((current) => {
const accepted = acceptMainTransaction(current, logicalRevision);
return accepted.ok ? accepted.state : current;
});
}
void invalidateCachedLODs(projectIdRef.current, result.snapshot.revision);
setEngineStatus(result.simplify
? `Engine: Decimate ${result.simplify.originalTriangleCount} -> ${result.simplify.outputTriangleCount} triangles (r${result.snapshot.revision})`
@@ -803,12 +900,12 @@ export function App() {
const storage = storageClientRef.current;
if (storage) {
try {
const operationId = `op-${result.snapshot.revision}-${crypto.randomUUID()}`;
await storage.appendOperation(operationId, projectIdRef.current, result.snapshot.revision, command);
const operationId = `op-${logicalRevision}-${crypto.randomUUID()}`;
await storage.appendOperation(operationId, projectIdRef.current, logicalRevision, command);
commandCountRef.current += 1;
if (commandCountRef.current % 10 === 0) {
const blend = await client.saveBlend();
await storage.saveSnapshot(projectIdRef.current, result.snapshot.revision, blend.slice(0));
await storage.saveSnapshot(projectIdRef.current, logicalRevision, blend.slice(0));
}
}
catch (error) {
@@ -816,8 +913,10 @@ export function App() {
}
}
}
return true;
} catch (error) {
setEngineStatus(`Engine: command failed (${errorMessage(error)})`);
return false;
}
};
@@ -858,18 +957,30 @@ export function App() {
...(["BEVEL", "LOOP_CUT"].includes(operation) ? { segments: operation === "BEVEL" ? 2 : 1 } : {}) });
};
const importImage = async (file: File): Promise<void> => {
const identity = beginUserAction("IMPORT");
let failureCode = "IMPORT_DECODE_FAILED";
let bitmap: ImageBitmap | null = null;
try {
const bitmap = await createImageBitmap(file);
bitmap = await createImageBitmap(file);
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
void applyEditCommand({ type: "importImage", name: file.name.replace(/\.[^.]+$/, "") || "Image",
failureCode = "IMPORT_MAIN_TRANSACTION_FAILED";
const applied = await applyEditCommand({ type: "importImage", name: file.name.replace(/\.[^.]+$/, "") || "Image",
mimeType: file.type === "image/jpeg" ? "image/jpeg" : "image/png", width: bitmap.width, height: bitmap.height, base64: btoa(binary) });
bitmap.close();
if (!applied) {
failUserAction(identity, failureCode);
return;
}
succeedUserAction(identity);
}
catch (error) {
failUserAction(identity, failureCode);
setEngineStatus(`Image import failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
finally {
bitmap?.close();
}
};
const transformActive = (tool: "translate" | "rotate" | "scale", amount = 0.1, axis: 0 | 1 | 2 = tool === "rotate" ? 2 : 0, axisVector?: [number, number, number]): void => {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
@@ -1085,7 +1196,9 @@ export function App() {
let mounted = true;
void loadWebEngineManifest()
.then(async (manifest) => {
const requiredResource = manifest.wasm.find((resource) => resource.required);
const requiredResource = "variants" in manifest
? manifest.variants.find((variant) => variant.id === "single")?.resources.wasm
: manifest.wasm.find((resource) => resource.required);
if (requiredResource) await verifyWasmResource(requiredResource);
if (mounted) setManifestStatus(`Manifest: verified r${manifest.protocolVersion}`);
})
@@ -1171,15 +1284,66 @@ export function App() {
};
const openBlendFile = async (file: File): Promise<void> => {
const identity = nextUserActionIdentity("OPEN");
if (!acquireProjectActionLock(identity)) return;
startUserAction(identity);
const controller = new AbortController();
openAbortControllerRef.current = controller;
const client = webClientRef.current;
if (!client) return;
setOpenProgress({ requestId: "ui", operation: "blend.open", phase: "started", fraction: 0, message: `打开 ${file.name}` });
let failureCode = "OPEN_FILE_READ_FAILED";
try {
const input = await file.arrayBuffer();
if (!client) {
failUserAction(identity, "OPEN_ENGINE_UNAVAILABLE");
return;
}
const input = await readFileBytes(file, {
signal: controller.signal,
yieldControl: () => new Promise((resolve) => requestAnimationFrame(() => resolve())),
onResourceState: (state) => setOpenCleanupEvidence((current) => ({
...current,
readerLiveReaders: state.liveReaders,
readerInputBytes: state.liveInputBytes,
readerStagingFiles: state.liveStagingFiles,
})),
onProgress: (item) => {
const update = (): void => {
setOpenReadEvidence((current) => ({
actionId: identity.actionId,
phase: item.phase,
bytesRead: item.bytesRead,
totalBytes: item.totalBytes,
progressEvents: current.actionId === identity.actionId ? current.progressEvents + 1 : 1,
}));
setOpenProgress({
requestId: identity.actionId,
operation: "blend.open.read",
phase: item.phase === "STARTED" ? "started" : item.phase === "COMPLETED" ? "completed" : item.phase === "CANCELLED" ? "cancelled" : "progress",
fraction: item.fraction,
bytesRead: item.bytesRead,
totalBytes: item.totalBytes,
message: `读取 ${file.name} ${item.bytesRead}/${item.totalBytes} B`,
});
};
if (item.phase !== "READING" || item.bytesRead % DEFAULT_FILE_READ_YIELD_BYTES === 0) flushSync(update);
else update();
},
});
if (controller.signal.aborted) throw new FileByteReadError("FILE_READ_CANCELLED", "cancelled before engine open");
const inputSha256 = await sha256Hex(input);
const projectId = file.name.replace(/\.blend$/i, "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "untitled";
projectIdRef.current = projectId;
const result = await client.openBlend(input, setOpenProgress);
setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(input) });
rememberLastProjectId(projectId);
failureCode = "OPEN_ENGINE_FAILED";
const result = await client.openBlend(input, (progress) => {
setOpenCleanupEvidence((current) => ({ ...current, lastStage: progress.stage ?? current.lastStage }));
setOpenProgress({ ...progress, requestId: identity.actionId });
}, controller.signal);
projectRevisionRef.current = result.snapshot.revision;
committedProjectRevisionRef.current = 0;
committedProjectHashRef.current = inputSha256;
setSaveTransaction(createSaveTransactionState({ revision: 0, sha256: inputSha256 }));
setDirtyState(createDirtyState(result.snapshot.revision));
setVolumeProject({ projectId, sourceBlendSha256: inputSha256 });
setPreview(null);
setLodLevels(null);
setGPUTextureAssets([]);
@@ -1188,32 +1352,104 @@ export function App() {
setGeometryBuffers(result.geometryBuffers);
setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []);
setFrame(result.snapshot.frame.current);
setSaved(true);
setEngineStatus(`Engine: SceneIR r${result.snapshot.revision} (${result.snapshot.nodes.length} objects)`);
void cachePackedAssets(projectId, result.snapshot).then((assets) => {
if (projectIdRef.current === projectId) setGPUTextureAssets(assets);
});
void restoreCachedLODs(projectId, result.snapshot);
succeedUserAction(identity);
} catch (error) {
setEngineStatus(`Engine: .blend read failed${error instanceof Error ? ` (${error.message})` : ""}`);
if (controller.signal.aborted || (error instanceof FileByteReadError && error.code === "FILE_READ_CANCELLED")) {
cancelUserAction(identity, "OPEN_CANCELLED");
if (client) {
try {
const resources = await client.openResourceStatus();
setOpenCleanupEvidence((current) => ({
...current,
engineActiveRequests: resources.activeRequests,
engineInputBytes: resources.liveInputBytes,
engineNativeHandles: resources.liveNativeHandles,
engineStagingFiles: resources.liveStagingFiles,
}));
}
catch {
// The visible cancellation remains valid; Worker recovery is handled separately.
}
}
setEngineStatus("Engine: .blend open cancelled");
}
else {
failUserAction(identity, error instanceof FileByteReadError ? error.code : failureCode);
setEngineStatus(`Engine: .blend read failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
} finally {
setOpenProgress(null);
if (openAbortControllerRef.current === controller) openAbortControllerRef.current = null;
releaseProjectActionLock(identity);
}
};
const cancelOpenFileRead = (): void => {
openAbortControllerRef.current?.abort();
};
const persistProject = async (): Promise<ArrayBuffer | null> => {
const client = webClientRef.current;
if (!client || !snapshot) return null;
const data = await client.saveBlend();
const storage = storageClientRef.current;
if (storage) {
await storage.saveProject(projectIdRef.current, snapshot.revision, data.slice(0));
await storage.saveSnapshot(projectIdRef.current, snapshot.revision, data.slice(0));
await storage.pruneOperations(projectIdRef.current, snapshot.revision);
const revision = Math.max(projectRevisionRef.current, committedProjectRevisionRef.current, snapshot.revision);
const started = beginSaveTransaction(createSaveTransactionState({
revision: committedProjectRevisionRef.current,
sha256: committedProjectHashRef.current,
}), revision);
if (!started.ok) throw new Error(started.errorCode);
let transaction = started.state;
setSaveTransaction(transaction);
try {
const data = await client.saveBlend();
const sha256 = await sha256Hex(data);
const staged = advanceSaveTransaction(transaction, "OPFS_STAGE", sha256);
if (!staged.ok) throw new Error(staged.errorCode);
transaction = staged.state;
setSaveTransaction(transaction);
const storage = storageClientRef.current;
let persisted = { revision, sha256 };
if (storage) {
const result = await storage.saveProject(projectIdRef.current, revision, data.slice(0));
persisted = { revision: result.revision, sha256: result.sha256 };
}
const sceneCommitted = advanceSaveTransaction(transaction, "SCENE_COMMIT");
if (!sceneCommitted.ok) throw new Error(sceneCommitted.errorCode);
transaction = sceneCommitted.state;
const metadataCommitted = advanceSaveTransaction(transaction, "METADATA_COMMIT");
if (!metadataCommitted.ok) throw new Error(metadataCommitted.errorCode);
transaction = metadataCommitted.state;
const committed = commitSaveTransaction(transaction, persisted);
if (!committed.ok) throw new Error(committed.errorCode);
transaction = committed.state;
setSaveTransaction(transaction);
committedProjectRevisionRef.current = persisted.revision;
committedProjectHashRef.current = persisted.sha256;
setVolumeProject({ projectId: projectIdRef.current, sourceBlendSha256: persisted.sha256 });
setDirtyState((current) => {
const accepted = acceptMainSave(current, persisted.revision);
return accepted.ok ? accepted.state : current;
});
if (storage) {
try {
await storage.saveSnapshot(projectIdRef.current, revision, data.slice(0));
await storage.pruneOperations(projectIdRef.current, revision);
}
catch (error) {
setStorageStatus(`Storage: post-commit maintenance failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
}
return data;
}
catch (error) {
setSaveTransaction(failSaveTransaction(transaction, transaction.stage === "SERIALIZE" ? "SAVE_SERIALIZE_INTERRUPTED" : "SAVE_STORAGE_INTERRUPTED"));
throw error;
}
setVolumeProject({ projectId: projectIdRef.current, sourceBlendSha256: await sha256Hex(data) });
setSaved(true);
return data;
};
const recoverCachedProject = async (): Promise<void> => {
@@ -1222,6 +1458,7 @@ export function App() {
if (!client || !storage) return;
try {
const projectId = projectIdRef.current;
rememberLastProjectId(projectId);
let baseRevision = 0;
let buffer: ArrayBuffer;
try {
@@ -1237,8 +1474,9 @@ export function App() {
baseRevision = saved.revision;
buffer = saved.buffer;
}
const baseSha256 = await sha256Hex(buffer);
let opened = await client.openBlend(buffer);
setVolumeProject({ projectId, sourceBlendSha256: await sha256Hex(buffer) });
setVolumeProject({ projectId, sourceBlendSha256: baseSha256 });
const replay = await storage.listOperations(projectId, baseRevision);
for (const operation of replay.operations) {
const payload = operation.payload as WebEngineEditCommand;
@@ -1247,6 +1485,14 @@ export function App() {
}
opened = await client.applyCommand(payload);
}
committedProjectRevisionRef.current = baseRevision;
committedProjectHashRef.current = baseSha256;
setSaveTransaction(createSaveTransactionState({ revision: baseRevision, sha256: baseSha256 }));
projectRevisionRef.current = replay.operations.reduce(
(revision, operation) => Math.max(revision + 1, operation.revision),
baseRevision,
);
setDirtyState(recoverDirtyState(projectRevisionRef.current, baseRevision));
setPreview(null);
setLodLevels(null);
setGPUTextureAssets([]);
@@ -1255,7 +1501,6 @@ export function App() {
setNonMeshGeometryBuffers(opened.nonMeshGeometryBuffers ?? []);
setFrame(opened.snapshot.frame.current);
setSelectedObjectIds(new Set(opened.snapshot.activeObjectId ? [opened.snapshot.activeObjectId] : []));
setSaved(replay.operations.length === 0);
setEngineStatus(`Recovery: ${replay.operations.length} operation(s), ${replay.quarantined} quarantined`);
void cachePackedAssets(projectId, opened.snapshot).then((assets) => {
if (projectIdRef.current === projectId) setGPUTextureAssets(assets);
@@ -1267,48 +1512,115 @@ export function App() {
};
const saveBlend = async (): Promise<void> => {
const saveIdentity = nextUserActionIdentity("SAVE");
if (!acquireProjectActionLock(saveIdentity)) return;
startUserAction(saveIdentity);
let data: ArrayBuffer | null = null;
try {
const persisted = await persistProject();
if (!persisted) {
failUserAction(saveIdentity, "SAVE_PROJECT_UNAVAILABLE");
}
else {
data = persisted;
succeedUserAction(saveIdentity);
}
} catch (error) {
failUserAction(saveIdentity, "SAVE_FAILED");
setEngineStatus(`Engine: .blend save failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
finally {
releaseProjectActionLock(saveIdentity);
}
if (!data) return;
const saveAsIdentity = beginUserAction("SAVE_AS");
try {
const data = await persistProject();
if (!data) return;
const url = URL.createObjectURL(new Blob([data], { type: "application/octet-stream" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "blender-web.blend";
anchor.click();
URL.revokeObjectURL(url);
succeedUserAction(saveAsIdentity);
} catch (error) {
setEngineStatus(`Engine: .blend save failed${error instanceof Error ? ` (${error.message})` : ""}`);
failUserAction(saveAsIdentity, "SAVE_AS_DOWNLOAD_FAILED");
setEngineStatus(`Engine: .blend download failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
};
const closeProject = (): void => {
if (!snapshot) return;
const identity: ProjectActionIdentity = { kind: "CLOSE", actionId: `CLOSE:${++userActionSequenceRef.current}` };
if (!acquireProjectActionLock(identity)) return;
try {
autosaveRef.current?.cancel();
setPreview(null);
setLodLevels(null);
setGPUTextureAssets([]);
setVolumeProject(null);
setSnapshot(null);
setSelectedObjectIds(new Set());
setMeshSelection({ meshId: null, mode: "FACE", indices: new Set() });
setGeometryBuffers([]);
setNonMeshGeometryBuffers([]);
setFrame(1);
setDirtyState(createDirtyState());
projectRevisionRef.current = 0;
committedProjectRevisionRef.current = 0;
committedProjectHashRef.current = null;
setSaveTransaction(createSaveTransactionState({ revision: 0, sha256: null }));
commandCountRef.current = 0;
setOpenProgress(null);
setEngineStatus("Engine: ready, open a .blend file");
}
finally {
releaseProjectActionLock(identity);
}
};
const reportGLBExport = async (): Promise<void> => {
if (!snapshot) return;
let exportSnapshot = snapshot;
let exportGeometryBuffers = geometryBuffers;
if ((snapshot.nonMeshData?.length ?? 0) > 0 && webClientRef.current) {
try {
const evaluated = await webClientRef.current.evaluateDepsgraph();
const mapped = mapEvaluatedNonMeshForExport(snapshot, geometryBuffers, evaluated.depsgraph);
exportSnapshot = mapped.snapshot;
exportGeometryBuffers = mapped.geometryBuffers;
const identity = beginUserAction("EXPORT");
if (!snapshot) {
failUserAction(identity, "EXPORT_PROJECT_UNAVAILABLE");
setEngineStatus("GLB: blocked (no open project)");
return;
}
try {
let exportSnapshot = snapshot;
let exportGeometryBuffers = geometryBuffers;
if ((snapshot.nonMeshData?.length ?? 0) > 0 && webClientRef.current) {
try {
const evaluated = await webClientRef.current.evaluateDepsgraph();
const mapped = mapEvaluatedNonMeshForExport(snapshot, geometryBuffers, evaluated.depsgraph);
exportSnapshot = mapped.snapshot;
exportGeometryBuffers = mapped.geometryBuffers;
}
catch {
// The exporter will emit a machine-readable evaluation-required error.
}
}
catch {
// The exporter will emit a machine-readable evaluation-required error.
const result = exportGLB(exportSnapshot, exportGeometryBuffers, [], nonMeshGeometryBuffers);
const report = result.report;
const errorCount = report.warnings.filter((warning) => warning.severity === "error").length;
const warningCount = report.warnings.length - errorCount;
if (result.glb) {
const url = URL.createObjectURL(new Blob([result.glb], { type: "model/gltf-binary" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "blender-web.glb";
anchor.click();
URL.revokeObjectURL(url);
succeedUserAction(identity);
setEngineStatus(`GLB: exported (${result.glb.byteLength} bytes, ${warningCount} warnings)`);
}
else {
failUserAction(identity, "EXPORT_BLOCKED");
setEngineStatus(`GLB: blocked (${errorCount} errors, ${warningCount} warnings)`);
}
}
const result = exportGLB(exportSnapshot, exportGeometryBuffers, [], nonMeshGeometryBuffers);
const report = result.report;
const errorCount = report.warnings.filter((warning) => warning.severity === "error").length;
const warningCount = report.warnings.length - errorCount;
if (result.glb) {
const url = URL.createObjectURL(new Blob([result.glb], { type: "model/gltf-binary" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "blender-web.glb";
anchor.click();
URL.revokeObjectURL(url);
setEngineStatus(`GLB: exported (${result.glb.byteLength} bytes, ${warningCount} warnings)`);
catch (error) {
failUserAction(identity, "EXPORT_FAILED");
setEngineStatus(`GLB: export failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
else setEngineStatus(`GLB: blocked (${errorCount} errors, ${warningCount} warnings)`);
};
useEffect(() => {
@@ -1317,11 +1629,16 @@ export function App() {
return;
}
autosaveRef.current?.schedule(async () => {
const identity: ProjectActionIdentity = { kind: "SAVE", actionId: `AUTOSAVE:${++userActionSequenceRef.current}` };
if (!acquireProjectActionLock(identity, false)) return;
try {
await persistProject();
} catch (error) {
setEngineStatus(`Engine: autosave failed${error instanceof Error ? ` (${error.message})` : ""}`);
}
finally {
releaseProjectActionLock(identity);
}
});
}, [saved, snapshot]);
@@ -1329,6 +1646,7 @@ export function App() {
const vertexCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.vertexCount, 0) ?? 0;
const faceCount = snapshot?.meshes.reduce((total, mesh) => total + mesh.faceCount, 0) ?? 0;
const frameRange = snapshot?.frame ?? { current: frame, start: 1, end: 250 };
const canCancelOpenRead = userActions.OPEN.status === "RUNNING" && (openProgress?.operation === "blend.open.read" || openProgress?.cancellable === true);
const operatorCommands: OperatorCommand[] = [
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
@@ -1338,21 +1656,41 @@ export function App() {
{ id: "edit.undo", label: "Undo", keywords: "history", execute: () => { void applyEditCommand({ type: "undo" }); } },
{ id: "edit.redo", label: "Redo", keywords: "history", execute: () => { void applyEditCommand({ type: "redo" }); } },
{ id: "file.save", label: "Save Project", keywords: "file blend", execute: () => { void saveBlend(); } },
{ id: "file.close", label: "Close Project", keywords: "file", execute: closeProject },
] : []),
];
return (
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}>
<main className="blender-app" data-workspace={workspace} data-ui-revision={uiState.context.revision}
data-user-action-import-status={userActions.IMPORT.status} data-user-action-import-id={userActions.IMPORT.identity?.actionId} data-user-action-import-error={userActions.IMPORT.errorCode ?? undefined}
data-user-action-open-status={userActions.OPEN.status} data-user-action-open-id={userActions.OPEN.identity?.actionId} data-user-action-open-error={userActions.OPEN.errorCode ?? undefined}
data-user-action-save-status={userActions.SAVE.status} data-user-action-save-id={userActions.SAVE.identity?.actionId} data-user-action-save-error={userActions.SAVE.errorCode ?? undefined}
data-user-action-save-as-status={userActions.SAVE_AS.status} data-user-action-save-as-id={userActions.SAVE_AS.identity?.actionId} data-user-action-save-as-error={userActions.SAVE_AS.errorCode ?? undefined}
data-user-action-export-status={userActions.EXPORT.status} data-user-action-export-id={userActions.EXPORT.identity?.actionId} data-user-action-export-error={userActions.EXPORT.errorCode ?? undefined}
data-project-action-conflict={projectActionConflict?.code} data-project-action-conflict-reason={projectActionConflict?.reason}
data-project-action-conflict-requested={projectActionConflict?.requested.kind} data-project-action-conflict-owner={projectActionConflict?.owner.kind}
data-project-action-conflict-request-id={projectActionConflict?.requested.actionId} data-project-action-conflict-owner-id={projectActionConflict?.owner.actionId}
data-open-read-action-id={openReadEvidence.actionId ?? undefined} data-open-read-phase={openReadEvidence.phase}
data-open-read-bytes={openReadEvidence.bytesRead} data-open-read-total={openReadEvidence.totalBytes} data-open-read-events={openReadEvidence.progressEvents}
data-open-cleanup-reader-count={openCleanupEvidence.readerLiveReaders} data-open-cleanup-reader-bytes={openCleanupEvidence.readerInputBytes}
data-open-cleanup-reader-staging={openCleanupEvidence.readerStagingFiles} data-open-cleanup-engine-requests={openCleanupEvidence.engineActiveRequests}
data-open-cleanup-engine-bytes={openCleanupEvidence.engineInputBytes} data-open-cleanup-engine-handles={openCleanupEvidence.engineNativeHandles}
data-open-cleanup-engine-staging={openCleanupEvidence.engineStagingFiles} data-open-cleanup-stage={openCleanupEvidence.lastStage}
data-save-transaction-status={saveTransaction.status} data-save-transaction-stage={saveTransaction.stage ?? undefined}
data-save-committed-revision={saveTransaction.committed.revision} data-save-committed-hash={saveTransaction.committed.sha256 ?? undefined}
data-save-candidate-revision={saveTransaction.candidate?.revision} data-save-candidate-hash={saveTransaction.candidate?.sha256 ?? undefined}
data-save-transaction-error={saveTransaction.errorCode ?? undefined} data-dirty={dirtyState.dirty}
data-current-main-revision={dirtyState.currentMainRevision} data-committed-main-revision={dirtyState.committedMainRevision}>
<header className="topbar">
<div className="brand"><span className="brand-mark" aria-hidden="true"></span><span>Web Blender Modeler V1</span></div>
<nav className="menu-bar" aria-label="主菜单"><button type="button" onClick={() => fileInputRef.current?.click()}></button><button type="button"></button><button type="button"></button><button type="button"></button><button type="button"></button></nav>
<nav className="workspace-tabs" aria-label="工作区">
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
</nav>
<div className="topbar-actions"><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}></button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}></button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}></button><button type="button" aria-label="导出 GLB" onClick={reportGLBExport}>GLB</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}></button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}></button><button type="button" aria-label="操作搜索" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button></div>
<div className="topbar-actions"><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}></button><button type="button" aria-label="关闭项目" disabled={!snapshot} onClick={closeProject}></button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}></button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}></button><button type="button" aria-label="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}></button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}></button><button type="button" aria-label="操作搜索" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button></div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept=".blend,application/octet-stream" data-testid="blend-file-input" onChange={(event) => { const file = event.target.files?.[0]; if (file) void openBlendFile(file); event.target.value = ""; }} />
</header>
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0} onClick={() => runMeshEdit(operation)}>{operation.replace("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <><button type="button" aria-label="添加立方体" onClick={() => void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] })}>Add Cube</button><button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><button type="button" onClick={() => setSaved(false)}>{saved ? "已保存" : "未保存"}</button></div>
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0} onClick={() => runMeshEdit(operation)}>{operation.replace("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <><button type="button" aria-label="添加立方体" onClick={() => void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] })}>Add Cube</button><button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
<div className="workspace-grid">
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onTransform={transformActive} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
@@ -1360,7 +1698,7 @@ export function App() {
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <OperatorSearch commands={operatorCommands} onClose={() => dispatchUI({ type: "toggleOperatorSearch", open: false })} /> : null}
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span data-testid="open-progress">{openProgress.message ?? "Opening"}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
</main>
);
}

View File

@@ -19,6 +19,7 @@ button { color: inherit; border: 0; cursor: pointer; }
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; }
.menu-bar button, .topbar-actions button, .workspace-tab, .workspace-toolbar button { padding: 5px 8px; background: transparent; border-radius: 3px; color: #bfc2c8; }
.menu-bar button:hover, .topbar-actions button:hover, .workspace-tab:hover, .workspace-toolbar button:hover { background: #3a3c41; color: #fff; }
.topbar-actions button:disabled { color: #6f737b; background: transparent; cursor: default; }
.workspace-tabs { align-self: stretch; gap: 0; }
.workspace-tab { min-width: 82px; border-bottom: 2px solid transparent; border-radius: 0; }
.workspace-tab.active { color: #fff; background: #313338; border-bottom-color: #e37a2c; }
@@ -62,6 +63,7 @@ button { color: inherit; border: 0; cursor: pointer; }
.property-section input[type="number"] { width: 72px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="text"], .property-section label > input:not([type]) { min-width: 0; width: 130px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="checkbox"] { accent-color: #e37a2c; }.property-actions { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }.property-actions button, .file-button { padding: 4px 7px; color: #e6e7e9; background: #393c42; border: 1px solid #4d5158; border-radius: 2px; }.property-actions button:disabled { opacity: .45; cursor: default; }.file-button { position: relative; cursor: pointer; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }.material-slots { display: grid; gap: 2px; max-height: 62px; overflow: auto; color: #b9bdc5; font-size: 11px; }.delimit-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 8px; margin: 6px 0; padding: 5px 0 3px; border: 0; border-top: 1px solid #383a40; }.delimit-options legend { padding: 0 5px 0 0; color: #7f858e; font-size: 10px; }.property-section .delimit-options label { justify-content: flex-start; min-width: 0; min-height: 20px; font-size: 10px; }
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #7f858f; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
.status-bar { gap: 16px; min-width: 0; min-height: 24px; overflow: hidden; color: #8f949c; font-size: 11px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }
.open-progress { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 360px; }.open-progress progress { width: 72px; height: 8px; accent-color: #e37a2c; }.open-progress > span { overflow: hidden; text-overflow: ellipsis; }.open-progress button { width: 20px; height: 20px; padding: 0; color: #d6d8dc; background: #3a3c41; border-radius: 2px; }
.file-input-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.operator-search { position: fixed; top: 56px; left: 50%; z-index: 10; width: min(480px, calc(100vw - 24px)); padding: 8px; transform: translateX(-50%); background: #303238; border: 1px solid #545860; border-radius: 4px; box-shadow: 0 12px 30px #0008; }
.operator-search input { width: 100%; padding: 8px 10px; color: #f4f5f6; background: #1d1f22; border: 1px solid #5a5e66; border-radius: 3px; outline: none; }

View File

@@ -8,6 +8,7 @@ import type {
WebEngineRequest,
WebEngineResponse,
WebEngineResult,
WebEngineOpenResourceStatus,
WebEngineStatus,
} from "../../../protocol/web-engine";
import type { LODGenerationRequest } from "../../../protocol/lod";
@@ -25,6 +26,7 @@ interface PendingRequest {
reject: (error: ErrorReport) => void;
onProgress?: (progress: ProgressEvent) => void;
timer: ReturnType<typeof setTimeout>;
cleanup?: () => void;
}
export interface WebEngineClientOptions {
@@ -61,11 +63,11 @@ export class WebEngineClient {
return (await this.request({ type: "init" })).status;
}
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void): Promise<BlendOpenResult> {
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void, signal?: AbortSignal): Promise<BlendOpenResult> {
if (buffer.byteLength === 0) {
throw this.report("INVALID_ARGUMENT", "无法打开空的 .blend 文件", true);
}
const result = await this.request({ type: "openBlend", buffer }, [buffer], onProgress);
const result = await this.request({ type: "openBlend", buffer }, [buffer], onProgress, signal);
if (!result.snapshot) throw this.report("BLEND_READ_FAILED", "WebEngine 未返回 SceneIR", true);
this.geometryBuffers = result.geometryBuffers ?? [];
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? [];
@@ -146,6 +148,12 @@ export class WebEngineClient {
return result.blend;
}
async openResourceStatus(): Promise<WebEngineOpenResourceStatus> {
const result = await this.request({ type: "openResourceStatus" });
if (!result.openResources) throw this.report("INVALID_ARGUMENT", "WebEngine open resource status missing", true);
return result.openResources;
}
terminate(): void {
this.failPending(this.report("WORKER_TERMINATED", "WebEngineWorker 已关闭", true));
this.worker?.terminate();
@@ -169,16 +177,29 @@ export class WebEngineClient {
command: WebEngineRequest["command"],
transfer: Transferable[] = [],
onProgress?: (progress: ProgressEvent) => void,
signal?: AbortSignal,
): Promise<WebEngineResult> {
const worker = this.start();
const requestId = `web-engine-${++this.requestCounter}`;
const request = { requestId, command } as WebEngineRequest;
return new Promise<WebEngineResult>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.get(requestId)?.cleanup?.();
this.pending.delete(requestId);
reject(this.report("WORKER_TERMINATED", `WebEngine 请求超时: ${command.type}`, true));
}, this.timeoutMs);
this.pending.set(requestId, { resolve, reject, onProgress, timer });
const abort = (): void => {
worker.postMessage({
requestId: `web-engine-cancel-${++this.requestCounter}`,
command: { type: "cancelOpen", targetRequestId: requestId },
} satisfies WebEngineRequest);
};
const cleanup = signal ? () => signal.removeEventListener("abort", abort) : undefined;
this.pending.set(requestId, { resolve, reject, onProgress, timer, cleanup });
if (signal) {
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
}
worker.postMessage(request, transfer);
});
}
@@ -192,6 +213,7 @@ export class WebEngineClient {
}
this.pending.delete(response.requestId);
clearTimeout(pending.timer);
pending.cleanup?.();
if (response.ok) pending.resolve(response.result);
else pending.reject(response.error);
}
@@ -199,6 +221,7 @@ export class WebEngineClient {
private failPending(error: ErrorReport): void {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.cleanup?.();
pending.reject(error);
}
this.pending.clear();

View File

@@ -0,0 +1,139 @@
import type { WebEngineVariantV2 } from "../../../protocol/manifest";
import type {
EngineVariantResourceState,
EngineVariantSession,
} from "./engine-variant-bootstrap";
import { EngineVariantLoadError } from "./engine-variant-bootstrap";
interface EmscriptenPThreadRuntime {
unusedWorkers: Worker[];
runningWorkers: Worker[];
terminateAllThreads(): void;
}
interface BrowserVariantModule {
HEAPU8: Uint8Array;
_malloc(size: number): number;
_free(pointer: number): void;
_web_engine_create(): number;
_web_engine_destroy(handle: number): void;
_web_engine_get_live_handles(): number;
_web_engine_open_blend(handle: number, data: number, length: number): number;
PThread?: EmscriptenPThreadRuntime;
}
type BrowserVariantFactory = (options: { wasmBinary: ArrayBuffer }) => Promise<BrowserVariantModule>;
async function verifiedBytes(url: string, expectedSha256: string, controllers: Set<AbortController>): Promise<ArrayBuffer> {
const controller = new AbortController();
controllers.add(controller);
try {
const response = await fetch(url, { cache: "no-store", signal: controller.signal });
if (!response.ok) {
throw new EngineVariantLoadError(
"ENGINE_VARIANT_RESOURCE_REQUEST_FAILED",
`${url} returned ${response.status}`,
);
}
const bytes = await response.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", bytes);
const actual = [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
if (actual !== expectedSha256) {
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", url);
}
return bytes;
}
finally {
controllers.delete(controller);
}
}
class BrowserEngineVariantSession implements EngineVariantSession<ArrayBuffer> {
readonly variant: WebEngineVariantV2;
private readonly module: BrowserVariantModule;
private readonly pendingRequests: Set<AbortController>;
private readonly timers = new Set<ReturnType<typeof setTimeout>>();
private handle: number;
constructor(
variant: WebEngineVariantV2,
module: BrowserVariantModule,
handle: number,
pendingRequests: Set<AbortController>,
) {
this.variant = variant;
this.module = module;
this.handle = handle;
this.pendingRequests = pendingRequests;
}
async openProject(project: ArrayBuffer): Promise<void> {
if (this.handle <= 0) throw new Error("ENGINE_VARIANT_SESSION_DISPOSED");
const pointer = this.module._malloc(project.byteLength);
if (project.byteLength > 0 && pointer <= 0) throw new Error("ENGINE_VARIANT_PROJECT_ALLOCATION_FAILED");
try {
if (project.byteLength > 0) this.module.HEAPU8.set(new Uint8Array(project), pointer);
const result = this.module._web_engine_open_blend(this.handle, pointer, project.byteLength);
if (result !== 0) throw new Error(`ENGINE_VARIANT_PROJECT_OPEN_FAILED: ${result}`);
}
finally {
if (pointer > 0) this.module._free(pointer);
}
}
resourceState(): EngineVariantResourceState {
const pthread = this.module.PThread;
return {
handles: this.handle > 0 ? this.module._web_engine_get_live_handles() : 0,
workers: pthread ? pthread.unusedWorkers.length + pthread.runningWorkers.length : 0,
timers: this.timers.size,
pendingRequests: this.pendingRequests.size,
};
}
testOnlySeedTrackedResources(): void {
this.pendingRequests.add(new AbortController());
const timer = setTimeout(() => this.timers.delete(timer), 60_000);
this.timers.add(timer);
}
async dispose(): Promise<EngineVariantResourceState> {
for (const controller of this.pendingRequests) controller.abort();
this.pendingRequests.clear();
for (const timer of this.timers) clearTimeout(timer);
this.timers.clear();
if (this.handle > 0) {
this.module._web_engine_destroy(this.handle);
this.handle = 0;
}
this.module.PThread?.terminateAllThreads();
return this.resourceState();
}
}
export async function createBrowserEngineVariantSession(
variant: WebEngineVariantV2,
): Promise<EngineVariantSession<ArrayBuffer>> {
const controllers = new Set<AbortController>();
const verifiedResources = new Map<string, ArrayBuffer>();
const verify = async (resource: { url: string; sha256: string }): Promise<ArrayBuffer> => {
const existing = verifiedResources.get(resource.url);
if (existing) return existing;
const bytes = await verifiedBytes(resource.url, resource.sha256, controllers);
verifiedResources.set(resource.url, bytes);
return bytes;
};
await verify(variant.resources.js);
const wasmBinary = await verify(variant.resources.wasm);
if (variant.id === "pthread") await verify(variant.resources.pthreadWorker);
const imported = await import(/* @vite-ignore */ variant.resources.js.url) as { default: BrowserVariantFactory };
const module = await imported.default({ wasmBinary });
const handle = module._web_engine_create();
if (handle <= 0) {
module.PThread?.terminateAllThreads();
throw new Error("ENGINE_VARIANT_HANDLE_CREATION_FAILED");
}
return new BrowserEngineVariantSession(variant, module, handle, controllers);
}

View File

@@ -0,0 +1,195 @@
import {
bindWebEngineRelease,
selectWebEngineVariant,
type WasmThreadingCapabilities,
type WebEngineReleaseBinding,
type WebEngineVariantPolicy,
} from "../../../protocol/engine-variant";
import type { CapabilityGateResult } from "../../../protocol/capability-gates";
import type { WebEngineManifestV2, WebEngineVariantV2 } from "../../../protocol/manifest";
export interface EngineVariantResourceState {
handles: number;
workers: number;
timers: number;
pendingRequests: number;
}
export interface EngineVariantSession<Project = ArrayBuffer> {
readonly variant: WebEngineVariantV2;
openProject(project: Project): Promise<void>;
dispose(): Promise<EngineVariantResourceState>;
resourceState(): EngineVariantResourceState;
testOnlySeedTrackedResources?(): void;
}
export interface EngineVariantFallbackReason {
code: "PTHREAD_INITIALIZATION_FAILED";
message: string;
}
export interface EngineVariantBootstrapResult {
status: "READY";
selected: "single" | "pthread";
attempted: Array<"single" | "pthread">;
fallbackReason: EngineVariantFallbackReason | null;
pthreadGate: CapabilityGateResult;
openCount: number;
failedAttemptCleanup: EngineVariantResourceState | null;
}
export interface EngineVariantBootstrapOutcome<Project = ArrayBuffer> {
result: EngineVariantBootstrapResult;
session: EngineVariantSession<Project>;
}
export type WebEngineRefreshRequired = Extract<WebEngineReleaseBinding, { status: "REFRESH_REQUIRED" }>;
export type WebEngineReleaseBootstrapOutcome<Project = ArrayBuffer> =
| EngineVariantBootstrapOutcome<Project>
| { result: WebEngineRefreshRequired; session: null };
export interface EngineVariantBootstrapDependencies<Project = ArrayBuffer> {
initialize(variant: WebEngineVariantV2): Promise<EngineVariantSession<Project>>;
}
export interface EngineVariantBootstrapTestHooks {
failPthreadAfterInitialize?: boolean;
seedTrackedPthreadResources?: boolean;
}
export class EngineVariantLoadError extends Error {
readonly code: "ENGINE_VARIANT_RESOURCE_REQUEST_FAILED" | "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH";
constructor(code: EngineVariantLoadError["code"], message: string, options?: ErrorOptions) {
super(message, options);
this.name = "EngineVariantLoadError";
this.code = code;
}
}
export class EngineVariantBootstrapError extends Error {
readonly code: "ENGINE_VARIANT_INTEGRITY_FAILED";
readonly attempted: Array<"single" | "pthread">;
constructor(attempted: Array<"single" | "pthread">, cause: EngineVariantLoadError) {
super(`${cause.code}: ${cause.message}`, { cause });
this.name = "EngineVariantBootstrapError";
this.code = "ENGINE_VARIANT_INTEGRITY_FAILED";
this.attempted = [...attempted];
}
}
function failureMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : "pthread engine initialization failed";
}
function rejectIntegrityFailure(
error: unknown,
attempted: Array<"single" | "pthread">,
): void {
if (error instanceof EngineVariantLoadError && error.code === "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH") {
throw new EngineVariantBootstrapError(attempted, error);
}
}
async function bootstrapSelectedWebEngineVariant<Project = ArrayBuffer>(
manifest: WebEngineManifestV2,
policy: WebEngineVariantPolicy,
capabilities: WasmThreadingCapabilities,
dependencies: EngineVariantBootstrapDependencies<Project>,
pendingProject?: Project,
testHooks?: EngineVariantBootstrapTestHooks,
): Promise<EngineVariantBootstrapOutcome<Project>> {
const selection = selectWebEngineVariant(manifest, policy, capabilities);
if (!selection.selectedVariant) throw new Error("PTHREAD_REQUIRED_CAPABILITY_BLOCKED");
const attempted: Array<"single" | "pthread"> = [];
let failedAttemptCleanup: EngineVariantResourceState | null = null;
let fallbackReason: EngineVariantFallbackReason | null = null;
let session: EngineVariantSession<Project> | null = null;
const initialize = async (variant: WebEngineVariantV2): Promise<EngineVariantSession<Project>> => {
attempted.push(variant.id);
return dependencies.initialize(variant);
};
try {
session = await initialize(selection.selectedVariant);
if (selection.selectedVariant.id === "pthread") {
if (testHooks?.seedTrackedPthreadResources) session.testOnlySeedTrackedResources?.();
if (testHooks?.failPthreadAfterInitialize) throw new Error("TEST_PTHREAD_INITIALIZATION_FAILURE");
}
}
catch (error) {
const failedSession = session;
if (failedSession) failedAttemptCleanup = await failedSession.dispose();
rejectIntegrityFailure(error, attempted);
if (policy !== "AUTO" || selection.selectedVariant.id !== "pthread") throw error;
fallbackReason = { code: "PTHREAD_INITIALIZATION_FAILED", message: failureMessage(error) };
const single = manifest.variants.find((variant) => variant.id === "single");
if (!single) throw new Error("ENGINE_MANIFEST_VARIANTS_MISSING", { cause: error });
try {
session = await initialize(single);
}
catch (fallbackError) {
rejectIntegrityFailure(fallbackError, attempted);
throw fallbackError;
}
}
if (!session) throw new Error("ENGINE_VARIANT_INITIALIZATION_FAILED");
let openCount = 0;
if (pendingProject !== undefined) {
try {
await session.openProject(pendingProject);
openCount = 1;
}
catch (error) {
await session.dispose();
throw error;
}
}
return {
result: {
status: "READY",
selected: session.variant.id,
attempted,
fallbackReason,
pthreadGate: selection.pthreadGate,
openCount,
failedAttemptCleanup,
},
session,
};
}
export async function bootstrapWebEngineRelease<Project = ArrayBuffer>(
expectedReleaseId: string,
manifest: WebEngineManifestV2,
policy: WebEngineVariantPolicy,
capabilities: WasmThreadingCapabilities,
dependencies: EngineVariantBootstrapDependencies<Project>,
pendingProject?: Project,
testHooks?: EngineVariantBootstrapTestHooks,
): Promise<WebEngineReleaseBootstrapOutcome<Project>> {
const binding = bindWebEngineRelease(expectedReleaseId, manifest);
if (binding.status === "REFRESH_REQUIRED") {
return { result: binding, session: null };
}
return bootstrapSelectedWebEngineVariant(
binding.manifest,
policy,
capabilities,
dependencies,
pendingProject,
testHooks,
);
}
export function engineVariantStatusLabel(result: EngineVariantBootstrapResult): string {
if (result.selected === "pthread") return "Runtime: pthread";
return result.fallbackReason
? `Runtime: single (pthread fallback: ${result.fallbackReason.code})`
: "Runtime: single";
}

View File

@@ -1,10 +1,8 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "../../../protocol/capability-gates";
export interface WasmThreadingCapabilities {
crossOriginIsolated: boolean;
sharedArrayBuffer: boolean;
worker: boolean;
}
import {
type WasmThreadingCapabilities,
} from "../../../protocol/engine-variant";
export { gateWasmThreadingCapability, selectWebEngineVariant } from "../../../protocol/engine-variant";
export type { WasmThreadingCapabilities } from "../../../protocol/engine-variant";
export interface BrowserCapabilities extends WasmThreadingCapabilities {
webgl2: boolean;
@@ -17,34 +15,6 @@ export interface BrowserCapabilities extends WasmThreadingCapabilities {
indexedDb: boolean;
}
export function gateWasmThreadingCapability(capabilities: WasmThreadingCapabilities): CapabilityGateResult {
const issues = [];
if (!capabilities.crossOriginIsolated) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Cross-origin isolation is required for the pthread WASM engine",
"crossOriginIsolated",
));
}
if (!capabilities.sharedArrayBuffer) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"SharedArrayBuffer is required for the pthread WASM engine",
"sharedArrayBuffer",
));
}
if (!capabilities.worker) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Worker is required for the pthread WASM engine",
"worker",
));
}
return issues.length > 0
? blockedGate("M6-02", "WASM_PTHREAD_ENGINE", issues)
: readyGate("M6-02", "WASM_PTHREAD_ENGINE");
}
function supportsWasmFeature(feature: "simd" | "threads"): boolean {
if (typeof WebAssembly === "undefined") return false;

View File

@@ -43,7 +43,7 @@ export class StorageClient {
return this.request({ type: "ensureProject", projectId }) as Promise<StorageProjectResult>;
}
saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "quota"): Promise<StorageSaveResult> {
saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota"): Promise<StorageSaveResult> {
return this.request({ type: "saveProject", projectId, revision, buffer, faultAt }, [buffer]) as Promise<StorageSaveResult>;
}

View File

@@ -1,6 +1,7 @@
const PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
export const OPFS_PROJECT_SCHEMA_VERSION = 1;
export interface OpfsProjectLayout {
projectId: string;
@@ -52,7 +53,7 @@ type OpfsStorage = StorageManager & { getDirectory?: () => Promise<FileSystemDir
export type ProjectSaveFault = "after-stage" | "after-scene-commit";
export interface ProjectBlendManifest {
schemaVersion: 1;
schemaVersion: typeof OPFS_PROJECT_SCHEMA_VERSION;
projectId: string;
revision: number;
bytes: number;
@@ -140,7 +141,7 @@ async function verifyFile(directory: FileSystemDirectoryHandle, name: string, by
}
function validateManifest(value: ProjectBlendManifest | undefined, projectId: string): ProjectBlendManifest | undefined {
if (!value || value.schemaVersion !== 1 || value.projectId !== projectId ||
if (!value || value.schemaVersion !== OPFS_PROJECT_SCHEMA_VERSION || value.projectId !== projectId ||
!Number.isInteger(value.revision) || value.revision < 0 ||
!Number.isInteger(value.bytes) || value.bytes <= 0 ||
!SHA256_PATTERN.test(value.sha256) || typeof value.committedAt !== "string") {
@@ -150,7 +151,7 @@ function validateManifest(value: ProjectBlendManifest | undefined, projectId: st
}
function validateJournal(value: ProjectBlendJournal | undefined, projectId: string): ProjectBlendJournal {
if (!value || value.schemaVersion !== 1 || value.projectId !== projectId ||
if (!value || value.schemaVersion !== OPFS_PROJECT_SCHEMA_VERSION || value.projectId !== projectId ||
!Number.isInteger(value.revision) || value.revision < 0 ||
!Number.isInteger(value.bytes) || value.bytes <= 0 ||
!SHA256_PATTERN.test(value.sha256) || typeof value.createdAt !== "string" ||
@@ -176,7 +177,7 @@ async function finalizeBlendCommit(
journal: ProjectBlendJournal,
): Promise<ProjectBlendManifest> {
const manifest: ProjectBlendManifest = {
schemaVersion: 1,
schemaVersion: OPFS_PROJECT_SCHEMA_VERSION,
projectId: journal.projectId,
revision: journal.revision,
bytes: journal.bytes,
@@ -266,7 +267,7 @@ export async function writeProjectBlend(
const stageName = `scene.blend.${crypto.randomUUID()}.stage`;
const sha256 = await sha256Hex(data);
const journal: ProjectBlendJournal = {
schemaVersion: 1,
schemaVersion: OPFS_PROJECT_SCHEMA_VERSION,
projectId,
revision,
bytes: data.byteLength,

View File

@@ -199,12 +199,12 @@ async function ensureProject(projectId: string): Promise<StorageProjectResult> {
};
}
async function saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: ProjectSaveFault | "quota"): Promise<StorageSaveResult> {
async function saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: ProjectSaveFault | "before-metadata-commit" | "quota"): Promise<StorageSaveResult> {
if (faultAt === "quota") throw new Error("QuotaExceededError: injected OPFS quota exhaustion");
const useOpfs = await useOpfsForProject(projectId);
const layout = useOpfs ? await ensureProjectLayout(projectId) : projectLayout(projectId);
const sha256 = await sha256Hex(buffer);
const existing = faultAt ? undefined : await readProjectRow(projectId);
const existing = await readProjectRow(projectId);
if (existing && existing.revision > revision) {
throw new Error(`PROJECT_REVISION_CONFLICT: committed revision ${existing.revision} is newer than ${revision}`);
}
@@ -232,8 +232,24 @@ async function saveProject(projectId: string, revision: number, buffer: ArrayBuf
};
}
if (useOpfs) {
const committed = await writeProjectBlend(projectId, revision, buffer, undefined, faultAt);
if (committed.manifest.sha256 !== sha256) throw new Error("Project commit digest mismatch");
const previousBuffer = faultAt && existing ? await readProjectBlend(projectId) : undefined;
try {
const opfsFault = faultAt === "after-stage" || faultAt === "after-scene-commit" ? faultAt : undefined;
const committed = await writeProjectBlend(projectId, revision, buffer, undefined, opfsFault);
if (committed.manifest.sha256 !== sha256) throw new Error("Project commit digest mismatch");
if (faultAt === "before-metadata-commit") {
throw new Error("PROJECT_SAVE_FAULT_INJECTED: before-metadata-commit");
}
}
catch (error) {
if (faultAt && existing && previousBuffer) {
const rollback = await writeProjectBlend(projectId, existing.revision, previousBuffer);
if (rollback.manifest.sha256 !== existing.sha256) {
throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored");
}
}
throw error;
}
}
else if (faultAt) {
throw new Error("PROJECT_SAVE_FAULT_UNAVAILABLE: IndexedDB commits are already transactional");

View File

@@ -1,6 +1,6 @@
import type { ErrorReport } from "../../../protocol/error";
import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
import type { MeshGeometryBuffer, WebEngineLODError, WebEngineLODLevelResult, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
import type { MeshGeometryBuffer, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
import { parseSceneSnapshotIR } from "../../../protocol/scene-ir";
import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify";
import type { LODGenerationRequest } from "../../../protocol/lod";
@@ -95,6 +95,21 @@ let currentSnapshot: ReturnType<typeof parseSceneSnapshotIR> | null = null;
let currentGeometryBuffers: MeshGeometryBuffer[] = [];
let sourceBlendBuffer: ArrayBuffer | null = null;
let sourceBlendRevision = -1;
const cancelledOpenRequests = new Set<string>();
const activeOpenResources = new Map<string, { liveInputBytes: number; liveNativeHandles: number; liveStagingFiles: number }>();
function openResourceStatus(): WebEngineOpenResourceStatus {
return [...activeOpenResources.values()].reduce<WebEngineOpenResourceStatus>((status, item) => ({
activeRequests: status.activeRequests + 1,
liveInputBytes: status.liveInputBytes + item.liveInputBytes,
liveNativeHandles: status.liveNativeHandles + item.liveNativeHandles,
liveStagingFiles: status.liveStagingFiles + item.liveStagingFiles,
}), { activeRequests: 0, liveInputBytes: 0, liveNativeHandles: 0, liveStagingFiles: 0 });
}
function throwIfOpenCancelled(requestId: string): void {
if (cancelledOpenRequests.has(requestId)) throw report("OPEN_CANCELLED", "Blend open cancelled before Main commit");
}
function status(): WebEngineStatus {
return {
@@ -470,6 +485,15 @@ function nativeError(fallbackCode: ErrorReport["code"]): ErrorReport {
return report(mappedCode, message);
}
function nativeErrorFor(target: WasmModule, fallbackCode: ErrorReport["code"]): ErrorReport {
const code = target._web_engine_last_error_code() ?? 0;
const message = target._web_engine_last_error_message() !== 0
? target.UTF8ToString(target._web_engine_last_error_message())
: "WebEngine native call failed";
const mappedCode: ErrorReport["code"] = code === -4 ? "BLEND_READ_FAILED" : code === -5 ? "BLEND_WRITE_FAILED" : fallbackCode;
return report(mappedCode, message);
}
async function initialize(): Promise<WebEngineStatus> {
if (module) return status();
if (!initializationPromise) {
@@ -921,8 +945,8 @@ async function evaluateIsolatedDecimate(
}
}
function progress(requestId: string, phase: "started" | "progress" | "completed", fraction: number, message: string): void {
scope.postMessage({ kind: "progress", requestId, progress: { requestId, operation: "blend.open", phase, fraction, message } });
function progress(requestId: string, phase: "started" | "progress" | "completed", fraction: number, message: string, stage?: string, cancellable?: boolean): void {
scope.postMessage({ kind: "progress", requestId, progress: { requestId, operation: "blend.open", phase, fraction, message, stage, cancellable } });
}
scope.onmessage = async (event) => {
@@ -936,27 +960,74 @@ scope.onmessage = async (event) => {
result = baseResult();
break;
case "openBlend": {
progress(request.requestId, "started", 0, "Reading .blend");
await initialize();
const sourceCopy = request.command.buffer.slice(0);
const input = copyIntoWasm(request.command.buffer);
const resource = { liveInputBytes: request.command.buffer.byteLength, liveNativeHandles: 0, liveStagingFiles: 0 };
activeOpenResources.set(request.requestId, resource);
let candidateModule: WasmModule | null = null;
let candidateHandle = 0;
try {
if (!module || module._web_engine_open_blend(handle, input.pointer, input.length) !== 0) {
throw nativeError("BLEND_READ_FAILED");
progress(request.requestId, "started", 0, "Preparing isolated Main", "NATIVE_INITIALIZE", true);
await initialize();
throwIfOpenCancelled(request.requestId);
if (!wasmFactory || !wasmBinary) throw report("WASM_INIT_FAILED", "WebEngine isolated open module is unavailable");
candidateModule = await wasmFactory({ wasmBinary: wasmBinary.slice(0) });
throwIfOpenCancelled(request.requestId);
candidateHandle = candidateModule._web_engine_create();
if (candidateHandle <= 0) throw report("WASM_INIT_FAILED", "WebEngine isolated open handle creation failed");
resource.liveNativeHandles = 1;
const pointer = candidateModule._malloc(request.command.buffer.byteLength);
if (!pointer) throw report("WASM_OUT_OF_MEMORY", "WebEngine could not allocate isolated blend input");
try {
candidateModule.HEAPU8.set(new Uint8Array(request.command.buffer), pointer);
if (candidateModule._web_engine_open_blend(candidateHandle, pointer, request.command.buffer.byteLength) !== 0) {
throw nativeErrorFor(candidateModule, "BLEND_READ_FAILED");
}
}
finally {
candidateModule._free(pointer);
resource.liveInputBytes = 0;
}
progress(request.requestId, "progress", 0.7, "Isolated Main opened", "NATIVE_OPENED", true);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
throwIfOpenCancelled(request.requestId);
const previousModule = module;
const previousHandle = handle;
module = candidateModule;
handle = candidateHandle;
try {
progress(request.requestId, "progress", 0.8, "Building SceneIR", "COMMITTING", false);
const scene = await readSnapshotResult();
throwIfOpenCancelled(request.requestId);
const sourceCopy = request.command.buffer.slice(0);
currentSnapshot = scene.snapshot;
sourceBlendBuffer = sourceCopy;
sourceBlendRevision = scene.snapshot.revision;
result = { status: status(), ...publishFullScene(scene) };
}
catch (error) {
module = previousModule;
handle = previousHandle;
throw error;
}
previousModule?._web_engine_destroy(previousHandle);
candidateHandle = 0;
resource.liveNativeHandles = 0;
progress(request.requestId, "completed", 1, "SceneIR ready", "COMPLETED", false);
}
finally {
module?._free(input.pointer);
if (candidateModule && candidateHandle > 0) candidateModule._web_engine_destroy(candidateHandle);
activeOpenResources.delete(request.requestId);
cancelledOpenRequests.delete(request.requestId);
}
progress(request.requestId, "progress", 0.8, "Building SceneIR");
const scene = await readSnapshotResult();
currentSnapshot = scene.snapshot;
sourceBlendBuffer = sourceCopy;
sourceBlendRevision = scene.snapshot.revision;
result = { status: status(), ...publishFullScene(scene) };
progress(request.requestId, "completed", 1, "SceneIR ready");
break;
}
break;
}
case "cancelOpen":
if (activeOpenResources.has(request.command.targetRequestId)) cancelledOpenRequests.add(request.command.targetRequestId);
result = { status: status(), openResources: openResourceStatus() };
break;
case "openResourceStatus":
result = { status: status(), openResources: openResourceStatus() };
break;
case "snapshot":
await initialize();
{

View File

@@ -11,6 +11,9 @@ const deploymentContract = JSON.parse(
fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../docs/web/deployment-contract.json"), "utf8"),
) as { responseHeaders: { allResponses: Record<string, string> } };
const isolationHeaders = deploymentContract.responseHeaders.allResponses;
const runtimeEnvironment = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
const unisolatedSingleThreadTest = runtimeEnvironment?.WEB_TEST_UNISOLATED_SINGLE_THREAD === "1";
const developmentHeaders = unisolatedSingleThreadTest ? {} : isolationHeaders;
function preserveIsolationHeaders(): Plugin {
const install = (server: { middlewares: { use: (handler: (request: unknown, response: { setHeader: (name: string, value: string) => void }, next: () => void) => void) => void } }) => {
@@ -26,8 +29,31 @@ function preserveIsolationHeaders(): Plugin {
};
}
function serveEngineVariantModuleImports(): Plugin {
const vendorRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "public/vendor/blender");
const install = (server: { middlewares: { use: (handler: (request: unknown, response: { setHeader: (name: string, value: string | number) => void }, next: () => void) => void) => void } }) => {
server.middlewares.use((request, response, next) => {
const url = new URL((request as { url?: string }).url ?? "/", "http://vite.local");
const match = url.pathname.match(/^\/vendor\/blender\/(single|pthread)\/web_engine\.js$/);
if (!match || !url.searchParams.has("import")) { next(); return; }
const filePath = path.join(vendorRoot, match[1], "web_engine.js");
if (!fs.existsSync(filePath)) { next(); return; }
const stat = fs.statSync(filePath);
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
response.setHeader("Content-Length", stat.size);
for (const [name, value] of Object.entries(developmentHeaders)) response.setHeader(name, value);
fs.createReadStream(filePath).pipe(response as never);
});
};
return {
name: "serve-engine-variant-module-imports",
configureServer: install,
configurePreviewServer: install,
};
}
function localVDBFixture(): Plugin {
const resourceRoot = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb";
const resourceRoot = runtimeEnvironment?.VDB_RESOURCE_ROOT ?? path.join(runtimeEnvironment?.HOME ?? "", "resource-library/blender-web-vdb");
const files = new Map([
["/__vdb_fixture__/manifest", { path: path.join(resourceRoot, "manifests/generated-smoke.nanovdb.json"), type: "application/json" }],
["/__vdb_fixture__/report", { path: path.join(resourceRoot, "reports/generated-smoke-conversion.json"), type: "application/json" }],
@@ -70,18 +96,19 @@ function localVDBFixture(): Plugin {
export default defineConfig({
root: "app",
plugins: [preserveIsolationHeaders(), localVDBFixture(), react()],
plugins: [
serveEngineVariantModuleImports(),
...(unisolatedSingleThreadTest ? [] : [preserveIsolationHeaders()]),
localVDBFixture(),
react(),
],
server: {
port: 5173,
strictPort: false,
headers: {
...isolationHeaders,
},
headers: developmentHeaders,
},
preview: {
headers: {
...isolationHeaders,
},
headers: developmentHeaders,
},
build: {
outDir: "../dist",

View File

@@ -25,8 +25,12 @@ target_include_directories(blender_web_engine_core PUBLIC
add_executable(web_engine web_engine_smoke.cpp)
target_link_libraries(web_engine PRIVATE blender_web_engine_core)
set(WEB_ENGINE_EXPORTED_RUNTIME_METHODS "['ccall','cwrap']")
if(WEB_ENGINE_THREADS)
set(WEB_ENGINE_EXPORTED_RUNTIME_METHODS "['ccall','cwrap','PThread']")
endif()
target_link_options(web_engine PRIVATE
"-sEXPORTED_FUNCTIONS=['_malloc','_free','_web_engine_create','_web_engine_destroy','_web_engine_get_memory_stats','_web_engine_get_live_handles','_web_engine_get_allocated_bytes','_web_engine_open_blend','_web_engine_apply_command','_web_engine_get_scene_snapshot','_web_engine_save_blend','_web_engine_free_buffer','_web_engine_last_error_code','_web_engine_last_error_message']"
"-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
"-sEXPORTED_RUNTIME_METHODS=${WEB_ENGINE_EXPORTED_RUNTIME_METHODS}"
)
set_target_properties(web_engine PROPERTIES OUTPUT_NAME "web_engine")

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "blender-web-editor",
"version": "0.1.0",
"version": "0.1.0-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "blender-web-editor",
"version": "0.1.0",
"version": "0.1.0-rc.1",
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"

View File

@@ -1,7 +1,7 @@
{
"name": "blender-web-editor",
"private": true,
"version": "0.1.0",
"version": "0.1.0-rc.1",
"type": "module",
"scripts": {
"dev": "vite --config app/vite.config.ts",
@@ -10,6 +10,25 @@
"lint": "eslint app",
"check:local-deps": "bash ../tools/web/check-local-deps.sh",
"test": "node --test tests/**/*.test.mjs",
"test:engine-manifest-v2": "node --test tests/unit/engine-manifest-v2.test.mjs",
"test:engine-variant-install": "node ../tools/web/check-engine-variant-install.mjs",
"test:engine-variant-selection": "node --test tests/unit/engine-variant-selection.test.mjs",
"test:engine-variant-fallback": "node --test tests/unit/engine-variant-fallback.test.mjs",
"test:engine-upgrade-safety": "node --test --test-name-pattern=M6-08 tests/unit/engine-variant-selection.test.mjs tests/unit/engine-variant-fallback.test.mjs && playwright test --config playwright.config.ts tests/e2e/engine-upgrade-safety.spec.ts",
"test:user-actions": "node --test tests/unit/user-action-state.test.mjs tests/unit/project-action-mutex.test.mjs tests/unit/file-byte-reader.test.mjs tests/unit/save-transaction.test.mjs tests/unit/dirty-state.test.mjs && playwright test --config playwright.config.ts tests/e2e/user-action-state.spec.ts tests/e2e/project-action-mutex.spec.ts tests/e2e/file-import-progress.spec.ts tests/e2e/save-interruption.spec.ts tests/e2e/dirty-state.spec.ts",
"test:deployment-http": "node ../tools/web/check-deployment-http.mjs",
"test:deployment-runbook": "node ../tools/web/check-deployment-runbook.mjs",
"test:upgrade-runbook": "node ../tools/web/check-upgrade-runbook.mjs",
"test:rollback-runbook": "node ../tools/web/check-rollback-runbook.mjs",
"test:operations-diagnostics": "node ../tools/web/check-operations-diagnostics.mjs",
"test:operations-rehearsal": "node ../tools/web/run-operations-rehearsal.mjs",
"test:deployment-cache": "npm run build && node ../tools/web/check-deployment-cache.mjs",
"test:archive-offline": "npm run build && node ../tools/web/create-offline-release.mjs && playwright test --config playwright.archive.config.ts",
"test:binary-archive": "node ../tools/web/check-binary-archive.mjs",
"test:source-archive": "node ../tools/web/check-source-archive.mjs",
"test:single-thread-unisolated": "playwright test --config playwright.single-thread.config.ts",
"test:pthread-engine": "playwright test --config playwright.config.ts tests/e2e/pthread-engine-isolated.spec.ts",
"test:pthread-fallback": "playwright test --config playwright.config.ts tests/e2e/pthread-fallback.spec.ts",
"test:e2e": "playwright test --config playwright.config.ts",
"test:capability-gates": "playwright test --config playwright.config.ts -g \"undeclared capability protocols\"",
"test:simulation-cache": "playwright test --config playwright.config.ts -g \"Simulation caches\"",
@@ -17,6 +36,7 @@
"test:network-interruption": "playwright test --config playwright.config.ts tests/e2e/network-interruption.spec.ts",
"test:device-loss": "playwright test --config playwright.config.ts tests/e2e/device-loss.spec.ts",
"test:oom-recovery": "playwright test --config playwright.config.ts tests/e2e/oom-recovery.spec.ts",
"test:storage-quota": "playwright test --config playwright.config.ts -g \"real OPFS quota\"",
"test:texture-4k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-4k-performance.spec.ts",
"test:texture-8k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-8k-performance.spec.ts",
"test:physics-main-reader": "node ../tools/web/check-physics-main-reader.mjs",
@@ -88,8 +108,19 @@
"test:v1-acceptance-evidence": "node ../tools/web/check-v1-acceptance-evidence.mjs",
"test:v1-acceptance-release-binding": "node ../tools/web/check-v1-acceptance-release-binding.mjs",
"test:status-consistency": "node ../tools/web/check-status-consistency.mjs",
"test:ci-report": "node ../tools/web/check-ci-report.mjs",
"test:ci-failure-report": "node ../tools/web/check-ci-failure-report.mjs",
"ci:quick": "node ../tools/web/run-ci-lane.mjs quick",
"ci:chromium": "node ../tools/web/run-ci-lane.mjs chromium",
"ci:release": "node ../tools/web/run-ci-lane.mjs release",
"release:v1-acceptance": "node ../tools/web/run-v1-acceptance.mjs",
"release:offline": "npm run build && node ../tools/web/check-offline-reproducibility.mjs",
"release:rc-manifest": "node ../tools/web/create-rc-manifest.mjs && node ../tools/web/check-rc-manifest.mjs",
"test:rc-manifest": "node ../tools/web/check-rc-manifest.mjs",
"test:rc-release-notes": "node ../tools/web/check-rc-release-notes.mjs",
"test:rc-known-limitations": "node ../tools/web/check-rc-known-limitations.mjs",
"test:rc-release-recovery": "node ../tools/web/check-rc-release-recovery.mjs",
"test:rc-docs": "npm run test:rc-release-notes && npm run test:rc-known-limitations && npm run test:rc-release-recovery",
"diagnose:depsgraph": "node ../tools/web/diagnose-depsgraph.mjs"
},
"dependencies": {

View File

@@ -0,0 +1,42 @@
import path from "node:path";
import { execFileSync } from "node:child_process";
import { defineConfig, devices } from "@playwright/test";
function resolveChromePath(): string | undefined {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
try { return execFileSync("which", ["google-chrome"], { encoding: "utf8" }).trim() || undefined; }
catch { return undefined; }
}
const chromePath = resolveChromePath();
const testPort = Number.parseInt(process.env.WEB_TEST_PORT ?? "5189", 10) || 5189;
const testOrigin = `http://127.0.0.1:${testPort}`;
const archive = path.resolve(process.env.M6_BINARY_ARCHIVE ?? "../release/blender-web-offline.tar.gz");
export default defineConfig({
testDir: "tests/e2e",
testMatch: "archive-offline.spec.ts",
timeout: 180_000,
expect: { timeout: 30_000 },
fullyParallel: false,
workers: 1,
reporter: [["list"]],
use: {
baseURL: testOrigin,
...devices["Desktop Chrome"],
acceptDownloads: true,
headless: true,
launchOptions: {
...(chromePath ? { executablePath: chromePath } : {}),
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
},
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
webServer: {
command: `node ../tools/web/archive-deployment-server.mjs --archive ${JSON.stringify(archive)} --port ${testPort}`,
url: testOrigin,
reuseExistingServer: false,
timeout: 60_000,
},
});

View File

@@ -1,11 +1,19 @@
import { defineConfig, devices } from "@playwright/test";
import { execFileSync } from "node:child_process";
const chromePath = process.env.CHROME_PATH ?? "/home/mes123456/.local/bin/google-chrome";
function resolveChromePath(): string | undefined {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
try { return execFileSync("which", ["google-chrome"], { encoding: "utf8" }).trim() || undefined; }
catch { return undefined; }
}
const chromePath = resolveChromePath();
const testPort = Number.parseInt(process.env.WEB_TEST_PORT ?? "5173", 10) || 5173;
const testOrigin = `http://127.0.0.1:${testPort}`;
export default defineConfig({
testDir: "tests/e2e",
testIgnore: ["archive-offline.spec.ts", "single-thread-unisolated.spec.ts"],
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
@@ -15,7 +23,7 @@ export default defineConfig({
...devices["Desktop Chrome"],
headless: true,
launchOptions: {
executablePath: chromePath,
...(chromePath ? { executablePath: chromePath } : {}),
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis", "--use-webgpu-adapter=swiftshader"],
},
screenshot: "only-on-failure",

View File

@@ -1,4 +1,13 @@
import { defineConfig, devices } from "@playwright/test";
import { execFileSync } from "node:child_process";
function resolveChromePath(): string | undefined {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
try { return execFileSync("which", ["google-chrome"], { encoding: "utf8" }).trim() || undefined; }
catch { return undefined; }
}
const chromePath = resolveChromePath();
const testPort = Number.parseInt(process.env.WEB_TEST_PORT ?? "5174", 10) || 5174;
const testOrigin = `http://127.0.0.1:${testPort}`;
@@ -11,7 +20,7 @@ export default defineConfig({
fullyParallel: false,
reporter: [["list"]],
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"], launchOptions: { executablePath: process.env.CHROME_PATH ?? "/home/mes123456/.local/bin/google-chrome", args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] } } },
{ name: "chromium", use: { ...devices["Desktop Chrome"], launchOptions: { ...(chromePath ? { executablePath: chromePath } : {}), args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] } } },
],
use: {
baseURL: testOrigin,

View File

@@ -0,0 +1,39 @@
import { defineConfig, devices } from "@playwright/test";
import { execFileSync } from "node:child_process";
function resolveChromePath(): string | undefined {
if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
try { return execFileSync("which", ["google-chrome"], { encoding: "utf8" }).trim() || undefined; }
catch { return undefined; }
}
const chromePath = resolveChromePath();
const testPort = Number.parseInt(process.env.WEB_TEST_PORT ?? "5202", 10) || 5202;
const testOrigin = `http://127.0.0.1:${testPort}`;
export default defineConfig({
testDir: "tests/e2e",
testMatch: "single-thread-unisolated.spec.ts",
timeout: 45_000,
expect: { timeout: 20_000 },
fullyParallel: false,
reporter: [["list"]],
use: {
baseURL: testOrigin,
...devices["Desktop Chrome"],
headless: true,
launchOptions: {
...(chromePath ? { executablePath: chromePath } : {}),
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
},
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
webServer: {
command: `WEB_TEST_UNISOLATED_SINGLE_THREAD=1 npm run dev -- --host 127.0.0.1 --port ${testPort} --strictPort`,
cwd: import.meta.dirname,
url: testOrigin,
reuseExistingServer: false,
timeout: 30_000,
},
});

View File

@@ -0,0 +1,53 @@
export interface DirtyState {
currentMainRevision: number;
committedMainRevision: number;
dirty: boolean;
}
export type DirtyStateResult =
| { ok: true; state: DirtyState }
| { ok: false; state: DirtyState; errorCode: "DIRTY_REVISION_INVALID" | "DIRTY_REVISION_STALE" | "DIRTY_SAVE_REVISION_MISMATCH" };
function validRevision(revision: number): boolean {
return Number.isSafeInteger(revision) && revision >= 0;
}
export function createDirtyState(revision = 0): DirtyState {
if (!validRevision(revision)) throw new Error("DIRTY_REVISION_INVALID");
return { currentMainRevision: revision, committedMainRevision: revision, dirty: false };
}
export function recoverDirtyState(currentMainRevision: number, committedMainRevision: number): DirtyState {
if (!validRevision(currentMainRevision) || !validRevision(committedMainRevision) || currentMainRevision < committedMainRevision) {
throw new Error("DIRTY_REVISION_INVALID");
}
return { currentMainRevision, committedMainRevision, dirty: currentMainRevision !== committedMainRevision };
}
export function acceptMainTransaction(state: DirtyState, revision: number): DirtyStateResult {
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
if (revision <= state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_REVISION_STALE" };
return {
ok: true,
state: { ...state, currentMainRevision: revision, dirty: revision !== state.committedMainRevision },
};
}
export function acceptMainSave(state: DirtyState, revision: number): DirtyStateResult {
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
if (revision !== state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_SAVE_REVISION_MISMATCH" };
return { ok: true, state: { currentMainRevision: revision, committedMainRevision: revision, dirty: false } };
}
export function acceptHistoryTransaction(state: DirtyState, revision: number, matchesCommittedContent: boolean): DirtyStateResult {
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
if (revision <= state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_REVISION_STALE" };
return {
ok: true,
state: {
currentMainRevision: revision,
committedMainRevision: matchesCommittedContent ? revision : state.committedMainRevision,
dirty: !matchesCommittedContent,
},
};
}

View File

@@ -0,0 +1,96 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { WebEngineManifestV2, WebEngineVariantV2 } from "./manifest";
export interface WasmThreadingCapabilities {
crossOriginIsolated: boolean;
sharedArrayBuffer: boolean;
worker: boolean;
}
export type WebEngineVariantPolicy = "AUTO" | "SINGLE_REQUIRED" | "PTHREAD_REQUIRED";
export interface WebEngineVariantSelection {
policy: WebEngineVariantPolicy;
selectedVariant: WebEngineVariantV2 | null;
pthreadGate: CapabilityGateResult;
}
export type WebEngineReleaseBinding =
| { status: "READY"; releaseId: string; manifest: WebEngineManifestV2 }
| {
status: "REFRESH_REQUIRED";
expectedReleaseId: string;
actualReleaseId: string;
manifest: null;
};
export function bindWebEngineRelease(
expectedReleaseId: string,
manifest: WebEngineManifestV2,
): WebEngineReleaseBinding {
if (manifest.releaseId === expectedReleaseId) {
return { status: "READY", releaseId: manifest.releaseId, manifest };
}
return {
status: "REFRESH_REQUIRED",
expectedReleaseId,
actualReleaseId: manifest.releaseId,
manifest: null,
};
}
export function gateWasmThreadingCapability(capabilities: WasmThreadingCapabilities): CapabilityGateResult {
const issues = [];
if (!capabilities.crossOriginIsolated) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Cross-origin isolation is required for the pthread WASM engine",
"crossOriginIsolated",
));
}
if (!capabilities.sharedArrayBuffer) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"SharedArrayBuffer is required for the pthread WASM engine",
"sharedArrayBuffer",
));
}
if (!capabilities.worker) {
issues.push(capabilityIssue(
"PLATFORM_CAPABILITY_UNAVAILABLE",
"Worker is required for the pthread WASM engine",
"worker",
));
}
return issues.length > 0
? blockedGate("M6-02", "WASM_PTHREAD_ENGINE", issues)
: readyGate("M6-02", "WASM_PTHREAD_ENGINE");
}
export function selectWebEngineVariant(
manifest: WebEngineManifestV2,
policy: WebEngineVariantPolicy,
capabilities: WasmThreadingCapabilities,
): WebEngineVariantSelection {
const single = manifest.variants.find((variant) => variant.id === "single");
const pthread = manifest.variants.find((variant) => variant.id === "pthread");
if (!single || !pthread) throw new Error("ENGINE_MANIFEST_VARIANTS_MISSING");
const pthreadGate = gateWasmThreadingCapability(capabilities);
if (policy === "SINGLE_REQUIRED") return { policy, selectedVariant: single, pthreadGate };
if (policy === "AUTO") {
return {
policy,
selectedVariant: pthreadGate.status === "READY" ? pthread : single,
pthreadGate,
};
}
if (policy === "PTHREAD_REQUIRED") {
return {
policy,
selectedVariant: pthreadGate.status === "READY" ? pthread : null,
pthreadGate,
};
}
throw new Error("ENGINE_VARIANT_POLICY_INVALID");
}

View File

@@ -8,6 +8,7 @@ export type ErrorCode =
| "WASM_INIT_FAILED"
| "WASM_OUT_OF_MEMORY"
| "BLEND_READ_FAILED"
| "OPEN_CANCELLED"
| "BLEND_WRITE_FAILED"
| "STORAGE_QUOTA"
| "STORAGE_TRANSACTION"

View File

@@ -0,0 +1,132 @@
export const LARGE_FILE_IMPORT_BYTES = 512 * 1024;
export const DEFAULT_FILE_READ_PROGRESS_CHUNK_BYTES = 1024 * 1024;
export const DEFAULT_FILE_READ_YIELD_BYTES = 4 * 1024 * 1024;
export type FileReadPhase = "STARTED" | "READING" | "COMPLETED" | "CANCELLED";
export interface FileReadProgress {
phase: FileReadPhase;
bytesRead: number;
totalBytes: number;
fraction: number;
}
export interface FileByteSource {
readonly size: number;
stream(): ReadableStream<Uint8Array>;
}
export interface FileByteReadOptions {
signal: AbortSignal;
onProgress?: (progress: FileReadProgress) => void;
onResourceState?: (state: FileByteReadResourceState) => void;
yieldControl?: () => Promise<void>;
progressChunkBytes?: number;
yieldEveryBytes?: number;
}
export interface FileByteReadResourceState {
liveReaders: number;
liveInputBytes: number;
liveStagingFiles: number;
}
export type FileByteReadErrorCode =
| "FILE_READ_CANCELLED"
| "FILE_READ_ALLOCATION_FAILED"
| "FILE_READ_SIZE_MISMATCH";
export class FileByteReadError extends Error {
readonly code: FileByteReadErrorCode;
constructor(code: FileByteReadErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "FileByteReadError";
this.code = code;
}
}
function progress(phase: FileReadPhase, bytesRead: number, totalBytes: number): FileReadProgress {
return {
phase,
bytesRead,
totalBytes,
fraction: totalBytes === 0 ? (phase === "COMPLETED" ? 1 : 0) : bytesRead / totalBytes,
};
}
export async function readFileBytes(source: FileByteSource, options: FileByteReadOptions): Promise<ArrayBuffer> {
const { signal, onProgress, onResourceState, yieldControl } = options;
const totalBytes = source.size;
const progressChunkBytes = options.progressChunkBytes ?? DEFAULT_FILE_READ_PROGRESS_CHUNK_BYTES;
const yieldEveryBytes = options.yieldEveryBytes ?? DEFAULT_FILE_READ_YIELD_BYTES;
if (!Number.isSafeInteger(totalBytes) || totalBytes < 0) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "file size is not a non-negative safe integer");
}
if (!Number.isSafeInteger(progressChunkBytes) || progressChunkBytes <= 0 || !Number.isSafeInteger(yieldEveryBytes) || yieldEveryBytes <= 0) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "progress or yield byte interval is invalid");
}
let outputBuffer: ArrayBuffer;
try {
outputBuffer = new ArrayBuffer(totalBytes);
}
catch {
throw new FileByteReadError("FILE_READ_ALLOCATION_FAILED", `could not allocate ${totalBytes} bytes`);
}
const output = new Uint8Array(outputBuffer);
const reader = source.stream().getReader();
onResourceState?.({ liveReaders: 1, liveInputBytes: totalBytes, liveStagingFiles: 0 });
let bytesRead = 0;
let nextYield = yieldEveryBytes;
let aborted = signal.aborted;
let completed = false;
const abort = (): void => {
aborted = true;
void reader.cancel("FILE_READ_CANCELLED").catch(() => undefined);
};
signal.addEventListener("abort", abort, { once: true });
onProgress?.(progress("STARTED", 0, totalBytes));
try {
while (true) {
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
const { done, value } = await reader.read();
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
if (done) break;
if (!value || bytesRead + value.byteLength > totalBytes) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "stream exceeded the declared file size");
}
for (let offset = 0; offset < value.byteLength; offset += progressChunkBytes) {
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
const chunk = value.subarray(offset, Math.min(value.byteLength, offset + progressChunkBytes));
output.set(chunk, bytesRead);
bytesRead += chunk.byteLength;
onProgress?.(progress("READING", bytesRead, totalBytes));
if (yieldControl && bytesRead >= nextYield && bytesRead < totalBytes) {
nextYield = bytesRead + yieldEveryBytes;
await yieldControl();
}
}
}
if (bytesRead !== totalBytes) {
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", `stream ended at ${bytesRead} of ${totalBytes} bytes`);
}
onProgress?.(progress("COMPLETED", bytesRead, totalBytes));
completed = true;
return outputBuffer;
}
catch (error) {
if (error instanceof FileByteReadError && error.code === "FILE_READ_CANCELLED") {
onProgress?.(progress("CANCELLED", bytesRead, totalBytes));
}
throw error;
}
finally {
signal.removeEventListener("abort", abort);
reader.releaseLock();
if (!completed) output.fill(0);
onResourceState?.({ liveReaders: 0, liveInputBytes: 0, liveStagingFiles: 0 });
}
}

View File

@@ -19,26 +19,256 @@ export interface WebEngineManifest {
wasm: WasmResource[];
}
export async function loadWebEngineManifest(url = "/engine-manifest.json"): Promise<WebEngineManifest> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`Engine manifest request failed: ${response.status}`);
const manifest = await response.json() as Partial<WebEngineManifest>;
if (manifest.schemaVersion !== 1 || manifest.protocolVersion !== 1) {
throw new Error("Unsupported engine manifest version");
}
if (!manifest.engine || !manifest.memory || !Array.isArray(manifest.wasm)) {
throw new Error("Invalid engine manifest shape");
}
return manifest as WebEngineManifest;
export const WEB_ENGINE_MANIFEST_V2_SCHEMA = 2 as const;
export const WEB_ENGINE_WASM_PAGE_BYTES = 65_536 as const;
export const WEB_ENGINE_MEMORY_LIMITS = {
minimumInitialPages: 256,
maximumPages: 32_768,
} as const;
export type WebEngineVariantId = "single" | "pthread";
export interface WebEngineVariantResourceV2 {
fileName: string;
url: string;
sha256: string;
}
export async function verifyWasmResource(resource: WasmResource): Promise<void> {
export interface WebEngineVariantMemoryV2<Shared extends boolean = boolean> {
initialPages: number;
maximumPages: number;
shared: Shared;
}
export interface WebEngineSingleVariantV2 {
id: "single";
memory: WebEngineVariantMemoryV2<false>;
resources: {
js: WebEngineVariantResourceV2;
wasm: WebEngineVariantResourceV2;
pthreadWorker?: never;
};
}
export interface WebEnginePthreadVariantV2 {
id: "pthread";
memory: WebEngineVariantMemoryV2<true>;
resources: {
js: WebEngineVariantResourceV2;
wasm: WebEngineVariantResourceV2;
pthreadWorker: WebEngineVariantResourceV2;
};
}
export type WebEngineVariantV2 = WebEngineSingleVariantV2 | WebEnginePthreadVariantV2;
export interface WebEngineManifestV2 {
schemaVersion: typeof WEB_ENGINE_MANIFEST_V2_SCHEMA;
protocolVersion: 1;
releaseId: string;
engineVersion: string;
engine: "blender-wasm";
variants: [WebEngineSingleVariantV2, WebEnginePthreadVariantV2];
}
export type LoadedWebEngineManifest = WebEngineManifest | WebEngineManifestV2;
export type WebEngineManifestValidationCode = "PROTOCOL_MISMATCH" | "ENGINE_MANIFEST_INVALID";
export class WebEngineManifestValidationError extends Error {
readonly code: WebEngineManifestValidationCode;
readonly path?: string;
constructor(code: WebEngineManifestValidationCode, message: string, path?: string) {
super(message);
this.name = "WebEngineManifestValidationError";
this.code = code;
this.path = path;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const ENGINE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function fail(code: WebEngineManifestValidationCode, message: string, path?: string): never {
throw new WebEngineManifestValidationError(code, message, path);
}
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
const allowedKeys = new Set(allowed);
const unexpected = Object.keys(value).find((key) => !allowedKeys.has(key));
if (unexpected) fail("ENGINE_MANIFEST_INVALID", `${path}.${unexpected} is not allowed`, `${path}.${unexpected}`);
}
function boundedText(value: unknown, path: string, pattern: RegExp): string {
if (typeof value !== "string" || !pattern.test(value)) {
fail("ENGINE_MANIFEST_INVALID", `${path} is invalid`, path);
}
return value;
}
function parseResource(
value: unknown,
path: string,
extension: ".js" | ".wasm",
): WebEngineVariantResourceV2 {
if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is required`, path);
exactKeys(value, ["fileName", "url", "sha256"], path);
const fileName = boundedText(value.fileName, `${path}.fileName`, FILE_NAME);
if (!fileName.endsWith(extension)) {
fail("ENGINE_MANIFEST_INVALID", `${path}.fileName must end with ${extension}`, `${path}.fileName`);
}
if (typeof value.url !== "string" || !value.url.startsWith("/") || /[?#\\\\]/.test(value.url)) {
fail("ENGINE_MANIFEST_INVALID", `${path}.url must be a root-relative URL`, `${path}.url`);
}
const segments = value.url.slice(1).split("/");
if (segments.length === 0 || segments.some((segment) => !FILE_NAME.test(segment) || segment === "." || segment === "..")) {
fail("ENGINE_MANIFEST_INVALID", `${path}.url contains an invalid path segment`, `${path}.url`);
}
if (segments.at(-1) !== fileName) {
fail("ENGINE_MANIFEST_INVALID", `${path}.url must end with fileName`, `${path}.url`);
}
const sha256 = boundedText(value.sha256, `${path}.sha256`, SHA256);
return { fileName, url: value.url, sha256 };
}
function parseMemory(value: unknown, id: WebEngineVariantId, path: string): WebEngineVariantMemoryV2 {
if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is required`, path);
exactKeys(value, ["initialPages", "maximumPages", "shared"], path);
const { initialPages, maximumPages, shared } = value;
if (!Number.isSafeInteger(initialPages) ||
(initialPages as number) < WEB_ENGINE_MEMORY_LIMITS.minimumInitialPages ||
(initialPages as number) > WEB_ENGINE_MEMORY_LIMITS.maximumPages) {
fail("ENGINE_MANIFEST_INVALID", `${path}.initialPages is outside the supported range`, `${path}.initialPages`);
}
if (!Number.isSafeInteger(maximumPages) ||
(maximumPages as number) < (initialPages as number) ||
(maximumPages as number) > WEB_ENGINE_MEMORY_LIMITS.maximumPages) {
fail("ENGINE_MANIFEST_INVALID", `${path}.maximumPages is outside the supported range`, `${path}.maximumPages`);
}
if (shared !== (id === "pthread")) {
fail("ENGINE_MANIFEST_INVALID", `${path}.shared must be ${id === "pthread"}`, `${path}.shared`);
}
return { initialPages: initialPages as number, maximumPages: maximumPages as number, shared };
}
function parseVariant(value: unknown, index: number): WebEngineVariantV2 {
const path = `variants[${index}]`;
if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is invalid`, path);
exactKeys(value, ["id", "memory", "resources"], path);
if (value.id !== "single" && value.id !== "pthread") {
fail("ENGINE_MANIFEST_INVALID", `${path}.id is invalid`, `${path}.id`);
}
const id = value.id;
if (!record(value.resources)) fail("ENGINE_MANIFEST_INVALID", `${path}.resources is required`, `${path}.resources`);
const resourcePath = `${path}.resources`;
exactKeys(value.resources, id === "pthread" ? ["js", "wasm", "pthreadWorker"] : ["js", "wasm"], resourcePath);
if (id === "pthread" && value.resources.pthreadWorker === undefined) {
fail("ENGINE_MANIFEST_INVALID", `${resourcePath}.pthreadWorker is required`, `${resourcePath}.pthreadWorker`);
}
const js = parseResource(value.resources.js, `${resourcePath}.js`, ".js");
const wasm = parseResource(value.resources.wasm, `${resourcePath}.wasm`, ".wasm");
const memory = parseMemory(value.memory, id, `${path}.memory`);
if (id === "pthread") {
const pthreadWorker = parseResource(
value.resources.pthreadWorker,
`${resourcePath}.pthreadWorker`,
".js",
);
return { id, memory: { ...memory, shared: true }, resources: { js, wasm, pthreadWorker } };
}
return { id, memory: { ...memory, shared: false }, resources: { js, wasm } };
}
export function validateWebEngineManifestV2(value: unknown): WebEngineManifestV2 {
if (!record(value) || value.schemaVersion !== WEB_ENGINE_MANIFEST_V2_SCHEMA) {
fail("PROTOCOL_MISMATCH", "Unsupported engine manifest schema", "schemaVersion");
}
exactKeys(value, ["schemaVersion", "protocolVersion", "releaseId", "engineVersion", "engine", "variants"], "manifest");
if (value.protocolVersion !== 1) {
fail("PROTOCOL_MISMATCH", "Unsupported WebEngine protocol version", "protocolVersion");
}
if (value.engine !== "blender-wasm") {
fail("ENGINE_MANIFEST_INVALID", "engine must be blender-wasm", "engine");
}
const engineVersion = boundedText(value.engineVersion, "engineVersion", ENGINE_VERSION);
const releaseId = boundedText(value.releaseId, "releaseId", ENGINE_VERSION);
if (!Array.isArray(value.variants) || value.variants.length !== 2) {
fail("ENGINE_MANIFEST_INVALID", "variants must contain exactly single and pthread", "variants");
}
const variants = value.variants.map(parseVariant);
const byId = new Map(variants.map((variant) => [variant.id, variant]));
if (byId.size !== 2 || !byId.has("single") || !byId.has("pthread")) {
fail("ENGINE_MANIFEST_INVALID", "variants must contain exactly one single and one pthread", "variants");
}
const single = byId.get("single") as WebEngineSingleVariantV2;
const pthread = byId.get("pthread") as WebEnginePthreadVariantV2;
for (const role of ["js", "wasm"] as const) {
if (single.resources[role].url === pthread.resources[role].url) {
fail(
"ENGINE_MANIFEST_INVALID",
`single and pthread ${role} resources must have distinct URLs`,
`variants.${role}`,
);
}
}
if (pthread.resources.pthreadWorker.url === single.resources.js.url) {
fail(
"ENGINE_MANIFEST_INVALID",
"pthread worker must not alias the single JS resource",
"variants.pthreadWorker",
);
}
if (pthread.resources.pthreadWorker.url === pthread.resources.js.url &&
pthread.resources.pthreadWorker.sha256 !== pthread.resources.js.sha256) {
fail(
"ENGINE_MANIFEST_INVALID",
"resources that share a URL must share the same SHA-256",
"variants.pthreadWorker.sha256",
);
}
return {
schemaVersion: WEB_ENGINE_MANIFEST_V2_SCHEMA,
protocolVersion: 1,
releaseId,
engineVersion,
engine: "blender-wasm",
variants: [single, pthread],
};
}
export async function loadWebEngineManifest(url = "/engine-manifest.json"): Promise<LoadedWebEngineManifest> {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`Engine manifest request failed: ${response.status}`);
const manifest = await response.json() as unknown;
if (record(manifest) && manifest.schemaVersion === WEB_ENGINE_MANIFEST_V2_SCHEMA) {
return validateWebEngineManifestV2(manifest);
}
const legacyManifest = manifest as Partial<WebEngineManifest>;
if (legacyManifest.schemaVersion !== 1 || legacyManifest.protocolVersion !== 1) {
throw new Error("Unsupported engine manifest version");
}
if (!legacyManifest.engine || !legacyManifest.memory || !Array.isArray(legacyManifest.wasm)) {
throw new Error("Invalid engine manifest shape");
}
return legacyManifest as WebEngineManifest;
}
export async function verifyWasmResource(
resource: Pick<WasmResource, "url" | "sha256"> & Partial<Pick<WasmResource, "id">>,
): Promise<void> {
const response = await fetch(resource.url, { cache: "no-store" });
if (!response.ok) throw new Error(`WASM resource request failed: ${resource.id}`);
const resourceId = resource.id ?? resource.url;
if (!response.ok) throw new Error(`WASM resource request failed: ${resourceId}`);
const bytes = await response.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", bytes);
const actual = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
if (actual !== resource.sha256.toLowerCase()) {
throw new Error(`WASM resource hash mismatch: ${resource.id}`);
throw new Error(`WASM resource hash mismatch: ${resourceId}`);
}
}

View File

@@ -5,7 +5,11 @@ export interface ProgressEvent {
operation: string;
phase: ProgressPhase;
fraction?: number;
bytesRead?: number;
totalBytes?: number;
message?: string;
revision?: number;
errorCode?: string;
stage?: string;
cancellable?: boolean;
}

View File

@@ -0,0 +1,79 @@
export const PROJECT_ACTION_KINDS = ["OPEN", "SAVE", "CLOSE"] as const;
export type ProjectActionKind = typeof PROJECT_ACTION_KINDS[number];
export interface ProjectActionIdentity {
actionId: string;
kind: ProjectActionKind;
}
export interface ProjectActionMutexState {
owner: ProjectActionIdentity | null;
}
export type ProjectActionConflictReason =
| "REPEATED_OPEN"
| "CONCURRENT_SAVE"
| "CLOSE_DURING_SAVE"
| "PROJECT_ACTION_EXCLUSIVE";
export interface ProjectActionConflict {
code: "USER_ACTION_CONFLICT";
reason: ProjectActionConflictReason;
requested: ProjectActionIdentity;
owner: ProjectActionIdentity;
}
export type ProjectActionAcquireResult =
| { granted: true; state: ProjectActionMutexState; conflict: null }
| { granted: false; state: ProjectActionMutexState; conflict: ProjectActionConflict };
export type ProjectActionReleaseResult =
| { released: true; state: ProjectActionMutexState; errorCode: null }
| {
released: false;
state: ProjectActionMutexState;
errorCode: "PROJECT_ACTION_LOCK_NOT_HELD" | "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH";
};
export const PROJECT_ACTION_CONFLICT_MATRIX: Readonly<Record<ProjectActionKind, Readonly<Record<ProjectActionKind, boolean>>>> = {
OPEN: { OPEN: true, SAVE: true, CLOSE: true },
SAVE: { OPEN: true, SAVE: true, CLOSE: true },
CLOSE: { OPEN: true, SAVE: true, CLOSE: true },
};
export function createProjectActionMutexState(): ProjectActionMutexState {
return { owner: null };
}
function conflictReason(requested: ProjectActionKind, owner: ProjectActionKind): ProjectActionConflictReason {
if (requested === "OPEN" && owner === "OPEN") return "REPEATED_OPEN";
if (requested === "SAVE" && owner === "SAVE") return "CONCURRENT_SAVE";
if (requested === "CLOSE" && owner === "SAVE") return "CLOSE_DURING_SAVE";
return "PROJECT_ACTION_EXCLUSIVE";
}
export function acquireProjectAction(state: ProjectActionMutexState, requested: ProjectActionIdentity): ProjectActionAcquireResult {
const owner = state.owner;
if (!owner) return { granted: true, state: { owner: requested }, conflict: null };
if (!PROJECT_ACTION_CONFLICT_MATRIX[requested.kind][owner.kind]) {
return { granted: true, state: { owner: requested }, conflict: null };
}
return {
granted: false,
state,
conflict: {
code: "USER_ACTION_CONFLICT",
reason: conflictReason(requested.kind, owner.kind),
requested,
owner,
},
};
}
export function releaseProjectAction(state: ProjectActionMutexState, identity: ProjectActionIdentity): ProjectActionReleaseResult {
if (!state.owner) return { released: false, state, errorCode: "PROJECT_ACTION_LOCK_NOT_HELD" };
if (state.owner.kind !== identity.kind || state.owner.actionId !== identity.actionId) {
return { released: false, state, errorCode: "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH" };
}
return { released: true, state: createProjectActionMutexState(), errorCode: null };
}

View File

@@ -0,0 +1,81 @@
export const SAVE_ATTEMPT_STAGES = ["SERIALIZE", "OPFS_STAGE", "SCENE_COMMIT", "METADATA_COMMIT"] as const;
export type SaveAttemptStage = typeof SAVE_ATTEMPT_STAGES[number];
export interface SaveCommitIdentity {
revision: number;
sha256: string | null;
}
export interface SaveTransactionState {
status: "IDLE" | "RUNNING" | "SUCCEEDED" | "FAILED";
stage: SaveAttemptStage | null;
committed: SaveCommitIdentity;
candidate: SaveCommitIdentity | null;
errorCode: string | null;
}
export type SaveTransactionResult =
| { ok: true; state: SaveTransactionState }
| { ok: false; state: SaveTransactionState; errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" | "SAVE_TRANSACTION_COMMIT_MISMATCH" };
const SHA256 = /^[a-f0-9]{64}$/;
function validIdentity(identity: SaveCommitIdentity, allowNullHash: boolean): boolean {
return Number.isSafeInteger(identity.revision) && identity.revision >= 0 &&
(allowNullHash ? identity.sha256 === null || SHA256.test(identity.sha256) : typeof identity.sha256 === "string" && SHA256.test(identity.sha256));
}
export function createSaveTransactionState(committed: SaveCommitIdentity): SaveTransactionState {
if (!validIdentity(committed, true)) throw new Error("SAVE_TRANSACTION_COMMIT_INVALID");
return { status: "IDLE", stage: null, committed, candidate: null, errorCode: null };
}
export function beginSaveTransaction(state: SaveTransactionState, targetRevision: number): SaveTransactionResult {
if (state.status === "RUNNING" || !Number.isSafeInteger(targetRevision) || targetRevision < state.committed.revision) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
return {
ok: true,
state: {
status: "RUNNING",
stage: "SERIALIZE",
committed: state.committed,
candidate: { revision: targetRevision, sha256: null },
errorCode: null,
},
};
}
export function advanceSaveTransaction(state: SaveTransactionState, stage: SaveAttemptStage, candidateSha256?: string): SaveTransactionResult {
if (state.status !== "RUNNING" || !state.stage || !state.candidate) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
const currentIndex = SAVE_ATTEMPT_STAGES.indexOf(state.stage);
const nextIndex = SAVE_ATTEMPT_STAGES.indexOf(stage);
if (nextIndex !== currentIndex + 1 || (stage === "OPFS_STAGE" && (!candidateSha256 || !SHA256.test(candidateSha256)))) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
return {
ok: true,
state: {
...state,
stage,
candidate: stage === "OPFS_STAGE" ? { ...state.candidate, sha256: candidateSha256! } : state.candidate,
},
};
}
export function failSaveTransaction(state: SaveTransactionState, errorCode: string): SaveTransactionState {
if (state.status !== "RUNNING" || !/^[A-Z][A-Z0-9_]*$/.test(errorCode)) return state;
return { ...state, status: "FAILED", errorCode };
}
export function commitSaveTransaction(state: SaveTransactionState, persisted: SaveCommitIdentity): SaveTransactionResult {
if (state.status !== "RUNNING" || state.stage !== "METADATA_COMMIT" || !state.candidate || !validIdentity(persisted, false)) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
}
if (persisted.revision !== state.candidate.revision || persisted.sha256 !== state.candidate.sha256) {
return { ok: false, state, errorCode: "SAVE_TRANSACTION_COMMIT_MISMATCH" };
}
return { ok: true, state: { status: "SUCCEEDED", stage: "METADATA_COMMIT", committed: persisted, candidate: persisted, errorCode: null } };
}

View File

@@ -192,7 +192,7 @@ export interface StorageRequest {
| { type: "smoke" }
| { type: "info" }
| { type: "ensureProject"; projectId: string }
| { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "quota" }
| { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota" }
| { type: "recoverProject"; projectId: string }
| { type: "readProject"; projectId: string }
| { type: "appendOperation"; id: string; projectId: string; revision: number; payload: unknown; inversePayload?: unknown }

View File

@@ -0,0 +1,109 @@
export const USER_ACTION_KINDS = ["IMPORT", "OPEN", "SAVE", "SAVE_AS", "EXPORT"] as const;
export type UserActionKind = typeof USER_ACTION_KINDS[number];
export const USER_ACTION_STATUSES = ["IDLE", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const;
export type UserActionStatus = typeof USER_ACTION_STATUSES[number];
export interface UserActionIdentity {
actionId: string;
kind: UserActionKind;
}
export interface UserActionState {
kind: UserActionKind;
status: UserActionStatus;
identity: UserActionIdentity | null;
errorCode: string | null;
}
export type UserActionEvent =
| { type: "START"; identity: UserActionIdentity }
| { type: "SUCCEED"; identity: UserActionIdentity }
| { type: "FAIL"; identity: UserActionIdentity; errorCode: string }
| { type: "CANCEL"; identity: UserActionIdentity; errorCode?: string }
| { type: "RESET"; kind: UserActionKind };
export type UserActionTransitionErrorCode =
| "USER_ACTION_KIND_MISMATCH"
| "USER_ACTION_IDENTITY_MISMATCH"
| "USER_ACTION_IDENTITY_REUSED"
| "USER_ACTION_INVALID_ERROR_CODE"
| "USER_ACTION_INVALID_TRANSITION";
export type UserActionTransitionResult =
| { ok: true; state: UserActionState }
| { ok: false; state: UserActionState; errorCode: UserActionTransitionErrorCode };
export type UserActionStates = Record<UserActionKind, UserActionState>;
export const USER_ACTION_ALLOWED_TRANSITIONS: Readonly<Record<UserActionStatus, readonly UserActionStatus[]>> = {
IDLE: ["RUNNING"],
RUNNING: ["SUCCEEDED", "FAILED", "CANCELLED"],
SUCCEEDED: ["IDLE", "RUNNING"],
FAILED: ["IDLE", "RUNNING"],
CANCELLED: ["IDLE", "RUNNING"],
};
function idleState(kind: UserActionKind): UserActionState {
return { kind, status: "IDLE", identity: null, errorCode: null };
}
export function createInitialUserActionStates(): UserActionStates {
return Object.fromEntries(USER_ACTION_KINDS.map((kind) => [kind, idleState(kind)])) as UserActionStates;
}
function rejected(state: UserActionState, errorCode: UserActionTransitionErrorCode): UserActionTransitionResult {
return { ok: false, state, errorCode };
}
function isStableErrorCode(value: string): boolean {
return /^[A-Z][A-Z0-9_]*$/.test(value);
}
export function transitionUserAction(state: UserActionState, event: UserActionEvent): UserActionTransitionResult {
if (event.type === "RESET") {
if (event.kind !== state.kind) return rejected(state, "USER_ACTION_KIND_MISMATCH");
if (!USER_ACTION_ALLOWED_TRANSITIONS[state.status].includes("IDLE")) {
return rejected(state, "USER_ACTION_INVALID_TRANSITION");
}
return { ok: true, state: idleState(state.kind) };
}
if (event.identity.kind !== state.kind) return rejected(state, "USER_ACTION_KIND_MISMATCH");
if (!event.identity.actionId) return rejected(state, "USER_ACTION_IDENTITY_MISMATCH");
if (event.type === "START") {
if (!USER_ACTION_ALLOWED_TRANSITIONS[state.status].includes("RUNNING")) {
return rejected(state, "USER_ACTION_INVALID_TRANSITION");
}
if (state.identity?.actionId === event.identity.actionId) {
return rejected(state, "USER_ACTION_IDENTITY_REUSED");
}
return {
ok: true,
state: { kind: state.kind, status: "RUNNING", identity: event.identity, errorCode: null },
};
}
if (state.status !== "RUNNING") return rejected(state, "USER_ACTION_INVALID_TRANSITION");
if (state.identity?.actionId !== event.identity.actionId) {
return rejected(state, "USER_ACTION_IDENTITY_MISMATCH");
}
if (event.type === "SUCCEED") {
return { ok: true, state: { ...state, status: "SUCCEEDED", errorCode: null } };
}
const errorCode = event.type === "CANCEL" ? event.errorCode ?? "USER_ACTION_CANCELLED" : event.errorCode;
if (!isStableErrorCode(errorCode)) return rejected(state, "USER_ACTION_INVALID_ERROR_CODE");
return {
ok: true,
state: { ...state, status: event.type === "CANCEL" ? "CANCELLED" : "FAILED", errorCode },
};
}
export function reduceUserActionStates(states: UserActionStates, event: UserActionEvent): UserActionStates {
const kind = event.type === "RESET" ? event.kind : event.identity.kind;
const result = transitionUserAction(states[kind], event);
return result.ok ? { ...states, [kind]: result.state } : states;
}

View File

@@ -137,6 +137,8 @@ export type WebEngineEditCommand =
export type WebEngineRequest =
| { requestId: string; command: { type: "init" } }
| { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; }
| { requestId: string; command: { type: "cancelOpen"; targetRequestId: string } }
| { requestId: string; command: { type: "openResourceStatus" } }
| { requestId: string; command: { type: "snapshot" } }
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
| { requestId: string; command: { type: "generateLOD"; payload: LODGenerationRequest } }
@@ -154,6 +156,13 @@ export interface WebEngineStatus {
allocatedBytes: number;
}
export interface WebEngineOpenResourceStatus {
activeRequests: number;
liveInputBytes: number;
liveNativeHandles: number;
liveStagingFiles: number;
}
export interface AssetRequestResult {
assetId: string;
status: "external" | "packed" | "packed-unavailable" | "missing" | "blocked";
@@ -202,6 +211,7 @@ export interface WebEngineResult {
capabilityGate?: CapabilityGateResult;
depsgraph?: DepsgraphEvaluationIR;
blend?: ArrayBuffer;
openResources?: WebEngineOpenResourceStatus;
}
export type WebEngineResponse =

View File

@@ -0,0 +1,147 @@
import { expect, test, type BrowserContext, type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { importGLBSemantics } from "../../protocol/glb-import";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
function isLoopbackRequest(value: string): boolean {
const url = new URL(value);
if (!["http:", "https:"].includes(url.protocol)) return true;
return url.hostname === "localhost" || url.hostname === "::1" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
}
async function isolateExternalNetwork(context: BrowserContext, page: Page) {
const externalAttempts: string[] = [];
const requested: string[] = [];
page.on("request", (request) => requested.push(request.url()));
await context.route("**/*", async (route) => {
if (isLoopbackRequest(route.request().url())) await route.continue();
else {
externalAttempts.push(route.request().url());
await route.abort("internetdisconnected");
}
});
return { externalAttempts, requested };
}
async function waitForArchiveBoot(page: Page): Promise<void> {
await page.goto("/");
await expect(page.getByText("Web Blender Modeler V1", { exact: true }).first()).toBeVisible();
await expect(page.locator(".status-bar")).toContainText("Manifest: verified r1");
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready, open a .blend file");
await expect(page.locator(".status-bar")).toContainText("Storage: IndexedDB + OPFS");
await page.evaluate(() => document.fonts.ready);
expect(await page.evaluate(() => document.fonts.status)).toBe("loaded");
}
async function objectCount(page: Page): Promise<number> {
const text = await page.getByTestId("scene-stats").innerText();
const match = text.match(/Objects (\d+)/);
if (!match) throw new Error(`scene stats omit object count: ${text}`);
return Number(match[1]);
}
async function stableIds(page: Page) {
return page.locator(".outliner-content .tree-row.child").evaluateAll((rows) => rows.map((row) => ({
objectId: (row as HTMLElement).dataset.nodeId ?? "",
dataId: (row as HTMLElement).dataset.dataId ?? "",
name: row.querySelector("span:nth-of-type(3)")?.textContent ?? "",
})).sort((left, right) => left.objectId.localeCompare(right.objectId)));
}
async function opfsCommit(page: Page, projectId: string) {
return page.evaluate(async (id) => {
const root = await navigator.storage.getDirectory();
const projects = await root.getDirectoryHandle("projects");
const project = await projects.getDirectoryHandle(id);
const manifestFile = await (await project.getFileHandle("scene.blend.meta.json")).getFile();
const manifest = JSON.parse(await manifestFile.text()) as { revision: number; bytes: number; sha256: string };
const blend = await (await project.getFileHandle("scene.blend")).getFile();
const bytes = await blend.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", bytes);
const sha256 = Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
return { ...manifest, actualBytes: bytes.byteLength, actualSha256: sha256 };
}, projectId);
}
test("M6-09 cold boots the extracted binary archive with no external network", async ({ context, page }) => {
const network = await isolateExternalNetwork(context, page);
await waitForArchiveBoot(page);
const paths = network.requested.filter((value) => value.startsWith(page.url().replace(/\/$/, ""))).map((value) => new URL(value).pathname);
expect(paths).toContain("/engine-manifest.json");
expect(paths.some((value) => value.endsWith(".wasm"))).toBe(true);
expect(paths.some((value) => value.endsWith(".css"))).toBe(true);
expect(paths.some((value) => value.includes(".worker-") && value.endsWith(".js"))).toBe(true);
expect(network.externalAttempts).toEqual([]);
});
test("M6-10 reopens and continues an OPFS project offline after Worker reconstruction", async ({ context, page }) => {
const network = await isolateExternalNetwork(context, page);
await waitForArchiveBoot(page);
const input = fs.readFileSync(basicBlend);
const projectId = `m6-offline-${Date.now()}`;
await page.getByTestId("blend-file-input").setInputFiles({
name: `${projectId}.blend`,
mimeType: "application/octet-stream",
buffer: input,
});
await expect(page.getByTestId("engine-status")).toContainText("Engine: SceneIR r");
const initialCount = await objectCount(page);
const initialIds = await stableIds(page);
await page.getByRole("button", { name: "添加立方体" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount + 1);
const editedIds = await stableIds(page);
const firstCreated = editedIds.find((entry) => !initialIds.some((initial) => initial.objectId === entry.objectId));
expect(firstCreated?.objectId).toBeTruthy();
expect(firstCreated?.dataId).toBeTruthy();
await page.getByRole("button", { name: "撤销" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount);
await page.getByRole("button", { name: "重做" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount + 1);
const firstSave = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
await firstSave;
const firstCommit = await opfsCommit(page, projectId);
expect(firstCommit.revision).toBeGreaterThan(0);
expect(firstCommit.bytes).toBe(firstCommit.actualBytes);
expect(firstCommit.sha256).toBe(firstCommit.actualSha256);
expect(firstCommit.sha256).toMatch(/^[a-f0-9]{64}$/);
await page.reload();
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready, open a .blend file");
await page.getByRole("button", { name: "恢复项目" }).click();
await expect(page.getByTestId("engine-status")).toContainText("Recovery: 0 operation(s), 0 quarantined");
expect(await stableIds(page)).toEqual(editedIds);
await page.getByRole("button", { name: "添加立方体" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount + 2);
await page.getByRole("button", { name: "撤销" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount + 1);
await page.getByRole("button", { name: "重做" }).click();
await expect.poll(() => objectCount(page)).toBe(initialCount + 2);
const secondSave = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
await secondSave;
const secondCommit = await opfsCommit(page, projectId);
expect(secondCommit.revision).toBeGreaterThan(firstCommit.revision);
expect(secondCommit.sha256).toBe(secondCommit.actualSha256);
expect(secondCommit.sha256).not.toBe(firstCommit.sha256);
const glbDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 GLB" }).click();
const glb = await glbDownload;
const glbPath = await glb.path();
expect(glbPath).toBeTruthy();
const glbBytes = fs.readFileSync(glbPath!);
const glbBuffer = glbBytes.buffer.slice(glbBytes.byteOffset, glbBytes.byteOffset + glbBytes.byteLength);
const imported = importGLBSemantics(glbBuffer);
expect(imported.meshCount).toBeGreaterThan(0);
expect(imported.primitiveCount).toBeGreaterThan(0);
expect(imported.meshes.some((mesh) => mesh.blenderId === firstCreated?.dataId)).toBe(true);
expect(network.externalAttempts).toEqual([]);
});

View File

@@ -0,0 +1,69 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
test("M7-06 changes dirty only for accepted Main transactions and matching saves", async ({ page }) => {
await page.goto("/");
await page.getByTestId("blend-file-input").setInputFiles(attributeBlend);
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-dirty", "false");
const openedRevision = await app.getAttribute("data-current-main-revision");
await expect(app).toHaveAttribute("data-committed-main-revision", openedRevision ?? "");
await page.getByRole("button", { name: "Modeling" }).click();
await expect(app).toHaveAttribute("data-dirty", "false");
await expect(app).toHaveAttribute("data-current-main-revision", openedRevision ?? "");
await page.getByRole("button", { name: "预览 Decimate" }).click();
await expect(page.getByTestId("engine-status")).toContainText("Preview");
await expect(app).toHaveAttribute("data-dirty", "false");
await expect(app).toHaveAttribute("data-current-main-revision", openedRevision ?? "");
await page.locator("label.file-button input[type=file]").setInputFiles({
name: "invalid.png",
mimeType: "image/png",
buffer: Buffer.from("not-a-png"),
});
await expect(app).toHaveAttribute("data-user-action-import-status", "FAILED");
await expect(app).toHaveAttribute("data-dirty", "false");
await expect(app).toHaveAttribute("data-current-main-revision", openedRevision ?? "");
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(app).toHaveAttribute("data-dirty", "true");
await expect(page.getByTestId("dirty-status")).toHaveText("未保存");
const editedRevision = Number(await app.getAttribute("data-current-main-revision"));
expect(editedRevision).toBeGreaterThan(Number(openedRevision));
await expect(app).toHaveAttribute("data-committed-main-revision", openedRevision ?? "");
const download = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
await download;
await expect(app).toHaveAttribute("data-dirty", "false");
await expect(page.getByTestId("dirty-status")).toHaveText("已保存");
await expect(app).toHaveAttribute("data-committed-main-revision", String(editedRevision));
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(app).toHaveAttribute("data-dirty", "true");
await page.getByRole("button", { name: "撤销" }).click();
await expect(app).toHaveAttribute("data-dirty", "false");
const undoRevision = Number(await app.getAttribute("data-current-main-revision"));
expect(undoRevision).toBeGreaterThan(editedRevision);
await page.getByRole("button", { name: "重做" }).click();
await expect(app).toHaveAttribute("data-dirty", "true");
const redoRevision = Number(await app.getAttribute("data-current-main-revision"));
expect(redoRevision).toBeGreaterThan(undoRevision);
await page.getByRole("button", { name: "撤销" }).click();
await expect(app).toHaveAttribute("data-dirty", "false");
await page.getByTestId("blend-file-input").setInputFiles({
name: "invalid.blend",
mimeType: "application/octet-stream",
buffer: Buffer.from([0x42, 0x41, 0x44]),
});
await expect(app).toHaveAttribute("data-user-action-open-status", "FAILED");
await expect(app).toHaveAttribute("data-dirty", "false");
const finalUndoRevision = await app.getAttribute("data-current-main-revision");
await expect(app).toHaveAttribute("data-committed-main-revision", finalUndoRevision ?? "");
});

View File

@@ -0,0 +1,92 @@
import { expect, test } from "@playwright/test";
test("refreshes on a release switch and rejects tampered WASM without fallback or open", async ({ page }) => {
await page.goto("/");
await expect(page.locator(".status-bar")).toContainText("Manifest: verified");
const versionGate = await page.evaluate(async () => {
const { bootstrapWebEngineRelease } = await import("/src/engine-client/engine-variant-bootstrap.ts");
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
let initializationCount = 0;
const outcome = await bootstrapWebEngineRelease(
"blender-wasm-previous",
manifest,
"AUTO",
{ crossOriginIsolated: true, sharedArrayBuffer: true, worker: true },
{
initialize: async () => {
initializationCount += 1;
throw new Error("release mismatch initialized an engine");
},
},
new ArrayBuffer(8),
);
return { result: outcome.result, hasSession: outcome.session !== null, initializationCount };
});
expect(versionGate).toEqual({
result: {
status: "REFRESH_REQUIRED",
expectedReleaseId: "blender-wasm-previous",
actualReleaseId: "blender-wasm-0.1.0-rc.1",
manifest: null,
},
hasSession: false,
initializationCount: 0,
});
const variantRequests: string[] = [];
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (pathname.startsWith("/vendor/blender/single/") || pathname.startsWith("/vendor/blender/pthread/")) {
variantRequests.push(pathname);
}
});
await page.route("**/vendor/blender/pthread/web_engine.wasm", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/wasm",
body: Buffer.from("tampered-pthread-wasm"),
});
});
const integrityGate = await page.evaluate(async () => {
const { detectBrowserCapabilities } = await import("/src/platform/capabilities.ts");
const { bootstrapWebEngineRelease } = await import("/src/engine-client/engine-variant-bootstrap.ts");
const { createBrowserEngineVariantSession } = await import("/src/engine-client/browser-engine-variant-session.ts");
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
try {
await bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
detectBrowserCapabilities(),
{ initialize: createBrowserEngineVariantSession },
new ArrayBuffer(8),
);
return { rejected: false };
}
catch (error) {
const failure = error as Error & {
code?: string;
attempted?: string[];
cause?: { code?: string };
};
return {
rejected: true,
code: failure.code,
causeCode: failure.cause?.code,
attempted: failure.attempted,
};
}
});
expect(integrityGate).toEqual({
rejected: true,
code: "ENGINE_VARIANT_INTEGRITY_FAILED",
causeCode: "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH",
attempted: ["pthread"],
});
expect(variantRequests.filter((url) => url.endsWith("/pthread/web_engine.wasm"))).toHaveLength(1);
expect(variantRequests.some((url) => url.includes("/single/"))).toBe(false);
});

View File

@@ -0,0 +1,111 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
const largeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/image_resource_matrix.blend");
test("M7-03 records exact streamed byte progress for a large valid blend", async ({ page }) => {
test.setTimeout(60_000);
const expectedBytes = fs.statSync(largeBlend).size;
expect(expectedBytes).toBeGreaterThanOrEqual(512 * 1024);
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
await page.getByTestId("blend-file-input").setInputFiles(largeBlend);
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED", { timeout: 30_000 });
await expect(app).toHaveAttribute("data-open-read-phase", "COMPLETED");
await expect(app).toHaveAttribute("data-open-read-bytes", String(expectedBytes));
await expect(app).toHaveAttribute("data-open-read-total", String(expectedBytes));
expect(Number(await app.getAttribute("data-open-read-events"))).toBeGreaterThan(2);
expect(await app.getAttribute("data-open-read-action-id")).toBe(await app.getAttribute("data-user-action-open-id"));
});
test("M7-03 cancels a large streamed open before WebEngine and preserves the current project", async ({ page }) => {
test.setTimeout(60_000);
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
const previousStats = await page.getByTestId("scene-stats").textContent();
const totalBytes = 40 * 1024 * 1024;
const syntheticLargeBlend = Buffer.alloc(totalBytes, 0x7f);
fs.readFileSync(basicBlend).copy(syntheticLargeBlend);
await page.getByTestId("blend-file-input").setInputFiles({
name: "cancelled-large.blend",
mimeType: "application/octet-stream",
buffer: syntheticLargeBlend,
});
const observation = await page.waitForFunction((expectedTotal) => {
const element = document.querySelector<HTMLElement>('[data-testid="open-progress"]');
const bytesRead = Number(element?.getAttribute("data-bytes-read"));
const actualTotal = Number(element?.getAttribute("data-total-bytes"));
const cancel = document.querySelector<HTMLButtonElement>('button[aria-label="取消打开"]');
if (bytesRead <= 0 || bytesRead >= expectedTotal || !cancel) return false;
cancel.click();
return { bytesRead, totalBytes: actualTotal };
}, totalBytes, { polling: "raf", timeout: 10_000 });
const observed = await observation.jsonValue() as { bytesRead: number; totalBytes: number };
expect(observed.bytesRead).toBeLessThan(totalBytes);
expect(observed.totalBytes).toBe(totalBytes);
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-user-action-open-status", "CANCELLED");
await expect(app).toHaveAttribute("data-user-action-open-error", "OPEN_CANCELLED");
await expect(app).toHaveAttribute("data-open-read-phase", "CANCELLED");
await expect(app).toHaveAttribute("data-open-cleanup-reader-count", "0");
await expect(app).toHaveAttribute("data-open-cleanup-reader-bytes", "0");
await expect(app).toHaveAttribute("data-open-cleanup-reader-staging", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-requests", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-bytes", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-handles", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-staging", "0");
const cancelledBytes = Number(await app.getAttribute("data-open-read-bytes"));
expect(cancelledBytes).toBeGreaterThan(0);
expect(cancelledBytes).toBeLessThan(totalBytes);
await expect(page.getByTestId("scene-stats")).toHaveText(previousStats ?? "");
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
expect(await page.evaluate(() => localStorage.getItem("blender-web:last-project-id"))).toBe("basic_scene");
});
test("M7-04 destroys isolated native open resources before reporting cancellation", async ({ page }) => {
test.setTimeout(60_000);
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
const previousStats = await page.getByTestId("scene-stats").textContent();
await page.getByTestId("blend-file-input").setInputFiles(largeBlend);
const cancellation = await page.waitForFunction(() => {
const progress = document.querySelector<HTMLElement>('[data-testid="open-progress"]');
const stage = progress?.dataset.stage;
const cancel = document.querySelector<HTMLButtonElement>('button[aria-label="取消打开"]');
if (!cancel || (stage !== "NATIVE_INITIALIZE" && stage !== "NATIVE_OPENED")) return false;
cancel.click();
return stage;
}, undefined, { polling: "raf", timeout: 10_000 });
expect(["NATIVE_INITIALIZE", "NATIVE_OPENED"]).toContain(await cancellation.jsonValue());
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-user-action-open-status", "CANCELLED");
await expect(app).toHaveAttribute("data-user-action-open-error", "OPEN_CANCELLED");
await expect(app).toHaveAttribute("data-open-cleanup-reader-count", "0");
await expect(app).toHaveAttribute("data-open-cleanup-reader-bytes", "0");
await expect(app).toHaveAttribute("data-open-cleanup-reader-staging", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-requests", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-bytes", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-handles", "0");
await expect(app).toHaveAttribute("data-open-cleanup-engine-staging", "0");
await expect(page.getByTestId("scene-stats")).toHaveText(previousStats ?? "");
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
const download = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
await download;
});

View File

@@ -0,0 +1,95 @@
import { expect, test, type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
async function waitForEngine(page: Page): Promise<void> {
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
}
test("M7-02 rejects a repeated open before it can replace the first project owner", async ({ page }) => {
await waitForEngine(page);
const bytes = Array.from(fs.readFileSync(basicBlend));
await page.evaluate(({ blendBytes }) => {
const input = document.querySelector<HTMLInputElement>("[data-testid=blend-file-input]");
if (!input) throw new Error("blend input missing");
const dispatchOpen = (name: string): void => {
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array(blendBytes)], name, { type: "application/octet-stream" }));
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
};
dispatchOpen("first-open.blend");
dispatchOpen("second-open.blend");
}, { blendBytes: bytes });
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT");
await expect(app).toHaveAttribute("data-project-action-conflict-reason", "REPEATED_OPEN");
await expect(app).toHaveAttribute("data-project-action-conflict-requested", "OPEN");
await expect(app).toHaveAttribute("data-project-action-conflict-owner", "OPEN");
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
expect(await page.evaluate(() => localStorage.getItem("blender-web:last-project-id"))).toBe("first-open");
});
test("M7-02 grants only one concurrent manual save and emits one download", async ({ page }) => {
await waitForEngine(page);
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
let downloadCount = 0;
page.on("download", () => { downloadCount += 1; });
await page.evaluate(() => {
const save = document.querySelector<HTMLButtonElement>('button[aria-label="保存项目"]');
if (!save) throw new Error("save button missing");
save.click();
save.click();
});
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT");
await expect(app).toHaveAttribute("data-project-action-conflict-reason", "CONCURRENT_SAVE");
await expect(app).toHaveAttribute("data-project-action-conflict-requested", "SAVE");
await expect(app).toHaveAttribute("data-project-action-conflict-owner", "SAVE");
await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-save-as-status", "SUCCEEDED");
await expect.poll(() => downloadCount).toBe(1);
});
test("M7-02 blocks close until save commit completes without terminating either worker", async ({ page }) => {
await waitForEngine(page);
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
const firstDownload = page.waitForEvent("download");
await page.evaluate(() => {
const save = document.querySelector<HTMLButtonElement>('button[aria-label="保存项目"]');
const close = document.querySelector<HTMLButtonElement>('button[aria-label="关闭项目"]');
if (!save || !close) throw new Error("project action button missing");
save.click();
close.click();
});
const app = page.locator(".blender-app");
await expect(app).toHaveAttribute("data-project-action-conflict", "USER_ACTION_CONFLICT");
await expect(app).toHaveAttribute("data-project-action-conflict-reason", "CLOSE_DURING_SAVE");
await expect(app).toHaveAttribute("data-project-action-conflict-requested", "CLOSE");
await expect(app).toHaveAttribute("data-project-action-conflict-owner", "SAVE");
await firstDownload;
await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
const secondDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
await secondDownload;
await expect(app).not.toHaveAttribute("data-project-action-conflict");
await page.getByRole("button", { name: "关闭项目" }).click();
await expect(page.getByTestId("scene-stats")).toContainText("Objects 0");
await expect(page.getByRole("button", { name: "关闭项目" })).toBeDisabled();
});

View File

@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
test("loads the selected pthread engine with shared memory and its worker pool", async ({ page }) => {
const pthreadRequests: string[] = [];
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (pathname.startsWith("/vendor/blender/pthread/")) pthreadRequests.push(pathname);
});
const documentResponse = await page.goto("/");
expect(documentResponse).not.toBeNull();
const headers = await documentResponse!.allHeaders();
expect(headers["cross-origin-opener-policy"]).toBe("same-origin");
expect(headers["cross-origin-embedder-policy"]).toBe("require-corp");
const result = await page.evaluate(async () => {
const { detectBrowserCapabilities, selectWebEngineVariant } = await import("/src/platform/capabilities.ts");
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
const selection = selectWebEngineVariant(manifest, "PTHREAD_REQUIRED", detectBrowserCapabilities());
if (!selection.selectedVariant) {
return { selected: null, gate: selection.pthreadGate, shared: false, handle: 0, liveAfterDestroy: -1 };
}
const variant = selection.selectedVariant;
const wasmResponse = await fetch(variant.resources.wasm.url, { cache: "no-store" });
if (!wasmResponse.ok) throw new Error(`pthread WASM request failed: ${wasmResponse.status}`);
const wasmBinary = await wasmResponse.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", wasmBinary);
const actualHash = [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
if (actualHash !== variant.resources.wasm.sha256) throw new Error("pthread WASM hash mismatch");
const imported = await import(/* @vite-ignore */ variant.resources.js.url) as {
default: (options: { wasmBinary: ArrayBuffer }) => Promise<{
HEAPU8: Uint8Array;
_web_engine_create(): number;
_web_engine_destroy(handle: number): void;
_web_engine_get_live_handles(): number;
PThread: {
unusedWorkers: Worker[];
runningWorkers: Worker[];
terminateAllThreads(): void;
};
}>;
};
const module = await imported.default({ wasmBinary });
const handle = module._web_engine_create();
const shared = module.HEAPU8.buffer instanceof SharedArrayBuffer;
const liveBeforeDestroy = module._web_engine_get_live_handles();
const poolWorkersBeforeDispose = module.PThread.unusedWorkers.length + module.PThread.runningWorkers.length;
module._web_engine_destroy(handle);
module.PThread.terminateAllThreads();
return {
selected: variant.id,
gate: selection.pthreadGate,
shared,
handle,
liveBeforeDestroy,
liveAfterDestroy: module._web_engine_get_live_handles(),
poolWorkersBeforeDispose,
poolWorkersAfterDispose: module.PThread.unusedWorkers.length + module.PThread.runningWorkers.length,
};
});
expect(result).toMatchObject({
selected: "pthread",
gate: { taskId: "M6-02", capability: "WASM_PTHREAD_ENGINE", status: "READY", issues: [] },
shared: true,
liveBeforeDestroy: 1,
liveAfterDestroy: 0,
poolWorkersBeforeDispose: 1,
poolWorkersAfterDispose: 0,
});
expect(result.handle).toBeGreaterThan(0);
expect(pthreadRequests.filter((url) => url.endsWith("/web_engine.wasm"))).toHaveLength(1);
expect(pthreadRequests.filter((url) => url.endsWith("/web_engine.js")).length).toBeGreaterThanOrEqual(2);
});

View File

@@ -0,0 +1,58 @@
import { expect, test } from "@playwright/test";
test("cleans an initialized pthread attempt before one single fallback", async ({ page }) => {
const variantRequests: string[] = [];
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (pathname.startsWith("/vendor/blender/single/") || pathname.startsWith("/vendor/blender/pthread/")) {
variantRequests.push(pathname);
}
});
await page.goto("/");
await expect(page.locator(".status-bar")).toContainText("Manifest: verified");
variantRequests.length = 0;
const report = await page.evaluate(async () => {
const { detectBrowserCapabilities } = await import("/src/platform/capabilities.ts");
const {
bootstrapWebEngineRelease,
engineVariantStatusLabel,
} = await import("/src/engine-client/engine-variant-bootstrap.ts");
const { createBrowserEngineVariantSession } = await import("/src/engine-client/browser-engine-variant-session.ts");
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json());
const outcome = await bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
detectBrowserCapabilities(),
{ initialize: createBrowserEngineVariantSession },
undefined,
{ failPthreadAfterInitialize: true, seedTrackedPthreadResources: true },
);
if (!outcome.session) throw new Error("current release unexpectedly requires refresh");
const activeBeforeDispose = outcome.session.resourceState();
const label = engineVariantStatusLabel(outcome.result);
const activeAfterDispose = await outcome.session.dispose();
return { result: outcome.result, activeBeforeDispose, activeAfterDispose, label };
});
expect(report.result).toMatchObject({
status: "READY",
selected: "single",
attempted: ["pthread", "single"],
fallbackReason: {
code: "PTHREAD_INITIALIZATION_FAILED",
message: "TEST_PTHREAD_INITIALIZATION_FAILURE",
},
openCount: 0,
failedAttemptCleanup: { handles: 0, workers: 0, timers: 0, pendingRequests: 0 },
});
expect(report.activeBeforeDispose).toEqual({ handles: 1, workers: 0, timers: 0, pendingRequests: 0 });
expect(report.activeAfterDispose).toEqual({ handles: 0, workers: 0, timers: 0, pendingRequests: 0 });
expect(report.label).toBe("Runtime: single (pthread fallback: PTHREAD_INITIALIZATION_FAILED)");
expect(report.label).not.toMatch(/pthread\s+ready/i);
expect(variantRequests.filter((url) => url.endsWith("/pthread/web_engine.wasm"))).toHaveLength(1);
expect(variantRequests.filter((url) => url.endsWith("/single/web_engine.wasm"))).toHaveLength(1);
expect(variantRequests.filter((url) => url.endsWith("/pthread/web_engine.js")).length).toBeGreaterThanOrEqual(3);
expect(variantRequests.filter((url) => url.endsWith("/single/web_engine.js")).length).toBeGreaterThanOrEqual(2);
});

View File

@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
test("M7-05 restores old revision, hash and bytes after every storage save interruption", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { StorageClient } = await import("/src/storage/StorageClient.ts");
const projectId = `m7-save-interrupt-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const oldBytes = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 7, 7, 7]);
const nextBytes = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 8, 8, 8, 8]);
const initial = new StorageClient();
const baseline = await initial.saveProject(projectId, 7, oldBytes.slice().buffer);
initial.terminate();
const observations = [];
for (const faultAt of ["after-stage", "after-scene-commit", "before-metadata-commit"] as const) {
const attempt = new StorageClient();
let error = "";
try {
await attempt.saveProject(projectId, 8, nextBytes.slice().buffer, faultAt);
}
catch (reason) {
error = reason instanceof Error ? reason.message : String(reason);
}
attempt.terminate();
const restarted = new StorageClient();
const restored = await restarted.readProject(projectId);
restarted.terminate();
observations.push({
faultAt,
error,
revision: restored.revision,
sha256: restored.sha256,
bytes: Array.from(new Uint8Array(restored.buffer)),
recovered: restored.recovered,
});
}
const completed = new StorageClient();
const committed = await completed.saveProject(projectId, 8, nextBytes.slice().buffer);
const reopened = await completed.readProject(projectId);
completed.terminate();
return {
baseline: { revision: baseline.revision, sha256: baseline.sha256, bytes: Array.from(oldBytes) },
observations,
committed: { revision: committed.revision, sha256: committed.sha256, reopenedRevision: reopened.revision, reopenedSha256: reopened.sha256 },
};
});
expect(result.observations).toHaveLength(3);
for (const item of result.observations) {
expect(item.error).toContain(`PROJECT_SAVE_FAULT_INJECTED: ${item.faultAt}`);
expect(item.revision).toBe(result.baseline.revision);
expect(item.sha256).toBe(result.baseline.sha256);
expect(item.bytes).toEqual(result.baseline.bytes);
expect(item.recovered).toBe(false);
}
expect(result.committed.revision).toBe(8);
expect(result.committed.reopenedRevision).toBe(8);
expect(result.committed.sha256).toBe(result.committed.reopenedSha256);
expect(result.committed.sha256).not.toBe(result.baseline.sha256);
});

View File

@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
test("boots and edits with the single-thread engine without cross-origin isolation", async ({ page }) => {
const engineAssetUrls: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (/web_engine|pthread/i.test(url.pathname)) engineAssetUrls.push(url.pathname);
});
const documentResponse = await page.goto("/");
expect(documentResponse).not.toBeNull();
const headers = await documentResponse!.allHeaders();
expect(headers["cross-origin-opener-policy"]).toBeUndefined();
expect(headers["cross-origin-embedder-policy"]).toBeUndefined();
expect(headers["cross-origin-resource-policy"]).toBeUndefined();
const platform = await page.evaluate(async () => {
const {
detectBrowserCapabilities,
gateWasmThreadingCapability,
selectWebEngineVariant,
} = await import("/src/platform/capabilities.ts");
const capabilities = detectBrowserCapabilities();
const pthreadGate = gateWasmThreadingCapability(capabilities);
const manifest = await fetch("/engine-manifest.json", { cache: "no-store" }).then((response) => response.json()) as {
schemaVersion: number;
memory?: { shared: boolean };
variants?: Array<{
id: "single" | "pthread";
memory: { shared: boolean };
resources: { js: { url: string }; wasm: { url: string } };
}>;
};
const singleMemory = manifest.schemaVersion === 2
? manifest.variants?.find((variant) => variant.id === "single")?.memory
: manifest.memory;
const selection = selectWebEngineVariant(
manifest as Parameters<typeof selectWebEngineVariant>[0],
"AUTO",
capabilities,
);
if (!selection.selectedVariant) throw new Error("AUTO did not select a single-thread variant");
const selected = selection.selectedVariant;
const wasmBinary = await fetch(selected.resources.wasm.url, { cache: "no-store" })
.then((response) => {
if (!response.ok) throw new Error(`single WASM request failed: ${response.status}`);
return response.arrayBuffer();
});
const imported = await import(/* @vite-ignore */ selected.resources.js.url) as {
default: (options: { wasmBinary: ArrayBuffer }) => Promise<{
HEAPU8: Uint8Array;
_web_engine_create(): number;
_web_engine_destroy(handle: number): void;
_web_engine_get_live_handles(): number;
}>;
};
const module = await imported.default({ wasmBinary });
const variantHandle = module._web_engine_create();
const variantSharedMemory = typeof SharedArrayBuffer !== "undefined" &&
module.HEAPU8.buffer instanceof SharedArrayBuffer;
module._web_engine_destroy(variantHandle);
return {
crossOriginIsolated: capabilities.crossOriginIsolated,
sharedArrayBuffer: capabilities.sharedArrayBuffer,
worker: capabilities.worker,
pthreadGate,
autoSelectedVariant: selected.id,
variantHandle,
variantSharedMemory,
variantLiveAfterDestroy: module._web_engine_get_live_handles(),
manifestSchemaVersion: manifest.schemaVersion,
manifestSharedMemory: singleMemory?.shared,
};
});
expect(platform).toMatchObject({
crossOriginIsolated: false,
sharedArrayBuffer: false,
worker: true,
autoSelectedVariant: "single",
variantSharedMemory: false,
variantLiveAfterDestroy: 0,
manifestSchemaVersion: 2,
manifestSharedMemory: false,
pthreadGate: {
taskId: "M6-02",
capability: "WASM_PTHREAD_ENGINE",
status: "BLOCKED",
},
});
expect(platform.variantHandle).toBeGreaterThan(0);
expect(platform.pthreadGate.issues.map((issue) => issue.path)).toEqual([
"crossOriginIsolated",
"sharedArrayBuffer",
]);
await expect(page.getByTestId("engine-status")).toContainText("Engine: ready", { timeout: 30_000 });
await expect(page.locator(".status-bar")).toContainText("WASM ABI: ready");
expect(engineAssetUrls.some((url) => url.endsWith("/vendor/blender/web_engine.wasm"))).toBe(true);
expect(engineAssetUrls.some((url) => url.endsWith("/vendor/blender/single/web_engine.wasm"))).toBe(true);
expect(engineAssetUrls.some((url) => /pthread/i.test(url))).toBe(false);
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3 · Vertices 8 · Faces 6");
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4 · Vertices 16 · Faces 18");
});

View File

@@ -1511,9 +1511,9 @@ test("autosaves a dirty project without starting a download", async ({ page }) =
await page.goto("/");
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "已保存" }).click();
await expect(page.getByRole("button", { name: "未保存" })).toBeVisible();
await expect(page.getByRole("button", { name: "已保存" })).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: "添加立方体" }).click();
await expect(page.getByTestId("dirty-status")).toHaveText("未保存");
await expect(page.getByTestId("dirty-status")).toHaveText("已保存", { timeout: 15_000 });
});
test("persists an operation log entry with an inverse payload", async ({ page }) => {

View File

@@ -0,0 +1,69 @@
import { expect, test } from "@playwright/test";
import path from "node:path";
const materialBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
const exportableBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
const image = path.resolve(import.meta.dirname, "../../../tests/files/web/media/sequencer-frame.png");
test("M7-01 records import, open, save, save-as and export success", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
const app = page.locator(".blender-app");
await page.getByTestId("blend-file-input").setInputFiles(exportableBlend);
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-open-id", /^OPEN:\d+$/);
const glbDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 GLB" }).click();
expect((await glbDownload).suggestedFilename()).toBe("blender-web.glb");
await expect(app).toHaveAttribute("data-user-action-export-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-export-id", /^EXPORT:\d+$/);
await page.getByTestId("blend-file-input").setInputFiles(materialBlend);
await expect(app).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
await page.locator("label.file-button input[type=file]").setInputFiles(image);
await expect(app).toHaveAttribute("data-user-action-import-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-import-id", /^IMPORT:\d+$/);
const blendDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "保存项目" }).click();
expect((await blendDownload).suggestedFilename()).toBe("blender-web.blend");
await expect(app).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-save-as-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-user-action-save-id", /^SAVE:\d+$/);
await expect(app).toHaveAttribute("data-user-action-save-as-id", /^SAVE_AS:\d+$/);
await expect(app).toHaveAttribute("data-save-transaction-status", "SUCCEEDED");
await expect(app).toHaveAttribute("data-save-transaction-stage", "METADATA_COMMIT");
await expect(app).toHaveAttribute("data-save-committed-hash", /^[a-f0-9]{64}$/);
});
test("M7-01 exposes stable failure codes without replacing the last valid scene", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
const app = page.locator(".blender-app");
await page.getByRole("button", { name: "导出 GLB" }).click();
await expect(app).toHaveAttribute("data-user-action-export-status", "FAILED");
await expect(app).toHaveAttribute("data-user-action-export-error", "EXPORT_PROJECT_UNAVAILABLE");
await page.getByTestId("blend-file-input").setInputFiles(materialBlend);
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
await page.getByTestId("blend-file-input").setInputFiles({
name: "invalid.blend",
mimeType: "application/octet-stream",
buffer: Buffer.from([0x42, 0x41, 0x44]),
});
await expect(app).toHaveAttribute("data-user-action-open-status", "FAILED");
await expect(app).toHaveAttribute("data-user-action-open-error", "OPEN_ENGINE_FAILED");
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
await page.locator("label.file-button input[type=file]").setInputFiles({
name: "invalid.png",
mimeType: "image/png",
buffer: Buffer.from("not-a-png"),
});
await expect(app).toHaveAttribute("data-user-action-import-status", "FAILED");
await expect(app).toHaveAttribute("data-user-action-import-error", "IMPORT_DECODE_FAILED");
});

View File

@@ -1,10 +1,15 @@
import { expect, test } from "@playwright/test";
test("gates only the pthread WASM engine on its three platform requirements", async ({ page }) => {
await page.goto("/");
const documentResponse = await page.goto("/");
expect(documentResponse).not.toBeNull();
await expect.poll(async () => (await documentResponse!.allHeaders())["cross-origin-opener-policy"]).toBe("same-origin");
await expect.poll(async () => (await documentResponse!.allHeaders())["cross-origin-embedder-policy"]).toBe("require-corp");
await expect.poll(async () => (await documentResponse!.allHeaders())["cross-origin-resource-policy"]).toBe("same-origin");
const gates = await page.evaluate(async () => {
const { gateWasmThreadingCapability } = await import("/src/platform/capabilities.ts");
const { detectBrowserCapabilities, gateWasmThreadingCapability } = await import("/src/platform/capabilities.ts");
return {
actual: gateWasmThreadingCapability(detectBrowserCapabilities()),
ready: gateWasmThreadingCapability({ crossOriginIsolated: true, sharedArrayBuffer: true, worker: true }),
noIsolation: gateWasmThreadingCapability({ crossOriginIsolated: false, sharedArrayBuffer: true, worker: true }),
noSharedArrayBuffer: gateWasmThreadingCapability({ crossOriginIsolated: true, sharedArrayBuffer: false, worker: true }),
@@ -13,6 +18,11 @@ test("gates only the pthread WASM engine on its three platform requirements", as
};
});
expect(gates.actual).toMatchObject({
capability: "WASM_PTHREAD_ENGINE",
status: "READY",
issues: [],
});
expect(gates.ready).toEqual({
taskId: "M6-02",
capability: "WASM_PTHREAD_ENGINE",

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const sourcePath = path.join(repoRoot, "web/protocol/dirty-state.ts");
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
const { acceptHistoryTransaction, acceptMainSave, acceptMainTransaction, createDirtyState, recoverDirtyState } = await import(moduleUrl);
test("M7-06 only marks dirty after an accepted Main transaction", () => {
const clean = createDirtyState(7);
const stale = acceptMainTransaction(clean, 7);
assert.deepEqual(stale, { ok: false, state: clean, errorCode: "DIRTY_REVISION_STALE" });
const edited = acceptMainTransaction(clean, 8);
assert.equal(edited.ok, true);
assert.deepEqual(edited.state, { currentMainRevision: 8, committedMainRevision: 7, dirty: true });
});
test("M7-06 failed, preview and UI-only work preserve the same dirty object", () => {
const clean = createDirtyState(3);
const failedCommandState = clean;
const previewState = failedCommandState;
const uiOnlyState = previewState;
assert.equal(failedCommandState, clean);
assert.equal(previewState, clean);
assert.equal(uiOnlyState, clean);
});
test("M7-06 clears dirty only for a save matching the accepted Main revision", () => {
const edited = recoverDirtyState(9, 7);
assert.deepEqual(acceptMainSave(edited, 8), { ok: false, state: edited, errorCode: "DIRTY_SAVE_REVISION_MISMATCH" });
const saved = acceptMainSave(edited, 9);
assert.equal(saved.ok, true);
assert.deepEqual(saved.state, { currentMainRevision: 9, committedMainRevision: 9, dirty: false });
});
test("M7-07 keeps transaction revisions monotonic while undo content toggles dirty", () => {
const saved = createDirtyState(10);
const edited = acceptMainTransaction(saved, 11);
assert.equal(edited.ok, true);
const undone = acceptHistoryTransaction(edited.state, 12, true);
assert.equal(undone.ok, true);
assert.deepEqual(undone.state, { currentMainRevision: 12, committedMainRevision: 12, dirty: false });
const redone = acceptHistoryTransaction(undone.state, 13, false);
assert.equal(redone.ok, true);
assert.deepEqual(redone.state, { currentMainRevision: 13, committedMainRevision: 12, dirty: true });
});

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const protocolPath = path.join(repoRoot, "web/protocol/manifest.ts");
const protocolSource = fs.readFileSync(protocolPath, "utf8");
const transpiled = ts.transpileModule(protocolSource, {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: protocolPath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const protocol = await import(`data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`);
const { validateWebEngineManifestV2 } = protocol;
const goldenPath = path.join(repoRoot, "tests/golden/M6-04A/engine-manifest-v2.json");
const golden = JSON.parse(fs.readFileSync(goldenPath, "utf8"));
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package.json"), "utf8"));
function deepFreeze(value) {
if (value && typeof value === "object") {
Object.freeze(value);
for (const item of Object.values(value)) deepFreeze(item);
}
return value;
}
function invalidCase(name, mutate, code, errorPath) {
return { name, mutate, code, errorPath };
}
test("M6-04A accepts an immutable single/pthread manifest without mutating it", () => {
const input = deepFreeze(structuredClone(golden));
const parsed = validateWebEngineManifestV2(input);
assert.deepEqual(parsed, golden);
assert.notEqual(parsed, input);
assert.notEqual(parsed.variants[0], input.variants[0]);
assert.notEqual(parsed.variants[0].resources.js, input.variants[0].resources.js);
assert.equal(parsed.variants[1].resources.pthreadWorker.url, parsed.variants[1].resources.js.url);
assert.equal(protocol.WEB_ENGINE_WASM_PAGE_BYTES, 65_536);
assert.deepEqual(protocol.WEB_ENGINE_MEMORY_LIMITS, {
minimumInitialPages: 256,
maximumPages: 32_768,
});
const reversed = validateWebEngineManifestV2({ ...golden, variants: [...golden.variants].reverse() });
assert.deepEqual(reversed.variants.map((variant) => variant.id), ["single", "pthread"]);
});
test("M6-04A rejects malformed variants, resources and memory declarations", async (t) => {
const cases = [
invalidCase("old schema", (value) => { value.schemaVersion = 1; }, "PROTOCOL_MISMATCH", "schemaVersion"),
invalidCase("unsupported protocol", (value) => { value.protocolVersion = 2; }, "PROTOCOL_MISMATCH", "protocolVersion"),
invalidCase("missing release ID", (value) => { delete value.releaseId; }, "ENGINE_MANIFEST_INVALID", "releaseId"),
invalidCase("invalid release ID", (value) => { value.releaseId = "release/id"; }, "ENGINE_MANIFEST_INVALID", "releaseId"),
invalidCase("missing pthread variant", (value) => { value.variants.pop(); }, "ENGINE_MANIFEST_INVALID", "variants"),
invalidCase("duplicate single variant", (value) => { value.variants[1].id = "single"; delete value.variants[1].resources.pthreadWorker; value.variants[1].memory.shared = false; }, "ENGINE_MANIFEST_INVALID", "variants"),
invalidCase("single shared memory", (value) => { value.variants[0].memory.shared = true; }, "ENGINE_MANIFEST_INVALID", "variants[0].memory.shared"),
invalidCase("single pthread worker", (value) => { value.variants[0].resources.pthreadWorker = structuredClone(value.variants[1].resources.pthreadWorker); }, "ENGINE_MANIFEST_INVALID", "variants[0].resources.pthreadWorker"),
invalidCase("pthread unshared memory", (value) => { value.variants[1].memory.shared = false; }, "ENGINE_MANIFEST_INVALID", "variants[1].memory.shared"),
invalidCase("missing pthread worker", (value) => { delete value.variants[1].resources.pthreadWorker; }, "ENGINE_MANIFEST_INVALID", "variants[1].resources.pthreadWorker"),
invalidCase("invalid SHA-256", (value) => { value.variants[1].resources.wasm.sha256 = "ABC"; }, "ENGINE_MANIFEST_INVALID", "variants[1].resources.wasm.sha256"),
invalidCase("remote resource URL", (value) => { value.variants[0].resources.js.url = "https://cdn.invalid/web_engine.single.js"; }, "ENGINE_MANIFEST_INVALID", "variants[0].resources.js.url"),
invalidCase("file name mismatch", (value) => { value.variants[0].resources.wasm.url = "/vendor/blender/wrong.wasm"; }, "ENGINE_MANIFEST_INVALID", "variants[0].resources.wasm.url"),
invalidCase("initial memory below floor", (value) => { value.variants[0].memory.initialPages = 255; }, "ENGINE_MANIFEST_INVALID", "variants[0].memory.initialPages"),
invalidCase("maximum memory below initial", (value) => { value.variants[1].memory.maximumPages = 255; }, "ENGINE_MANIFEST_INVALID", "variants[1].memory.maximumPages"),
invalidCase("maximum memory above ceiling", (value) => { value.variants[1].memory.maximumPages = 32_769; }, "ENGINE_MANIFEST_INVALID", "variants[1].memory.maximumPages"),
invalidCase("aliased variant WASM", (value) => { value.variants[1].resources.wasm = structuredClone(value.variants[0].resources.wasm); }, "ENGINE_MANIFEST_INVALID", "variants.wasm"),
invalidCase("worker aliases single JS", (value) => { value.variants[1].resources.pthreadWorker = structuredClone(value.variants[0].resources.js); }, "ENGINE_MANIFEST_INVALID", "variants.pthreadWorker"),
invalidCase("same worker URL with another hash", (value) => { value.variants[1].resources.pthreadWorker.sha256 = "5".repeat(64); }, "ENGINE_MANIFEST_INVALID", "variants.pthreadWorker.sha256"),
invalidCase("unknown manifest field", (value) => { value.defaultVariant = "pthread"; }, "ENGINE_MANIFEST_INVALID", "manifest.defaultVariant"),
];
for (const item of cases) {
await t.test(item.name, () => {
const input = structuredClone(golden);
item.mutate(input);
assert.throws(
() => validateWebEngineManifestV2(input),
(error) => error?.name === "WebEngineManifestValidationError" &&
error.code === item.code && error.path === item.errorPath,
);
});
}
});
test("M6-04B installs the production manifest with explicit variant paths", () => {
const production = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
const parsed = validateWebEngineManifestV2(production);
assert.equal(parsed.releaseId, `blender-wasm-${packageJson.version}`);
assert.equal(parsed.variants[0].resources.wasm.url, "/vendor/blender/single/web_engine.wasm");
assert.equal(parsed.variants[1].resources.wasm.url, "/vendor/blender/pthread/web_engine.wasm");
assert.notEqual(parsed.variants[0].resources.wasm.sha256, parsed.variants[1].resources.wasm.sha256);
});

View File

@@ -0,0 +1,249 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
function transpileDataUrl(filePath, replacements = new Map()) {
const result = ts.transpileModule(fs.readFileSync(filePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: filePath,
reportDiagnostics: true,
});
assert.deepEqual(result.diagnostics, []);
let source = result.outputText;
for (const [specifier, replacement] of replacements) {
source = source.replaceAll(`"${specifier}"`, JSON.stringify(replacement));
}
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
}
const gatesUrl = transpileDataUrl(path.join(repoRoot, "web/protocol/capability-gates.ts"));
const selectorUrl = transpileDataUrl(
path.join(repoRoot, "web/protocol/engine-variant.ts"),
new Map([["./capability-gates", gatesUrl]]),
);
const bootstrapUrl = transpileDataUrl(
path.join(repoRoot, "web/app/src/engine-client/engine-variant-bootstrap.ts"),
new Map([["../../../protocol/engine-variant", selectorUrl]]),
);
const {
bootstrapWebEngineRelease,
engineVariantStatusLabel,
EngineVariantLoadError,
} = await import(bootstrapUrl);
const manifest = JSON.parse(fs.readFileSync(
path.join(repoRoot, "tests/golden/M6-04A/engine-manifest-v2.json"),
"utf8",
));
const ready = { crossOriginIsolated: true, sharedArrayBuffer: true, worker: true };
class FakeSession {
constructor(variant) {
this.variant = variant;
this.opened = [];
this.disposals = 0;
this.state = {
handles: 1,
workers: variant.id === "pthread" ? 1 : 0,
timers: 0,
pendingRequests: 0,
};
}
async openProject(project) {
this.opened.push(project);
}
resourceState() {
return { ...this.state };
}
testOnlySeedTrackedResources() {
this.state.timers = 1;
this.state.pendingRequests = 1;
}
async dispose() {
this.disposals += 1;
this.state = { handles: 0, workers: 0, timers: 0, pendingRequests: 0 };
return this.resourceState();
}
}
function dependencies() {
const sessions = [];
return {
sessions,
initialize: async (variant) => {
const session = new FakeSession(variant);
sessions.push(session);
return session;
},
};
}
test("M6-05A/B/C injects one pthread failure, cleans it and falls back once", async () => {
const deps = dependencies();
const outcome = await bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
ready,
deps,
undefined,
{ failPthreadAfterInitialize: true, seedTrackedPthreadResources: true },
);
assert.deepEqual(outcome.result.attempted, ["pthread", "single"]);
assert.equal(deps.sessions.length, 2);
assert.equal(deps.sessions[0].disposals, 1);
assert.deepEqual(outcome.result.failedAttemptCleanup, {
handles: 0,
workers: 0,
timers: 0,
pendingRequests: 0,
});
assert.equal(outcome.result.selected, "single");
assert.deepEqual(outcome.result.fallbackReason, {
code: "PTHREAD_INITIALIZATION_FAILED",
message: "TEST_PTHREAD_INITIALIZATION_FAILURE",
});
assert.equal(outcome.result.openCount, 0);
assert.equal(deps.sessions[0].opened.length, 0);
assert.equal(deps.sessions[1].opened.length, 0);
});
test("M6-05D opens a pending project once only after the fallback settles", async () => {
const deps = dependencies();
const project = { id: "pending-project" };
const outcome = await bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
ready,
deps,
project,
{ failPthreadAfterInitialize: true },
);
assert.equal(outcome.result.openCount, 1);
assert.deepEqual(deps.sessions[0].opened, []);
assert.deepEqual(deps.sessions[1].opened, [project]);
});
test("M6-05E exposes selected, attempted and fallbackReason without pthread READY UI", async () => {
const deps = dependencies();
const outcome = await bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
ready,
deps,
undefined,
{ failPthreadAfterInitialize: true },
);
const label = engineVariantStatusLabel(outcome.result);
assert.deepEqual(
{
selected: outcome.result.selected,
attempted: outcome.result.attempted,
fallbackReason: outcome.result.fallbackReason?.code,
},
{
selected: "single",
attempted: ["pthread", "single"],
fallbackReason: "PTHREAD_INITIALIZATION_FAILED",
},
);
assert.equal(label, "Runtime: single (pthread fallback: PTHREAD_INITIALIZATION_FAILED)");
assert.doesNotMatch(label, /pthread\s+ready/i);
});
test("M6-08E treats a manifest hash mismatch as fatal without fallback or project open", async () => {
const initialized = [];
const project = { id: "must-stay-unopened" };
await assert.rejects(
bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
ready,
{
initialize: async (variant) => {
initialized.push(variant.id);
if (variant.id === "pthread") {
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", variant.resources.wasm.url);
}
return new FakeSession(variant);
},
},
project,
),
(error) => error?.code === "ENGINE_VARIANT_INTEGRITY_FAILED" &&
error.cause?.code === "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH" &&
JSON.stringify(error.attempted) === JSON.stringify(["pthread"]),
);
assert.deepEqual(initialized, ["pthread"]);
});
test("M6-08D returns refresh-required before initialization or project open", async () => {
let initializationCount = 0;
const outcome = await bootstrapWebEngineRelease(
"blender-wasm-previous",
manifest,
"AUTO",
ready,
{
initialize: async (variant) => {
initializationCount += 1;
return new FakeSession(variant);
},
},
{ id: "must-stay-unopened" },
);
assert.deepEqual(outcome, {
result: {
status: "REFRESH_REQUIRED",
expectedReleaseId: "blender-wasm-previous",
actualReleaseId: manifest.releaseId,
manifest: null,
},
session: null,
});
assert.equal(initializationCount, 0);
});
test("M6-08E normalizes a fallback variant hash mismatch and never opens the project", async () => {
const initialized = [];
const pthreadSession = new FakeSession(manifest.variants[1]);
await assert.rejects(
bootstrapWebEngineRelease(
manifest.releaseId,
manifest,
"AUTO",
ready,
{
initialize: async (variant) => {
initialized.push(variant.id);
if (variant.id === "single") {
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", variant.resources.wasm.url);
}
return pthreadSession;
},
},
{ id: "must-stay-unopened" },
{ failPthreadAfterInitialize: true },
),
(error) => error?.code === "ENGINE_VARIANT_INTEGRITY_FAILED" &&
error.cause?.code === "ENGINE_VARIANT_RESOURCE_HASH_MISMATCH" &&
JSON.stringify(error.attempted) === JSON.stringify(["pthread", "single"]),
);
assert.deepEqual(initialized, ["pthread", "single"]);
assert.equal(pthreadSession.disposals, 1);
assert.deepEqual(pthreadSession.opened, []);
});

View File

@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
function transpileDataUrl(filePath, replacements = new Map()) {
const result = ts.transpileModule(fs.readFileSync(filePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: filePath,
reportDiagnostics: true,
});
assert.deepEqual(result.diagnostics, []);
let source = result.outputText;
for (const [specifier, replacement] of replacements) {
source = source.replaceAll(`"${specifier}"`, JSON.stringify(replacement));
}
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
}
const gatesUrl = transpileDataUrl(path.join(repoRoot, "web/protocol/capability-gates.ts"));
const selectorUrl = transpileDataUrl(
path.join(repoRoot, "web/protocol/engine-variant.ts"),
new Map([["./capability-gates", gatesUrl]]),
);
const { bindWebEngineRelease, gateWasmThreadingCapability, selectWebEngineVariant } = await import(selectorUrl);
const manifest = JSON.parse(fs.readFileSync(
path.join(repoRoot, "tests/golden/M6-04A/engine-manifest-v2.json"),
"utf8",
));
const ready = { crossOriginIsolated: true, sharedArrayBuffer: true, worker: true };
function deepFreeze(value) {
if (value && typeof value === "object") {
Object.freeze(value);
for (const item of Object.values(value)) deepFreeze(item);
}
return value;
}
test("M6-04C selects from only its manifest, policy and capability inputs", () => {
const frozenManifest = deepFreeze(structuredClone(manifest));
const frozenCapabilities = deepFreeze({ ...ready });
const first = selectWebEngineVariant(frozenManifest, "SINGLE_REQUIRED", frozenCapabilities);
const second = selectWebEngineVariant(frozenManifest, "SINGLE_REQUIRED", frozenCapabilities);
assert.deepEqual(first, second);
assert.equal(first.policy, "SINGLE_REQUIRED");
assert.equal(first.selectedVariant, frozenManifest.variants[0]);
assert.equal(first.selectedVariant.id, "single");
assert.equal(first.pthreadGate.status, "READY");
assert.throws(
() => selectWebEngineVariant(frozenManifest, "INVALID", frozenCapabilities),
/ENGINE_VARIANT_POLICY_INVALID/,
);
});
test("M6-04D AUTO selects pthread only when the M6-02 gate is READY", () => {
const capabilityCases = [
[ready, "pthread", []],
[{ ...ready, crossOriginIsolated: false }, "single", ["crossOriginIsolated"]],
[{ ...ready, sharedArrayBuffer: false }, "single", ["sharedArrayBuffer"]],
[{ ...ready, worker: false }, "single", ["worker"]],
[
{ crossOriginIsolated: false, sharedArrayBuffer: false, worker: false },
"single",
["crossOriginIsolated", "sharedArrayBuffer", "worker"],
],
];
for (const [capabilities, expectedId, expectedPaths] of capabilityCases) {
const selection = selectWebEngineVariant(manifest, "AUTO", capabilities);
assert.equal(selection.selectedVariant.id, expectedId);
assert.equal(selection.pthreadGate.status, expectedId === "pthread" ? "READY" : "BLOCKED");
assert.deepEqual(selection.pthreadGate.issues.map((issue) => issue.path), expectedPaths);
}
});
test("M6-04E PTHREAD_REQUIRED exposes the M6-02 block without a requestable variant", () => {
const capabilities = { crossOriginIsolated: false, sharedArrayBuffer: false, worker: true };
const expectedGate = gateWasmThreadingCapability(capabilities);
const blocked = selectWebEngineVariant(manifest, "PTHREAD_REQUIRED", capabilities);
assert.equal(blocked.selectedVariant, null);
assert.deepEqual(blocked.pthreadGate, expectedGate);
assert.deepEqual(blocked.pthreadGate, {
taskId: "M6-02",
capability: "WASM_PTHREAD_ENGINE",
status: "BLOCKED",
issues: [
{
code: "PLATFORM_CAPABILITY_UNAVAILABLE",
message: "Cross-origin isolation is required for the pthread WASM engine",
path: "crossOriginIsolated",
recoverable: true,
},
{
code: "PLATFORM_CAPABILITY_UNAVAILABLE",
message: "SharedArrayBuffer is required for the pthread WASM engine",
path: "sharedArrayBuffer",
recoverable: true,
},
],
});
const allowed = selectWebEngineVariant(manifest, "PTHREAD_REQUIRED", ready);
assert.equal(allowed.selectedVariant.id, "pthread");
assert.equal(allowed.pthreadGate.status, "READY");
});
test("M6-08C/D binds both variants to one release and refuses mixed-version startup", () => {
const current = bindWebEngineRelease(manifest.releaseId, manifest);
assert.equal(current.status, "READY");
assert.equal(current.manifest, manifest);
assert.deepEqual(current.manifest.variants.map((variant) => variant.id), ["single", "pthread"]);
const switched = bindWebEngineRelease("blender-wasm-previous", manifest);
assert.deepEqual(switched, {
status: "REFRESH_REQUIRED",
expectedReleaseId: "blender-wasm-previous",
actualReleaseId: manifest.releaseId,
manifest: null,
});
});

View File

@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const sourcePath = path.join(repoRoot, "web/protocol/file-byte-reader.ts");
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
const { FileByteReadError, readFileBytes } = await import(moduleUrl);
function chunkSource(chunks, declaredSize = chunks.reduce((total, chunk) => total + (chunk.byteLength ?? chunk.length), 0)) {
return {
size: declaredSize,
stream() {
let index = 0;
return new ReadableStream({
pull(controller) {
if (index === chunks.length) controller.close();
else controller.enqueue(Uint8Array.from(chunks[index++]));
},
});
},
};
}
test("M7-03 reports progress from exact consumed byte counts", async () => {
const observations = [];
const result = await readFileBytes(chunkSource([[1, 2], [3, 4, 5], [6]]), {
signal: new AbortController().signal,
onProgress: (item) => observations.push(item),
});
assert.deepEqual(Array.from(new Uint8Array(result)), [1, 2, 3, 4, 5, 6]);
assert.deepEqual(observations.map(({ phase, bytesRead, totalBytes, fraction }) => [phase, bytesRead, totalBytes, fraction]), [
["STARTED", 0, 6, 0],
["READING", 2, 6, 2 / 6],
["READING", 5, 6, 5 / 6],
["READING", 6, 6, 1],
["COMPLETED", 6, 6, 1],
]);
});
test("M7-03 cancels between chunks without reporting completion", async () => {
const controller = new AbortController();
const observations = [];
const resources = [];
await assert.rejects(
readFileBytes(chunkSource([[1, 2], [3, 4], [5, 6]]), {
signal: controller.signal,
onProgress: (item) => {
observations.push(item);
if (item.phase === "READING" && item.bytesRead === 2) controller.abort();
},
onResourceState: (state) => resources.push(state),
}),
(error) => error instanceof FileByteReadError && error.code === "FILE_READ_CANCELLED",
);
assert.deepEqual(observations.map((item) => [item.phase, item.bytesRead]), [
["STARTED", 0],
["READING", 2],
["CANCELLED", 2],
]);
assert.deepEqual(resources, [
{ liveReaders: 1, liveInputBytes: 6, liveStagingFiles: 0 },
{ liveReaders: 0, liveInputBytes: 0, liveStagingFiles: 0 },
]);
});
test("M7-03 rejects streams shorter or longer than their declared byte size", async () => {
await assert.rejects(
readFileBytes(chunkSource([[1, 2]], 3), { signal: new AbortController().signal }),
(error) => error instanceof FileByteReadError && error.code === "FILE_READ_SIZE_MISMATCH",
);
await assert.rejects(
readFileBytes(chunkSource([[1, 2, 3]], 2), { signal: new AbortController().signal }),
(error) => error instanceof FileByteReadError && error.code === "FILE_READ_SIZE_MISMATCH",
);
});

View File

@@ -30,6 +30,10 @@ test("required renderer and engine assets are vendored", () => {
"app/src/vendor/blender/web_engine.wasm",
"app/public/vendor/blender/web_engine.js",
"app/public/vendor/blender/web_engine.wasm",
"app/public/vendor/blender/single/web_engine.js",
"app/public/vendor/blender/single/web_engine.wasm",
"app/public/vendor/blender/pthread/web_engine.js",
"app/public/vendor/blender/pthread/web_engine.wasm",
];
for (const relativePath of requiredFiles) {
const filePath = path.join(webRoot, relativePath);
@@ -47,6 +51,12 @@ test("required renderer and engine assets are vendored", () => {
}
});
test("schema v2 engine assets carry a valid release identity", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "app/public/engine-manifest.json"), "utf8"));
assert.equal(manifest.schemaVersion, 2);
assert.match(manifest.releaseId, /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/);
});
test("attribute geometry fixture is reproducible input", () => {
const fixture = path.join(webRoot, "..", "tests/files/web/attribute_scene.blend");
const manifest = JSON.parse(fs.readFileSync(path.join(webRoot, "..", "tests/files/web/manifest.json"), "utf8"));

View File

@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import ts from "typescript";
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const sourcePath = path.join(repoRoot, "web/protocol/project-action-mutex.ts");
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
fileName: sourcePath,
reportDiagnostics: true,
});
assert.deepEqual(transpiled.diagnostics, []);
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled.outputText).toString("base64")}`;
const {
PROJECT_ACTION_CONFLICT_MATRIX,
acquireProjectAction,
createProjectActionMutexState,
releaseProjectAction,
} = await import(moduleUrl);
const action = (kind, sequence) => ({ kind, actionId: `${kind}:${sequence}` });
test("M7-02 keeps the project action conflict matrix symmetric and exclusive", () => {
for (const requested of ["OPEN", "SAVE", "CLOSE"]) {
for (const owner of ["OPEN", "SAVE", "CLOSE"]) {
assert.equal(PROJECT_ACTION_CONFLICT_MATRIX[requested][owner], true);
assert.equal(PROJECT_ACTION_CONFLICT_MATRIX[requested][owner], PROJECT_ACTION_CONFLICT_MATRIX[owner][requested]);
}
}
});
test("M7-02 rejects repeated open with its original owner unchanged", () => {
const first = action("OPEN", 1);
const second = action("OPEN", 2);
const acquired = acquireProjectAction(createProjectActionMutexState(), first);
assert.equal(acquired.granted, true);
assert.deepEqual(acquireProjectAction(acquired.state, second), {
granted: false,
state: acquired.state,
conflict: { code: "USER_ACTION_CONFLICT", reason: "REPEATED_OPEN", requested: second, owner: first },
});
});
test("M7-02 rejects concurrent save and close-during-save with stable reasons", () => {
const first = action("SAVE", 1);
const acquired = acquireProjectAction(createProjectActionMutexState(), first);
assert.equal(acquired.granted, true);
assert.equal(acquireProjectAction(acquired.state, action("SAVE", 2)).conflict.reason, "CONCURRENT_SAVE");
assert.equal(acquireProjectAction(acquired.state, action("CLOSE", 3)).conflict.reason, "CLOSE_DURING_SAVE");
});
test("M7-02 only lets the exact owner release the project lock", () => {
const owner = action("SAVE", 1);
const acquired = acquireProjectAction(createProjectActionMutexState(), owner);
assert.equal(acquired.granted, true);
assert.deepEqual(releaseProjectAction(acquired.state, action("SAVE", 2)), {
released: false,
state: acquired.state,
errorCode: "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH",
});
assert.deepEqual(releaseProjectAction(acquired.state, owner), {
released: true,
state: { owner: null },
errorCode: null,
});
});

Some files were not shown because too many files have changed in this diff Show More