Advance M7 workflows and release operations
This commit is contained in:
102
.gitea/workflows/m6-ci.yml
Normal file
102
.gitea/workflows/m6-ci.yml
Normal 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
|
||||
@@ -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`.
|
||||
|
||||
518
docs/BLENDER_5_2_FULL_PARITY_WBS.md
Normal file
518
docs/BLENDER_5_2_FULL_PARITY_WBS.md
Normal 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-15,M6 已完成 35/71:single/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 解包后使用合同服务器启动,不读取工作区文件。
|
||||
@@ -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/71;M7 已完成 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 runner:50 declarations、49 unique、49 passed、
|
||||
0 failed,总命令耗时 454,106 ms。
|
||||
0 failed;M6-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-256:binary
|
||||
`f2903a75026a0a28615aeb5f4834a3859af83022c82bc50e28437dcbed68e549`;source
|
||||
`9a9c1b505c68a618de842be7c248f628b104b11b80e8a40d111f62540fcba327`。下一任务:`M6-01`。
|
||||
`9a9c1b505c68a618de842be7c248f628b104b11b80e8a40d111f62540fcba327`。当前下一任务:`M7-08`。
|
||||
|
||||
## 12. V1 后原子里程碑
|
||||
|
||||
@@ -551,40 +565,157 @@ M5 最终 archive SHA-256:binary
|
||||
|
||||
### 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 lane:typecheck、lint、unit、status、evidence schema。
|
||||
- [ ] `M6-14` 建立 CI Chromium lane:P0 用户闭环、双视口和 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-256;v2 纯校验器固定 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/fallbackReason,UI 不显示 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 ms;full E2E 126/126,V1 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 条目执行隔离和修复提示。
|
||||
|
||||
@@ -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 ms;quick/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"
|
||||
```
|
||||
|
||||
20
docs/status/ci-lane-report.schema.json
Normal file
20
docs/status/ci-lane-report.schema.json
Normal 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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
47
docs/web/CI.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
68
docs/web/KNOWN_LIMITATIONS.md
Normal file
68
docs/web/KNOWN_LIMITATIONS.md
Normal 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
60
docs/web/RELEASE_NOTES.md
Normal 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).
|
||||
105
docs/web/RELEASE_RECOVERY.md
Normal file
105
docs/web/RELEASE_RECOVERY.md
Normal 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.
|
||||
61
docs/web/engine-manifest-v2.md
Normal file
61
docs/web/engine-manifest-v2.md
Normal 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 pages(2 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 篡改响应。
|
||||
69
docs/web/operations-diagnostics.json
Normal file
69
docs/web/operations-diagnostics.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
54
tests/golden/M6-04A/engine-manifest-v2.json
Normal file
54
tests/golden/M6-04A/engine-manifest-v2.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
70
tools/web/archive-deployment-server.mjs
Normal file
70
tools/web/archive-deployment-server.mjs
Normal 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`);
|
||||
|
||||
139
tools/web/check-binary-archive.mjs
Normal file
139
tools/web/check-binary-archive.mjs
Normal 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 });
|
||||
}
|
||||
26
tools/web/check-ci-failure-report.mjs
Normal file
26
tools/web/check-ci-failure-report.mjs
Normal 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 });
|
||||
}
|
||||
|
||||
92
tools/web/check-ci-report.mjs
Normal file
92
tools/web/check-ci-report.mjs
Normal 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`);
|
||||
161
tools/web/check-deployment-cache.mjs
Normal file
161
tools/web/check-deployment-cache.mjs
Normal 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 });
|
||||
}
|
||||
@@ -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/);
|
||||
|
||||
184
tools/web/check-deployment-http.mjs
Normal file
184
tools/web/check-deployment-http.mjs
Normal 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 });
|
||||
}
|
||||
164
tools/web/check-deployment-runbook.mjs
Normal file
164
tools/web/check-deployment-runbook.mjs
Normal 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 });
|
||||
}
|
||||
109
tools/web/check-engine-variant-install.mjs
Normal file
109
tools/web/check-engine-variant-install.mjs
Normal 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 });
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
58
tools/web/check-operations-diagnostics.mjs
Normal file
58
tools/web/check-operations-diagnostics.mjs
Normal 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`);
|
||||
50
tools/web/check-rc-known-limitations.mjs
Normal file
50
tools/web/check-rc-known-limitations.mjs
Normal 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`);
|
||||
75
tools/web/check-rc-manifest.mjs
Normal file
75
tools/web/check-rc-manifest.mjs
Normal 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`);
|
||||
39
tools/web/check-rc-release-notes.mjs
Normal file
39
tools/web/check-rc-release-notes.mjs
Normal 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`);
|
||||
39
tools/web/check-rc-release-recovery.mjs
Normal file
39
tools/web/check-rc-release-recovery.mjs
Normal 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");
|
||||
136
tools/web/check-rollback-runbook.mjs
Normal file
136
tools/web/check-rollback-runbook.mjs
Normal 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 });
|
||||
}
|
||||
165
tools/web/check-source-archive.mjs
Normal file
165
tools/web/check-source-archive.mjs
Normal 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 });
|
||||
}
|
||||
|
||||
117
tools/web/check-upgrade-runbook.mjs
Normal file
117
tools/web/check-upgrade-runbook.mjs
Normal 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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`);
|
||||
|
||||
98
tools/web/create-rc-manifest.mjs
Normal file
98
tools/web/create-rc-manifest.mjs
Normal 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`);
|
||||
166
tools/web/deployment-http-server.mjs
Normal file
166
tools/web/deployment-http-server.mjs
Normal 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`);
|
||||
}
|
||||
@@ -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
186
tools/web/run-ci-lane.mjs
Normal 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;
|
||||
191
tools/web/run-operations-rehearsal.mjs
Normal file
191
tools/web/run-operations-rehearsal.mjs
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
19
web/app/public/vendor/blender/pthread/web_engine.js
vendored
Normal file
19
web/app/public/vendor/blender/pthread/web_engine.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/pthread/web_engine.wasm
vendored
Normal file
BIN
web/app/public/vendor/blender/pthread/web_engine.wasm
vendored
Normal file
Binary file not shown.
16
web/app/public/vendor/blender/single/web_engine.js
vendored
Normal file
16
web/app/public/vendor/blender/single/web_engine.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/app/public/vendor/blender/single/web_engine.wasm
vendored
Normal file
BIN
web/app/public/vendor/blender/single/web_engine.wasm
vendored
Normal file
Binary file not shown.
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
139
web/app/src/engine-client/browser-engine-variant-session.ts
Normal file
139
web/app/src/engine-client/browser-engine-variant-session.ts
Normal 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);
|
||||
}
|
||||
195
web/app/src/engine-client/engine-variant-bootstrap.ts
Normal file
195
web/app/src/engine-client/engine-variant-bootstrap.ts
Normal 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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
4
web/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
42
web/playwright.archive.config.ts
Normal file
42
web/playwright.archive.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
web/playwright.single-thread.config.ts
Normal file
39
web/playwright.single-thread.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
53
web/protocol/dirty-state.ts
Normal file
53
web/protocol/dirty-state.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
96
web/protocol/engine-variant.ts
Normal file
96
web/protocol/engine-variant.ts
Normal 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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
132
web/protocol/file-byte-reader.ts
Normal file
132
web/protocol/file-byte-reader.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
79
web/protocol/project-action-mutex.ts
Normal file
79
web/protocol/project-action-mutex.ts
Normal 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 };
|
||||
}
|
||||
81
web/protocol/save-transaction.ts
Normal file
81
web/protocol/save-transaction.ts
Normal 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 } };
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
109
web/protocol/user-action-state.ts
Normal file
109
web/protocol/user-action-state.ts
Normal 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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
147
web/tests/e2e/archive-offline.spec.ts
Normal file
147
web/tests/e2e/archive-offline.spec.ts
Normal 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([]);
|
||||
});
|
||||
|
||||
69
web/tests/e2e/dirty-state.spec.ts
Normal file
69
web/tests/e2e/dirty-state.spec.ts
Normal 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 ?? "");
|
||||
});
|
||||
92
web/tests/e2e/engine-upgrade-safety.spec.ts
Normal file
92
web/tests/e2e/engine-upgrade-safety.spec.ts
Normal 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);
|
||||
});
|
||||
111
web/tests/e2e/file-import-progress.spec.ts
Normal file
111
web/tests/e2e/file-import-progress.spec.ts
Normal 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;
|
||||
});
|
||||
95
web/tests/e2e/project-action-mutex.spec.ts
Normal file
95
web/tests/e2e/project-action-mutex.spec.ts
Normal 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();
|
||||
});
|
||||
78
web/tests/e2e/pthread-engine-isolated.spec.ts
Normal file
78
web/tests/e2e/pthread-engine-isolated.spec.ts
Normal 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);
|
||||
});
|
||||
58
web/tests/e2e/pthread-fallback.spec.ts
Normal file
58
web/tests/e2e/pthread-fallback.spec.ts
Normal 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);
|
||||
});
|
||||
62
web/tests/e2e/save-interruption.spec.ts
Normal file
62
web/tests/e2e/save-interruption.spec.ts
Normal 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);
|
||||
});
|
||||
110
web/tests/e2e/single-thread-unisolated.spec.ts
Normal file
110
web/tests/e2e/single-thread-unisolated.spec.ts
Normal 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");
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
69
web/tests/e2e/user-action-state.spec.ts
Normal file
69
web/tests/e2e/user-action-state.spec.ts
Normal 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");
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
55
web/tests/unit/dirty-state.test.mjs
Normal file
55
web/tests/unit/dirty-state.test.mjs
Normal 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 });
|
||||
});
|
||||
97
web/tests/unit/engine-manifest-v2.test.mjs
Normal file
97
web/tests/unit/engine-manifest-v2.test.mjs
Normal 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);
|
||||
});
|
||||
249
web/tests/unit/engine-variant-fallback.test.mjs
Normal file
249
web/tests/unit/engine-variant-fallback.test.mjs
Normal 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, []);
|
||||
});
|
||||
125
web/tests/unit/engine-variant-selection.test.mjs
Normal file
125
web/tests/unit/engine-variant-selection.test.mjs
Normal 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,
|
||||
});
|
||||
});
|
||||
84
web/tests/unit/file-byte-reader.test.mjs
Normal file
84
web/tests/unit/file-byte-reader.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
68
web/tests/unit/project-action-mutex.test.mjs
Normal file
68
web/tests/unit/project-action-mutex.test.mjs
Normal 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
Reference in New Issue
Block a user